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 {
case let .notify(title, body, sound, priority):
case let .notify(title, body, sound, priority, delivery):
let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines)
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")
let chosenDelivery = delivery ?? .system
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):
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 sound: String?
var priority: NotificationPriority?
var delivery: NotificationDelivery?
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
@@ -63,11 +64,13 @@ struct ClawdisCLI {
case "--sound": sound = args.popFirst()
case "--priority":
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
}
}
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":
var caps: [Capability] = []
@@ -172,7 +175,7 @@ struct ClawdisCLI {
clawdis-mac — talk to the running Clawdis.app XPC service
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
[--cap <notifications|accessibility|screenRecording|microphone|speechRecognition>]
[--interactive]

View File

@@ -21,8 +21,23 @@ public enum NotificationPriority: String, Codable, Sendable {
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 {
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 screenshot(displayID: UInt32?, windowID: UInt32?, format: String)
case runShell(
@@ -56,7 +71,7 @@ public struct Response: Codable, Sendable {
extension Request: Codable {
private enum CodingKeys: String, CodingKey {
case type
case title, body, sound, priority
case title, body, sound, priority, delivery
case caps, interactive
case displayID, windowID, format
case command, cwd, env, timeoutSec, needsScreenRecording
@@ -77,12 +92,13 @@ extension Request: Codable {
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.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(title, forKey: .title)
try container.encode(body, forKey: .body)
try container.encodeIfPresent(sound, forKey: .sound)
try container.encodeIfPresent(priority, forKey: .priority)
try container.encodeIfPresent(delivery, forKey: .delivery)
case let .ensurePermissions(caps, interactive):
try container.encode(Kind.ensurePermissions, forKey: .type)
@@ -128,7 +144,8 @@ extension Request: Codable {
let body = try container.decode(String.self, forKey: .body)
let sound = try container.decodeIfPresent(String.self, forKey: .sound)
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:
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`)
- 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]`
- `screenshot [--display-id N | --window-id N] [--out path]`
- `run -- cmd args... [--cwd] [--env KEY=VAL] [--timeout 30] [--needs-screen-recording]`
- `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.
- 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.
## Integration with clawdis/Clawdis (Node/TS)