mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
- 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.
261 lines
9.5 KiB
Swift
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()
|
|
}
|
|
}
|