mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-17 13:15:53 +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):
|
||||
return value
|
||||
case .failure(let 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
|
||||
await handleServerModeChange()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue