From 414a2160e7ae8f651c6bb4953f24f16238fae2db Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Jul 2025 13:43:44 +0200 Subject: [PATCH] Rework Settings to give Notifications its own Tab --- .../Core/Services/ConfigManager.swift | 9 +- .../Core/Services/NotificationService.swift | 6 +- .../Settings/AuthenticationSection.swift | 122 +++++++++ .../Views/Settings/GeneralSettingsView.swift | 151 +++-------- .../Settings/NotificationSettingsView.swift | 241 ++++++++++++++++++ .../Views/Settings/PermissionsSection.swift | 131 ++++++++++ .../Settings/RemoteAccessSettingsView.swift | 15 ++ .../Views/Settings/SettingsTab.swift | 6 +- .../Presentation/Views/SettingsView.swift | 21 +- mac/VibeTunnel/VibeTunnelApp.swift | 4 + 10 files changed, 573 insertions(+), 133 deletions(-) create mode 100644 mac/VibeTunnel/Presentation/Views/Settings/AuthenticationSection.swift create mode 100644 mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift create mode 100644 mac/VibeTunnel/Presentation/Views/Settings/PermissionsSection.swift diff --git a/mac/VibeTunnel/Core/Services/ConfigManager.swift b/mac/VibeTunnel/Core/Services/ConfigManager.swift index 1e6c321f..de6e5458 100644 --- a/mac/VibeTunnel/Core/Services/ConfigManager.swift +++ b/mac/VibeTunnel/Core/Services/ConfigManager.swift @@ -47,6 +47,7 @@ final class ConfigManager { var notificationClaudeTurn: Bool = false var notificationSoundEnabled: Bool = true var notificationVibrationEnabled: Bool = true + var showInNotificationCenter: Bool = true // Remote access var ngrokEnabled: Bool = false @@ -107,6 +108,7 @@ final class ConfigManager { var claudeTurn: Bool var soundEnabled: Bool var vibrationEnabled: Bool + var showInNotificationCenter: Bool? } private struct RemoteAccessConfig: Codable { @@ -193,6 +195,9 @@ final class ConfigManager { self.notificationClaudeTurn = notif.claudeTurn self.notificationSoundEnabled = notif.soundEnabled self.notificationVibrationEnabled = notif.vibrationEnabled + if let showInCenter = notif.showInNotificationCenter { + self.showInNotificationCenter = showInCenter + } } } @@ -236,6 +241,7 @@ final class ConfigManager { self.notificationClaudeTurn = false self.notificationSoundEnabled = true self.notificationVibrationEnabled = true + self.showInNotificationCenter = true saveConfiguration() } @@ -281,7 +287,8 @@ final class ConfigManager { bell: notificationBell, claudeTurn: notificationClaudeTurn, soundEnabled: notificationSoundEnabled, - vibrationEnabled: notificationVibrationEnabled + vibrationEnabled: notificationVibrationEnabled, + showInNotificationCenter: showInNotificationCenter ) ) diff --git a/mac/VibeTunnel/Core/Services/NotificationService.swift b/mac/VibeTunnel/Core/Services/NotificationService.swift index b8b1e56d..76e3559b 100644 --- a/mac/VibeTunnel/Core/Services/NotificationService.swift +++ b/mac/VibeTunnel/Core/Services/NotificationService.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import Observation import os.log @preconcurrency import UserNotifications @@ -8,6 +9,7 @@ import os.log /// Connects to the VibeTunnel server to receive real-time events like session starts, /// command completions, and errors, then displays them as native macOS notifications. @MainActor +@Observable final class NotificationService: NSObject { @MainActor static let shared = NotificationService() @@ -730,9 +732,7 @@ final class NotificationService: NSObject { deinit { // Note: We can't call disconnect() here because it's @MainActor isolated // The cleanup will happen when the EventSource is deallocated - eventSource?.disconnect() - eventSource = nil - NotificationCenter.default.removeObserver(self) + // NotificationCenter observers are automatically removed on deinit in modern Swift } } diff --git a/mac/VibeTunnel/Presentation/Views/Settings/AuthenticationSection.swift b/mac/VibeTunnel/Presentation/Views/Settings/AuthenticationSection.swift new file mode 100644 index 00000000..9f188fcb --- /dev/null +++ b/mac/VibeTunnel/Presentation/Views/Settings/AuthenticationSection.swift @@ -0,0 +1,122 @@ +import SwiftUI +import AppKit +import os.log + +/// Authentication configuration section for remote access settings +struct AuthenticationSection: View { + @Binding var authMode: AuthenticationMode + @Binding var enableSSHKeys: Bool + let logger: Logger + let serverManager: ServerManager + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 16) { + // Authentication mode picker + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Authentication Method") + .font(.callout) + Spacer() + Picker("", selection: $authMode) { + ForEach(AuthenticationMode.allCases, id: \.self) { mode in + Text(mode.displayName) + .tag(mode) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(alignment: .trailing) + .onChange(of: authMode) { _, newValue in + // Save the authentication mode + UserDefaults.standard.set( + newValue.rawValue, + forKey: AppConstants.UserDefaultsKeys.authenticationMode + ) + Task { + logger.info("Authentication mode changed to: \(newValue.rawValue)") + await serverManager.restart() + } + } + } + Text(authMode.description) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // Additional info based on selected mode + if authMode == .osAuth || authMode == .both { + HStack(alignment: .center, spacing: 6) { + Image(systemName: "info.circle") + .foregroundColor(.blue) + .font(.system(size: 12)) + .frame(width: 16, height: 16) + Text("Uses your macOS username: \(NSUserName())") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + } + + if authMode == .sshKeys || authMode == .both { + HStack(alignment: .center, spacing: 6) { + Image(systemName: "key.fill") + .foregroundColor(.blue) + .font(.system(size: 12)) + .frame(width: 16, height: 16) + Text("SSH keys from ~/.ssh/authorized_keys") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Open folder") { + let sshPath = NSHomeDirectory() + "/.ssh" + if FileManager.default.fileExists(atPath: sshPath) { + NSWorkspace.shared.open(URL(fileURLWithPath: sshPath)) + } else { + // Create .ssh directory if it doesn't exist + try? FileManager.default.createDirectory( + atPath: sshPath, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + NSWorkspace.shared.open(URL(fileURLWithPath: sshPath)) + } + } + .buttonStyle(.link) + .font(.caption) + } + } + } + } header: { + Text("Authentication") + .font(.headline) + } footer: { + Text("Localhost connections are always accessible without authentication.") + .font(.caption) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + } +} + +#Preview { + struct PreviewWrapper: View { + @State var authMode = AuthenticationMode.osAuth + @State var enableSSHKeys = false + + var body: some View { + AuthenticationSection( + authMode: $authMode, + enableSSHKeys: $enableSSHKeys, + logger: Logger(subsystem: "preview", category: "auth"), + serverManager: ServerManager.shared + ) + .frame(width: 500) + .padding() + } + } + + return PreviewWrapper() +} \ No newline at end of file diff --git a/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift index a4f6f5d7..3e25b277 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift @@ -6,8 +6,6 @@ import SwiftUI struct GeneralSettingsView: View { @AppStorage("autostart") private var autostart = false - @AppStorage("showNotifications") - private var showNotifications = true @AppStorage(AppConstants.UserDefaultsKeys.updateChannel) private var updateChannelRaw = UpdateChannel.stable.rawValue @AppStorage(AppConstants.UserDefaultsKeys.showInDock) @@ -16,8 +14,10 @@ struct GeneralSettingsView: View { private var preventSleepWhenRunning = true @Environment(ConfigManager.self) private var configManager + @Environment(SystemPermissionManager.self) private var permissionManager @State private var isCheckingForUpdates = false + @State private var permissionUpdateTrigger = 0 private let startupManager = StartupManager() private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "GeneralSettings") @@ -26,10 +26,20 @@ struct GeneralSettingsView: View { UpdateChannel(rawValue: updateChannelRaw) ?? .stable } - private func updateNotificationPreferences() { - // Load current preferences from ConfigManager and notify the service - let prefs = NotificationService.NotificationPreferences(fromConfig: configManager) - NotificationService.shared.updatePreferences(prefs) + // MARK: - Helper Properties + + // IMPORTANT: These computed properties ensure the UI always shows current permission state. + // The permissionUpdateTrigger dependency forces SwiftUI to re-evaluate these properties + // when permissions change. Without this, the UI would not update when permissions are + // granted in System Settings while this view is visible. + private var hasAppleScriptPermission: Bool { + _ = permissionUpdateTrigger + return permissionManager.hasPermission(.appleScript) + } + + private var hasAccessibilityPermission: Bool { + _ = permissionUpdateTrigger + return permissionManager.hasPermission(.accessibility) } var body: some View { @@ -66,114 +76,6 @@ struct GeneralSettingsView: View { } } - // Show Session Notifications - VStack(alignment: .leading, spacing: 4) { - Toggle("Show Session Notifications", isOn: $showNotifications) - .onChange(of: showNotifications) { _, newValue in - // Ensure NotificationService starts/stops based on the toggle - if newValue { - Task { - // Request permissions and show test notification - let granted = await NotificationService.shared - .requestPermissionAndShowTestNotification() - - if granted { - await NotificationService.shared.start() - } else { - // If permission denied, turn toggle back off - await MainActor.run { - showNotifications = false - - // Show alert explaining the situation - let alert = NSAlert() - alert.messageText = "Notification Permission Required" - alert.informativeText = "VibeTunnel needs permission to show notifications. Please enable notifications for VibeTunnel in System Settings." - alert.alertStyle = .informational - alert.addButton(withTitle: "Open System Settings") - alert.addButton(withTitle: "Cancel") - - if alert.runModal() == .alertFirstButtonReturn { - // Settings will already be open from the service - } - } - } - } - } else { - NotificationService.shared.stop() - } - } - Text("Display native macOS notifications for session and command events.") - .font(.caption) - .foregroundStyle(.secondary) - - if showNotifications { - VStack(alignment: .leading, spacing: 6) { - Text("Notify me for:") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 20) - .padding(.top, 4) - - VStack(alignment: .leading, spacing: 4) { - Toggle("Session starts", isOn: Binding( - get: { configManager.notificationSessionStart }, - set: { newValue in - configManager.notificationSessionStart = newValue - updateNotificationPreferences() - } - )) - .toggleStyle(.checkbox) - - Toggle("Session ends", isOn: Binding( - get: { configManager.notificationSessionExit }, - set: { newValue in - configManager.notificationSessionExit = newValue - updateNotificationPreferences() - } - )) - .toggleStyle(.checkbox) - - Toggle("Commands complete (> 3 seconds)", isOn: Binding( - get: { configManager.notificationCommandCompletion }, - set: { newValue in - configManager.notificationCommandCompletion = newValue - updateNotificationPreferences() - } - )) - .toggleStyle(.checkbox) - - Toggle("Commands fail", isOn: Binding( - get: { configManager.notificationCommandError }, - set: { newValue in - configManager.notificationCommandError = newValue - updateNotificationPreferences() - } - )) - .toggleStyle(.checkbox) - - Toggle("Terminal bell (\u{0007})", isOn: Binding( - get: { configManager.notificationBell }, - set: { newValue in - configManager.notificationBell = newValue - updateNotificationPreferences() - } - )) - .toggleStyle(.checkbox) - - Toggle("Claude turn notifications", isOn: Binding( - get: { configManager.notificationClaudeTurn }, - set: { newValue in - configManager.notificationClaudeTurn = newValue - updateNotificationPreferences() - } - )) - .toggleStyle(.checkbox) - } - .padding(.leading, 20) - } - } - } - // Prevent Sleep VStack(alignment: .leading, spacing: 4) { Toggle("Prevent Sleep When Running", isOn: $preventSleepWhenRunning) @@ -185,6 +87,13 @@ struct GeneralSettingsView: View { Text("Application") .font(.headline) } + + // System Permissions section (moved from Security) + PermissionsSection( + hasAppleScriptPermission: hasAppleScriptPermission, + hasAccessibilityPermission: hasAccessibilityPermission, + permissionManager: permissionManager + ) } .formStyle(.grouped) .scrollContentBackground(.hidden) @@ -193,6 +102,19 @@ struct GeneralSettingsView: View { .task { // Sync launch at login status autostart = startupManager.isLaunchAtLoginEnabled + // Check permissions before first render to avoid UI flashing + await permissionManager.checkAllPermissions() + } + .onAppear { + // Register for continuous monitoring + permissionManager.registerForMonitoring() + } + .onDisappear { + permissionManager.unregisterFromMonitoring() + } + .onReceive(NotificationCenter.default.publisher(for: .permissionsUpdated)) { _ in + // Increment trigger to force computed property re-evaluation + permissionUpdateTrigger += 1 } } @@ -243,3 +165,4 @@ struct GeneralSettingsView: View { } } } + diff --git a/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift new file mode 100644 index 00000000..63bde4cd --- /dev/null +++ b/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift @@ -0,0 +1,241 @@ +import AppKit +import os.log +import SwiftUI + +private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "NotificationSettings") + +/// Settings view for managing notification preferences +struct NotificationSettingsView: View { + @AppStorage("showNotifications") + private var showNotifications = true + + @Environment(ConfigManager.self) private var configManager + @Environment(NotificationService.self) private var notificationService + + @State private var isTestingNotification = false + @State private var showingPermissionAlert = false + + private func updateNotificationPreferences() { + // Load current preferences from ConfigManager and notify the service + let prefs = NotificationService.NotificationPreferences(fromConfig: configManager) + notificationService.updatePreferences(prefs) + // Also update the enabled state in ConfigManager + configManager.notificationsEnabled = showNotifications + } + + var body: some View { + NavigationStack { + @Bindable var bindableConfig = configManager + + Form { + // Master toggle section + Section { + VStack(alignment: .leading, spacing: 12) { + Toggle("Show Session Notifications", isOn: $showNotifications) + .controlSize(.large) + .onChange(of: showNotifications) { _, newValue in + // Update ConfigManager's notificationsEnabled to match + configManager.notificationsEnabled = newValue + + // Ensure NotificationService starts/stops based on the toggle + if newValue { + Task { + // Request permissions and show test notification + let granted = await notificationService + .requestPermissionAndShowTestNotification() + + if granted { + await notificationService.start() + } else { + // If permission denied, turn toggle back off + await MainActor.run { + showNotifications = false + configManager.notificationsEnabled = false + showingPermissionAlert = true + } + } + } + } else { + notificationService.stop() + } + } + Text("Display native macOS notifications for session and command events") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 8) + } + + // Notification types section + if showNotifications { + Section { + NotificationToggleRow( + title: "Session starts", + description: "When a new session starts (useful for shared terminals)", + isOn: $bindableConfig.notificationSessionStart + ) + .onChange(of: bindableConfig.notificationSessionStart) { _, _ in + updateNotificationPreferences() + } + + NotificationToggleRow( + title: "Session ends", + description: "When a session terminates or crashes (shows exit code)", + isOn: $bindableConfig.notificationSessionExit + ) + .onChange(of: bindableConfig.notificationSessionExit) { _, _ in + updateNotificationPreferences() + } + + NotificationToggleRow( + title: "Commands fail", + description: "When commands fail with non-zero exit codes", + isOn: $bindableConfig.notificationCommandError + ) + .onChange(of: bindableConfig.notificationCommandError) { _, _ in + updateNotificationPreferences() + } + + NotificationToggleRow( + title: "Commands complete (> 3 seconds)", + description: "When commands taking >3 seconds finish (builds, tests, etc.)", + isOn: $bindableConfig.notificationCommandCompletion + ) + .onChange(of: bindableConfig.notificationCommandCompletion) { _, _ in + updateNotificationPreferences() + } + + NotificationToggleRow( + title: "Terminal bell (🔔)", + description: "Terminal bell (^G) from vim, IRC mentions, completion sounds", + isOn: $bindableConfig.notificationBell + ) + .onChange(of: bindableConfig.notificationBell) { _, _ in + updateNotificationPreferences() + } + + NotificationToggleRow( + title: "Claude turn notifications", + description: "When Claude AI finishes responding and awaits input", + isOn: $bindableConfig.notificationClaudeTurn + ) + .onChange(of: bindableConfig.notificationClaudeTurn) { _, _ in + updateNotificationPreferences() + } + } header: { + Text("Notification Types") + .font(.headline) + } + + // Behavior section + Section { + VStack(spacing: 12) { + Toggle("Play sound", isOn: $bindableConfig.notificationSoundEnabled) + .onChange(of: bindableConfig.notificationSoundEnabled) { _, _ in + updateNotificationPreferences() + } + + Toggle("Show in Notification Center", isOn: $bindableConfig.showInNotificationCenter) + .onChange(of: bindableConfig.showInNotificationCenter) { _, _ in + updateNotificationPreferences() + } + } + } header: { + Text("Notification Behavior") + .font(.headline) + } + + // Test section + Section { + VStack(alignment: .leading, spacing: 12) { + HStack { + Button("Test Notification") { + Task { + isTestingNotification = true + await notificationService.sendGenericNotification( + title: "VibeTunnel Test", + body: "This is a test notification to verify your settings are working correctly." + ) + // Reset button state after a delay + try? await Task.sleep(nanoseconds: 1_000_000_000) + isTestingNotification = false + } + } + .buttonStyle(.bordered) + .disabled(!showNotifications || isTestingNotification) + + if isTestingNotification { + ProgressView() + .scaleEffect(0.7) + .frame(width: 16, height: 16) + } + + Spacer() + } + + HStack { + Button("Open System Settings") { + notificationService.openNotificationSettings() + } + .buttonStyle(.link) + + Spacer() + } + } + } header: { + Text("Actions") + .font(.headline) + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .navigationTitle("Notification Settings") + .onAppear { + // Sync the AppStorage value with ConfigManager on first load + showNotifications = configManager.notificationsEnabled + } + } + .alert("Notification Permission Required", isPresented: $showingPermissionAlert) { + Button("Open System Settings") { + notificationService.openNotificationSettings() + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + "VibeTunnel needs permission to show notifications. Please enable notifications for VibeTunnel in System Settings." + ) + } + } +} + +/// Reusable component for notification toggle rows with descriptions +struct NotificationToggleRow: View { + let title: String + let description: String + @Binding var isOn: Bool + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.body) + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + Toggle("", isOn: $isOn) + .labelsHidden() + } + .padding(.vertical, 6) + } +} + +#Preview { + NotificationSettingsView() + .environment(ConfigManager.shared) + .environment(NotificationService.shared) + .frame(width: 560, height: 700) +} diff --git a/mac/VibeTunnel/Presentation/Views/Settings/PermissionsSection.swift b/mac/VibeTunnel/Presentation/Views/Settings/PermissionsSection.swift new file mode 100644 index 00000000..213b9f1b --- /dev/null +++ b/mac/VibeTunnel/Presentation/Views/Settings/PermissionsSection.swift @@ -0,0 +1,131 @@ +import SwiftUI + +/// System permissions configuration section +struct PermissionsSection: View { + let hasAppleScriptPermission: Bool + let hasAccessibilityPermission: Bool + let permissionManager: SystemPermissionManager + + var body: some View { + Section { + // Automation permission + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Terminal Automation") + .font(.body) + Text("Required to launch and control terminal applications.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if hasAppleScriptPermission { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Granted") + .foregroundColor(.secondary) + } + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 2) + .frame(height: 22) // Match small button height + .contextMenu { + Button("Refresh Status") { + permissionManager.forcePermissionRecheck() + } + Button("Open System Settings...") { + permissionManager.requestPermission(.appleScript) + } + } + } else { + Button("Grant Permission") { + permissionManager.requestPermission(.appleScript) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + // Accessibility permission + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Accessibility") + .font(.body) + Text("Required to enter terminal startup commands.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if hasAccessibilityPermission { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Granted") + .foregroundColor(.secondary) + } + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 2) + .frame(height: 22) // Match small button height + .contextMenu { + Button("Refresh Status") { + permissionManager.forcePermissionRecheck() + } + Button("Open System Settings...") { + permissionManager.requestPermission(.accessibility) + } + } + } else { + Button("Grant Permission") { + permissionManager.requestPermission(.accessibility) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } header: { + Text("System Permissions") + .font(.headline) + } footer: { + if hasAppleScriptPermission && hasAccessibilityPermission { + Text( + "All permissions granted. VibeTunnel has full functionality." + ) + .font(.caption) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .foregroundColor(.green) + } else { + Text( + "Terminals can be captured without permissions, however new sessions won't load." + ) + .font(.caption) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + } + } +} + +#Preview { + struct PreviewWrapper: View { + @State var hasAppleScript = true + @State var hasAccessibility = false + + var body: some View { + PermissionsSection( + hasAppleScriptPermission: hasAppleScript, + hasAccessibilityPermission: hasAccessibility, + permissionManager: SystemPermissionManager.shared + ) + .frame(width: 500) + .padding() + } + } + + return PreviewWrapper() +} \ No newline at end of file diff --git a/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift index 83469072..fce74070 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift @@ -12,6 +12,10 @@ struct RemoteAccessSettingsView: View { private var serverPort = "4020" @AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode) private var accessModeString = AppConstants.Defaults.dashboardAccessMode + @AppStorage(AppConstants.UserDefaultsKeys.authenticationMode) + private var authModeString = "os" + + @State private var authMode: AuthenticationMode = .osAuth @Environment(NgrokService.self) private var ngrokService @@ -43,6 +47,14 @@ struct RemoteAccessSettingsView: View { var body: some View { NavigationStack { Form { + // Authentication section (moved from Security) + AuthenticationSection( + authMode: $authMode, + enableSSHKeys: .constant(authMode == .sshKeys || authMode == .both), + logger: logger, + serverManager: serverManager + ) + TailscaleIntegrationSection( tailscaleService: tailscaleService, serverPort: serverPort, @@ -78,6 +90,9 @@ struct RemoteAccessSettingsView: View { .onAppear { onAppearSetup() updateLocalIPAddress() + // Initialize authentication mode from stored value + let storedMode = UserDefaults.standard.string(forKey: AppConstants.UserDefaultsKeys.authenticationMode) ?? "os" + authMode = AuthenticationMode(rawValue: storedMode) ?? .osAuth } } .alert("ngrok Authentication Required", isPresented: $showingAuthTokenAlert) { diff --git a/mac/VibeTunnel/Presentation/Views/Settings/SettingsTab.swift b/mac/VibeTunnel/Presentation/Views/Settings/SettingsTab.swift index 70257116..b3221df8 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/SettingsTab.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/SettingsTab.swift @@ -6,10 +6,10 @@ import Foundation /// with associated display names and SF Symbol icons for the tab bar. enum SettingsTab: String, CaseIterable { case general + case notifications case quickStart case dashboard case remoteAccess - case securityPermissions case advanced case debug case about @@ -17,10 +17,10 @@ enum SettingsTab: String, CaseIterable { var displayName: String { switch self { case .general: "General" + case .notifications: "Notifications" case .quickStart: "Quick Start" case .dashboard: "Dashboard" case .remoteAccess: "Remote" - case .securityPermissions: "Security" case .advanced: "Advanced" case .debug: "Debug" case .about: "About" @@ -30,10 +30,10 @@ enum SettingsTab: String, CaseIterable { var icon: String { switch self { case .general: "gear" + case .notifications: "bell.badge" case .quickStart: "bolt.fill" case .dashboard: "server.rack" case .remoteAccess: "network" - case .securityPermissions: "lock.shield" case .advanced: "gearshape.2" case .debug: "hammer" case .about: "info.circle" diff --git a/mac/VibeTunnel/Presentation/Views/SettingsView.swift b/mac/VibeTunnel/Presentation/Views/SettingsView.swift index d0f59937..2fdec8aa 100644 --- a/mac/VibeTunnel/Presentation/Views/SettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/SettingsView.swift @@ -14,17 +14,17 @@ struct SettingsView: View { // MARK: - Constants private enum Layout { - static let defaultTabSize = CGSize(width: 520, height: 710) - static let fallbackTabSize = CGSize(width: 520, height: 450) + static let defaultTabSize = CGSize(width: 550, height: 710) + static let fallbackTabSize = CGSize(width: 550, height: 450) } /// Define ideal sizes for each tab private let tabSizes: [SettingsTab: CGSize] = [ .general: Layout.defaultTabSize, + .notifications: Layout.defaultTabSize, .quickStart: Layout.defaultTabSize, .dashboard: Layout.defaultTabSize, .remoteAccess: Layout.defaultTabSize, - .securityPermissions: Layout.defaultTabSize, .advanced: Layout.defaultTabSize, .debug: Layout.defaultTabSize, .about: Layout.defaultTabSize @@ -38,6 +38,12 @@ struct SettingsView: View { } .tag(SettingsTab.general) + NotificationSettingsView() + .tabItem { + Label(SettingsTab.notifications.displayName, systemImage: SettingsTab.notifications.icon) + } + .tag(SettingsTab.notifications) + QuickStartSettingsView() .tabItem { Label(SettingsTab.quickStart.displayName, systemImage: SettingsTab.quickStart.icon) @@ -56,15 +62,6 @@ struct SettingsView: View { } .tag(SettingsTab.remoteAccess) - SecurityPermissionsSettingsView() - .tabItem { - Label( - SettingsTab.securityPermissions.displayName, - systemImage: SettingsTab.securityPermissions.icon - ) - } - .tag(SettingsTab.securityPermissions) - AdvancedSettingsView() .tabItem { Label(SettingsTab.advanced.displayName, systemImage: SettingsTab.advanced.icon) diff --git a/mac/VibeTunnel/VibeTunnelApp.swift b/mac/VibeTunnel/VibeTunnelApp.swift index 9d95ede9..983c9b70 100644 --- a/mac/VibeTunnel/VibeTunnelApp.swift +++ b/mac/VibeTunnel/VibeTunnelApp.swift @@ -27,6 +27,7 @@ struct VibeTunnelApp: App { @State var sessionService: SessionService? @State var worktreeService = WorktreeService(serverManager: ServerManager.shared) @State var configManager = ConfigManager.shared + @State var notificationService = NotificationService.shared init() { // Connect the app delegate to this app instance @@ -57,6 +58,7 @@ struct VibeTunnelApp: App { .environment(repositoryDiscoveryService) .environment(configManager) .environment(worktreeService) + .environment(notificationService) } .windowResizability(.contentSize) .defaultSize(width: 580, height: 480) @@ -83,6 +85,7 @@ struct VibeTunnelApp: App { sessionMonitor: sessionMonitor )) .environment(worktreeService) + .environment(notificationService) } else { Text("Session not found") .frame(width: 400, height: 300) @@ -109,6 +112,7 @@ struct VibeTunnelApp: App { sessionMonitor: sessionMonitor )) .environment(worktreeService) + .environment(notificationService) } .commands { CommandGroup(after: .appInfo) {