Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Sources/KeyPath/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 14 additions & 2 deletions Sources/KeyPath/Managers/KanataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +148 to +151

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P0] Call main-actor notification API from non-isolated KanataManager

UserNotificationService is declared @MainActor, so invoking UserNotificationService.shared.notifyStatusChange(...) from KanataManager’s property observer (which is not main-actor isolated) will not compile: the compiler reports “call to main actor-isolated instance method … in a synchronous nonisolated context.” Wrap these calls in Task { @MainActor in … } or mark KanataManager as @MainActor before accessing the notification service. The same issue exists in the launchFailureStatus observer below.

Useful? React with 👍 / 👎.

}
}
}
@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
Expand Down
14 changes: 14 additions & 0 deletions Sources/KeyPath/Services/PreferencesService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)"
)
Expand Down
54 changes: 54 additions & 0 deletions Sources/KeyPath/Services/UserNotificationService.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}

25 changes: 0 additions & 25 deletions Sources/KeyPath/UI/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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")
Expand Down
13 changes: 13 additions & 0 deletions Sources/KeyPath/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ struct SettingsView: View {
Divider()
diagnosticsSection
Divider()
notificationsSection
Divider()
communicationSection
Divider()
developerToolsSection
Expand Down Expand Up @@ -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) {
Expand Down
Loading