From 39254229a07bb0e53b488c6eb7d6cb490f7f9441 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 6 Dec 2025 21:38:21 +0100 Subject: [PATCH] mac: fix notification prompt and center onboarding toggle --- apps/macos/Sources/Clawdis/AppMain.swift | 402 +++++++++++++++++------ 1 file changed, 310 insertions(+), 92 deletions(-) diff --git a/apps/macos/Sources/Clawdis/AppMain.swift b/apps/macos/Sources/Clawdis/AppMain.swift index eec43561c..010964205 100644 --- a/apps/macos/Sources/Clawdis/AppMain.swift +++ b/apps/macos/Sources/Clawdis/AppMain.swift @@ -14,6 +14,7 @@ import Speech import SwiftUI import UserNotifications import VideoToolbox +import UniformTypeIdentifiers private let serviceName = "com.steipete.clawdis.xpc" private let launchdLabel = "com.steipete.clawdis" @@ -28,6 +29,7 @@ private let voiceWakeMicKey = "clawdis.voiceWakeMicID" private let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID" private let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs" private let modelCatalogPathKey = "clawdis.modelCatalogPath" +private let modelCatalogReloadKey = "clawdis.modelCatalogReload" private let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 // MARK: - App model @@ -242,13 +244,30 @@ enum PermissionManager { switch cap { case .notifications: let center = UNUserNotificationCenter.current() - let status = await center.notificationSettings() - if status.authorizationStatus == .notDetermined, interactive { - _ = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) - let post = await center.notificationSettings() - results[cap] = post.authorizationStatus == .authorized - } else { - results[cap] = status.authorizationStatus == .authorized + let settings = await center.notificationSettings() + + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + results[cap] = true + + case .notDetermined: + if interactive { + let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false + let updated = await center.notificationSettings() + results[cap] = granted && (updated.authorizationStatus == .authorized || updated + .authorizationStatus == .provisional) + } else { + results[cap] = false + } + + case .denied: + results[cap] = false + if interactive { + NotificationPermissionHelper.openSettings() + } + + @unknown default: + results[cap] = false } case .accessibility: @@ -322,6 +341,90 @@ enum PermissionManager { } } +enum NotificationPermissionHelper { + static func openSettings() { + let candidates = [ + "x-apple.systempreferences:com.apple.Notifications-Settings.extension", + "x-apple.systempreferences:com.apple.preference.notifications", + ] + + for candidate in candidates { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + return + } + } + } +} + +// MARK: - Permission monitoring + +@MainActor +final class PermissionMonitor: ObservableObject { + static let shared = PermissionMonitor() + + @Published private(set) var status: [Capability: Bool] = [:] + + private var monitorTimer: Timer? + private var isChecking = false + private var registrations = 0 + private var lastCheck: Date? + private let minimumCheckInterval: TimeInterval = 0.5 + + func register() { + self.registrations += 1 + if self.registrations == 1 { + self.startMonitoring() + } + } + + func unregister() { + guard self.registrations > 0 else { return } + self.registrations -= 1 + if self.registrations == 0 { + self.stopMonitoring() + } + } + + func refreshNow() async { + await self.checkStatus(force: true) + } + + private func startMonitoring() { + Task { await self.checkStatus(force: true) } + + self.monitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + await self.checkStatus(force: false) + } + } + } + + private func stopMonitoring() { + self.monitorTimer?.invalidate() + self.monitorTimer = nil + self.lastCheck = nil + } + + private func checkStatus(force: Bool) async { + if self.isChecking { return } + let now = Date() + if !force, let lastCheck, now.timeIntervalSince(lastCheck) < self.minimumCheckInterval { + return + } + + self.isChecking = true + self.lastCheck = now + + let latest = await PermissionManager.status() + if latest != self.status { + self.status = latest + } + + self.isChecking = false + } +} + enum ScreenRecordingProbe { static func isAuthorized() -> Bool { if #available(macOS 10.15, *) { @@ -506,14 +609,12 @@ private struct MenuContent: View { Toggle(isOn: self.$state.swabbleEnabled) { Text("Voice Wake") } .disabled(!voiceWakeSupported) .opacity(voiceWakeSupported ? 1 : 0.5) + Button("Open Chat") { WebChatManager.shared.show(sessionKey: self.primarySessionKey()) } + Divider() Button("Settings…") { self.open(tab: .general) } .keyboardShortcut(",", modifiers: [.command]) Button("About Clawdis") { self.open(tab: .about) } - Button("Open Web Chat") { WebChatManager.shared.show(sessionKey: self.primarySessionKey()) } Divider() - Button("Test Notification") { - Task { _ = await NotificationManager().send(title: "Clawdis", body: "Test notification", sound: nil) } - } Button("Quit") { NSApplication.shared.terminate(nil) } } @@ -1104,10 +1205,13 @@ enum ModelCatalogLoader { } var body = String(source[firstBrace...lastBrace]) body = body.replacingOccurrences( - of: #"satisfies\s+[A-Za-z0-9_<>.,\-\s]+"#, + of: #"(?m)\bsatisfies\s+[^,}\n]+"#, + with: "", + options: .regularExpression) + body = body.replacingOccurrences( + of: #"(?m)\bas\s+[^;,\n]+"#, with: "", options: .regularExpression) - body = body.replacingOccurrences(of: #"as\s+[A-Za-z0-9_<>.,\-\s]+"#, with: "", options: .regularExpression) return "var MODELS = \(body);" } } @@ -1306,15 +1410,14 @@ struct ConfigSettings: View { @State private var configModel: String = "" @State private var customModel: String = "" @State private var configStorePath: String = SessionLoader.defaultStorePath - @State private var configContextTokens: String = "" - @State private var configStatus: String? @State private var configSaving = false @State private var hasLoaded = false @State private var models: [ModelChoice] = [] @State private var modelsLoading = false @State private var modelError: String? - @State private var modelCatalogPath: String = UserDefaults.standard - .string(forKey: modelCatalogPathKey) ?? ModelCatalogLoader.defaultPath + @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath + @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 + @State private var allowAutosave = false var body: some View { VStack(alignment: .leading, spacing: 14) { @@ -1329,7 +1432,7 @@ struct ConfigSettings: View { Picker("Model", selection: self.$configModel) { ForEach(self.models) { choice in Text( - "\(choice.name) — \(choice.provider.uppercased())\(choice.contextWindow.map { " \($0 / 1000)k ctx" } ?? "")") + "\(choice.name) — \(choice.provider.uppercased())") .tag(choice.id) } Text("Manual entry…").tag("__custom__") @@ -1337,6 +1440,9 @@ struct ConfigSettings: View { .labelsHidden() .frame(width: 360) .disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty)) + .onChange(of: self.configModel) { _, _ in + self.autosaveConfig() + } if self.configModel == "__custom__" { TextField("Enter model ID", text: self.$customModel) @@ -1344,9 +1450,16 @@ struct ConfigSettings: View { .frame(width: 320) .onChange(of: self.customModel) { _, newValue in self.configModel = newValue + self.autosaveConfig() } } + if let contextLabel = self.selectedContextLabel { + Text(contextLabel) + .font(.footnote) + .foregroundStyle(.secondary) + } + HStack(spacing: 10) { Button { Task { await self.loadModels() } @@ -1355,20 +1468,10 @@ struct ConfigSettings: View { } .disabled(self.modelsLoading) - Button { - self.chooseCatalogFile() - } label: { - Label("Choose file…", systemImage: "folder") - } - if let modelError { Text(modelError) .font(.footnote) .foregroundStyle(.secondary) - } else if !self.models.isEmpty { - Text("Loaded \(self.models.count) models") - .font(.footnote) - .foregroundStyle(.secondary) } } } @@ -1378,46 +1481,36 @@ struct ConfigSettings: View { TextField("Path", text: self.$configStorePath) .textFieldStyle(.roundedBorder) .frame(width: 360) + .onChange(of: self.configStorePath) { _, _ in + self.autosaveConfig() + } } LabeledContent("Context tokens") { TextField("Optional", text: self.$configContextTokens) .textFieldStyle(.roundedBorder) .frame(width: 160) - } - - HStack(spacing: 12) { - Button { - Task { await self.saveConfig() } - } label: { - Label(self.configSaving ? "Saving…" : "Save config", systemImage: "square.and.arrow.down") - .labelStyle(.titleAndIcon) - } - .disabled(self.configSaving) - - Button { - self.loadConfig() - } label: { - Label("Revert", systemImage: "arrow.uturn.backward") - } - .disabled(self.configSaving) - - if let configStatus { - Text(configStatus) - .font(.footnote) - .foregroundStyle(.secondary) - } + .onChange(of: self.configContextTokens) { _, _ in + self.autosaveConfig() + } } Spacer() } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) + .onChange(of: self.modelCatalogPath) { _, _ in + Task { await self.loadModels() } + } + .onChange(of: self.modelCatalogReloadBump) { _, _ in + Task { await self.loadModels() } + } .task { guard !self.hasLoaded else { return } self.hasLoaded = true self.loadConfig() await self.loadModels() + self.allowAutosave = true } } @@ -1430,10 +1523,9 @@ struct ConfigSettings: View { private func loadConfig() { let url = self.configURL() guard let data = try? Data(contentsOf: url) else { - self.configModel = "" + self.configModel = SessionLoader.fallbackModel self.configStorePath = SessionLoader.defaultStorePath self.configContextTokens = "" - self.configStatus = "Using defaults (no config file yet)" return } guard @@ -1441,7 +1533,6 @@ struct ConfigSettings: View { let inbound = parsed["inbound"] as? [String: Any], let reply = inbound["reply"] as? [String: Any] else { - self.configStatus = "Invalid config file; using defaults" return } @@ -1453,15 +1544,19 @@ struct ConfigSettings: View { self.configModel = loadedModel self.customModel = loadedModel } else { - self.configModel = "" - self.customModel = "" + self.configModel = SessionLoader.fallbackModel + self.customModel = SessionLoader.fallbackModel } if let ctx = (agent?["contextTokens"] as? NSNumber)?.intValue { self.configContextTokens = "\(ctx)" } else { self.configContextTokens = "" } - self.configStatus = "Loaded from config" + } + + private func autosaveConfig() { + guard self.allowAutosave else { return } + Task { await self.saveConfig() } } private func saveConfig() async { @@ -1496,10 +1591,7 @@ struct ConfigSettings: View { at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: url, options: [.atomic]) - self.configStatus = "Saved to \(url.path)" - } catch { - self.configStatus = "Save failed: \(error.localizedDescription)" - } + } catch {} } private func loadModels() async { @@ -1521,21 +1613,18 @@ struct ConfigSettings: View { self.modelsLoading = false } - private func chooseCatalogFile() { - let panel = NSOpenPanel() - panel.title = "Select models.generated.ts" - if let tsType = UTType(filenameExtension: "ts") { - panel.allowedContentTypes = [tsType] - } else { - panel.allowedFileTypes = ["ts"] // fallback - } - panel.allowsMultipleSelection = false - panel.directoryURL = URL(fileURLWithPath: self.modelCatalogPath).deletingLastPathComponent() - if panel.runModal() == .OK, let url = panel.url { - self.modelCatalogPath = url.path - UserDefaults.standard.set(url.path, forKey: modelCatalogPathKey) - Task { await self.loadModels() } + private var selectedContextLabel: String? { + let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel + guard + !chosenId.isEmpty, + let choice = self.models.first(where: { $0.id == chosenId }), + let context = choice.contextWindow + else { + return nil } + + let human = context >= 1000 ? "\(context / 1000)k" : "\(context)" + return "Context window: \(human) tokens" } } @@ -1630,8 +1719,8 @@ private struct Badge: View { struct SettingsRootView: View { @ObservedObject var state: AppState - @State private var permStatus: [Capability: Bool] = [:] - @State private var loadingPerms = false + @ObservedObject private var permissionMonitor = PermissionMonitor.shared + @State private var monitoringPermissions = false @State private var selectedTab: SettingsTab = .general var body: some View { @@ -1653,7 +1742,7 @@ struct SettingsRootView: View { .tag(SettingsTab.voiceWake) PermissionsSettings( - status: self.permStatus, + status: self.permissionMonitor.status, refresh: self.refreshPerms, showOnboarding: { OnboardingController.shared.show() }) .tabItem { Label("Permissions", systemImage: "lock.shield") } @@ -1684,12 +1773,17 @@ struct SettingsRootView: View { if let pending = SettingsTabRouter.consumePending() { self.selectedTab = self.validTab(for: pending) } + self.updatePermissionMonitoring(for: self.selectedTab) } .onChange(of: self.state.debugPaneEnabled) { _, enabled in if !enabled, self.selectedTab == .debug { self.selectedTab = .general } } + .onChange(of: self.selectedTab) { _, newValue in + self.updatePermissionMonitoring(for: newValue) + } + .onDisappear { self.stopPermissionMonitoring() } .task { await self.refreshPerms() } } @@ -1700,10 +1794,24 @@ struct SettingsRootView: View { @MainActor private func refreshPerms() async { - guard !self.loadingPerms else { return } - self.loadingPerms = true - self.permStatus = await PermissionManager.status() - self.loadingPerms = false + await self.permissionMonitor.refreshNow() + } + + private func updatePermissionMonitoring(for tab: SettingsTab) { + let shouldMonitor = tab == .permissions + if shouldMonitor, !self.monitoringPermissions { + self.monitoringPermissions = true + PermissionMonitor.shared.register() + } else if !shouldMonitor, self.monitoringPermissions { + self.monitoringPermissions = false + PermissionMonitor.shared.unregister() + } + } + + private func stopPermissionMonitoring() { + guard self.monitoringPermissions else { return } + self.monitoringPermissions = false + PermissionMonitor.shared.unregister() } } @@ -2587,6 +2695,12 @@ struct PermissionsSettings: View { } struct DebugSettings: View { + @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath + @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 + @State private var modelsCount: Int? + @State private var modelsLoading = false + @State private var modelsError: String? + var body: some View { VStack(alignment: .leading, spacing: 10) { LabeledContent("PID") { Text("\(ProcessInfo.processInfo.processIdentifier)") } @@ -2594,6 +2708,46 @@ struct DebugSettings: View { Button("Open /tmp/clawdis.log") { NSWorkspace.shared.open(URL(fileURLWithPath: "/tmp/clawdis.log")) } } LabeledContent("Binary path") { Text(Bundle.main.bundlePath).font(.footnote) } + LabeledContent("Model catalog") { + VStack(alignment: .leading, spacing: 6) { + Text(self.modelCatalogPath) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + HStack(spacing: 8) { + Button { + self.chooseCatalogFile() + } label: { + Label("Choose models.generated.ts…", systemImage: "folder") + } + .buttonStyle(.bordered) + + Button { + Task { await self.reloadModels() } + } label: { + Label(self.modelsLoading ? "Reloading…" : "Reload models", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .disabled(self.modelsLoading) + } + if let modelsError { + Text(modelsError) + .font(.footnote) + .foregroundStyle(.secondary) + } else if let modelsCount { + Text("Loaded \(modelsCount) models") + .font(.footnote) + .foregroundStyle(.secondary) + } + Text("Used by the Config tab model picker; point at a different build when debugging.") + .font(.footnote) + .foregroundStyle(.tertiary) + } + } + Button("Send Test Notification") { + Task { _ = await NotificationManager().send(title: "Clawdis", body: "Test notification", sound: nil) } + } + .buttonStyle(.bordered) HStack { Button("Restart app") { self.relaunch() } Button("Reveal app in Finder") { self.revealApp() } @@ -2603,6 +2757,39 @@ struct DebugSettings: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) + .task { await self.reloadModels() } + } + + private func chooseCatalogFile() { + let panel = NSOpenPanel() + panel.title = "Select models.generated.ts" + if let tsType = UTType(filenameExtension: "ts") { + panel.allowedContentTypes = [tsType] + } else { + panel.allowedFileTypes = ["ts"] // fallback + } + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: self.modelCatalogPath).deletingLastPathComponent() + if panel.runModal() == .OK, let url = panel.url { + self.modelCatalogPath = url.path + self.modelCatalogReloadBump += 1 + Task { await self.reloadModels() } + } + } + + private func reloadModels() async { + guard !self.modelsLoading else { return } + self.modelsLoading = true + self.modelsError = nil + self.modelCatalogReloadBump += 1 + defer { self.modelsLoading = false } + do { + let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath) + self.modelsCount = loaded.count + } catch { + self.modelsCount = nil + self.modelsError = error.localizedDescription + } } private func relaunch() { @@ -2944,15 +3131,17 @@ final class OnboardingController { struct OnboardingView: View { @State private var currentPage = 0 - @State private var permStatus: [Capability: Bool] = [:] @State private var isRequesting = false @State private var installingCLI = false @State private var cliStatus: String? @State private var copied = false + @State private var monitoringPermissions = false @ObservedObject private var state = AppStateStore.shared + @ObservedObject private var permissionMonitor = PermissionMonitor.shared private let pageWidth: CGFloat = 640 private let contentHeight: CGFloat = 260 + private let permissionsPageIndex = 2 private var pageCount: Int { 6 } private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" } private let devLinkCommand = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac" @@ -2977,7 +3166,7 @@ struct OnboardingView: View { .animation( .interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25), value: self.currentPage) - .frame(width: self.pageWidth, height: self.contentHeight, alignment: .top) + .frame(height: self.contentHeight, alignment: .top) .clipped() } .frame(height: 260) @@ -2986,7 +3175,14 @@ struct OnboardingView: View { } .frame(width: self.pageWidth, height: 560) .background(Color(NSColor.windowBackgroundColor)) - .onAppear { self.currentPage = 0 } + .onAppear { + self.currentPage = 0 + self.updatePermissionMonitoring(for: 0) + } + .onChange(of: self.currentPage) { _, newValue in + self.updatePermissionMonitoring(for: newValue) + } + .onDisappear { self.stopPermissionMonitoring() } .task { await self.refreshPerms() } } @@ -3043,7 +3239,7 @@ struct OnboardingView: View { self.onboardingCard { ForEach(Capability.allCases, id: \.self) { cap in - PermissionRow(capability: cap, status: self.permStatus[cap] ?? false) { + PermissionRow(capability: cap, status: self.permissionMonitor.status[cap] ?? false) { Task { await self.request(cap) } } } @@ -3118,15 +3314,20 @@ struct OnboardingView: View { .fixedSize(horizontal: false, vertical: true) self.onboardingCard { - Toggle("Launch at login", isOn: self.$state.launchAtLogin) - .toggleStyle(.switch) - .onChange(of: self.state.launchAtLogin) { _, newValue in - AppStateStore.updateLaunchAtLogin(enabled: newValue) - } + HStack { + Spacer() + Toggle("Launch at login", isOn: self.$state.launchAtLogin) + .toggleStyle(.switch) + .onChange(of: self.state.launchAtLogin) { _, newValue in + AppStateStore.updateLaunchAtLogin(enabled: newValue) + } + Spacer() + } Text( "You can pause from the menu bar anytime. Settings keeps a \"Show onboarding\" button if you need to revisit.") .font(.footnote) .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) } } } @@ -3261,7 +3462,7 @@ struct OnboardingView: View { @MainActor private func refreshPerms() async { - self.permStatus = await PermissionManager.status() + await self.permissionMonitor.refreshNow() } @MainActor @@ -3273,6 +3474,23 @@ struct OnboardingView: View { await self.refreshPerms() } + private func updatePermissionMonitoring(for pageIndex: Int) { + let shouldMonitor = pageIndex == self.permissionsPageIndex + if shouldMonitor, !self.monitoringPermissions { + self.monitoringPermissions = true + PermissionMonitor.shared.register() + } else if !shouldMonitor, self.monitoringPermissions { + self.monitoringPermissions = false + PermissionMonitor.shared.unregister() + } + } + + private func stopPermissionMonitoring() { + guard self.monitoringPermissions else { return } + self.monitoringPermissions = false + PermissionMonitor.shared.unregister() + } + private func installCLI() async { guard !self.installingCLI else { return } self.installingCLI = true