mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +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 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
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
@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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
@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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue