From f1320b79ce9ff6827a0cd0ed7a4094a2011ade63 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 12 Dec 2025 19:27:38 +0000 Subject: [PATCH] feat(mac): add overlay notification delivery --- .../Clawdis/ControlRequestHandler.swift | 25 ++- .../macos/Sources/Clawdis/NotifyOverlay.swift | 191 ++++++++++++++++++ apps/macos/Sources/ClawdisCLI/main.swift | 7 +- apps/macos/Sources/ClawdisIPC/IPC.swift | 25 ++- docs/clawdis-mac.md | 3 +- 5 files changed, 241 insertions(+), 10 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/NotifyOverlay.swift diff --git a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift index 3c7057de6..ac5bd9882 100644 --- a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift +++ b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift @@ -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) diff --git a/apps/macos/Sources/Clawdis/NotifyOverlay.swift b/apps/macos/Sources/Clawdis/NotifyOverlay.swift new file mode 100644 index 000000000..0ea3f300b --- /dev/null +++ b/apps/macos/Sources/Clawdis/NotifyOverlay.swift @@ -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? + private var dismissTask: Task? + + 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() + } + } +} + diff --git a/apps/macos/Sources/ClawdisCLI/main.swift b/apps/macos/Sources/ClawdisCLI/main.swift index 45a3bda78..f38f4cc7a 100644 --- a/apps/macos/Sources/ClawdisCLI/main.swift +++ b/apps/macos/Sources/ClawdisCLI/main.swift @@ -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 --body [--sound ] [--priority ] + clawdis-mac notify --title --body [--sound ] [--priority ] [--delivery ] clawdis-mac ensure-permissions [--cap ] [--interactive] diff --git a/apps/macos/Sources/ClawdisIPC/IPC.swift b/apps/macos/Sources/ClawdisIPC/IPC.swift index 8eacc033f..64036f06f 100644 --- a/apps/macos/Sources/ClawdisIPC/IPC.swift +++ b/apps/macos/Sources/ClawdisIPC/IPC.swift @@ -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) diff --git a/docs/clawdis-mac.md b/docs/clawdis-mac.md index 6cdfd68a6..b98133fa0 100644 --- a/docs/clawdis-mac.md +++ b/docs/clawdis-mac.md @@ -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)