macOS: add --priority flag for time-sensitive notifications

Add NotificationPriority enum with passive/active/timeSensitive levels
that map to UNNotificationInterruptionLevel. timeSensitive breaks
through Focus modes for urgent notifications.

Usage: clawdis-mac notify --title X --body Y --priority timeSensitive
main
Peter Steinberger 2025-12-12 18:27:12 +00:00
parent 8ca240fb2c
commit c86cb4e9a5
4 changed files with 34 additions and 9 deletions

View File

@ -14,9 +14,9 @@ enum ControlRequestHandler {
} }
switch request { switch request {
case let .notify(title, body, sound): case let .notify(title, body, sound, priority):
let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines) let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines)
let ok = await notifier.send(title: title, body: body, sound: chosenSound) let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority)
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized") return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
case let .ensurePermissions(caps, interactive): case let .ensurePermissions(caps, interactive):

View File

@ -1,9 +1,10 @@
import ClawdisIPC
import Foundation import Foundation
import UserNotifications import UserNotifications
@MainActor @MainActor
struct NotificationManager { struct NotificationManager {
func send(title: String, body: String, sound: String?) async -> Bool { func send(title: String, body: String, sound: String?, priority: NotificationPriority? = nil) async -> Bool {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
let status = await center.notificationSettings() let status = await center.notificationSettings()
if status.authorizationStatus == .notDetermined { if status.authorizationStatus == .notDetermined {
@ -20,6 +21,18 @@ struct NotificationManager {
content.sound = UNNotificationSound(named: UNNotificationSoundName(soundName)) content.sound = UNNotificationSound(named: UNNotificationSoundName(soundName))
} }
// Set interruption level based on priority
if let priority {
switch priority {
case .passive:
content.interruptionLevel = .passive
case .active:
content.interruptionLevel = .active
case .timeSensitive:
content.interruptionLevel = .timeSensitive
}
}
let req = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) let req = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
do { do {
try await center.add(req) try await center.add(req)

View File

@ -54,17 +54,20 @@ struct ClawdisCLI {
var title: String? var title: String?
var body: String? var body: String?
var sound: String? var sound: String?
var priority: NotificationPriority?
while !args.isEmpty { while !args.isEmpty {
let arg = args.removeFirst() let arg = args.removeFirst()
switch arg { switch arg {
case "--title": title = args.popFirst() case "--title": title = args.popFirst()
case "--body": body = args.popFirst() case "--body": body = args.popFirst()
case "--sound": sound = args.popFirst() case "--sound": sound = args.popFirst()
case "--priority":
if let val = args.popFirst(), let p = NotificationPriority(rawValue: val) { priority = p }
default: break default: break
} }
} }
guard let t = title, let b = body else { throw CLIError.help } guard let t = title, let b = body else { throw CLIError.help }
return .notify(title: t, body: b, sound: sound) return .notify(title: t, body: b, sound: sound, priority: priority)
case "ensure-permissions": case "ensure-permissions":
var caps: [Capability] = [] var caps: [Capability] = []
@ -169,7 +172,7 @@ struct ClawdisCLI {
clawdis-mac talk to the running Clawdis.app XPC service clawdis-mac talk to the running Clawdis.app XPC service
Usage: Usage:
clawdis-mac notify --title <t> --body <b> [--sound <name>] clawdis-mac notify --title <t> --body <b> [--sound <name>] [--priority <passive|active|timeSensitive>]
clawdis-mac ensure-permissions clawdis-mac ensure-permissions
[--cap <notifications|accessibility|screenRecording|microphone|speechRecognition>] [--cap <notifications|accessibility|screenRecording|microphone|speechRecognition>]
[--interactive] [--interactive]

View File

@ -14,8 +14,15 @@ public enum Capability: String, Codable, CaseIterable, Sendable {
// MARK: - Requests // MARK: - Requests
/// Notification interruption level (maps to UNNotificationInterruptionLevel)
public enum NotificationPriority: String, Codable, Sendable {
case passive // silent, no wake
case active // default
case timeSensitive // breaks through Focus modes
}
public enum Request: Sendable { public enum Request: Sendable {
case notify(title: String, body: String, sound: String?) case notify(title: String, body: String, sound: String?, priority: NotificationPriority?)
case ensurePermissions([Capability], interactive: Bool) case ensurePermissions([Capability], interactive: Bool)
case screenshot(displayID: UInt32?, windowID: UInt32?, format: String) case screenshot(displayID: UInt32?, windowID: UInt32?, format: String)
case runShell( case runShell(
@ -49,7 +56,7 @@ public struct Response: Codable, Sendable {
extension Request: Codable { extension Request: Codable {
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case type case type
case title, body, sound case title, body, sound, priority
case caps, interactive case caps, interactive
case displayID, windowID, format case displayID, windowID, format
case command, cwd, env, timeoutSec, needsScreenRecording case command, cwd, env, timeoutSec, needsScreenRecording
@ -70,11 +77,12 @@ extension Request: Codable {
public func encode(to encoder: Encoder) throws { public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
switch self { switch self {
case let .notify(title, body, sound): case let .notify(title, body, sound, priority):
try container.encode(Kind.notify, forKey: .type) try container.encode(Kind.notify, forKey: .type)
try container.encode(title, forKey: .title) try container.encode(title, forKey: .title)
try container.encode(body, forKey: .body) try container.encode(body, forKey: .body)
try container.encodeIfPresent(sound, forKey: .sound) try container.encodeIfPresent(sound, forKey: .sound)
try container.encodeIfPresent(priority, forKey: .priority)
case let .ensurePermissions(caps, interactive): case let .ensurePermissions(caps, interactive):
try container.encode(Kind.ensurePermissions, forKey: .type) try container.encode(Kind.ensurePermissions, forKey: .type)
@ -119,7 +127,8 @@ extension Request: Codable {
let title = try container.decode(String.self, forKey: .title) let title = try container.decode(String.self, forKey: .title)
let body = try container.decode(String.self, forKey: .body) let body = try container.decode(String.self, forKey: .body)
let sound = try container.decodeIfPresent(String.self, forKey: .sound) let sound = try container.decodeIfPresent(String.self, forKey: .sound)
self = .notify(title: title, body: body, sound: sound) let priority = try container.decodeIfPresent(NotificationPriority.self, forKey: .priority)
self = .notify(title: title, body: body, sound: sound, priority: priority)
case .ensurePermissions: case .ensurePermissions:
let caps = try container.decode([Capability].self, forKey: .caps) let caps = try container.decode([Capability].self, forKey: .caps)