mac: fix notification prompt and center onboarding toggle

This commit is contained in:
Peter Steinberger
2025-12-06 21:38:21 +01:00
parent 6182b205c8
commit 39254229a0

View File

@@ -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