mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Rework Settings to give Notifications its own Tab
This commit is contained in:
parent
8945dd5d66
commit
414a2160e7
10 changed files with 573 additions and 133 deletions
|
|
@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue