vibetunnel/mac/VibeTunnel/Utilities/CLIInstaller.swift
Peter Steinberger 42021bb514 Fix inconsistent button state management
- Remove all uses of deprecated highlight() method in CustomMenuWindow
- Consistently use state property for NSStatusBarButton management
- Update StatusBarMenuManager to reset button state when menu state is .none
- Fix concurrency issues in CustomMenuWindow frame observer
- Ensure button state is properly managed throughout menu lifecycle

This fixes the issue where the button could display inconsistent visual states
or get stuck due to conflicting approaches between highlight() and state.
2025-07-02 00:00:53 +01:00

261 lines
9.5 KiB
Swift

import AppKit
import Foundation
import Observation
import os.log
import SwiftUI
/// Service responsible for creating symlinks to command line tools with sudo authentication.
///
/// ## Overview
/// This service creates symlinks from the application bundle's resources to system locations like /usr/local/bin
/// to enable command line access to bundled tools. It handles sudo authentication through system dialogs.
///
/// ## Usage
/// ```swift
/// let installer = CLIInstaller()
/// installer.installCLITool()
/// ```
///
/// ## Safety Considerations
/// - Always prompts user before performing operations requiring sudo
/// - Provides clear error messages and graceful failure handling
/// - Checks for existing symlinks and handles conflicts appropriately
/// - Logs all operations for debugging purposes
@MainActor
@Observable
final class CLIInstaller {
// MARK: - Properties
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "CLIInstaller")
private let binDirectory: String
private var vtTargetPath: String {
URL(fileURLWithPath: binDirectory).appendingPathComponent("vt").path
}
var isInstalled = false
var isInstalling = false
var lastError: String?
// MARK: - Initialization
/// Creates a CLI installer
/// - Parameters:
/// - binDirectory: Directory for installation (defaults to /usr/local/bin)
init(binDirectory: String = "/usr/local/bin") {
self.binDirectory = binDirectory
}
// MARK: - Public Interface
/// Checks if the CLI tool is installed
func checkInstallationStatus() {
Task { @MainActor in
// Check if vt script exists and is configured correctly
var isCorrectlyInstalled = false
// Check both /usr/local/bin and Apple Silicon Homebrew path
let pathsToCheck = [
vtTargetPath,
"/opt/homebrew/bin/vt"
]
for path in pathsToCheck {
if FileManager.default.fileExists(atPath: path) {
// Check if it contains the correct app path reference
if let content = try? String(contentsOfFile: path, encoding: .utf8) {
// Verify it's our wrapper script with all expected components
if content.contains("VibeTunnel CLI wrapper") &&
content.contains("$TRY_PATH/Contents/Resources/vibetunnel") &&
content.contains("exec \"$VIBETUNNEL_BIN\" fwd")
{
isCorrectlyInstalled = true
logger.info("CLIInstaller: Found valid vt script at \(path)")
break
}
}
}
}
// Update state
isInstalled = isCorrectlyInstalled
logger.info("CLIInstaller: vt script installed: \(self.isInstalled)")
}
}
/// Installs the CLI tool (async version for WelcomeView)
func install() async {
await MainActor.run {
installCLITool()
}
}
/// Installs the vt CLI tool to /usr/local/bin
func installCLITool() {
logger.info("CLIInstaller: Starting CLI tool installation...")
isInstalling = true
lastError = nil
// Verify that vt script exists in the app bundle
guard Bundle.main.path(forResource: "vt", ofType: nil) != nil else {
logger.error("CLIInstaller: Could not find vt script in app bundle")
lastError = "The vt script could not be found in the application bundle."
showError("The vt script could not be found in the application bundle.")
isInstalling = false
return
}
// Show confirmation dialog
let confirmAlert = NSAlert()
confirmAlert.messageText = "Install VT Command Line Tool"
confirmAlert
.informativeText =
"This will install the 'vt' command that runs VibeTunnel from your Applications folder. Administrator privileges are required."
confirmAlert.addButton(withTitle: "Install")
confirmAlert.addButton(withTitle: "Cancel")
confirmAlert.alertStyle = .informational
confirmAlert.icon = NSApp.applicationIconImage
let response = confirmAlert.runModal()
if response != .alertFirstButtonReturn {
logger.info("CLIInstaller: User cancelled installation")
isInstalling = false
return
}
// Perform the installation
performInstallation()
}
// MARK: - Private Implementation
/// Performs the actual installation with sudo privileges
private func performInstallation() {
logger.info("CLIInstaller: Installing vt script")
guard let vtScriptPath = Bundle.main.path(forResource: "vt", ofType: nil) else {
logger.error("CLIInstaller: Could not find vt script in app bundle")
lastError = "The vt script could not be found in the application bundle."
showError("The vt script could not be found in the application bundle.")
isInstalling = false
return
}
// Create the installation script
let script = """
#!/bin/bash
set -e
# Create /usr/local/bin if it doesn't exist
if [ ! -d "\(binDirectory)" ]; then
mkdir -p "\(binDirectory)"
echo "Created directory \(binDirectory)"
fi
# Remove existing vt if it exists
if [ -L "\(vtTargetPath)" ] || [ -f "\(vtTargetPath)" ]; then
rm -f "\(vtTargetPath)"
echo "Removed existing file at \(vtTargetPath)"
fi
# Copy vt script from app bundle
cp "\(vtScriptPath)" "\(vtTargetPath)"
chmod +x "\(vtTargetPath)"
echo "Installed vt script at \(vtTargetPath)"
# Clean up old vibetunnel binary if it exists
if [ -f "/usr/local/bin/vibetunnel" ]; then
rm -f "/usr/local/bin/vibetunnel"
echo "Removed old vibetunnel binary"
fi
"""
// Write the script to a temporary file
let tempDir = FileManager.default.temporaryDirectory
let scriptURL = tempDir.appendingPathComponent("install_vt_cli.sh")
do {
try script.write(to: scriptURL, atomically: true, encoding: .utf8)
// Make the script executable
let attributes: [FileAttributeKey: Any] = [.posixPermissions: 0o755]
try FileManager.default.setAttributes(attributes, ofItemAtPath: scriptURL.path)
logger.info("CLIInstaller: Created installation script at \(scriptURL.path)")
// Execute with osascript to get sudo dialog
let appleScript = """
do shell script "bash '\(scriptURL.path)'" with administrator privileges
"""
let task = Process()
task.launchPath = "/usr/bin/osascript"
task.arguments = ["-e", appleScript]
let pipe = Pipe()
let errorPipe = Pipe()
task.standardOutput = pipe
task.standardError = errorPipe
try task.run()
task.waitUntilExit()
// Clean up the temporary script
try? FileManager.default.removeItem(at: scriptURL)
if task.terminationStatus == 0 {
logger.info("CLIInstaller: Installation completed successfully")
isInstalled = true
isInstalling = false
showSuccess()
// Refresh installation status
checkInstallationStatus()
} else {
let errorString: String
do {
if let errorData = try errorPipe.fileHandleForReading.readToEnd() {
errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error"
} else {
errorString = "Unknown error"
}
} catch {
logger.debug("Could not read error output: \(error.localizedDescription)")
errorString = "Unknown error (could not read stderr)"
}
logger.error("CLIInstaller: Installation failed with status \(task.terminationStatus): \(errorString)")
lastError = "Installation failed: \(errorString)"
isInstalling = false
showError("Installation failed: \(errorString)")
}
} catch {
logger.error("CLIInstaller: Installation failed with error: \(error)")
lastError = "Installation failed: \(error.localizedDescription)"
isInstalling = false
showError("Installation failed: \(error.localizedDescription)")
}
}
/// Shows success message after installation
private func showSuccess() {
let alert = NSAlert()
alert.messageText = "CLI Tools Installed Successfully"
alert
.informativeText =
"The 'vt' command has been installed. You can now use 'vt' from the terminal to run VibeTunnel."
alert.addButton(withTitle: "OK")
alert.alertStyle = .informational
alert.icon = NSApp.applicationIconImage
alert.runModal()
}
/// Shows error message for installation failures
private func showError(_ message: String) {
let alert = NSAlert()
alert.messageText = "CLI Tool Installation Failed"
alert.informativeText = message
alert.addButton(withTitle: "OK")
alert.alertStyle = .critical
alert.runModal()
}
}