diff --git a/Sources/KeyPath/App.swift b/Sources/KeyPath/App.swift index 718d8fa34..30a75a4a3 100644 --- a/Sources/KeyPath/App.swift +++ b/Sources/KeyPath/App.swift @@ -31,6 +31,9 @@ public struct KeyPathApp: App { appDelegate.kanataManager = manager appDelegate.isHeadlessMode = isHeadlessMode + + // Request user notification authorization on first launch + UserNotificationService.shared.requestAuthorizationIfNeeded() } public var body: some Scene { diff --git a/Sources/KeyPath/Managers/KanataManager.swift b/Sources/KeyPath/Managers/KanataManager.swift index af22f44b7..3ae7461dc 100644 --- a/Sources/KeyPath/Managers/KanataManager.swift +++ b/Sources/KeyPath/Managers/KanataManager.swift @@ -145,10 +145,22 @@ class KanataManager: ObservableObject { // MARK: - UI State Properties (from SimpleKanataManager) /// Simple lifecycle state for UI display - @Published private(set) var currentState: SimpleKanataState = .starting + @Published private(set) var currentState: SimpleKanataState = .starting { + didSet { + if oldValue != currentState { + UserNotificationService.shared.notifyStatusChange(currentState) + } + } + } @Published private(set) var errorReason: String? @Published private(set) var showWizard: Bool = false - @Published private(set) var launchFailureStatus: LaunchFailureStatus? + @Published private(set) var launchFailureStatus: LaunchFailureStatus? { + didSet { + if let status = launchFailureStatus { + UserNotificationService.shared.notifyLaunchFailure(status) + } + } + } @Published private(set) var autoStartAttempts: Int = 0 @Published private(set) var lastHealthCheck: Date? @Published private(set) var retryCount: Int = 0 diff --git a/Sources/KeyPath/Services/PreferencesService.swift b/Sources/KeyPath/Services/PreferencesService.swift index 6eb68f0a0..bbeacba50 100644 --- a/Sources/KeyPath/Services/PreferencesService.swift +++ b/Sources/KeyPath/Services/PreferencesService.swift @@ -101,6 +101,14 @@ final class PreferencesService { } } + /// Whether user notifications are enabled + var notificationsEnabled: Bool { + didSet { + UserDefaults.standard.set(notificationsEnabled, forKey: Keys.notificationsEnabled) + AppLogger.shared.log("🔔 [PreferencesService] Notifications enabled: \(notificationsEnabled)") + } + } + // MARK: - Keys private enum Keys { @@ -109,6 +117,7 @@ final class PreferencesService { static let udpServerPort = "KeyPath.UDP.ServerPort" static let udpAuthToken = "KeyPath.UDP.AuthToken" static let udpSessionTimeout = "KeyPath.UDP.SessionTimeout" + static let notificationsEnabled = "KeyPath.Notifications.Enabled" } // MARK: - Defaults @@ -119,6 +128,7 @@ final class PreferencesService { static let udpServerPort = 37001 // Default port for Kanata UDP server static let udpAuthToken = "" // Auto-generate token static let udpSessionTimeout = 1800 // 30 minutes (same as Kanata default) + static let notificationsEnabled = true } // MARK: - Initialization @@ -137,6 +147,10 @@ final class PreferencesService { udpSessionTimeout = UserDefaults.standard.object(forKey: Keys.udpSessionTimeout) as? Int ?? Defaults.udpSessionTimeout + notificationsEnabled = + UserDefaults.standard.object(forKey: Keys.notificationsEnabled) as? Bool + ?? Defaults.notificationsEnabled + AppLogger.shared.log( "🔧 [PreferencesService] Initialized - Protocol: \(communicationProtocol.rawValue), UDP enabled: \(udpServerEnabled)" ) diff --git a/Sources/KeyPath/Services/UserNotificationService.swift b/Sources/KeyPath/Services/UserNotificationService.swift new file mode 100644 index 000000000..c09edbf5e --- /dev/null +++ b/Sources/KeyPath/Services/UserNotificationService.swift @@ -0,0 +1,54 @@ +import Foundation +import UserNotifications + +/// Manages local user notifications for KeyPath +@MainActor +final class UserNotificationService: NSObject, UNUserNotificationCenterDelegate { + static let shared = UserNotificationService() + + private let center = UNUserNotificationCenter.current() + private let preferences: PreferencesService + private let authorizationRequestedKey = "KeyPath.NotificationAuthorizationRequested" + + private override init(preferences: PreferencesService = .shared) { + self.preferences = preferences + super.init() + center.delegate = self + } + + /// Request authorization if it hasn't been requested before + func requestAuthorizationIfNeeded() { + let requested = UserDefaults.standard.bool(forKey: authorizationRequestedKey) + guard !requested else { return } + + center.requestAuthorization(options: [.alert, .sound]) { granted, _ in + UserDefaults.standard.set(true, forKey: self.authorizationRequestedKey) + Task { @MainActor in + self.preferences.notificationsEnabled = granted + } + } + } + + /// Send a generic notification if the user has enabled notifications + private func sendNotification(title: String, body: String) { + guard preferences.notificationsEnabled else { return } + + let content = UNMutableNotificationContent() + content.title = title + content.body = body + + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + center.add(request) + } + + /// Notify user of a launch failure + func notifyLaunchFailure(_ status: LaunchFailureStatus) { + sendNotification(title: "Kanata Launch Failed", body: status.shortMessage) + } + + /// Notify user of a status change + func notifyStatusChange(_ state: SimpleKanataState) { + sendNotification(title: "Kanata Status", body: "Kanata is now \(state.displayName)") + } +} + diff --git a/Sources/KeyPath/UI/ContentView.swift b/Sources/KeyPath/UI/ContentView.swift index e729c4ac7..4d0e6084f 100644 --- a/Sources/KeyPath/UI/ContentView.swift +++ b/Sources/KeyPath/UI/ContentView.swift @@ -23,8 +23,6 @@ struct ContentView: View { // Enhanced error handling @State private var enhancedErrorInfo: ErrorInfo? - // Toast manager for launch failure notifications - @State private var toastManager = WizardToastManager() // Diagnostics view state @State private var showingDiagnostics = false @@ -67,22 +65,6 @@ struct ContentView: View { .frame(height: (showStatusMessage && !statusMessage.contains("❌")) ? nil : 0) .clipped() - // Kanata Launch Failure Toast - inline, non-blocking design - if let toast = toastManager.currentToast { - WizardToastView( - toast: toast, - onDismiss: { - toastManager.dismissToast() - }, - onAction: { - // Handle launch failure toast action (open setup wizard) - toastManager.dismissToast() - showingInstallationWizard = true - } - ) - .padding(.top, 8) - } - // Diagnostic Summary (show critical issues) if !kanataManager.diagnostics.isEmpty { let criticalIssues = kanataManager.diagnostics.filter { $0.severity == .critical || $0.severity == .error } @@ -183,13 +165,6 @@ struct ContentView: View { AppLogger.shared.log( "🔍 [ContentView] showingInstallationWizard is now: \(showingInstallationWizard)") } - .onChange(of: kanataManager.launchFailureStatus) { newStatus in - // Show launch failure toast when status changes - if let failureStatus = newStatus { - AppLogger.shared.log("🍞 [ContentView] Showing launch failure toast: \(failureStatus.shortMessage)") - toastManager.showLaunchFailure(failureStatus) - } - } .onChange(of: kanataManager.lastConfigUpdate) { _ in // Show status message when config is updated externally showStatusMessage(message: "Key mappings updated") diff --git a/Sources/KeyPath/UI/SettingsView.swift b/Sources/KeyPath/UI/SettingsView.swift index 745d9e983..11b436409 100644 --- a/Sources/KeyPath/UI/SettingsView.swift +++ b/Sources/KeyPath/UI/SettingsView.swift @@ -203,6 +203,8 @@ struct SettingsView: View { Divider() diagnosticsSection Divider() + notificationsSection + Divider() communicationSection Divider() developerToolsSection @@ -358,6 +360,17 @@ struct SettingsView: View { } } + private var notificationsSection: some View { + SettingsSection(title: "Notifications") { + Toggle("Enable Notifications", isOn: $preferences.notificationsEnabled) + .onChange(of: preferences.notificationsEnabled) { enabled in + if enabled { + UserNotificationService.shared.requestAuthorizationIfNeeded() + } + } + } + } + private var communicationSection: some View { SettingsSection(title: "UDP Communication") { VStack(spacing: 12) {