Rework Settings to give Notifications its own Tab

This commit is contained in:
Peter Steinberger 2025-07-28 13:43:44 +02:00
parent 8945dd5d66
commit 414a2160e7
10 changed files with 573 additions and 133 deletions

View file

@ -47,6 +47,7 @@ final class ConfigManager {
var notificationClaudeTurn: Bool = false var notificationClaudeTurn: Bool = false
var notificationSoundEnabled: Bool = true var notificationSoundEnabled: Bool = true
var notificationVibrationEnabled: Bool = true var notificationVibrationEnabled: Bool = true
var showInNotificationCenter: Bool = true
// Remote access // Remote access
var ngrokEnabled: Bool = false var ngrokEnabled: Bool = false
@ -107,6 +108,7 @@ final class ConfigManager {
var claudeTurn: Bool var claudeTurn: Bool
var soundEnabled: Bool var soundEnabled: Bool
var vibrationEnabled: Bool var vibrationEnabled: Bool
var showInNotificationCenter: Bool?
} }
private struct RemoteAccessConfig: Codable { private struct RemoteAccessConfig: Codable {
@ -193,6 +195,9 @@ final class ConfigManager {
self.notificationClaudeTurn = notif.claudeTurn self.notificationClaudeTurn = notif.claudeTurn
self.notificationSoundEnabled = notif.soundEnabled self.notificationSoundEnabled = notif.soundEnabled
self.notificationVibrationEnabled = notif.vibrationEnabled self.notificationVibrationEnabled = notif.vibrationEnabled
if let showInCenter = notif.showInNotificationCenter {
self.showInNotificationCenter = showInCenter
}
} }
} }
@ -236,6 +241,7 @@ final class ConfigManager {
self.notificationClaudeTurn = false self.notificationClaudeTurn = false
self.notificationSoundEnabled = true self.notificationSoundEnabled = true
self.notificationVibrationEnabled = true self.notificationVibrationEnabled = true
self.showInNotificationCenter = true
saveConfiguration() saveConfiguration()
} }
@ -281,7 +287,8 @@ final class ConfigManager {
bell: notificationBell, bell: notificationBell,
claudeTurn: notificationClaudeTurn, claudeTurn: notificationClaudeTurn,
soundEnabled: notificationSoundEnabled, soundEnabled: notificationSoundEnabled,
vibrationEnabled: notificationVibrationEnabled vibrationEnabled: notificationVibrationEnabled,
showInNotificationCenter: showInNotificationCenter
) )
) )

View file

@ -1,5 +1,6 @@
import AppKit import AppKit
import Foundation import Foundation
import Observation
import os.log import os.log
@preconcurrency import UserNotifications @preconcurrency import UserNotifications
@ -8,6 +9,7 @@ import os.log
/// Connects to the VibeTunnel server to receive real-time events like session starts, /// Connects to the VibeTunnel server to receive real-time events like session starts,
/// command completions, and errors, then displays them as native macOS notifications. /// command completions, and errors, then displays them as native macOS notifications.
@MainActor @MainActor
@Observable
final class NotificationService: NSObject { final class NotificationService: NSObject {
@MainActor @MainActor
static let shared = NotificationService() static let shared = NotificationService()
@ -730,9 +732,7 @@ final class NotificationService: NSObject {
deinit { deinit {
// Note: We can't call disconnect() here because it's @MainActor isolated // Note: We can't call disconnect() here because it's @MainActor isolated
// The cleanup will happen when the EventSource is deallocated // The cleanup will happen when the EventSource is deallocated
eventSource?.disconnect() // NotificationCenter observers are automatically removed on deinit in modern Swift
eventSource = nil
NotificationCenter.default.removeObserver(self)
} }
} }

View file

@ -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()
}

View file

@ -6,8 +6,6 @@ import SwiftUI
struct GeneralSettingsView: View { struct GeneralSettingsView: View {
@AppStorage("autostart") @AppStorage("autostart")
private var autostart = false private var autostart = false
@AppStorage("showNotifications")
private var showNotifications = true
@AppStorage(AppConstants.UserDefaultsKeys.updateChannel) @AppStorage(AppConstants.UserDefaultsKeys.updateChannel)
private var updateChannelRaw = UpdateChannel.stable.rawValue private var updateChannelRaw = UpdateChannel.stable.rawValue
@AppStorage(AppConstants.UserDefaultsKeys.showInDock) @AppStorage(AppConstants.UserDefaultsKeys.showInDock)
@ -16,8 +14,10 @@ struct GeneralSettingsView: View {
private var preventSleepWhenRunning = true private var preventSleepWhenRunning = true
@Environment(ConfigManager.self) private var configManager @Environment(ConfigManager.self) private var configManager
@Environment(SystemPermissionManager.self) private var permissionManager
@State private var isCheckingForUpdates = false @State private var isCheckingForUpdates = false
@State private var permissionUpdateTrigger = 0
private let startupManager = StartupManager() private let startupManager = StartupManager()
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "GeneralSettings") private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "GeneralSettings")
@ -26,10 +26,20 @@ struct GeneralSettingsView: View {
UpdateChannel(rawValue: updateChannelRaw) ?? .stable UpdateChannel(rawValue: updateChannelRaw) ?? .stable
} }
private func updateNotificationPreferences() { // MARK: - Helper Properties
// Load current preferences from ConfigManager and notify the service
let prefs = NotificationService.NotificationPreferences(fromConfig: configManager) // IMPORTANT: These computed properties ensure the UI always shows current permission state.
NotificationService.shared.updatePreferences(prefs) // 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 { 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 // Prevent Sleep
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Toggle("Prevent Sleep When Running", isOn: $preventSleepWhenRunning) Toggle("Prevent Sleep When Running", isOn: $preventSleepWhenRunning)
@ -185,6 +87,13 @@ struct GeneralSettingsView: View {
Text("Application") Text("Application")
.font(.headline) .font(.headline)
} }
// System Permissions section (moved from Security)
PermissionsSection(
hasAppleScriptPermission: hasAppleScriptPermission,
hasAccessibilityPermission: hasAccessibilityPermission,
permissionManager: permissionManager
)
} }
.formStyle(.grouped) .formStyle(.grouped)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
@ -193,6 +102,19 @@ struct GeneralSettingsView: View {
.task { .task {
// Sync launch at login status // Sync launch at login status
autostart = startupManager.isLaunchAtLoginEnabled 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 {
} }
} }
} }

View file

@ -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)
}

View file

@ -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()
}

View file

@ -12,6 +12,10 @@ struct RemoteAccessSettingsView: View {
private var serverPort = "4020" private var serverPort = "4020"
@AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode) @AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode)
private var accessModeString = AppConstants.Defaults.dashboardAccessMode private var accessModeString = AppConstants.Defaults.dashboardAccessMode
@AppStorage(AppConstants.UserDefaultsKeys.authenticationMode)
private var authModeString = "os"
@State private var authMode: AuthenticationMode = .osAuth
@Environment(NgrokService.self) @Environment(NgrokService.self)
private var ngrokService private var ngrokService
@ -43,6 +47,14 @@ struct RemoteAccessSettingsView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
// Authentication section (moved from Security)
AuthenticationSection(
authMode: $authMode,
enableSSHKeys: .constant(authMode == .sshKeys || authMode == .both),
logger: logger,
serverManager: serverManager
)
TailscaleIntegrationSection( TailscaleIntegrationSection(
tailscaleService: tailscaleService, tailscaleService: tailscaleService,
serverPort: serverPort, serverPort: serverPort,
@ -78,6 +90,9 @@ struct RemoteAccessSettingsView: View {
.onAppear { .onAppear {
onAppearSetup() onAppearSetup()
updateLocalIPAddress() 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) { .alert("ngrok Authentication Required", isPresented: $showingAuthTokenAlert) {

View file

@ -6,10 +6,10 @@ import Foundation
/// with associated display names and SF Symbol icons for the tab bar. /// with associated display names and SF Symbol icons for the tab bar.
enum SettingsTab: String, CaseIterable { enum SettingsTab: String, CaseIterable {
case general case general
case notifications
case quickStart case quickStart
case dashboard case dashboard
case remoteAccess case remoteAccess
case securityPermissions
case advanced case advanced
case debug case debug
case about case about
@ -17,10 +17,10 @@ enum SettingsTab: String, CaseIterable {
var displayName: String { var displayName: String {
switch self { switch self {
case .general: "General" case .general: "General"
case .notifications: "Notifications"
case .quickStart: "Quick Start" case .quickStart: "Quick Start"
case .dashboard: "Dashboard" case .dashboard: "Dashboard"
case .remoteAccess: "Remote" case .remoteAccess: "Remote"
case .securityPermissions: "Security"
case .advanced: "Advanced" case .advanced: "Advanced"
case .debug: "Debug" case .debug: "Debug"
case .about: "About" case .about: "About"
@ -30,10 +30,10 @@ enum SettingsTab: String, CaseIterable {
var icon: String { var icon: String {
switch self { switch self {
case .general: "gear" case .general: "gear"
case .notifications: "bell.badge"
case .quickStart: "bolt.fill" case .quickStart: "bolt.fill"
case .dashboard: "server.rack" case .dashboard: "server.rack"
case .remoteAccess: "network" case .remoteAccess: "network"
case .securityPermissions: "lock.shield"
case .advanced: "gearshape.2" case .advanced: "gearshape.2"
case .debug: "hammer" case .debug: "hammer"
case .about: "info.circle" case .about: "info.circle"

View file

@ -14,17 +14,17 @@ struct SettingsView: View {
// MARK: - Constants // MARK: - Constants
private enum Layout { private enum Layout {
static let defaultTabSize = CGSize(width: 520, height: 710) static let defaultTabSize = CGSize(width: 550, height: 710)
static let fallbackTabSize = CGSize(width: 520, height: 450) static let fallbackTabSize = CGSize(width: 550, height: 450)
} }
/// Define ideal sizes for each tab /// Define ideal sizes for each tab
private let tabSizes: [SettingsTab: CGSize] = [ private let tabSizes: [SettingsTab: CGSize] = [
.general: Layout.defaultTabSize, .general: Layout.defaultTabSize,
.notifications: Layout.defaultTabSize,
.quickStart: Layout.defaultTabSize, .quickStart: Layout.defaultTabSize,
.dashboard: Layout.defaultTabSize, .dashboard: Layout.defaultTabSize,
.remoteAccess: Layout.defaultTabSize, .remoteAccess: Layout.defaultTabSize,
.securityPermissions: Layout.defaultTabSize,
.advanced: Layout.defaultTabSize, .advanced: Layout.defaultTabSize,
.debug: Layout.defaultTabSize, .debug: Layout.defaultTabSize,
.about: Layout.defaultTabSize .about: Layout.defaultTabSize
@ -38,6 +38,12 @@ struct SettingsView: View {
} }
.tag(SettingsTab.general) .tag(SettingsTab.general)
NotificationSettingsView()
.tabItem {
Label(SettingsTab.notifications.displayName, systemImage: SettingsTab.notifications.icon)
}
.tag(SettingsTab.notifications)
QuickStartSettingsView() QuickStartSettingsView()
.tabItem { .tabItem {
Label(SettingsTab.quickStart.displayName, systemImage: SettingsTab.quickStart.icon) Label(SettingsTab.quickStart.displayName, systemImage: SettingsTab.quickStart.icon)
@ -56,15 +62,6 @@ struct SettingsView: View {
} }
.tag(SettingsTab.remoteAccess) .tag(SettingsTab.remoteAccess)
SecurityPermissionsSettingsView()
.tabItem {
Label(
SettingsTab.securityPermissions.displayName,
systemImage: SettingsTab.securityPermissions.icon
)
}
.tag(SettingsTab.securityPermissions)
AdvancedSettingsView() AdvancedSettingsView()
.tabItem { .tabItem {
Label(SettingsTab.advanced.displayName, systemImage: SettingsTab.advanced.icon) Label(SettingsTab.advanced.displayName, systemImage: SettingsTab.advanced.icon)

View file

@ -27,6 +27,7 @@ struct VibeTunnelApp: App {
@State var sessionService: SessionService? @State var sessionService: SessionService?
@State var worktreeService = WorktreeService(serverManager: ServerManager.shared) @State var worktreeService = WorktreeService(serverManager: ServerManager.shared)
@State var configManager = ConfigManager.shared @State var configManager = ConfigManager.shared
@State var notificationService = NotificationService.shared
init() { init() {
// Connect the app delegate to this app instance // Connect the app delegate to this app instance
@ -57,6 +58,7 @@ struct VibeTunnelApp: App {
.environment(repositoryDiscoveryService) .environment(repositoryDiscoveryService)
.environment(configManager) .environment(configManager)
.environment(worktreeService) .environment(worktreeService)
.environment(notificationService)
} }
.windowResizability(.contentSize) .windowResizability(.contentSize)
.defaultSize(width: 580, height: 480) .defaultSize(width: 580, height: 480)
@ -83,6 +85,7 @@ struct VibeTunnelApp: App {
sessionMonitor: sessionMonitor sessionMonitor: sessionMonitor
)) ))
.environment(worktreeService) .environment(worktreeService)
.environment(notificationService)
} else { } else {
Text("Session not found") Text("Session not found")
.frame(width: 400, height: 300) .frame(width: 400, height: 300)
@ -109,6 +112,7 @@ struct VibeTunnelApp: App {
sessionMonitor: sessionMonitor sessionMonitor: sessionMonitor
)) ))
.environment(worktreeService) .environment(worktreeService)
.environment(notificationService)
} }
.commands { .commands {
CommandGroup(after: .appInfo) { CommandGroup(after: .appInfo) {