recover lost commits

This commit is contained in:
Peter Steinberger 2025-06-18 16:49:39 +02:00
parent 915d3e3eb1
commit 5804790ae1
17 changed files with 869 additions and 530 deletions

View file

@ -75,11 +75,13 @@ final class AppleScriptExecutor {
}
}
switch result! {
switch result {
case .success(let value):
return value
case .failure(let error):
throw error
case .none:
throw AppleScriptError.executionFailed(message: "Script execution result was nil", errorCode: nil)
}
}
}

View file

@ -85,7 +85,8 @@ class ServerManager {
)
}
@objc private func userDefaultsDidChange() {
@objc
private func userDefaultsDidChange() {
Task { @MainActor in
await handleServerModeChange()
}

View file

@ -200,7 +200,6 @@ final class TerminalSpawnService: @unchecked Sendable {
let response = SpawnResponse(success: true, error: nil, sessionId: request.sessionId)
return try JSONEncoder().encode(response)
} catch {
logger.error("Failed to handle spawn request: \(error)")
let response = SpawnResponse(success: false, error: error.localizedDescription, sessionId: nil)

View file

@ -104,22 +104,25 @@ struct MenuBarView: View {
Divider()
// About
Button(action: {
SettingsOpener.openSettings()
// Navigate to About tab after settings opens
Task {
try? await Task.sleep(for: .milliseconds(100))
NotificationCenter.default.post(
name: .openSettingsTab,
object: SettingsTab.about
)
Button(
action: {
SettingsOpener.openSettings()
// Navigate to About tab after settings opens
Task {
try? await Task.sleep(for: .milliseconds(100))
NotificationCenter.default.post(
name: .openSettingsTab,
object: SettingsTab.about
)
}
},
label: {
HStack {
Image(systemName: "info.circle")
Text("About VibeTunnel")
}
}
}) {
HStack {
Image(systemName: "info.circle")
Text("About VibeTunnel")
}
}
)
} label: {
Label("Help", systemImage: "questionmark.circle")
}
@ -135,11 +138,14 @@ struct MenuBarView: View {
)
// Settings button
Button(action: {
SettingsOpener.openSettings()
}) {
Label("Settings…", systemImage: "gear")
}
Button(
action: {
SettingsOpener.openSettings()
},
label: {
Label("Settings…", systemImage: "gear")
}
)
.buttonStyle(MenuButtonStyle())
.keyboardShortcut(",", modifiers: .command)

View file

@ -1,4 +1,11 @@
import SwiftUI
import OSLog
// MARK: - Logger
private extension Logger {
static let advanced = Logger(subsystem: "com.vibetunnel.VibeTunnel", category: "AdvancedSettings")
}
/// Advanced settings tab for power user options
struct AdvancedSettingsView: View {
@ -13,6 +20,9 @@ struct AdvancedSettingsView: View {
var body: some View {
NavigationStack {
Form {
// Terminal preference section
TerminalPreferenceSection()
// Integration section
Section {
VStack(alignment: .leading, spacing: 8) {
@ -61,9 +71,6 @@ struct AdvancedSettingsView: View {
.font(.headline)
}
// Terminal preference section
TerminalPreferenceSection()
// Advanced section
Section {
VStack(alignment: .leading, spacing: 4) {
@ -122,7 +129,8 @@ struct AdvancedSettingsView: View {
// MARK: - Terminal Preference Section
private struct TerminalPreferenceSection: View {
@AppStorage("preferredTerminal") private var preferredTerminal = Terminal.terminal.rawValue
@AppStorage("preferredTerminal")
private var preferredTerminal = Terminal.terminal.rawValue
@State private var terminalLauncher = TerminalLauncher.shared
@State private var showingError = false
@State private var errorMessage = ""
@ -164,7 +172,7 @@ private struct TerminalPreferenceSection: View {
try terminalLauncher.launchCommand("echo 'VibeTunnel Terminal Test: Success!'")
} catch {
// Log the error
print("Failed to launch terminal test: \(error)")
Logger.advanced.error("Failed to launch terminal test: \(error)")
// Set up alert content based on error type
if let terminalError = error as? TerminalLauncherError {

View file

@ -177,6 +177,9 @@ struct DashboardSettingsView: View {
await ServerManager.shared.restart()
logger.info("Server restarted on port \(port)")
// Wait for server to be fully ready before restarting session monitor
try? await Task.sleep(for: .seconds(1))
// Restart session monitoring with new port
SessionMonitor.shared.stopMonitoring()
SessionMonitor.shared.startMonitoring()
@ -190,6 +193,9 @@ struct DashboardSettingsView: View {
await ServerManager.shared.restart()
logger.info("Server restarted with bind address \(accessMode.bindAddress)")
// Wait for server to be fully ready before restarting session monitor
try? await Task.sleep(for: .seconds(1))
// Restart session monitoring
SessionMonitor.shared.stopMonitoring()
SessionMonitor.shared.startMonitoring()
@ -490,29 +496,35 @@ private struct AccessModeView: View {
if let ipAddress = localIPAddress {
Spacer()
Button(action: {
let urlString = "http://\(ipAddress):\(serverPort)"
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
Button(
action: {
let urlString = "http://\(ipAddress):\(serverPort)"
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
},
label: {
Text("http://\(ipAddress):\(serverPort)")
.font(.caption)
.foregroundStyle(.blue)
.underline()
}
}) {
Text("http://\(ipAddress):\(serverPort)")
.font(.caption)
.foregroundStyle(.blue)
.underline()
}
)
.buttonStyle(.plain)
.pointingHandCursor()
Button(action: {
let urlString = "http://\(ipAddress):\(serverPort)"
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(urlString, forType: .string)
}) {
Image(systemName: "doc.on.doc")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(
action: {
let urlString = "http://\(ipAddress):\(serverPort)"
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(urlString, forType: .string)
},
label: {
Image(systemName: "doc.on.doc")
.font(.caption)
.foregroundStyle(.secondary)
}
)
.buttonStyle(.plain)
.help("Copy URL")
} else {
@ -554,31 +566,37 @@ private struct PortConfigurationView: View {
}
VStack(spacing: 0) {
Button(action: {
if portNumber < 65_535 {
portNumber += 1
serverPort = String(portNumber)
restartServerWithNewPort(portNumber)
Button(
action: {
if portNumber < 65_535 {
portNumber += 1
serverPort = String(portNumber)
restartServerWithNewPort(portNumber)
}
},
label: {
Image(systemName: "chevron.up")
.font(.system(size: 10))
.frame(width: 16, height: 12)
}
}) {
Image(systemName: "chevron.up")
.font(.system(size: 10))
.frame(width: 16, height: 12)
}
)
.buttonStyle(.plain)
.help("Increase port number")
Button(action: {
if portNumber > 1 {
portNumber -= 1
serverPort = String(portNumber)
restartServerWithNewPort(portNumber)
Button(
action: {
if portNumber > 1 {
portNumber -= 1
serverPort = String(portNumber)
restartServerWithNewPort(portNumber)
}
},
label: {
Image(systemName: "chevron.down")
.font(.system(size: 10))
.frame(width: 16, height: 12)
}
}) {
Image(systemName: "chevron.down")
.font(.system(size: 10))
.frame(width: 16, height: 12)
}
)
.buttonStyle(.plain)
.help("Decrease port number")
}

View file

@ -211,7 +211,7 @@ private struct PermissionsSection: View {
.font(.headline)
} footer: {
Text(
"Terminal automation is required for all terminals. Accessibility is only needed for terminals that simulate keyboard input."
"Automation is required to spawn new Terminal windows. Accessibility is used to enter text."
)
.font(.caption)
.frame(maxWidth: .infinity)

View file

@ -0,0 +1,134 @@
import SwiftUI
/// Fifth page showing how to access the dashboard and ngrok integration.
///
/// This view provides information about accessing the VibeTunnel dashboard
/// from various devices, including options for ngrok tunneling and Tailscale
/// networking. It also displays project credits.
///
/// ## Topics
///
/// ### Overview
/// The dashboard access page includes:
/// - Instructions for remote dashboard access
/// - Open Dashboard button for local access
/// - Information about tunneling options (ngrok, Tailscale)
/// - Project credits and contributor links
///
/// ### Networking Options
/// - Local access via localhost
/// - ngrok tunnel configuration
/// - Tailscale VPN recommendation
struct AccessDashboardPageView: View {
@AppStorage("ngrokEnabled")
private var ngrokEnabled = false
@AppStorage("serverPort")
private var serverPort = "4020"
var body: some View {
VStack(spacing: 30) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Accessing Your Dashboard")
.font(.largeTitle)
.fontWeight(.semibold)
Text(
"To access your terminals from any device, create a tunnel from your device.\n\nThis can be done via **ngrok** in settings or **Tailscale** (recommended)."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
VStack(spacing: 12) {
// Open Dashboard button
Button(action: {
if let dashboardURL = URL(string: "http://127.0.0.1:\(serverPort)") {
NSWorkspace.shared.open(dashboardURL)
}
}, label: {
HStack {
Image(systemName: "safari")
Text("Open Dashboard")
}
})
.buttonStyle(.borderedProminent)
.controlSize(.large)
// Tailscale link button
TailscaleLink()
}
}
// Credits
VStack(spacing: 4) {
Text("VibeTunnel is open source and brought to you by")
.font(.caption)
.foregroundColor(.secondary)
HStack(spacing: 4) {
CreditLink(name: "@badlogic", url: "https://mariozechner.at/")
Text("")
.font(.caption)
.foregroundColor(.secondary)
CreditLink(name: "@mitsuhiko", url: "https://lucumr.pocoo.org/")
Text("")
.font(.caption)
.foregroundColor(.secondary)
CreditLink(name: "@steipete", url: "https://steipete.me")
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
// MARK: - Supporting Views
/// Tailscale link component with hover effect
struct TailscaleLink: View {
@State private var isHovering = false
var body: some View {
Button(action: {
if let tailscaleURL = URL(string: "https://tailscale.com/") {
NSWorkspace.shared.open(tailscaleURL)
}
}, label: {
HStack {
Image(systemName: "link")
Text("Learn more about Tailscale")
.underline(isHovering, color: .accentColor)
}
})
.buttonStyle(.link)
.pointingHandCursor()
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.2)) {
isHovering = hovering
}
}
}
}
// MARK: - Preview
struct AccessDashboardPageView_Previews: PreviewProvider {
static var previews: some View {
AccessDashboardPageView()
.frame(width: 640, height: 480)
.background(Color(NSColor.windowBackgroundColor))
}
}

View file

@ -0,0 +1,153 @@
import SwiftUI
/// Fourth page explaining dashboard security and access protection.
///
/// This view allows users to set up password protection for their dashboard
/// when accessing it over the network. It provides secure password entry
/// with confirmation and validation.
///
/// ## Topics
///
/// ### Overview
/// The dashboard protection page includes:
/// - Password and confirmation fields
/// - Password validation (minimum 6 characters)
/// - Secure storage in keychain
/// - Automatic network mode switching when password is set
/// - Option to skip password protection
///
/// ### Security
/// - Passwords are stored securely in the system keychain
/// - Network access is automatically enabled when a password is set
/// - Dashboard remains localhost-only without password
struct ProtectDashboardPageView: View {
@State private var password = ""
@State private var confirmPassword = ""
@State private var showError = false
@State private var errorMessage = ""
@State private var isPasswordSet = false
private let dashboardKeychain = DashboardKeychain.shared
var body: some View {
VStack(spacing: 30) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Protect Your Dashboard")
.font(.largeTitle)
.fontWeight(.semibold)
Text(
"If you want to access your dashboard over the network, set a password now.\nOtherwise, it will only be accessible via localhost."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
// Password fields
VStack(spacing: 12) {
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 300)
.onChange(of: password) { _, _ in
// Reset password saved state when user starts typing
if isPasswordSet {
isPasswordSet = false
}
}
SecureField("Confirm Password", text: $confirmPassword)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 300)
.onChange(of: confirmPassword) { _, _ in
// Reset password saved state when user starts typing
if isPasswordSet {
isPasswordSet = false
}
}
if showError {
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
}
if isPasswordSet {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Password saved securely")
.foregroundColor(.secondary)
}
.font(.caption)
} else {
Button("Set Password") {
setPassword()
}
.buttonStyle(.bordered)
.disabled(password.isEmpty)
}
Text("Leave empty to skip password protection")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
private func setPassword() {
showError = false
guard !password.isEmpty else {
return
}
guard password == confirmPassword else {
errorMessage = "Passwords do not match"
showError = true
return
}
guard password.count >= 6 else {
errorMessage = "Password must be at least 6 characters"
showError = true
return
}
if dashboardKeychain.setPassword(password) {
isPasswordSet = true
UserDefaults.standard.set(true, forKey: "dashboardPasswordEnabled")
// When password is set for the first time, automatically switch to network mode
let currentMode = DashboardAccessMode(rawValue: UserDefaults.standard
.string(forKey: "dashboardAccessMode") ?? ""
) ?? .localhost
if currentMode == .localhost {
UserDefaults.standard.set(DashboardAccessMode.network.rawValue, forKey: "dashboardAccessMode")
}
} else {
errorMessage = "Failed to save password to keychain"
showError = true
}
}
}
// MARK: - Preview
struct ProtectDashboardPageView_Previews: PreviewProvider {
static var previews: some View {
ProtectDashboardPageView()
.frame(width: 640, height: 480)
.background(Color(NSColor.windowBackgroundColor))
}
}

View file

@ -0,0 +1,118 @@
import SwiftUI
/// Third page requesting AppleScript automation and accessibility permissions.
///
/// This view guides users through granting necessary permissions for VibeTunnel
/// to function properly. It handles both AppleScript permissions for terminal
/// automation and accessibility permissions for sending commands.
///
/// ## Topics
///
/// ### Overview
/// The permissions page includes:
/// - AppleScript permission request and status
/// - Accessibility permission request and status
/// - Terminal application selector
/// - Real-time permission status updates
///
/// ### Requirements
/// - ``AppleScriptPermissionManager`` for AppleScript permissions
/// - ``AccessibilityPermissionManager`` for accessibility permissions
/// - Terminal selection stored in UserDefaults
struct RequestPermissionsPageView: View {
@StateObject private var appleScriptManager = AppleScriptPermissionManager.shared
@State private var accessibilityUpdateTrigger = 0
private var hasAccessibilityPermission: Bool {
// This will cause a re-read whenever accessibilityUpdateTrigger changes
_ = accessibilityUpdateTrigger
return AccessibilityPermissionManager.shared.hasPermission()
}
var body: some View {
VStack(spacing: 30) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Request Permissions")
.font(.largeTitle)
.fontWeight(.semibold)
Text(
"VibeTunnel needs AppleScript to start new terminal sessions\nand accessibility to send commands."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
// Permissions buttons
VStack(spacing: 16) {
// Automation permission
if appleScriptManager.hasPermission {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Automation permission granted")
.foregroundColor(.secondary)
}
.font(.body)
.frame(maxWidth: 250)
.frame(height: 32)
} else {
Button("Grant Automation Permission") {
appleScriptManager.requestPermission()
}
.buttonStyle(.borderedProminent)
.controlSize(.regular)
.frame(width: 250, height: 32)
}
// Accessibility permission
if hasAccessibilityPermission {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Accessibility permission granted")
.foregroundColor(.secondary)
}
.font(.body)
.frame(maxWidth: 250)
.frame(height: 32)
} else {
Button("Grant Accessibility Permission") {
AccessibilityPermissionManager.shared.requestPermission()
}
.buttonStyle(.bordered)
.controlSize(.regular)
.frame(width: 250, height: 32)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.task {
_ = await appleScriptManager.checkPermission()
}
.onReceive(Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()) { _ in
// Force a re-render to check accessibility permission
accessibilityUpdateTrigger += 1
}
}
}
// MARK: - Preview
struct RequestPermissionsPageView_Previews: PreviewProvider {
static var previews: some View {
RequestPermissionsPageView()
.frame(width: 640, height: 480)
.background(Color(NSColor.windowBackgroundColor))
}
}

View file

@ -0,0 +1,150 @@
import SwiftUI
/// Terminal selection page for choosing the preferred terminal application.
///
/// This view allows users to select their preferred terminal and test
/// the automation permission by launching a test command.
///
/// ## Topics
///
/// ### Overview
/// The terminal selection page includes:
/// - Terminal application picker
/// - Test button to verify terminal automation works
/// - Error handling for permission issues
struct SelectTerminalPageView: View {
@AppStorage("preferredTerminal") private var preferredTerminal = Terminal.terminal.rawValue
private let terminalLauncher = TerminalLauncher.shared
@State private var showingError = false
@State private var errorTitle = ""
@State private var errorMessage = ""
var body: some View {
VStack(spacing: 30) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Select Terminal")
.font(.largeTitle)
.fontWeight(.semibold)
Text("VibeTunnel can spawn new sessions and open a terminal for you.\nThis will require permissions.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
// Terminal selector and test button
VStack(spacing: 16) {
// Terminal picker
Picker("", selection: $preferredTerminal) {
ForEach(Terminal.installed, id: \.rawValue) { terminal in
HStack {
if let icon = terminal.appIcon {
Image(nsImage: icon)
.resizable()
.frame(width: 16, height: 16)
}
Text(terminal.displayName)
}
.tag(terminal.rawValue)
}
}
.pickerStyle(.menu)
.labelsHidden()
.frame(width: 200)
// Test terminal button
Button("Test Terminal Permission") {
testTerminal()
}
.buttonStyle(.borderedProminent)
.frame(width: 200)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.alert(errorTitle, isPresented: $showingError) {
Button("OK") { }
if errorTitle == "Permission Denied" {
Button("Open System Settings") {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") {
NSWorkspace.shared.open(url)
}
}
}
} message: {
Text(errorMessage)
}
}
private func testTerminal() {
Task {
do {
try terminalLauncher.launchCommand("echo 'VibeTunnel Terminal Test: Success! You can now use VibeTunnel with your terminal.'")
} catch {
// Handle errors
if let terminalError = error as? TerminalLauncherError {
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."
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."
case .terminalNotFound:
errorTitle = "Terminal Not Found"
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."
case -1_728:
errorTitle = "Terminal Not Available"
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:
errorTitle = "Accessibility Permission Required"
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)"
}
} else {
errorTitle = "Terminal Launch Failed"
errorMessage = "Failed to launch terminal: \(details)"
}
case .processLaunchFailed(let details):
errorTitle = "Process Launch Failed"
errorMessage = "Failed to start terminal process: \(details)"
}
} else {
errorTitle = "Terminal Launch Failed"
errorMessage = error.localizedDescription
}
showingError = true
}
}
}
}
// MARK: - Preview
struct SelectTerminalPageView_Previews: PreviewProvider {
static var previews: some View {
SelectTerminalPageView()
.frame(width: 640, height: 480)
.background(Color(NSColor.windowBackgroundColor))
}
}

View file

@ -0,0 +1,113 @@
import SwiftUI
/// Second page explaining the VT command-line tool and installation.
///
/// This view guides users through installing the `vt` command-line tool,
/// which is essential for capturing terminal applications. It displays
/// installation status and provides clear examples of usage.
///
/// ## Topics
///
/// ### Overview
/// The VT command page includes:
/// - Explanation of terminal app capturing
/// - Example usage of the `vt` command
/// - CLI tool installation button with status feedback
/// - Error handling for installation failures
///
/// ### Requirements
/// - ``CLIInstaller`` instance for managing installation
struct VTCommandPageView: View {
/// The CLI installer instance managing the installation process
var cliInstaller: CLIInstaller
var body: some View {
VStack(spacing: 30) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Capturing Terminal Apps")
.font(.largeTitle)
.fontWeight(.semibold)
Text(
"VibeTunnel can capture any terminal app or terminal.\nJust prefix it with the `vt` command and it will show up on the dashboard."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
Text("For example, to remote control Claude Code, type:")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Text("vt claude")
.font(.system(.body, design: .monospaced))
.foregroundColor(.primary)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.gray.opacity(0.1))
.cornerRadius(6)
// Install VT Binary button
VStack(spacing: 12) {
if cliInstaller.isInstalled {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("CLI tool is installed")
.foregroundColor(.secondary)
}
} else {
Button("Install VT Command Line Tool") {
Task {
await cliInstaller.install()
}
}
.buttonStyle(.borderedProminent)
.disabled(cliInstaller.isInstalling)
if cliInstaller.isInstalling {
ProgressView()
.scaleEffect(0.8)
}
}
if let error = cliInstaller.lastError {
Text(error)
.font(.caption)
.foregroundColor(.red)
.frame(maxWidth: 300)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.onAppear {
Task {
// This happens on startup, but we wanna refresh before showing.
await MainActor.run {
cliInstaller.checkInstallationStatus()
}
}
}
}
}
// MARK: - Preview
struct VTCommandPageView_Previews: PreviewProvider {
static var previews: some View {
VTCommandPageView(cliInstaller: CLIInstaller())
.frame(width: 640, height: 480)
.background(Color(NSColor.windowBackgroundColor))
}
}

View file

@ -0,0 +1,59 @@
import SwiftUI
/// First page of the welcome flow introducing VibeTunnel.
///
/// This view presents the initial onboarding screen with the app icon,
/// welcome message, and brief description of VibeTunnel's capabilities.
/// It serves as the entry point to the onboarding experience.
///
/// ## Topics
///
/// ### Overview
/// The welcome page displays:
/// - VibeTunnel app icon with shadow effect
/// - Welcome title and tagline
/// - Brief explanation of the onboarding process
struct WelcomePageView: View {
var body: some View {
VStack(spacing: 40) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 20) {
Text("Welcome to VibeTunnel")
.font(.largeTitle)
.fontWeight(.semibold)
Text("Turn any browser into your terminal. Command your agents on the go.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
Text(
"You'll be quickly guided through the basics of VibeTunnel.\nThis screen can always be opened from the settings."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
// MARK: - Preview
struct WelcomePageView_Previews: PreviewProvider {
static var previews: some View {
WelcomePageView()
.frame(width: 640, height: 480)
.background(Color(NSColor.windowBackgroundColor))
}
}

View file

@ -6,6 +6,17 @@ import SwiftUI
/// guides through CLI installation, requests AppleScript permissions, and explains
/// dashboard security best practices. The view tracks completion state to ensure
/// it's only shown once.
///
/// ## Topics
///
/// ### Overview
/// The welcome flow consists of six pages:
/// - ``WelcomePageView`` - Introduction and app overview
/// - ``VTCommandPageView`` - CLI tool installation
/// - ``RequestPermissionsPageView`` - System permissions setup
/// - ``SelectTerminalPageView`` - Terminal selection and testing
/// - ``ProtectDashboardPageView`` - Dashboard security configuration
/// - ``AccessDashboardPageView`` - Remote access instructions
struct WelcomeView: View {
@State private var currentPage = 0
@Environment(\.dismiss)
@ -37,14 +48,20 @@ struct WelcomeView: View {
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
}
// Page 4: Protect Your Dashboard
// Page 4: Select Terminal
if currentPage == 3 {
SelectTerminalPageView()
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
}
// Page 5: Protect Your Dashboard
if currentPage == 4 {
ProtectDashboardPageView()
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
}
// Page 5: Accessing Dashboard
if currentPage == 4 {
// Page 6: Accessing Dashboard
if currentPage == 5 {
AccessDashboardPageView()
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
}
@ -56,7 +73,7 @@ struct WelcomeView: View {
VStack(spacing: 0) {
// Page indicators
HStack(spacing: 8) {
ForEach(0..<5) { index in
ForEach(0..<6) { index in
Button(action: {
withAnimation {
currentPage = index
@ -97,11 +114,11 @@ struct WelcomeView: View {
}
private var buttonTitle: String {
currentPage == 4 ? "Finish" : "Next"
currentPage == 5 ? "Finish" : "Next"
}
private func handleNextAction() {
if currentPage < 4 {
if currentPage < 5 {
withAnimation {
currentPage += 1
}
@ -114,453 +131,10 @@ struct WelcomeView: View {
}
}
// MARK: - Welcome Page
/// First page of the welcome flow introducing VibeTunnel.
private struct WelcomePageView: View {
var body: some View {
VStack(spacing: 40) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 20) {
Text("Welcome to VibeTunnel")
.font(.largeTitle)
.fontWeight(.semibold)
Text("Turn any browser into your terminal. Command your agents on the go.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
Text(
"You'll be quickly guided through the basics of VibeTunnel.\nThis screen can always be opened from the settings."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
// MARK: - VT Command Page
/// Second page explaining the VT command-line tool and installation.
private struct VTCommandPageView: View {
var cliInstaller: CLIInstaller
var body: some View {
VStack(spacing: 30) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Capturing Terminal Apps")
.font(.largeTitle)
.fontWeight(.semibold)
Text(
"VibeTunnel can capture any terminal app or terminal.\nJust prefix it with the `vt` command and it will show up on the dashboard."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
Text("For example, to remote control Claude Code, type:")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Text("vt claude")
.font(.system(.body, design: .monospaced))
.foregroundColor(.primary)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.gray.opacity(0.1))
.cornerRadius(6)
// Install VT Binary button
VStack(spacing: 12) {
if cliInstaller.isInstalled {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("CLI tool is installed")
.foregroundColor(.secondary)
}
} else {
Button("Install VT Command Line Tool") {
Task {
await cliInstaller.install()
}
}
.buttonStyle(.borderedProminent)
.disabled(cliInstaller.isInstalling)
if cliInstaller.isInstalling {
ProgressView()
.scaleEffect(0.8)
}
}
if let error = cliInstaller.lastError {
Text(error)
.font(.caption)
.foregroundColor(.red)
.frame(maxWidth: 300)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.onAppear {
// Delay slightly to allow view to settle before animating
Task {
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
await MainActor.run {
cliInstaller.checkInstallationStatus()
}
}
}
}
}
// MARK: - Request Permissions Page
/// Third page requesting AppleScript automation and accessibility permissions.
private struct RequestPermissionsPageView: View {
@StateObject private var appleScriptManager = AppleScriptPermissionManager.shared
@State private var hasAccessibilityPermission = false
var body: some View {
VStack(spacing: 30) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Request Permissions")
.font(.largeTitle)
.fontWeight(.semibold)
Text(
"VibeTunnel needs AppleScript automation to launch and manage terminal sessions\nand accessibility to send commands to certain terminals."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
// Permissions buttons
VStack(spacing: 16) {
// Automation permission
if appleScriptManager.hasPermission {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Automation permission granted")
.foregroundColor(.secondary)
}
.font(.body)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.frame(height: 38) // Match large button height
} else {
Button("Grant Automation Permission") {
appleScriptManager.requestPermission()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
// Accessibility permission
if hasAccessibilityPermission {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Accessibility permission granted")
.foregroundColor(.secondary)
}
.font(.body)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.frame(height: 38) // Match large button height
} else {
Button("Grant Accessibility Permission") {
AccessibilityPermissionManager.shared.requestPermission()
}
.buttonStyle(.bordered)
.controlSize(.large)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.task {
_ = await appleScriptManager.checkPermission()
hasAccessibilityPermission = AccessibilityPermissionManager.shared.hasPermission()
}
.onReceive(Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()) { _ in
// Check accessibility permission status periodically
hasAccessibilityPermission = AccessibilityPermissionManager.shared.hasPermission()
}
}
}
// MARK: - Protect Dashboard Page
/// Fourth page explaining dashboard security and access protection.
private struct ProtectDashboardPageView: View {
@State private var password = ""
@State private var confirmPassword = ""
@State private var showError = false
@State private var errorMessage = ""
@State private var isPasswordSet = false
private let dashboardKeychain = DashboardKeychain.shared
var body: some View {
VStack(spacing: 30) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Protect Your Dashboard")
.font(.largeTitle)
.fontWeight(.semibold)
Text(
"If you want to access your dashboard over the network, set a password now.\nOtherwise, it will only be accessible via localhost."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
// Password fields
VStack(spacing: 12) {
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 300)
.onChange(of: password) { _, _ in
// Reset password saved state when user starts typing
if isPasswordSet {
isPasswordSet = false
}
}
SecureField("Confirm Password", text: $confirmPassword)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 300)
.onChange(of: confirmPassword) { _, _ in
// Reset password saved state when user starts typing
if isPasswordSet {
isPasswordSet = false
}
}
if showError {
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
}
if isPasswordSet {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Password saved securely")
.foregroundColor(.secondary)
}
.font(.caption)
} else {
Button("Set Password") {
setPassword()
}
.buttonStyle(.bordered)
.disabled(password.isEmpty)
}
Text("Leave empty to skip password protection")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
private func setPassword() {
showError = false
guard !password.isEmpty else {
return
}
guard password == confirmPassword else {
errorMessage = "Passwords do not match"
showError = true
return
}
guard password.count >= 6 else {
errorMessage = "Password must be at least 6 characters"
showError = true
return
}
if dashboardKeychain.setPassword(password) {
isPasswordSet = true
UserDefaults.standard.set(true, forKey: "dashboardPasswordEnabled")
// When password is set for the first time, automatically switch to network mode
let currentMode = DashboardAccessMode(rawValue: UserDefaults.standard
.string(forKey: "dashboardAccessMode") ?? ""
) ?? .localhost
if currentMode == .localhost {
UserDefaults.standard.set(DashboardAccessMode.network.rawValue, forKey: "dashboardAccessMode")
}
} else {
errorMessage = "Failed to save password to keychain"
showError = true
}
}
}
// MARK: - Access Dashboard Page
/// Fifth page showing how to access the dashboard and ngrok integration.
private struct AccessDashboardPageView: View {
@AppStorage("ngrokEnabled")
private var ngrokEnabled = false
@AppStorage("serverPort")
private var serverPort = "4020"
var body: some View {
VStack(spacing: 30) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Accessing Your Dashboard")
.font(.largeTitle)
.fontWeight(.semibold)
Text(
"To access your terminals from any device, create a tunnel from your device.\n\nThis can be done via **ngrok** in settings or **Tailscale** (recommended)."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
VStack(spacing: 12) {
// Open Dashboard button
Button(action: {
if let dashboardURL = URL(string: "http://127.0.0.1:\(serverPort)") {
NSWorkspace.shared.open(dashboardURL)
}
}, label: {
HStack {
Image(systemName: "safari")
Text("Open Dashboard")
}
})
.buttonStyle(.borderedProminent)
.controlSize(.large)
// Tailscale link button
TailscaleLink()
}
}
// Credits
VStack(spacing: 4) {
Text("VibeTunnel is open source and brought to you by")
.font(.caption)
.foregroundColor(.secondary)
HStack(spacing: 4) {
CreditLink(name: "@badlogic", url: "https://mariozechner.at/")
Text("")
.font(.caption)
.foregroundColor(.secondary)
CreditLink(name: "@mitsuhiko", url: "https://lucumr.pocoo.org/")
Text("")
.font(.caption)
.foregroundColor(.secondary)
CreditLink(name: "@steipete", url: "https://steipete.me")
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
// MARK: - Tailscale Link Component
struct TailscaleLink: View {
@State private var isHovering = false
var body: some View {
Button(action: {
if let tailscaleURL = URL(string: "https://tailscale.com/") {
NSWorkspace.shared.open(tailscaleURL)
}
}, label: {
HStack {
Image(systemName: "link")
Text("Learn more about Tailscale")
.underline(isHovering, color: .accentColor)
}
})
.buttonStyle(.link)
.pointingHandCursor()
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.2)) {
isHovering = hovering
}
}
}
}
// MARK: - Preview
struct WelcomeView_Previews: PreviewProvider {
static var previews: some View {
WelcomeView()
}
}
}

View file

@ -324,7 +324,7 @@ final class ApplicationMover {
configuration.activates = true
configuration.promptsUserIfNeeded = true
workspace.open(appURL, configuration: configuration) { app, error in
workspace.open(appURL, configuration: configuration) { _, error in
Task { @MainActor in
if let error {
self.logger.error("Failed to launch app from Applications: \(error)")

View file

@ -155,7 +155,8 @@ enum SettingsOpener {
/// A minimal hidden window that enables Settings to work in MenuBarExtra apps.
/// This is a workaround for FB10184971.
struct HiddenWindowView: View {
@Environment(\.openSettings) private var openSettings
@Environment(\.openSettings)
private var openSettings
var body: some View {
Color.clear

View file

@ -327,15 +327,13 @@ final class TerminalLauncher {
// Find all terminals that are currently running
var runningTerminals: [Terminal] = []
for terminal in Terminal.allCases {
if runningApps.contains(where: { $0.bundleIdentifier == terminal.bundleIdentifier }) {
runningTerminals.append(terminal)
logger.debug("Detected running terminal: \(terminal.rawValue)")
}
for terminal in Terminal.allCases where runningApps.contains(where: { $0.bundleIdentifier == terminal.bundleIdentifier }) {
runningTerminals.append(terminal)
logger.debug("Detected running terminal: \(terminal.rawValue)")
}
// Return the terminal with highest priority
return runningTerminals.max(by: { $0.detectionPriority < $1.detectionPriority })
return runningTerminals.max { $0.detectionPriority < $1.detectionPriority }
}
private func getValidTerminal() -> Terminal {
@ -511,14 +509,19 @@ final class TerminalLauncher {
_ = ttyFwdPath ?? findTTYFwdBinary()
// The command comes pre-formatted from Rust, just launch it
// Pass the working directory separately to avoid double-escaping issues
// This avoids double escaping issues
// Properly escape the directory path for shell
let escapedDir = expandedWorkingDir.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let fullCommand = "cd \"\(escapedDir)\" && \(command)"
// Get the preferred terminal or fallback
let terminal = getValidTerminal()
// Launch with configuration - let TerminalLaunchConfig handle the escaping
// Launch with configuration
let config = TerminalLaunchConfig(
command: command,
workingDirectory: expandedWorkingDir,
command: fullCommand,
workingDirectory: nil,
terminal: terminal
)
try launchWithConfig(config)