show welcome dialog on update + lint

This commit is contained in:
Peter Steinberger 2025-06-18 12:55:20 +02:00
parent d9d134ff2b
commit daf455ec9a
17 changed files with 445 additions and 369 deletions

View file

@ -0,0 +1,13 @@
import Foundation
/// Central location for app-wide constants and configuration values
enum AppConstants {
/// Current version of the welcome dialog
/// Increment this when significant changes require re-showing the welcome flow
static let currentWelcomeVersion = 2
/// UserDefaults keys
enum UserDefaultsKeys {
static let welcomeVersion = "welcomeVersion"
}
}

View file

@ -1,6 +1,6 @@
import Foundation
import AppKit
import ApplicationServices
import Foundation
import OSLog
/// Manages Accessibility permissions required for sending keystrokes.
@ -39,7 +39,9 @@ final class AccessibilityPermissionManager {
logger.info("Accessibility permission dialog triggered")
// After a short delay, also open System Settings as a fallback
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
if let url =
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
{
NSWorkspace.shared.open(url)
}
}

View file

@ -1,5 +1,5 @@
import Foundation
@preconcurrency import AppKit
import Foundation
import OSLog
/// Sendable wrapper for NSAppleEventDescriptor
@ -49,7 +49,7 @@ final class AppleScriptExecutor {
let result = scriptObject.executeAndReturnError(&error)
if let error = error {
if let error {
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
@ -69,7 +69,7 @@ final class AppleScriptExecutor {
DispatchQueue.main.sync {
do {
result = .success(try execute(script, timeout: timeout))
result = try .success(execute(script, timeout: timeout))
} catch {
result = .failure(error)
}
@ -147,7 +147,7 @@ final class AppleScriptExecutor {
let result = scriptObject.executeAndReturnError(&error)
if let error = error {
if let error {
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
@ -225,17 +225,17 @@ enum AppleScriptError: LocalizedError {
var errorDescription: String? {
switch self {
case .scriptCreationFailed:
return "Failed to create AppleScript object"
"Failed to create AppleScript object"
case .executionFailed(let message, let errorCode):
if let code = errorCode {
return "AppleScript error \(code): \(message)"
"AppleScript error \(code): \(message)"
} else {
return "AppleScript error: \(message)"
"AppleScript error: \(message)"
}
case .permissionDenied:
return "AppleScript permission denied. Please grant permission in System Settings."
"AppleScript permission denied. Please grant permission in System Settings."
case .timeout:
return "AppleScript execution timed out"
"AppleScript execution timed out"
}
}
@ -246,13 +246,13 @@ enum AppleScriptError: LocalizedError {
case .executionFailed(_, let errorCode):
if let code = errorCode {
switch code {
case -1743:
case -1_743:
return "User permission is required to control other applications."
case -1728:
case -1_728:
return "The application is not running or cannot be controlled."
case -1708:
case -1_708:
return "The event was not handled by the target application."
case -2741:
case -2_741:
return "AppleScript syntax error - check for unescaped quotes or invalid identifiers."
default:
return nil
@ -268,11 +268,11 @@ enum AppleScriptError: LocalizedError {
var isPermissionError: Bool {
switch self {
case .permissionDenied:
return true
true
case .executionFailed(_, let errorCode):
return errorCode == -1743
errorCode == -1_743
default:
return false
false
}
}

View file

@ -1,5 +1,5 @@
import Foundation
import AppKit
import Foundation
import OSLog
/// Manages AppleScript automation permissions for VibeTunnel.

View file

@ -20,30 +20,33 @@ final class DashboardKeychain {
/// Get the dashboard password from keychain
func getPassword() -> String? {
#if DEBUG
// In debug builds, skip keychain access to avoid authorization dialogs
logger.info("Debug mode: Skipping keychain password retrieval. Password will only persist during current app session.")
return nil
#else
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let password = String(data: data, encoding: .utf8)
else {
logger.debug("No password found in keychain")
// In debug builds, skip keychain access to avoid authorization dialogs
logger
.info(
"Debug mode: Skipping keychain password retrieval. Password will only persist during current app session."
)
return nil
}
#else
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true
]
logger.debug("Password retrieved from keychain")
return password
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let password = String(data: data, encoding: .utf8)
else {
logger.debug("No password found in keychain")
return nil
}
logger.debug("Password retrieved from keychain")
return password
#endif
}
@ -100,9 +103,12 @@ final class DashboardKeychain {
logger.info("Password \(success ? "saved to" : "failed to save to") keychain")
#if DEBUG
if success {
logger.info("Debug mode: Password saved to keychain but will not persist across app restarts. The password will only be available during this session to avoid keychain authorization dialogs during development.")
}
if success {
logger
.info(
"Debug mode: Password saved to keychain but will not persist across app restarts. The password will only be available during this session to avoid keychain authorization dialogs during development."
)
}
#endif
return success

View file

@ -131,7 +131,7 @@ final class TerminalSpawnService: @unchecked Sendable {
defer { close(clientSocket) }
// Read request data
var buffer = [UInt8](repeating: 0, count: 65536)
var buffer = [UInt8](repeating: 0, count: 65_536)
let bytesRead = recv(clientSocket, &buffer, buffer.count, 0)
guard bytesRead > 0 else {
@ -148,11 +148,11 @@ final class TerminalSpawnService: @unchecked Sendable {
private func handleRequestSync(_ data: Data) -> Data {
struct SpawnRequest: Codable {
let ttyFwdPath: String? // Optional: if provided, use this path instead of bundled one
let ttyFwdPath: String? // Optional: if provided, use this path instead of bundled one
let workingDir: String
let sessionId: String
let command: String // Already properly formatted command (not array)
let terminal: String? // Optional: preferred terminal (e.g. "ghostty", "terminal")
let command: String // Already properly formatted command (not array)
let terminal: String? // Optional: preferred terminal (e.g. "ghostty", "terminal")
}
struct SpawnResponse: Codable {

View file

@ -716,7 +716,7 @@ public final class TunnelServer {
let command: [String]
let workingDir: String?
let term: String?
let spawn_terminal: Bool?
let spawnTerminal: Bool?
}
let sessionRequest = try JSONDecoder().decode(CreateSessionRequest.self, from: requestData)
@ -726,11 +726,14 @@ public final class TunnelServer {
}
// Handle terminal spawning if requested
if sessionRequest.spawn_terminal ?? false {
if sessionRequest.spawnTerminal ?? false {
logger.info("Spawn terminal requested for command: \(sessionRequest.command.joined(separator: " "))")
let sessionId = UUID().uuidString
let workingDir = resolvePath(sessionRequest.workingDir ?? "~/", fallback: FileManager.default.homeDirectoryForCurrentUser.path)
let workingDir = resolvePath(
sessionRequest.workingDir ?? "~/",
fallback: FileManager.default.homeDirectoryForCurrentUser.path
)
_ = sessionRequest.command.joined(separator: " ")
// Connect to the terminal spawn service via Unix socket
@ -739,7 +742,10 @@ public final class TunnelServer {
guard socketFd >= 0 else {
logger.error("Failed to create socket")
return errorResponse(message: "Failed to create socket for terminal spawning", status: .internalServerError)
return errorResponse(
message: "Failed to create socket for terminal spawning",
status: .internalServerError
)
}
defer { close(socketFd) }
@ -778,18 +784,31 @@ public final class TunnelServer {
do {
let requestData = try JSONEncoder().encode(spawnRequest)
let sendResult = send(socketFd, requestData.withUnsafeBytes { $0.baseAddress }, requestData.count, 0)
let sendResult = send(
socketFd,
requestData.withUnsafeBytes { $0.baseAddress },
requestData.count,
0
)
guard sendResult == requestData.count else {
throw NSError(domain: "TerminalSpawn", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to send complete request"])
throw NSError(
domain: "TerminalSpawn",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to send complete request"]
)
}
// Read the response
var responseBuffer = [UInt8](repeating: 0, count: 4096)
var responseBuffer = [UInt8](repeating: 0, count: 4_096)
let bytesRead = recv(socketFd, &responseBuffer, responseBuffer.count, 0)
guard bytesRead > 0 else {
throw NSError(domain: "TerminalSpawn", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to read response"])
throw NSError(
domain: "TerminalSpawn",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Failed to read response"]
)
}
let responseData = Data(bytes: responseBuffer, count: bytesRead)
@ -827,11 +846,17 @@ public final class TunnelServer {
} else {
let errorMsg = spawnResponse.error ?? "Unknown error"
logger.error("Failed to spawn terminal: \(errorMsg)")
return errorResponse(message: "Failed to spawn terminal: \(errorMsg)", status: .internalServerError)
return errorResponse(
message: "Failed to spawn terminal: \(errorMsg)",
status: .internalServerError
)
}
} catch {
logger.error("Failed to communicate with terminal spawn service: \(error)")
return errorResponse(message: "Failed to spawn terminal: \(error.localizedDescription)", status: .internalServerError)
return errorResponse(
message: "Failed to spawn terminal: \(error.localizedDescription)",
status: .internalServerError
)
}
}

View file

@ -171,28 +171,35 @@ private struct TerminalPreferenceSection: View {
switch terminalError {
case .appleScriptPermissionDenied:
errorTitle = "Permission Denied"
errorMessage = "VibeTunnel needs permission to control terminal applications.\n\nPlease grant Automation permission in System Settings > Privacy & Security > Automation."
errorMessage =
"VibeTunnel needs permission to control terminal applications.\n\nPlease grant Automation permission in System Settings > Privacy & Security > Automation."
case .accessibilityPermissionDenied:
errorTitle = "Accessibility Permission Required"
errorMessage = "VibeTunnel needs Accessibility permission to send keystrokes to \(Terminal(rawValue: preferredTerminal)?.displayName ?? "terminal").\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."
errorMessage =
"VibeTunnel needs Accessibility permission to send keystrokes to \(Terminal(rawValue: preferredTerminal)?.displayName ?? "terminal").\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."
case .terminalNotFound:
errorTitle = "Terminal Not Found"
errorMessage = "The selected terminal application could not be found. Please select a different terminal."
errorMessage =
"The selected terminal application could not be found. Please select a different terminal."
case .appleScriptExecutionFailed(let details, let errorCode):
if let code = errorCode {
switch code {
case -1_743:
errorTitle = "Permission Denied"
errorMessage = "VibeTunnel needs permission to control terminal applications.\n\nPlease grant Automation permission in System Settings > Privacy & Security > Automation."
errorMessage =
"VibeTunnel needs permission to control terminal applications.\n\nPlease grant Automation permission in System Settings > Privacy & Security > Automation."
case -1_728:
errorTitle = "Terminal Not Available"
errorMessage = "The terminal application is not running or cannot be controlled.\n\nDetails: \(details)"
errorMessage =
"The terminal application is not running or cannot be controlled.\n\nDetails: \(details)"
case -1_708:
errorTitle = "Terminal Communication Error"
errorMessage = "The terminal did not respond to the command.\n\nDetails: \(details)"
case -25211:
errorMessage =
"The terminal did not respond to the command.\n\nDetails: \(details)"
case -25_211:
errorTitle = "Accessibility Permission Required"
errorMessage = "System Events requires Accessibility permission to send keystrokes.\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."
errorMessage =
"System Events requires Accessibility permission to send keystrokes.\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."
default:
errorTitle = "Terminal Launch Failed"
errorMessage = "AppleScript error \(code): \(details)"
@ -232,10 +239,12 @@ private struct TerminalPreferenceSection: View {
.multilineTextAlignment(.center)
}
.alert(errorTitle, isPresented: $showingError) {
Button("OK") { }
Button("OK") {}
if errorTitle == "Permission Denied" {
Button("Open System Settings") {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") {
if let url =
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation")
{
NSWorkspace.shared.open(url)
}
}

View file

@ -822,4 +822,3 @@ private struct NgrokErrorView: View {
}
}
}

View file

@ -369,10 +369,12 @@ private struct ServerSection: View {
Text("HTTP Server")
.font(.headline)
} footer: {
Text("The HTTP server provides REST API endpoints for terminal session management. Choose between the built-in Swift Hummingbird server or the Rust tty-fwd binary.")
.font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
Text(
"The HTTP server provides REST API endpoints for terminal session management. Choose between the built-in Swift Hummingbird server or the Rust tty-fwd binary."
)
.font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
}
}
}
@ -609,4 +611,3 @@ private struct DeveloperToolsSection: View {
}
}
}

View file

@ -1,5 +1,5 @@
import SwiftUI
import Combine
import SwiftUI
/// General settings tab for basic app preferences
struct GeneralSettingsView: View {
@ -210,10 +210,12 @@ private struct PermissionsSection: View {
Text("Permissions")
.font(.headline)
} footer: {
Text("Terminal automation is required for all terminals. Accessibility is only needed for terminals that simulate keyboard input.")
.font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
Text(
"Terminal automation is required for all terminals. Accessibility is only needed for terminals that simulate keyboard input."
)
.font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
}
.task {
_ = await appleScriptManager.checkPermission()

View file

@ -10,8 +10,8 @@ struct WelcomeView: View {
@State private var currentPage = 0
@Environment(\.dismiss)
private var dismiss
@AppStorage("hasSeenWelcome")
private var hasSeenWelcome = false
@AppStorage(AppConstants.UserDefaultsKeys.welcomeVersion)
private var welcomeVersion = 0
@State private var cliInstaller = CLIInstaller()
@StateObject private var permissionManager = AppleScriptPermissionManager.shared
@ -107,7 +107,7 @@ struct WelcomeView: View {
}
} else {
// Finish action - open Settings
hasSeenWelcome = true
welcomeVersion = AppConstants.currentWelcomeVersion
dismiss()
SettingsOpener.openSettings()
}

View file

@ -1,8 +1,8 @@
import AppKit
import Foundation
import Observation
import SwiftUI
import os.log
import SwiftUI
/// Service responsible for creating symlinks to command line tools with sudo authentication.
///

View file

@ -88,7 +88,9 @@ enum SettingsOpener {
guard let window = notification.object as? NSWindow else { return }
Task { @MainActor in
guard window.title.contains("Settings") || window.identifier?.rawValue.contains(settingsWindowIdentifier) == true else {
guard window.title.contains("Settings") || window.identifier?.rawValue
.contains(settingsWindowIdentifier) == true
else {
return
}
@ -108,7 +110,7 @@ enum SettingsOpener {
/// Finds the settings window using multiple detection methods
static func findSettingsWindow() -> NSWindow? {
// Try multiple methods to find the window
return NSApp.windows.first { window in
NSApp.windows.first { window in
// Check by identifier
if window.identifier?.rawValue == settingsWindowIdentifier {
return true
@ -116,14 +118,17 @@ enum SettingsOpener {
// Check by title
if window.isVisible && window.styleMask.contains(.titled) &&
(window.title.localizedCaseInsensitiveContains("settings") ||
window.title.localizedCaseInsensitiveContains("preferences")) {
(window.title.localizedCaseInsensitiveContains("settings") ||
window.title.localizedCaseInsensitiveContains("preferences")
)
{
return true
}
// Check by content view controller type
if let contentVC = window.contentViewController,
String(describing: type(of: contentVC)).contains("Settings") {
String(describing: type(of: contentVC)).contains("Settings")
{
return true
}
@ -131,8 +136,6 @@ enum SettingsOpener {
}
}
/// Opens the Settings window and navigates to a specific tab
static func openSettingsTab(_ tab: SettingsTab) {
openSettings()
@ -168,4 +171,3 @@ struct HiddenWindowView: View {
extension Notification.Name {
static let openSettingsRequest = Notification.Name("openSettingsRequest")
}

View file

@ -1,7 +1,7 @@
import AppKit
import Foundation
import SwiftUI
import os.log
import SwiftUI
/// Terminal launch configuration
struct TerminalLaunchConfig {
@ -10,7 +10,7 @@ struct TerminalLaunchConfig {
let terminal: Terminal
var fullCommand: String {
guard let workingDirectory = workingDirectory else {
guard let workingDirectory else {
return command
}
let escapedDir = workingDirectory.replacingOccurrences(of: "\"", with: "\\\"")
@ -85,13 +85,13 @@ enum Terminal: String, CaseIterable {
/// Priority for auto-detection (higher is better, based on popularity)
var detectionPriority: Int {
switch self {
case .terminal: return 100 // Highest - macOS default, most popular
case .iTerm2: return 95 // Very popular among developers
case .warp: return 85 // Popular modern terminal
case .ghostty: return 80 // New but gaining popularity
case .alacritty: return 70 // Popular among power users
case .wezterm: return 60 // Less common but powerful
case .hyper: return 50 // Less popular Electron-based
case .terminal: 100 // Highest - macOS default, most popular
case .iTerm2: 95 // Very popular among developers
case .warp: 85 // Popular modern terminal
case .ghostty: 80 // New but gaining popularity
case .alacritty: 70 // Popular among power users
case .wezterm: 60 // Less common but powerful
case .hyper: 50 // Less popular Electron-based
}
}
@ -155,51 +155,51 @@ enum Terminal: String, CaseIterable {
switch self {
case .terminal:
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .iTerm2:
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .ghostty:
// Use unified AppleScript approach
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .alacritty:
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .warp:
// Use unified AppleScript approach
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .hyper:
// Use unified AppleScript approach
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .wezterm:
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
}
}
/// Process name for AppleScript typing
var processName: String {
switch self {
case .terminal: return "Terminal"
case .iTerm2: return "iTerm"
case .ghostty: return "Ghostty"
case .warp: return "Warp"
case .alacritty: return "Alacritty"
case .hyper: return "Hyper"
case .wezterm: return "WezTerm"
case .terminal: "Terminal"
case .iTerm2: "iTerm"
case .ghostty: "Ghostty"
case .warp: "Warp"
case .alacritty: "Alacritty"
case .hyper: "Hyper"
case .wezterm: "WezTerm"
}
}
/// Whether this terminal requires keystroke-based input (needs Accessibility permission)
var requiresKeystrokeInput: Bool {
// All terminals now use keystroke-based input
return true
true
}
}
@ -217,19 +217,19 @@ enum TerminalLauncherError: LocalizedError {
var errorDescription: String? {
switch self {
case .terminalNotFound:
return "Selected terminal application not found"
"Selected terminal application not found"
case .appleScriptPermissionDenied:
return "AppleScript permission denied. Please grant permission in System Settings."
"AppleScript permission denied. Please grant permission in System Settings."
case .accessibilityPermissionDenied:
return "Accessibility permission required to send keystrokes. Please grant permission in System Settings."
"Accessibility permission required to send keystrokes. Please grant permission in System Settings."
case .appleScriptExecutionFailed(let message, let errorCode):
if let code = errorCode {
return "AppleScript error \(code): \(message)"
"AppleScript error \(code): \(message)"
} else {
return "AppleScript error: \(message)"
"AppleScript error: \(message)"
}
case .processLaunchFailed(let message):
return "Failed to launch process: \(message)"
"Failed to launch process: \(message)"
}
}
@ -248,7 +248,7 @@ enum TerminalLauncherError: LocalizedError {
return "The application is not running or cannot be controlled."
case -1_708:
return "The event was not handled by the target application."
case -25211:
case -25_211:
return "Accessibility permission is required to send keystrokes."
default:
return nil
@ -281,14 +281,12 @@ final class TerminalLauncher {
logger.info("TerminalLauncher initialized successfully")
}
func launchCommand(_ command: String) throws {
let terminal = getValidTerminal()
let config = TerminalLaunchConfig(command: command, workingDirectory: nil, terminal: terminal)
try launchWithConfig(config)
}
func verifyPreferredTerminal() {
let terminal = Terminal(rawValue: preferredTerminal) ?? .terminal
if !terminal.isInstalled {
@ -313,7 +311,10 @@ final class TerminalLauncher {
let installedTerminals = Terminal.installed.filter { $0 != .terminal }
if let bestTerminal = installedTerminals.max(by: { $0.detectionPriority < $1.detectionPriority }) {
preferredTerminal = bestTerminal.rawValue
logger.info("No running terminals found, set preferred terminal to most popular installed: \(bestTerminal.rawValue)")
logger
.info(
"No running terminals found, set preferred terminal to most popular installed: \(bestTerminal.rawValue)"
)
}
}
}
@ -344,7 +345,10 @@ final class TerminalLauncher {
if actualTerminal != terminal {
// Update preference to fallback
preferredTerminal = actualTerminal.rawValue
logger.warning("Preferred terminal \(terminal.rawValue) not installed, falling back to \(actualTerminal.rawValue)")
logger
.warning(
"Preferred terminal \(terminal.rawValue) not installed, falling back to \(actualTerminal.rawValue)"
)
}
return actualTerminal
@ -414,7 +418,8 @@ final class TerminalLauncher {
process.waitUntilExit()
if process.terminationStatus != 0 {
throw TerminalLauncherError.processLaunchFailed("Process exited with status \(process.terminationStatus)")
throw TerminalLauncherError
.processLaunchFailed("Process exited with status \(process.terminationStatus)")
}
} catch {
logger.error("Failed to launch terminal: \(error.localizedDescription)")
@ -436,15 +441,19 @@ final class TerminalLauncher {
} catch let error as AppleScriptError {
// Check if this is a permission error
if case .executionFailed(_, let errorCode) = error,
let code = errorCode {
let code = errorCode
{
switch code {
case -25211, -1719:
case -25_211, -1_719:
// These error codes indicate accessibility permission issues
throw TerminalLauncherError.accessibilityPermissionDenied
case -2741:
case -2_741:
// This is a syntax error: "Expected end of line but found identifier"
// It usually means the AppleScript has unescaped quotes or other syntax issues
throw TerminalLauncherError.appleScriptExecutionFailed("AppleScript syntax error - likely unescaped quotes in command", errorCode: code)
throw TerminalLauncherError.appleScriptExecutionFailed(
"AppleScript syntax error - likely unescaped quotes in command",
errorCode: code
)
default:
break
}
@ -457,11 +466,9 @@ final class TerminalLauncher {
}
}
// MARK: - Terminal Session Launching
func launchTerminalSession(workingDirectory: String, command: String, sessionId: String) throws {
// Find tty-fwd binary path
let ttyFwdPath = findTTYFwdBinary()
@ -473,7 +480,8 @@ final class TerminalLauncher {
// Construct the full command with cd && tty-fwd && exit pattern
// tty-fwd will use TTY_SESSION_ID from environment or generate one
let fullCommand = "cd \"\(escapedWorkingDir)\" && TTY_SESSION_ID=\"\(sessionId)\" \(ttyFwdPath) -- \(command) && exit"
let fullCommand =
"cd \"\(escapedWorkingDir)\" && TTY_SESSION_ID=\"\(sessionId)\" \(ttyFwdPath) -- \(command) && exit"
// Get the preferred terminal or fallback
let terminal = getValidTerminal()
@ -488,7 +496,14 @@ final class TerminalLauncher {
}
/// Optimized terminal session launching that receives pre-formatted command from Rust
func launchOptimizedTerminalSession(workingDirectory: String, command: String, sessionId: String, ttyFwdPath: String? = nil) throws {
func launchOptimizedTerminalSession(
workingDirectory: String,
command: String,
sessionId: String,
ttyFwdPath: String? = nil
)
throws
{
// Expand tilde in working directory path
let expandedWorkingDir = (workingDirectory as NSString).expandingTildeInPath

View file

@ -103,9 +103,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
NSApp.setActivationPolicy(showInDock ? .regular : .accessory)
// Show welcome screen on first launch
let hasSeenWelcome = UserDefaults.standard.bool(forKey: "hasSeenWelcome")
if !hasSeenWelcome && !isRunningInTests && !isRunningInPreview {
// Show welcome screen when version changes
let storedWelcomeVersion = UserDefaults.standard.integer(forKey: AppConstants.UserDefaultsKeys.welcomeVersion)
// Show welcome if version is different from current
if storedWelcomeVersion < AppConstants.currentWelcomeVersion && !isRunningInTests && !isRunningInPreview {
showWelcomeScreen()
}