feat(mac): add overlay notification delivery
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
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 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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user