feat(mac): add overlay notification delivery
This commit is contained in:
@@ -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)
|
||||
|
||||
191
apps/macos/Sources/Clawdis/NotifyOverlay.swift
Normal file
191
apps/macos/Sources/Clawdis/NotifyOverlay.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user