vibetunnel/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift
2025-06-17 01:47:56 +02:00

820 lines
29 KiB
Swift

import AppKit
import os.log
import SwiftUI
/// Dashboard settings tab for server and access configuration
struct DashboardSettingsView: View {
@AppStorage("serverPort")
private var serverPort = "4020"
@AppStorage("ngrokEnabled")
private var ngrokEnabled = false
@AppStorage("dashboardPasswordEnabled")
private var passwordEnabled = false
@AppStorage("ngrokTokenPresent")
private var ngrokTokenPresent = false
@AppStorage("dashboardAccessMode")
private var accessModeString = DashboardAccessMode.localhost.rawValue
@State private var password = ""
@State private var confirmPassword = ""
@State private var showPasswordFields = false
@State private var passwordError: String?
@State private var passwordSaved = false
@State private var ngrokAuthToken = ""
@State private var ngrokStatus: NgrokTunnelStatus?
@State private var isStartingNgrok = false
@State private var ngrokError: String?
@State private var showingAuthTokenAlert = false
@State private var showingKeychainAlert = false
@State private var showingServerErrorAlert = false
@State private var serverErrorMessage = ""
@State private var isTokenRevealed = false
@State private var maskedToken = ""
@State private var localIPAddress: String?
private let dashboardKeychain = DashboardKeychain.shared
private let ngrokService = NgrokService.shared
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "DashboardSettings")
private var accessMode: DashboardAccessMode {
DashboardAccessMode(rawValue: accessModeString) ?? .localhost
}
var body: some View {
NavigationStack {
Form {
SecuritySection(
passwordEnabled: $passwordEnabled,
password: $password,
confirmPassword: $confirmPassword,
showPasswordFields: $showPasswordFields,
passwordError: $passwordError,
passwordSaved: $passwordSaved,
dashboardKeychain: dashboardKeychain,
savePassword: savePassword
)
ServerConfigurationSection(
accessMode: accessMode,
accessModeString: $accessModeString,
serverPort: $serverPort,
localIPAddress: localIPAddress,
restartServerWithNewBindAddress: restartServerWithNewBindAddress,
restartServerWithNewPort: restartServerWithNewPort
)
NgrokIntegrationSection(
ngrokEnabled: $ngrokEnabled,
ngrokAuthToken: $ngrokAuthToken,
ngrokTokenPresent: $ngrokTokenPresent,
isTokenRevealed: $isTokenRevealed,
maskedToken: $maskedToken,
isStartingNgrok: isStartingNgrok,
ngrokError: ngrokError,
ngrokService: ngrokService,
checkAndStartNgrok: checkAndStartNgrok,
stopNgrok: stopNgrok,
toggleTokenVisibility: toggleTokenVisibility
)
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.navigationTitle("Dashboard Settings")
}
.onAppear {
onAppearSetup()
}
.onChange(of: accessMode) { _, _ in
updateLocalIPAddress()
}
.alert("ngrok Auth Token Required", isPresented: $showingAuthTokenAlert) {
Button("OK") {}
} message: {
Text(
"Please enter your ngrok auth token before enabling the tunnel. You can get a free auth token at ngrok.com"
)
}
.alert("Keychain Access Error", isPresented: $showingKeychainAlert) {
Button("OK") {}
} message: {
Text("Failed to save the auth token to the keychain. Please check your keychain permissions and try again.")
}
.alert("Failed to Restart Server", isPresented: $showingServerErrorAlert) {
Button("OK") {}
} message: {
Text(serverErrorMessage)
}
}
// MARK: - Private Methods
private func onAppearSetup() {
// Check password status
if dashboardKeychain.hasPassword() {
passwordSaved = true
passwordEnabled = true
}
// Check if token exists without triggering keychain
if ngrokService.hasAuthToken && !ngrokTokenPresent {
ngrokTokenPresent = true
}
// Update masked field based on token presence
if ngrokTokenPresent && !isTokenRevealed {
maskedToken = String(repeating: "", count: 12)
}
// Get local IP address
updateLocalIPAddress()
}
private func savePassword() {
passwordError = nil
guard !password.isEmpty else {
passwordError = "Password cannot be empty"
return
}
guard password == confirmPassword else {
passwordError = "Passwords do not match"
return
}
guard password.count >= 4 else {
passwordError = "Password must be at least 4 characters"
return
}
if dashboardKeychain.setPassword(password) {
passwordSaved = true
showPasswordFields = false
password = ""
confirmPassword = ""
// Clear cached password in LazyBasicAuthMiddleware
LazyBasicAuthMiddleware<BasicRequestContext>.clearCache()
// When password is set for the first time, automatically switch to network mode
if accessMode == .localhost {
accessModeString = DashboardAccessMode.network.rawValue
restartServerWithNewBindAddress()
}
} else {
passwordError = "Failed to save password to keychain"
}
}
private func restartServerWithNewPort(_ port: Int) {
Task {
// Update the port in ServerManager and restart
ServerManager.shared.port = String(port)
await ServerManager.shared.restart()
logger.info("Server restarted on port \(port)")
// Restart session monitoring with new port
SessionMonitor.shared.stopMonitoring()
SessionMonitor.shared.startMonitoring()
}
}
private func restartServerWithNewBindAddress() {
Task {
// Update the bind address in ServerManager and restart
ServerManager.shared.bindAddress = accessMode.bindAddress
await ServerManager.shared.restart()
logger.info("Server restarted with bind address \(accessMode.bindAddress)")
// Restart session monitoring
SessionMonitor.shared.stopMonitoring()
SessionMonitor.shared.startMonitoring()
}
}
private func checkAndStartNgrok() {
logger.debug("checkAndStartNgrok called")
// Check if we have a token in the keychain without accessing it
guard ngrokTokenPresent || ngrokService.hasAuthToken else {
logger.debug("No auth token stored")
ngrokError = "Please enter your ngrok auth token first"
ngrokEnabled = false
showingAuthTokenAlert = true
return
}
// If token hasn't been revealed yet, we need to access it from keychain
if !isTokenRevealed && ngrokAuthToken.isEmpty {
// This will trigger keychain access
if let token = ngrokService.authToken {
ngrokAuthToken = token
logger.debug("Retrieved token from keychain for ngrok start")
} else {
logger.error("Failed to retrieve token from keychain")
ngrokError = "Failed to access auth token. Please try again."
ngrokEnabled = false
showingKeychainAlert = true
return
}
}
logger.debug("Starting ngrok with auth token present")
isStartingNgrok = true
ngrokError = nil
Task {
do {
let port = Int(serverPort) ?? 4_020
logger.info("Starting ngrok on port \(port)")
_ = try await ngrokService.start(port: port)
isStartingNgrok = false
ngrokStatus = await ngrokService.getStatus()
logger.info("ngrok started successfully")
} catch {
logger.error("ngrok start error: \(error)")
isStartingNgrok = false
ngrokError = error.localizedDescription
ngrokEnabled = false
}
}
}
private func stopNgrok() {
Task {
try? await ngrokService.stop()
ngrokStatus = nil
// Don't clear the error here - let it remain visible
}
}
private func toggleTokenVisibility() {
if isTokenRevealed {
// Hide the token
isTokenRevealed = false
ngrokAuthToken = ""
if ngrokTokenPresent {
maskedToken = String(repeating: "", count: 12)
}
} else {
// Reveal the token - this will trigger keychain access
if let token = ngrokService.authToken {
ngrokAuthToken = token
isTokenRevealed = true
} else {
// No token stored, just reveal the empty field
ngrokAuthToken = ""
isTokenRevealed = true
}
}
}
private func updateLocalIPAddress() {
Task {
if accessMode == .network {
localIPAddress = NetworkUtility.getLocalIPAddress()
} else {
localIPAddress = nil
}
}
}
}
// MARK: - Security Section
private struct SecuritySection: View {
@Binding var passwordEnabled: Bool
@Binding var password: String
@Binding var confirmPassword: String
@Binding var showPasswordFields: Bool
@Binding var passwordError: String?
@Binding var passwordSaved: Bool
let dashboardKeychain: DashboardKeychain
let savePassword: () -> Void
var body: some View {
Section {
VStack(alignment: .leading, spacing: 12) {
Toggle("Password protect dashboard", isOn: $passwordEnabled)
.onChange(of: passwordEnabled) { _, newValue in
if newValue && !dashboardKeychain.hasPassword() {
showPasswordFields = true
} else if !newValue {
// Clear password when disabled
_ = dashboardKeychain.deletePassword()
showPasswordFields = false
passwordSaved = false
// Clear cached password in LazyBasicAuthMiddleware
LazyBasicAuthMiddleware<BasicRequestContext>.clearCache()
}
}
Text("Require a password to access the dashboard from remote connections.")
.font(.caption)
.foregroundStyle(.secondary)
if showPasswordFields || (passwordEnabled && !passwordSaved) {
PasswordFieldsView(
password: $password,
confirmPassword: $confirmPassword,
passwordError: $passwordError,
showPasswordFields: $showPasswordFields,
passwordEnabled: $passwordEnabled,
savePassword: savePassword
)
}
if passwordSaved {
SavedPasswordView(
showPasswordFields: $showPasswordFields,
passwordSaved: $passwordSaved,
password: $password,
confirmPassword: $confirmPassword
)
}
}
} header: {
Text("Security")
.font(.headline)
} footer: {
Text("Localhost always accessible without password. Username is ignored in remote connections.")
.font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
}
}
}
// MARK: - Password Fields View
private struct PasswordFieldsView: View {
@Binding var password: String
@Binding var confirmPassword: String
@Binding var passwordError: String?
@Binding var showPasswordFields: Bool
@Binding var passwordEnabled: Bool
let savePassword: () -> Void
var body: some View {
VStack(spacing: 8) {
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
SecureField("Confirm Password", text: $confirmPassword)
.textFieldStyle(.roundedBorder)
if let error = passwordError {
Text(error)
.font(.caption)
.foregroundColor(.red)
}
HStack {
Button("Cancel") {
showPasswordFields = false
passwordEnabled = false
password = ""
confirmPassword = ""
passwordError = nil
}
.buttonStyle(.bordered)
Button("Save Password") {
savePassword()
}
.buttonStyle(.borderedProminent)
.disabled(password.isEmpty)
}
}
.padding(.top, 4)
}
}
// MARK: - Saved Password View
private struct SavedPasswordView: View {
@Binding var showPasswordFields: Bool
@Binding var passwordSaved: Bool
@Binding var password: String
@Binding var confirmPassword: String
var body: some View {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Password saved")
.font(.caption)
Spacer()
Button("Change Password") {
showPasswordFields = true
passwordSaved = false
password = ""
confirmPassword = ""
}
.buttonStyle(.link)
.font(.caption)
}
}
}
// MARK: - Server Configuration Section
private struct ServerConfigurationSection: View {
let accessMode: DashboardAccessMode
@Binding var accessModeString: String
@Binding var serverPort: String
let localIPAddress: String?
let restartServerWithNewBindAddress: () -> Void
let restartServerWithNewPort: (Int) -> Void
var body: some View {
Section {
AccessModeView(
accessMode: accessMode,
accessModeString: $accessModeString,
serverPort: serverPort,
localIPAddress: localIPAddress,
restartServerWithNewBindAddress: restartServerWithNewBindAddress
)
PortConfigurationView(
serverPort: $serverPort,
restartServerWithNewPort: restartServerWithNewPort
)
} header: {
Text("Server Configuration")
.font(.headline)
}
}
}
// MARK: - Access Mode View
private struct AccessModeView: View {
let accessMode: DashboardAccessMode
@Binding var accessModeString: String
let serverPort: String
let localIPAddress: String?
let restartServerWithNewBindAddress: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Allow accessing the dashboard from:")
Spacer()
Picker("", selection: Binding(
get: { accessMode },
set: { newMode in
accessModeString = newMode.rawValue
restartServerWithNewBindAddress()
}
)) {
ForEach(DashboardAccessMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.menu)
.labelsHidden()
}
HStack(spacing: 8) {
Text(accessMode.description)
.font(.caption)
.foregroundStyle(.secondary)
// Show IP address when network access is enabled
if accessMode == .network {
if let ipAddress = localIPAddress {
Spacer()
Button(action: {
let urlString = "http://\(ipAddress):\(serverPort)"
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
}) {
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)
}
.buttonStyle(.plain)
.help("Copy URL")
} else {
Spacer()
Text("Unable to determine local IP address")
.font(.caption)
.foregroundStyle(.red)
}
}
}
}
}
}
// MARK: - Port Configuration View
private struct PortConfigurationView: View {
@Binding var serverPort: String
let restartServerWithNewPort: (Int) -> Void
@State private var portNumber: Int = 4_020
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Server port:")
Spacer()
HStack(spacing: 4) {
TextField("", text: $serverPort)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
.multilineTextAlignment(.center)
.onChange(of: serverPort) { _, newValue in
// Validate port number
if let port = Int(newValue), port > 0, port < 65_536 {
portNumber = port
restartServerWithNewPort(port)
}
}
VStack(spacing: 0) {
Button(action: {
if portNumber < 65_535 {
portNumber += 1
serverPort = String(portNumber)
restartServerWithNewPort(portNumber)
}
}) {
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)
}
}) {
Image(systemName: "chevron.down")
.font(.system(size: 10))
.frame(width: 16, height: 12)
}
.buttonStyle(.plain)
.help("Decrease port number")
}
}
.onAppear {
portNumber = Int(serverPort) ?? 4_020
}
}
Text("The server will automatically restart when the port is changed.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Ngrok Integration Section
private struct NgrokIntegrationSection: View {
@Binding var ngrokEnabled: Bool
@Binding var ngrokAuthToken: String
@Binding var ngrokTokenPresent: Bool
@Binding var isTokenRevealed: Bool
@Binding var maskedToken: String
let isStartingNgrok: Bool
let ngrokError: String?
let ngrokService: NgrokService
let checkAndStartNgrok: () -> Void
let stopNgrok: () -> Void
let toggleTokenVisibility: () -> Void
var body: some View {
Section {
VStack(alignment: .leading, spacing: 12) {
// ngrok Enable Toggle
NgrokToggleView(
ngrokEnabled: $ngrokEnabled,
checkAndStartNgrok: checkAndStartNgrok,
stopNgrok: stopNgrok
)
// Auth Token
NgrokAuthTokenView(
ngrokAuthToken: $ngrokAuthToken,
ngrokTokenPresent: $ngrokTokenPresent,
isTokenRevealed: $isTokenRevealed,
maskedToken: $maskedToken,
ngrokService: ngrokService,
toggleTokenVisibility: toggleTokenVisibility
)
// Status
if ngrokEnabled {
NgrokStatusView(
ngrokService: ngrokService,
isStartingNgrok: isStartingNgrok
)
}
// Error display
if let error = ngrokError {
NgrokErrorView(error: error)
}
}
} header: {
Text("ngrok Integration")
.font(.headline)
} footer: {
Text(
"Alternatively, we recommend [Tailscale](https://tailscale.com/) to create a virtual network to access your Mac."
)
.font(.caption)
.foregroundStyle(.secondary)
.tint(.blue)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
}
}
}
// MARK: - Ngrok Toggle View
private struct NgrokToggleView: View {
@Binding var ngrokEnabled: Bool
let checkAndStartNgrok: () -> Void
let stopNgrok: () -> Void
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "NgrokToggle")
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Toggle("Enable ngrok tunnel", isOn: $ngrokEnabled)
.onChange(of: ngrokEnabled) { oldValue, newValue in
logger.debug("ngrok toggle changed from \(oldValue) to \(newValue)")
if newValue {
// Add a small delay to ensure auth token is saved to keychain
Task {
try? await Task.sleep(for: .milliseconds(100))
await MainActor.run {
checkAndStartNgrok()
}
}
} else {
stopNgrok()
}
}
Text("Expose VibeTunnel to the internet using ngrok.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Ngrok Auth Token View
private struct NgrokAuthTokenView: View {
@Binding var ngrokAuthToken: String
@Binding var ngrokTokenPresent: Bool
@Binding var isTokenRevealed: Bool
@Binding var maskedToken: String
let ngrokService: NgrokService
let toggleTokenVisibility: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Auth token:")
Spacer()
HStack(spacing: 4) {
if isTokenRevealed {
SecureField("", text: $ngrokAuthToken)
.frame(width: 220)
.textFieldStyle(.roundedBorder)
.onChange(of: ngrokAuthToken) { _, newValue in
ngrokService.authToken = newValue.isEmpty ? nil : newValue
ngrokTokenPresent = !newValue.isEmpty
}
} else {
TextField("", text: $maskedToken)
.frame(width: 220)
.textFieldStyle(.roundedBorder)
.disabled(true)
.onAppear {
// Show masked placeholder if token exists
if ngrokTokenPresent {
maskedToken = String(repeating: "", count: 12)
} else {
maskedToken = ""
}
}
}
Button(action: {
toggleTokenVisibility()
}, label: {
Image(systemName: isTokenRevealed ? "eye.slash" : "eye")
})
.buttonStyle(.plain)
.help(isTokenRevealed ? "Hide token" : "Reveal token")
}
}
HStack {
Text("Get your free auth token at")
.font(.caption)
.foregroundStyle(.secondary)
Button("ngrok.com") {
if let url = URL(string: "https://dashboard.ngrok.com/auth/your-authtoken") {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(.link)
.font(.caption)
}
}
}
}
// MARK: - Ngrok Status View
private struct NgrokStatusView: View {
let ngrokService: NgrokService
let isStartingNgrok: Bool
var body: some View {
if let publicUrl = ngrokService.publicUrl {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Tunnel active")
.font(.caption)
Spacer()
Image(systemName: "doc.on.doc")
.font(.caption)
.foregroundColor(.secondary)
.help("Copy URL")
.onTapGesture {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(publicUrl, forType: .string)
}
Button("Open Browser") {
if let url = URL(string: publicUrl) {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(.bordered)
.controlSize(.small)
}
Text(publicUrl)
.font(.caption)
.textSelection(.enabled)
.foregroundStyle(.secondary)
}
} else if isStartingNgrok {
HStack {
ProgressView()
.scaleEffect(0.8)
Text("Starting ngrok tunnel...")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
// MARK: - Ngrok Error View
private struct NgrokErrorView: View {
let error: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text("Error")
.font(.caption)
}
Text(error)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}