feat(mac): add overlay notification delivery

This commit is contained in:
Peter Steinberger
2025-12-12 19:27:38 +00:00
parent d2158966db
commit f1320b79ce
5 changed files with 241 additions and 10 deletions

View File

@@ -14,10 +14,29 @@ enum ControlRequestHandler {
} }
switch request { switch request {
case let .notify(title, body, sound, priority): case let .notify(title, body, sound, priority, delivery):
let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines) let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines)
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority) let chosenDelivery = delivery ?? .system
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
switch chosenDelivery {
case .system:
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")
case .overlay:
await MainActor.run {
NotifyOverlayController.shared.present(title: title, body: body)
}
return Response(ok: true)
case .auto:
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority)
if ok { return Response(ok: true) }
await MainActor.run {
NotifyOverlayController.shared.present(title: title, body: body)
}
return Response(ok: true, message: "notification not authorized; used overlay")
}
case let .ensurePermissions(caps, interactive): case let .ensurePermissions(caps, interactive):
let statuses = await PermissionManager.ensure(caps, interactive: interactive) let statuses = await PermissionManager.ensure(caps, interactive: interactive)

View File

@@ -0,0 +1,191 @@
import AppKit
import QuartzCore
import SwiftUI
/// Lightweight, borderless panel for in-app "toast" notifications (bypasses macOS Notification Center).
@MainActor
final class NotifyOverlayController: ObservableObject {
static let shared = NotifyOverlayController()
@Published private(set) var model = Model()
var isVisible: Bool { self.model.isVisible }
struct Model {
var title: String = ""
var body: String = ""
var isVisible: Bool = false
}
private var window: NSPanel?
private var hostingView: NSHostingView<NotifyOverlayView>?
private var dismissTask: Task<Void, Never>?
private let width: CGFloat = 360
private let padding: CGFloat = 12
private let maxHeight: CGFloat = 220
private let minHeight: CGFloat = 64
func present(title: String, body: String, autoDismissAfter: TimeInterval = 6) {
self.dismissTask?.cancel()
self.model.title = title
self.model.body = body
self.ensureWindow()
self.hostingView?.rootView = NotifyOverlayView(controller: self)
self.presentWindow()
if autoDismissAfter > 0 {
self.dismissTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(autoDismissAfter * 1_000_000_000))
await MainActor.run { self?.dismiss() }
}
}
}
func dismiss() {
self.dismissTask?.cancel()
self.dismissTask = nil
guard let window else { return }
let target = window.frame.offsetBy(dx: 8, dy: 6)
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.16
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(target, display: true)
window.animator().alphaValue = 0
} completionHandler: {
Task { @MainActor in
window.orderOut(nil)
self.model.isVisible = false
}
}
}
// MARK: - Private
private func presentWindow() {
self.ensureWindow()
self.hostingView?.rootView = NotifyOverlayView(controller: self)
let target = self.targetFrame()
guard let window else { return }
if !self.model.isVisible {
self.model.isVisible = true
let start = target.offsetBy(dx: 0, dy: -6)
window.setFrame(start, display: true)
window.alphaValue = 0
window.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(target, display: true)
window.animator().alphaValue = 1
}
} else {
self.updateWindowFrame(animate: true)
window.orderFrontRegardless()
}
}
private func ensureWindow() {
if self.window != nil { return }
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.minHeight),
styleMask: [.nonactivatingPanel, .borderless],
backing: .buffered,
defer: false)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.level = .statusBar
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
panel.hidesOnDeactivate = false
panel.isMovable = false
panel.isFloatingPanel = true
panel.becomesKeyOnlyIfNeeded = true
panel.titleVisibility = .hidden
panel.titlebarAppearsTransparent = true
let host = NSHostingView(rootView: NotifyOverlayView(controller: self))
host.translatesAutoresizingMaskIntoConstraints = false
panel.contentView = host
self.hostingView = host
self.window = panel
}
private func targetFrame() -> NSRect {
guard let screen = NSScreen.main else { return .zero }
let height = self.measuredHeight()
let size = NSSize(width: self.width, height: height)
let visible = screen.visibleFrame
let origin = CGPoint(x: visible.maxX - size.width - 8, y: visible.maxY - size.height - 8)
return NSRect(origin: origin, size: size)
}
private func updateWindowFrame(animate: Bool = false) {
guard let window else { return }
let frame = self.targetFrame()
if animate {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.12
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(frame, display: true)
}
} else {
window.setFrame(frame, display: true)
}
}
private func measuredHeight() -> CGFloat {
let maxWidth = self.width - self.padding * 2
let titleFont = NSFont.systemFont(ofSize: 13, weight: .semibold)
let bodyFont = NSFont.systemFont(ofSize: 12, weight: .regular)
let titleRect = (self.model.title as NSString).boundingRect(
with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
attributes: [.font: titleFont],
context: nil)
let bodyRect = (self.model.body as NSString).boundingRect(
with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
attributes: [.font: bodyFont],
context: nil)
let contentHeight = ceil(titleRect.height + 6 + bodyRect.height)
let total = contentHeight + self.padding * 2
return max(self.minHeight, min(total, self.maxHeight))
}
}
private struct NotifyOverlayView: View {
@ObservedObject var controller: NotifyOverlayController
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(self.controller.model.title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(1)
Text(self.controller.model.body)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.regularMaterial)
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1)
)
.onTapGesture {
self.controller.dismiss()
}
}
}

View File

@@ -55,6 +55,7 @@ struct ClawdisCLI {
var body: String? var body: String?
var sound: String? var sound: String?
var priority: NotificationPriority? var priority: NotificationPriority?
var delivery: NotificationDelivery?
while !args.isEmpty { while !args.isEmpty {
let arg = args.removeFirst() let arg = args.removeFirst()
switch arg { switch arg {
@@ -63,11 +64,13 @@ struct ClawdisCLI {
case "--sound": sound = args.popFirst() case "--sound": sound = args.popFirst()
case "--priority": case "--priority":
if let val = args.popFirst(), let p = NotificationPriority(rawValue: val) { priority = p } if let val = args.popFirst(), let p = NotificationPriority(rawValue: val) { priority = p }
case "--delivery":
if let val = args.popFirst(), let d = NotificationDelivery(rawValue: val) { delivery = d }
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, priority: priority) return .notify(title: t, body: b, sound: sound, priority: priority, delivery: delivery)
case "ensure-permissions": case "ensure-permissions":
var caps: [Capability] = [] var caps: [Capability] = []
@@ -172,7 +175,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>] [--priority <passive|active|timeSensitive>] clawdis-mac notify --title <t> --body <b> [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>]
clawdis-mac ensure-permissions clawdis-mac ensure-permissions
[--cap <notifications|accessibility|screenRecording|microphone|speechRecognition>] [--cap <notifications|accessibility|screenRecording|microphone|speechRecognition>]
[--interactive] [--interactive]

View File

@@ -21,8 +21,23 @@ public enum NotificationPriority: String, Codable, Sendable {
case timeSensitive // breaks through Focus modes case timeSensitive // breaks through Focus modes
} }
/// Notification delivery mechanism.
public enum NotificationDelivery: String, Codable, Sendable {
/// Use macOS notification center (UNUserNotificationCenter).
case system
/// Use an in-app overlay/toast (no Notification Center history).
case overlay
/// Prefer system; fall back to overlay when system isn't available.
case auto
}
public enum Request: Sendable { public enum Request: Sendable {
case notify(title: String, body: String, sound: String?, priority: NotificationPriority?) case notify(
title: String,
body: String,
sound: String?,
priority: NotificationPriority?,
delivery: NotificationDelivery?)
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(
@@ -56,7 +71,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, priority case title, body, sound, priority, delivery
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
@@ -77,12 +92,13 @@ 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, priority): case let .notify(title, body, sound, priority, delivery):
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) try container.encodeIfPresent(priority, forKey: .priority)
try container.encodeIfPresent(delivery, forKey: .delivery)
case let .ensurePermissions(caps, interactive): case let .ensurePermissions(caps, interactive):
try container.encode(Kind.ensurePermissions, forKey: .type) try container.encode(Kind.ensurePermissions, forKey: .type)
@@ -128,7 +144,8 @@ extension Request: Codable {
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)
let priority = try container.decodeIfPresent(NotificationPriority.self, forKey: .priority) let priority = try container.decodeIfPresent(NotificationPriority.self, forKey: .priority)
self = .notify(title: title, body: body, sound: sound, priority: priority) let delivery = try container.decodeIfPresent(NotificationDelivery.self, forKey: .delivery)
self = .notify(title: title, body: body, sound: sound, priority: priority, delivery: delivery)
case .ensurePermissions: case .ensurePermissions:
let caps = try container.decode([Capability].self, forKey: .caps) let caps = try container.decode([Capability].self, forKey: .caps)

View File

@@ -64,13 +64,14 @@ struct Response { ok: Bool; message?: String; payload?: Data }
## CLI (`clawdis-mac`) ## CLI (`clawdis-mac`)
- Subcommands (JSON out, non-zero exit on failure): - Subcommands (JSON out, non-zero exit on failure):
- `notify --title --body [--sound] [--priority passive|active|timeSensitive]` - `notify --title --body [--sound] [--priority passive|active|timeSensitive] [--delivery system|overlay|auto]`
- `ensure-permissions --cap accessibility --cap screenRecording [--interactive]` - `ensure-permissions --cap accessibility --cap screenRecording [--interactive]`
- `screenshot [--display-id N | --window-id N] [--out path]` - `screenshot [--display-id N | --window-id N] [--out path]`
- `run -- cmd args... [--cwd] [--env KEY=VAL] [--timeout 30] [--needs-screen-recording]` - `run -- cmd args... [--cwd] [--env KEY=VAL] [--timeout 30] [--needs-screen-recording]`
- `status` - `status`
- Sounds: supply any macOS alert name with `--sound` per notification; omit the flag to use the system default. There is no longer a persisted “default sound” in the app UI. - Sounds: supply any macOS alert name with `--sound` per notification; omit the flag to use the system default. There is no longer a persisted “default sound” in the app UI.
- Priority: `timeSensitive` is best-effort and falls back to `active` unless the app is signed with the Time Sensitive Notifications entitlement. - Priority: `timeSensitive` is best-effort and falls back to `active` unless the app is signed with the Time Sensitive Notifications entitlement.
- Delivery: `overlay` and `auto` show an in-app toast panel (bypasses Notification Center/Focus).
- Internals: builds Request, connects via AsyncXPCConnection, prints Response as JSON to stdout. - Internals: builds Request, connects via AsyncXPCConnection, prints Response as JSON to stdout.
## Integration with clawdis/Clawdis (Node/TS) ## Integration with clawdis/Clawdis (Node/TS)