mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
recover lost commits
This commit is contained in:
parent
915d3e3eb1
commit
5804790ae1
17 changed files with 869 additions and 530 deletions
|
|
@ -75,11 +75,13 @@ final class AppleScriptExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch result! {
|
switch result {
|
||||||
case .success(let value):
|
case .success(let value):
|
||||||
return value
|
return value
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
throw error
|
throw error
|
||||||
|
case .none:
|
||||||
|
throw AppleScriptError.executionFailed(message: "Script execution result was nil", errorCode: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,8 @@ class ServerManager {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func userDefaultsDidChange() {
|
@objc
|
||||||
|
private func userDefaultsDidChange() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
await handleServerModeChange()
|
await handleServerModeChange()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,6 @@ final class TerminalSpawnService: @unchecked Sendable {
|
||||||
|
|
||||||
let response = SpawnResponse(success: true, error: nil, sessionId: request.sessionId)
|
let response = SpawnResponse(success: true, error: nil, sessionId: request.sessionId)
|
||||||
return try JSONEncoder().encode(response)
|
return try JSONEncoder().encode(response)
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to handle spawn request: \(error)")
|
logger.error("Failed to handle spawn request: \(error)")
|
||||||
let response = SpawnResponse(success: false, error: error.localizedDescription, sessionId: nil)
|
let response = SpawnResponse(success: false, error: error.localizedDescription, sessionId: nil)
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,8 @@ struct MenuBarView: View {
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// About
|
// About
|
||||||
Button(action: {
|
Button(
|
||||||
|
action: {
|
||||||
SettingsOpener.openSettings()
|
SettingsOpener.openSettings()
|
||||||
// Navigate to About tab after settings opens
|
// Navigate to About tab after settings opens
|
||||||
Task {
|
Task {
|
||||||
|
|
@ -114,12 +115,14 @@ struct MenuBarView: View {
|
||||||
object: SettingsTab.about
|
object: SettingsTab.about
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}) {
|
},
|
||||||
|
label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
Text("About VibeTunnel")
|
Text("About VibeTunnel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Help", systemImage: "questionmark.circle")
|
Label("Help", systemImage: "questionmark.circle")
|
||||||
}
|
}
|
||||||
|
|
@ -135,11 +138,14 @@ struct MenuBarView: View {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Settings button
|
// Settings button
|
||||||
Button(action: {
|
Button(
|
||||||
|
action: {
|
||||||
SettingsOpener.openSettings()
|
SettingsOpener.openSettings()
|
||||||
}) {
|
},
|
||||||
|
label: {
|
||||||
Label("Settings…", systemImage: "gear")
|
Label("Settings…", systemImage: "gear")
|
||||||
}
|
}
|
||||||
|
)
|
||||||
.buttonStyle(MenuButtonStyle())
|
.buttonStyle(MenuButtonStyle())
|
||||||
.keyboardShortcut(",", modifiers: .command)
|
.keyboardShortcut(",", modifiers: .command)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import SwiftUI
|
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
|
/// Advanced settings tab for power user options
|
||||||
struct AdvancedSettingsView: View {
|
struct AdvancedSettingsView: View {
|
||||||
|
|
@ -13,6 +20,9 @@ struct AdvancedSettingsView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
// Terminal preference section
|
||||||
|
TerminalPreferenceSection()
|
||||||
|
|
||||||
// Integration section
|
// Integration section
|
||||||
Section {
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
|
@ -61,9 +71,6 @@ struct AdvancedSettingsView: View {
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal preference section
|
|
||||||
TerminalPreferenceSection()
|
|
||||||
|
|
||||||
// Advanced section
|
// Advanced section
|
||||||
Section {
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
|
@ -122,7 +129,8 @@ struct AdvancedSettingsView: View {
|
||||||
// MARK: - Terminal Preference Section
|
// MARK: - Terminal Preference Section
|
||||||
|
|
||||||
private struct TerminalPreferenceSection: View {
|
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 terminalLauncher = TerminalLauncher.shared
|
||||||
@State private var showingError = false
|
@State private var showingError = false
|
||||||
@State private var errorMessage = ""
|
@State private var errorMessage = ""
|
||||||
|
|
@ -164,7 +172,7 @@ private struct TerminalPreferenceSection: View {
|
||||||
try terminalLauncher.launchCommand("echo 'VibeTunnel Terminal Test: Success!'")
|
try terminalLauncher.launchCommand("echo 'VibeTunnel Terminal Test: Success!'")
|
||||||
} catch {
|
} catch {
|
||||||
// Log the error
|
// 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
|
// Set up alert content based on error type
|
||||||
if let terminalError = error as? TerminalLauncherError {
|
if let terminalError = error as? TerminalLauncherError {
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,9 @@ struct DashboardSettingsView: View {
|
||||||
await ServerManager.shared.restart()
|
await ServerManager.shared.restart()
|
||||||
logger.info("Server restarted on port \(port)")
|
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
|
// Restart session monitoring with new port
|
||||||
SessionMonitor.shared.stopMonitoring()
|
SessionMonitor.shared.stopMonitoring()
|
||||||
SessionMonitor.shared.startMonitoring()
|
SessionMonitor.shared.startMonitoring()
|
||||||
|
|
@ -190,6 +193,9 @@ struct DashboardSettingsView: View {
|
||||||
await ServerManager.shared.restart()
|
await ServerManager.shared.restart()
|
||||||
logger.info("Server restarted with bind address \(accessMode.bindAddress)")
|
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
|
// Restart session monitoring
|
||||||
SessionMonitor.shared.stopMonitoring()
|
SessionMonitor.shared.stopMonitoring()
|
||||||
SessionMonitor.shared.startMonitoring()
|
SessionMonitor.shared.startMonitoring()
|
||||||
|
|
@ -490,29 +496,35 @@ private struct AccessModeView: View {
|
||||||
if let ipAddress = localIPAddress {
|
if let ipAddress = localIPAddress {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: {
|
Button(
|
||||||
|
action: {
|
||||||
let urlString = "http://\(ipAddress):\(serverPort)"
|
let urlString = "http://\(ipAddress):\(serverPort)"
|
||||||
if let url = URL(string: urlString) {
|
if let url = URL(string: urlString) {
|
||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}) {
|
},
|
||||||
|
label: {
|
||||||
Text("http://\(ipAddress):\(serverPort)")
|
Text("http://\(ipAddress):\(serverPort)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(.blue)
|
||||||
.underline()
|
.underline()
|
||||||
}
|
}
|
||||||
|
)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.pointingHandCursor()
|
.pointingHandCursor()
|
||||||
|
|
||||||
Button(action: {
|
Button(
|
||||||
|
action: {
|
||||||
let urlString = "http://\(ipAddress):\(serverPort)"
|
let urlString = "http://\(ipAddress):\(serverPort)"
|
||||||
NSPasteboard.general.clearContents()
|
NSPasteboard.general.clearContents()
|
||||||
NSPasteboard.general.setString(urlString, forType: .string)
|
NSPasteboard.general.setString(urlString, forType: .string)
|
||||||
}) {
|
},
|
||||||
|
label: {
|
||||||
Image(systemName: "doc.on.doc")
|
Image(systemName: "doc.on.doc")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("Copy URL")
|
.help("Copy URL")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -554,31 +566,37 @@ private struct PortConfigurationView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Button(action: {
|
Button(
|
||||||
|
action: {
|
||||||
if portNumber < 65_535 {
|
if portNumber < 65_535 {
|
||||||
portNumber += 1
|
portNumber += 1
|
||||||
serverPort = String(portNumber)
|
serverPort = String(portNumber)
|
||||||
restartServerWithNewPort(portNumber)
|
restartServerWithNewPort(portNumber)
|
||||||
}
|
}
|
||||||
}) {
|
},
|
||||||
|
label: {
|
||||||
Image(systemName: "chevron.up")
|
Image(systemName: "chevron.up")
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.frame(width: 16, height: 12)
|
.frame(width: 16, height: 12)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("Increase port number")
|
.help("Increase port number")
|
||||||
|
|
||||||
Button(action: {
|
Button(
|
||||||
|
action: {
|
||||||
if portNumber > 1 {
|
if portNumber > 1 {
|
||||||
portNumber -= 1
|
portNumber -= 1
|
||||||
serverPort = String(portNumber)
|
serverPort = String(portNumber)
|
||||||
restartServerWithNewPort(portNumber)
|
restartServerWithNewPort(portNumber)
|
||||||
}
|
}
|
||||||
}) {
|
},
|
||||||
|
label: {
|
||||||
Image(systemName: "chevron.down")
|
Image(systemName: "chevron.down")
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.frame(width: 16, height: 12)
|
.frame(width: 16, height: 12)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("Decrease port number")
|
.help("Decrease port number")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ private struct PermissionsSection: View {
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(
|
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)
|
.font(.caption)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
113
VibeTunnel/Presentation/Views/Welcome/VTCommandPageView.swift
Normal file
113
VibeTunnel/Presentation/Views/Welcome/VTCommandPageView.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
59
VibeTunnel/Presentation/Views/Welcome/WelcomePageView.swift
Normal file
59
VibeTunnel/Presentation/Views/Welcome/WelcomePageView.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,17 @@ import SwiftUI
|
||||||
/// guides through CLI installation, requests AppleScript permissions, and explains
|
/// guides through CLI installation, requests AppleScript permissions, and explains
|
||||||
/// dashboard security best practices. The view tracks completion state to ensure
|
/// dashboard security best practices. The view tracks completion state to ensure
|
||||||
/// it's only shown once.
|
/// 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 {
|
struct WelcomeView: View {
|
||||||
@State private var currentPage = 0
|
@State private var currentPage = 0
|
||||||
@Environment(\.dismiss)
|
@Environment(\.dismiss)
|
||||||
|
|
@ -37,14 +48,20 @@ struct WelcomeView: View {
|
||||||
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
|
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page 4: Protect Your Dashboard
|
// Page 4: Select Terminal
|
||||||
if currentPage == 3 {
|
if currentPage == 3 {
|
||||||
|
SelectTerminalPageView()
|
||||||
|
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page 5: Protect Your Dashboard
|
||||||
|
if currentPage == 4 {
|
||||||
ProtectDashboardPageView()
|
ProtectDashboardPageView()
|
||||||
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
|
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page 5: Accessing Dashboard
|
// Page 6: Accessing Dashboard
|
||||||
if currentPage == 4 {
|
if currentPage == 5 {
|
||||||
AccessDashboardPageView()
|
AccessDashboardPageView()
|
||||||
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
|
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +73,7 @@ struct WelcomeView: View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Page indicators
|
// Page indicators
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(0..<5) { index in
|
ForEach(0..<6) { index in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
currentPage = index
|
currentPage = index
|
||||||
|
|
@ -97,11 +114,11 @@ struct WelcomeView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var buttonTitle: String {
|
private var buttonTitle: String {
|
||||||
currentPage == 4 ? "Finish" : "Next"
|
currentPage == 5 ? "Finish" : "Next"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleNextAction() {
|
private func handleNextAction() {
|
||||||
if currentPage < 4 {
|
if currentPage < 5 {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
currentPage += 1
|
currentPage += 1
|
||||||
}
|
}
|
||||||
|
|
@ -114,449 +131,6 @@ 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
|
// MARK: - Preview
|
||||||
|
|
||||||
struct WelcomeView_Previews: PreviewProvider {
|
struct WelcomeView_Previews: PreviewProvider {
|
||||||
|
|
|
||||||
|
|
@ -324,7 +324,7 @@ final class ApplicationMover {
|
||||||
configuration.activates = true
|
configuration.activates = true
|
||||||
configuration.promptsUserIfNeeded = true
|
configuration.promptsUserIfNeeded = true
|
||||||
|
|
||||||
workspace.open(appURL, configuration: configuration) { app, error in
|
workspace.open(appURL, configuration: configuration) { _, error in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
if let error {
|
if let error {
|
||||||
self.logger.error("Failed to launch app from Applications: \(error)")
|
self.logger.error("Failed to launch app from Applications: \(error)")
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,8 @@ enum SettingsOpener {
|
||||||
/// A minimal hidden window that enables Settings to work in MenuBarExtra apps.
|
/// A minimal hidden window that enables Settings to work in MenuBarExtra apps.
|
||||||
/// This is a workaround for FB10184971.
|
/// This is a workaround for FB10184971.
|
||||||
struct HiddenWindowView: View {
|
struct HiddenWindowView: View {
|
||||||
@Environment(\.openSettings) private var openSettings
|
@Environment(\.openSettings)
|
||||||
|
private var openSettings
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Color.clear
|
Color.clear
|
||||||
|
|
|
||||||
|
|
@ -327,15 +327,13 @@ final class TerminalLauncher {
|
||||||
// Find all terminals that are currently running
|
// Find all terminals that are currently running
|
||||||
var runningTerminals: [Terminal] = []
|
var runningTerminals: [Terminal] = []
|
||||||
|
|
||||||
for terminal in Terminal.allCases {
|
for terminal in Terminal.allCases where runningApps.contains(where: { $0.bundleIdentifier == terminal.bundleIdentifier }) {
|
||||||
if runningApps.contains(where: { $0.bundleIdentifier == terminal.bundleIdentifier }) {
|
|
||||||
runningTerminals.append(terminal)
|
runningTerminals.append(terminal)
|
||||||
logger.debug("Detected running terminal: \(terminal.rawValue)")
|
logger.debug("Detected running terminal: \(terminal.rawValue)")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Return the terminal with highest priority
|
// 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 {
|
private func getValidTerminal() -> Terminal {
|
||||||
|
|
@ -511,14 +509,19 @@ final class TerminalLauncher {
|
||||||
_ = ttyFwdPath ?? findTTYFwdBinary()
|
_ = ttyFwdPath ?? findTTYFwdBinary()
|
||||||
|
|
||||||
// The command comes pre-formatted from Rust, just launch it
|
// 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
|
// Get the preferred terminal or fallback
|
||||||
let terminal = getValidTerminal()
|
let terminal = getValidTerminal()
|
||||||
|
|
||||||
// Launch with configuration - let TerminalLaunchConfig handle the escaping
|
// Launch with configuration
|
||||||
let config = TerminalLaunchConfig(
|
let config = TerminalLaunchConfig(
|
||||||
command: command,
|
command: fullCommand,
|
||||||
workingDirectory: expandedWorkingDir,
|
workingDirectory: nil,
|
||||||
terminal: terminal
|
terminal: terminal
|
||||||
)
|
)
|
||||||
try launchWithConfig(config)
|
try launchWithConfig(config)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue