new auth logic

This commit is contained in:
Peter Steinberger 2025-06-24 01:47:37 +02:00
parent 62c6052faf
commit 288a3197d2
19 changed files with 595 additions and 923 deletions

View file

@ -53,7 +53,7 @@ extension View {
systemPermissionManager: SystemPermissionManager? = nil,
terminalLauncher: TerminalLauncher? = nil
)
-> some View
-> some View
{
self
.environment(\.serverManager, serverManager ?? ServerManager.shared)

View file

@ -41,7 +41,7 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
/// Static URLs to ensure they're validated at compile time
private static let stableAppcastURL: URL = {
guard let url =
URL(string: "https://stats.store/api/v1/appcast/appcast.xml")
URL(string: "https://stats.store/api/v1/appcast/appcast.xml")
else {
fatalError("Invalid stable appcast URL - this should never happen with a hardcoded URL")
}
@ -50,9 +50,9 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
private static let prereleaseAppcastURL: URL = {
guard let url =
URL(
string: "https://stats.store/api/v1/appcast/appcast-prerelease.xml"
)
URL(
string: "https://stats.store/api/v1/appcast/appcast-prerelease.xml"
)
else {
fatalError("Invalid prerelease appcast URL - this should never happen with a hardcoded URL")
}

View file

@ -133,18 +133,22 @@ final class BunServer {
// Build the vibetunnel command with all arguments
var vibetunnelArgs = "--port \(port) --bind \(bindAddress)"
// Add password flag if password protection is enabled
if UserDefaults.standard.bool(forKey: "dashboardPasswordEnabled") && DashboardKeychain.shared.hasPassword() {
logger.info("Password protection enabled, retrieving from keychain")
if let password = DashboardKeychain.shared.getPassword() {
// Escape the password for shell
let escapedPassword = password.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "$", with: "\\$")
.replacingOccurrences(of: "`", with: "\\`")
.replacingOccurrences(of: "\\", with: "\\\\")
// Use password-only mode for better UX - users can enter any username
vibetunnelArgs += " --password \"\(escapedPassword)\""
}
// Add authentication flags based on configuration
let authMode = UserDefaults.standard.string(forKey: "authenticationMode") ?? "os"
logger.info("Configuring authentication mode: \(authMode)")
switch authMode {
case "none":
vibetunnelArgs += " --no-auth"
case "ssh":
vibetunnelArgs += " --enable-ssh-keys --disallow-user-password"
case "both":
vibetunnelArgs += " --enable-ssh-keys"
case "os":
fallthrough
default:
// OS authentication is the default, no special flags needed
break
}
// Create wrapper to run vibetunnel with a parent death signal
@ -480,7 +484,7 @@ final class BunServer {
seconds: TimeInterval,
operation: @escaping @Sendable () async -> T
)
async -> T?
async -> T?
{
await withTaskGroup(of: T?.self) { group in
group.addTask {

View file

@ -20,33 +20,33 @@ final class DashboardKeychain {
/// Get the dashboard password from keychain
func getPassword() -> String? {
#if DEBUG
// In debug builds, skip keychain access to avoid authorization dialogs
logger
.info(
"Debug mode: Skipping keychain password retrieval. Password will only persist during current app session."
)
return nil
#else
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let password = String(data: data, encoding: .utf8)
else {
logger.debug("No password found in keychain")
// In debug builds, skip keychain access to avoid authorization dialogs
logger
.info(
"Debug mode: Skipping keychain password retrieval. Password will only persist during current app session."
)
return nil
}
#else
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true
]
logger.debug("Password retrieved from keychain")
return password
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let password = String(data: data, encoding: .utf8)
else {
logger.debug("No password found in keychain")
return nil
}
logger.debug("Password retrieved from keychain")
return password
#endif
}
@ -103,12 +103,12 @@ final class DashboardKeychain {
logger.info("Password \(success ? "saved to" : "failed to save to") keychain")
#if DEBUG
if success {
logger
.info(
"Debug mode: Password saved to keychain but will not persist across app restarts. The password will only be available during this session to avoid keychain authorization dialogs during development."
)
}
if success {
logger
.info(
"Debug mode: Password saved to keychain but will not persist across app restarts. The password will only be available during this session to avoid keychain authorization dialogs during development."
)
}
#endif
return success

View file

@ -291,7 +291,7 @@ final class NgrokService: NgrokTunnelProtocol {
seconds: TimeInterval,
operation: @Sendable @escaping () async throws -> T
)
async throws -> T
async throws -> T
{
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {

View file

@ -62,7 +62,7 @@ class ServerManager {
get {
let mode = DashboardAccessMode(rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? ""
) ??
.localhost
.localhost
return mode.bindAddress
}
set {

View file

@ -52,38 +52,38 @@ public final class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate {
// Initialize Sparkle with standard configuration
#if DEBUG
// In debug mode, start the updater for testing
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: self,
userDriverDelegate: userDriverDelegate
)
// In debug mode, start the updater for testing
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: self,
userDriverDelegate: userDriverDelegate
)
#else
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: self,
userDriverDelegate: userDriverDelegate
)
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: self,
userDriverDelegate: userDriverDelegate
)
#endif
// Configure automatic updates
if let updater = updaterController?.updater {
#if DEBUG
// Enable automatic checks in debug too
updater.automaticallyChecksForUpdates = true
updater.automaticallyDownloadsUpdates = false
logger.info("Sparkle updater initialized in DEBUG mode - automatic updates enabled for testing")
// Enable automatic checks in debug too
updater.automaticallyChecksForUpdates = true
updater.automaticallyDownloadsUpdates = false
logger.info("Sparkle updater initialized in DEBUG mode - automatic updates enabled for testing")
#else
// Enable automatic checking for updates
updater.automaticallyChecksForUpdates = true
// Enable automatic checking for updates
updater.automaticallyChecksForUpdates = true
// Enable automatic downloading of updates
updater.automaticallyDownloadsUpdates = true
// Enable automatic downloading of updates
updater.automaticallyDownloadsUpdates = true
// Set update check interval to 24 hours
updater.updateCheckInterval = 86_400
// Set update check interval to 24 hours
updater.updateCheckInterval = 86_400
logger.info("Sparkle updater initialized successfully with automatic downloads enabled")
logger.info("Sparkle updater initialized successfully with automatic downloads enabled")
#endif
// Start the updater for both debug and release builds

View file

@ -39,7 +39,7 @@ final class SparkleUserDriverDelegate: NSObject, @preconcurrency SPUStandardUser
_ update: SUAppcastItem,
andInImmediateFocus immediateFocus: Bool
)
-> Bool
-> Bool
{
logger.info("Should handle showing update: \(update.displayVersionString), immediate: \(immediateFocus)")

View file

@ -134,7 +134,7 @@ actor TerminalManager {
seconds: TimeInterval,
operation: @escaping @Sendable () async throws -> T
)
async throws -> T
async throws -> T
{
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {

View file

@ -150,7 +150,7 @@ final class WindowTracker {
tabReference: String?,
tabID: String?
)
-> WindowInfo?
-> WindowInfo?
{
let allWindows = Self.getAllTerminalWindows()

View file

@ -32,7 +32,7 @@ extension View {
error: Binding<Error?>,
onDismiss: (() -> Void)? = nil
)
-> some View
-> some View
{
modifier(ErrorAlertModifier(error: error, title: title, onDismiss: onDismiss))
}
@ -49,7 +49,7 @@ extension Task where Failure == Error {
errorBinding: Binding<Error?>,
operation: @escaping () async throws -> T
)
-> Task<T, Error>
-> Task<T, Error>
{
Task<T, Error>(priority: priority) {
do {

View file

@ -12,7 +12,7 @@ extension View {
horizontal: CGFloat = 16,
vertical: CGFloat = 14
)
-> some View
-> some View
{
self
.padding(.horizontal, horizontal)

View file

@ -54,7 +54,7 @@ struct MenuBarView: View {
// Show Tutorial
Button(action: {
#if !SWIFT_PACKAGE
AppDelegate.showWelcomeScreen()
AppDelegate.showWelcomeScreen()
#endif
}, label: {
HStack {

View file

@ -327,7 +327,7 @@ private struct DeveloperToolsSection: View {
Spacer()
Button("Show Welcome") {
#if !SWIFT_PACKAGE
AppDelegate.showWelcomeScreen()
AppDelegate.showWelcomeScreen()
#endif
}
.buttonStyle(.bordered)

View file

@ -1,43 +1,35 @@
import SwiftUI
/// Fourth page explaining dashboard security and access protection.
/// Fourth page explaining dashboard security and authentication.
///
/// 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.
/// This view explains how the dashboard is protected using the system's
/// built-in authentication. Users don't need to set up a password as
/// authentication uses their macOS username and password by default.
///
/// ## 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
/// - Explanation of OS-based authentication
/// - Information about SSH key authentication option
/// - Link to settings for authentication configuration
///
/// ### 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
/// - Uses macOS system authentication (PAM) by default
/// - SSH key authentication available as an alternative
/// - No separate password setup required
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
@State private var showingSettings = false
var body: some View {
VStack(spacing: 30) {
VStack(spacing: 16) {
Text("Protect Your Dashboard")
Text("Dashboard Security")
.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."
"Your dashboard is protected using your macOS username and password.\nNo additional setup is required."
)
.font(.body)
.foregroundColor(.secondary)
@ -45,51 +37,61 @@ struct ProtectDashboardPageView: View {
.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
}
}
// Authentication info
VStack(spacing: 20) {
// Security icon and explanation
HStack(spacing: 12) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 48))
.foregroundColor(.accentColor)
.symbolRenderingMode(.hierarchical)
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")
VStack(alignment: .leading, spacing: 8) {
Text("Secure by Default")
.font(.headline)
Text("Access requires your macOS credentials")
.font(.body)
.foregroundColor(.secondary)
}
.font(.caption)
} else {
Button("Set Password") {
setPassword()
}
.buttonStyle(.bordered)
.disabled(password.isEmpty)
}
.frame(maxWidth: 400)
Text("Leave empty to skip password protection")
// Authentication methods
VStack(alignment: .leading, spacing: 12) {
Label {
VStack(alignment: .leading, spacing: 4) {
Text("macOS Authentication")
.font(.callout)
.fontWeight(.medium)
Text("Uses your system username and password")
.font(.caption)
.foregroundColor(.secondary)
}
} icon: {
Image(systemName: "person.badge.shield.checkmark.fill")
.foregroundColor(.green)
}
Label {
VStack(alignment: .leading, spacing: 4) {
Text("SSH Key Authentication")
.font(.callout)
.fontWeight(.medium)
Text("Available as an alternative in Settings")
.font(.caption)
.foregroundColor(.secondary)
}
} icon: {
Image(systemName: "key.fill")
.foregroundColor(.blue)
}
}
.padding()
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
.frame(maxWidth: 400)
Text("You can configure authentication methods later in Settings")
.font(.caption)
.foregroundColor(.secondary)
}
@ -98,42 +100,6 @@ struct ProtectDashboardPageView: View {
}
.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

View file

@ -172,8 +172,8 @@ final class ApplicationMover {
logger.debug("ApplicationMover: hdiutil returned \(data.count) bytes")
guard let plist = try PropertyListSerialization
.propertyList(from: data, options: [], format: nil) as? [String: Any],
let images = plist["images"] as? [[String: Any]]
.propertyList(from: data, options: [], format: nil) as? [String: Any],
let images = plist["images"] as? [[String: Any]]
else {
logger.debug("ApplicationMover: No disk images found in hdiutil output")
return nil

View file

@ -383,7 +383,7 @@ final class TerminalLauncher {
var runningTerminals: [Terminal] = []
for terminal in Terminal.allCases
where runningApps.contains(where: { $0.bundleIdentifier == terminal.bundleIdentifier })
where runningApps.contains(where: { $0.bundleIdentifier == terminal.bundleIdentifier })
{
runningTerminals.append(terminal)
logger.debug("Detected running terminal: \(terminal.rawValue)")
@ -416,7 +416,7 @@ final class TerminalLauncher {
_ config: TerminalLaunchConfig,
sessionId: String? = nil
)
throws -> TerminalLaunchResult
throws -> TerminalLaunchResult
{
logger.debug("Launch config - command: \(config.command)")
logger.debug("Launch config - fullCommand: \(config.fullCommand)")
@ -519,7 +519,7 @@ final class TerminalLauncher {
if process.terminationStatus != 0 {
throw TerminalLauncherError
.processLaunchFailed("Process exited with status \(process.terminationStatus)")
.processLaunchFailed("Process exited with status \(process.terminationStatus)")
}
} catch {
logger.error("Failed to launch terminal: \(error.localizedDescription)")
@ -676,7 +676,7 @@ final class TerminalLauncher {
sessionId: String,
vibetunnelPath: String? = nil
)
throws
throws
{
// Expand tilde in working directory path
let expandedWorkingDir = (workingDirectory as NSString).expandingTildeInPath
@ -801,7 +801,7 @@ final class TerminalLauncher {
workingDir: String,
sessionId: String? = nil
)
-> String
-> String
{
// Bun executable has fwd command built-in
logger.info("Using Bun executable for session creation")

View file

@ -21,81 +21,81 @@ struct VibeTunnelApp: App {
var body: some Scene {
#if os(macOS)
// Hidden WindowGroup to make Settings work in MenuBarExtra-only apps
// This is a workaround for FB10184971
WindowGroup("HiddenWindow") {
HiddenWindowView()
}
.windowResizability(.contentSize)
.defaultSize(width: 1, height: 1)
.windowStyle(.hiddenTitleBar)
// Hidden WindowGroup to make Settings work in MenuBarExtra-only apps
// This is a workaround for FB10184971
WindowGroup("HiddenWindow") {
HiddenWindowView()
}
.windowResizability(.contentSize)
.defaultSize(width: 1, height: 1)
.windowStyle(.hiddenTitleBar)
// Welcome Window
WindowGroup("Welcome", id: "welcome") {
WelcomeView()
.environment(sessionMonitor)
.environment(serverManager)
.environment(ngrokService)
.environment(permissionManager)
.environment(terminalLauncher)
}
.windowResizability(.contentSize)
.defaultSize(width: 580, height: 480)
.windowStyle(.hiddenTitleBar)
// Session Detail Window
WindowGroup("Session Details", id: "session-detail", for: String.self) { $sessionId in
if let sessionId,
let session = sessionMonitor.sessions[sessionId]
{
SessionDetailView(session: session)
// Welcome Window
WindowGroup("Welcome", id: "welcome") {
WelcomeView()
.environment(sessionMonitor)
.environment(serverManager)
.environment(ngrokService)
.environment(permissionManager)
.environment(terminalLauncher)
} else {
Text("Session not found")
.frame(width: 400, height: 300)
}
}
.windowResizability(.contentSize)
.windowResizability(.contentSize)
.defaultSize(width: 580, height: 480)
.windowStyle(.hiddenTitleBar)
Settings {
SettingsView()
.environment(sessionMonitor)
.environment(serverManager)
.environment(ngrokService)
.environment(permissionManager)
.environment(terminalLauncher)
}
.commands {
CommandGroup(after: .appInfo) {
Button("About VibeTunnel") {
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
)
// Session Detail Window
WindowGroup("Session Details", id: "session-detail", for: String.self) { $sessionId in
if let sessionId,
let session = sessionMonitor.sessions[sessionId]
{
SessionDetailView(session: session)
.environment(sessionMonitor)
.environment(serverManager)
.environment(ngrokService)
.environment(permissionManager)
.environment(terminalLauncher)
} else {
Text("Session not found")
.frame(width: 400, height: 300)
}
}
.windowResizability(.contentSize)
Settings {
SettingsView()
.environment(sessionMonitor)
.environment(serverManager)
.environment(ngrokService)
.environment(permissionManager)
.environment(terminalLauncher)
}
.commands {
CommandGroup(after: .appInfo) {
Button("About VibeTunnel") {
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
)
}
}
}
}
}
MenuBarExtra {
MenuBarView()
.environment(sessionMonitor)
.environment(serverManager)
.environment(ngrokService)
.environment(permissionManager)
.environment(terminalLauncher)
} label: {
Image("menubar")
.renderingMode(.template)
}
MenuBarExtra {
MenuBarView()
.environment(sessionMonitor)
.environment(serverManager)
.environment(ngrokService)
.environment(permissionManager)
.environment(terminalLauncher)
} label: {
Image("menubar")
.renderingMode(.template)
}
#endif
}
}
@ -121,25 +121,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
NSClassFromString("XCTestCase") != nil
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
#if DEBUG
let isRunningInDebug = true
let isRunningInDebug = true
#else
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
.contains("libMainThreadChecker.dylib") ?? false ||
processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] != nil
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
.contains("libMainThreadChecker.dylib") ?? false ||
processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] != nil
#endif
// Handle single instance check before doing anything else
#if DEBUG
// Skip single instance check in debug builds
#else
if !isRunningInPreview && !isRunningInTests && !isRunningInDebug {
handleSingleInstanceCheck()
registerForDistributedNotifications()
if !isRunningInPreview && !isRunningInTests && !isRunningInDebug {
handleSingleInstanceCheck()
registerForDistributedNotifications()
// Check if app needs to be moved to Applications folder
let applicationMover = ApplicationMover()
applicationMover.checkAndOfferToMoveToApplications()
}
// Check if app needs to be moved to Applications folder
let applicationMover = ApplicationMover()
applicationMover.checkAndOfferToMoveToApplications()
}
#endif
// Initialize Sparkle updater manager
@ -340,11 +340,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
NSClassFromString("XCTestCase") != nil
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
#if DEBUG
let isRunningInDebug = true
let isRunningInDebug = true
#else
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
.contains("libMainThreadChecker.dylib") ?? false ||
processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] != nil
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
.contains("libMainThreadChecker.dylib") ?? false ||
processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] != nil
#endif
// Skip cleanup during tests
@ -374,13 +374,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
#if DEBUG
// Skip removing observer in debug builds
#else
if !isRunningInPreview && !isRunningInTests && !isRunningInDebug {
DistributedNotificationCenter.default().removeObserver(
self,
name: Self.showSettingsNotification,
object: nil
)
}
if !isRunningInPreview && !isRunningInTests && !isRunningInDebug {
DistributedNotificationCenter.default().removeObserver(
self,
name: Self.showSettingsNotification,
object: nil
)
}
#endif
// Remove update check notification observer