From 8395548077efd0ae10cbf6823ce842251c5b99dd Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 09:50:51 +0530 Subject: [PATCH 01/69] Add notification live activity to the notch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors Notification Center banners (Messages, WhatsApp, Telegram, Discord, Mail, Outlook, FaceTime by default) as a closed-state pill and an expanded compose view in the notch, using the XPC helper's existing Accessibility access to read banners via AX rather than any private notification API. - NotificationWatcher (helper): AXObserver + poll fallback over com.apple.notificationcenterui, matched by banner subrole since the containment path isn't stable across macOS releases. Reply/call actions and dismiss are driven by whatever AX actions the banner actually exposes (verified live: WhatsApp uses "Show Details"/"Send", not "Reply" — action names are not what they look like). - SystemNotificationManager: app-side state, per-app allow list, auto-dismiss timer, optional per-app system-banner suppression (closes the OS banner right after capture — there's no API to stop it rendering in the first place). - NotificationLiveActivity: closed pill (icon + status dot, or a widened code pill when a verification code is detected) and an expanded view with reply, call accept/decline, or open-in-app, depending on what the banner supports. - ContactAvatarManager: resolves sender name to a Contacts photo, falls back to a colored monogram. Exact-match only — an ambiguous name match returns no photo rather than guessing. - OTPDetector: verification-code extraction, gated on a nearby keyword rather than bare digit runs, to avoid matching phone numbers/prices/invoice numbers. Covered by an assert-based self-check that runs on every debug launch. - NotificationSettingsView: per-app enable + banner-suppress toggles, its own Settings tab. Reply-send and OTP detection were both verified against live banners (WhatsApp send, and terminal-notifier-posted OTP-shaped banners run through the real capture path), not just unit-tested in isolation. --- .../BoringNotchXPCHelper.swift | 54 ++ .../BoringNotchXPCHelperProtocol.swift | 23 + .../NotificationWatcher.swift | 333 ++++++++++++ BoringNotchXPCHelper/main.swift | 2 +- boringNotch.xcodeproj/project.pbxproj | 36 +- boringNotch/BoringViewCoordinator.swift | 20 + boringNotch/ContentView.swift | 44 +- .../BoringNotchXPCHelperProtocol.swift | 22 + .../XPCHelperClient/XPCHelperClient.swift | 142 +++++- boringNotch/boringNotch.entitlements | 2 + boringNotch/boringNotchApp.swift | 11 + .../Notch/NotificationLiveActivity.swift | 482 ++++++++++++++++++ .../components/NotificationDebugWindow.swift | 91 ++++ .../components/Settings/SettingsView.swift | 5 + .../Views/NotificationSettingsView.swift | 108 ++++ boringNotch/helpers/OTPDetector.swift | 167 ++++++ .../managers/ContactAvatarManager.swift | 123 +++++ .../managers/SystemNotificationManager.swift | 252 +++++++++ boringNotch/models/Constants.swift | 27 + 19 files changed, 1916 insertions(+), 28 deletions(-) create mode 100644 BoringNotchXPCHelper/NotificationWatcher.swift create mode 100644 boringNotch/components/Notch/NotificationLiveActivity.swift create mode 100644 boringNotch/components/NotificationDebugWindow.swift create mode 100644 boringNotch/components/Settings/Views/NotificationSettingsView.swift create mode 100644 boringNotch/helpers/OTPDetector.swift create mode 100644 boringNotch/managers/ContactAvatarManager.swift create mode 100644 boringNotch/managers/SystemNotificationManager.swift diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift index 2e13379e2..b7f08acff 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift @@ -78,6 +78,60 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { } } + // MARK: - Notification Center banners + + /// One watcher for the whole helper: `BoringNotchXPCHelper` is created per + /// connection, the AX observer must not be. + private static let watcher = NotificationWatcher() + + @objc func startNotificationWatching(with reply: @escaping (Bool) -> Void) { + // Capture the delegate for this connection before hopping queues — + // NSXPCConnection.current() is only valid inside the incoming call. + let delegate = NSXPCConnection.current()?.remoteObjectProxy as? BoringNotchXPCHelperDelegate + + // The AX observer needs a live run loop; the helper's is on main. + DispatchQueue.main.async { + let watcher = Self.watcher + watcher.onBanner = { notification in + delegate?.notificationDidAppear([ + "token": notification.token, + "appName": notification.appName ?? "", + "bundleID": notification.bundleID ?? "", + "title": notification.title ?? "", + "subtitle": notification.subtitle ?? "", + "body": notification.body ?? "", + "actions": notification.actions.joined(separator: "\n") + ]) + } + watcher.onBannerGone = { delegate?.notificationDidDisappear($0) } + reply(watcher.start()) + } + } + + @objc func stopNotificationWatching() { + DispatchQueue.main.async { Self.watcher.stop() } + } + + @objc func replyToNotification(_ token: String, text: String, with reply: @escaping (Bool) -> Void) { + DispatchQueue.main.async { reply(Self.watcher.reply(token: token, text: text)) } + } + + @objc func performNotificationAction(_ token: String, name: String, with reply: @escaping (Bool) -> Void) { + DispatchQueue.main.async { reply(Self.watcher.performAction(token: token, name: name)) } + } + + @objc func openNotification(_ token: String, with reply: @escaping (Bool) -> Void) { + DispatchQueue.main.async { reply(Self.watcher.open(token: token)) } + } + + @objc func notificationDebugDump(with reply: @escaping (String) -> Void) { + DispatchQueue.main.async { reply(Self.watcher.debugDump()) } + } + + @objc func dismissNotification(_ token: String, with reply: @escaping (Bool) -> Void) { + DispatchQueue.main.async { reply(Self.watcher.dismiss(token: token)) } + } + private class KeyboardBrightnessClient { private static let keyboardID: UInt64 = 1 private var clientInstance: NSObject? diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift b/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift index 1fc0cb244..aa0c3f40b 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift @@ -59,8 +59,31 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { func stopLunarEventStream() /// Write Lunar's hideOSD preference (disable/enable Lunar's OSD when we replace it). func setLunarOSDHidden(_ hide: Bool, with reply: @escaping (Bool) -> Void) + // Notification Center banner observation (performed by the helper) + func startNotificationWatching(with reply: @escaping (Bool) -> Void) + func stopNotificationWatching() + func replyToNotification(_ token: String, text: String, with reply: @escaping (Bool) -> Void) + func performNotificationAction(_ token: String, name: String, with reply: @escaping (Bool) -> Void) + func openNotification(_ token: String, with reply: @escaping (Bool) -> Void) + func dismissNotification(_ token: String, with reply: @escaping (Bool) -> Void) + func notificationDebugDump(with reply: @escaping (String) -> Void) } +/// Pushed from the helper back to the app. The app sets an object conforming to +/// this as its connection's `exportedObject`. +@objc protocol BoringNotchXPCHelperDelegate { + /// Keys: token, appName, bundleID, title, subtitle, body, actions + /// (`actions` is newline-joined). A plain string dictionary keeps the XPC + /// interface free of custom coded types. + func notificationDidAppear(_ payload: [String: String]) + func notificationDidDisappear(_ token: String) +} + +/// A connection has exactly one exported object, and the helper calls back for +/// both Lunar events and notification banners — so the app vends a single +/// object conforming to both. +@objc protocol BoringNotchXPCAppDelegate: BoringNotchXPCHelperLunarListener, BoringNotchXPCHelperDelegate {} + /* To use the service from an application or other process, use NSXPCConnection to establish a connection to the service by doing something like this: diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift new file mode 100644 index 000000000..95ca99094 --- /dev/null +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -0,0 +1,333 @@ +// +// NotificationWatcher.swift +// BoringNotchXPCHelper +// +// Observes Notification Center banners through the Accessibility API and +// streams them to the app. This lives in the helper because the app itself is +// sandboxed, and a sandboxed process cannot drive AX on another application. +// +// Verified hierarchy (macOS 26): +// AXWindow "Notification Center" (subrole AXSystemDialog) +// └ … groups … > AXScrollArea (identifier AXNotificationListItems) +// └ banner (subrole AXNotificationCenterBanner) +// · AXIdentifier = per-notification UUID +// · AXAttributedDescription = "App, Title, Subtitle, Body" +// └ AXStaticText identifier "title" / "subtitle" / "body" (AXValue) +// +// The window only exists while banners are on screen, so a banner is only +// actionable while visible. +// + +import Foundation +import ApplicationServices +import AppKit + +private let notificationCenterBundleID = "com.apple.notificationcenterui" +private let bannerSubroles: Set = ["AXNotificationCenterBanner", "AXNotificationCenterAlert"] + +struct CapturedNotification { + let token: String // banner AXIdentifier (UUID), stable while on screen + let appName: String? + let bundleID: String? + let title: String? + let subtitle: String? + let body: String? + let actions: [String] // AX action names, e.g. AXPress / AXReply / button titles +} + +final class NotificationWatcher { + var onBanner: ((CapturedNotification) -> Void)? + var onBannerGone: ((String) -> Void)? + + private var observer: AXObserver? + private var appElement: AXUIElement? + private var pollTimer: Timer? + private var live: [String: AXUIElement] = [:] + + var isRunning: Bool { appElement != nil } + + // MARK: - Lifecycle + + @discardableResult + func start() -> Bool { + guard AXIsProcessTrusted() else { return false } + guard !isRunning else { return true } + guard let notificationCenter = NSRunningApplication.runningApplications( + withBundleIdentifier: notificationCenterBundleID + ).first else { return false } + + let app = AXUIElementCreateApplication(notificationCenter.processIdentifier) + appElement = app + + var created: AXObserver? + let callback: AXObserverCallback = { _, _, _, context in + guard let context else { return } + Unmanaged.fromOpaque(context).takeUnretainedValue().scan() + } + if AXObserverCreate(notificationCenter.processIdentifier, callback, &created) == .success, + let created { + let context = Unmanaged.passUnretained(self).toOpaque() + for name in [kAXWindowCreatedNotification, kAXCreatedNotification, kAXUIElementDestroyedNotification] { + AXObserverAddNotification(created, app, name as CFString, context) + } + CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(created), .defaultMode) + observer = created + } + + // ponytail: the AX notifications above fire inconsistently for the + // banner window, so a light poll backstops them. Remove the timer if a + // single notification name ever proves reliable across releases. + let timer = Timer(timeInterval: 0.35, repeats: true) { [weak self] _ in self?.scan() } + RunLoop.current.add(timer, forMode: .common) + pollTimer = timer + + scan() + return true + } + + func stop() { + pollTimer?.invalidate() + pollTimer = nil + if let observer { + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(observer), .defaultMode) + } + observer = nil + appElement = nil + live.removeAll() + } + + // MARK: - Scanning + + private func scan() { + guard let appElement else { return } + var seen: Set = [] + + for window in (appElement[kAXWindowsAttribute] as? [AXUIElement]) ?? [] { + guard window[kAXSubroleAttribute] as? String == "AXSystemDialog" else { continue } + for banner in banners(in: window) { + guard let token = banner[kAXIdentifierAttribute] as? String else { continue } + seen.insert(token) + guard live[token] == nil else { continue } + live[token] = banner + onBanner?(capture(banner, token: token)) + } + } + + for token in live.keys where !seen.contains(token) { + live[token] = nil + onBannerGone?(token) + } + } + + /// Match on subrole rather than a fixed containment path — the wrapping + /// groups change between macOS releases, the subrole has not. + private func banners(in element: AXUIElement, depth: Int = 0) -> [AXUIElement] { + guard depth < 14 else { return [] } + if let subrole = element[kAXSubroleAttribute] as? String, bannerSubroles.contains(subrole) { + return [element] + } + return ((element[kAXChildrenAttribute] as? [AXUIElement]) ?? []) + .flatMap { banners(in: $0, depth: depth + 1) } + } + + // MARK: - Reading a banner + + private func capture(_ banner: AXUIElement, token: String) -> CapturedNotification { + var parts: [String: String] = [:] + collectLabelledText(in: banner, into: &parts) + + // AXAttributedDescription reads "App, Title, Subtitle, Body"; only its + // first field (the app name) isn't available as a labelled child. + let appName = (banner["AXAttributedDescription"] as? NSAttributedString)?.string + .components(separatedBy: ",").first? + .trimmingCharacters(in: .whitespaces) + .trimmingCharacters(in: CharacterSet(charactersIn: "\u{200E}\u{2068}\u{2069}")) + + return CapturedNotification( + token: token, + appName: appName, + bundleID: appName.flatMap(bundleID(forAppNamed:)), + title: parts["title"], + subtitle: parts["subtitle"], + body: parts["body"], + actions: availableActions(on: banner) + ) + } + + private func collectLabelledText(in element: AXUIElement, into parts: inout [String: String], depth: Int = 0) { + guard depth < 10 else { return } + if let identifier = element[kAXIdentifierAttribute] as? String, + ["title", "subtitle", "body"].contains(identifier), + let value = element[kAXValueAttribute] as? String { + parts[identifier] = value.trimmingCharacters( + in: CharacterSet(charactersIn: "\u{2068}\u{2069}\u{200E}").union(.whitespacesAndNewlines) + ) + } + for child in (element[kAXChildrenAttribute] as? [AXUIElement]) ?? [] { + collectLabelledText(in: child, into: &parts, depth: depth + 1) + } + } + + /// Reply/Accept/Decline are exposed either as AX actions on the banner or + /// as descendant buttons, and which one varies by app and notification + /// type — so report both rather than guessing. + private func availableActions(on banner: AXUIElement) -> [String] { + // AXPress is the "open the notification" default, not a user-facing + // choice — the notch exposes that as tapping the notification itself. + var labels = actionNames(of: banner) + .map(actionLabel) + .filter { $0 != kAXPressAction } + for button in descendants(of: banner, matching: [kAXButtonRole, kAXMenuButtonRole]) { + if let title = button[kAXTitleAttribute] as? String, !title.isEmpty { + labels.append(title) + } + } + return labels + } + + /// Maps a user-facing label back to the raw action string AX expects. + private func rawAction(on element: AXUIElement, matching predicate: (String) -> Bool) -> String? { + actionNames(of: element).first { predicate(actionLabel($0)) } + } + + /// Raw action names as AX reports them. Notification Center returns records + /// rather than plain names for its custom actions: + /// "Name:Reply\nTarget:0x0\nSelector:(null)" + /// The raw string is what `AXUIElementPerformAction` needs, so keep it and + /// use `actionLabel` for anything user-facing. + private func actionNames(of element: AXUIElement) -> [String] { + var names: CFArray? + guard AXUIElementCopyActionNames(element, &names) == .success else { return [] } + return (names as? [String]) ?? [] + } + + private func actionLabel(_ raw: String) -> String { + guard raw.hasPrefix("Name:") else { return raw } + return String(raw.dropFirst("Name:".count).prefix { !$0.isNewline }) + } + + private func descendants(of element: AXUIElement, matching roles: [String], depth: Int = 0) -> [AXUIElement] { + guard depth < 10 else { return [] } + var found: [AXUIElement] = [] + if depth > 0, let role = element[kAXRoleAttribute] as? String, roles.contains(role) { + found.append(element) + } + for child in (element[kAXChildrenAttribute] as? [AXUIElement]) ?? [] { + found += descendants(of: child, matching: roles, depth: depth + 1) + } + return found + } + + private func bundleID(forAppNamed name: String) -> String? { + NSWorkspace.shared.runningApplications.first { + $0.localizedName == name + }?.bundleIdentifier + } + + // MARK: - Acting on a banner + + /// Types into the banner's reply field and submits it. Only works while + /// the banner is on screen and the source app offers a reply field; the + /// caller falls back to opening the app otherwise. + /// + /// Verified against a live WhatsApp banner (2026-08-12): Notification + /// Center does not expose a "Reply" action. The field is revealed by + /// "Show Details" (collapsed) / "Hide Details" (already expanded), and + /// submitted with a "Send" action on the banner itself — not + /// kAXConfirmAction on the field, which is untested and unreliable across + /// text-area implementations. + func reply(token: String, text: String) -> Bool { + guard let banner = live[token] else { return false } + + if replyField(in: banner) == nil { + if let action = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("details") }) { + AXUIElementPerformAction(banner, action as CFString) + } else if let button = descendants(of: banner, matching: [kAXButtonRole]).first(where: { + ($0[kAXTitleAttribute] as? String)?.lowercased().contains("reply") == true + }) { + AXUIElementPerformAction(button, kAXPressAction as CFString) + } + RunLoop.current.run(until: Date().addingTimeInterval(0.4)) + } + + guard let field = replyField(in: banner) else { return false } + AXUIElementSetAttributeValue(field, kAXFocusedAttribute as CFString, kCFBooleanTrue) + guard AXUIElementSetAttributeValue(field, kAXValueAttribute as CFString, text as CFString) == .success + else { return false } + + if let send = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("send") }) { + return AXUIElementPerformAction(banner, send as CFString) == .success + } + return AXUIElementPerformAction(field, kAXConfirmAction as CFString) == .success + } + + private func replyField(in element: AXUIElement, depth: Int = 0) -> AXUIElement? { + descendants(of: element, matching: [kAXTextFieldRole, kAXTextAreaRole]).first + } + + /// Performs a named AX action on the banner, or presses the button with + /// that title (Accept / Decline on call notifications). + func performAction(token: String, name: String) -> Bool { + guard let banner = live[token] else { return false } + if let raw = rawAction(on: banner, matching: { $0 == name }) { + return AXUIElementPerformAction(banner, raw as CFString) == .success + } + guard let button = descendants(of: banner, matching: [kAXButtonRole, kAXMenuButtonRole]).first(where: { + $0[kAXTitleAttribute] as? String == name + }) else { return false } + return AXUIElementPerformAction(button, kAXPressAction as CFString) == .success + } + + /// Opens the notification in its source app (the banner's default action). + func open(token: String) -> Bool { + guard let banner = live[token] else { return false } + return AXUIElementPerformAction(banner, kAXPressAction as CFString) == .success + } + + /// Clears the banner from screen. Notification Center exposes this as a + /// "Close" action rather than the AXRemove one might expect. + func dismiss(token: String) -> Bool { + guard let banner = live[token], + let raw = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("close") }) + else { return false } + return AXUIElementPerformAction(banner, raw as CFString) == .success + } + + // MARK: - Debug + + /// Full attribute dump of the banner window, for the debug window. + func debugDump() -> String { + guard let appElement else { return "watcher not running" } + var out = "" + for window in (appElement[kAXWindowsAttribute] as? [AXUIElement]) ?? [] { + guard window[kAXSubroleAttribute] as? String == "AXSystemDialog" else { continue } + describe(window, depth: 0, into: &out) + } + return out.isEmpty ? "no banners on screen" : out + } + + private func describe(_ element: AXUIElement, depth: Int, into out: inout String) { + guard depth < 14 else { return } + let pad = String(repeating: " ", count: depth) + out += "\(pad)\(element[kAXRoleAttribute] as? String ?? "?") \(element[kAXSubroleAttribute] as? String ?? "")" + out += " actions=\(actionNames(of: element))\n" + var names: CFArray? + if AXUIElementCopyAttributeNames(element, &names) == .success, let names = names as? [String] { + for name in names where name != kAXChildrenAttribute && name != kAXParentAttribute { + guard let value = element[name] else { continue } + out += "\(pad) · \(name) = \(String(describing: value).prefix(200))\n" + } + } + for child in (element[kAXChildrenAttribute] as? [AXUIElement]) ?? [] { + describe(child, depth: depth + 1, into: &out) + } + } +} + +private extension AXUIElement { + subscript(attribute: String) -> Any? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(self, attribute as CFString, &value) == .success else { return nil } + return value + } +} diff --git a/BoringNotchXPCHelper/main.swift b/BoringNotchXPCHelper/main.swift index f8afea721..10aa34bcb 100644 --- a/BoringNotchXPCHelper/main.swift +++ b/BoringNotchXPCHelper/main.swift @@ -17,7 +17,7 @@ class ServiceDelegate: NSObject, NSXPCListenerDelegate { newConnection.exportedInterface = NSXPCInterface(with: (any BoringNotchXPCHelperProtocol).self) // Configure the interface for callbacks from the helper to the app. - let listenerInterface = NSXPCInterface(with: (any BoringNotchXPCHelperLunarListener).self) + let listenerInterface = NSXPCInterface(with: (any BoringNotchXPCAppDelegate).self) listenerInterface.setClasses( NSSet(array: [BNLunarBrightnessEvent.self]) as! Set, for: #selector(BoringNotchXPCHelperLunarListener.lunarEventDidUpdate(_:)), diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 500c7cfe6..ac97f4871 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -98,6 +98,10 @@ 14288E0C2C6F8EC000B9F80C /* AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14288E0B2C6F8EC000B9F80C /* AppIcons.swift */; }; 1443E7F32C609DCE0027C1FC /* matters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1443E7F22C609DCE0027C1FC /* matters.swift */; }; 147163982C5D35B70068B555 /* MusicVisualizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147163972C5D35B70068B555 /* MusicVisualizer.swift */; }; + AA01SNM12E7A0001 /* SystemNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01SNM22E7A0001 /* SystemNotificationManager.swift */; }; + AA01NDW12E7A0001 /* NotificationDebugWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01NDW22E7A0001 /* NotificationDebugWindow.swift */; }; + AA01NLA12E7A0001 /* NotificationLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01NLA22E7A0001 /* NotificationLiveActivity.swift */; }; + AA03OTP12E7A0001 /* OTPDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA03OTP22E7A0001 /* OTPDetector.swift */; }; 1471639A2C5D35FF0068B555 /* MusicManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147163992C5D35FF0068B555 /* MusicManager.swift */; }; 1471A8592C6281BD0058408D /* BoringNotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */; }; 149E0B972C737D00006418B1 /* WebcamManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149E0B962C737D00006418B1 /* WebcamManager.swift */; }; @@ -272,6 +276,8 @@ 11DB26692EDD0CDF001EA0CF /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = ""; }; 11DB266A2EDD0CDF001EA0CF /* BatterySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatterySettingsView.swift; sourceTree = ""; }; 11DB266B2EDD0CDF001EA0CF /* CalendarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSettingsView.swift; sourceTree = ""; }; + AA02NSV22E7A0001 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; + AA02NSV12E7A0001 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA02NSV22E7A0001 /* NotificationSettingsView.swift */; }; 11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; 11DB266D2EDD0CDF001EA0CF /* OSDSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDSettingsView.swift; sourceTree = ""; }; 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSettingsView.swift; sourceTree = ""; }; @@ -291,6 +297,12 @@ 1443E7F22C609DCE0027C1FC /* matters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = matters.swift; sourceTree = ""; }; 1443E7F42C609E650027C1FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 147163972C5D35B70068B555 /* MusicVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicVisualizer.swift; sourceTree = ""; }; + AA01SNM22E7A0001 /* SystemNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemNotificationManager.swift; sourceTree = ""; }; + AA01NDW22E7A0001 /* NotificationDebugWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugWindow.swift; sourceTree = ""; }; + AA01NLA22E7A0001 /* NotificationLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationLiveActivity.swift; sourceTree = ""; }; + AA02CAM22E7A0001 /* ContactAvatarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactAvatarManager.swift; sourceTree = ""; }; + AA02CAM12E7A0001 /* ContactAvatarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA02CAM22E7A0001 /* ContactAvatarManager.swift */; }; + AA03OTP22E7A0001 /* OTPDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPDetector.swift; sourceTree = ""; }; 147163992C5D35FF0068B555 /* MusicManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicManager.swift; sourceTree = ""; }; 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringNotchWindow.swift; sourceTree = ""; }; 149E0B962C737D00006418B1 /* WebcamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamManager.swift; sourceTree = ""; }; @@ -556,6 +568,7 @@ 11DB26692EDD0CDF001EA0CF /* AppearanceSettingsView.swift */, 11DB266A2EDD0CDF001EA0CF /* BatterySettingsView.swift */, 11DB266B2EDD0CDF001EA0CF /* CalendarSettingsView.swift */, + AA02NSV22E7A0001 /* NotificationSettingsView.swift */, 11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */, 11DB266D2EDD0CDF001EA0CF /* OSDSettingsView.swift */, 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */, @@ -584,6 +597,7 @@ 1153BD972D9881F900979FB0 /* AppleScriptHelper.swift */, 14288DD62C6E015000B9F80C /* AudioPlayer.swift */, 5955950C2E900ED800C66711 /* ApplicationRelauncher.swift */, + AA03OTP22E7A0001 /* OTPDetector.swift */, 14288E0B2C6F8EC000B9F80C /* AppIcons.swift */, AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */, ); @@ -612,6 +626,7 @@ isa = PBXGroup; children = ( 11985BDF2F37A3C800F81585 /* OSD */, + AA01NDW22E7A0001 /* NotificationDebugWindow.swift */, B141C23B2CA5F50900AC8CC8 /* Onboarding */, 14C08BB72C8DE49E000F8AA0 /* Calendar */, 9A987A042C73CA66005CA465 /* Shelf */, @@ -636,6 +651,8 @@ 11D58EA12E760AE100FA8377 /* ImageService.swift */, F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */, 112FB7342CCF16F70015238C /* NotchSpaceManager.swift */, + AA01SNM22E7A0001 /* SystemNotificationManager.swift */, + AA02CAM22E7A0001 /* ContactAvatarManager.swift */, 147163992C5D35FF0068B555 /* MusicManager.swift */, F1F2A0A200000000000000F2 /* AudioCaptureManager.swift */, 149E0B962C737D00006418B1 /* WebcamManager.swift */, @@ -829,6 +846,7 @@ B186542F2C6F455E000B926A /* Notch */ = { isa = PBXGroup; children = ( + AA01NLA22E7A0001 /* NotificationLiveActivity.swift */, 1194E8862EA6DDA7009C82D6 /* BoringNotchSkyLightWindow.swift */, 1160F8D72DD98230006FBB94 /* NotchShape.swift */, 9AB0C6BB2C73C9CB00F7CD30 /* NotchHomeView.swift */, @@ -1101,6 +1119,11 @@ 5917FD112E57891600E87F1C /* MediaKeyInterceptor.swift in Sources */, 14C08BC12C8E03AD000F8AA0 /* NSImage+Extensions.swift in Sources */, 149E0B9A2C737D40006418B1 /* WebcamView.swift in Sources */, + AA01SNM12E7A0001 /* SystemNotificationManager.swift in Sources */, + AA01NDW12E7A0001 /* NotificationDebugWindow.swift in Sources */, + AA01NLA12E7A0001 /* NotificationLiveActivity.swift in Sources */, + AA02CAM12E7A0001 /* ContactAvatarManager.swift in Sources */, + AA03OTP12E7A0001 /* OTPDetector.swift in Sources */, 1471639A2C5D35FF0068B555 /* MusicManager.swift in Sources */, F1F2A0A100000000000000F1 /* AudioCaptureManager.swift in Sources */, B1B112932C6A577E00093D8F /* MouseTracker.swift in Sources */, @@ -1124,6 +1147,7 @@ 118EBE272E92DE8400D54B5A /* NSMenu+AssociatedObject.swift in Sources */, B1CE8CFE2C6F659400DD9871 /* KeyboardShortcutsHelper.swift in Sources */, 11DB26732EDD0CDF001EA0CF /* ShortcutsSettingsView.swift in Sources */, + AA02NSV12E7A0001 /* NotificationSettingsView.swift in Sources */, 11DB26742EDD0CDF001EA0CF /* GeneralSettingsView.swift in Sources */, 11DB26752EDD0CDF001EA0CF /* ShelfSettingsView.swift in Sources */, 11DB26762EDD0CDF001EA0CF /* AppearanceSettingsView.swift in Sources */, @@ -1398,13 +1422,14 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = boringNotch/boringNotch.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"boringNotch/Preview Content\""; DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = JPWMG84CH8; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; @@ -1427,6 +1452,7 @@ INFOPLIST_KEY_NSAppleEventsUsageDescription = "This app uses AppleEvents to control music"; INFOPLIST_KEY_NSCalendarsUsageDescription = "This app uses the calendar to display your calendar events"; INFOPLIST_KEY_NSCameraUsageDescription = "This app uses the camera to display a live camera view"; + INFOPLIST_KEY_NSContactsUsageDescription = "This app matches notification senders to your contacts to show their photo"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSRemindersUsageDescription = "This app uses Reminders to display your scheduled reminder in the calendar"; LD_RUNPATH_SEARCH_PATHS = ( @@ -1465,13 +1491,14 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = boringNotch/boringNotch.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"boringNotch/Preview Content\""; DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = JPWMG84CH8; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; @@ -1493,6 +1520,7 @@ INFOPLIST_KEY_NSAppleEventsUsageDescription = "This app uses AppleEvents to control music"; INFOPLIST_KEY_NSCalendarsUsageDescription = "This app uses the calendar to display your calendar events"; INFOPLIST_KEY_NSCameraUsageDescription = "This app uses the camera to display a live camera view"; + INFOPLIST_KEY_NSContactsUsageDescription = "This app matches notification senders to your contacts to show their photo"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSRemindersUsageDescription = "This app uses Reminders to display your scheduled reminder in the calendar"; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/boringNotch/BoringViewCoordinator.swift b/boringNotch/BoringViewCoordinator.swift index 654c34936..db181a448 100644 --- a/boringNotch/BoringViewCoordinator.swift +++ b/boringNotch/BoringViewCoordinator.swift @@ -101,6 +101,7 @@ class BoringViewCoordinator: ObservableObject { private var osdReplacementCancellable: AnyCancellable? private var boringShelfCancellable: AnyCancellable? private var osdSourceCancellables: [AnyCancellable] = [] + private var notificationLiveActivityCancellable: AnyCancellable? private init() { // Perform migration from name-based to UUID-based storage @@ -173,12 +174,31 @@ class BoringViewCoordinator: ObservableObject { } } + // Observe changes to the notification live activity + notificationLiveActivityCancellable = Defaults.publisher(.notificationLiveActivity) + .sink { change in + Task { @MainActor in + if change.newValue { + await SystemNotificationManager.shared.start() + if !SystemNotificationManager.shared.isWatching { + Defaults[.notificationLiveActivity] = false + } + } else { + SystemNotificationManager.shared.stop() + } + } + } + Task { @MainActor in helloAnimationRunning = firstLaunch if Defaults[.osdReplacement] { await MediaKeyInterceptor.shared.start(promptIfNeeded: false) } + + if Defaults[.notificationLiveActivity] { + await SystemNotificationManager.shared.start() + } self.applyOSDSources() } } diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 07896651c..9db5936ca 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -23,6 +23,7 @@ struct ContentView: View { @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var brightnessManager = BrightnessManager.shared @ObservedObject var volumeManager = VolumeManager.shared + @ObservedObject var notificationManager = SystemNotificationManager.shared @State private var hoverTask: Task? @State private var isHovering: Bool = false @State private var anyDropDebounceTask: Task? @@ -92,6 +93,16 @@ struct ContentView: View { && vm.notchState == .closed && Defaults[.showPowerStatusNotifications] { chinWidth = 640 + } else if notificationManager.activeNotification?.detectedCode != nil && vm.notchState == .closed + && !vm.hideOnClosed + { + // Wide enough for the code itself plus a copy affordance, without + // going as far as the battery pill's 640. + chinWidth = 420 + } else if notificationManager.activeNotification != nil && vm.notchState == .closed + && !vm.hideOnClosed + { + chinWidth += (2 * max(0, vm.effectiveClosedNotchHeight - 12) + 20) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed @@ -328,6 +339,9 @@ struct ContentView: View { .frame(width: 76, alignment: .trailing) } .frame(height: displayClosedNotchHeight, alignment: .center) + } else if let notification = notificationManager.activeNotification, vm.notchState == .closed, !vm.hideOnClosed { + NotificationLiveActivity(notification: notification) + .transition(.opacity) } else if coordinator.shouldShowSneakPeek(on: vm.screenUUID) && Defaults[.inlineOSD] && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .battery) && vm.notchState == .closed { InlineOSD( type: coordinator.binding(for: vm.screenUUID).type, @@ -400,18 +414,24 @@ struct ContentView: View { .zIndex(1) if vm.notchState == .open { VStack { - switch coordinator.currentView { - case .home: - NotchHomeView( - albumArtNamespace: albumArtNamespace, - horizontalMediaGestureFeedback: horizontalMediaGestureFeedback, - isHoveringMusicArea: $isHoveringMusicArea - ) - case .shelf: - ShelfView( - dropInteraction: vm.dropInteraction, - animation: vm.animation - ) + // An open notch with a live notification is showing the + // reply UI — the usual tabs can wait until it's dismissed. + if let notification = notificationManager.activeNotification { + NotificationExpandedView(notification: notification) + } else { + switch coordinator.currentView { + case .home: + NotchHomeView( + albumArtNamespace: albumArtNamespace, + horizontalMediaGestureFeedback: horizontalMediaGestureFeedback, + isHoveringMusicArea: $isHoveringMusicArea + ) + case .shelf: + ShelfView( + dropInteraction: vm.dropInteraction, + animation: vm.animation + ) + } } } .transition( diff --git a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift b/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift index 2b1f7ce69..5b10079af 100644 --- a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift +++ b/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift @@ -59,4 +59,26 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { func stopLunarEventStream() /// Write Lunar's hideOSD preference (disable/enable Lunar's OSD when we replace it). func setLunarOSDHidden(_ hide: Bool, with reply: @escaping (Bool) -> Void) + // Notification Center banner observation (performed by the helper) + func startNotificationWatching(with reply: @escaping (Bool) -> Void) + func stopNotificationWatching() + func replyToNotification(_ token: String, text: String, with reply: @escaping (Bool) -> Void) + func performNotificationAction(_ token: String, name: String, with reply: @escaping (Bool) -> Void) + func openNotification(_ token: String, with reply: @escaping (Bool) -> Void) + func dismissNotification(_ token: String, with reply: @escaping (Bool) -> Void) + func notificationDebugDump(with reply: @escaping (String) -> Void) } + +/// Pushed from the helper back to the app. The app sets an object conforming to +/// this as its connection's `exportedObject`. +@objc protocol BoringNotchXPCHelperDelegate { + /// Keys: token, appName, bundleID, title, subtitle, body, actions + /// (`actions` is newline-joined). + func notificationDidAppear(_ payload: [String: String]) + func notificationDidDisappear(_ token: String) +} + +/// A connection has exactly one exported object, and the helper calls back for +/// both Lunar events and notification banners — so the app vends a single +/// object conforming to both. +@objc protocol BoringNotchXPCAppDelegate: BoringNotchXPCHelperLunarListener, BoringNotchXPCHelperDelegate {} diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index e3ad4b0fc..2833abc62 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -10,6 +10,7 @@ final class XPCHelperClient: NSObject { private var remoteService: RemoteXPCService? private var connection: NSXPCConnection? private var lastKnownAuthorization: Bool? + private let notificationDelegate = NotificationXPCDelegate() private var monitoringTask: Task? private var lunarListener: BoringNotchXPCHelperLunarListener? private var hasLunarListener: Bool = false @@ -35,14 +36,11 @@ final class XPCHelperClient: NSObject { let conn = NSXPCConnection(serviceName: serviceName) - if needsListener, let lunarListener { - let listenerInterface = makeLunarListenerInterface() - conn.exportedInterface = listenerInterface - conn.exportedObject = lunarListener - hasLunarListener = true - } else { - hasLunarListener = false - } + // One exported object serves both callback protocols. + notificationDelegate.lunarListener = lunarListener + conn.exportedInterface = makeAppDelegateInterface() + conn.exportedObject = notificationDelegate + hasLunarListener = needsListener && lunarListener != nil conn.interruptionHandler = { [weak self] in Task { @MainActor in @@ -61,7 +59,7 @@ final class XPCHelperClient: NSObject { } conn.resume() - + let service = RemoteXPCService( connection: conn, remoteInterface: BoringNotchXPCHelperProtocol.self @@ -77,8 +75,8 @@ final class XPCHelperClient: NSObject { remoteService } - private func makeLunarListenerInterface() -> NSXPCInterface { - let interface = NSXPCInterface(with: (any BoringNotchXPCHelperLunarListener).self) + private func makeAppDelegateInterface() -> NSXPCInterface { + let interface = NSXPCInterface(with: (any BoringNotchXPCAppDelegate).self) interface.setClasses( NSSet(array: [BNLunarBrightnessEvent.self]) as! Set, for: #selector(BoringNotchXPCHelperLunarListener.lunarEventDidUpdate(_:)), @@ -367,3 +365,125 @@ final class XPCHelperClient: NSObject { } } +// MARK: - Notification Center banners + +/// The app's single exported XPC object. Banner pushes are republished as local +/// notifications; Lunar events are forwarded to whichever listener the OSD code +/// registered, since both callbacks share one connection. +final class NotificationXPCDelegate: NSObject, BoringNotchXPCAppDelegate { + var lunarListener: BoringNotchXPCHelperLunarListener? + + func lunarEventDidUpdate(_ event: BNLunarBrightnessEvent) { + lunarListener?.lunarEventDidUpdate(event) + } + + func lunarStreamDidStop(_ reason: String?) { + lunarListener?.lunarStreamDidStop(reason) + } + + func notificationDidAppear(_ payload: [String: String]) { + NotificationCenter.default.post( + name: .systemNotificationDidAppear, object: nil, userInfo: payload + ) + } + + func notificationDidDisappear(_ token: String) { + NotificationCenter.default.post( + name: .systemNotificationDidDisappear, object: nil, userInfo: ["token": token] + ) + } +} + +extension XPCHelperClient { + nonisolated func startNotificationWatching() async -> Bool { + do { + let service = await MainActor.run { ensureRemoteService() } + return try await service.withContinuation { service, continuation in + service.startNotificationWatching { started in + continuation.resume(returning: started) + } + } + } catch { + return false + } + } + + nonisolated func stopNotificationWatching() { + Task { + let service = await MainActor.run { ensureRemoteService() } + try? await service.withService { $0.stopNotificationWatching() } + } + } + + nonisolated func replyToNotification(token: String, text: String) async -> Bool { + do { + let service = await MainActor.run { ensureRemoteService() } + return try await service.withContinuation { service, continuation in + service.replyToNotification(token, text: text) { sent in + continuation.resume(returning: sent) + } + } + } catch { + return false + } + } + + nonisolated func performNotificationAction(token: String, name: String) async -> Bool { + do { + let service = await MainActor.run { ensureRemoteService() } + return try await service.withContinuation { service, continuation in + service.performNotificationAction(token, name: name) { done in + continuation.resume(returning: done) + } + } + } catch { + return false + } + } + + nonisolated func openNotification(token: String) async -> Bool { + do { + let service = await MainActor.run { ensureRemoteService() } + return try await service.withContinuation { service, continuation in + service.openNotification(token) { opened in + continuation.resume(returning: opened) + } + } + } catch { + return false + } + } + + nonisolated func dismissNotification(token: String) async -> Bool { + do { + let service = await MainActor.run { ensureRemoteService() } + return try await service.withContinuation { service, continuation in + service.dismissNotification(token) { dismissed in + continuation.resume(returning: dismissed) + } + } + } catch { + return false + } + } + + nonisolated func notificationDebugDump() async -> String { + do { + let service = await MainActor.run { ensureRemoteService() } + return try await service.withContinuation { service, continuation in + service.notificationDebugDump { dump in + continuation.resume(returning: dump) + } + } + } catch { + return "xpc error: \(error)" + } + } +} + +extension Notification.Name { + static let systemNotificationDidAppear = Notification.Name("systemNotificationDidAppear") + static let systemNotificationDidDisappear = Notification.Name("systemNotificationDidDisappear") +} + + diff --git a/boringNotch/boringNotch.entitlements b/boringNotch/boringNotch.entitlements index c903362a9..57acc4681 100644 --- a/boringNotch/boringNotch.entitlements +++ b/boringNotch/boringNotch.entitlements @@ -22,6 +22,8 @@ com.apple.security.personal-information.calendars + com.apple.security.personal-information.addressbook + com.apple.security.temporary-exception.apple-events com.spotify.client diff --git a/boringNotch/boringNotchApp.swift b/boringNotch/boringNotchApp.swift index c3b78a6b6..bdc5c552a 100644 --- a/boringNotch/boringNotchApp.swift +++ b/boringNotch/boringNotchApp.swift @@ -22,6 +22,9 @@ struct DynamicNotchApp: App { let updaterController: SPUStandardUpdaterController init() { + #if DEBUG + OTPDetector.runSelfCheck() + #endif let sparkleUpdaterDelegate = BoringSparkleUpdaterDelegate() self.sparkleUpdaterDelegate = sparkleUpdaterDelegate updaterController = SPUStandardUpdaterController( @@ -41,6 +44,10 @@ struct DynamicNotchApp: App { } .keyboardShortcut(KeyEquivalent(","), modifiers: .command) CheckForUpdatesView(updater: updaterController.updater) + Button("Notification Debug") { + openWindow(id: "notification-debug") + NSApp.activate(ignoringOtherApps: true) + } Divider() Button("Restart Boring Notch") { ApplicationRelauncher.restart() @@ -50,6 +57,10 @@ struct DynamicNotchApp: App { } .keyboardShortcut(KeyEquivalent("Q"), modifiers: .command) } + + Window("Notification Debug", id: "notification-debug") { + NotificationDebugView() + } } } diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift new file mode 100644 index 000000000..769e75daa --- /dev/null +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -0,0 +1,482 @@ +// +// NotificationLiveActivity.swift +// boringNotch +// +// Closed-state and expanded presentations for an incoming system +// notification mirrored from Notification Center. +// +// Design intent: mirror the restraint of a native macOS/iOS notification +// banner — clear hierarchy (who → what → action), one obvious primary +// action, generous but not wasteful spacing, and motion that settles +// rather than bounces. Reuses the app's existing visual language (accent +// color, AppIcon, MarqueeText, HoverButton) rather than inventing new +// primitives. +// + +import Defaults +import SwiftUI + +/// Closed notch: app icon on the left, a status dot on the right that +/// briefly pulses on arrival — the same "something just happened" language +/// as an unread badge, without needing to read anything at a glance. +struct NotificationLiveActivity: View { + @EnvironmentObject var vm: BoringViewModel + let notification: SystemNotification + + // The ring is always in the tree; its scale/opacity are what animate. + // Gating the view itself with `if` would give SwiftUI no starting frame + // to interpolate from, so the "pulse" would just appear already faded. + @State private var ringScale: CGFloat = 1 + @State private var ringOpacity: Double = 0 + + private var itemSize: CGFloat { max(0, vm.effectiveClosedNotchHeight - 12) } + + var body: some View { + Group { + if let code = notification.detectedCode { + codePill(code) + } else { + statusPill + } + } + .frame(height: vm.effectiveClosedNotchHeight, alignment: .center) + .onAppear { pulse() } + .onChange(of: notification.id) { _, _ in pulse() } + } + + /// The default closed treatment: icon left, status dot right. + private var statusPill: some View { + HStack { + NotificationAppIcon(bundleID: notification.bundleID, size: itemSize) + + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width - cornerRadiusInsets.closed.top) + + ZStack { + Circle() + .stroke(Color.effectiveAccent, lineWidth: 1.5) + .scaleEffect(ringScale) + .opacity(ringOpacity) + Circle() + .fill(notification.isLive ? Color.effectiveAccent : Color.secondary) + .frame(width: 7, height: 7) + } + .frame(width: itemSize, height: itemSize) + } + } + + /// A verification code doesn't need opening the notch to be useful — the + /// closed pill widens just enough to show the code and a one-tap copy, + /// same instinct as iOS surfacing OTPs directly on the lock screen. + private func codePill(_ code: String) -> some View { + HStack { + NotificationAppIcon(bundleID: notification.bundleID, size: itemSize) + + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width - cornerRadiusInsets.closed.top) + + HStack(spacing: 8) { + Text(code) + .font(.system(size: 15, weight: .semibold, design: .monospaced)) + .kerning(1.5) + .foregroundStyle(.white) + .lineLimit(1) + + CodeCopyButton(code: code, diameter: itemSize) + } + } + } + + private func pulse() { + ringScale = 1 + ringOpacity = 0.8 + withAnimation(.easeOut(duration: 0.6)) { + ringScale = 1.8 + ringOpacity = 0 + } + } +} + +/// Open notch: sender, message, and one clear primary action — a reply +/// composer, call controls, or a hand-off to the source app, depending on +/// what the notification actually supports. +struct NotificationExpandedView: View { + @EnvironmentObject var vm: BoringViewModel + @ObservedObject private var manager = SystemNotificationManager.shared + + let notification: SystemNotification + + @State private var replyText = "" + @State private var isSending = false + @State private var didSend = false + @FocusState private var replyFocused: Bool + + private var kind: NotificationKind { .init(notification) } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + headerAvatar + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 6) { + header + textBlock + actionArea + } + } + .padding(.horizontal, 4) + .onAppear { + manager.holdActive() + if kind == .reply { replyFocused = true } + } + .onDisappear { manager.resumeDismiss() } + } + + // MARK: - Avatar + + /// A contact photo (or monogram fallback) when the notification has a + /// human sender, badged with the source app's icon bottom-trailing — + /// mirrors iMessage's own Communication Notifications treatment, but + /// works for any app since it's a plain name lookup rather than an + /// intent donation. + @ViewBuilder + private var headerAvatar: some View { + if let sender = notification.sender { + ZStack(alignment: .bottomTrailing) { + PersonAvatarView(name: sender, size: 44) + if let bundleID = notification.bundleID { + AppIcon(for: bundleID) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18, height: 18) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(.black, lineWidth: 1.5)) + .offset(x: 3, y: 3) + } + } + } else { + NotificationAppIcon(bundleID: notification.bundleID, size: 44) + } + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(notification.sender ?? notification.appName ?? "Notification") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Text(notification.receivedAt, style: .relative) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + .fixedSize() + + Spacer(minLength: 8) + + HoverButton(icon: "xmark", iconColor: .secondary, scale: .medium) { + manager.dismissActive(token: notification.id) + } + } + } + + // MARK: - Body text + + @ViewBuilder + private var textBlock: some View { + if let subtitle = notification.subtitle, subtitle != notification.sender { + Text(subtitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if let body = notification.body { + Text(body) + .font(.system(size: 13)) + .foregroundStyle(.secondary.opacity(0.9)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .lineSpacing(1) + } + } + + // MARK: - Primary action, chosen by what the notification supports + + @ViewBuilder + private var actionArea: some View { + switch kind { + case .code(let value): + codeRow(value) + case .call: + callActionRow + case .reply: + replyRow + case .openOnly: + openRow + } + } + + // MARK: - Verification code + + private func codeRow(_ code: String) -> some View { + HStack(spacing: 10) { + Text(code) + .font(.system(size: 20, weight: .semibold, design: .monospaced)) + .kerning(2) + .foregroundStyle(.white) + .lineLimit(1) + + Spacer(minLength: 8) + + CodeCopyButton(code: code, diameter: 26, showsLabel: true) + } + .padding(.top, 4) + } + + // MARK: - Reply + + private var replyRow: some View { + HStack(spacing: 8) { + HStack(spacing: 6) { + TextField("Reply", text: $replyText, axis: .horizontal) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .focused($replyFocused) + .onSubmit(send) + .disabled(isSending || didSend) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(.white.opacity(0.08), in: Capsule()) + .overlay(Capsule().strokeBorder(.white.opacity(replyFocused ? 0.18 : 0))) + .animation(.easeOut(duration: 0.15), value: replyFocused) + + sendButton + } + .padding(.top, 2) + } + + @ViewBuilder + private var sendButton: some View { + ZStack { + Circle() + .fill(didSend ? Color.green : (canSend ? Color.effectiveAccent : Color.white.opacity(0.1))) + .frame(width: 26, height: 26) + + if isSending { + ProgressView() + .controlSize(.small) + .tint(.white) + } else if didSend { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + } else { + Image(systemName: "arrow.up") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(canSend ? .white : .secondary) + } + } + .animation(.smooth(duration: 0.25), value: isSending) + .animation(.smooth(duration: 0.25), value: didSend) + .contentShape(Circle()) + .onTapGesture(perform: send) + .disabled(!canSend) + .sensoryFeedback(.success, trigger: didSend) + } + + private var canSend: Bool { + !replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSending && !didSend + } + + private func send() { + guard canSend else { return } + let text = replyText + isSending = true + Task { + let sent = await manager.reply(to: notification, text: text) + isSending = false + if sent { + didSend = true + replyText = "" + try? await Task.sleep(for: .milliseconds(900)) + manager.dismissActive(token: notification.id) + } + } + } + + // MARK: - Calls + + /// Real phone/FaceTime affordance — green to accept, red to decline — + /// rather than generic rectangular buttons. + private var callActionRow: some View { + HStack(spacing: 14) { + Spacer() + if let decline = notification.actions.first(where: { + $0.localizedCaseInsensitiveContains("decline") + }) { + callButton(symbol: "phone.down.fill", tint: .red) { + Task { + await manager.perform(decline, on: notification) + manager.dismissActive(token: notification.id) + } + } + } + if let accept = notification.actions.first(where: { action in + ["accept", "answer", "join"].contains { action.localizedCaseInsensitiveContains($0) } + }) { + callButton(symbol: "phone.fill", tint: .green) { + Task { + await manager.perform(accept, on: notification) + manager.dismissActive(token: notification.id) + } + } + } + } + .padding(.top, 2) + } + + private func callButton(symbol: String, tint: Color, action: @escaping () -> Void) -> some View { + Button(action: action) { + Circle() + .fill(tint) + .frame(width: 30, height: 30) + .overlay { + Image(systemName: symbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + } + } + .buttonStyle(ScaleDownButtonStyle()) + } + + // MARK: - Fallback: no reply field available + + private var openRow: some View { + Button { + Task { await manager.open(notification) } + } label: { + HStack(spacing: 6) { + Image(systemName: "arrow.up.forward.app.fill") + .font(.system(size: 11)) + Text("Open in \(notification.appName ?? "app")") + .font(.system(size: 12, weight: .medium)) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.white.opacity(0.06), in: Capsule()) + } + .buttonStyle(ScaleDownButtonStyle()) + .padding(.top, 2) + } +} + +/// What a notification's actions actually let the user do — decides which +/// action row renders. Computed once per render rather than scattering the +/// same string matching across the view. +private enum NotificationKind: Equatable { + case code(String), call, reply, openOnly + + init(_ notification: SystemNotification) { + // A verification code wins over everything else — copying it is + // almost certainly what the user opened the notch for. + if let code = notification.detectedCode { + self = .code(code) + return + } + let hasCallActions = notification.actions.contains { action in + ["accept", "decline", "answer", "join"].contains { action.localizedCaseInsensitiveContains($0) } + } + if hasCallActions { + self = .call + } else if notification.canReply { + self = .reply + } else { + self = .openOnly + } + } +} + +private struct NotificationAppIcon: View { + let bundleID: String? + let size: CGFloat + + var body: some View { + Group { + if let bundleID { + AppIcon(for: bundleID) + .resizable() + } else { + Image(systemName: "bell.fill") + .resizable() + .scaledToFit() + .padding(size * 0.22) + .foregroundStyle(.secondary) + } + } + .aspectRatio(contentMode: .fit) + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: size * 0.22)) + } +} + +/// A restrained press state for the plain icon-style buttons above — +/// matches the subtle scale feedback used on the album art button rather +/// than a full opacity/highlight change. +private struct ScaleDownButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.92 : 1) + .animation(.smooth(duration: 0.15), value: configuration.isPressed) + } +} + +/// Copies a code to the pasteboard and morphs into a checkmark for a beat — +/// same confirmation language as the reply send button, so the two feel like +/// one design rather than two different affordances for "did that work?" +private struct CodeCopyButton: View { + let code: String + var diameter: CGFloat + var showsLabel: Bool = false + + @State private var didCopy = false + + var body: some View { + Button(action: copy) { + HStack(spacing: 4) { + Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + .font(.system(size: diameter * 0.42, weight: .semibold)) + if showsLabel { + Text(didCopy ? "Copied" : "Copy") + .font(.system(size: 12, weight: .medium)) + } + } + .foregroundStyle(.white) + .frame(height: diameter) + .padding(.horizontal, showsLabel ? 10 : 0) + .frame(width: showsLabel ? nil : diameter) + .background( + (didCopy ? Color.green : Color.effectiveAccent), + in: Capsule() + ) + } + .buttonStyle(ScaleDownButtonStyle()) + .animation(.smooth(duration: 0.25), value: didCopy) + .sensoryFeedback(.success, trigger: didCopy) + } + + private func copy() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(code, forType: .string) + didCopy = true + Task { + try? await Task.sleep(for: .seconds(1.5)) + didCopy = false + } + } +} diff --git a/boringNotch/components/NotificationDebugWindow.swift b/boringNotch/components/NotificationDebugWindow.swift new file mode 100644 index 000000000..a397d5681 --- /dev/null +++ b/boringNotch/components/NotificationDebugWindow.swift @@ -0,0 +1,91 @@ +// +// NotificationDebugWindow.swift +// boringNotch +// +// Developer window for inspecting captured Notification Center banners before +// they are wired into the notch UI. +// + +import SwiftUI + +struct NotificationDebugView: View { + @StateObject private var manager = SystemNotificationManager.shared + @State private var replyText = "" + @State private var axDump = "" + @State private var lastResult = "" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Circle() + .fill(manager.isWatching ? .green : .red) + .frame(width: 8, height: 8) + Text(manager.isWatching ? "Watching" : "Not watching") + Spacer() + Button("Start") { Task { await manager.start() } } + Button("Stop") { manager.stop() } + Button("Clear") { manager.clear() } + Button("Dump AX tree") { Task { axDump = await manager.debugDump() } } + } + + if !lastResult.isEmpty { + Text(lastResult).font(.caption).foregroundStyle(.secondary) + } + + List(manager.notifications) { notification in + VStack(alignment: .leading, spacing: 4) { + HStack { + if let icon = notification.icon { + Image(nsImage: icon).resizable().frame(width: 20, height: 20) + } + Text(notification.appName ?? "unknown app").bold() + Text(notification.bundleID ?? "no bundle id") + .font(.caption).foregroundStyle(.secondary) + Spacer() + if !notification.isLive { + Text("expired").font(.caption).foregroundStyle(.orange) + } + } + Text(notification.title ?? "—") + if let subtitle = notification.subtitle { + Text(subtitle).font(.caption) + } + Text(notification.body ?? "—").font(.caption).foregroundStyle(.secondary) + Text("actions: \(notification.actions.joined(separator: ", "))") + .font(.caption2).foregroundStyle(.tertiary) + + HStack { + TextField("reply…", text: $replyText) + .onSubmit { send(to: notification) } + Button("Send") { send(to: notification) } + .disabled(replyText.isEmpty) + Button("Open") { + Task { await manager.open(notification) } + } + } + .disabled(!notification.isLive) + } + .padding(.vertical, 4) + } + + if !axDump.isEmpty { + ScrollView { + Text(axDump).font(.system(.caption, design: .monospaced)).textSelection(.enabled) + } + .frame(height: 200) + } + } + .padding() + .frame(minWidth: 520, minHeight: 480) + .task { await manager.start() } + } + + private func send(to notification: SystemNotification) { + let text = replyText + replyText = "" + Task { + let sent = await manager.reply(to: notification, text: text) + lastResult = sent ? "replied" : "no reply field — opened the app instead" + } + } +} diff --git a/boringNotch/components/Settings/SettingsView.swift b/boringNotch/components/Settings/SettingsView.swift index a3d19c78d..7c8993963 100644 --- a/boringNotch/components/Settings/SettingsView.swift +++ b/boringNotch/components/Settings/SettingsView.swift @@ -13,6 +13,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case general case appearance case media + case notifications case calendar case osd case battery @@ -29,6 +30,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .general: "General" case .appearance: "Appearance" case .media: "Media" + case .notifications: "Notifications" case .calendar: "Calendar" case .osd: "OSD" case .battery: "Battery" @@ -45,6 +47,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .general: "gear" case .appearance: "eye" case .media: "play.laptopcomputer" + case .notifications: "bell.badge" case .calendar: "calendar" case .osd: "dial.medium.fill" case .battery: "battery.100.bolt" @@ -88,6 +91,8 @@ struct SettingsView: View { Appearance() case .media: Media() + case .notifications: + NotificationSettingsView() case .calendar: CalendarSettings() case .osd: diff --git a/boringNotch/components/Settings/Views/NotificationSettingsView.swift b/boringNotch/components/Settings/Views/NotificationSettingsView.swift new file mode 100644 index 000000000..22e83efbc --- /dev/null +++ b/boringNotch/components/Settings/Views/NotificationSettingsView.swift @@ -0,0 +1,108 @@ +// +// NotificationSettingsView.swift +// boringNotch +// +// Per-app controls for the notch's notification live activity: which apps +// are mirrored, and which of those should also have their system banner +// auto-dismissed once captured. +// + +import Defaults +import SwiftUI + +private struct KnownNotificationApp: Identifiable { + let bundleID: String + let name: String + var id: String { bundleID } +} + +private let knownNotificationApps: [KnownNotificationApp] = [ + .init(bundleID: "com.apple.MobileSMS", name: "Messages"), + .init(bundleID: "com.apple.FaceTime", name: "FaceTime"), + .init(bundleID: "com.apple.mail", name: "Mail"), + .init(bundleID: "com.microsoft.Outlook", name: "Outlook"), + .init(bundleID: "net.whatsapp.WhatsApp", name: "WhatsApp"), + .init(bundleID: "ru.keepcoder.Telegram", name: "Telegram"), + .init(bundleID: "com.tdesktop.Telegram", name: "Telegram Desktop"), + .init(bundleID: "com.hnc.Discord", name: "Discord") +] + +struct NotificationSettingsView: View { + @Default(.notificationLiveActivity) var notificationLiveActivity + @Default(.notificationsFromAllApps) var notificationsFromAllApps + @Default(.notificationAllowedApps) var allowedApps + @Default(.notificationSuppressedApps) var suppressedApps + + var body: some View { + Form { + Section { + Defaults.Toggle(key: .notificationLiveActivity) { + Text("Show notifications in the notch") + } + } footer: { + Text("Requires Accessibility access. Only banners are mirrored — notifications delivered silently to Notification Center aren't visible to the app.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + Defaults.Toggle(key: .notificationsFromAllApps) { + Text("From all apps") + } + .disabled(!notificationLiveActivity) + + if !notificationsFromAllApps { + ForEach(knownNotificationApps) { app in + appRow(app) + } + } + } header: { + Text("Apps") + } footer: { + if !notificationsFromAllApps { + Text("Only these apps show a live activity in the notch. Turn on \"From all apps\" to mirror everything instead.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .disabled(!notificationLiveActivity) + } + .formStyle(.grouped) + .navigationTitle("Notifications") + } + + @ViewBuilder + private func appRow(_ app: KnownNotificationApp) -> some View { + let isAllowed = allowedApps.contains(app.bundleID) + + VStack(alignment: .leading, spacing: 4) { + HStack { + AppIcon(for: app.bundleID) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 5)) + + Toggle(app.name, isOn: Binding( + get: { allowedApps.contains(app.bundleID) }, + set: { on in + if on { allowedApps.insert(app.bundleID) } else { allowedApps.remove(app.bundleID) } + } + )) + } + + if isAllowed { + Toggle("Hide system banner, show in notch only", isOn: Binding( + get: { suppressedApps.contains(app.bundleID) }, + set: { on in + if on { suppressedApps.insert(app.bundleID) } else { suppressedApps.remove(app.bundleID) } + } + )) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 28) + } + } + .disabled(!notificationLiveActivity) + } +} diff --git a/boringNotch/helpers/OTPDetector.swift b/boringNotch/helpers/OTPDetector.swift new file mode 100644 index 000000000..b35c51fea --- /dev/null +++ b/boringNotch/helpers/OTPDetector.swift @@ -0,0 +1,167 @@ +// +// OTPDetector.swift +// boringNotch +// +// Finds a one-time verification code in notification text. Precision over +// recall: a phone number or price shown as a fake "code to copy" is worse +// than occasionally missing a real one — same trade-off iOS's own OTP +// autofill makes (it also requires contextual signal, not bare digits). +// +// Rules, in order: +// 1. A digit run of 4–8 digits (or two 3-digit groups joined by a dash/ +// space, e.g. "123-456") is a *candidate*. +// 2. A candidate is only accepted if a verification-related keyword +// ("code", "otp", "verification", "passcode", "pin", …) appears within +// `keywordWindow` characters of it — this is what keeps "call me at +// 9876543210" and "invoice #48293021" from matching, since neither has +// a keyword nearby, without needing hand-written phone/invoice rules. +// 3. Currency, percentage, and time-adjacent digits are rejected outright +// even if a keyword happens to be nearby (e.g. "OTP fee is $500"). +// 4. If no digit-only candidate is accepted, a secondary pass looks for a +// short uppercase alphanumeric token (Steam Guard-style: "R7K9P2") next +// to the same keyword set. +// + +import Foundation + +enum OTPDetector { + /// Longest a real OTP/2FA code gets in practice; wider misses ambiguity + /// with tracking numbers and account IDs. + private static let digitCountRange = 4...8 + + /// How close a keyword has to be to a candidate to count as context, + /// measured in UTF-16 code units. Covers "Your code is 482910" and + /// "482910 is your verification code" without also matching a keyword + /// three sentences away. + private static let keywordWindow = 40 + + private static let keywords = [ + "verification", "one-time", "one time", "passcode", "otp", + "security code", "confirmation code", "confirmation", "access code", + "auth code", "authentication code", "two-factor", "2fa", "pin", "code" + ] + + /// Used for the alphanumeric fallback only. Excludes bare "code"/"pin" — + /// those two are common in non-OTP contexts too ("promo code SAVE20", + /// "pin your location"), which is fine for the digit-only pass (a random + /// 6-digit number near "code" is almost always a real OTP) but would + /// wrongly accept a dictionary-word promo code like "SAVE20" here. + private static let strongKeywords = [ + "verification", "one-time", "one time", "passcode", "otp", + "security code", "confirmation code", "access code", "auth code", + "authentication code", "two-factor", "2fa" + ] + + private static let digitRunRegex = try! NSRegularExpression( + pattern: #"\d{3}[-\s]\d{3}\b|\b\d{4,8}\b"# + ) + private static let alphanumericRegex = try! NSRegularExpression( + pattern: #"\b(?=[A-Z0-9]*\d)(?=[A-Z0-9]*[A-Z])[A-Z0-9]{5,8}\b"# + ) + + /// Returns the code with any separators stripped, ready to paste into an + /// OTP field — or nil if nothing in `text` clears the bar above. + static func detect(in text: String) -> String? { + guard !text.isEmpty else { return nil } + let ns = text as NSString + + if let match = bestDigitCandidate(in: text, ns: ns) { + return match.replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: " ", with: "") + } + + return bestAlphanumericCandidate(in: text, ns: ns) + } + + private static func bestDigitCandidate(in text: String, ns: NSString) -> String? { + let matches = digitRunRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) + for match in matches { + guard !isExcluded(match.range, in: ns), hasNearbyKeyword(match.range, in: ns) else { continue } + return ns.substring(with: match.range) + } + return nil + } + + private static func bestAlphanumericCandidate(in text: String, ns: NSString) -> String? { + let matches = alphanumericRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) + for match in matches where hasNearbyKeyword(match.range, in: ns, from: strongKeywords) { + return ns.substring(with: match.range) + } + return nil + } + + /// Currency amounts, percentages, and clock times shouldn't match even + /// with a keyword nearby ("OTP delivery fee is $500", "meeting at 4:30"). + private static func isExcluded(_ range: NSRange, in ns: NSString) -> Bool { + let before = charBefore(range, in: ns) + let after = charAfter(range, in: ns) + + if let before, "$€£¥₹".contains(before) { return true } + if let after, after == "%" { return true } + if let after, after == ":" { return true } + if let before, before == ":" { return true } + if let after, after == "." { + // "500.00" — a decimal amount, not a code with a trailing period. + let next = ns.substring(with: NSRange(location: range.location + range.length, length: min(3, ns.length - range.location - range.length))) + if next.dropFirst().allSatisfy(\.isNumber) { return true } + } + return false + } + + private static func charBefore(_ range: NSRange, in ns: NSString) -> Character? { + guard range.location > 0 else { return nil } + return Character(UnicodeScalar(ns.character(at: range.location - 1))!) + } + + private static func charAfter(_ range: NSRange, in ns: NSString) -> Character? { + let end = range.location + range.length + guard end < ns.length else { return nil } + return Character(UnicodeScalar(ns.character(at: end))!) + } + + private static func hasNearbyKeyword(_ range: NSRange, in ns: NSString, from list: [String] = keywords) -> Bool { + let windowStart = max(0, range.location - keywordWindow) + let windowEnd = min(ns.length, range.location + range.length + keywordWindow) + let window = ns.substring(with: NSRange(location: windowStart, length: windowEnd - windowStart)).lowercased() + return list.contains { window.contains($0) } + } +} + +#if DEBUG +/// Non-exhaustive but covers the shapes that matter: separators, prefix vs. +/// suffix keyword position, currency/time/percentage traps, and an +/// alphanumeric fallback. Run via `OTPDetector.runSelfCheck()`. +extension OTPDetector { + static func runSelfCheck() { + let shouldDetect: [(String, String)] = [ + ("Your WhatsApp code: 123-456. Don't share this with anyone.", "123456"), + ("G-593821 is your Google verification code.", "593821"), + ("Your Instagram code is 482910. Learn more.", "482910"), + ("Use 7482 as your verification code. Expires in 10 minutes.", "7482"), + ("123456 is your Facebook confirmation code", "123456"), + ("<#> Your ABC App code is 384950 #hash", "384950"), + ("Your OTP for a transaction of INR 500.00 is 837201. Valid for 5 mins.", "837201"), + ("Your Amazon OTP is: 4821", "4821"), + ("Your Steam Guard verification code: R7K9P2", "R7K9P2") + ] + + let shouldMiss: [String] = [ + "Hey, call me at 9876543210 when you're free", + "Meeting at 3:30 today, don't forget code review at 4", + "Your invoice #48293021 total is due", + "Ref: 293847, please quote when calling about your 2023 order", + "Get 20% off with code SAVE20 at checkout", + "The OTP delivery fee is $5000 this month" + ] + + for (text, expected) in shouldDetect { + let got = detect(in: text) + assert(got == expected, "OTPDetector missed \"\(text)\" — expected \(expected), got \(got ?? "nil")") + } + for text in shouldMiss { + let got = detect(in: text) + assert(got == nil, "OTPDetector false-positived on \"\(text)\" — got \(got ?? "nil")") + } + } +} +#endif diff --git a/boringNotch/managers/ContactAvatarManager.swift b/boringNotch/managers/ContactAvatarManager.swift new file mode 100644 index 000000000..d14c27091 --- /dev/null +++ b/boringNotch/managers/ContactAvatarManager.swift @@ -0,0 +1,123 @@ +// +// ContactAvatarManager.swift +// boringNotch +// +// Resolves a notification sender's name to a Contacts photo, for the +// "person, not app" avatar treatment iMessage's Communication Notifications +// use. Notification Center banners don't expose a photo over Accessibility +// at all (verified against live WhatsApp/Discord banners — title/subtitle/ +// body text and buttons only, no image element), so this is the only way +// to get one for third-party apps too. +// +// Many senders won't resolve — a WhatsApp/Telegram display name rarely +// matches a Contacts card exactly, and some notifications aren't from a +// person at all. Callers fall back to a monogram avatar in that case. +// + +import AppKit +import Contacts +import SwiftUI + +@MainActor +final class ContactAvatarManager: ObservableObject { + static let shared = ContactAvatarManager() + + private let store = CNContactStore() + private var isAuthorized = false + /// Exact-name lookups are cheap to repeat but the store fetch isn't; + /// cache misses too so a name that doesn't resolve isn't retried forever. + private var cache: [String: NSImage?] = [:] + + private init() { + isAuthorized = CNContactStore.authorizationStatus(for: .contacts) == .authorized + } + + private func ensureAuthorized() async -> Bool { + switch CNContactStore.authorizationStatus(for: .contacts) { + case .authorized: + isAuthorized = true + return true + case .notDetermined: + let granted = (try? await store.requestAccess(for: .contacts)) ?? false + isAuthorized = granted + return granted + default: + return false + } + } + + /// Looks up a contact photo by exact display-name match. Returns nil + /// immediately (no permission prompt) unless the caller has already + /// established access via `requestAccessIfNeeded`. + func photo(forSenderNamed name: String) -> NSImage? { + if let cached = cache[name] { return cached } + guard isAuthorized else { return nil } + + let keys = [CNContactImageDataKey, CNContactThumbnailImageDataKey] as [CNKeyDescriptor] + let predicate = CNContact.predicateForContacts(matchingName: name) + + guard let contacts = try? store.unifiedContacts(matching: predicate, keysToFetch: keys), + // Ambiguous matches (multiple people share a first name) are + // worse than no photo — a wrong face is worse than a monogram. + contacts.count == 1, + let data = contacts[0].thumbnailImageData ?? contacts[0].imageData, + let image = NSImage(data: data) + else { + cache[name] = .some(nil) + return nil + } + + cache[name] = image + return image + } + + /// Call once, e.g. when notification live activity starts, so the first + /// banner isn't blocked on a permission prompt mid-render. + func requestAccessIfNeeded() async { + _ = await ensureAuthorized() + } +} + +/// Stable "person" avatar: a real contact photo when one resolves, otherwise +/// a colored monogram — the same fallback Contacts/Messages/Mail use for +/// people without a saved photo, so it never looks broken. +struct PersonAvatarView: View { + let name: String + let size: CGFloat + + @ObservedObject private var contacts = ContactAvatarManager.shared + + var body: some View { + Group { + if let photo = contacts.photo(forSenderNamed: name) { + Image(nsImage: photo) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + ZStack { + monogramColor + Text(initials) + .font(.system(size: size * 0.4, weight: .semibold)) + .foregroundStyle(.white) + } + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } + + private var initials: String { + let parts = name.split(separator: " ").prefix(2) + let letters = parts.compactMap { $0.first }.map(String.init) + return letters.isEmpty ? "?" : letters.joined().uppercased() + } + + /// Hashes the name to a hue so the same person gets the same color across + /// notifications, without needing to persist anything. + private var monogramColor: Color { + var hasher = Hasher() + hasher.combine(name) + let hue = Double(abs(hasher.finalize()) % 360) / 360 + return Color(hue: hue, saturation: 0.55, brightness: 0.75) + } +} diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift new file mode 100644 index 000000000..ef7b45696 --- /dev/null +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -0,0 +1,252 @@ +// +// SystemNotificationManager.swift +// boringNotch +// +// Collects Notification Center banners captured by the XPC helper and exposes +// them to the UI. The helper does the Accessibility work; this side only +// models what is currently on screen. +// + +import AppKit +import Combine +import Defaults +import SwiftUI + +struct SystemNotification: Identifiable, Equatable { + /// Notification Center's own identifier for the banner, valid while it is + /// on screen. Also the token the helper needs to act on it. + let id: String + let appName: String? + let bundleID: String? + let title: String? + let subtitle: String? + let body: String? + let actions: [String] + let receivedAt: Date + + /// True while the banner still exists, i.e. while replying can work. + var isLive: Bool = true + + /// Best-effort signal for showing the reply row. Notification Center + /// doesn't label reply-capable banners consistently — WhatsApp exposes + /// "Show Details" (reveals the field) and "Send" (submits), never the + /// word "reply" (verified against a live banner). Either is a decent + /// hint; `SystemNotificationManager.reply` is the actual source of truth + /// and falls back to opening the app if no field materializes. + var canReply: Bool { + isLive && actions.contains { + $0.localizedCaseInsensitiveContains("send") || $0.localizedCaseInsensitiveContains("details") + } + } + + /// A verification code found in the notification text, if any. Computed + /// rather than stored — it's cheap regex work and only ever read a + /// handful of times per notification. + var detectedCode: String? { + OTPDetector.detect(in: [title, subtitle, body].compactMap { $0 }.joined(separator: " ")) + } + + var icon: NSImage? { + guard let bundleID, + let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) + else { return nil } + return NSWorkspace.shared.icon(forFile: url.path) + } + + /// Whoever the message is from — banners put the sender in the title. + var sender: String? { title } +} + +@MainActor +final class SystemNotificationManager: ObservableObject { + static let shared = SystemNotificationManager() + + /// Most recent first, live banners before expired ones. + @Published private(set) var notifications: [SystemNotification] = [] + @Published private(set) var isWatching = false + + /// The notification the notch is currently showing, if any. + @Published var activeNotification: SystemNotification? + + /// How long the notch keeps showing a notification after it arrives. + private let activeDuration: TimeInterval = 8 + private var dismissTask: Task? + + /// Expired banners are kept around briefly so the notch can still show the + /// last message after the banner itself has faded. + private let historyLimit = 20 + + private var observers: [NSObjectProtocol] = [] + + private init() { + observers.append(NotificationCenter.default.addObserver( + forName: .systemNotificationDidAppear, object: nil, queue: .main + ) { [weak self] note in + guard let payload = note.userInfo as? [String: String] else { return } + MainActor.assumeIsolated { self?.add(payload) } + }) + + observers.append(NotificationCenter.default.addObserver( + forName: .systemNotificationDidDisappear, object: nil, queue: .main + ) { [weak self] note in + guard let token = note.userInfo?["token"] as? String else { return } + MainActor.assumeIsolated { self?.markExpired(token) } + }) + } + + deinit { + observers.forEach(NotificationCenter.default.removeObserver) + } + + // MARK: - Lifecycle + + func start() async { + guard await XPCHelperClient.shared.ensureAccessibilityAuthorization(promptIfNeeded: true) else { + isWatching = false + return + } + isWatching = await XPCHelperClient.shared.startNotificationWatching() + // Ask up front so the first banner isn't blocked on a permission + // prompt mid-render. Contacts access is optional — a denial just + // means every avatar falls back to a monogram. + await ContactAvatarManager.shared.requestAccessIfNeeded() + } + + func stop() { + XPCHelperClient.shared.stopNotificationWatching() + isWatching = false + } + + // MARK: - Incoming banners + + private func add(_ payload: [String: String]) { + guard let token = payload["token"], !token.isEmpty else { return } + func value(_ key: String) -> String? { + let string = payload[key] ?? "" + return string.isEmpty ? nil : string + } + + let notification = SystemNotification( + id: token, + appName: value("appName"), + bundleID: value("bundleID"), + title: value("title"), + subtitle: value("subtitle"), + body: value("body"), + actions: (value("actions") ?? "").components(separatedBy: "\n").filter { !$0.isEmpty }, + receivedAt: Date() + ) + + notifications.removeAll { $0.id == token } + notifications.insert(notification, at: 0) + if notifications.count > historyLimit { + notifications.removeLast(notifications.count - historyLimit) + } + + guard isAllowed(notification) else { return } + show(notification) + suppressSystemBannerIfNeeded(notification) + } + + /// Closes the OS banner right after capture for apps the user has opted + /// to mute at the system level — the notch's own live activity is meant + /// to be the only thing they see for these. This can only run after the + /// banner has already rendered and been read; nothing can stop it from + /// appearing at all. + private func suppressSystemBannerIfNeeded(_ notification: SystemNotification) { + guard let bundleID = notification.bundleID, + Defaults[.notificationSuppressedApps].contains(bundleID) + else { return } + Task { await XPCHelperClient.shared.dismissNotification(token: notification.id) } + } + + /// The notch mirrors banners rather than replacing them, so an unfiltered + /// feed would duplicate every system notification. Default to the messaging + /// apps people actually reply to. + private func isAllowed(_ notification: SystemNotification) -> Bool { + if Defaults[.notificationsFromAllApps] { return true } + guard let bundleID = notification.bundleID else { return false } + return Defaults[.notificationAllowedApps].contains(bundleID) + } + + private func show(_ notification: SystemNotification) { + withAnimation(.smooth) { activeNotification = notification } + dismissTask?.cancel() + dismissTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(self?.activeDuration ?? 8)) + guard !Task.isCancelled else { return } + await MainActor.run { self?.dismissActive(token: notification.id) } + } + } + + /// Passing a token only dismisses if it is still the one on screen, so a + /// newer notification isn't cleared by an older one's timer. + func dismissActive(token: String? = nil) { + if let token, activeNotification?.id != token { return } + dismissTask?.cancel() + dismissTask = nil + withAnimation(.smooth) { activeNotification = nil } + } + + /// Keeps the notification up while the user is interacting with it. + func holdActive() { + dismissTask?.cancel() + dismissTask = nil + } + + /// Restarts the dismiss countdown once the user stops interacting — + /// without this a held notification would stay in the notch forever. + func resumeDismiss(after delay: TimeInterval = 3) { + guard let active = activeNotification, dismissTask == nil else { return } + dismissTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + await MainActor.run { self?.dismissActive(token: active.id) } + } + } + + private func markExpired(_ token: String) { + if let index = notifications.firstIndex(where: { $0.id == token }) { + notifications[index].isLive = false + } + if activeNotification?.id == token { + activeNotification?.isLive = false + } + } + + func clear() { + notifications.removeAll() + dismissActive() + } + + // MARK: - Acting + + /// Sends an inline reply. Returns false when the banner is gone or the app + /// has no reply action — callers should fall back to `open`. + @discardableResult + func reply(to notification: SystemNotification, text: String) async -> Bool { + let sent = await XPCHelperClient.shared.replyToNotification(token: notification.id, text: text) + if !sent { await open(notification) } + dismissActive(token: notification.id) + return sent + } + + func perform(_ action: String, on notification: SystemNotification) async -> Bool { + await XPCHelperClient.shared.performNotificationAction(token: notification.id, name: action) + } + + /// Opens the notification in its source app; falls back to launching the + /// app once the banner is gone. + @discardableResult + func open(_ notification: SystemNotification) async -> Bool { + if await XPCHelperClient.shared.openNotification(token: notification.id) { return true } + guard let bundleID = notification.bundleID, + let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) + else { return false } + return (try? await NSWorkspace.shared.openApplication(at: url, configuration: .init())) != nil + } + + func debugDump() async -> String { + await XPCHelperClient.shared.notificationDebugDump() + } +} diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index fc2847421..2d2473317 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -293,6 +293,33 @@ extension Defaults.Keys { // MARK: OSD static let osdReplacement = Key("osdReplacement", default: false) static let inlineOSD = Key("inlineOSD", default: false) + + // MARK: Notifications + /// Off by default: mirroring banners needs Accessibility access. + static let notificationLiveActivity = Key("notificationLiveActivity", default: false) + static let notificationsFromAllApps = Key("notificationsFromAllApps", default: false) + static let notificationAllowedApps = Key>( + "notificationAllowedApps", + default: [ + "com.apple.MobileSMS", // Messages + "com.apple.FaceTime", + "com.apple.mail", + "com.microsoft.Outlook", + "net.whatsapp.WhatsApp", + "ru.keepcoder.Telegram", // Telegram Desktop (App Store build) + "com.tdesktop.Telegram", + "com.hnc.Discord" + ] + ) + /// Apps whose system banner gets closed immediately after boring.notch + /// captures it, so the notch becomes the only lasting surface. Can't + /// prevent the banner from rendering at all — there's no macOS API for + /// that — this just makes it live on screen for well under a second. + static let notificationSuppressedApps = Key>( + "notificationSuppressedApps", + default: [] + ) + static let enableGradient = Key("enableGradient", default: false) static let systemEventIndicatorShadow = Key("systemEventIndicatorShadow", default: false) static let systemEventIndicatorUseAccent = Key("systemEventIndicatorUseAccent", default: false) From 37d89d2f8e01782a9a3a382ac3622cc0113cb1be Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 10:36:08 +0530 Subject: [PATCH 02/69] Sanitize stale nonNotchHeightMode value at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Match real notch height" was removed as a choice for non-notch displays (no real notch to match there), but a value persisted from an older build that allowed it has no matching Picker tag anymore — SwiftUI logs "the selection is invalid" and shows no selection. Reset it to matchMenuBar on sync rather than leaving stale prefs with no valid UI representation. --- boringNotch/sizing/matters.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/boringNotch/sizing/matters.swift b/boringNotch/sizing/matters.swift index a94048c53..dacd078cf 100644 --- a/boringNotch/sizing/matters.swift +++ b/boringNotch/sizing/matters.swift @@ -88,6 +88,15 @@ enum MusicPlayerImageSizes { @MainActor func syncNotchHeightIfNeeded() { var didChangeHeight = false + // "Match real notch height" isn't a valid choice for a non-notch display + // — there's no real notch to match — so it's not offered in that + // Picker. A value here can only be leftover from an older build that + // allowed it; fall back to the sensible default rather than leaving a + // persisted value with no matching Picker tag. + if Defaults[.nonNotchHeightMode] == .matchRealNotchSize { + Defaults[.nonNotchHeightMode] = .matchMenuBar + } + switch Defaults[.notchHeightMode] { case .matchRealNotchSize: let realHeight = getRealNotchHeight() From e88790383a154b31165cfd7c64a0ff96d2fd06a3 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 10:40:58 +0530 Subject: [PATCH 03/69] Add Claude desktop to notification live activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same capture pipeline as every other app — just extends the default allow list with com.anthropic.claudefordesktop (confirmed from Claude.app's own Info.plist). Also scopes the Contacts-avatar treatment to actual messaging/calling apps (Messages, FaceTime, Mail, Outlook, WhatsApp, Telegram, Discord) rather than every app's notification title — Claude's title is a session name, not a person, and would otherwise fire a pointless Contacts lookup and show a nonsensical monogram. --- boringNotch.xcodeproj/project.pbxproj | 36 +++++----- boringNotch/Localizable.xcstrings | 71 +++++++++++++++++++ .../Notch/NotificationLiveActivity.swift | 31 +++++--- .../Views/NotificationSettingsView.swift | 3 +- boringNotch/models/Constants.swift | 3 +- 5 files changed, 116 insertions(+), 28 deletions(-) diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index ac97f4871..c1ecf253a 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -98,10 +98,6 @@ 14288E0C2C6F8EC000B9F80C /* AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14288E0B2C6F8EC000B9F80C /* AppIcons.swift */; }; 1443E7F32C609DCE0027C1FC /* matters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1443E7F22C609DCE0027C1FC /* matters.swift */; }; 147163982C5D35B70068B555 /* MusicVisualizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147163972C5D35B70068B555 /* MusicVisualizer.swift */; }; - AA01SNM12E7A0001 /* SystemNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01SNM22E7A0001 /* SystemNotificationManager.swift */; }; - AA01NDW12E7A0001 /* NotificationDebugWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01NDW22E7A0001 /* NotificationDebugWindow.swift */; }; - AA01NLA12E7A0001 /* NotificationLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01NLA22E7A0001 /* NotificationLiveActivity.swift */; }; - AA03OTP12E7A0001 /* OTPDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA03OTP22E7A0001 /* OTPDetector.swift */; }; 1471639A2C5D35FF0068B555 /* MusicManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147163992C5D35FF0068B555 /* MusicManager.swift */; }; 1471A8592C6281BD0058408D /* BoringNotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */; }; 149E0B972C737D00006418B1 /* WebcamManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149E0B962C737D00006418B1 /* WebcamManager.swift */; }; @@ -141,6 +137,12 @@ 9A987A0D2C73CA66005CA465 /* ShelfView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A987A032C73CA66005CA465 /* ShelfView.swift */; }; 9A987A102C73CA8D005CA465 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 9A987A0F2C73CA8D005CA465 /* Collections */; }; 9AB0C6BD2C73C9CB00F7CD30 /* NotchHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AB0C6BB2C73C9CB00F7CD30 /* NotchHomeView.swift */; }; + AA01NDW12E7A0001 /* NotificationDebugWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01NDW22E7A0001 /* NotificationDebugWindow.swift */; }; + AA01NLA12E7A0001 /* NotificationLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01NLA22E7A0001 /* NotificationLiveActivity.swift */; }; + AA01SNM12E7A0001 /* SystemNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01SNM22E7A0001 /* SystemNotificationManager.swift */; }; + AA02CAM12E7A0001 /* ContactAvatarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA02CAM22E7A0001 /* ContactAvatarManager.swift */; }; + AA02NSV12E7A0001 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA02NSV22E7A0001 /* NotificationSettingsView.swift */; }; + AA03OTP12E7A0001 /* OTPDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA03OTP22E7A0001 /* OTPDetector.swift */; }; B10348D92C74E56000475897 /* ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10348D82C74E56000475897 /* ConditionalModifier.swift */; }; B10F84A32C6C9596009F3026 /* TestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10F84A22C6C9596009F3026 /* TestView.swift */; }; B141C2412CA5F53F00AC8CC8 /* SparkleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B141C2402CA5F53E00AC8CC8 /* SparkleView.swift */; }; @@ -276,8 +278,6 @@ 11DB26692EDD0CDF001EA0CF /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = ""; }; 11DB266A2EDD0CDF001EA0CF /* BatterySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatterySettingsView.swift; sourceTree = ""; }; 11DB266B2EDD0CDF001EA0CF /* CalendarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSettingsView.swift; sourceTree = ""; }; - AA02NSV22E7A0001 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; - AA02NSV12E7A0001 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA02NSV22E7A0001 /* NotificationSettingsView.swift */; }; 11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; 11DB266D2EDD0CDF001EA0CF /* OSDSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDSettingsView.swift; sourceTree = ""; }; 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSettingsView.swift; sourceTree = ""; }; @@ -297,12 +297,6 @@ 1443E7F22C609DCE0027C1FC /* matters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = matters.swift; sourceTree = ""; }; 1443E7F42C609E650027C1FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 147163972C5D35B70068B555 /* MusicVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicVisualizer.swift; sourceTree = ""; }; - AA01SNM22E7A0001 /* SystemNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemNotificationManager.swift; sourceTree = ""; }; - AA01NDW22E7A0001 /* NotificationDebugWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugWindow.swift; sourceTree = ""; }; - AA01NLA22E7A0001 /* NotificationLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationLiveActivity.swift; sourceTree = ""; }; - AA02CAM22E7A0001 /* ContactAvatarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactAvatarManager.swift; sourceTree = ""; }; - AA02CAM12E7A0001 /* ContactAvatarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA02CAM22E7A0001 /* ContactAvatarManager.swift */; }; - AA03OTP22E7A0001 /* OTPDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPDetector.swift; sourceTree = ""; }; 147163992C5D35FF0068B555 /* MusicManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicManager.swift; sourceTree = ""; }; 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringNotchWindow.swift; sourceTree = ""; }; 149E0B962C737D00006418B1 /* WebcamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamManager.swift; sourceTree = ""; }; @@ -343,6 +337,12 @@ 9A987A032C73CA66005CA465 /* ShelfView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShelfView.swift; sourceTree = ""; }; 9AB0C6BB2C73C9CB00F7CD30 /* NotchHomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotchHomeView.swift; sourceTree = ""; }; A5167212301F85B40018095A /* boringNotchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = boringNotchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + AA01NDW22E7A0001 /* NotificationDebugWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugWindow.swift; sourceTree = ""; }; + AA01NLA22E7A0001 /* NotificationLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationLiveActivity.swift; sourceTree = ""; }; + AA01SNM22E7A0001 /* SystemNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemNotificationManager.swift; sourceTree = ""; }; + AA02CAM22E7A0001 /* ContactAvatarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactAvatarManager.swift; sourceTree = ""; }; + AA02NSV22E7A0001 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; + AA03OTP22E7A0001 /* OTPDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPDetector.swift; sourceTree = ""; }; AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputRouteResolver.swift; sourceTree = ""; }; B10348D82C74E56000475897 /* ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalModifier.swift; sourceTree = ""; }; B10F84A22C6C9596009F3026 /* TestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestView.swift; sourceTree = ""; }; @@ -1247,6 +1247,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BoringNotchXPCHelper/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BoringNotchXPCHelper; @@ -1272,6 +1273,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 272; DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BoringNotchXPCHelper/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BoringNotchXPCHelper; @@ -1346,6 +1348,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; @@ -1405,6 +1408,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; @@ -1422,14 +1426,14 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = boringNotch/boringNotch.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"boringNotch/Preview Content\""; DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = JPWMG84CH8; + "DEVELOPMENT_TEAM[sdk=macosx*]" = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; @@ -1491,14 +1495,14 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = boringNotch/boringNotch.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"boringNotch/Preview Content\""; DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = JPWMG84CH8; + "DEVELOPMENT_TEAM[sdk=macosx*]" = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index 21c0e7f6e..e31226d64 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -603,6 +603,9 @@ } } } + }, + "actions: %@" : { + }, "Add" : { "extractionState" : "stale", @@ -1849,6 +1852,9 @@ } } } + }, + "Apps" : { + }, "Auto-scroll to next event" : { "localizations" : { @@ -5057,6 +5063,9 @@ } } } + }, + "Clear" : { + }, "Clear slot" : { "localizations" : { @@ -6036,6 +6045,12 @@ } } } + }, + "Copied" : { + + }, + "Copy" : { + }, "Copy Meeting Link" : { @@ -7821,6 +7836,9 @@ } } } + }, + "Dump AX tree" : { + }, "Edit layout" : { "localizations" : { @@ -9036,6 +9054,9 @@ } } } + }, + "expired" : { + }, "Extend hover area" : { "localizations" : { @@ -9498,6 +9519,8 @@ "Frame shape" : { "comment" : "A label for the shape of the mirror frame." }, + "From all apps" : { + }, "Full charge" : { "localizations" : { "cs" : { @@ -11028,6 +11051,9 @@ } } } + }, + "Hide system banner, show in notch only" : { + }, "Hide title bar" : { "localizations" : { @@ -15555,6 +15581,9 @@ } } } + }, + "Not watching" : { + }, "Notch animation" : { "localizations" : { @@ -16085,6 +16114,12 @@ } } } + }, + "Notification Debug" : { + + }, + "Notifications" : { + }, "Now Playing" : { "localizations" : { @@ -16161,6 +16196,12 @@ } } } + }, + "Only these apps show a live activity in the notch. Turn on \"From all apps\" to mirror everything instead." : { + + }, + "Open" : { + }, "Open Calendar Settings" : { "localizations" : { @@ -16279,6 +16320,9 @@ } } } + }, + "Open in %@" : { + }, "Open Notch" : { "extractionState" : "stale", @@ -18959,6 +19003,12 @@ } } } + }, + "Reply" : { + + }, + "reply…" : { + }, "Request Accessibility" : { "extractionState" : "stale", @@ -19072,6 +19122,9 @@ } } } + }, + "Requires Accessibility access. Only banners are mirrored — notifications delivered silently to Notification Center aren't visible to the app." : { + }, "Requires macOS 14.2 or later. Update macOS to enable real-time audio waveform." : { "localizations" : { @@ -20091,6 +20144,9 @@ } } } + }, + "Send" : { + }, "Settings" : { "localizations" : { @@ -21689,6 +21745,9 @@ } } } + }, + "Show charging wattage" : { + }, "Show cool face animation while inactive" : { "localizations" : { @@ -22237,6 +22296,9 @@ } } } + }, + "Show notifications in the notch" : { + }, "Show on all displays" : { "localizations" : { @@ -24224,6 +24286,12 @@ } } } + }, + "Start" : { + + }, + "Stop" : { + }, "Stopped" : { "extractionState" : "stale", @@ -26798,6 +26866,9 @@ } } } + }, + "Watching" : { + }, "Week starts on" : { "comment" : "Calendar setting: which weekday the week strip starts on", diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index 769e75daa..85d1c8f84 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -141,20 +141,31 @@ struct NotificationExpandedView: View { /// mirrors iMessage's own Communication Notifications treatment, but /// works for any app since it's a plain name lookup rather than an /// intent donation. + /// + /// Only attempted for messaging/calling apps. Other apps' "title" field + /// is often not a person at all — Claude's is a session name, for + /// instance — and treating it as one would both look wrong and fire a + /// pointless Contacts search. + private static let personAvatarBundleIDs: Set = [ + "com.apple.MobileSMS", "com.apple.FaceTime", "com.apple.mail", "com.microsoft.Outlook", + "net.whatsapp.WhatsApp", "ru.keepcoder.Telegram", "com.tdesktop.Telegram", + "com.hnc.Discord" + ] + @ViewBuilder private var headerAvatar: some View { - if let sender = notification.sender { + if let sender = notification.sender, + let bundleID = notification.bundleID, + Self.personAvatarBundleIDs.contains(bundleID) { ZStack(alignment: .bottomTrailing) { PersonAvatarView(name: sender, size: 44) - if let bundleID = notification.bundleID { - AppIcon(for: bundleID) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 18, height: 18) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(.black, lineWidth: 1.5)) - .offset(x: 3, y: 3) - } + AppIcon(for: bundleID) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18, height: 18) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(.black, lineWidth: 1.5)) + .offset(x: 3, y: 3) } } else { NotificationAppIcon(bundleID: notification.bundleID, size: 44) diff --git a/boringNotch/components/Settings/Views/NotificationSettingsView.swift b/boringNotch/components/Settings/Views/NotificationSettingsView.swift index 22e83efbc..b46045065 100644 --- a/boringNotch/components/Settings/Views/NotificationSettingsView.swift +++ b/boringNotch/components/Settings/Views/NotificationSettingsView.swift @@ -24,7 +24,8 @@ private let knownNotificationApps: [KnownNotificationApp] = [ .init(bundleID: "net.whatsapp.WhatsApp", name: "WhatsApp"), .init(bundleID: "ru.keepcoder.Telegram", name: "Telegram"), .init(bundleID: "com.tdesktop.Telegram", name: "Telegram Desktop"), - .init(bundleID: "com.hnc.Discord", name: "Discord") + .init(bundleID: "com.hnc.Discord", name: "Discord"), + .init(bundleID: "com.anthropic.claudefordesktop", name: "Claude") ] struct NotificationSettingsView: View { diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 2d2473317..2d2672ba3 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -308,7 +308,8 @@ extension Defaults.Keys { "net.whatsapp.WhatsApp", "ru.keepcoder.Telegram", // Telegram Desktop (App Store build) "com.tdesktop.Telegram", - "com.hnc.Discord" + "com.hnc.Discord", + "com.anthropic.claudefordesktop" ] ) /// Apps whose system banner gets closed immediately after boring.notch From 6c4c8fe0efc5f468113461254b251ae6de3cf9d9 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 10:46:27 +0530 Subject: [PATCH 04/69] Prefer the real "Reply" action when a banner exposes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An earlier live test only ever captured a banner post-expansion, where "Reply" is already gone (replaced by "Show Details"/"Send") — leading to a wrong "Notification Center never exposes Reply" comment. A fresh AX dump of a collapsed WhatsApp banner shows the actual action list is ["AXPress", "Show Details", "Reply", "Close"]. Reply still works via the "Show Details" fallback either way, but now uses the more direct, purpose-built action when it's there. canReply's heuristic gets the same correction. --- .../NotificationWatcher.swift | 19 ++++++++++++------- boringNotch.xcodeproj/project.pbxproj | 12 ++++-------- .../managers/SystemNotificationManager.swift | 16 +++++++++------- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index 95ca99094..112339ac3 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -230,17 +230,22 @@ final class NotificationWatcher { /// the banner is on screen and the source app offers a reply field; the /// caller falls back to opening the app otherwise. /// - /// Verified against a live WhatsApp banner (2026-08-12): Notification - /// Center does not expose a "Reply" action. The field is revealed by - /// "Show Details" (collapsed) / "Hide Details" (already expanded), and - /// submitted with a "Send" action on the banner itself — not - /// kAXConfirmAction on the field, which is untested and unreliable across - /// text-area implementations. + /// Verified against a live WhatsApp banner (2026-08-12): a collapsed + /// banner's actions are ["AXPress", "Show Details", "Reply", "Close"] — + /// a real "Reply" action does exist (an earlier check only ever caught + /// the banner post-expansion, where it's already gone, which led to a + /// wrong assumption here). "Reply" is preferred since it's the more + /// direct, purpose-built action; "Show Details" is the fallback for + /// banners/apps that don't expose it. Either way the field is submitted + /// via the "Send" action on the banner — not kAXConfirmAction on the + /// field, which is untested and unreliable across text-area + /// implementations. func reply(token: String, text: String) -> Bool { guard let banner = live[token] else { return false } if replyField(in: banner) == nil { - if let action = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("details") }) { + if let action = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("reply") }) + ?? rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("details") }) { AXUIElementPerformAction(banner, action as CFString) } else if let button = descendants(of: banner, matching: [kAXButtonRole]).first(where: { ($0[kAXTitleAttribute] as? String)?.lowercased().contains("reply") == true diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index c1ecf253a..b2d231f88 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -1426,14 +1426,12 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = boringNotch/boringNotch.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"boringNotch/Preview Content\""; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = ""; + DEVELOPMENT_TEAM = JPWMG84CH8; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; @@ -1495,14 +1493,12 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = boringNotch/boringNotch.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"boringNotch/Preview Content\""; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = ""; + DEVELOPMENT_TEAM = JPWMG84CH8; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index ef7b45696..f0c429187 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -27,15 +27,17 @@ struct SystemNotification: Identifiable, Equatable { /// True while the banner still exists, i.e. while replying can work. var isLive: Bool = true - /// Best-effort signal for showing the reply row. Notification Center - /// doesn't label reply-capable banners consistently — WhatsApp exposes - /// "Show Details" (reveals the field) and "Send" (submits), never the - /// word "reply" (verified against a live banner). Either is a decent - /// hint; `SystemNotificationManager.reply` is the actual source of truth - /// and falls back to opening the app if no field materializes. + /// Best-effort signal for showing the reply row. A collapsed WhatsApp + /// banner exposes "Reply" directly; once expanded that's replaced by + /// "Show Details"/"Send" (verified against live banners in both states). + /// Any of the three is a decent hint; `SystemNotificationManager.reply` + /// is the actual source of truth and falls back to opening the app if no + /// field materializes. var canReply: Bool { isLive && actions.contains { - $0.localizedCaseInsensitiveContains("send") || $0.localizedCaseInsensitiveContains("details") + $0.localizedCaseInsensitiveContains("reply") + || $0.localizedCaseInsensitiveContains("send") + || $0.localizedCaseInsensitiveContains("details") } } From 2b654d8a2cbf3684f9b5c4849cd1ae65621183a0 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 10:50:19 +0530 Subject: [PATCH 05/69] Fix WhatsApp notifications being silently dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp's localizedName is literally "\u{200E}WhatsApp" — it carries a leading LEFT-TO-RIGHT MARK. capture() stripped bidi marks from the parsed app name, then compared that stripped string against the unstripped localizedName, so the match failed, bundleID came back nil, and isAllowed() discarded every WhatsApp notification before it ever reached the notch. Discord/Messages/Claude have no such mark, which is why this went unnoticed. Match on a normalized name (bidi marks removed, case-folded) on both sides, and add an app-name-to-bundle-ID-suffix fallback so a lookup miss degrades to a best-effort match instead of dropping the notification outright. --- .../NotificationWatcher.swift | 30 +++++++++++++++++-- .../managers/SystemNotificationManager.swift | 17 +++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index 112339ac3..dac0dfb38 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -218,12 +218,38 @@ final class NotificationWatcher { return found } + /// Matches on a normalized name because both sides can carry invisible + /// bidi marks: WhatsApp's `localizedName` is literally "\u{200E}WhatsApp" + /// (LEFT-TO-RIGHT MARK), and the banner's own description is wrapped in + /// isolates. Comparing raw strings silently failed to resolve WhatsApp's + /// bundle ID, which dropped every one of its notifications at the + /// allow-list check. private func bundleID(forAppNamed name: String) -> String? { - NSWorkspace.shared.runningApplications.first { - $0.localizedName == name + let target = Self.normalizedAppName(name) + guard !target.isEmpty else { return nil } + return NSWorkspace.shared.runningApplications.first { + guard let localizedName = $0.localizedName else { return false } + return Self.normalizedAppName(localizedName) == target }?.bundleIdentifier } + private static func normalizedAppName(_ name: String) -> String { + name.filter { !$0.unicodeScalars.allSatisfy(bidiControlCharacters.contains) } + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } + + /// Directional formatting characters Notification Center and app names + /// both sprinkle in: LRM/RLM, the isolate family, and the embedding / + /// override set. + private static let bidiControlCharacters: CharacterSet = { + var set = CharacterSet() + set.insert(charactersIn: "\u{200E}\u{200F}") // LRM, RLM + set.insert(charactersIn: "\u{2066}"..."\u{2069}") // isolates + PDI + set.insert(charactersIn: "\u{202A}"..."\u{202E}") // embeddings/overrides + return set + }() + // MARK: - Acting on a banner /// Types into the banner's reply field and submits it. Only works while diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index f0c429187..26c23f20e 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -167,8 +167,21 @@ final class SystemNotificationManager: ObservableObject { /// apps people actually reply to. private func isAllowed(_ notification: SystemNotification) -> Bool { if Defaults[.notificationsFromAllApps] { return true } - guard let bundleID = notification.bundleID else { return false } - return Defaults[.notificationAllowedApps].contains(bundleID) + + if let bundleID = notification.bundleID { + return Defaults[.notificationAllowedApps].contains(bundleID) + } + + // Bundle ID resolution goes through the app's *running* name, which + // can miss (renamed app, helper process owning the notification, an + // app that quit between posting and capture). Rather than silently + // dropping the notification, fall back to matching the app name + // against the tail of an allowed bundle ID — "WhatsApp" against + // net.whatsapp.WhatsApp. + guard let appName = notification.appName?.lowercased(), !appName.isEmpty else { return false } + return Defaults[.notificationAllowedApps].contains { bundleID in + bundleID.split(separator: ".").last.map { appName == $0.lowercased() } ?? false + } } private func show(_ notification: SystemNotification) { From e7f2b8099b325cf2ff4ccbcdc8d63aedac19d82d Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 10:54:59 +0530 Subject: [PATCH 06/69] Fix silent XPC callback drop and add banner diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper cast its callback proxy to BoringNotchXPCHelperDelegate, but the connection's remoteObjectInterface is the combined BoringNotchXPCAppDelegate. A distant-object proxy's conformance comes from the exact interface it was configured with, so casting to the parent protocol can yield nil — and since the delegate is optional, every captured banner was then silently discarded before reaching the app. Cast to the exact protocol, use remoteObjectProxyWithErrorHandler so transport errors surface instead of vanishing, and log at each hop (captured -> delivered -> filtered/shown) so a break in the chain is attributable rather than invisible. --- .../BoringNotchXPCHelper.swift | 20 +++++++++++++++++-- .../XPCHelperClient/XPCHelperClient.swift | 1 + .../managers/SystemNotificationManager.swift | 6 +++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift index b7f08acff..661c13773 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift @@ -87,12 +87,26 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { @objc func startNotificationWatching(with reply: @escaping (Bool) -> Void) { // Capture the delegate for this connection before hopping queues — // NSXPCConnection.current() is only valid inside the incoming call. - let delegate = NSXPCConnection.current()?.remoteObjectProxy as? BoringNotchXPCHelperDelegate + // + // Cast to BoringNotchXPCAppDelegate, not its parent protocol: the + // proxy's conformance is built from the exact interface the + // connection was configured with, so casting to the parent can + // return nil and silently swallow every callback. + let connection = NSXPCConnection.current() + let proxy = connection?.remoteObjectProxyWithErrorHandler { error in + NSLog("[boringNotch] notification callback failed: \(error.localizedDescription)") + } + let delegate = proxy as? BoringNotchXPCAppDelegate + + if delegate == nil { + NSLog("[boringNotch] could not obtain notification delegate proxy — banners will not reach the app") + } // The AX observer needs a live run loop; the helper's is on main. DispatchQueue.main.async { let watcher = Self.watcher watcher.onBanner = { notification in + NSLog("[boringNotch] captured banner: app=\(notification.appName ?? "-") bundle=\(notification.bundleID ?? "-") title=\(notification.title ?? "-")") delegate?.notificationDidAppear([ "token": notification.token, "appName": notification.appName ?? "", @@ -104,7 +118,9 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { ]) } watcher.onBannerGone = { delegate?.notificationDidDisappear($0) } - reply(watcher.start()) + let started = watcher.start() + NSLog("[boringNotch] notification watcher start -> \(started), AX trusted: \(AXIsProcessTrusted())") + reply(started) } } diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index 2833abc62..62a1bce29 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -382,6 +382,7 @@ final class NotificationXPCDelegate: NSObject, BoringNotchXPCAppDelegate { } func notificationDidAppear(_ payload: [String: String]) { + NSLog("[boringNotch] app received banner: \(payload["appName"] ?? "-") / \(payload["title"] ?? "-")") NotificationCenter.default.post( name: .systemNotificationDidAppear, object: nil, userInfo: payload ) diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index 26c23f20e..3ec8ce48b 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -145,7 +145,11 @@ final class SystemNotificationManager: ObservableObject { notifications.removeLast(notifications.count - historyLimit) } - guard isAllowed(notification) else { return } + guard isAllowed(notification) else { + NSLog("[boringNotch] filtered out: \(notification.appName ?? "-") bundle=\(notification.bundleID ?? "nil")") + return + } + NSLog("[boringNotch] showing in notch: \(notification.appName ?? "-")") show(notification) suppressSystemBannerIfNeeded(notification) } From 54ba242a8a3ec98d8cabb0ca4da2f07d4581b0bd Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 10:57:58 +0530 Subject: [PATCH 07/69] Fix watcher never scanning after startup in the XPC service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An XPC service's main thread is driven by dispatch_main(), which services DispatchQueue.main blocks but does not run a CFRunLoop. The watcher depended on one twice — Timer + RunLoop.current.add for the poll, and CFRunLoopAddSource for the AXObserver — so neither ever fired. start() ran its single initial scan(), reported success, and then captured nothing for the rest of the process's life. From the outside this looked like "watcher started, AX trusted, no banners ever seen", which is exactly what it was. Replace both with a DispatchSourceTimer, which needs no run loop. The AXObserver goes away rather than being moved to a dedicated run-loop thread: it was already only a latency optimization over the poll that actually does the work, and polling every 0.35s catches every banner (they live ~5s). reply() had the same latent bug — RunLoop.run(until:) returns immediately here, so the wait for the reply field to appear was a no-op. Use Thread.sleep. --- .../NotificationWatcher.swift | 55 +++++++++---------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index dac0dfb38..46095c727 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -39,11 +39,14 @@ final class NotificationWatcher { var onBanner: ((CapturedNotification) -> Void)? var onBannerGone: ((String) -> Void)? - private var observer: AXObserver? private var appElement: AXUIElement? - private var pollTimer: Timer? + private var pollTimer: DispatchSourceTimer? private var live: [String: AXUIElement] = [:] + /// Banners live ~5s, so this catches every one with room to spare while + /// staying cheap — each tick is a shallow AX tree walk. + private let pollInterval: TimeInterval = 0.35 + var isRunning: Bool { appElement != nil } // MARK: - Lifecycle @@ -59,26 +62,23 @@ final class NotificationWatcher { let app = AXUIElementCreateApplication(notificationCenter.processIdentifier) appElement = app - var created: AXObserver? - let callback: AXObserverCallback = { _, _, _, context in - guard let context else { return } - Unmanaged.fromOpaque(context).takeUnretainedValue().scan() - } - if AXObserverCreate(notificationCenter.processIdentifier, callback, &created) == .success, - let created { - let context = Unmanaged.passUnretained(self).toOpaque() - for name in [kAXWindowCreatedNotification, kAXCreatedNotification, kAXUIElementDestroyedNotification] { - AXObserverAddNotification(created, app, name as CFString, context) - } - CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(created), .defaultMode) - observer = created - } - - // ponytail: the AX notifications above fire inconsistently for the - // banner window, so a light poll backstops them. Remove the timer if a - // single notification name ever proves reliable across releases. - let timer = Timer(timeInterval: 0.35, repeats: true) { [weak self] _ in self?.scan() } - RunLoop.current.add(timer, forMode: .common) + // Polling only, deliberately — no AXObserver and no Timer. + // + // This runs inside an XPC service, whose main thread is driven by + // dispatch_main(). That services DispatchQueue.main blocks but does + // NOT run a CFRunLoop, so anything depending on one is dead code + // here: Timer/RunLoop.add never fires, and an AXObserver's + // CFRunLoopSource never delivers. An earlier version used both and + // silently captured nothing after the single scan() below — the + // watcher reported "started" and then went quiet forever. + // + // A DispatchSourceTimer needs no run loop, so it works. All watcher + // state stays on the main queue, which is also where the helper + // dispatches reply/action calls, so there's no locking to get wrong. + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + pollInterval, repeating: pollInterval) + timer.setEventHandler { [weak self] in self?.scan() } + timer.resume() pollTimer = timer scan() @@ -86,12 +86,8 @@ final class NotificationWatcher { } func stop() { - pollTimer?.invalidate() + pollTimer?.cancel() pollTimer = nil - if let observer { - CFRunLoopRemoveSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(observer), .defaultMode) - } - observer = nil appElement = nil live.removeAll() } @@ -278,7 +274,10 @@ final class NotificationWatcher { }) { AXUIElementPerformAction(button, kAXPressAction as CFString) } - RunLoop.current.run(until: Date().addingTimeInterval(0.4)) + // Thread.sleep, not RunLoop.run: there's no CFRunLoop to advance + // in an XPC service, so RunLoop.run(until:) returns immediately + // and the reply field wouldn't have appeared yet. + Thread.sleep(forTimeInterval: 0.4) } guard let field = replyField(in: banner) else { return false } From f2594cf8659a81235017bf4ffd76b6852bbea345 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 11:03:23 +0530 Subject: [PATCH 08/69] Keep the XPC connection alive so banner pushes reach the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper captures its callback proxy once, when notification watching starts. ensureRemoteService invalidated and rebuilt the connection whenever Lunar/OSD asked for a listener, which left the helper holding a proxy to a dead connection — banners kept being captured and logged in the helper, and silently never arrived in the app. Nothing needs the teardown any more: since the exported object serves both callback protocols from creation, a live connection is always reusable. Register the Lunar listener on that shared object at subscribe time, since the connection may already exist and that's now the only path that wires it up. Also add a stack of closed-notch live activities: a notification takes the front and reverts to music on expiry (falling out of the list is the whole mechanism — no restore bookkeeping), with horizontal swipe to move between them. Chin width follows the selected activity rather than whichever happens to exist. --- .../BoringNotchXPCHelper.swift | 2 +- boringNotch.xcodeproj/project.pbxproj | 4 + boringNotch/ContentView.swift | 89 ++++++++++---- .../XPCHelperClient/XPCHelperClient.swift | 26 ++-- .../components/Notch/LiveActivityStack.swift | 115 ++++++++++++++++++ 5 files changed, 206 insertions(+), 30 deletions(-) create mode 100644 boringNotch/components/Notch/LiveActivityStack.swift diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift index 661c13773..4e003c3fb 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift @@ -106,7 +106,7 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { DispatchQueue.main.async { let watcher = Self.watcher watcher.onBanner = { notification in - NSLog("[boringNotch] captured banner: app=\(notification.appName ?? "-") bundle=\(notification.bundleID ?? "-") title=\(notification.title ?? "-")") + NSLog("[boringNotch] captured banner: app=\(notification.appName ?? "-") bundle=\(notification.bundleID ?? "-") title=\(notification.title ?? "-") delegate=\(delegate == nil ? "nil" : "ok")") delegate?.notificationDidAppear([ "token": notification.token, "appName": notification.appName ?? "", diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index b2d231f88..2024d5aa0 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -339,6 +339,8 @@ A5167212301F85B40018095A /* boringNotchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = boringNotchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; AA01NDW22E7A0001 /* NotificationDebugWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugWindow.swift; sourceTree = ""; }; AA01NLA22E7A0001 /* NotificationLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationLiveActivity.swift; sourceTree = ""; }; + AA04LAS22E7A0001 /* LiveActivityStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityStack.swift; sourceTree = ""; }; + AA04LAS12E7A0001 /* LiveActivityStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA04LAS22E7A0001 /* LiveActivityStack.swift */; }; AA01SNM22E7A0001 /* SystemNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemNotificationManager.swift; sourceTree = ""; }; AA02CAM22E7A0001 /* ContactAvatarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactAvatarManager.swift; sourceTree = ""; }; AA02NSV22E7A0001 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; @@ -847,6 +849,7 @@ isa = PBXGroup; children = ( AA01NLA22E7A0001 /* NotificationLiveActivity.swift */, + AA04LAS22E7A0001 /* LiveActivityStack.swift */, 1194E8862EA6DDA7009C82D6 /* BoringNotchSkyLightWindow.swift */, 1160F8D72DD98230006FBB94 /* NotchShape.swift */, 9AB0C6BB2C73C9CB00F7CD30 /* NotchHomeView.swift */, @@ -1122,6 +1125,7 @@ AA01SNM12E7A0001 /* SystemNotificationManager.swift in Sources */, AA01NDW12E7A0001 /* NotificationDebugWindow.swift in Sources */, AA01NLA12E7A0001 /* NotificationLiveActivity.swift in Sources */, + AA04LAS12E7A0001 /* LiveActivityStack.swift in Sources */, AA02CAM12E7A0001 /* ContactAvatarManager.swift in Sources */, AA03OTP12E7A0001 /* OTPDetector.swift in Sources */, 1471639A2C5D35FF0068B555 /* MusicManager.swift in Sources */, diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 9db5936ca..71bdc88f5 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -24,6 +24,8 @@ struct ContentView: View { @ObservedObject var brightnessManager = BrightnessManager.shared @ObservedObject var volumeManager = VolumeManager.shared @ObservedObject var notificationManager = SystemNotificationManager.shared + /// Which entry of the closed-notch activity stack is on top. + @State private var activityIndex: Int = 0 @State private var hoverTask: Task? @State private var isHovering: Bool = false @State private var anyDropDebounceTask: Task? @@ -86,6 +88,35 @@ struct ContentView: View { ) } + /// Closed-notch activities, newest first. A notification sits in front of + /// music, so an incoming message takes over the display; when it expires + /// it drops out of this list on its own and music comes back — no + /// explicit "restore previous activity" bookkeeping needed. + private var liveActivities: [LiveActivityItem] { + var items: [LiveActivityItem] = [] + + if let notification = notificationManager.activeNotification { + items.append(.notification(notification)) + } + + let musicIsShowing = (!coordinator.expandingView.show || coordinator.expandingView.type == .music) + && (musicManager.isPlaying || !musicManager.isPlayerIdle) + && coordinator.musicLiveActivityEnabled + if musicIsShowing { + items.append(.music) + } + + return items + } + + /// The activity currently on top of the stack — what the chin has to be + /// sized for. + private var selectedActivity: LiveActivityItem? { + let items = liveActivities + guard !items.isEmpty else { return nil } + return items[min(max(activityIndex, 0), items.count - 1)] + } + private var computedChinWidth: CGFloat { var chinWidth: CGFloat = vm.closedNotchSize.width @@ -93,21 +124,21 @@ struct ContentView: View { && vm.notchState == .closed && Defaults[.showPowerStatusNotifications] { chinWidth = 640 - } else if notificationManager.activeNotification?.detectedCode != nil && vm.notchState == .closed - && !vm.hideOnClosed - { - // Wide enough for the code itself plus a copy affordance, without - // going as far as the battery pill's 640. - chinWidth = 420 - } else if notificationManager.activeNotification != nil && vm.notchState == .closed - && !vm.hideOnClosed - { - chinWidth += (2 * max(0, vm.effectiveClosedNotchHeight - 12) + 20) - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) - && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) - && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed - { - chinWidth += (2 * max(0, displayClosedNotchHeight - 12) + 20 + 2 * liveActivityEdgeMargin + 2) + } else if vm.notchState == .closed, !vm.hideOnClosed, let activity = selectedActivity { + // Sized for whichever activity is actually on top, not for + // whichever happens to exist — otherwise swiping to music while a + // notification is still in the stack leaves the chin at the + // notification's width. + switch activity { + case .notification(let notification) where notification.detectedCode != nil: + // Wide enough for the code itself plus a copy affordance, + // without going as far as the battery pill's 640. + chinWidth = 420 + case .notification: + chinWidth += (2 * max(0, vm.effectiveClosedNotchHeight - 12) + 20) + case .music: + chinWidth += (2 * max(0, displayClosedNotchHeight - 12) + 20 + 2 * liveActivityEdgeMargin + 2) + } } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed @@ -217,6 +248,18 @@ struct ContentView: View { } } } + // A new notification always takes the front of the stack, + // even if the user had swiped away to music. + .onChange(of: notificationManager.activeNotification?.id) { _, newID in + if newID != nil { activityIndex = 0 } + } + // Activities disappear on their own (a notification + // expires, music stops). Keep the selection in range so + // the stack falls back to whatever is left instead of + // pointing past the end. + .onChange(of: liveActivities.count) { _, count in + if activityIndex >= count { activityIndex = max(count - 1, 0) } + } .onChange(of: vm.isBatteryPopoverActive) { if !vm.isBatteryPopoverActive && !isHovering && vm.notchState == .open && !SharingStateManager.shared.preventNotchClose { hoverTask?.cancel() @@ -339,9 +382,6 @@ struct ContentView: View { .frame(width: 76, alignment: .trailing) } .frame(height: displayClosedNotchHeight, alignment: .center) - } else if let notification = notificationManager.activeNotification, vm.notchState == .closed, !vm.hideOnClosed { - NotificationLiveActivity(notification: notification) - .transition(.opacity) } else if coordinator.shouldShowSneakPeek(on: vm.screenUUID) && Defaults[.inlineOSD] && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .battery) && vm.notchState == .closed { InlineOSD( type: coordinator.binding(for: vm.screenUUID).type, @@ -352,9 +392,16 @@ struct ContentView: View { gestureProgress: $gestureProgress ) .transition(.opacity) - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { - MusicLiveActivity() - .frame(alignment: .center) + } else if !liveActivities.isEmpty && vm.notchState == .closed && !vm.hideOnClosed { + LiveActivityStackView(items: liveActivities, index: $activityIndex) { item in + switch item { + case .notification(let notification): + NotificationLiveActivity(notification: notification) + case .music: + MusicLiveActivity() + .frame(alignment: .center) + } + } } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { BoringFaceAnimation() } else if vm.notchState == .open { diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index 62a1bce29..c48c6a4a9 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -24,16 +24,22 @@ final class XPCHelperClient: NSObject { @MainActor private func ensureRemoteService(needsListener: Bool = false) -> RemoteXPCService { - if let existing = remoteService, (!needsListener || hasLunarListener) { + // Always reuse a live connection — never tear one down to attach a + // listener. The exported object below serves *both* callback + // protocols from the moment the connection is created, so there's + // nothing to re-negotiate. + // + // This previously invalidated and rebuilt the connection whenever + // Lunar/OSD asked for a listener. The helper captures its callback + // proxy once, when notification watching starts; invalidating that + // connection left it holding a dead proxy, so banners kept being + // captured in the helper and silently never arrived in the app. + if let existing = remoteService { + notificationDelegate.lunarListener = lunarListener + hasLunarListener = hasLunarListener || (needsListener && lunarListener != nil) return existing } - if let connection { - connection.invalidate() - self.connection = nil - self.remoteService = nil - } - let conn = NSXPCConnection(serviceName: serviceName) // One exported object serves both callback protocols. @@ -41,7 +47,7 @@ final class XPCHelperClient: NSObject { conn.exportedInterface = makeAppDelegateInterface() conn.exportedObject = notificationDelegate hasLunarListener = needsListener && lunarListener != nil - + conn.interruptionHandler = { [weak self] in Task { @MainActor in self?.connection = nil @@ -321,6 +327,10 @@ final class XPCHelperClient: NSObject { nonisolated func startLunarEventStream(listener: BoringNotchXPCHelperLunarListener) async -> Bool { await MainActor.run { lunarListener = listener + // Register on the shared exported object too: the connection may + // already exist (it isn't rebuilt for listeners any more), in + // which case this is the only path that hooks Lunar events up. + notificationDelegate.lunarListener = listener } do { let service = await MainActor.run { diff --git a/boringNotch/components/Notch/LiveActivityStack.swift b/boringNotch/components/Notch/LiveActivityStack.swift new file mode 100644 index 000000000..905699ac4 --- /dev/null +++ b/boringNotch/components/Notch/LiveActivityStack.swift @@ -0,0 +1,115 @@ +// +// LiveActivityStack.swift +// boringNotch +// +// A browsable stack of closed-notch live activities, in the spirit of the +// Dynamic Island / Lock Screen activity stack: the newest activity takes +// the front, older ones sit behind it, and the user can swipe between them. +// +// Transient activities (a notification) auto-expire and reveal whatever was +// underneath (music), so an incoming message interrupts the now-playing +// display for a beat and then hands it back on its own. +// + +import Defaults +import SwiftUI + +/// One entry in the closed-notch stack. +/// +/// Deliberately excludes the momentary HUDs — volume/brightness OSD and the +/// battery pill. Those are interrupts, not activities: they take over for +/// ~1.5s and aren't something you'd want to swipe back to, which is also how +/// iOS separates them from live activities. +enum LiveActivityItem: Identifiable, Equatable { + case notification(SystemNotification) + case music + + var id: String { + switch self { + case .notification(let notification): "notification-\(notification.id)" + case .music: "music" + } + } +} + +/// Renders the selected activity with the others hinted behind it, and +/// handles swiping between them. +/// +/// Takes its content via a closure so callers keep ownership of how each +/// activity draws — `MusicLiveActivity` depends on ContentView's namespace +/// and gesture state, and dragging it out here would be a much larger, +/// riskier change than this feature needs. +struct LiveActivityStackView: View { + let items: [LiveActivityItem] + @Binding var index: Int + @ViewBuilder let content: (LiveActivityItem) -> Content + + @State private var dragOffset: CGFloat = 0 + @State private var haptics: Bool = false + + private var clampedIndex: Int { min(max(index, 0), max(items.count - 1, 0)) } + + var body: some View { + ZStack { + // The card behind the front one, nudged down and dimmed. Purely a + // depth cue that there's more here — the closed notch has no room + // for a page-dot row. + if items.count > 1 { + Capsule() + .fill(.white.opacity(0.12)) + .frame(height: 3) + .padding(.horizontal, 40) + .offset(y: 9) + .transition(.opacity) + } + + if let item = items[safe: clampedIndex] { + content(item) + .id(item.id) + .offset(x: dragOffset) + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .opacity + )) + } + } + .animation(.smooth(duration: 0.3), value: clampedIndex) + .animation(.smooth(duration: 0.3), value: items.count) + .contentShape(Rectangle()) + // minimumDistance keeps taps (open the notch) and the notch's own + // vertical pan gestures working — this only claims deliberate + // horizontal drags. + .gesture( + DragGesture(minimumDistance: 14) + .onChanged { value in + guard items.count > 1, abs(value.translation.width) > abs(value.translation.height) else { return } + dragOffset = value.translation.width * 0.3 + } + .onEnded { value in + dragOffset = 0 + guard items.count > 1, + abs(value.translation.width) > abs(value.translation.height), + abs(value.translation.width) > 24 + else { return } + move(by: value.translation.width < 0 ? 1 : -1) + } + ) + .sensoryFeedback(.alignment, trigger: haptics) + } + + private func move(by delta: Int) { + let next = clampedIndex + delta + guard items.indices.contains(next) else { return } + withAnimation(.smooth(duration: 0.3)) { index = next } + if Defaults[.enableHaptics] { haptics.toggle() } + } +} + +private extension Array { + /// The stack's contents change out from under the selection (a + /// notification expires, music stops), so every read is bounds-checked + /// rather than trusting an index that was valid a frame ago. + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} From c01ee9690ce2b29487a786b60f0c349d4308d7a1 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 11:07:09 +0530 Subject: [PATCH 09/69] Stop stale notifications hijacking the opened notch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit holdActive() ran on appear and cancelled the dismiss timer for as long as the expanded view existed, so opening the notch pinned the current notification indefinitely — hovering the notch minutes later still showed a long-dead message instead of the normal home view, with no reply field (the banner was long gone) and no way back except dismissing it. Hold only while the reply field has focus, which is the case that actually needs protecting from the timer. Everything else lets the notification age out and hand the notch back. Also make the expanded view fill its slot instead of clustering in the top-left corner, and scale the avatar and type to the 640x190 open notch rather than banner-sized proportions. --- .../Notch/NotificationLiveActivity.swift | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index 85d1c8f84..12384c7f2 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -116,21 +116,35 @@ struct NotificationExpandedView: View { private var kind: NotificationKind { .init(notification) } var body: some View { - HStack(alignment: .top, spacing: 12) { + HStack(alignment: .center, spacing: 16) { headerAvatar - .padding(.top, 2) - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { header textBlock actionArea } + .frame(maxWidth: .infinity, alignment: .leading) } - .padding(.horizontal, 4) + // Fill the opened notch rather than clustering in its top-left + // corner — this sits in the same slot NotchHomeView occupies, which + // is sized to the full open notch. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(.horizontal, 8) + // Rebuild cleanly when one notification replaces another, instead of + // reusing the previous one's view state. + .id(notification.id) .onAppear { - manager.holdActive() if kind == .reply { replyFocused = true } } + // Hold the notification open only while the user is actually typing + // a reply. Holding it for the whole time the notch is open pinned + // stale notifications indefinitely and blocked the normal notch + // content — opening the notch minutes later still showed the old + // message. + .onChange(of: replyFocused) { _, focused in + if focused { manager.holdActive() } else { manager.resumeDismiss() } + } .onDisappear { manager.resumeDismiss() } } @@ -158,17 +172,17 @@ struct NotificationExpandedView: View { let bundleID = notification.bundleID, Self.personAvatarBundleIDs.contains(bundleID) { ZStack(alignment: .bottomTrailing) { - PersonAvatarView(name: sender, size: 44) + PersonAvatarView(name: sender, size: 64) AppIcon(for: bundleID) .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 18, height: 18) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(.black, lineWidth: 1.5)) - .offset(x: 3, y: 3) + .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(.black, lineWidth: 2)) + .offset(x: 4, y: 4) } } else { - NotificationAppIcon(bundleID: notification.bundleID, size: 44) + NotificationAppIcon(bundleID: notification.bundleID, size: 64) } } @@ -177,12 +191,12 @@ struct NotificationExpandedView: View { private var header: some View { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(notification.sender ?? notification.appName ?? "Notification") - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: 17, weight: .semibold)) .foregroundStyle(.primary) .lineLimit(1) Text(notification.receivedAt, style: .relative) - .font(.system(size: 11)) + .font(.system(size: 12)) .foregroundStyle(.tertiary) .fixedSize() @@ -200,18 +214,18 @@ struct NotificationExpandedView: View { private var textBlock: some View { if let subtitle = notification.subtitle, subtitle != notification.sender { Text(subtitle) - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) .lineLimit(1) } if let body = notification.body { Text(body) - .font(.system(size: 13)) + .font(.system(size: 15)) .foregroundStyle(.secondary.opacity(0.9)) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) - .lineSpacing(1) + .lineSpacing(2) } } From d55dcada39216c4f84572e71cc61de8e439e47b6 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 11:11:57 +0530 Subject: [PATCH 10/69] Don't let a notification expire while you're opening the notch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things made the notification vanish just as the notch opened: holding was scoped to reply-field focus (so a notification with no reply action was never held at all), and the countdown only paused once the expanded view existed — but opening waits out minimumHoverDuration plus an animation, so a notification near the end of its 8s could die during the gesture that was meant to reveal it. Freeze the countdown when the pointer arrives and resume when it leaves, with the open notch holding it too. holdActive now caps at maxLifetime (30s) instead of cancelling outright, so this can't regress into pinning a dead notification in the notch forever. resumeDismiss now replaces the pending task rather than bailing when one exists — otherwise holdActive's cap would survive the notch closing and keep the notification up for the full 30s. --- boringNotch/ContentView.swift | 16 +++++++-- .../Notch/NotificationLiveActivity.swift | 14 ++++---- .../managers/SystemNotificationManager.swift | 34 +++++++++++++++++-- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 71bdc88f5..f3afacf4e 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -652,7 +652,16 @@ struct ContentView: View { withAnimation(animationSpring) { isHovering = true } - + + // Freeze the dismiss countdown the moment the pointer arrives, + // not when the notch finishes opening. Opening waits out + // minimumHoverDuration plus an animation, and a notification + // near the end of its life would expire during that — so it + // vanished exactly as the notch opened around it. + if notificationManager.activeNotification != nil { + notificationManager.holdActive() + } + if vm.notchState == .closed && Defaults[.enableHaptics] { haptics.toggle() } @@ -682,7 +691,10 @@ struct ContentView: View { withAnimation(animationSpring) { self.isHovering = false } - + + // Pointer left — let the notification age out again. + self.notificationManager.resumeDismiss() + if self.vm.notchState == .open && !self.vm.isBatteryPopoverActive && !SharingStateManager.shared.preventNotchClose { self.vm.close() } diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index 12384c7f2..812b964c1 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -134,17 +134,15 @@ struct NotificationExpandedView: View { // Rebuild cleanly when one notification replaces another, instead of // reusing the previous one's view state. .id(notification.id) + // This view exists only while the notch is open, so appear/disappear + // is the open/close signal: pause the countdown while the user is + // looking at it, resume when they close. holdActive caps itself at + // maxLifetime, so an abandoned open notch still lets the + // notification go rather than pinning it forever. .onAppear { + manager.holdActive() if kind == .reply { replyFocused = true } } - // Hold the notification open only while the user is actually typing - // a reply. Holding it for the whole time the notch is open pinned - // stale notifications indefinitely and blocked the normal notch - // content — opening the notch minutes later still showed the old - // message. - .onChange(of: replyFocused) { _, focused in - if focused { manager.holdActive() } else { manager.resumeDismiss() } - } .onDisappear { manager.resumeDismiss() } } diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index 3ec8ce48b..6d8b583b9 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -72,6 +72,11 @@ final class SystemNotificationManager: ObservableObject { /// How long the notch keeps showing a notification after it arrives. private let activeDuration: TimeInterval = 8 + + /// Ceiling on how long a notification can occupy the notch even while + /// held open. Past this it's stale — the source banner is long gone, so + /// replying is impossible anyway. + private let maxLifetime: TimeInterval = 30 private var dismissTask: Task? /// Expired banners are kept around briefly so the notch can still show the @@ -207,16 +212,41 @@ final class SystemNotificationManager: ObservableObject { withAnimation(.smooth) { activeNotification = nil } } - /// Keeps the notification up while the user is interacting with it. + /// Keeps the notification up while the user is engaging with it — the + /// notch is open, or they're typing a reply. Without this the countdown + /// keeps running and the notification vanishes mid-read. + /// + /// Bounded by `maxLifetime` rather than cancelled outright: an + /// indefinite hold meant an opened notch pinned its notification + /// forever, so hovering the notch much later still showed a long-dead + /// message instead of the normal content. func holdActive() { dismissTask?.cancel() dismissTask = nil + + guard let active = activeNotification else { return } + let remaining = maxLifetime - Date().timeIntervalSince(active.receivedAt) + guard remaining > 0 else { + dismissActive(token: active.id) + return + } + dismissTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(remaining)) + guard !Task.isCancelled else { return } + await MainActor.run { self?.dismissActive(token: active.id) } + } } /// Restarts the dismiss countdown once the user stops interacting — /// without this a held notification would stay in the notch forever. + /// + /// Replaces whatever task is pending rather than bailing when one + /// exists: holdActive always leaves its maxLifetime cap scheduled, so a + /// bail-if-busy check here would leave the notification sitting for the + /// full cap after the notch closed instead of the short countdown. func resumeDismiss(after delay: TimeInterval = 3) { - guard let active = activeNotification, dismissTask == nil else { return } + guard let active = activeNotification else { return } + dismissTask?.cancel() dismissTask = Task { [weak self] in try? await Task.sleep(for: .seconds(delay)) guard !Task.isCancelled else { return } From 1d709592f796438416a91180a3c211ae08a6a330 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 11:18:50 +0530 Subject: [PATCH 11/69] Make the expanded notification compact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opened notch is sized for the home/shelf tabs, and the notification view was stretching to fill it — two lines of text floating in a 190pt-tall black slab. Shrink the notch itself to 132pt while a notification is showing, cap the content at 460pt wide, and scale the avatar and type back down so it reads as a notification rather than a page. --- boringNotch/ContentView.swift | 9 ++++- .../Notch/NotificationLiveActivity.swift | 36 +++++++++---------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index f3afacf4e..c5260e505 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -109,6 +109,13 @@ struct ContentView: View { return items } + /// A notification is a glance, not a workspace — it doesn't need the full + /// height the home/shelf tabs are sized for, and stretching to fill it + /// just surrounds two lines of text with empty black. + private var openNotchHeight: CGFloat { + notificationManager.activeNotification != nil ? 132 : vm.notchSize.height + } + /// The activity currently on top of the stack — what the chin has to be /// sized for. private var selectedActivity: LiveActivityItem? { @@ -191,7 +198,7 @@ struct ContentView: View { .opacity((isNotchHeightZero && vm.notchState == .closed) ? 0.01 : 1) mainLayout - .frame(height: vm.notchState == .open ? vm.notchSize.height : nil) + .frame(height: vm.notchState == .open ? openNotchHeight : nil) .conditionalModifier(true) { view in return view .animation(vm.notchState == .open ? StandardAnimations.open : StandardAnimations.close, value: vm.notchState) diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index 812b964c1..4c59ffc0f 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -116,21 +116,21 @@ struct NotificationExpandedView: View { private var kind: NotificationKind { .init(notification) } var body: some View { - HStack(alignment: .center, spacing: 16) { + HStack(alignment: .center, spacing: 12) { headerAvatar - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 5) { header textBlock actionArea } .frame(maxWidth: .infinity, alignment: .leading) } - // Fill the opened notch rather than clustering in its top-left - // corner — this sits in the same slot NotchHomeView occupies, which - // is sized to the full open notch. - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .padding(.horizontal, 8) + // Sized to its content, not to the notch. The opened notch is built + // for the home/shelf tabs; a notification is a glance, so filling + // that area just wraps two lines of text in empty black. + .frame(maxWidth: 460, alignment: .leading) + .padding(.horizontal, 4) // Rebuild cleanly when one notification replaces another, instead of // reusing the previous one's view state. .id(notification.id) @@ -170,17 +170,17 @@ struct NotificationExpandedView: View { let bundleID = notification.bundleID, Self.personAvatarBundleIDs.contains(bundleID) { ZStack(alignment: .bottomTrailing) { - PersonAvatarView(name: sender, size: 64) + PersonAvatarView(name: sender, size: 46) AppIcon(for: bundleID) .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 24, height: 24) - .clipShape(RoundedRectangle(cornerRadius: 7)) - .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(.black, lineWidth: 2)) - .offset(x: 4, y: 4) + .frame(width: 20, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.black, lineWidth: 1.5)) + .offset(x: 3, y: 3) } } else { - NotificationAppIcon(bundleID: notification.bundleID, size: 64) + NotificationAppIcon(bundleID: notification.bundleID, size: 46) } } @@ -189,12 +189,12 @@ struct NotificationExpandedView: View { private var header: some View { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(notification.sender ?? notification.appName ?? "Notification") - .font(.system(size: 17, weight: .semibold)) + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.primary) .lineLimit(1) Text(notification.receivedAt, style: .relative) - .font(.system(size: 12)) + .font(.system(size: 11)) .foregroundStyle(.tertiary) .fixedSize() @@ -212,18 +212,18 @@ struct NotificationExpandedView: View { private var textBlock: some View { if let subtitle = notification.subtitle, subtitle != notification.sender { Text(subtitle) - .font(.system(size: 13, weight: .medium)) + .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) .lineLimit(1) } if let body = notification.body { Text(body) - .font(.system(size: 15)) + .font(.system(size: 13)) .foregroundStyle(.secondary.opacity(0.9)) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) - .lineSpacing(2) + .lineSpacing(1) } } From 5f48817cd61bfcd10fe75b85f2c7edff1116128d Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 11:26:26 +0530 Subject: [PATCH 12/69] Fit the notch to the notification instead of full width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several things were forcing the panel to its full 640pt regardless of how short the message was: a Spacer in the header row, another in the code and call rows, a bare TextField (which claims every point offered), maxWidth: .infinity on the text column, and BoringHeader — whose tab bar spans the whole notch — rendering above it. Drop the Spacers, bound the text column and reply field, and hide the tab bar while a notification is showing. A notification is a glance, not somewhere you switch between home and shelf. Also keep the reply box for as long as the notification is in the notch, rather than swapping it for "Open in " the moment the system banner dies (~5s). Replying types into that banner's own field, so there's genuinely no way to send once it's gone — but silently dropping a typed message is worse than being useful about it: the draft goes to the clipboard, the app opens, and the button shows a clipboard glyph rather than a checkmark, since a hand-off is not a delivery. --- boringNotch/ContentView.swift | 7 +- .../Notch/NotificationLiveActivity.swift | 68 ++++++++++++------- .../components/NotificationDebugWindow.swift | 8 ++- .../managers/ContactAvatarManager.swift | 2 + .../managers/SystemNotificationManager.swift | 38 +++++++++-- 5 files changed, 90 insertions(+), 33 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index c5260e505..c8ebb7883 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -411,7 +411,12 @@ struct ContentView: View { } } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { BoringFaceAnimation() - } else if vm.notchState == .open { + } else if vm.notchState == .open && notificationManager.activeNotification == nil { + // No tab bar over a notification: it's a glance, + // not a place to switch between home and shelf — + // and the header spans the full notch width, + // which is what was stretching the whole panel + // out around a short message. BoringHeader() .frame(height: max(24, displayClosedNotchHeight)) .opacity(gestureProgress != 0 ? 1.0 - min(abs(gestureProgress) * 0.1, 0.3) : 1.0) diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index 4c59ffc0f..1518dcb81 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -111,6 +111,7 @@ struct NotificationExpandedView: View { @State private var replyText = "" @State private var isSending = false @State private var didSend = false + @State private var didHandOff = false @FocusState private var replyFocused: Bool private var kind: NotificationKind { .init(notification) } @@ -119,17 +120,20 @@ struct NotificationExpandedView: View { HStack(alignment: .center, spacing: 12) { headerAvatar + // maxWidth (not .infinity) lets this shrink to whatever the + // message actually needs while still wrapping long ones, so the + // notch hugs the content instead of always spanning its full + // width. VStack(alignment: .leading, spacing: 5) { header textBlock actionArea } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: 300, alignment: .leading) + + dismissButton + .padding(.leading, 4) } - // Sized to its content, not to the notch. The opened notch is built - // for the home/shelf tabs; a notification is a glance, so filling - // that area just wraps two lines of text in empty black. - .frame(maxWidth: 460, alignment: .leading) .padding(.horizontal, 4) // Rebuild cleanly when one notification replaces another, instead of // reusing the previous one's view state. @@ -193,16 +197,18 @@ struct NotificationExpandedView: View { .foregroundStyle(.primary) .lineLimit(1) + // No Spacer here — it would expand to fill and drag the notch + // out to full width regardless of how short the message is. Text(notification.receivedAt, style: .relative) .font(.system(size: 11)) .foregroundStyle(.tertiary) .fixedSize() + } + } - Spacer(minLength: 8) - - HoverButton(icon: "xmark", iconColor: .secondary, scale: .medium) { - manager.dismissActive(token: notification.id) - } + private var dismissButton: some View { + HoverButton(icon: "xmark", iconColor: .secondary, scale: .medium) { + manager.dismissActive(token: notification.id) } } @@ -253,8 +259,6 @@ struct NotificationExpandedView: View { .foregroundStyle(.white) .lineLimit(1) - Spacer(minLength: 8) - CodeCopyButton(code: code, diameter: 26, showsLabel: true) } .padding(.top, 4) @@ -270,7 +274,10 @@ struct NotificationExpandedView: View { .font(.system(size: 13)) .focused($replyFocused) .onSubmit(send) - .disabled(isSending || didSend) + .disabled(isSending || didSend || didHandOff) + // A bare TextField takes every point offered, which + // would stretch the notch back out. + .frame(width: 200) } .padding(.horizontal, 12) .padding(.vertical, 7) @@ -287,7 +294,7 @@ struct NotificationExpandedView: View { private var sendButton: some View { ZStack { Circle() - .fill(didSend ? Color.green : (canSend ? Color.effectiveAccent : Color.white.opacity(0.1))) + .fill(fillStyle) .frame(width: 26, height: 26) if isSending { @@ -298,6 +305,12 @@ struct NotificationExpandedView: View { Image(systemName: "checkmark") .font(.system(size: 11, weight: .bold)) .foregroundStyle(.white) + } else if didHandOff { + // Clipboard, not a checkmark: the message wasn't delivered, + // it was copied for the user to paste into the app. + Image(systemName: "doc.on.clipboard") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) } else { Image(systemName: "arrow.up") .font(.system(size: 12, weight: .semibold)) @@ -306,14 +319,22 @@ struct NotificationExpandedView: View { } .animation(.smooth(duration: 0.25), value: isSending) .animation(.smooth(duration: 0.25), value: didSend) + .animation(.smooth(duration: 0.25), value: didHandOff) .contentShape(Circle()) .onTapGesture(perform: send) .disabled(!canSend) .sensoryFeedback(.success, trigger: didSend) } + private var fillStyle: Color { + if didSend { return .green } + if didHandOff { return .orange } + return canSend ? .effectiveAccent : .white.opacity(0.1) + } + private var canSend: Bool { - !replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSending && !didSend + !replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !isSending && !didSend && !didHandOff } private func send() { @@ -321,14 +342,16 @@ struct NotificationExpandedView: View { let text = replyText isSending = true Task { - let sent = await manager.reply(to: notification, text: text) + let outcome = await manager.reply(to: notification, text: text) isSending = false - if sent { - didSend = true - replyText = "" - try? await Task.sleep(for: .milliseconds(900)) - manager.dismissActive(token: notification.id) - } + replyText = "" + // A hand-off is not a delivery — showing the same checkmark for + // both would tell the user their message went out when it's + // actually sitting on the clipboard. + didSend = outcome == .sent + didHandOff = outcome == .handedOffToApp + try? await Task.sleep(for: .milliseconds(1200)) + manager.dismissActive(token: notification.id) } } @@ -338,7 +361,6 @@ struct NotificationExpandedView: View { /// rather than generic rectangular buttons. private var callActionRow: some View { HStack(spacing: 14) { - Spacer() if let decline = notification.actions.first(where: { $0.localizedCaseInsensitiveContains("decline") }) { diff --git a/boringNotch/components/NotificationDebugWindow.swift b/boringNotch/components/NotificationDebugWindow.swift index a397d5681..b03b0c36b 100644 --- a/boringNotch/components/NotificationDebugWindow.swift +++ b/boringNotch/components/NotificationDebugWindow.swift @@ -84,8 +84,12 @@ struct NotificationDebugView: View { let text = replyText replyText = "" Task { - let sent = await manager.reply(to: notification, text: text) - lastResult = sent ? "replied" : "no reply field — opened the app instead" + switch await manager.reply(to: notification, text: text) { + case .sent: + lastResult = "replied via the live banner" + case .handedOffToApp: + lastResult = "banner gone — copied to clipboard and opened the app" + } } } } diff --git a/boringNotch/managers/ContactAvatarManager.swift b/boringNotch/managers/ContactAvatarManager.swift index d14c27091..67fca6fd4 100644 --- a/boringNotch/managers/ContactAvatarManager.swift +++ b/boringNotch/managers/ContactAvatarManager.swift @@ -63,10 +63,12 @@ final class ContactAvatarManager: ObservableObject { let data = contacts[0].thumbnailImageData ?? contacts[0].imageData, let image = NSImage(data: data) else { + NSLog("[boringNotch] avatar for \(name.debugDescription): no contact photo, using monogram") cache[name] = .some(nil) return nil } + NSLog("[boringNotch] avatar for \(name.debugDescription): using contact photo") cache[name] = image return image } diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index 6d8b583b9..ef6aa171c 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -33,8 +33,13 @@ struct SystemNotification: Identifiable, Equatable { /// Any of the three is a decent hint; `SystemNotificationManager.reply` /// is the actual source of truth and falls back to opening the app if no /// field materializes. + /// Whether to offer a reply box at all — based on the app supporting + /// replies, not on whether the banner is still alive. The compose box + /// stays for as long as the notification is in the notch; if the banner + /// has since died, `reply` degrades instead of the UI disappearing out + /// from under a half-typed message. var canReply: Bool { - isLive && actions.contains { + actions.contains { $0.localizedCaseInsensitiveContains("reply") || $0.localizedCaseInsensitiveContains("send") || $0.localizedCaseInsensitiveContains("details") @@ -270,14 +275,33 @@ final class SystemNotificationManager: ObservableObject { // MARK: - Acting - /// Sends an inline reply. Returns false when the banner is gone or the app - /// has no reply action — callers should fall back to `open`. + enum ReplyOutcome { + /// Delivered through the live banner's reply field. + case sent + /// The banner was gone, so the draft went to the clipboard and the + /// app was opened for the user to paste. + case handedOffToApp + } + + /// Sends an inline reply. + /// + /// Replying works by typing into the system banner's own reply field, so + /// it's only possible while that banner is on screen — roughly five + /// seconds. There is no API to send on an app's behalf after that. + /// Rather than dropping a typed message on the floor, hand it off: put + /// the draft on the clipboard and open the app so it's one paste away. @discardableResult - func reply(to notification: SystemNotification, text: String) async -> Bool { - let sent = await XPCHelperClient.shared.replyToNotification(token: notification.id, text: text) - if !sent { await open(notification) } + func reply(to notification: SystemNotification, text: String) async -> ReplyOutcome { + if await XPCHelperClient.shared.replyToNotification(token: notification.id, text: text) { + dismissActive(token: notification.id) + return .sent + } + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + await open(notification) dismissActive(token: notification.id) - return sent + return .handedOffToApp } func perform(_ action: String, on notification: SystemNotification) async -> Bool { From 414cfbd4fc0346823baca6cf44f1a1b15f071eb6 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 11:32:08 +0530 Subject: [PATCH 13/69] Pin close button to top-right, let reply field fill its width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The X sat inline in the header row at the avatar's vertical center rather than the card's actual top-right corner. Moved it to a topTrailing overlay on the whole card so it's pinned regardless of content height. The reply TextField was fixed at 200pt. It's safe to let it flex now: the containing column is already capped at 300pt (from the earlier width-fitting fix), so a flexible field fills up to that cap instead of stretching the notch the way an unbounded TextField would have. Confirmed via logging that the earlier "H for Matashree" avatar question wasn't a bug — it's a real Contacts photo on that card, not the monogram fallback. Header/tabs stay hidden during a notification, per explicit confirmation: bringing them back would force the notch to full width again, undoing the width-fitting work, since BoringHeader's layout needs the full span to make sense. --- .../Notch/NotificationLiveActivity.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index 1518dcb81..1f9758db0 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -130,11 +130,15 @@ struct NotificationExpandedView: View { actionArea } .frame(maxWidth: 300, alignment: .leading) - - dismissButton - .padding(.leading, 4) } .padding(.horizontal, 4) + .padding(.trailing, 20) + // Pinned to the card's actual top-right corner rather than sitting + // inline at the avatar's vertical center, matching where a close + // control belongs on a notification. + .overlay(alignment: .topTrailing) { + dismissButton + } // Rebuild cleanly when one notification replaces another, instead of // reusing the previous one's view state. .id(notification.id) @@ -275,15 +279,18 @@ struct NotificationExpandedView: View { .focused($replyFocused) .onSubmit(send) .disabled(isSending || didSend || didHandOff) - // A bare TextField takes every point offered, which - // would stretch the notch back out. - .frame(width: 200) + // Safe to let this fill available width now: the + // containing column is already capped at 300pt, so this + // only fills up to that cap rather than stretching the + // notch itself the way an unbounded TextField would. + .frame(maxWidth: .infinity) } .padding(.horizontal, 12) .padding(.vertical, 7) .background(.white.opacity(0.08), in: Capsule()) .overlay(Capsule().strokeBorder(.white.opacity(replyFocused ? 0.18 : 0))) .animation(.easeOut(duration: 0.15), value: replyFocused) + .frame(maxWidth: .infinity) sendButton } From 16dbfbb7d665b52c5ac87233893ffa4db16f1fe1 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 11:39:22 +0530 Subject: [PATCH 14/69] Fix notch top-alignment and tighten notification layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported "notch sits a bit off the top of the screen" only happened with a notification open, which pointed at the recent openNotchHeight change rather than window positioning (windowSize is a fixed constant, independent of content height). Confirmed the cause: .frame(height: openNotchHeight) had no alignment, so it defaulted to centering — shrinking from 190pt to 132pt pulled the visible top edge down by roughly half the difference instead of staying flush with the window's top-anchored origin. Added alignment: .top. Also: the close button was a full 30pt HoverButton, reading as a toolbar control rather than a notification's dismiss — replaced with an 18pt compact circle closer to iOS's. And a blanket 20pt trailing padding on the whole card (added only so the header text wouldn't run under the close button) was pushing the reply row's right edge in for no reason, visible as a ~43pt dead gap next to the send button — moved that reserve onto just the header row, which is the only thing it actually needs to clear. --- boringNotch/ContentView.swift | 9 ++++++++- .../Notch/NotificationLiveActivity.swift | 20 +++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index c8ebb7883..aef406d50 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -198,7 +198,14 @@ struct ContentView: View { .opacity((isNotchHeightZero && vm.notchState == .closed) ? 0.01 : 1) mainLayout - .frame(height: vm.notchState == .open ? openNotchHeight : nil) + // alignment: .top matters here — without it this frame + // defaults to centering, and shrinking the height for a + // notification (openNotchHeight < vm.notchSize.height) + // then pulls the visible top edge down by half the + // difference instead of staying flush with the window's + // top-anchored origin. That's what read as "the notch + // sits a bit off the top of the screen." + .frame(height: vm.notchState == .open ? openNotchHeight : nil, alignment: .top) .conditionalModifier(true) { view in return view .animation(vm.notchState == .open ? StandardAnimations.open : StandardAnimations.close, value: vm.notchState) diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index 1f9758db0..f94d3f1d3 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -132,12 +132,12 @@ struct NotificationExpandedView: View { .frame(maxWidth: 300, alignment: .leading) } .padding(.horizontal, 4) - .padding(.trailing, 20) // Pinned to the card's actual top-right corner rather than sitting // inline at the avatar's vertical center, matching where a close // control belongs on a notification. .overlay(alignment: .topTrailing) { dismissButton + .padding(.top, -2) } // Rebuild cleanly when one notification replaces another, instead of // reusing the previous one's view state. @@ -208,12 +208,28 @@ struct NotificationExpandedView: View { .foregroundStyle(.tertiary) .fixedSize() } + // Clears the close button, which overlays the card rather than + // taking a layout slot — reserved here, on the one row it can + // actually collide with, instead of on the whole card (which pushed + // the reply row's right edge in by 20pt of dead space for no + // reason, since nothing else in the card runs that wide). + .padding(.trailing, 22) } + /// Deliberately not HoverButton's default 30pt sizing — that reads as a + /// full toolbar control; a notification's close button wants to be + /// closer to iOS's compact circular dismiss. private var dismissButton: some View { - HoverButton(icon: "xmark", iconColor: .secondary, scale: .medium) { + Button { manager.dismissActive(token: notification.id) + } label: { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 18, height: 18) + .background(.white.opacity(0.1), in: Circle()) } + .buttonStyle(ScaleDownButtonStyle()) } // MARK: - Body text From 7467fcc1f2a1c8359b0b1043778b0b87e82d121b Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 11:43:17 +0530 Subject: [PATCH 15/69] Fix reply field never receiving keystrokes; pause dismiss while typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BoringNotchWindow.canBecomeKey was hardcoded false — deliberate, so a click on the notch never steals focus from the frontmost app, but it also meant no text field in this window could ever receive a real keyDown event. @FocusState/.focused() only sets SwiftUI's internal responder within the view hierarchy; macOS never routes keystrokes to a window that can't become key. Every keystroke while "typing" into the reply field was actually going to whatever app was previously frontmost. Made canBecomeKey conditional on a new wantsKeyForTextInput flag, default false, flipped on only while the reply field is actually focused and back off the instant it isn't (focus-lost, send, or the view disappearing) — never left on, or every other notch interaction (hover-to-open, music controls) regresses to stealing focus. Also: typing now pauses the dismiss timer with no cap, versus the existing capped hold that only engaged on notch-open. A keystroke is the clearest possible "still here" signal, so maxLifetime — which exists to protect against an abandoned open notch — doesn't apply while there's an actual person composing a reply. Reverts to the capped hold the moment the field loses focus. --- .../components/Notch/BoringNotchWindow.swift | 23 +++++++++- .../Notch/NotificationLiveActivity.swift | 46 ++++++++++++++++++- .../managers/SystemNotificationManager.swift | 13 ++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/boringNotch/components/Notch/BoringNotchWindow.swift b/boringNotch/components/Notch/BoringNotchWindow.swift index 7f4ac42ce..886f8f74b 100644 --- a/boringNotch/components/Notch/BoringNotchWindow.swift +++ b/boringNotch/components/Notch/BoringNotchWindow.swift @@ -40,10 +40,29 @@ class BoringNotchWindow: NSPanel { hasShadow = false } + /// False by default so a click on the notch never steals focus from + /// whatever app is frontmost — that's load-bearing for every other + /// interaction (hover-to-open, music controls, OSD). But it also means + /// NO text field in this window can ever receive a keystroke: SwiftUI's + /// @FocusState/.focused() only sets the responder *within* the view + /// hierarchy, and macOS never routes real keyDown events to a window + /// that can't become key. A reply field needs this flipped on for the + /// moment it's actually being typed into, and back off immediately + /// after — never left permanently true, or every other interaction + /// regresses. + var wantsKeyForTextInput = false { + didSet { + guard wantsKeyForTextInput != oldValue else { return } + if wantsKeyForTextInput { + makeKey() + } + } + } + override var canBecomeKey: Bool { - false + wantsKeyForTextInput } - + override var canBecomeMain: Bool { false } diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index f94d3f1d3..7be0049e7 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -113,6 +113,7 @@ struct NotificationExpandedView: View { @State private var didSend = false @State private var didHandOff = false @FocusState private var replyFocused: Bool + @State private var hostWindow: BoringNotchWindow? private var kind: NotificationKind { .init(notification) } @@ -142,6 +143,7 @@ struct NotificationExpandedView: View { // Rebuild cleanly when one notification replaces another, instead of // reusing the previous one's view state. .id(notification.id) + .background(WindowAccessor { self.hostWindow = $0 as? BoringNotchWindow }) // This view exists only while the notch is open, so appear/disappear // is the open/close signal: pause the countdown while the user is // looking at it, resume when they close. holdActive caps itself at @@ -151,7 +153,32 @@ struct NotificationExpandedView: View { manager.holdActive() if kind == .reply { replyFocused = true } } - .onDisappear { manager.resumeDismiss() } + .onDisappear { + manager.resumeDismiss() + // Always hand key status back — if this fired without the + // focus-lost branch below running first (the whole view can + // disappear while still focused, e.g. the notch closing), a + // stuck `true` here would leave the window able to steal focus + // on some later, unrelated click. + hostWindow?.wantsKeyForTextInput = false + } + .onChange(of: replyFocused) { _, focused in + // The window can only accept keystrokes while it's key, and it + // must not stay key a moment longer than the field is actually + // focused — see BoringNotchWindow.wantsKeyForTextInput. + hostWindow?.wantsKeyForTextInput = focused + if focused { + manager.holdWhileTyping() + } else { + manager.holdActive() + } + } + .onChange(of: replyText) { _, _ in + // Stop the timer's clock is exactly what typing should do — a + // keystroke is the clearest possible "still here" signal, so it + // gets an uncapped hold rather than the notch-open cap. + if replyFocused { manager.holdWhileTyping() } + } } // MARK: - Avatar @@ -548,3 +575,20 @@ private struct CodeCopyButton: View { } } } + +/// Reads the NSWindow hosting this SwiftUI view. Needed because +/// BoringNotchWindow can't become key by default (a click on the notch must +/// never steal focus from the frontmost app) — the reply field has to reach +/// through to that window to ask for key status only for the moment it's +/// actually being typed into. +private struct WindowAccessor: NSViewRepresentable { + let onResolve: (NSWindow?) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { onResolve(view.window) } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index ef6aa171c..d59bc38d2 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -217,6 +217,19 @@ final class SystemNotificationManager: ObservableObject { withAnimation(.smooth) { activeNotification = nil } } + /// Keeps the notification up for as long as the reply field is actually + /// being typed into — no cap. A keystroke is the clearest possible + /// signal that this isn't an abandoned notch, so `maxLifetime` (which + /// exists to protect against exactly that) doesn't apply here. Reverting + /// to the capped `holdActive` happens the moment the field loses focus, + /// and the notch closing at all (hover-out) tears the view down + /// regardless, so this can't strand a notification the way an + /// unconditional hold on notch-open did. + func holdWhileTyping() { + dismissTask?.cancel() + dismissTask = nil + } + /// Keeps the notification up while the user is engaging with it — the /// notch is open, or they're typing a reply. Without this the countdown /// keeps running and the notification vanishes mid-read. From 63ca34035726f2c7d7392699d1227492bf6c8450 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 11:48:24 +0530 Subject: [PATCH 16/69] Add Apple Intelligence smart-reply suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Researched via Apple docs/WWDC25-26 material before building: the FoundationModels framework (macOS 26+) gives direct Swift access to the on-device ~3B-parameter model powering Apple Intelligence — no network calls, entirely local. SystemLanguageModel.availability reports three distinct unavailable reasons (deviceNotEligible, appleIntelligenceNotEnabled, modelNotReady), which map directly to honest per-case messaging rather than one vague "unavailable" state. SmartReplyManager wraps LanguageModelSession + @Generable guided generation to draft up to 3 short reply options from a notification's sender/body. Every touchpoint is behind @available(macOS 26.0, *) or #if canImport(FoundationModels) — this project's deployment target is macOS 14, so an unguarded reference wouldn't just lose the feature, it would risk the app failing to *launch* below macOS 26. Verified rather than assumed: built the actual binary and inspected the debug dylib's load commands directly. FoundationModels shows `weak` — Swift's availability annotations alone were enough to weak-link it correctly, no manual framework entry needed in the project file. Off by default (new Settings toggle, Notifications tab) even though it's on-device — suggestions appear as tappable chips above the reply box that fill the draft for review, never auto-send, since an AI-drafted reply going out under someone's name deserves a glance first. --- boringNotch.xcodeproj/project.pbxproj | 4 + .../Notch/NotificationLiveActivity.swift | 39 +++++++ .../Views/NotificationSettingsView.swift | 27 +++++ boringNotch/managers/SmartReplyManager.swift | 100 ++++++++++++++++++ boringNotch/models/Constants.swift | 4 + 5 files changed, 174 insertions(+) create mode 100644 boringNotch/managers/SmartReplyManager.swift diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 2024d5aa0..5f0dc0526 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -99,6 +99,7 @@ 1443E7F32C609DCE0027C1FC /* matters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1443E7F22C609DCE0027C1FC /* matters.swift */; }; 147163982C5D35B70068B555 /* MusicVisualizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147163972C5D35B70068B555 /* MusicVisualizer.swift */; }; 1471639A2C5D35FF0068B555 /* MusicManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147163992C5D35FF0068B555 /* MusicManager.swift */; }; + AA05SRM12E7A0001 /* SmartReplyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA05SRM22E7A0001 /* SmartReplyManager.swift */; }; 1471A8592C6281BD0058408D /* BoringNotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */; }; 149E0B972C737D00006418B1 /* WebcamManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149E0B962C737D00006418B1 /* WebcamManager.swift */; }; 149E0B9A2C737D40006418B1 /* WebcamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149E0B992C737D40006418B1 /* WebcamView.swift */; }; @@ -298,6 +299,7 @@ 1443E7F42C609E650027C1FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 147163972C5D35B70068B555 /* MusicVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicVisualizer.swift; sourceTree = ""; }; 147163992C5D35FF0068B555 /* MusicManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicManager.swift; sourceTree = ""; }; + AA05SRM22E7A0001 /* SmartReplyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartReplyManager.swift; sourceTree = ""; }; 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringNotchWindow.swift; sourceTree = ""; }; 149E0B962C737D00006418B1 /* WebcamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamManager.swift; sourceTree = ""; }; 149E0B992C737D40006418B1 /* WebcamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamView.swift; sourceTree = ""; }; @@ -656,6 +658,7 @@ AA01SNM22E7A0001 /* SystemNotificationManager.swift */, AA02CAM22E7A0001 /* ContactAvatarManager.swift */, 147163992C5D35FF0068B555 /* MusicManager.swift */, + AA05SRM22E7A0001 /* SmartReplyManager.swift */, F1F2A0A200000000000000F2 /* AudioCaptureManager.swift */, 149E0B962C737D00006418B1 /* WebcamManager.swift */, 14C08BB52C8DE42D000F8AA0 /* CalendarManager.swift */, @@ -1129,6 +1132,7 @@ AA02CAM12E7A0001 /* ContactAvatarManager.swift in Sources */, AA03OTP12E7A0001 /* OTPDetector.swift in Sources */, 1471639A2C5D35FF0068B555 /* MusicManager.swift in Sources */, + AA05SRM12E7A0001 /* SmartReplyManager.swift in Sources */, F1F2A0A100000000000000F1 /* AudioCaptureManager.swift in Sources */, B1B112932C6A577E00093D8F /* MouseTracker.swift in Sources */, B1C448962C9712C4001F0858 /* ActionBar.swift in Sources */, diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index 7be0049e7..ecd420b76 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -114,6 +114,7 @@ struct NotificationExpandedView: View { @State private var didHandOff = false @FocusState private var replyFocused: Bool @State private var hostWindow: BoringNotchWindow? + @State private var suggestions: [String] = [] private var kind: NotificationKind { .init(notification) } @@ -314,6 +315,44 @@ struct NotificationExpandedView: View { // MARK: - Reply private var replyRow: some View { + VStack(alignment: .leading, spacing: 6) { + if !suggestions.isEmpty { + suggestionChips + } + replyField + } + .task(id: notification.id) { + guard Defaults[.smartRepliesEnabled], let body = notification.body else { return } + suggestions = await SmartReplyManager.suggestReplies(sender: notification.sender, body: body) + } + } + + /// Tapping a chip fills the field rather than sending immediately — an + /// AI-drafted reply should get a glance before it goes out under your + /// name, not fire on a single tap. + private var suggestionChips: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(suggestions, id: \.self) { suggestion in + Button { + replyText = suggestion + replyFocused = true + } label: { + Text(suggestion) + .font(.system(size: 12)) + .foregroundStyle(.primary) + .lineLimit(1) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.white.opacity(0.1), in: Capsule()) + } + .buttonStyle(ScaleDownButtonStyle()) + } + } + } + } + + private var replyField: some View { HStack(spacing: 8) { HStack(spacing: 6) { TextField("Reply", text: $replyText, axis: .horizontal) diff --git a/boringNotch/components/Settings/Views/NotificationSettingsView.swift b/boringNotch/components/Settings/Views/NotificationSettingsView.swift index b46045065..36e9cbedf 100644 --- a/boringNotch/components/Settings/Views/NotificationSettingsView.swift +++ b/boringNotch/components/Settings/Views/NotificationSettingsView.swift @@ -67,11 +67,38 @@ struct NotificationSettingsView: View { } } .disabled(!notificationLiveActivity) + + Section { + Defaults.Toggle(key: .smartRepliesEnabled) { + Text("Suggest replies with Apple Intelligence") + } + .disabled(!notificationLiveActivity || !smartRepliesAvailable) + } footer: { + Text(smartReplyFooter) + .font(.caption) + .foregroundStyle(.secondary) + } } .formStyle(.grouped) .navigationTitle("Notifications") } + private var smartRepliesAvailable: Bool { + if case .available = SmartReplyManager.availability { return true } + return false + } + + private var smartReplyFooter: String { + // Drafts run entirely on-device via Apple's on-device model — no + // network calls, nothing leaves the Mac. + switch SmartReplyManager.availability { + case .available: + return "Drafts a few short reply options for messages, entirely on-device. Nothing is sent over the network." + case .unavailable(let reason): + return reason + } + } + @ViewBuilder private func appRow(_ app: KnownNotificationApp) -> some View { let isAllowed = allowedApps.contains(app.bundleID) diff --git a/boringNotch/managers/SmartReplyManager.swift b/boringNotch/managers/SmartReplyManager.swift new file mode 100644 index 000000000..7e8e07b28 --- /dev/null +++ b/boringNotch/managers/SmartReplyManager.swift @@ -0,0 +1,100 @@ +// +// SmartReplyManager.swift +// boringNotch +// +// Draft reply suggestions using Apple's on-device Foundation Models — +// entirely local, no network calls, same model that powers Apple +// Intelligence system-wide. +// +// Every touchpoint here is macOS 26+ and Apple-Intelligence-enabled only. +// This project's deployment target is macOS 14, so nothing outside an +// `@available`/`#available` guard may reference FoundationModels — Swift's +// autolinking weak-links the framework based on those annotations, which is +// what lets the app still launch on older macOS versions at all. Skipping a +// guard wouldn't just lose this feature, it would risk the whole app +// failing to launch below macOS 26. +// + +import Foundation + +#if canImport(FoundationModels) +import FoundationModels +#endif + +enum SmartReplyAvailability: Equatable { + case available + case unavailable(reason: String) +} + +enum SmartReplyManager { + static var availability: SmartReplyAvailability { + #if canImport(FoundationModels) + guard #available(macOS 26.0, *) else { + return .unavailable(reason: "Requires macOS 26 or later.") + } + switch SystemLanguageModel.default.availability { + case .available: + return .available + case .unavailable(let reason): + return .unavailable(reason: describeUnavailable(reason)) + } + #else + return .unavailable(reason: "Requires macOS 26 or later.") + #endif + } + + #if canImport(FoundationModels) + @available(macOS 26.0, *) + private static func describeUnavailable(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String { + switch reason { + case .deviceNotEligible: + return "This Mac doesn't support Apple Intelligence." + case .appleIntelligenceNotEnabled: + return "Turn on Apple Intelligence in System Settings → Apple Intelligence & Siri." + case .modelNotReady: + return "The on-device model is still downloading." + @unknown default: + return "Apple Intelligence isn't available right now." + } + } + #endif + + /// Up to three short reply drafts for the given message, or an empty + /// array on any unavailability/failure — callers show nothing rather + /// than an error state for what's an optional nicety, not a feature the + /// UI depends on. + static func suggestReplies(sender: String?, body: String) async -> [String] { + #if canImport(FoundationModels) + guard #available(macOS 26.0, *), + case .available = SystemLanguageModel.default.availability, + !body.isEmpty + else { return [] } + + do { + let session = LanguageModelSession(instructions: """ + You draft extremely short, casual reply suggestions to an \ + incoming message, in the voice of the person replying, not \ + the sender. Match the tone and language of the message. \ + Never invent facts, plans, times, or commitments the message \ + doesn't mention. Each reply must be under 8 words. + """) + let prompt = "From: \(sender ?? "someone")\nMessage: \(body)\n\nSuggest 3 short possible replies." + let result = try await session.respond(to: prompt, generating: ReplySuggestionSet.self) + return Array(result.content.replies.prefix(3)) + } catch { + return [] + } + #else + return [] + #endif + } +} + +#if canImport(FoundationModels) +@available(macOS 26.0, *) +@Generable +struct ReplySuggestionSet: Equatable { + @Guide(description: "2 to 3 short, casual reply suggestions, each under 8 words") + let replies: [String] +} +#endif diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 2d2672ba3..50564ad8a 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -320,6 +320,10 @@ extension Defaults.Keys { "notificationSuppressedApps", default: [] ) + /// Off by default: a new capability, even though it runs entirely + /// on-device with no network calls. Only takes effect on macOS 26+ with + /// Apple Intelligence enabled — see SmartReplyManager. + static let smartRepliesEnabled = Key("smartRepliesEnabled", default: false) static let enableGradient = Key("enableGradient", default: false) static let systemEventIndicatorShadow = Key("systemEventIndicatorShadow", default: false) From c3a7d0019c1a49e5d18c93b39c77b8f7161e25ad Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 12:01:03 +0530 Subject: [PATCH 17/69] Fix reply typing for real: BoringNotchWindow is dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key-window fix from the previous round targeted BoringNotchWindow, but that class is never instantiated anywhere — grepped for it, only hits are the class definition and doc comments. The window actually created for the notch (createBoringNotchWindow) is BoringNotchSkyLightWindow, a separate NSPanel subclass with its own, independent hardcoded `canBecomeKey { false }`. WindowAccessor's `as? BoringNotchWindow` cast against the real, running BoringNotchSkyLightWindow instance silently returned nil every time — sibling classes, not a subclass relationship — so hostWindow was always nil and the entire fix was a no-op. Explains the exact symptom: mouse clicks worked (chips, which don't need a key window) while typing didn't (needs one) and send stayed disabled (correctly — replyText never had anything in it to send). Moved wantsKeyForTextInput to BoringNotchSkyLightWindow, the class that's actually live, and retargeted the cast. --- boringNotch/Localizable.xcstrings | 3 +++ .../Notch/BoringNotchSkyLightWindow.swift | 21 ++++++++++++++++++- .../Notch/NotificationLiveActivity.swift | 8 +++---- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index e31226d64..5fa19131c 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -24411,6 +24411,9 @@ } } } + }, + "Suggest replies with Apple Intelligence" : { + }, "System" : { "localizations" : { diff --git a/boringNotch/components/Notch/BoringNotchSkyLightWindow.swift b/boringNotch/components/Notch/BoringNotchSkyLightWindow.swift index e7a96cdb1..a380967e4 100644 --- a/boringNotch/components/Notch/BoringNotchSkyLightWindow.swift +++ b/boringNotch/components/Notch/BoringNotchSkyLightWindow.swift @@ -146,6 +146,25 @@ class BoringNotchSkyLightWindow: NSPanel { } } - override var canBecomeKey: Bool { false } + /// False by default so a click on the notch never activates the app or + /// steals focus from whatever is frontmost — load-bearing for every + /// normal interaction (hover-to-open, music controls, OSD). A text field + /// needs this flipped on for the moment it's actually being typed into, + /// and back off the instant it isn't — never left permanently true. + /// + /// This is the window class actually instantiated for the notch + /// (createBoringNotchWindow uses BoringNotchSkyLightWindow, not the + /// separate, unused BoringNotchWindow class) — an earlier fix targeted + /// that unused class and silently did nothing. + var wantsKeyForTextInput = false { + didSet { + guard wantsKeyForTextInput != oldValue else { return } + if wantsKeyForTextInput { + makeKey() + } + } + } + + override var canBecomeKey: Bool { wantsKeyForTextInput } override var canBecomeMain: Bool { false } } diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index ecd420b76..1de76bef2 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -113,7 +113,7 @@ struct NotificationExpandedView: View { @State private var didSend = false @State private var didHandOff = false @FocusState private var replyFocused: Bool - @State private var hostWindow: BoringNotchWindow? + @State private var hostWindow: BoringNotchSkyLightWindow? @State private var suggestions: [String] = [] private var kind: NotificationKind { .init(notification) } @@ -144,7 +144,7 @@ struct NotificationExpandedView: View { // Rebuild cleanly when one notification replaces another, instead of // reusing the previous one's view state. .id(notification.id) - .background(WindowAccessor { self.hostWindow = $0 as? BoringNotchWindow }) + .background(WindowAccessor { self.hostWindow = $0 as? BoringNotchSkyLightWindow }) // This view exists only while the notch is open, so appear/disappear // is the open/close signal: pause the countdown while the user is // looking at it, resume when they close. holdActive caps itself at @@ -166,7 +166,7 @@ struct NotificationExpandedView: View { .onChange(of: replyFocused) { _, focused in // The window can only accept keystrokes while it's key, and it // must not stay key a moment longer than the field is actually - // focused — see BoringNotchWindow.wantsKeyForTextInput. + // focused — see BoringNotchSkyLightWindow.wantsKeyForTextInput. hostWindow?.wantsKeyForTextInput = focused if focused { manager.holdWhileTyping() @@ -616,7 +616,7 @@ private struct CodeCopyButton: View { } /// Reads the NSWindow hosting this SwiftUI view. Needed because -/// BoringNotchWindow can't become key by default (a click on the notch must +/// BoringNotchSkyLightWindow can't become key by default (a click on the notch must /// never steal focus from the frontmost app) — the reply field has to reach /// through to that window to ask for key status only for the moment it's /// actually being typed into. From 1a6908226140b8da05f94a0999bc5261c4bd8535 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 14:36:42 +0530 Subject: [PATCH 18/69] Keep notch open while composing; drop broken stack-depth cue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping the reply field closed the notch. Clicking it changes window key status, which rebuilds tracking areas and fires a spurious hover-exit — and hover-exit is a close path. Rather than trying to filter a bogus hover event, hold the notch open for the whole compose session via SharingStateManager, which every close path already honours. Begin/end are balanced through a local flag since those sessions are refcounted, and a leaked one would pin the notch open permanently. Also removed the stack-depth capsule. It was drawn behind the content as a full-width shape, but the closed pill's middle is a black rectangle masking the physical notch cutout — so the capsule was bisected and rendered as two disembodied lines flanking the notch, which is the garbled UI in the report, not a transition artifact. The geometry can't support that cue; swiping remains the way to reach the stack. --- .../components/Notch/LiveActivityStack.swift | 18 ++++-------- .../Notch/NotificationLiveActivity.swift | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/boringNotch/components/Notch/LiveActivityStack.swift b/boringNotch/components/Notch/LiveActivityStack.swift index 905699ac4..559883192 100644 --- a/boringNotch/components/Notch/LiveActivityStack.swift +++ b/boringNotch/components/Notch/LiveActivityStack.swift @@ -51,18 +51,12 @@ struct LiveActivityStackView: View { var body: some View { ZStack { - // The card behind the front one, nudged down and dimmed. Purely a - // depth cue that there's more here — the closed notch has no room - // for a page-dot row. - if items.count > 1 { - Capsule() - .fill(.white.opacity(0.12)) - .frame(height: 3) - .padding(.horizontal, 40) - .offset(y: 9) - .transition(.opacity) - } - + // No "card behind the card" depth cue here. A full-width shape + // drawn behind the content is bisected by the black rectangle + // that masks the physical notch cutout, so it renders as two + // disembodied lines flanking the notch rather than as a stack. + // The closed pill has no room for a page-dot row either, so the + // stack stays discoverable by swiping rather than by ornament. if let item = items[safe: clampedIndex] { content(item) .id(item.id) diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index 1de76bef2..f199c1e11 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -115,6 +115,7 @@ struct NotificationExpandedView: View { @FocusState private var replyFocused: Bool @State private var hostWindow: BoringNotchSkyLightWindow? @State private var suggestions: [String] = [] + @State private var isComposing = false private var kind: NotificationKind { .init(notification) } @@ -162,6 +163,9 @@ struct NotificationExpandedView: View { // stuck `true` here would leave the window able to steal focus // on some later, unrelated click. hostWindow?.wantsKeyForTextInput = false + // Same reasoning for the compose hold: a leaked one would pin + // the notch open permanently, since every close path checks it. + endComposing() } .onChange(of: replyFocused) { _, focused in // The window can only accept keystrokes while it's key, and it @@ -170,8 +174,16 @@ struct NotificationExpandedView: View { hostWindow?.wantsKeyForTextInput = focused if focused { manager.holdWhileTyping() + // Clicking into the field changes window key status, which + // rebuilds tracking areas and fires a spurious hover-exit — + // that's what was closing the notch the instant you tapped + // the input. Every close path already honours + // preventNotchClose, so hold it for the whole compose + // session rather than trying to filter the bogus hover. + beginComposing() } else { manager.holdActive() + endComposing() } } .onChange(of: replyText) { _, _ in @@ -426,6 +438,23 @@ struct NotificationExpandedView: View { && !isSending && !didSend && !didHandOff } + /// SharingStateManager refcounts its sessions, so these must stay + /// balanced — a leaked begin pins the notch open for good, since every + /// close path honours preventNotchClose. The local flag guarantees at + /// most one outstanding session per view regardless of how many times + /// focus flips. + private func beginComposing() { + guard !isComposing else { return } + isComposing = true + SharingStateManager.shared.beginInteraction() + } + + private func endComposing() { + guard isComposing else { return } + isComposing = false + SharingStateManager.shared.endInteraction() + } + private func send() { guard canSend else { return } let text = replyText From f993f94b7c9708c900f5e9e4a31ae6beed7956da Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 14:41:00 +0530 Subject: [PATCH 19/69] Make the send button actually respond to clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping send did nothing while Enter (onSubmit) worked, and the suggestion chips — already real Buttons — worked too. That split pointed at the gesture, not at send(). The send button was a bare shape with .onTapGesture, which needs a clean mouse-down/up pair in a window whose key status isn't changing. Clicking it blurs the text field, which flips key status mid-click and swallowed the tap. Converted it to a real Button, which tracks the press properly across that change, and which is also what the working chips use. Second half of the same race: blurring the field tore down the compose hold and key status on mouse-down, which could close the notch out from under the click before mouse-up landed. That teardown is now deferred ~350ms and cancelled if focus returns, with onDisappear cancelling it outright — an orphaned task would leak a refcounted preventNotchClose hold and pin the notch open permanently. --- .../Notch/NotificationLiveActivity.swift | 102 +++++++++++------- 1 file changed, 64 insertions(+), 38 deletions(-) diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index f199c1e11..3b3714d66 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -116,6 +116,7 @@ struct NotificationExpandedView: View { @State private var hostWindow: BoringNotchSkyLightWindow? @State private var suggestions: [String] = [] @State private var isComposing = false + @State private var endComposeTask: Task? private var kind: NotificationKind { .init(notification) } @@ -157,22 +158,26 @@ struct NotificationExpandedView: View { } .onDisappear { manager.resumeDismiss() + // Tear down immediately here, and cancel any deferred teardown: + // the view is going away, so there's no in-flight click left to + // protect, and letting that task outlive the view would leak a + // refcounted hold that pins the notch open for good. + endComposeTask?.cancel() + endComposeTask = nil // Always hand key status back — if this fired without the - // focus-lost branch below running first (the whole view can - // disappear while still focused, e.g. the notch closing), a - // stuck `true` here would leave the window able to steal focus - // on some later, unrelated click. + // focus-lost branch running first (the view can disappear while + // still focused, e.g. the notch closing), a stuck `true` would + // leave the window able to steal focus on some later click. hostWindow?.wantsKeyForTextInput = false - // Same reasoning for the compose hold: a leaked one would pin - // the notch open permanently, since every close path checks it. endComposing() } .onChange(of: replyFocused) { _, focused in - // The window can only accept keystrokes while it's key, and it - // must not stay key a moment longer than the field is actually - // focused — see BoringNotchSkyLightWindow.wantsKeyForTextInput. - hostWindow?.wantsKeyForTextInput = focused if focused { + // The window can only accept keystrokes while it's key — + // see BoringNotchSkyLightWindow.wantsKeyForTextInput. + endComposeTask?.cancel() + endComposeTask = nil + hostWindow?.wantsKeyForTextInput = true manager.holdWhileTyping() // Clicking into the field changes window key status, which // rebuilds tracking areas and fires a spurious hover-exit — @@ -182,8 +187,20 @@ struct NotificationExpandedView: View { // session rather than trying to filter the bogus hover. beginComposing() } else { - manager.holdActive() - endComposing() + // Deliberately deferred. Clicking Send blurs the field on + // mouse-down; releasing the hold and key status right then + // can close the notch out from under the click before + // mouse-up lands, so the press never completes. Give an + // in-flight click time to finish, and cancel if focus comes + // straight back. + endComposeTask?.cancel() + endComposeTask = Task { + try? await Task.sleep(for: .milliseconds(350)) + guard !Task.isCancelled else { return } + hostWindow?.wantsKeyForTextInput = false + manager.holdActive() + endComposing() + } } } .onChange(of: replyText) { _, _ in @@ -392,38 +409,47 @@ struct NotificationExpandedView: View { } @ViewBuilder + /// A real Button, not a shape with .onTapGesture. In a non-activating + /// panel a bare tap gesture needs a clean mouse-down/up pair in a window + /// whose key status isn't changing — but clicking here blurs the text + /// field, which flips key status mid-click and ate the tap. Enter + /// (onSubmit) worked the whole time, and the suggestion chips (already + /// Buttons) worked, which is what pointed at the gesture rather than at + /// send() itself. Buttons track the press properly across that change. private var sendButton: some View { - ZStack { - Circle() - .fill(fillStyle) - .frame(width: 26, height: 26) - - if isSending { - ProgressView() - .controlSize(.small) - .tint(.white) - } else if didSend { - Image(systemName: "checkmark") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.white) - } else if didHandOff { - // Clipboard, not a checkmark: the message wasn't delivered, - // it was copied for the user to paste into the app. - Image(systemName: "doc.on.clipboard") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(.white) - } else { - Image(systemName: "arrow.up") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(canSend ? .white : .secondary) + Button(action: send) { + ZStack { + Circle() + .fill(fillStyle) + .frame(width: 26, height: 26) + + if isSending { + ProgressView() + .controlSize(.small) + .tint(.white) + } else if didSend { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + } else if didHandOff { + // Clipboard, not a checkmark: the message wasn't + // delivered, it was copied for the user to paste. + Image(systemName: "doc.on.clipboard") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) + } else { + Image(systemName: "arrow.up") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(canSend ? .white : .secondary) + } } + .contentShape(Circle()) } + .buttonStyle(ScaleDownButtonStyle()) + .disabled(!canSend) .animation(.smooth(duration: 0.25), value: isSending) .animation(.smooth(duration: 0.25), value: didSend) .animation(.smooth(duration: 0.25), value: didHandOff) - .contentShape(Circle()) - .onTapGesture(perform: send) - .disabled(!canSend) .sensoryFeedback(.success, trigger: didSend) } From a7d713ad41269041545c917e236693aa3bcb6c40 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 14:43:21 +0530 Subject: [PATCH 20/69] Fix duplicate reply suggestions colliding as ForEach ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-device model sometimes returns the same suggestion twice ("Got it!"), which SwiftUI flagged as a duplicate id under ForEach(id: \.self) — undefined rendering. Fixed at both levels: SmartReplyManager dedupes case-insensitively (keeping first-seen order, trimming blanks), since a repeated chip is useless to show regardless; and the view keys by position instead of by string value, so the UI doesn't depend on model output being distinct. --- .../components/Notch/NotificationLiveActivity.swift | 6 +++++- boringNotch/managers/SmartReplyManager.swift | 11 ++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index 3b3714d66..e3463f9dd 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -362,7 +362,11 @@ struct NotificationExpandedView: View { private var suggestionChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { - ForEach(suggestions, id: \.self) { suggestion in + // Keyed by position, not by the string itself: two identical + // suggestions would collide as ForEach ids and render + // undefined. SmartReplyManager already dedupes, but the + // view shouldn't depend on model output being distinct. + ForEach(Array(suggestions.enumerated()), id: \.offset) { _, suggestion in Button { replyText = suggestion replyFocused = true diff --git a/boringNotch/managers/SmartReplyManager.swift b/boringNotch/managers/SmartReplyManager.swift index 7e8e07b28..24d45fa7b 100644 --- a/boringNotch/managers/SmartReplyManager.swift +++ b/boringNotch/managers/SmartReplyManager.swift @@ -80,7 +80,16 @@ enum SmartReplyManager { """) let prompt = "From: \(sender ?? "someone")\nMessage: \(body)\n\nSuggest 3 short possible replies." let result = try await session.respond(to: prompt, generating: ReplySuggestionSet.self) - return Array(result.content.replies.prefix(3)) + + // The model repeats itself sometimes ("Got it!" twice), which is + // both useless as a second chip and a duplicate SwiftUI ForEach + // id. Dedupe case-insensitively, keeping first-seen order. + var seen = Set() + return result.content.replies + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted } + .prefix(3) + .map { $0 } } catch { return [] } From 1fbd47c0c35e53af4e8851051ddfbb7e71314bc0 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 14:53:51 +0530 Subject: [PATCH 21/69] Stop releasing key status on every focus flip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My deferred-teardown fix broke typing. Focus is far noisier than "user is done": the suggestion chips arriving restructure the view above the text field and drop focus, and clicking Send blurs on mouse-down. Each of those scheduled a teardown that resigned the window's key status ~350ms later — mid-typing. Grant key status on first focus and release it only in onDisappear, which is the one unambiguous done signal. Same for the compose hold. That removes the timing window entirely rather than tuning the delay, and drops the cancellable-task bookkeeping it needed. Also apply a pending key grant when WindowAccessor resolves the window: it reports asynchronously, so onAppear's auto-focus could run while hostWindow was still nil and silently no-op. --- .../Notch/NotificationLiveActivity.swift | 74 +++++++++---------- 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index e3463f9dd..eefbe43fe 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -116,7 +116,6 @@ struct NotificationExpandedView: View { @State private var hostWindow: BoringNotchSkyLightWindow? @State private var suggestions: [String] = [] @State private var isComposing = false - @State private var endComposeTask: Task? private var kind: NotificationKind { .init(notification) } @@ -156,52 +155,47 @@ struct NotificationExpandedView: View { manager.holdActive() if kind == .reply { replyFocused = true } } + // The single teardown point for both the key-window grant and the + // compose hold. Focus changes are too noisy to release on (see + // onChange above), so everything is unwound here — and it must be + // unconditional: a stuck grant would let the notch steal focus on + // some later unrelated click, and a leaked compose hold would pin + // the notch open permanently, since every close path checks it. .onDisappear { manager.resumeDismiss() - // Tear down immediately here, and cancel any deferred teardown: - // the view is going away, so there's no in-flight click left to - // protect, and letting that task outlive the view would leak a - // refcounted hold that pins the notch open for good. - endComposeTask?.cancel() - endComposeTask = nil - // Always hand key status back — if this fired without the - // focus-lost branch running first (the view can disappear while - // still focused, e.g. the notch closing), a stuck `true` would - // leave the window able to steal focus on some later click. hostWindow?.wantsKeyForTextInput = false endComposing() } + // Apply a pending key request once the window resolves — + // WindowAccessor reports asynchronously, so onAppear's auto-focus + // can run while hostWindow is still nil. + .onChange(of: hostWindow) { _, window in + if replyFocused { window?.wantsKeyForTextInput = true } + } .onChange(of: replyFocused) { _, focused in - if focused { - // The window can only accept keystrokes while it's key — - // see BoringNotchSkyLightWindow.wantsKeyForTextInput. - endComposeTask?.cancel() - endComposeTask = nil - hostWindow?.wantsKeyForTextInput = true - manager.holdWhileTyping() - // Clicking into the field changes window key status, which - // rebuilds tracking areas and fires a spurious hover-exit — - // that's what was closing the notch the instant you tapped - // the input. Every close path already honours - // preventNotchClose, so hold it for the whole compose - // session rather than trying to filter the bogus hover. - beginComposing() - } else { - // Deliberately deferred. Clicking Send blurs the field on - // mouse-down; releasing the hold and key status right then - // can close the notch out from under the click before - // mouse-up lands, so the press never completes. Give an - // in-flight click time to finish, and cancel if focus comes - // straight back. - endComposeTask?.cancel() - endComposeTask = Task { - try? await Task.sleep(for: .milliseconds(350)) - guard !Task.isCancelled else { return } - hostWindow?.wantsKeyForTextInput = false - manager.holdActive() - endComposing() - } + guard focused else { + // Deliberately does NOT release key status or the compose + // hold. Focus flips constantly for reasons that have + // nothing to do with the user being done: the suggestion + // chips arriving restructure the view above the field and + // drop focus, and clicking Send blurs on mouse-down. + // Releasing on each of those resigned key status + // mid-typing and closed the notch out from under clicks. + // Both are released in onDisappear instead, which is the + // only unambiguous "done" signal. + manager.holdActive() + return } + // The window can only accept keystrokes while it's key — see + // BoringNotchSkyLightWindow.wantsKeyForTextInput. + hostWindow?.wantsKeyForTextInput = true + manager.holdWhileTyping() + // Clicking into the field changes window key status, which + // rebuilds tracking areas and fires a spurious hover-exit — + // that's what closed the notch the instant you tapped the + // input. Every close path already honours preventNotchClose, + // so hold it for the whole compose session. + beginComposing() } .onChange(of: replyText) { _, _ in // Stop the timer's clock is exactly what typing should do — a From f8a3cf18b05fbf63ba8191c6d2c1119143f90891 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 14:56:54 +0530 Subject: [PATCH 22/69] Hold notifications until the notch closes, not on a timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the notch is open (or hovered), the notification now stays indefinitely — it goes when the notch closes or a newer notification replaces it. This drops the 30s maxLifetime cap I'd added earlier to stop an abandoned open notch pinning a stale message. That cap is redundant: closing the notch already clears the notification, so the notch closing is what bounds the hold, and a stale one can't survive to be seen later regardless of how long it was held open. The 8s countdown still applies to the closed, unhovered pill — otherwise a notification would occupy that slot forever and music would never come back. --- .../managers/SystemNotificationManager.swift | 64 +++++++------------ 1 file changed, 24 insertions(+), 40 deletions(-) diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index d59bc38d2..ae2cd1231 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -75,13 +75,11 @@ final class SystemNotificationManager: ObservableObject { /// The notification the notch is currently showing, if any. @Published var activeNotification: SystemNotification? - /// How long the notch keeps showing a notification after it arrives. + /// How long the closed notch shows a notification before handing the + /// pill back to whatever was there before (usually music). Only applies + /// while the notch is closed and unhovered — hovering or opening it + /// holds the notification indefinitely. private let activeDuration: TimeInterval = 8 - - /// Ceiling on how long a notification can occupy the notch even while - /// held open. Past this it's stale — the source banner is long gone, so - /// replying is impossible anyway. - private let maxLifetime: TimeInterval = 30 private var dismissTask: Task? /// Expired banners are kept around briefly so the notch can still show the @@ -217,51 +215,37 @@ final class SystemNotificationManager: ObservableObject { withAnimation(.smooth) { activeNotification = nil } } - /// Keeps the notification up for as long as the reply field is actually - /// being typed into — no cap. A keystroke is the clearest possible - /// signal that this isn't an abandoned notch, so `maxLifetime` (which - /// exists to protect against exactly that) doesn't apply here. Reverting - /// to the capped `holdActive` happens the moment the field loses focus, - /// and the notch closing at all (hover-out) tears the view down - /// regardless, so this can't strand a notification the way an - /// unconditional hold on notch-open did. + /// Keeps the notification up for as long as the reply field is being + /// typed into. Same behaviour as `holdActive` now — kept as a separate + /// name because the call sites mean different things. func holdWhileTyping() { - dismissTask?.cancel() - dismissTask = nil + holdActive() } - /// Keeps the notification up while the user is engaging with it — the - /// notch is open, or they're typing a reply. Without this the countdown - /// keeps running and the notification vanishes mid-read. + /// Holds the notification indefinitely while the notch is open. No time + /// cap: it stays until the notch closes or a newer notification + /// replaces it. /// - /// Bounded by `maxLifetime` rather than cancelled outright: an - /// indefinite hold meant an opened notch pinned its notification - /// forever, so hovering the notch much later still showed a long-dead - /// message instead of the normal content. + /// This previously capped at `maxLifetime` to stop an abandoned open + /// notch pinning a stale message. That cap is unnecessary now that + /// closing the notch clears the notification outright (see + /// `resumeDismiss`) — the notch closing is what bounds it, so a stale + /// one can't survive to be seen later regardless of how long it was + /// held. func holdActive() { dismissTask?.cancel() dismissTask = nil - - guard let active = activeNotification else { return } - let remaining = maxLifetime - Date().timeIntervalSince(active.receivedAt) - guard remaining > 0 else { - dismissActive(token: active.id) - return - } - dismissTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(remaining)) - guard !Task.isCancelled else { return } - await MainActor.run { self?.dismissActive(token: active.id) } - } } /// Restarts the dismiss countdown once the user stops interacting — - /// without this a held notification would stay in the notch forever. + /// Clears the notification shortly after the notch closes. This is what + /// bounds the otherwise-indefinite `holdActive`, so it must always + /// schedule — a bail-if-busy check here would let an indefinite hold + /// survive the notch closing and strand the notification. /// - /// Replaces whatever task is pending rather than bailing when one - /// exists: holdActive always leaves its maxLifetime cap scheduled, so a - /// bail-if-busy check here would leave the notification sitting for the - /// full cap after the notch closed instead of the short countdown. + /// The short delay exists so briefly clipping the notch edge on the way + /// past doesn't destroy a notification the user was mid-way through + /// reading. func resumeDismiss(after delay: TimeInterval = 3) { guard let active = activeNotification else { return } dismissTask?.cancel() From bef3222799674766194782f069fc43abdbb28c32 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 15:06:41 +0530 Subject: [PATCH 23/69] Add send/hand-off sounds; document why late replies can't send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replying types into the notification's AX reply field, so it only works while that element exists. Measured what actually happens rather than assuming: - live banner on screen ....... element valid, "Reply" action present - banner faded, NC closed ..... notificationcenterui has ZERO windows - banner faded, NC panel open .. items reachable, but actions are only [AXPress, Show Details, Close] — the reply action does not survive the banner I first tried retaining the AXUIElement past the banner on the theory that the notification lives on in Notification Center. It doesn't help: reading AXRole from a retained reference returns nil and CopyActionNames returns empty — the element is destroyed, not detached. Reverted, and recorded the measurement in the code so it isn't retried. So a reply typed after the banner fades genuinely cannot be delivered; there is no API to send on an app's behalf. The hand-off (draft to clipboard, open the app) stays as the honest fallback. Sounds: Tink on a real send, Pop on hand-off. Deliberately different — a "sent" sound when nothing was sent is a lie the user only discovers when the reply never arrives. The orange clipboard glyph carries the meaning; the sound is just click feedback. --- .../NotificationWatcher.swift | 18 ++++++++--- .../managers/SystemNotificationManager.swift | 30 +++++++++++++++---- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index 46095c727..a44f51b4b 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -115,6 +115,16 @@ final class NotificationWatcher { } } + /// Measured, not assumed: once a banner leaves the screen its + /// AXUIElement is destroyed outright — reading AXRole from a retained + /// reference returns nil and AXUIElementCopyActionNames returns empty. + /// Holding onto elements past their banner therefore buys nothing, so + /// this is just `live`. Replying is only possible while the banner is + /// on screen; there is no API to send on an app's behalf afterwards. + private func element(for token: String) -> AXUIElement? { + live[token] + } + /// Match on subrole rather than a fixed containment path — the wrapping /// groups change between macOS releases, the subrole has not. private func banners(in element: AXUIElement, depth: Int = 0) -> [AXUIElement] { @@ -263,7 +273,7 @@ final class NotificationWatcher { /// field, which is untested and unreliable across text-area /// implementations. func reply(token: String, text: String) -> Bool { - guard let banner = live[token] else { return false } + guard let banner = element(for: token) else { return false } if replyField(in: banner) == nil { if let action = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("reply") }) @@ -298,7 +308,7 @@ final class NotificationWatcher { /// Performs a named AX action on the banner, or presses the button with /// that title (Accept / Decline on call notifications). func performAction(token: String, name: String) -> Bool { - guard let banner = live[token] else { return false } + guard let banner = element(for: token) else { return false } if let raw = rawAction(on: banner, matching: { $0 == name }) { return AXUIElementPerformAction(banner, raw as CFString) == .success } @@ -310,14 +320,14 @@ final class NotificationWatcher { /// Opens the notification in its source app (the banner's default action). func open(token: String) -> Bool { - guard let banner = live[token] else { return false } + guard let banner = element(for: token) else { return false } return AXUIElementPerformAction(banner, kAXPressAction as CFString) == .success } /// Clears the banner from screen. Notification Center exposes this as a /// "Close" action rather than the AXRemove one might expect. func dismiss(token: String) -> Bool { - guard let banner = live[token], + guard let banner = element(for: token), let raw = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("close") }) else { return false } return AXUIElementPerformAction(banner, raw as CFString) == .success diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index ae2cd1231..00284c50a 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -282,25 +282,45 @@ final class SystemNotificationManager: ObservableObject { /// Sends an inline reply. /// - /// Replying works by typing into the system banner's own reply field, so - /// it's only possible while that banner is on screen — roughly five - /// seconds. There is no API to send on an app's behalf after that. - /// Rather than dropping a typed message on the floor, hand it off: put - /// the draft on the clipboard and open the app so it's one paste away. + /// Replying types into the notification's own reply field via + /// Accessibility, so it depends on that element still being reachable. + /// The helper retains elements after their banner fades (the + /// notification lives on in Notification Center), which is what makes + /// replying work beyond the ~5s banner. If it genuinely can't be + /// reached, hand off rather than dropping a typed message: the draft + /// goes to the clipboard and the app opens so it's one paste away. @discardableResult func reply(to notification: SystemNotification, text: String) async -> ReplyOutcome { if await XPCHelperClient.shared.replyToNotification(token: notification.id, text: text) { + NSLog("[boringNotch] reply sent via AX for \(notification.appName ?? "-")") + playSentSound() dismissActive(token: notification.id) return .sent } + NSLog("[boringNotch] reply could not be delivered (banner gone) — handing off to \(notification.appName ?? "app")") NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) + playHandOffSound() await open(notification) dismissActive(token: notification.id) return .handedOffToApp } + /// Only on a real send. A "sent" sound when nothing was sent is a lie + /// the user can't see through — they'd find out when the reply never + /// arrived. + private func playSentSound() { + NSSound(named: "Tink")?.play() + } + + /// Distinct from the sent sound on purpose: there's still feedback that + /// the click registered, but it must not read as a delivery. The orange + /// clipboard glyph on the button carries the actual meaning. + private func playHandOffSound() { + NSSound(named: "Pop")?.play() + } + func perform(_ action: String, on notification: SystemNotification) async -> Bool { await XPCHelperClient.shared.performNotificationAction(token: notification.id, name: action) } From 929ec7c7184330440741451cfcbc3f825bc1d5ff Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 15:16:42 +0530 Subject: [PATCH 24/69] Send iMessage replies for real via Messages scripting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replying through the notification's AX field only works while the banner is on screen — measured: once it fades the element is destroyed (AXRole nil, no actions), Notification Center entries expose only AXPress/Show Details/Close, and with NC closed the process has no windows at all. So a reply typed after ~5s could never be delivered. Messages is the one supported app with a real scripting dictionary (`send to `), so iMessage replies now go out properly at any time, independent of the notification. Order is: AX reply (while the banner lives) -> Messages scripting -> clipboard hand-off. Two things found by testing rather than assuming, both of which would have shipped broken: - `name of chat` returns `missing value` for every chat in a real Messages library, so the obvious chat-name match can never succeed. Participants do carry the display name the notification shows, so the lookup uses those. Dry-ran the exact matching logic against a real contact before wiring it up. - Message text goes into an AppleScript string literal, so quotes and backslashes are escaped — an apostrophe or quote in a reply would otherwise break the script. Also added NSAppleEventsUsageDescription to the helper's Info.plist: the helper is the process sending the Apple event, and without a usage string there macOS kills it instead of showing the Automation prompt. Checked the suggested Atoll repo — it's a fork of boring.notch with no notification mirroring at all, so nothing to borrow for capture. It did prompt simplifying this to NSAppleScript.executeAndReturnError instead of hand-built subroutine event descriptors. --- .../BoringNotchXPCHelper.swift | 9 ++ .../BoringNotchXPCHelperProtocol.swift | 1 + BoringNotchXPCHelper/Info.plist | 6 ++ BoringNotchXPCHelper/MessagesSender.swift | 85 +++++++++++++++++++ boringNotch.xcodeproj/project.pbxproj | 8 +- .../BoringNotchXPCHelperProtocol.swift | 1 + .../XPCHelperClient/XPCHelperClient.swift | 13 +++ .../managers/SystemNotificationManager.swift | 14 +++ 8 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 BoringNotchXPCHelper/MessagesSender.swift diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift index 4e003c3fb..a1a5cc866 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift @@ -132,6 +132,15 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { DispatchQueue.main.async { reply(Self.watcher.reply(token: token, text: text)) } } + /// Sends an iMessage directly through the Messages scripting + /// dictionary, bypassing the notification entirely. The app falls back + /// to this when the AX reply above fails because the banner has faded — + /// for Messages that recovers a real send instead of a clipboard + /// hand-off. No other supported app offers an equivalent. + @objc func sendIMessage(_ text: String, toChatNamed name: String, with reply: @escaping (Bool) -> Void) { + DispatchQueue.main.async { reply(MessagesSender.send(text, toChatNamed: name)) } + } + @objc func performNotificationAction(_ token: String, name: String, with reply: @escaping (Bool) -> Void) { DispatchQueue.main.async { reply(Self.watcher.performAction(token: token, name: name)) } } diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift b/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift index aa0c3f40b..e12f0b38e 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift @@ -63,6 +63,7 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { func startNotificationWatching(with reply: @escaping (Bool) -> Void) func stopNotificationWatching() func replyToNotification(_ token: String, text: String, with reply: @escaping (Bool) -> Void) + func sendIMessage(_ text: String, toChatNamed name: String, with reply: @escaping (Bool) -> Void) func performNotificationAction(_ token: String, name: String, with reply: @escaping (Bool) -> Void) func openNotification(_ token: String, with reply: @escaping (Bool) -> Void) func dismissNotification(_ token: String, with reply: @escaping (Bool) -> Void) diff --git a/BoringNotchXPCHelper/Info.plist b/BoringNotchXPCHelper/Info.plist index c123a5d13..d703d6cb1 100644 --- a/BoringNotchXPCHelper/Info.plist +++ b/BoringNotchXPCHelper/Info.plist @@ -2,6 +2,12 @@ + + NSAppleEventsUsageDescription + boring.notch sends your replies to iMessage conversations from the notch. XPCService ServiceType diff --git a/BoringNotchXPCHelper/MessagesSender.swift b/BoringNotchXPCHelper/MessagesSender.swift new file mode 100644 index 000000000..31c223d87 --- /dev/null +++ b/BoringNotchXPCHelper/MessagesSender.swift @@ -0,0 +1,85 @@ +// +// MessagesSender.swift +// BoringNotchXPCHelper +// +// Sends iMessage replies through the Messages scripting dictionary. +// +// Why this exists: replying to a notification normally means typing into +// the system banner's own AX reply field, which stops working the moment +// that banner fades (~5s) — the element is destroyed, measured, not +// assumed. Messages is the one supported app with a real scripting +// dictionary (`send to `), so an iMessage reply +// can be delivered properly at any time, with no dependency on the +// notification still existing. +// +// There is no equivalent for WhatsApp/Telegram/Discord — none ship a +// scripting dictionary, and driving their UI with simulated clicks is far +// too brittle to put behind a send button. Those keep the clipboard +// hand-off. +// +// Lives in the helper because the app is sandboxed; sending Apple events +// to another app needs the unsandboxed context the helper already has. +// + +import Foundation +import AppKit + +enum MessagesSender { + /// Matches the notification's sender against a Messages chat (then a + /// participant) and sends. Returns false if the chat can't be resolved + /// or automation permission was denied, so the caller can fall back to + /// the clipboard hand-off. + static func send(_ text: String, toChatNamed name: String) -> Bool { + // Participants only — deliberately not chats. Verified against a + // real Messages library: `name of chat` returns `missing value` for + // every chat, so matching on it can never succeed. Participants do + // carry the display name the notification shows ("Harsh Vardhan + // Goswami"), which is the only thing a notification gives us. + // + // The first match wins. Duplicate participant entries for the same + // person are normal (one per handle/service — e:me@…, +9198…), and + // they all reach the same human, so picking the first is fine. + let script = """ + tell application "Messages" + repeat with p in participants + try + if (name of p as string) is equal to "\(escape(name))" then + send "\(escape(text))" to p + return "ok" + end if + end try + end repeat + end tell + return "notfound" + """ + + guard let scriptObject = NSAppleScript(source: script) else { return false } + + var error: NSDictionary? + let output = scriptObject.executeAndReturnError(&error) + + if let error { + // -1743 is "not authorized to send Apple events" — the user + // declined the Automation prompt, which is a legitimate choice, + // not a bug. Everything else is worth seeing in the log. + NSLog("[boringNotch] Messages send failed: \(error)") + return false + } + + let ok = output.stringValue == "ok" + if !ok { + NSLog("[boringNotch] Messages: no chat or participant named \(name.debugDescription)") + } + return ok + } + + /// Message text is arbitrary user input going into an AppleScript + /// string literal, so backslashes and quotes have to be neutralised — + /// otherwise a quote in a reply breaks the script (or worse, changes + /// what it does). Backslash first, or it would re-escape the escapes. + private static func escape(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } +} diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 5f0dc0526..bfe90a097 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -99,7 +99,6 @@ 1443E7F32C609DCE0027C1FC /* matters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1443E7F22C609DCE0027C1FC /* matters.swift */; }; 147163982C5D35B70068B555 /* MusicVisualizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147163972C5D35B70068B555 /* MusicVisualizer.swift */; }; 1471639A2C5D35FF0068B555 /* MusicManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147163992C5D35FF0068B555 /* MusicManager.swift */; }; - AA05SRM12E7A0001 /* SmartReplyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA05SRM22E7A0001 /* SmartReplyManager.swift */; }; 1471A8592C6281BD0058408D /* BoringNotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */; }; 149E0B972C737D00006418B1 /* WebcamManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149E0B962C737D00006418B1 /* WebcamManager.swift */; }; 149E0B9A2C737D40006418B1 /* WebcamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149E0B992C737D40006418B1 /* WebcamView.swift */; }; @@ -144,6 +143,8 @@ AA02CAM12E7A0001 /* ContactAvatarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA02CAM22E7A0001 /* ContactAvatarManager.swift */; }; AA02NSV12E7A0001 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA02NSV22E7A0001 /* NotificationSettingsView.swift */; }; AA03OTP12E7A0001 /* OTPDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA03OTP22E7A0001 /* OTPDetector.swift */; }; + AA04LAS12E7A0001 /* LiveActivityStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA04LAS22E7A0001 /* LiveActivityStack.swift */; }; + AA05SRM12E7A0001 /* SmartReplyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA05SRM22E7A0001 /* SmartReplyManager.swift */; }; B10348D92C74E56000475897 /* ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10348D82C74E56000475897 /* ConditionalModifier.swift */; }; B10F84A32C6C9596009F3026 /* TestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10F84A22C6C9596009F3026 /* TestView.swift */; }; B141C2412CA5F53F00AC8CC8 /* SparkleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B141C2402CA5F53E00AC8CC8 /* SparkleView.swift */; }; @@ -299,7 +300,6 @@ 1443E7F42C609E650027C1FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 147163972C5D35B70068B555 /* MusicVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicVisualizer.swift; sourceTree = ""; }; 147163992C5D35FF0068B555 /* MusicManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicManager.swift; sourceTree = ""; }; - AA05SRM22E7A0001 /* SmartReplyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartReplyManager.swift; sourceTree = ""; }; 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringNotchWindow.swift; sourceTree = ""; }; 149E0B962C737D00006418B1 /* WebcamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamManager.swift; sourceTree = ""; }; 149E0B992C737D40006418B1 /* WebcamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamView.swift; sourceTree = ""; }; @@ -341,12 +341,12 @@ A5167212301F85B40018095A /* boringNotchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = boringNotchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; AA01NDW22E7A0001 /* NotificationDebugWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugWindow.swift; sourceTree = ""; }; AA01NLA22E7A0001 /* NotificationLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationLiveActivity.swift; sourceTree = ""; }; - AA04LAS22E7A0001 /* LiveActivityStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityStack.swift; sourceTree = ""; }; - AA04LAS12E7A0001 /* LiveActivityStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA04LAS22E7A0001 /* LiveActivityStack.swift */; }; AA01SNM22E7A0001 /* SystemNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemNotificationManager.swift; sourceTree = ""; }; AA02CAM22E7A0001 /* ContactAvatarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactAvatarManager.swift; sourceTree = ""; }; AA02NSV22E7A0001 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; AA03OTP22E7A0001 /* OTPDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPDetector.swift; sourceTree = ""; }; + AA04LAS22E7A0001 /* LiveActivityStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityStack.swift; sourceTree = ""; }; + AA05SRM22E7A0001 /* SmartReplyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartReplyManager.swift; sourceTree = ""; }; AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputRouteResolver.swift; sourceTree = ""; }; B10348D82C74E56000475897 /* ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalModifier.swift; sourceTree = ""; }; B10F84A22C6C9596009F3026 /* TestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestView.swift; sourceTree = ""; }; diff --git a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift b/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift index 5b10079af..9d16bd168 100644 --- a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift +++ b/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift @@ -63,6 +63,7 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { func startNotificationWatching(with reply: @escaping (Bool) -> Void) func stopNotificationWatching() func replyToNotification(_ token: String, text: String, with reply: @escaping (Bool) -> Void) + func sendIMessage(_ text: String, toChatNamed name: String, with reply: @escaping (Bool) -> Void) func performNotificationAction(_ token: String, name: String, with reply: @escaping (Bool) -> Void) func openNotification(_ token: String, with reply: @escaping (Bool) -> Void) func dismissNotification(_ token: String, with reply: @escaping (Bool) -> Void) diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index c48c6a4a9..044346ce1 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -465,6 +465,19 @@ extension XPCHelperClient { } } + nonisolated func sendIMessage(_ text: String, toChatNamed name: String) async -> Bool { + do { + let service = await MainActor.run { ensureRemoteService() } + return try await service.withContinuation { service, continuation in + service.sendIMessage(text, toChatNamed: name) { sent in + continuation.resume(returning: sent) + } + } + } catch { + return false + } + } + nonisolated func dismissNotification(token: String) async -> Bool { do { let service = await MainActor.run { ensureRemoteService() } diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index 00284c50a..d19816234 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -298,6 +298,20 @@ final class SystemNotificationManager: ObservableObject { return .sent } + // The banner is gone, so AX can't deliver. Messages is the one + // supported app that can still be sent to properly — it has a real + // scripting dictionary, so the reply goes out for real instead of + // becoming a clipboard hand-off. Nothing equivalent exists for + // WhatsApp/Telegram/Discord. + if notification.bundleID == "com.apple.MobileSMS", + let chatName = notification.sender, + await XPCHelperClient.shared.sendIMessage(text, toChatNamed: chatName) { + NSLog("[boringNotch] reply sent via Messages scripting for \(chatName)") + playSentSound() + dismissActive(token: notification.id) + return .sent + } + NSLog("[boringNotch] reply could not be delivered (banner gone) — handing off to \(notification.appName ?? "app")") NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) From e5c46bbf0f161f5256c20267a4124bbef80251f7 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 15:24:17 +0530 Subject: [PATCH 25/69] Hold system banners open so replies keep working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured that an untouched banner dies in ~1.25s, destroying the AX element that replying depends on — which is why a reply typed in the notch could never be delivered. Two further measurements changed what's possible: - Performing the details toggle resets the dismissal timer. Re-doing it on an interval held a banner alive for a full 30s test with its reply field intact. - The banner window's AXPosition is writable: set to (-5000,-5000) it actually moves and stays there. So the watcher now holds a banner alive for as long as the notch is showing that notification, and for apps set to "hide system banner" moves it off-screen first. Hiding and replying are no longer mutually exclusive: previously suppression closed the banner outright, which hid it but destroyed the reply field with it. Held banners are released when the notch stops showing the notification, so the keep-alive can't pin one indefinitely. Leaving a window moved is recoverable regardless: with no banners showing, notificationcenterui has zero windows — the window is per-session and destroyed after — so a fresh one always spawns at its normal position. --- .../BoringNotchXPCHelper.swift | 8 +++ .../BoringNotchXPCHelperProtocol.swift | 2 + .../NotificationWatcher.swift | 67 +++++++++++++++++++ .../BoringNotchXPCHelperProtocol.swift | 2 + .../XPCHelperClient/XPCHelperClient.swift | 16 +++++ .../managers/SystemNotificationManager.swift | 31 ++++++--- 6 files changed, 115 insertions(+), 11 deletions(-) diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift index a1a5cc866..1a721f955 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift @@ -157,6 +157,14 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { DispatchQueue.main.async { reply(Self.watcher.dismiss(token: token)) } } + @objc func holdNotification(_ token: String, offScreen: Bool) { + DispatchQueue.main.async { Self.watcher.hold(token: token, offScreen: offScreen) } + } + + @objc func releaseNotification(_ token: String) { + DispatchQueue.main.async { Self.watcher.release(token: token) } + } + private class KeyboardBrightnessClient { private static let keyboardID: UInt64 = 1 private var clientInstance: NSObject? diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift b/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift index e12f0b38e..bec4ac60a 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift @@ -67,6 +67,8 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { func performNotificationAction(_ token: String, name: String, with reply: @escaping (Bool) -> Void) func openNotification(_ token: String, with reply: @escaping (Bool) -> Void) func dismissNotification(_ token: String, with reply: @escaping (Bool) -> Void) + func holdNotification(_ token: String, offScreen: Bool) + func releaseNotification(_ token: String) func notificationDebugDump(with reply: @escaping (String) -> Void) } diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index a44f51b4b..6d9855788 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -43,6 +43,16 @@ final class NotificationWatcher { private var pollTimer: DispatchSourceTimer? private var live: [String: AXUIElement] = [:] + /// Banners being deliberately kept alive so their reply field stays + /// usable, and of those, the ones moved off-screen. + private var held: Set = [] + private var heldOffScreen: Set = [] + private var lastRefresh = Date.distantPast + + /// Comfortably inside the ~5s dismissal window the toggle resets, so a + /// missed tick can't let a held banner slip away. + private let refreshInterval: TimeInterval = 2.5 + /// Banners live ~5s, so this catches every one with room to spare while /// staying cheap — each tick is a shallow AX tree walk. private let pollInterval: TimeInterval = 0.35 @@ -111,8 +121,65 @@ final class NotificationWatcher { for token in live.keys where !seen.contains(token) { live[token] = nil + heldOffScreen.remove(token) onBannerGone?(token) } + + refreshHeldBanners() + } + + /// Keeps held banners from timing out. + /// + /// Measured: an untouched banner dies in ~1.25s and its AX element is + /// destroyed with it, which is what made replying impossible after the + /// fact. Performing the details toggle resets that dismissal timer — + /// re-performing it on an interval held a banner alive for a full 30s + /// test with its reply field intact. That's what lets the notch offer a + /// working reply box for as long as the notification is showing. + private func refreshHeldBanners() { + guard !held.isEmpty else { return } + let now = Date() + guard now.timeIntervalSince(lastRefresh) >= refreshInterval else { return } + lastRefresh = now + + for token in held { + guard let banner = live[token] else { continue } + if let toggle = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("details") }) { + AXUIElementPerformAction(banner, toggle as CFString) + } + } + } + + /// Holds a banner open so its reply field stays usable, optionally + /// moving it off-screen first so the user never sees it. + /// + /// The off-screen move is safe to leave behind: when no banners are + /// showing, notificationcenterui has zero windows — the banner window is + /// created per session and destroyed after — so a moved window can't + /// permanently hide notifications. A fresh one spawns at its normal + /// position. + func hold(token: String, offScreen: Bool) { + guard let banner = live[token] else { return } + held.insert(token) + + guard offScreen, !heldOffScreen.contains(token) else { return } + heldOffScreen.insert(token) + guard let windowValue = banner[kAXWindowAttribute] else { return } + let window = windowValue as! AXUIElement + var target = CGPoint(x: -5000, y: -5000) + if let position = AXValueCreate(.cgPoint, &target) { + AXUIElementSetAttributeValue(window, kAXPositionAttribute as CFString, position) + } + } + + /// Stops holding a banner and lets it dismiss naturally. + func release(token: String) { + held.remove(token) + heldOffScreen.remove(token) + guard let banner = live[token] else { return } + if let close = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("close") }) { + AXUIElementPerformAction(banner, close as CFString) + } } /// Measured, not assumed: once a banner leaves the screen its diff --git a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift b/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift index 9d16bd168..382c2cb1d 100644 --- a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift +++ b/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift @@ -67,6 +67,8 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { func performNotificationAction(_ token: String, name: String, with reply: @escaping (Bool) -> Void) func openNotification(_ token: String, with reply: @escaping (Bool) -> Void) func dismissNotification(_ token: String, with reply: @escaping (Bool) -> Void) + func holdNotification(_ token: String, offScreen: Bool) + func releaseNotification(_ token: String) func notificationDebugDump(with reply: @escaping (String) -> Void) } diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index 044346ce1..4ce5d7fd4 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -465,6 +465,22 @@ extension XPCHelperClient { } } + /// Keeps a banner alive so its reply field stays usable, optionally + /// moving it off-screen so the user never sees it. + nonisolated func holdNotification(token: String, offScreen: Bool) { + Task { + let service = await MainActor.run { ensureRemoteService() } + try? await service.withService { $0.holdNotification(token, offScreen: offScreen) } + } + } + + nonisolated func releaseNotification(token: String) { + Task { + let service = await MainActor.run { ensureRemoteService() } + try? await service.withService { $0.releaseNotification(token) } + } + } + nonisolated func sendIMessage(_ text: String, toChatNamed name: String) async -> Bool { do { let service = await MainActor.run { ensureRemoteService() } diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index d19816234..bd2f82c16 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -159,19 +159,22 @@ final class SystemNotificationManager: ObservableObject { } NSLog("[boringNotch] showing in notch: \(notification.appName ?? "-")") show(notification) - suppressSystemBannerIfNeeded(notification) + holdSystemBanner(notification) } - /// Closes the OS banner right after capture for apps the user has opted - /// to mute at the system level — the notch's own live activity is meant - /// to be the only thing they see for these. This can only run after the - /// banner has already rendered and been read; nothing can stop it from - /// appearing at all. - private func suppressSystemBannerIfNeeded(_ notification: SystemNotification) { - guard let bundleID = notification.bundleID, - Defaults[.notificationSuppressedApps].contains(bundleID) - else { return } - Task { await XPCHelperClient.shared.dismissNotification(token: notification.id) } + /// Holds the system banner open for as long as the notch is showing the + /// notification, so its reply field stays usable — an untouched banner + /// dies in seconds, taking the only means of replying with it. + /// + /// For apps set to "hide system banner", the banner is also moved + /// off-screen, which is what makes hiding and replying possible at the + /// same time. The previous implementation closed the banner outright, + /// which hid it but destroyed the reply field along with it. + private func holdSystemBanner(_ notification: SystemNotification) { + let hidden = notification.bundleID.map { + Defaults[.notificationSuppressedApps].contains($0) + } ?? false + XPCHelperClient.shared.holdNotification(token: notification.id, offScreen: hidden) } /// The notch mirrors banners rather than replacing them, so an unfiltered @@ -212,6 +215,12 @@ final class SystemNotificationManager: ObservableObject { if let token, activeNotification?.id != token { return } dismissTask?.cancel() dismissTask = nil + // Stop holding the system banner open — otherwise the keep-alive + // would pin it (visibly, for non-hidden apps) long after the notch + // has moved on. + if let id = activeNotification?.id { + XPCHelperClient.shared.releaseNotification(token: id) + } withAnimation(.smooth) { activeNotification = nil } } From 5463a4357948849eaf2e5e48bb4b4b4ee61f3120 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 15:29:45 +0530 Subject: [PATCH 26/69] Always park held banners off-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holding a banner alive works by re-performing its details toggle, which leaves it expanded — showing the system banner's own reply field, on top of everything, taking focus. Two live text fields competing for the same keystrokes is worse than no keep-alive at all. So the off-screen move is no longer conditional on the per-app "hide system banner" setting: every held banner is parked at (-5000,-5000) for the duration, leaving the notch as the only visible surface. --- .../BoringNotchXPCHelper.swift | 4 ++-- .../BoringNotchXPCHelperProtocol.swift | 2 +- .../NotificationWatcher.swift | 24 ++++++++++++------- .../BoringNotchXPCHelperProtocol.swift | 2 +- .../XPCHelperClient/XPCHelperClient.swift | 4 ++-- .../managers/SystemNotificationManager.swift | 17 +++++++------ 6 files changed, 29 insertions(+), 24 deletions(-) diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift index 1a721f955..8c401bcd3 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift @@ -157,8 +157,8 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { DispatchQueue.main.async { reply(Self.watcher.dismiss(token: token)) } } - @objc func holdNotification(_ token: String, offScreen: Bool) { - DispatchQueue.main.async { Self.watcher.hold(token: token, offScreen: offScreen) } + @objc func holdNotification(_ token: String) { + DispatchQueue.main.async { Self.watcher.hold(token: token) } } @objc func releaseNotification(_ token: String) { diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift b/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift index bec4ac60a..4922e2867 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift @@ -67,7 +67,7 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { func performNotificationAction(_ token: String, name: String, with reply: @escaping (Bool) -> Void) func openNotification(_ token: String, with reply: @escaping (Bool) -> Void) func dismissNotification(_ token: String, with reply: @escaping (Bool) -> Void) - func holdNotification(_ token: String, offScreen: Bool) + func holdNotification(_ token: String) func releaseNotification(_ token: String) func notificationDebugDump(with reply: @escaping (String) -> Void) } diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index 6d9855788..e5e55ffa4 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -150,19 +150,25 @@ final class NotificationWatcher { } } - /// Holds a banner open so its reply field stays usable, optionally - /// moving it off-screen first so the user never sees it. + /// Holds a banner open so its reply field stays usable, moving it + /// off-screen first. /// - /// The off-screen move is safe to leave behind: when no banners are - /// showing, notificationcenterui has zero windows — the banner window is - /// created per session and destroyed after — so a moved window can't - /// permanently hide notifications. A fresh one spawns at its normal - /// position. - func hold(token: String, offScreen: Bool) { + /// The move is not optional. Holding a banner works by re-performing + /// its details toggle, which leaves it expanded — showing its own reply + /// field, on top of everything, taking focus. Two live text fields + /// competing for the same keystrokes is worse than no keep-alive at + /// all, so the banner is parked off-screen for the whole hold and the + /// notch is the only visible surface. + /// + /// Safe to leave behind: with no banners showing, notificationcenterui + /// has zero windows — the window is created per session and destroyed + /// after — so a moved window can't permanently hide notifications. A + /// fresh one always spawns at its normal position. + func hold(token: String) { guard let banner = live[token] else { return } held.insert(token) - guard offScreen, !heldOffScreen.contains(token) else { return } + guard !heldOffScreen.contains(token) else { return } heldOffScreen.insert(token) guard let windowValue = banner[kAXWindowAttribute] else { return } let window = windowValue as! AXUIElement diff --git a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift b/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift index 382c2cb1d..e0ac72225 100644 --- a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift +++ b/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift @@ -67,7 +67,7 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { func performNotificationAction(_ token: String, name: String, with reply: @escaping (Bool) -> Void) func openNotification(_ token: String, with reply: @escaping (Bool) -> Void) func dismissNotification(_ token: String, with reply: @escaping (Bool) -> Void) - func holdNotification(_ token: String, offScreen: Bool) + func holdNotification(_ token: String) func releaseNotification(_ token: String) func notificationDebugDump(with reply: @escaping (String) -> Void) } diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index 4ce5d7fd4..66d385f02 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -467,10 +467,10 @@ extension XPCHelperClient { /// Keeps a banner alive so its reply field stays usable, optionally /// moving it off-screen so the user never sees it. - nonisolated func holdNotification(token: String, offScreen: Bool) { + nonisolated func holdNotification(token: String) { Task { let service = await MainActor.run { ensureRemoteService() } - try? await service.withService { $0.holdNotification(token, offScreen: offScreen) } + try? await service.withService { $0.holdNotification(token) } } } diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index bd2f82c16..dbdaf507e 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -164,17 +164,16 @@ final class SystemNotificationManager: ObservableObject { /// Holds the system banner open for as long as the notch is showing the /// notification, so its reply field stays usable — an untouched banner - /// dies in seconds, taking the only means of replying with it. + /// dies in seconds, taking the only means of replying with it — and + /// parks it off-screen for the duration. /// - /// For apps set to "hide system banner", the banner is also moved - /// off-screen, which is what makes hiding and replying possible at the - /// same time. The previous implementation closed the banner outright, - /// which hid it but destroyed the reply field along with it. + /// Hiding isn't optional here: holding works by re-triggering the + /// banner's details toggle, which leaves it expanded with its own reply + /// field visible and focused. Two text fields fighting over the same + /// keystrokes is worse than no keep-alive, so the notch is the only + /// surface on screen while it's held. private func holdSystemBanner(_ notification: SystemNotification) { - let hidden = notification.bundleID.map { - Defaults[.notificationSuppressedApps].contains($0) - } ?? false - XPCHelperClient.shared.holdNotification(token: notification.id, offScreen: hidden) + XPCHelperClient.shared.holdNotification(token: notification.id) } /// The notch mirrors banners rather than replacing them, so an unfiltered From c74100dad5c823d457345462f46d7026424d2c92 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 15:31:39 +0530 Subject: [PATCH 27/69] Remove the now-dead "hide system banner" setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every held banner is parked off-screen regardless, so the per-app toggle changed nothing. A switch that does nothing is worse than no switch — hiding is inherent to how replying works now, not a preference. Drops the toggle and its notificationSuppressedApps key; the app rows are just the on/off switch again. --- .../Views/NotificationSettingsView.swift | 41 ++++++------------- boringNotch/models/Constants.swift | 8 ---- 2 files changed, 12 insertions(+), 37 deletions(-) diff --git a/boringNotch/components/Settings/Views/NotificationSettingsView.swift b/boringNotch/components/Settings/Views/NotificationSettingsView.swift index 36e9cbedf..d177bf31c 100644 --- a/boringNotch/components/Settings/Views/NotificationSettingsView.swift +++ b/boringNotch/components/Settings/Views/NotificationSettingsView.swift @@ -32,7 +32,6 @@ struct NotificationSettingsView: View { @Default(.notificationLiveActivity) var notificationLiveActivity @Default(.notificationsFromAllApps) var notificationsFromAllApps @Default(.notificationAllowedApps) var allowedApps - @Default(.notificationSuppressedApps) var suppressedApps var body: some View { Form { @@ -101,35 +100,19 @@ struct NotificationSettingsView: View { @ViewBuilder private func appRow(_ app: KnownNotificationApp) -> some View { - let isAllowed = allowedApps.contains(app.bundleID) + HStack { + AppIcon(for: app.bundleID) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 5)) - VStack(alignment: .leading, spacing: 4) { - HStack { - AppIcon(for: app.bundleID) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) - .clipShape(RoundedRectangle(cornerRadius: 5)) - - Toggle(app.name, isOn: Binding( - get: { allowedApps.contains(app.bundleID) }, - set: { on in - if on { allowedApps.insert(app.bundleID) } else { allowedApps.remove(app.bundleID) } - } - )) - } - - if isAllowed { - Toggle("Hide system banner, show in notch only", isOn: Binding( - get: { suppressedApps.contains(app.bundleID) }, - set: { on in - if on { suppressedApps.insert(app.bundleID) } else { suppressedApps.remove(app.bundleID) } - } - )) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 28) - } + Toggle(app.name, isOn: Binding( + get: { allowedApps.contains(app.bundleID) }, + set: { on in + if on { allowedApps.insert(app.bundleID) } else { allowedApps.remove(app.bundleID) } + } + )) } .disabled(!notificationLiveActivity) } diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 50564ad8a..1dfa78e78 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -312,14 +312,6 @@ extension Defaults.Keys { "com.anthropic.claudefordesktop" ] ) - /// Apps whose system banner gets closed immediately after boring.notch - /// captures it, so the notch becomes the only lasting surface. Can't - /// prevent the banner from rendering at all — there's no macOS API for - /// that — this just makes it live on screen for well under a second. - static let notificationSuppressedApps = Key>( - "notificationSuppressedApps", - default: [] - ) /// Off by default: a new capability, even though it runs entirely /// on-device with no network calls. Only takes effect on macOS 26+ with /// Apple Intelligence enabled — see SmartReplyManager. From 2fbebcedcfc65faaee5c7c455d5920311db1d312 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 15:38:15 +0530 Subject: [PATCH 28/69] Add compact mode: a player-only opened notch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read Atoll's implementation for reference (its enableMinimalisticUI swaps openNotchSize for a smaller one and renders a dedicated player). Took the shape of the idea, not the code — its player view is ~1500 lines and assumes managers this project doesn't have. Built on the pieces the full layout already uses — MusicSliderView, HoverButton, MarqueeText, and the musicControlSlots preference — so seeking and the configured transport buttons behave identically in both layouts rather than drifting apart. Opened notch shrinks to 150pt tall, content capped at 380pt wide, and the tab bar is dropped: it switches between tabs the compact layout doesn't have, and it spans the full notch width, which would defeat the narrowing. Off by default so existing users keep their current layout. Three things the compiler caught that were assumptions on my part: this project has no useMusicVisualizer preference (removed), NotchHomeView's `padded` helper is fileprivate so the slot padding is done locally rather than widening its access, and AudioSpectrumView takes a plain Bool plus its own tintColor — the gradient-and-mask wrapper I'd written around it would have fought the colour it already applies. --- boringNotch.xcodeproj/project.pbxproj | 4 + boringNotch/ContentView.swift | 21 +- .../components/Notch/CompactHomeView.swift | 180 ++++++++++++++++++ .../Settings/Views/GeneralSettingsView.swift | 10 + boringNotch/models/Constants.swift | 6 + 5 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 boringNotch/components/Notch/CompactHomeView.swift diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index bfe90a097..7d251da97 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -341,6 +341,8 @@ A5167212301F85B40018095A /* boringNotchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = boringNotchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; AA01NDW22E7A0001 /* NotificationDebugWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugWindow.swift; sourceTree = ""; }; AA01NLA22E7A0001 /* NotificationLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationLiveActivity.swift; sourceTree = ""; }; + AA06CHV22E7A0001 /* CompactHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactHomeView.swift; sourceTree = ""; }; + AA06CHV12E7A0001 /* CompactHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA06CHV22E7A0001 /* CompactHomeView.swift */; }; AA01SNM22E7A0001 /* SystemNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemNotificationManager.swift; sourceTree = ""; }; AA02CAM22E7A0001 /* ContactAvatarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactAvatarManager.swift; sourceTree = ""; }; AA02NSV22E7A0001 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; @@ -852,6 +854,7 @@ isa = PBXGroup; children = ( AA01NLA22E7A0001 /* NotificationLiveActivity.swift */, + AA06CHV22E7A0001 /* CompactHomeView.swift */, AA04LAS22E7A0001 /* LiveActivityStack.swift */, 1194E8862EA6DDA7009C82D6 /* BoringNotchSkyLightWindow.swift */, 1160F8D72DD98230006FBB94 /* NotchShape.swift */, @@ -1128,6 +1131,7 @@ AA01SNM12E7A0001 /* SystemNotificationManager.swift in Sources */, AA01NDW12E7A0001 /* NotificationDebugWindow.swift in Sources */, AA01NLA12E7A0001 /* NotificationLiveActivity.swift in Sources */, + AA06CHV12E7A0001 /* CompactHomeView.swift in Sources */, AA04LAS12E7A0001 /* LiveActivityStack.swift in Sources */, AA02CAM12E7A0001 /* ContactAvatarManager.swift in Sources */, AA03OTP12E7A0001 /* OTPDetector.swift in Sources */, diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index aef406d50..9bb9271fd 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -113,7 +113,18 @@ struct ContentView: View { /// height the home/shelf tabs are sized for, and stretching to fill it /// just surrounds two lines of text with empty black. private var openNotchHeight: CGFloat { - notificationManager.activeNotification != nil ? 132 : vm.notchSize.height + if notificationManager.activeNotification != nil { return 132 } + return Defaults[.compactMode] ? 150 : vm.notchSize.height + } + + /// Compact mode drops the tab bar along with the tabs it switches + /// between — there's only the player to show, so a switcher would have + /// nothing to switch to. Also what keeps the panel narrow, since the + /// header spans the full notch width. + private var showsHeader: Bool { + vm.notchState == .open + && notificationManager.activeNotification == nil + && !Defaults[.compactMode] } /// The activity currently on top of the stack — what the chin has to be @@ -418,7 +429,7 @@ struct ContentView: View { } } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { BoringFaceAnimation() - } else if vm.notchState == .open && notificationManager.activeNotification == nil { + } else if showsHeader { // No tab bar over a notification: it's a glance, // not a place to switch between home and shelf — // and the header spans the full notch width, @@ -484,6 +495,12 @@ struct ContentView: View { // reply UI — the usual tabs can wait until it's dismissed. if let notification = notificationManager.activeNotification { NotificationExpandedView(notification: notification) + } else if Defaults[.compactMode] { + // Player only — no tab switching, so currentView is + // ignored here rather than offering a shelf the + // compact layout has no room (or tab bar) for. + CompactHomeView(albumArtNamespace: albumArtNamespace) + .frame(maxWidth: 380) } else { switch coordinator.currentView { case .home: diff --git a/boringNotch/components/Notch/CompactHomeView.swift b/boringNotch/components/Notch/CompactHomeView.swift new file mode 100644 index 000000000..9c4faa8c9 --- /dev/null +++ b/boringNotch/components/Notch/CompactHomeView.swift @@ -0,0 +1,180 @@ +// +// CompactHomeView.swift +// boringNotch +// +// A smaller open-notch layout: just the now-playing essentials — art, +// title, scrubber, transport — with no tab bar, calendar or mirror. +// +// Deliberately built on the same pieces the full layout uses +// (MusicSliderView, HoverButton, MarqueeText, the musicControlSlots +// preference) rather than a parallel implementation, so a fix to seeking +// or the control slots applies to both layouts instead of drifting. +// + +import Defaults +import SwiftUI + +struct CompactHomeView: View { + @EnvironmentObject var vm: BoringViewModel + @ObservedObject var musicManager = MusicManager.shared + let albumArtNamespace: Namespace.ID + + @State private var sliderValue: Double = 0 + @State private var dragging: Bool = false + @State private var lastDragged: Date = .distantPast + + @Default(.musicControlSlots) private var slotConfig + @Default(.musicControlSlotLimit) private var slotLimit + @Default(.coloredSpectrogram) private var coloredSpectrogram + @Default(.playerColorTinting) private var playerColorTinting + + private let artSize: CGFloat = 56 + + var body: some View { + if musicManager.isPlayerIdle && !musicManager.isPlaying { + idleState + } else { + VStack(spacing: 8) { + header + MusicSliderView( + sliderValue: $sliderValue, + duration: $musicManager.songDuration, + lastDragged: $lastDragged, + color: musicManager.avgColor, + dragging: $dragging, + currentDate: Date(), + timestampDate: musicManager.timestampDate, + elapsedTime: musicManager.elapsedTime, + playbackRate: musicManager.playbackRate, + isPlaying: musicManager.isPlaying + ) { newValue in + MusicManager.shared.seek(to: newValue) + } + .frame(height: 24) + transport + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .buttonStyle(PlainButtonStyle()) + } + } + + private var header: some View { + HStack(spacing: 10) { + AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace) + .frame(width: artSize, height: artSize) + + GeometryReader { geo in + VStack(alignment: .leading, spacing: 1) { + Spacer(minLength: 0) + MarqueeText( + musicManager.songTitle, + font: .system(size: 13, weight: .semibold), + color: .white, + frameWidth: geo.size.width + ) + MarqueeText( + musicManager.artistName, + font: .system(size: 11), + color: playerColorTinting + ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) + : .gray, + frameWidth: geo.size.width + ) + Spacer(minLength: 0) + } + } + .frame(height: artSize) + + // AudioSpectrumView tints itself, so no outer gradient/mask — + // that would just fight the colour it already applies. + if !musicManager.isPlayerIdle { + AudioSpectrumView( + isPlaying: musicManager.isPlaying, + tintColor: coloredSpectrogram + ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) + : .gray + ) + .frame(width: 18, height: 14) + .frame(height: artSize) + } + } + } + + /// Same slot preference as the full layout, so the buttons a user + /// configured there are the ones they get here. + private var transport: some View { + // Pad/trim to the configured slot count locally — NotchHomeView's + // `padded` helper is fileprivate to that file, and widening its + // access just for this would be a worse trade than four lines here. + let count = min(max(slotLimit, MusicControlButton.minSlotCount), MusicControlButton.maxSlotCount) + var slots = slotConfig + if slots.count < count { + slots += Array(repeating: .none, count: count - slots.count) + } + slots = Array(slots.prefix(count)) + + return HStack(spacing: 4) { + ForEach(Array(slots.enumerated()), id: \.offset) { _, slot in + slotView(for: slot) + } + } + .frame(maxWidth: .infinity, alignment: .center) + } + + @ViewBuilder + private func slotView(for slot: MusicControlButton) -> some View { + switch slot { + case .shuffle: + HoverButton(icon: "shuffle", iconColor: musicManager.isShuffled ? .red : .primary, scale: .medium) { + MusicManager.shared.toggleShuffle() + } + case .previous: + HoverButton(icon: "backward.fill", scale: .medium) { + MusicManager.shared.previousTrack() + } + case .playPause: + HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) { + MusicManager.shared.togglePlay() + } + case .next: + HoverButton(icon: "forward.fill", scale: .medium) { + MusicManager.shared.nextTrack() + } + case .repeatMode: + HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) { + MusicManager.shared.toggleRepeat() + } + case .none: + EmptyView() + default: + // Slots that only make sense in the full layout (mirror, + // calendar, and anything added later) are skipped rather than + // rendered half-working in a player-only view. + EmptyView() + } + } + + private var repeatIcon: String { + switch musicManager.repeatMode { + case .one: "repeat.1" + default: "repeat" + } + } + + private var repeatIconColor: Color { + musicManager.repeatMode == .off ? .primary : .red + } + + private var idleState: some View { + VStack(spacing: 6) { + Image(systemName: "music.note") + .font(.system(size: 20, weight: .light)) + .foregroundStyle(.gray) + Text("Nothing playing") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.gray) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/boringNotch/components/Settings/Views/GeneralSettingsView.swift b/boringNotch/components/Settings/Views/GeneralSettingsView.swift index 2fc9175d9..7048e88de 100644 --- a/boringNotch/components/Settings/Views/GeneralSettingsView.swift +++ b/boringNotch/components/Settings/Views/GeneralSettingsView.swift @@ -289,5 +289,15 @@ struct GeneralSettings: View { } header: { Text("Notch behavior") } + + Section { + Defaults.Toggle(key: .compactMode) { + Text("Compact mode") + } + } footer: { + Text("Shows a smaller opened notch with just the music player — no tabs, calendar or mirror.") + .font(.caption) + .foregroundStyle(.secondary) + } } } diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 1dfa78e78..672ace2e9 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -294,6 +294,12 @@ extension Defaults.Keys { static let osdReplacement = Key("osdReplacement", default: false) static let inlineOSD = Key("inlineOSD", default: false) + // MARK: Layout + /// Swaps the opened notch for a smaller, player-only layout: no tab + /// bar, calendar or mirror. Off by default so existing users keep the + /// layout they already have. + static let compactMode = Key("compactMode", default: false) + // MARK: Notifications /// Off by default: mirroring banners needs Accessibility access. static let notificationLiveActivity = Key("notificationLiveActivity", default: false) From c6434790881d736dddae94eaaad7e14aab5c413a Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 15:44:42 +0530 Subject: [PATCH 29/69] Match Atoll's compact layout properly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass was my own arrangement and looked it. This follows Atoll's MinimalisticMusicPlayerView proportions directly — both projects are GPL-3.0 and Atoll is itself a boring.notch fork, so this is license-clean with attribution (credited in the file header). Ported to MusicSliderView, additively: - TimeLabelLayout .inline, putting the times either side of the track instead of stacked beneath it - TrailingLabel .remaining, which counts down ("-2:56") - configurable resting/dragging track heights, replacing hard-coded 5/9 in CustomSlider Every new parameter defaults to the previous behaviour, so the standard layout renders exactly as before. Compact layout now: 50pt art, 12/10pt title and artist, a 42pt visualizer block sized to the trailing time label so its bars centre over it, inline progress row, 10pt-spaced transport. Panel is 420x180, matching the size these proportions were designed against. --- boringNotch/ContentView.swift | 6 +- boringNotch/Localizable.xcstrings | 12 +- .../components/Notch/CompactHomeView.swift | 184 +++++++++++------- .../components/Notch/NotchHomeView.swift | 120 ++++++++++-- 4 files changed, 221 insertions(+), 101 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 9bb9271fd..fc21cc2b4 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -114,7 +114,7 @@ struct ContentView: View { /// just surrounds two lines of text with empty black. private var openNotchHeight: CGFloat { if notificationManager.activeNotification != nil { return 132 } - return Defaults[.compactMode] ? 150 : vm.notchSize.height + return Defaults[.compactMode] ? 180 : vm.notchSize.height } /// Compact mode drops the tab bar along with the tabs it switches @@ -499,8 +499,10 @@ struct ContentView: View { // Player only — no tab switching, so currentView is // ignored here rather than offering a shelf the // compact layout has no room (or tab bar) for. + // 420pt wide matches Atoll's minimalistic base size, + // which these proportions were designed against. CompactHomeView(albumArtNamespace: albumArtNamespace) - .frame(maxWidth: 380) + .frame(maxWidth: 420) } else { switch coordinator.currentView { case .home: diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index 5fa19131c..51e1db1a0 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -5869,6 +5869,9 @@ } } } + }, + "Compact mode" : { + }, "Continue" : { "localizations" : { @@ -11051,9 +11054,6 @@ } } } - }, - "Hide system banner, show in notch only" : { - }, "Hide title bar" : { "localizations" : { @@ -16114,6 +16114,9 @@ } } } + }, + "Nothing playing" : { + }, "Notification Debug" : { @@ -23240,6 +23243,9 @@ } } } + }, + "Shows a smaller opened notch with just the music player — no tabs, calendar or mirror." : { + }, "Slider color" : { "localizations" : { diff --git a/boringNotch/components/Notch/CompactHomeView.swift b/boringNotch/components/Notch/CompactHomeView.swift index 9c4faa8c9..3e36ce803 100644 --- a/boringNotch/components/Notch/CompactHomeView.swift +++ b/boringNotch/components/Notch/CompactHomeView.swift @@ -5,10 +5,18 @@ // A smaller open-notch layout: just the now-playing essentials — art, // title, scrubber, transport — with no tab bar, calendar or mirror. // -// Deliberately built on the same pieces the full layout uses -// (MusicSliderView, HoverButton, MarqueeText, the musicControlSlots -// preference) rather than a parallel implementation, so a fix to seeking -// or the control slots applies to both layouts instead of drifting. +// Layout and proportions follow Atoll's MinimalisticMusicPlayerView +// (https://github.com/Ebullioscopic/Atoll, GPL-3.0, itself a boring.notch +// fork): 50pt album art, 12/10pt title and artist, a fixed-width +// visualizer block on the right sized to match the trailing time label so +// the bars centre over it, an inline progress row with a counting-down +// remaining time, and a 10pt-spaced transport row. +// +// Built on this project's own MusicSliderView / HoverButton / +// MarqueeText and the musicControlSlots preference rather than porting +// Atoll's ~1500-line view wholesale, so seeking and the configured +// transport buttons stay identical between the two layouts instead of +// drifting apart. // import Defaults @@ -28,100 +36,130 @@ struct CompactHomeView: View { @Default(.coloredSpectrogram) private var coloredSpectrogram @Default(.playerColorTinting) private var playerColorTinting - private let artSize: CGFloat = 56 + private let albumArtWidth: CGFloat = 50 + private let headerSpacing: CGFloat = 10 + /// Matches the trailing time label's width in the row below, so the + /// visualizer's bars sit centred over "-0:00" rather than drifting. + private let vizBlockWidth: CGFloat = 42 + private let vizBarWidth: CGFloat = 24 var body: some View { - if musicManager.isPlayerIdle && !musicManager.isPlaying { + if !musicManager.isPlaying && musicManager.isPlayerIdle { idleState } else { - VStack(spacing: 8) { + VStack(spacing: 0) { header - MusicSliderView( - sliderValue: $sliderValue, - duration: $musicManager.songDuration, - lastDragged: $lastDragged, - color: musicManager.avgColor, - dragging: $dragging, - currentDate: Date(), - timestampDate: musicManager.timestampDate, - elapsedTime: musicManager.elapsedTime, - playbackRate: musicManager.playbackRate, - isPlaying: musicManager.isPlaying - ) { newValue in - MusicManager.shared.seek(to: newValue) - } - .frame(height: 24) + .frame(height: albumArtWidth) + + progressRow + .padding(.top, 6) + transport + .padding(.top, 4) } .padding(.horizontal, 12) - .padding(.vertical, 10) + .padding(.top, 6) + .padding(.bottom, 10) + .frame(maxWidth: .infinity) .buttonStyle(PlainButtonStyle()) } } + // MARK: - Header + private var header: some View { - HStack(spacing: 10) { - AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace) - .frame(width: artSize, height: artSize) + GeometryReader { geo in + let textWidth = max( + 0, + geo.size.width - albumArtWidth - headerSpacing - (vizBlockWidth + headerSpacing) + ) + + HStack(alignment: .center, spacing: headerSpacing) { + AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace) + .frame(width: albumArtWidth, height: albumArtWidth) - GeometryReader { geo in VStack(alignment: .leading, spacing: 1) { - Spacer(minLength: 0) MarqueeText( musicManager.songTitle, - font: .system(size: 13, weight: .semibold), + font: .system(size: 12, weight: .semibold), color: .white, - frameWidth: geo.size.width + frameWidth: textWidth ) - MarqueeText( - musicManager.artistName, - font: .system(size: 11), - color: playerColorTinting + + Text(musicManager.artistName) + .font(.system(size: 10)) + .foregroundStyle( + playerColorTinting + ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) + : .gray + ) + .lineLimit(1) + } + .frame(width: textWidth, alignment: .leading) + + ZStack { + AudioSpectrumView( + isPlaying: musicManager.isPlaying, + tintColor: coloredSpectrogram ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) - : .gray, - frameWidth: geo.size.width + : .gray ) - Spacer(minLength: 0) + .frame(width: vizBarWidth, height: 16) } - } - .frame(height: artSize) - - // AudioSpectrumView tints itself, so no outer gradient/mask — - // that would just fight the colour it already applies. - if !musicManager.isPlayerIdle { - AudioSpectrumView( - isPlaying: musicManager.isPlaying, - tintColor: coloredSpectrogram - ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) - : .gray - ) - .frame(width: 18, height: 14) - .frame(height: artSize) + .frame(width: vizBlockWidth) } } } - /// Same slot preference as the full layout, so the buttons a user - /// configured there are the ones they get here. - private var transport: some View { - // Pad/trim to the configured slot count locally — NotchHomeView's - // `padded` helper is fileprivate to that file, and widening its - // access just for this would be a worse trade than four lines here. - let count = min(max(slotLimit, MusicControlButton.minSlotCount), MusicControlButton.maxSlotCount) - var slots = slotConfig - if slots.count < count { - slots += Array(repeating: .none, count: count - slots.count) + // MARK: - Progress + + private var progressRow: some View { + TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in + MusicSliderView( + sliderValue: $sliderValue, + duration: $musicManager.songDuration, + lastDragged: $lastDragged, + color: musicManager.avgColor, + dragging: $dragging, + currentDate: timeline.date, + timestampDate: musicManager.timestampDate, + elapsedTime: musicManager.elapsedTime, + playbackRate: musicManager.playbackRate, + isPlaying: musicManager.isPlaying, + onValueChange: { MusicManager.shared.seek(to: $0) }, + labelLayout: .inline, + trailingLabel: .remaining, + restingTrackHeight: 7, + draggingTrackHeight: 11 + ) } - slots = Array(slots.prefix(count)) + .onAppear { sliderValue = musicManager.elapsedTime } + } - return HStack(spacing: 4) { - ForEach(Array(slots.enumerated()), id: \.offset) { _, slot in + // MARK: - Transport + + /// Uses the same slot preference as the full layout, so the buttons + /// configured there are the ones that appear here. + private var transport: some View { + HStack(spacing: 10) { + ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in slotView(for: slot) } } .frame(maxWidth: .infinity, alignment: .center) } + private var displayedSlots: [MusicControlButton] { + // Padding is done here rather than via NotchHomeView's `padded` + // helper, which is fileprivate to that file. + let count = min(max(slotLimit, MusicControlButton.minSlotCount), MusicControlButton.maxSlotCount) + var slots = slotConfig + if slots.count < count { + slots += Array(repeating: .none, count: count - slots.count) + } + return Array(slots.prefix(count)) + } + @ViewBuilder private func slotView(for slot: MusicControlButton) -> some View { switch slot { @@ -148,18 +186,14 @@ struct CompactHomeView: View { case .none: EmptyView() default: - // Slots that only make sense in the full layout (mirror, - // calendar, and anything added later) are skipped rather than - // rendered half-working in a player-only view. + // Slots that only make sense in the full layout are skipped + // rather than rendered half-working in a player-only view. EmptyView() } } private var repeatIcon: String { - switch musicManager.repeatMode { - case .one: "repeat.1" - default: "repeat" - } + musicManager.repeatMode == .one ? "repeat.1" : "repeat" } private var repeatIconColor: Color { @@ -167,11 +201,11 @@ struct CompactHomeView: View { } private var idleState: some View { - VStack(spacing: 6) { - Image(systemName: "music.note") - .font(.system(size: 20, weight: .light)) + VStack(spacing: 8) { + Image(systemName: "music.note.slash") + .font(.system(size: 24, weight: .light)) .foregroundStyle(.gray) - Text("Nothing playing") + Text("Nothing Playing") .font(.system(size: 12, weight: .medium)) .foregroundStyle(.gray) } diff --git a/boringNotch/components/Notch/NotchHomeView.swift b/boringNotch/components/Notch/NotchHomeView.swift index 425dff4d7..ac25ce994 100644 --- a/boringNotch/components/Notch/NotchHomeView.swift +++ b/boringNotch/components/Notch/NotchHomeView.swift @@ -481,39 +481,113 @@ struct MusicSliderView: View { let isPlaying: Bool var onValueChange: (Double) -> Void + // Layout options, ported from Atoll (GPL-3.0, itself a boring.notch + // fork) so the compact layout can put the times either side of the + // track. Defaults reproduce the previous stacked/duration look exactly, + // so the standard layout is untouched. + var labelLayout: TimeLabelLayout = .stacked + var trailingLabel: TrailingLabel = .duration + var restingTrackHeight: CGFloat = 5 + var draggingTrackHeight: CGFloat = 9 + + enum TimeLabelLayout { + /// Times on a row beneath the track. + case stacked + /// Times flanking the track on the same row. + case inline + } + + enum TrailingLabel { + case duration + /// Counts down: "-2:56". + case remaining + } var body: some View { + Group { + switch labelLayout { + case .stacked: stackedContent + case .inline: inlineContent + } + } + .onChange(of: currentDate) { + guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return } + sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: currentDate) + } + } + + private var stackedContent: some View { VStack { - CustomSlider( - value: $sliderValue, - range: 0...duration, - color: Defaults[.sliderColor] == SliderColorEnum.albumArt - ? Color(nsColor: color).ensureMinimumBrightness(factor: 0.8) - : Defaults[.sliderColor] == SliderColorEnum.accent ? .effectiveAccent : .white, - dragging: $dragging, - lastDragged: $lastDragged, - onValueChange: onValueChange - ) - .frame(height: 10, alignment: .center) + sliderCore + .frame(height: sliderFrameHeight, alignment: .center) HStack { Text(timeString(from: sliderValue)) Spacer() - Text(timeString(from: duration)) + Text(trailingTimeText) } .fontWeight(.medium) - .foregroundColor( - Defaults[.playerColorTinting] - ? Color(nsColor: color).ensureMinimumBrightness(factor: 0.6) : .gray - ) + .foregroundColor(timeLabelColor) .font(.caption) } - .onChange(of: currentDate) { - guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return } - sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: currentDate) + } + + private var inlineContent: some View { + HStack(spacing: 6) { + Text(timeString(from: sliderValue)) + .font(inlineLabelFont) + .foregroundColor(timeLabelColor) + .frame(width: 36, alignment: .leading) + + sliderCore + .frame(height: sliderFrameHeight) + .frame(maxWidth: .infinity) + + Text(trailingTimeText) + .font(inlineLabelFont) + .foregroundColor(timeLabelColor) + .frame(width: 42, alignment: .trailing) + } + } + + private var sliderCore: some View { + CustomSlider( + value: $sliderValue, + range: 0...duration, + color: Defaults[.sliderColor] == SliderColorEnum.albumArt + ? Color(nsColor: color).ensureMinimumBrightness(factor: 0.8) + : Defaults[.sliderColor] == SliderColorEnum.accent ? .effectiveAccent : .white, + dragging: $dragging, + lastDragged: $lastDragged, + onValueChange: onValueChange, + restingTrackHeight: restingTrackHeight, + draggingTrackHeight: draggingTrackHeight + ) + } + + private var timeLabelColor: Color { + Defaults[.playerColorTinting] + ? Color(nsColor: color).ensureMinimumBrightness(factor: 0.6) : .gray + } + + private var trailingTimeText: String { + switch trailingLabel { + case .duration: + return timeString(from: duration) + case .remaining: + return "-" + timeString(from: max(duration - sliderValue, 0)) } } + /// Monospaced digits so the label doesn't jitter as the numbers tick. + private var inlineLabelFont: Font { + .system(size: 11, weight: .medium).monospacedDigit() + } + + private var sliderFrameHeight: CGFloat { + max(restingTrackHeight, draggingTrackHeight) + 1 + } + func timeString(from seconds: Double) -> String { guard seconds.isFinite else { return "--:--" } let totalMinutes = Int(seconds) / 60 @@ -537,11 +611,15 @@ struct CustomSlider: View { @Binding var lastDragged: Date var onValueChange: ((Double) -> Void)? var onDragChange: ((Double) -> Void)? + /// Defaults match the previous hard-coded 5/9 so the standard layout is + /// unchanged; the compact layout passes a chunkier track. + var restingTrackHeight: CGFloat = 5 + var draggingTrackHeight: CGFloat = 9 var body: some View { GeometryReader { geometry in let width = geometry.size.width - let height = CGFloat(dragging ? 9 : 5) + let height = CGFloat(dragging ? draggingTrackHeight : restingTrackHeight) let rangeSpan = range.upperBound - range.lowerBound let progress = rangeSpan == .zero ? 0 : (value - range.lowerBound) / rangeSpan @@ -557,7 +635,7 @@ struct CustomSlider: View { .frame(width: filledTrackWidth, height: height) } .cornerRadius(height / 2) - .frame(height: 10) + .frame(height: max(restingTrackHeight, draggingTrackHeight) + 1) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) From 3e53e535be548eb72a936b2a1e818ca06abd4aec Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 15:58:01 +0530 Subject: [PATCH 30/69] Compact mode: full transport row, media output, fixed album art badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things were making this diverge from Atoll: - Only three buttons rendered. The row was driven by the musicControlSlots preference, whose default is [.none, .previous, .playPause, .next, .none] — so shuffle and media output never appeared. Compact mode now uses a fixed five; that preference exists to configure the full layout. - No media-output control existed at all. Added one showing the current route (laptop / headphones / AirPods / speaker) via a new AudioOutputRouteResolver.outputRouteSymbol(). Atoll's opens a popover that switches device inline, which needs its AudioRouteManager — this project only classifies the current route, so this opens Sound settings instead rather than faking a picker that can't switch anything. - The Spotify badge spilled out of the artwork. AlbumArtView's badge is a fixed 30pt at +10/+10, sized for the 120pt art in the full layout; on 50pt art it overflows the corner. Compact mode draws its own art with an 18pt badge at +5/+5. --- boringNotch/Localizable.xcstrings | 2 +- .../components/Notch/CompactHomeView.swift | 72 ++++++++++++++----- .../helpers/AudioOutputRouteResolver.swift | 16 +++++ 3 files changed, 70 insertions(+), 20 deletions(-) diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index 51e1db1a0..6f78f907f 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -16115,7 +16115,7 @@ } } }, - "Nothing playing" : { + "Nothing Playing" : { }, "Notification Debug" : { diff --git a/boringNotch/components/Notch/CompactHomeView.swift b/boringNotch/components/Notch/CompactHomeView.swift index 3e36ce803..f04d7e1d7 100644 --- a/boringNotch/components/Notch/CompactHomeView.swift +++ b/boringNotch/components/Notch/CompactHomeView.swift @@ -13,10 +13,11 @@ // remaining time, and a 10pt-spaced transport row. // // Built on this project's own MusicSliderView / HoverButton / -// MarqueeText and the musicControlSlots preference rather than porting -// Atoll's ~1500-line view wholesale, so seeking and the configured -// transport buttons stay identical between the two layouts instead of -// drifting apart. +// MarqueeText rather than porting Atoll's ~1500-line view wholesale, so +// seeking behaves identically in both layouts instead of drifting apart. +// The transport row is a fixed five here rather than the musicControlSlots +// preference — that preference exists to configure the full layout, and +// its default would leave compact mode without shuffle or media output. // import Defaults @@ -31,8 +32,6 @@ struct CompactHomeView: View { @State private var dragging: Bool = false @State private var lastDragged: Date = .distantPast - @Default(.musicControlSlots) private var slotConfig - @Default(.musicControlSlotLimit) private var slotLimit @Default(.coloredSpectrogram) private var coloredSpectrogram @Default(.playerColorTinting) private var playerColorTinting @@ -75,8 +74,7 @@ struct CompactHomeView: View { ) HStack(alignment: .center, spacing: headerSpacing) { - AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace) - .frame(width: albumArtWidth, height: albumArtWidth) + compactAlbumArt VStack(alignment: .leading, spacing: 1) { MarqueeText( @@ -138,8 +136,6 @@ struct CompactHomeView: View { // MARK: - Transport - /// Uses the same slot preference as the full layout, so the buttons - /// configured there are the ones that appear here. private var transport: some View { HStack(spacing: 10) { ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in @@ -149,15 +145,12 @@ struct CompactHomeView: View { .frame(maxWidth: .infinity, alignment: .center) } + /// Fixed five, deliberately not the musicControlSlots preference. That + /// preference defaults to [.none, .previous, .playPause, .next, .none], + /// which is why this row was rendering only three buttons — compact + /// mode is meant to show shuffle and media output too. private var displayedSlots: [MusicControlButton] { - // Padding is done here rather than via NotchHomeView's `padded` - // helper, which is fileprivate to that file. - let count = min(max(slotLimit, MusicControlButton.minSlotCount), MusicControlButton.maxSlotCount) - var slots = slotConfig - if slots.count < count { - slots += Array(repeating: .none, count: count - slots.count) - } - return Array(slots.prefix(count)) + [.shuffle, .previous, .playPause, .next, .none] } @ViewBuilder @@ -184,7 +177,7 @@ struct CompactHomeView: View { MusicManager.shared.toggleRepeat() } case .none: - EmptyView() + mediaOutputButton default: // Slots that only make sense in the full layout are skipped // rather than rendered half-working in a player-only view. @@ -192,6 +185,47 @@ struct CompactHomeView: View { } } + /// Shows where audio is currently going and opens Sound settings. + /// + /// Atoll's equivalent opens a popover that switches device inline, but + /// that needs its AudioRouteManager (enumeration + switching), which + /// this project doesn't have — AudioOutputRouteResolver only classifies + /// the current route. Handing off to Sound settings is honest about + /// that rather than faking a picker that can't switch anything. + private var mediaOutputButton: some View { + HoverButton(icon: routeSymbol, scale: .medium) { + if let url = URL(string: "x-apple.systempreferences:com.apple.Sound-Settings.extension") { + NSWorkspace.shared.open(url) + } + } + } + + private var compactAlbumArt: some View { + ZStack(alignment: .bottomTrailing) { + Image(nsImage: musicManager.albumArt) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: albumArtWidth, height: albumArtWidth) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + // Badge scaled to this art. AlbumArtView's is a fixed 30pt with + // a +10/+10 offset, sized for the 120pt art in the full layout — + // on 50pt art it spills outside the corner. + if !musicManager.usingAppIconForArtwork { + AppIcon(for: musicManager.bundleIdentifier ?? "com.apple.Music") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18, height: 18) + .offset(x: 5, y: 5) + } + } + .frame(width: albumArtWidth, height: albumArtWidth) + } + + private var routeSymbol: String { + AudioOutputRouteResolver.shared.outputRouteSymbol() + } + private var repeatIcon: String { musicManager.repeatMode == .one ? "repeat.1" : "repeat" } diff --git a/boringNotch/helpers/AudioOutputRouteResolver.swift b/boringNotch/helpers/AudioOutputRouteResolver.swift index 9ac1a6ef3..c98f264d9 100644 --- a/boringNotch/helpers/AudioOutputRouteResolver.swift +++ b/boringNotch/helpers/AudioOutputRouteResolver.swift @@ -26,6 +26,22 @@ final class AudioOutputRouteResolver { private let stateQueue = DispatchQueue(label: "AudioOutputRouteResolver.state") private var cachedRouteKind: AudioOutputRouteKind = .unknown + /// Symbol for the current output route, independent of volume level — + /// for the media-output button, which shows *where* audio is going + /// rather than how loud it is. Built-in output reads as the machine + /// itself (a laptop), matching how macOS's own output picker shows it. + func outputRouteSymbol() -> String { + let routeKind = stateQueue.sync { cachedRouteKind } + switch routeKind { + case .airPods: return "airpods" + case .airPodsPro: return "airpodspro" + case .airPodsMax: return "airpodsmax" + case .wiredHeadphones, .bluetoothHeadphones: return "headphones" + case .externalSpeaker: return "hifispeaker" + case .builtInSpeaker, .unknown: return "laptopcomputer" + } + } + func volumeSymbol(for value: CGFloat) -> String { let clampedValue = max(0, min(1, value)) let routeKind = stateQueue.sync { cachedRouteKind } From 4ef3702cc2d3cc60de34f884267882fdc74328a2 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 16:01:19 +0530 Subject: [PATCH 31/69] Make the media-output button actually switch devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AudioRouteManager: enumerates output devices via CoreAudio and sets the system default. Adapted from Atoll's manager of the same name (GPL-3.0, itself a boring.notch fork) — credited in the file. Distinct from the existing AudioOutputRouteResolver, which only classifies the current route into an icon and can't switch anything; that stays as-is and is still the fallback icon before the first enumeration completes. The button now opens a popover listing devices with the active one checked and sorted first, switching on click. Verified against CoreAudio directly rather than assuming: enumeration returns both devices on this machine, the output-stream filter correctly drops the microphone (otherwise the picker would offer a mic as somewhere to send audio), the active device resolves, and its transport type 'bltn' maps to the laptopcomputer icon that matches Atoll's screenshot. Devices are enumerated when the popover opens rather than polled — AirPods connect and displays wake, so a list built at launch would be stale by the time anyone opened it. --- boringNotch.xcodeproj/project.pbxproj | 4 + .../components/Notch/CompactHomeView.swift | 82 ++++++- boringNotch/managers/AudioRouteManager.swift | 207 ++++++++++++++++++ 3 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 boringNotch/managers/AudioRouteManager.swift diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 7d251da97..d7da4392d 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -300,6 +300,8 @@ 1443E7F42C609E650027C1FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 147163972C5D35B70068B555 /* MusicVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicVisualizer.swift; sourceTree = ""; }; 147163992C5D35FF0068B555 /* MusicManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicManager.swift; sourceTree = ""; }; + AA07ARM22E7A0001 /* AudioRouteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouteManager.swift; sourceTree = ""; }; + AA07ARM12E7A0001 /* AudioRouteManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA07ARM22E7A0001 /* AudioRouteManager.swift */; }; 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringNotchWindow.swift; sourceTree = ""; }; 149E0B962C737D00006418B1 /* WebcamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamManager.swift; sourceTree = ""; }; 149E0B992C737D40006418B1 /* WebcamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamView.swift; sourceTree = ""; }; @@ -660,6 +662,7 @@ AA01SNM22E7A0001 /* SystemNotificationManager.swift */, AA02CAM22E7A0001 /* ContactAvatarManager.swift */, 147163992C5D35FF0068B555 /* MusicManager.swift */, + AA07ARM22E7A0001 /* AudioRouteManager.swift */, AA05SRM22E7A0001 /* SmartReplyManager.swift */, F1F2A0A200000000000000F2 /* AudioCaptureManager.swift */, 149E0B962C737D00006418B1 /* WebcamManager.swift */, @@ -1136,6 +1139,7 @@ AA02CAM12E7A0001 /* ContactAvatarManager.swift in Sources */, AA03OTP12E7A0001 /* OTPDetector.swift in Sources */, 1471639A2C5D35FF0068B555 /* MusicManager.swift in Sources */, + AA07ARM12E7A0001 /* AudioRouteManager.swift in Sources */, AA05SRM12E7A0001 /* SmartReplyManager.swift in Sources */, F1F2A0A100000000000000F1 /* AudioCaptureManager.swift in Sources */, B1B112932C6A577E00093D8F /* MouseTracker.swift in Sources */, diff --git a/boringNotch/components/Notch/CompactHomeView.swift b/boringNotch/components/Notch/CompactHomeView.swift index f04d7e1d7..741014522 100644 --- a/boringNotch/components/Notch/CompactHomeView.swift +++ b/boringNotch/components/Notch/CompactHomeView.swift @@ -31,6 +31,8 @@ struct CompactHomeView: View { @State private var sliderValue: Double = 0 @State private var dragging: Bool = false @State private var lastDragged: Date = .distantPast + @State private var showingOutputPicker = false + @ObservedObject private var routeManager = AudioRouteManager.shared @Default(.coloredSpectrogram) private var coloredSpectrogram @Default(.playerColorTinting) private var playerColorTinting @@ -185,18 +187,21 @@ struct CompactHomeView: View { } } - /// Shows where audio is currently going and opens Sound settings. - /// - /// Atoll's equivalent opens a popover that switches device inline, but - /// that needs its AudioRouteManager (enumeration + switching), which - /// this project doesn't have — AudioOutputRouteResolver only classifies - /// the current route. Handing off to Sound settings is honest about - /// that rather than faking a picker that can't switch anything. + /// Shows where audio is going and switches it, via a popover device + /// picker. private var mediaOutputButton: some View { HoverButton(icon: routeSymbol, scale: .medium) { - if let url = URL(string: "x-apple.systempreferences:com.apple.Sound-Settings.extension") { - NSWorkspace.shared.open(url) - } + // Enumerate on open rather than polling: devices come and go + // (AirPods connecting, a display waking) and a list built at + // launch would be stale by the time anyone opened it. + routeManager.refreshDevices() + showingOutputPicker.toggle() + } + .popover(isPresented: $showingOutputPicker, arrowEdge: .bottom) { + AudioOutputPicker( + routeManager: routeManager, + onSelect: { showingOutputPicker = false } + ) } } @@ -222,8 +227,10 @@ struct CompactHomeView: View { .frame(width: albumArtWidth, height: albumArtWidth) } + /// Prefer the live device's own icon; fall back to the resolver's + /// classification before the first enumeration has run. private var routeSymbol: String { - AudioOutputRouteResolver.shared.outputRouteSymbol() + routeManager.activeDevice?.iconName ?? AudioOutputRouteResolver.shared.outputRouteSymbol() } private var repeatIcon: String { @@ -246,3 +253,56 @@ struct CompactHomeView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } + +/// Output device list for the compact player's media-output button. +struct AudioOutputPicker: View { + @ObservedObject var routeManager: AudioRouteManager + let onSelect: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Output") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 6) + + if routeManager.devices.isEmpty { + // Enumeration is async, so an empty list on first open is + // normal rather than an error worth alarming anyone about. + Text("Looking for devices…") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.bottom, 10) + } else { + ForEach(routeManager.devices) { device in + Button { + routeManager.select(device) + onSelect() + } label: { + HStack(spacing: 8) { + Image(systemName: device.iconName) + .frame(width: 18) + Text(device.name) + .font(.system(size: 12)) + .lineLimit(1) + Spacer(minLength: 12) + if device.id == routeManager.activeDeviceID { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .bold)) + } + } + .contentShape(Rectangle()) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + } + .padding(.bottom, 6) + } + } + .frame(minWidth: 220) + } +} diff --git a/boringNotch/managers/AudioRouteManager.swift b/boringNotch/managers/AudioRouteManager.swift new file mode 100644 index 000000000..9b94c5702 --- /dev/null +++ b/boringNotch/managers/AudioRouteManager.swift @@ -0,0 +1,207 @@ +// +// AudioRouteManager.swift +// boringNotch +// +// Lists the Mac's audio output devices and switches the system default +// between them, for the compact player's media-output button. +// +// Adapted from Atoll's AudioRouteManager +// (https://github.com/Ebullioscopic/Atoll, GPL-3.0, itself a boring.notch +// fork). +// +// Distinct from AudioOutputRouteResolver, which only classifies the +// *current* route into an icon for the OSD. This one enumerates every +// device and can change which is active. +// + +import Combine +import CoreAudio +import Foundation + +struct AudioOutputDevice: Identifiable, Equatable { + let id: AudioDeviceID + let name: String + let transportType: UInt32 + + /// Name first, transport second: a name match is more specific than the + /// transport ("AirPods Pro" over Bluetooth beats a generic headphones + /// glyph), and matches how macOS's own output menu labels things. + var iconName: String { + let normalized = name.lowercased() + + if normalized.contains("airpods max") { return "airpodsmax" } + if normalized.contains("airpods pro") { return "airpodspro" } + if normalized.contains("airpods") { return "airpods" } + if normalized.contains("macbook") { return "laptopcomputer" } + if normalized.contains("homepod") { return "homepod" } + if normalized.contains("headphone") || normalized.contains("headset") || normalized.contains("beats") { + return "headphones" + } + if normalized.contains("display") || normalized.contains("monitor") { return "display" } + + switch transportType { + case kAudioDeviceTransportTypeBluetooth, kAudioDeviceTransportTypeBluetoothLE: + return normalized.contains("speaker") ? "hifispeaker" : "headphones" + case kAudioDeviceTransportTypeAirPlay: + return "airplayaudio" + case kAudioDeviceTransportTypeDisplayPort, kAudioDeviceTransportTypeHDMI: + return "tv" + case kAudioDeviceTransportTypeUSB: + return "hifispeaker" + case kAudioDeviceTransportTypeBuiltIn: + return "laptopcomputer" + default: + return "speaker.wave.2" + } + } +} + +@MainActor +final class AudioRouteManager: ObservableObject { + static let shared = AudioRouteManager() + + @Published private(set) var devices: [AudioOutputDevice] = [] + @Published private(set) var activeDeviceID: AudioDeviceID = 0 + + var activeDevice: AudioOutputDevice? { + devices.first { $0.id == activeDeviceID } + } + + /// CoreAudio property reads block, so they stay off the main thread — + /// the picker opens from a click and shouldn't stutter the notch. + private let queue = DispatchQueue(label: "boringNotch.AudioRouteManager") + + private init() {} + + func refreshDevices() { + queue.async { [weak self] in + guard let self else { return } + let defaultID = Self.fetchDefaultOutputDevice() + let found = Self.fetchOutputDeviceIDs().compactMap(Self.makeDevice) + // Active device first, then alphabetical — the one you're using + // is the one you're most likely looking for. + let sorted = found.sorted { lhs, rhs in + if lhs.id == defaultID { return true } + if rhs.id == defaultID { return false } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + Task { @MainActor in + self.activeDeviceID = defaultID + self.devices = sorted + } + } + } + + func select(_ device: AudioOutputDevice) { + queue.async { [weak self] in + guard Self.setDefaultOutputDevice(device.id) else { return } + Task { @MainActor in + self?.activeDeviceID = device.id + self?.refreshDevices() + } + } + } + + // MARK: - CoreAudio + + private static func fetchDefaultOutputDevice() -> AudioDeviceID { + var deviceID = AudioDeviceID() + var size = UInt32(MemoryLayout.size) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID + ) + return status == noErr ? deviceID : 0 + } + + @discardableResult + private static func setDefaultOutputDevice(_ deviceID: AudioDeviceID) -> Bool { + var target = deviceID + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + return AudioObjectSetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, 0, nil, + UInt32(MemoryLayout.size), + &target + ) == noErr + } + + private static func fetchOutputDeviceIDs() -> [AudioDeviceID] { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize( + AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size + ) == noErr else { return [] } + + var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout.size) + guard AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &ids + ) == noErr else { return [] } + + // Every device is returned, inputs included — keep only the ones + // that actually have output streams, or the picker would offer + // microphones as places to send audio. + return ids.filter(hasOutputStreams) + } + + private static func hasOutputStreams(_ deviceID: AudioDeviceID) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreams, + mScope: kAudioDevicePropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain + ) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size) == noErr else { + return false + } + return size > 0 + } + + private static func makeDevice(_ deviceID: AudioDeviceID) -> AudioOutputDevice? { + guard let name = stringProperty(deviceID, kAudioObjectPropertyName), !name.isEmpty else { + return nil + } + return AudioOutputDevice(id: deviceID, name: name, transportType: transportType(deviceID)) + } + + private static func stringProperty(_ deviceID: AudioDeviceID, _ selector: AudioObjectPropertySelector) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var size = UInt32(MemoryLayout.size) + var value: CFString? + let status = withUnsafeMutablePointer(to: &value) { pointer in + AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, pointer) + } + guard status == noErr else { return nil } + return value as String? + } + + private static func transportType(_ deviceID: AudioDeviceID) -> UInt32 { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyTransportType, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var value: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &value) == noErr else { + return 0 + } + return value + } +} From 0f34d35ddf943231920ce16bd9d0024e39cf8599 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 16:09:54 +0530 Subject: [PATCH 32/69] Compact mode: match Atoll's actual dimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two numbers were wrong, and together they made the panel read sparse. Height was 180 — Atoll's minimalisticBaseOpenNotchSize. That constant is its window allowance, not the panel: calculateDynamicHeight() sums 50 header + 6+4 progress + 54+2 controls + 15 top + 3 bottom = 134. The extra 46pt was empty space the content floated in. Controls were HoverButton's 30/40pt. Atoll uses 36pt secondary buttons with 18pt glyphs and a 54pt play/pause with a 26pt glyph — noticeably larger, which is what gives the row its weight against 50pt artwork. Added CompactControlButton for that, mirroring their squircle-fills-on- hover treatment rather than HoverButton's capsule. Padding now follows their formula exactly: 15 top, 3 bottom, 6 before the progress row, 2 before the controls. --- boringNotch/ContentView.swift | 6 +- boringNotch/Localizable.xcstrings | 6 ++ .../components/Notch/CompactHomeView.swift | 89 ++++++++++++++++--- 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index fc21cc2b4..85c1027b6 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -114,7 +114,11 @@ struct ContentView: View { /// just surrounds two lines of text with empty black. private var openNotchHeight: CGFloat { if notificationManager.activeNotification != nil { return 132 } - return Defaults[.compactMode] ? 180 : vm.notchSize.height + // 134pt is Atoll's own content height for this layout (50 header + + // 10 progress + 56 controls + 15/3 padding). The 420x180 constant is + // its window allowance, not the panel — using it left the content + // floating in empty space. + return Defaults[.compactMode] ? 134 : vm.notchSize.height } /// Compact mode drops the tab bar along with the tabs it switches diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index 6f78f907f..e032a399d 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -12459,6 +12459,9 @@ } } } + }, + "Looking for devices…" : { + }, "Low" : { "localizations" : { @@ -17375,6 +17378,9 @@ } } } + }, + "Output" : { + }, "Pick a Color" : { "localizations" : { diff --git a/boringNotch/components/Notch/CompactHomeView.swift b/boringNotch/components/Notch/CompactHomeView.swift index 741014522..84bed8610 100644 --- a/boringNotch/components/Notch/CompactHomeView.swift +++ b/boringNotch/components/Notch/CompactHomeView.swift @@ -56,11 +56,11 @@ struct CompactHomeView: View { .padding(.top, 6) transport - .padding(.top, 4) + .padding(.top, 2) } .padding(.horizontal, 12) - .padding(.top, 6) - .padding(.bottom, 10) + .padding(.top, 15) + .padding(.bottom, 3) .frame(maxWidth: .infinity) .buttonStyle(PlainButtonStyle()) } @@ -145,6 +145,29 @@ struct CompactHomeView: View { } } .frame(maxWidth: .infinity, alignment: .center) + .frame(height: playPauseSize) + } + + /// Atoll's control dimensions: 36pt secondary buttons with 18pt glyphs, + /// a 54pt play/pause with a 26pt glyph. HoverButton's 30/40 is what made + /// this row read undersized against the rest of the panel. + private let controlSize: CGFloat = 36 + private let playPauseSize: CGFloat = 54 + + private func compactControl( + icon: String, + size: CGFloat, + glyph: CGFloat, + tint: Color = .white, + action: @escaping () -> Void + ) -> some View { + CompactControlButton( + icon: icon, + frameSize: size, + glyphSize: glyph, + tint: tint, + action: action + ) } /// Fixed five, deliberately not the musicControlSlots preference. That @@ -159,23 +182,28 @@ struct CompactHomeView: View { private func slotView(for slot: MusicControlButton) -> some View { switch slot { case .shuffle: - HoverButton(icon: "shuffle", iconColor: musicManager.isShuffled ? .red : .primary, scale: .medium) { - MusicManager.shared.toggleShuffle() - } + compactControl( + icon: "shuffle", + size: controlSize, + glyph: 18, + tint: musicManager.isShuffled ? .red : .white + ) { MusicManager.shared.toggleShuffle() } case .previous: - HoverButton(icon: "backward.fill", scale: .medium) { + compactControl(icon: "backward.fill", size: controlSize, glyph: 18) { MusicManager.shared.previousTrack() } case .playPause: - HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) { - MusicManager.shared.togglePlay() - } + compactControl( + icon: musicManager.isPlaying ? "pause.fill" : "play.fill", + size: playPauseSize, + glyph: 26 + ) { MusicManager.shared.togglePlay() } case .next: - HoverButton(icon: "forward.fill", scale: .medium) { + compactControl(icon: "forward.fill", size: controlSize, glyph: 18) { MusicManager.shared.nextTrack() } case .repeatMode: - HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) { + compactControl(icon: repeatIcon, size: controlSize, glyph: 18, tint: repeatIconColor) { MusicManager.shared.toggleRepeat() } case .none: @@ -190,7 +218,7 @@ struct CompactHomeView: View { /// Shows where audio is going and switches it, via a popover device /// picker. private var mediaOutputButton: some View { - HoverButton(icon: routeSymbol, scale: .medium) { + compactControl(icon: routeSymbol, size: controlSize, glyph: 18) { // Enumerate on open rather than polling: devices come and go // (AirPods connecting, a display waking) and a list built at // launch would be stale by the time anyone opened it. @@ -306,3 +334,38 @@ struct AudioOutputPicker: View { .frame(minWidth: 220) } } + +/// Transport button matching Atoll's MinimalisticSquircircleButton: a +/// squircle that fills faintly on hover, sized independently of its glyph +/// so a 54pt play/pause and a 36pt skip share the same visual language. +/// +/// Not HoverButton — that's fixed at 30/40pt with a capsule fill, which +/// reads undersized against this layout's 50pt artwork. +private struct CompactControlButton: View { + let icon: String + let frameSize: CGFloat + let glyphSize: CGFloat + let tint: Color + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + RoundedRectangle(cornerRadius: frameSize * 0.4, style: .continuous) + .fill(isHovering ? Color.white.opacity(0.12) : .clear) + .frame(width: frameSize, height: frameSize) + .overlay { + Image(systemName: icon) + .font(.system(size: glyphSize, weight: .medium)) + .foregroundStyle(tint) + .contentTransition(.symbolEffect(.replace)) + } + .contentShape(RoundedRectangle(cornerRadius: frameSize * 0.4, style: .continuous)) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.18)) { isHovering = hovering } + } + } +} From f56fed8abf7008f118d3652b5318da52029fd358 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 16:17:40 +0530 Subject: [PATCH 33/69] Compact mode: pin panel to Atoll's 420x180 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Width was maxWidth: 420, so the content sized itself to ~350 and never reached it. Fixed width instead. Height measured 189 rather than the intended 180. Atoll's 15/3 padding formula assumes the player is the whole panel; here a 38pt notch-clearance spacer sits above it, so their numbers overshot by 9. Trimmed the player's own top/bottom padding to 8/1 so the total lands on 180 — matching the dimension that actually shows, rather than an internal padding value that doesn't. --- boringNotch/ContentView.swift | 7 ++++--- boringNotch/components/Notch/CompactHomeView.swift | 9 +++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 85c1027b6..c5286cd29 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -503,10 +503,11 @@ struct ContentView: View { // Player only — no tab switching, so currentView is // ignored here rather than offering a shelf the // compact layout has no room (or tab bar) for. - // 420pt wide matches Atoll's minimalistic base size, - // which these proportions were designed against. + // Fixed 420 rather than maxWidth: the content sizes + // itself to ~350 otherwise, which left the panel + // narrower than Atoll's. This pins it to their width. CompactHomeView(albumArtNamespace: albumArtNamespace) - .frame(maxWidth: 420) + .frame(width: 420) } else { switch coordinator.currentView { case .home: diff --git a/boringNotch/components/Notch/CompactHomeView.swift b/boringNotch/components/Notch/CompactHomeView.swift index 84bed8610..c37452ab0 100644 --- a/boringNotch/components/Notch/CompactHomeView.swift +++ b/boringNotch/components/Notch/CompactHomeView.swift @@ -59,8 +59,13 @@ struct CompactHomeView: View { .padding(.top, 2) } .padding(.horizontal, 12) - .padding(.top, 15) - .padding(.bottom, 3) + // Atoll's formula is 15/3, but that assumes the player is the + // whole panel. Here a 38pt notch-clearance spacer sits above it, + // so keeping 15/3 pushed the total to 189. Trimmed by 9 to land + // the panel on Atoll's 180 overall, which is the number that + // actually shows. + .padding(.top, 8) + .padding(.bottom, 1) .frame(maxWidth: .infinity) .buttonStyle(PlainButtonStyle()) } From 21384d8e38f7637447a94c4969077007f47cfc5a Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 16:19:23 +0530 Subject: [PATCH 34/69] Compact mode: narrow the panel by 20% to 336pt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 420 less 20%. Also lands within a few points of Atoll's own Dynamic Island width (340) — the tighter of their two compact sizes — so it's a size that layout was already designed to hold. The title column is computed from available width rather than fixed, so it absorbs the change: ~200pt at this width, still comfortably wider than the marquee needs before it starts scrolling. --- boringNotch/ContentView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index c5286cd29..60a30f05b 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -503,11 +503,11 @@ struct ContentView: View { // Player only — no tab switching, so currentView is // ignored here rather than offering a shelf the // compact layout has no room (or tab bar) for. - // Fixed 420 rather than maxWidth: the content sizes - // itself to ~350 otherwise, which left the panel - // narrower than Atoll's. This pins it to their width. + // 336 = Atoll's 420 base less 20%, which also lands + // within a few points of their Dynamic Island width + // (340) — the tighter of their two compact sizes. CompactHomeView(albumArtNamespace: albumArtNamespace) - .frame(width: 420) + .frame(width: 336) } else { switch coordinator.currentView { case .home: From b3c89d2518e2e8833e13b32733068c33dd3b74e3 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 16:25:02 +0530 Subject: [PATCH 35/69] Add shuffle/audio output to both layouts; fix compact hover collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Media output is now a real MusicControlButton case rather than the .none slot compact mode was overloading, so it appears in the settings picker and works in the standard layout too. defaultLayout becomes [shuffle, previous, playPause, next, mediaOutput] — the old default left two empty slots, which is why a fresh install showed three transport buttons with dead space either side. Fixed a hover bug I introduced with the height change: setting the compact frame to a fixed 118pt while the content is ~153pt (38pt notch spacer + player) left the transport row outside the frame. That frame bounds hit-testing as well as drawing, so moving toward the buttons registered as a hover-exit and closed the notch. Compact now sizes to its content; its height is governed by its own padding, which is the real lever regardless. Also: rounder opened corners in compact (35 vs 19), matching Atoll's separate minimalisticCornerRadiusInsets — at this size the standard radius reads square rather than pill-like. Components trimmed ~10% (45pt art, 32/48pt controls) and the battery indicator is back, overlaid top-right since compact hides BoringHeader (which spans the full notch width) and took the battery with it. --- boringNotch/ContentView.swift | 28 +++++-- .../components/Notch/CompactHomeView.swift | 83 +++++++++++++++---- .../components/Notch/NotchHomeView.swift | 2 + boringNotch/models/MusicControlButton.swift | 17 +++- boringNotch/sizing/matters.swift | 7 ++ 5 files changed, 107 insertions(+), 30 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 60a30f05b..235f57092 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -55,10 +55,16 @@ struct ContentView: View { return effectiveHeight / 38.0 } + /// Compact mode gets a rounder opened shape (35 vs 19) — at its smaller + /// size the standard radius reads square rather than pill-like. + private var openedInsets: (top: CGFloat, bottom: CGFloat) { + Defaults[.compactMode] ? compactCornerRadiusInsets.opened : cornerRadiusInsets.opened + } + private var topCornerRadius: CGFloat { // If the notch is open, return the opened radius. if vm.notchState == .open { - return cornerRadiusInsets.opened.top + return openedInsets.top } // For the closed notch, scale if enabled @@ -75,7 +81,7 @@ struct ContentView: View { let bottomCorner: CGFloat if vm.notchState == .open { - bottomCorner = cornerRadiusInsets.opened.bottom + bottomCorner = openedInsets.bottom } else if let scaleFactor = cornerRadiusScaleFactor { bottomCorner = max(0, baseClosedBottom * scaleFactor) } else { @@ -112,13 +118,17 @@ struct ContentView: View { /// A notification is a glance, not a workspace — it doesn't need the full /// height the home/shelf tabs are sized for, and stretching to fill it /// just surrounds two lines of text with empty black. - private var openNotchHeight: CGFloat { + /// nil means "size to content". + /// + /// Compact mode must use nil: this frame bounds hit-testing as well as + /// layout, so any value shorter than the content leaves the transport + /// row outside the hover region — moving toward the buttons registered + /// as a hover-exit and closed the notch. The compact panel's height is + /// controlled by its own internal padding instead, which is the honest + /// lever anyway. + private var openNotchHeight: CGFloat? { if notificationManager.activeNotification != nil { return 132 } - // 134pt is Atoll's own content height for this layout (50 header + - // 10 progress + 56 controls + 15/3 padding). The 420x180 constant is - // its window allowance, not the panel — using it left the content - // floating in empty space. - return Defaults[.compactMode] ? 134 : vm.notchSize.height + return Defaults[.compactMode] ? nil : vm.notchSize.height } /// Compact mode drops the tab bar along with the tabs it switches @@ -193,7 +203,7 @@ struct ContentView: View { .frame(alignment: .top) .padding( .horizontal, - vm.notchState == .open ? cornerRadiusInsets.opened.top : cornerRadiusInsets.closed.bottom + vm.notchState == .open ? openedInsets.top : cornerRadiusInsets.closed.bottom ) .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) .background(.black) diff --git a/boringNotch/components/Notch/CompactHomeView.swift b/boringNotch/components/Notch/CompactHomeView.swift index c37452ab0..6dae633f1 100644 --- a/boringNotch/components/Notch/CompactHomeView.swift +++ b/boringNotch/components/Notch/CompactHomeView.swift @@ -26,6 +26,7 @@ import SwiftUI struct CompactHomeView: View { @EnvironmentObject var vm: BoringViewModel @ObservedObject var musicManager = MusicManager.shared + @ObservedObject var batteryModel = BatteryStatusViewModel.shared let albumArtNamespace: Namespace.ID @State private var sliderValue: Double = 0 @@ -37,7 +38,7 @@ struct CompactHomeView: View { @Default(.coloredSpectrogram) private var coloredSpectrogram @Default(.playerColorTinting) private var playerColorTinting - private let albumArtWidth: CGFloat = 50 + private let albumArtWidth: CGFloat = 45 private let headerSpacing: CGFloat = 10 /// Matches the trailing time label's width in the row below, so the /// visualizer's bars sit centred over "-0:00" rather than drifting. @@ -53,10 +54,10 @@ struct CompactHomeView: View { .frame(height: albumArtWidth) progressRow - .padding(.top, 6) + .padding(.top, 4) transport - .padding(.top, 2) + .padding(.top, 1) } .padding(.horizontal, 12) // Atoll's formula is 15/3, but that assumes the player is the @@ -64,7 +65,7 @@ struct CompactHomeView: View { // so keeping 15/3 pushed the total to 189. Trimmed by 9 to land // the panel on Atoll's 180 overall, which is the number that // actually shows. - .padding(.top, 8) + .padding(.top, 4) .padding(.bottom, 1) .frame(maxWidth: .infinity) .buttonStyle(PlainButtonStyle()) @@ -114,6 +115,26 @@ struct CompactHomeView: View { .frame(width: vizBlockWidth) } } + .overlay(alignment: .topTrailing) { + // Compact mode hides BoringHeader (it spans the full notch + // width), which took the battery with it. Overlaid rather than + // placed in the HStack so it doesn't steal width from the title. + if Defaults[.showBatteryIndicator] { + BoringBatteryView( + batteryWidth: 24, + isCharging: batteryModel.isCharging, + isInLowPowerMode: batteryModel.isInLowPowerMode, + isPluggedIn: batteryModel.isPluggedIn, + levelBattery: batteryModel.levelBattery, + maxCapacity: batteryModel.maxCapacity, + timeToFullCharge: batteryModel.timeToFullCharge, + timeToDischarge: batteryModel.timeToDischarge, + maxAdapterWatts: batteryModel.maxAdapterWatts, + isForNotification: false + ) + .offset(y: -14) + } + } } // MARK: - Progress @@ -156,8 +177,8 @@ struct CompactHomeView: View { /// Atoll's control dimensions: 36pt secondary buttons with 18pt glyphs, /// a 54pt play/pause with a 26pt glyph. HoverButton's 30/40 is what made /// this row read undersized against the rest of the panel. - private let controlSize: CGFloat = 36 - private let playPauseSize: CGFloat = 54 + private let controlSize: CGFloat = 32 + private let playPauseSize: CGFloat = 48 private func compactControl( icon: String, @@ -175,12 +196,11 @@ struct CompactHomeView: View { ) } - /// Fixed five, deliberately not the musicControlSlots preference. That - /// preference defaults to [.none, .previous, .playPause, .next, .none], - /// which is why this row was rendering only three buttons — compact - /// mode is meant to show shuffle and media output too. + /// Fixed five rather than the musicControlSlots preference, so compact + /// mode always shows the full transport regardless of how the standard + /// layout is configured. private var displayedSlots: [MusicControlButton] { - [.shuffle, .previous, .playPause, .next, .none] + [.shuffle, .previous, .playPause, .next, .mediaOutput] } @ViewBuilder @@ -190,29 +210,31 @@ struct CompactHomeView: View { compactControl( icon: "shuffle", size: controlSize, - glyph: 18, + glyph: 16, tint: musicManager.isShuffled ? .red : .white ) { MusicManager.shared.toggleShuffle() } case .previous: - compactControl(icon: "backward.fill", size: controlSize, glyph: 18) { + compactControl(icon: "backward.fill", size: controlSize, glyph: 16) { MusicManager.shared.previousTrack() } case .playPause: compactControl( icon: musicManager.isPlaying ? "pause.fill" : "play.fill", size: playPauseSize, - glyph: 26 + glyph: 23 ) { MusicManager.shared.togglePlay() } case .next: - compactControl(icon: "forward.fill", size: controlSize, glyph: 18) { + compactControl(icon: "forward.fill", size: controlSize, glyph: 16) { MusicManager.shared.nextTrack() } case .repeatMode: - compactControl(icon: repeatIcon, size: controlSize, glyph: 18, tint: repeatIconColor) { + compactControl(icon: repeatIcon, size: controlSize, glyph: 16, tint: repeatIconColor) { MusicManager.shared.toggleRepeat() } - case .none: + case .mediaOutput: mediaOutputButton + case .none: + EmptyView() default: // Slots that only make sense in the full layout are skipped // rather than rendered half-working in a player-only view. @@ -223,7 +245,7 @@ struct CompactHomeView: View { /// Shows where audio is going and switches it, via a popover device /// picker. private var mediaOutputButton: some View { - compactControl(icon: routeSymbol, size: controlSize, glyph: 18) { + compactControl(icon: routeSymbol, size: controlSize, glyph: 16) { // Enumerate on open rather than polling: devices come and go // (AirPods connecting, a display waking) and a list built at // launch would be stale by the time anyone opened it. @@ -374,3 +396,28 @@ private struct CompactControlButton: View { } } } + +/// Audio-output slot for the standard layout's control row. +/// +/// Separate from CompactHomeView's inline version only because that one +/// uses the compact button sizing; the picker and behaviour are shared. +struct MediaOutputSlotButton: View { + @ObservedObject private var routeManager = AudioRouteManager.shared + @State private var showingPicker = false + + var body: some View { + HoverButton(icon: routeSymbol, scale: .medium) { + routeManager.refreshDevices() + showingPicker.toggle() + } + .popover(isPresented: $showingPicker, arrowEdge: .bottom) { + AudioOutputPicker(routeManager: routeManager) { + showingPicker = false + } + } + } + + private var routeSymbol: String { + routeManager.activeDevice?.iconName ?? AudioOutputRouteResolver.shared.outputRouteSymbol() + } +} diff --git a/boringNotch/components/Notch/NotchHomeView.swift b/boringNotch/components/Notch/NotchHomeView.swift index ac25ce994..c682c440b 100644 --- a/boringNotch/components/Notch/NotchHomeView.swift +++ b/boringNotch/components/Notch/NotchHomeView.swift @@ -276,6 +276,8 @@ struct MusicControlsView: View { HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) { MusicManager.shared.toggleRepeat() } + case .mediaOutput: + MediaOutputSlotButton() case .volume: VolumeControlView() case .favorite: diff --git a/boringNotch/models/MusicControlButton.swift b/boringNotch/models/MusicControlButton.swift index 430ea6430..2547c1040 100644 --- a/boringNotch/models/MusicControlButton.swift +++ b/boringNotch/models/MusicControlButton.swift @@ -17,16 +17,20 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S case favorite case goBackward case goForward + case mediaOutput case none var id: String { rawValue } + /// Shuffle and media output round out the default row. The previous + /// default left two empty slots, so a fresh install showed only three + /// transport buttons with dead space either side. static let defaultLayout: [MusicControlButton] = [ - .none, + .shuffle, .previous, .playPause, .next, - .none + .mediaOutput ] static let minSlotCount: Int = 3 @@ -41,7 +45,8 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S .favorite, .volume, .goBackward, - .goForward + .goForward, + .mediaOutput ] var label: String { @@ -64,6 +69,8 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S return "Backward 15s" case .goForward: return "Forward 15s" + case .mediaOutput: + return "Audio output" case .none: return "Empty slot" } @@ -89,6 +96,10 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S return "gobackward.15" case .goForward: return "goforward.15" + case .mediaOutput: + // Placeholder for the settings picker; the live button swaps in + // the actual route's glyph (laptop / headphones / AirPods). + return "laptopcomputer" case .none: return "" } diff --git a/boringNotch/sizing/matters.swift b/boringNotch/sizing/matters.swift index dacd078cf..ff5a90636 100644 --- a/boringNotch/sizing/matters.swift +++ b/boringNotch/sizing/matters.swift @@ -17,6 +17,13 @@ let openNotchSize: CGSize = .init(width: 640, height: 190) let windowSize: CGSize = .init(width: openNotchSize.width, height: openNotchSize.height + shadowPadding) let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14)) +/// Compact mode uses a much rounder opened shape than the standard layout +/// — matching Atoll's minimalisticCornerRadiusInsets (35/35 against the +/// standard 19/24). At compact's smaller size the standard radius reads +/// square; the rounder corners are what make it look like a pill rather +/// than a shrunken panel. +let compactCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 35, bottom: 35), closed: cornerRadiusInsets.closed) + // Horizontal gap between closed-state live-activity content (album art / waveform) // and the physical notch edge. Without this margin the hardware bezel clips the // adjacent content since the spacer rect used to be narrower than the physical notch. From 027a12e1b599d2637f0231cdbf76d4a624f2a9c4 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 16:26:58 +0530 Subject: [PATCH 36/69] Compact mode keeps cached track state instead of "Nothing Playing" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standard layout has no idle branch — it renders whatever MusicManager last cached, so a paused or stopped track keeps its artwork, title and scrub position. Compact mode had its own idle placeholder, which meant it dropped state the full layout holds onto. Removed, so both behave the same. Settings → Media needed no change for the new controls: the slot palette iterates MusicControlButton.pickerOptions and renders each control's iconName, both of which mediaOutput was already added to. Verified rather than assumed — it shows in the palette with the laptop glyph and can be dragged or tapped into any slot, alongside shuffle which was already there. --- .../components/Notch/CompactHomeView.swift | 55 +++++++------------ 1 file changed, 21 insertions(+), 34 deletions(-) diff --git a/boringNotch/components/Notch/CompactHomeView.swift b/boringNotch/components/Notch/CompactHomeView.swift index 6dae633f1..0f3001a0e 100644 --- a/boringNotch/components/Notch/CompactHomeView.swift +++ b/boringNotch/components/Notch/CompactHomeView.swift @@ -45,31 +45,29 @@ struct CompactHomeView: View { private let vizBlockWidth: CGFloat = 42 private let vizBarWidth: CGFloat = 24 + // No idle branch, deliberately. The standard layout has none either — + // it renders whatever MusicManager last cached, so a paused or stopped + // track keeps its art, title and scrub position. A "Nothing Playing" + // placeholder here made compact mode lose state the full layout keeps. var body: some View { - if !musicManager.isPlaying && musicManager.isPlayerIdle { - idleState - } else { - VStack(spacing: 0) { - header - .frame(height: albumArtWidth) - - progressRow - .padding(.top, 4) - - transport - .padding(.top, 1) - } - .padding(.horizontal, 12) - // Atoll's formula is 15/3, but that assumes the player is the - // whole panel. Here a 38pt notch-clearance spacer sits above it, - // so keeping 15/3 pushed the total to 189. Trimmed by 9 to land - // the panel on Atoll's 180 overall, which is the number that - // actually shows. - .padding(.top, 4) - .padding(.bottom, 1) - .frame(maxWidth: .infinity) - .buttonStyle(PlainButtonStyle()) + VStack(spacing: 0) { + header + .frame(height: albumArtWidth) + + progressRow + .padding(.top, 4) + + transport + .padding(.top, 1) } + .padding(.horizontal, 12) + // Atoll's 15/3 formula assumes the player is the whole panel; here + // a notch-clearance spacer sits above it, so these are trimmed to + // land the panel at the intended overall height. + .padding(.top, 4) + .padding(.bottom, 1) + .frame(maxWidth: .infinity) + .buttonStyle(PlainButtonStyle()) } // MARK: - Header @@ -296,17 +294,6 @@ struct CompactHomeView: View { musicManager.repeatMode == .off ? .primary : .red } - private var idleState: some View { - VStack(spacing: 8) { - Image(systemName: "music.note.slash") - .font(.system(size: 24, weight: .light)) - .foregroundStyle(.gray) - Text("Nothing Playing") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.gray) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } } /// Output device list for the compact player's media-output button. From dd0d3810e555f15e726d7da5bbe262eab1cd9b9a Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 16:58:16 +0530 Subject: [PATCH 37/69] Fix held banner leak that could block the real Notification Center MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit show() overwrote activeNotification directly instead of going through dismissActive, so a superseded notification's hold was never released: dismissActive only releases whatever activeNotification happens to be *when it runs*, and by the time an overwritten notification would have been dismissed, it was already gone from that property. Any time a second notification arrived while the first was still being held (open notch, or within the 8s closed-pill window), the first's banner window was permanently parked off-screen at (-5000,-5000) with nothing left to release it — held/heldOffScreen never lost that token, so the 2.5s refresh loop kept it alive and off-screen indefinitely. Likely why clicking the system clock stopped opening Notification Center: notificationcenterui can reuse a window instance for its own real NC panel, and if that reuse landed on one we'd shoved off-screen and left permanently parked, the panel would "open" invisibly. Fixed by releasing the previous active notification's hold in show() before the new one replaces it. Also clears held/heldOffScreen in NotificationWatcher.stop() — not the live-leak fix (the refresh loop already stops there, so anything still held dies on its own within a couple seconds), but stale tokens surviving a stop/restart cycle were still wrong. --- BoringNotchXPCHelper/NotificationWatcher.swift | 8 ++++++++ boringNotch/Localizable.xcstrings | 3 --- .../managers/SystemNotificationManager.swift | 13 +++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index e5e55ffa4..b3e1198b0 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -100,6 +100,14 @@ final class NotificationWatcher { pollTimer = nil appElement = nil live.removeAll() + // Correctness hygiene rather than the fix for a live leak: once + // pollTimer stops, refreshHeldBanners never runs again either, so + // anything still in `held` stops being artificially kept alive and + // dies on its own within a couple of seconds regardless. But + // leaving stale tokens around after a stop/restart cycle is still + // wrong, so clear them explicitly. + held.removeAll() + heldOffScreen.removeAll() } // MARK: - Scanning diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index e032a399d..48b0a0802 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -16117,9 +16117,6 @@ } } } - }, - "Nothing Playing" : { - }, "Notification Debug" : { diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index dbdaf507e..907ab2b5b 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -199,6 +199,19 @@ final class SystemNotificationManager: ObservableObject { } private func show(_ notification: SystemNotification) { + // A newer notification replaces the active one directly here rather + // than going through dismissActive, so its hold was never being + // released: dismissActive only releases whatever activeNotification + // happens to be *when it runs*, and by the time a superseded + // notification would be dismissed it's already been overwritten. + // The result was a parked, off-screen banner window that never came + // back — and since the same window can get reused by + // notificationcenterui for its own real Notification Center panel, + // that panel could end up opening off-screen too, which is why the + // system clock stopped visibly doing anything. + if let previous = activeNotification, previous.id != notification.id { + XPCHelperClient.shared.releaseNotification(token: previous.id) + } withAnimation(.smooth) { activeNotification = notification } dismissTask?.cancel() dismissTask = Task { [weak self] in From 10e6bf0bf33c551a6c6fa8a5f9a0e1b0d2ef571d Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 19:46:21 +0530 Subject: [PATCH 38/69] Fix the inline song-change peek layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects in that path, all specific to the inline sneak peek: - HStack was .top aligned, so the title and artist sat visibly high against the album art next to them, which is vertically centered. - Width was a hard-coded 380. The title sits left of the notch cutout and the artist right of it, separated by a spacer as wide as the notch itself — so on a wider notch there was no budget left for the artist and the labels collided. Now derived from closedNotchSize with a fixed label budget either side. - That hard-coded width also dropped liveActivityEdgeMargin, which the non-peek path includes precisely so content clears the physical bezel. Restored. - No horizontal padding, so labels butted directly against the artwork and the visualizer. Also widens the chin to match while the peek is showing; it was still sized for the un-expanded pill, leaving the hover region narrower than what was actually drawn. --- boringNotch/ContentView.swift | 49 ++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 235f57092..f3e578dcd 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -170,6 +170,12 @@ struct ContentView: View { chinWidth += (2 * max(0, vm.effectiveClosedNotchHeight - 12) + 20) case .music: chinWidth += (2 * max(0, displayClosedNotchHeight - 12) + 20 + 2 * liveActivityEdgeMargin + 2) + // The inline song-change peek widens the pill itself, so the + // chin has to grow with it — otherwise the hover region is + // narrower than what's on screen. + if showingInlineMusicPeek { + chinWidth += 2 * inlineMusicPeekLabelWidth + } } } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] @@ -561,6 +567,32 @@ struct ContentView: View { ) } + /// True while the song-change peek is expanding the closed pill inline. + private var showingInlineMusicPeek: Bool { + coordinator.expandingView.show + && coordinator.expandingView.type == .music + && Defaults[.sneakPeekStyles] == .inline + } + + /// Width of the black centre section of the closed music pill. + /// + /// Derived from the real notch width rather than the previous hard-coded + /// 380. That constant assumed a particular notch size: the title sits + /// left of the cutout and the artist right of it, separated by a spacer + /// as wide as the notch itself, so on a wider notch there was no room + /// left for the artist and the labels collided. Sizing from + /// closedNotchSize keeps a fixed label budget either side whatever the + /// hardware is, and keeps liveActivityEdgeMargin in play so content + /// clears the bezel — the inline path had dropped it entirely. + private var musicActivityCenterWidth: CGFloat { + let margin = vm.closedNotchSize.width - 4 + (2 * liveActivityEdgeMargin) + guard showingInlineMusicPeek else { return margin } + return margin + (2 * inlineMusicPeekLabelWidth) + } + + /// Space reserved for the title (left of the cutout) and artist (right). + private let inlineMusicPeekLabelWidth: CGFloat = 110 + @ViewBuilder func MusicLiveActivity() -> some View { HStack(spacing: 0) { @@ -597,7 +629,10 @@ struct ContentView: View { Rectangle() .fill(.black) .overlay( - HStack(alignment: .top) { + // .center, not .top: the album art beside this is + // vertically centered, so top-aligned labels sat visibly + // high against it. + HStack(alignment: .center) { if coordinator.expandingView.show && coordinator.expandingView.type == .music { @@ -606,7 +641,7 @@ struct ContentView: View { color: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, delayDuration: 0.4, - frameWidth: 100 + frameWidth: inlineMusicPeekLabelWidth ) .opacity( (coordinator.expandingView.show @@ -618,6 +653,7 @@ struct ContentView: View { Text(musicManager.artistName) .lineLimit(1) .truncationMode(.tail) + .frame(width: inlineMusicPeekLabelWidth, alignment: .trailing) .foregroundStyle( Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) @@ -631,14 +667,9 @@ struct ContentView: View { ) } } + .padding(.horizontal, 8) ) - .frame( - width: (coordinator.expandingView.show - && coordinator.expandingView.type == .music - && Defaults[.sneakPeekStyles] == .inline) - ? 380 - : vm.closedNotchSize.width - 4 + (2 * liveActivityEdgeMargin) - ) + .frame(width: musicActivityCenterWidth) HStack { AudioSpectrumView( From 877dbf41707ade59ab98906b4d21bfec58f47fdd Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 21:49:48 +0530 Subject: [PATCH 39/69] Queue notifications that arrive mid-reply instead of replacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing a reply and having a new message yank the notification out from under you loses whatever was typed. Now, while the reply field has focus, incoming notifications queue instead of taking over, and the header shows a "+N" badge for what's waiting — with the app's icon when they're all from the same app, so a burst from one conversation reads as "2 more from WhatsApp" rather than an anonymous count. The queue promotes oldest-first when the reply finishes, so a burst is read in arrival order. Queued notifications keep their banners held: that hold is what makes replying possible past the banner's few seconds on screen, so dropping it would leave them unreplyable by the time they're promoted. That makes the queue a resource, not just a list — so it's capped at 5, and every path that drops one (over-cap, stop, clear) releases its hold first. A dropped-but-unreleased entry would leave a window parked off-screen with nothing left to free it. Promotion is skipped while still composing, since the field can hold focus for a beat after a send and promoting there would replace the sent confirmation before it's seen; the isComposingReply didSet covers the case where the notification is dismissed while still focused, which would otherwise stall the queue. --- .../Notch/NotificationLiveActivity.swift | 42 +++++++++++ .../managers/SystemNotificationManager.swift | 71 ++++++++++++++++++- 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index eefbe43fe..74a1d9b9e 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -164,6 +164,7 @@ struct NotificationExpandedView: View { .onDisappear { manager.resumeDismiss() hostWindow?.wantsKeyForTextInput = false + manager.isComposingReply = false endComposing() } // Apply a pending key request once the window resolves — @@ -184,12 +185,16 @@ struct NotificationExpandedView: View { // Both are released in onDisappear instead, which is the // only unambiguous "done" signal. manager.holdActive() + manager.isComposingReply = false return } // The window can only accept keystrokes while it's key — see // BoringNotchSkyLightWindow.wantsKeyForTextInput. hostWindow?.wantsKeyForTextInput = true manager.holdWhileTyping() + // Newer notifications queue instead of replacing this one while + // the field has focus. + manager.isComposingReply = true // Clicking into the field changes window key status, which // rebuilds tracking areas and fires a spurious hover-exit — // that's what closed the notch the instant you tapped the @@ -258,6 +263,8 @@ struct NotificationExpandedView: View { .font(.system(size: 11)) .foregroundStyle(.tertiary) .fixedSize() + + queuedBadge } // Clears the close button, which overlays the card rather than // taking a layout slot — reserved here, on the one row it can @@ -270,6 +277,41 @@ struct NotificationExpandedView: View { /// Deliberately not HoverButton's default 30pt sizing — that reads as a /// full toolbar control; a notification's close button wants to be /// closer to iOS's compact circular dismiss. + /// What's waiting behind this notification. Shows the app's icon when + /// everything queued is from one app, so a burst from one conversation + /// reads as "2 more from WhatsApp" rather than an anonymous count. + @ViewBuilder + private var queuedBadge: some View { + let queued = manager.queued + if !queued.isEmpty { + HStack(spacing: 3) { + if let bundleID = singleQueuedAppBundleID { + AppIcon(for: bundleID) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 11, height: 11) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } + Text("+\(queued.count)") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(0.9)) + } + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.white.opacity(0.14), in: Capsule()) + .transition(.scale.combined(with: .opacity)) + .animation(.smooth(duration: 0.2), value: queued.count) + .help("\(queued.count) more waiting") + } + } + + /// The shared bundle ID when every queued notification came from the + /// same app, else nil. + private var singleQueuedAppBundleID: String? { + let ids = Set(manager.queued.compactMap(\.bundleID)) + return ids.count == 1 ? ids.first : nil + } + private var dismissButton: some View { Button { manager.dismissActive(token: notification.id) diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index 907ab2b5b..e31f2017b 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -75,6 +75,26 @@ final class SystemNotificationManager: ObservableObject { /// The notification the notch is currently showing, if any. @Published var activeNotification: SystemNotification? + /// Notifications that arrived while the user was mid-reply, held back + /// rather than shown. Promoted one at a time once they're done. + @Published private(set) var queued: [SystemNotification] = [] + + /// Set by the reply UI while its field has focus. Newer notifications + /// queue instead of replacing the active one during this — yanking a + /// message out from under someone mid-sentence loses what they typed. + var isComposingReply = false { + didSet { + guard oldValue, !isComposingReply else { return } + // Finished composing and the notification is already gone (sent + // or dismissed) — nothing will promote the queue, so do it here. + if activeNotification == nil { promoteNextQueued() } + } + } + + /// Queued banners are all held alive off-screen, so this can't grow + /// without bound — past a handful, the oldest are dropped and released. + private let queueLimit = 5 + /// How long the closed notch shows a notification before handing the /// pill back to whatever was there before (usually music). Only applies /// while the notch is closed and unhovered — hovering or opening it @@ -125,6 +145,17 @@ final class SystemNotificationManager: ObservableObject { func stop() { XPCHelperClient.shared.stopNotificationWatching() isWatching = false + releaseQueued() + } + + /// Frees every queued notification's held banner. Anything still queued + /// is holding a parked, off-screen window, so dropping the queue without + /// this would strand them. + private func releaseQueued() { + for notification in queued { + XPCHelperClient.shared.releaseNotification(token: notification.id) + } + queued.removeAll() } // MARK: - Incoming banners @@ -157,9 +188,38 @@ final class SystemNotificationManager: ObservableObject { NSLog("[boringNotch] filtered out: \(notification.appName ?? "-") bundle=\(notification.bundleID ?? "nil")") return } - NSLog("[boringNotch] showing in notch: \(notification.appName ?? "-")") - show(notification) + + // Hold the banner either way: a queued notification still needs its + // reply field alive for when it's promoted, and holding is what + // keeps that possible past the banner's few seconds on screen. holdSystemBanner(notification) + + if isComposingReply, activeNotification != nil { + enqueue(notification) + return + } + + show(notification) + } + + private func enqueue(_ notification: SystemNotification) { + queued.removeAll { $0.id == notification.id } + queued.append(notification) + + // Oldest out first. Their held banners are released on the way, or + // they'd stay parked off-screen with nothing left to free them. + while queued.count > queueLimit { + let dropped = queued.removeFirst() + XPCHelperClient.shared.releaseNotification(token: dropped.id) + } + } + + /// Shows the oldest queued notification, if any. Oldest first so a burst + /// is read in the order it arrived. + private func promoteNextQueued() { + guard !queued.isEmpty else { return } + let next = queued.removeFirst() + show(next) } /// Holds the system banner open for as long as the notch is showing the @@ -234,6 +294,12 @@ final class SystemNotificationManager: ObservableObject { XPCHelperClient.shared.releaseNotification(token: id) } withAnimation(.smooth) { activeNotification = nil } + + // Anything that queued up behind a reply gets its turn now. Skipped + // while still composing — the reply field can be focused for a beat + // after a send, and promoting there would replace the "sent" + // confirmation before it's been seen. + if !isComposingReply { promoteNextQueued() } } /// Keeps the notification up for as long as the reply field is being @@ -288,6 +354,7 @@ final class SystemNotificationManager: ObservableObject { func clear() { notifications.removeAll() + releaseQueued() dismissActive() } From a029b92c81521c68d2e13b9c29982201d4a3f384 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Wed, 12 Aug 2026 21:51:39 +0530 Subject: [PATCH 40/69] Scale the battery fill with its width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fill height was `(batteryWidth - 2.75) - 18`. The -18 is absolute, so the formula only lands correctly at the 30pt the standard layout passes; compact mode's 24pt collapsed it to a 3.25pt sliver floating inside the outline. The charging bolt was likewise pinned at 17x17. The outline itself is an SF Symbol scaled by width, so everything drawn inside it has to scale by width too. Expressed relative to a 30pt reference, which keeps that case numerically identical (verified: 9.25 before and after) while fixing every other size — 24pt now yields 7.40 rather than 3.25. Worth noting 26pt was broken too, and that's BoringBatteryView's own default parameter, so any caller not passing 30 explicitly was already drawing a too-short fill. --- .../Live activities/BoringBattery.swift | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/boringNotch/components/Live activities/BoringBattery.swift b/boringNotch/components/Live activities/BoringBattery.swift index e86ba33b4..55ac01927 100644 --- a/boringNotch/components/Live activities/BoringBattery.swift +++ b/boringNotch/components/Live activities/BoringBattery.swift @@ -39,6 +39,25 @@ struct BatteryView: View { } } + /// The outline is an SF Symbol scaled by width, so everything drawn + /// inside it has to scale by width too. + /// + /// These were previously absolute: the fill height was + /// `(batteryWidth - 2.75) - 18`, which only lands correctly at the + /// default 30pt — at compact mode's 24pt it collapses to ~3pt, a sliver + /// floating inside the outline. Expressing them relative to a reference + /// width keeps the 30pt case numerically identical to before while + /// making every other size correct. + private static let referenceWidth: CGFloat = 30 + private var sizeScale: CGFloat { batteryWidth / Self.referenceWidth } + + /// 9.25pt at the 30pt reference — the interior cavity height of the + /// battery symbol. + private var fillHeight: CGFloat { 9.25 * sizeScale } + /// Combined width of the outline stroke and terminal nub. + private var fillInset: CGFloat { 6 * sizeScale } + private var fillLeadingInset: CGFloat { 2 * sizeScale } + var body: some View { ZStack(alignment: .leading) { @@ -51,13 +70,13 @@ struct BatteryView: View { width: batteryWidth + 1 ) - RoundedRectangle(cornerRadius: 2.5) + RoundedRectangle(cornerRadius: 2.5 * sizeScale) .fill(batteryColor) .frame( - width: CGFloat(((CGFloat(CFloat(levelBattery)) / 100) * (batteryWidth - 6))), - height: (batteryWidth - 2.75) - 18 + width: (CGFloat(levelBattery) / 100) * (batteryWidth - fillInset), + height: fillHeight ) - .padding(.leading, 2) + .padding(.leading, fillLeadingInset) if iconStatus != "" && (isForNotification || Defaults[.showPowerStatusIcons]) { ZStack { @@ -66,8 +85,8 @@ struct BatteryView: View { .aspectRatio(contentMode: .fit) .foregroundColor(.white) .frame( - width: 17, - height: 17 + width: 17 * sizeScale, + height: 17 * sizeScale ) } .frame(width: batteryWidth, height: batteryWidth) From 001e55efc4dc15db323424261a335c03e77b58db Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Thu, 13 Aug 2026 00:08:45 +0530 Subject: [PATCH 41/69] Make the queued-notification badge a browsable stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping the "+N" badge now rotates to the next queued notification and sends the current one to the back, so repeated taps cycle through everything and come back around instead of consuming as they go. Cycling rebuilds the compose view for a different notification (it's keyed by id), so a half-typed reply would vanish the moment you looked at something else — the exact lost-typing problem the queue exists to prevent. Drafts are now kept per notification in the manager and restored on appear, and dropped when one is sent, dismissed, or evicted from the queue. Rotation deliberately doesn't release the outgoing notification's held banner: it's going back into the stack, not away, and without the hold it wouldn't be replyable when it comes back around. --- boringNotch/Localizable.xcstrings | 22 +++++++++ .../Notch/NotificationLiveActivity.swift | 48 ++++++++++++------- .../managers/SystemNotificationManager.swift | 46 +++++++++++++++++- 3 files changed, 96 insertions(+), 20 deletions(-) diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index 48b0a0802..335ea8412 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -179,6 +179,28 @@ } } } + }, + "%lld more waiting" : { + + }, + "%lld%%" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + } + } + }, + "+%lld" : { + }, "About" : { "localizations" : { diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index 74a1d9b9e..50cd37346 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -153,6 +153,10 @@ struct NotificationExpandedView: View { // notification go rather than pinning it forever. .onAppear { manager.holdActive() + // Cycling the stack rebuilds this view for a different + // notification (.id below), so a half-typed reply only survives + // if it's restored from the manager rather than kept in @State. + replyText = manager.draft(for: notification.id) if kind == .reply { replyFocused = true } } // The single teardown point for both the key-window grant and the @@ -202,10 +206,11 @@ struct NotificationExpandedView: View { // so hold it for the whole compose session. beginComposing() } - .onChange(of: replyText) { _, _ in - // Stop the timer's clock is exactly what typing should do — a - // keystroke is the clearest possible "still here" signal, so it - // gets an uncapped hold rather than the notch-open cap. + .onChange(of: replyText) { _, text in + manager.setDraft(text, for: notification.id) + // Stopping the timer's clock is exactly what typing should do — + // a keystroke is the clearest possible "still here" signal, so + // it gets an uncapped hold rather than the notch-open cap. if replyFocused { manager.holdWhileTyping() } } } @@ -284,24 +289,30 @@ struct NotificationExpandedView: View { private var queuedBadge: some View { let queued = manager.queued if !queued.isEmpty { - HStack(spacing: 3) { - if let bundleID = singleQueuedAppBundleID { - AppIcon(for: bundleID) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 11, height: 11) - .clipShape(RoundedRectangle(cornerRadius: 3)) + Button { + manager.cycleToNextQueued() + } label: { + HStack(spacing: 3) { + if let bundleID = singleQueuedAppBundleID { + AppIcon(for: bundleID) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 11, height: 11) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } + Text("+\(queued.count)") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(0.9)) } - Text("+\(queued.count)") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.white.opacity(0.9)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.white.opacity(0.14), in: Capsule()) + .contentShape(Capsule()) } - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(.white.opacity(0.14), in: Capsule()) + .buttonStyle(ScaleDownButtonStyle()) .transition(.scale.combined(with: .opacity)) .animation(.smooth(duration: 0.2), value: queued.count) - .help("\(queued.count) more waiting") + .help("Tap to see the next of \(queued.count) waiting") } } @@ -534,6 +545,7 @@ struct NotificationExpandedView: View { // actually sitting on the clipboard. didSend = outcome == .sent didHandOff = outcome == .handedOffToApp + manager.clearDraft(for: notification.id) try? await Task.sleep(for: .milliseconds(1200)) manager.dismissActive(token: notification.id) } diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index e31f2017b..575f4f46d 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -154,6 +154,7 @@ final class SystemNotificationManager: ObservableObject { private func releaseQueued() { for notification in queued { XPCHelperClient.shared.releaseNotification(token: notification.id) + clearDraft(for: notification.id) } queued.removeAll() } @@ -211,9 +212,45 @@ final class SystemNotificationManager: ObservableObject { while queued.count > queueLimit { let dropped = queued.removeFirst() XPCHelperClient.shared.releaseNotification(token: dropped.id) + clearDraft(for: dropped.id) } } + /// Rotates the stack: shows the next queued notification and sends the + /// current one to the back, so repeated taps cycle through everything + /// and always come back around rather than discarding as they go. + func cycleToNextQueued() { + guard let next = queued.first else { return } + queued.removeFirst() + if let current = activeNotification { + queued.append(current) + } + show(next, releasingPrevious: false) + } + + // MARK: - Reply drafts + + /// Half-typed replies, keyed by notification. Cycling the stack tears + /// the compose view down and rebuilds it for a different notification, + /// so without this a draft would vanish the moment you looked at + /// something else — the same lost-typing problem the queue exists to + /// prevent. + private var replyDrafts: [String: String] = [:] + + func draft(for id: String) -> String { replyDrafts[id] ?? "" } + + func setDraft(_ text: String, for id: String) { + if text.isEmpty { + replyDrafts.removeValue(forKey: id) + } else { + replyDrafts[id] = text + } + } + + func clearDraft(for id: String) { + replyDrafts.removeValue(forKey: id) + } + /// Shows the oldest queued notification, if any. Oldest first so a burst /// is read in the order it arrived. private func promoteNextQueued() { @@ -258,7 +295,11 @@ final class SystemNotificationManager: ObservableObject { } } - private func show(_ notification: SystemNotification) { + /// `releasingPrevious: false` when rotating through the stack — the + /// outgoing notification goes back into the queue rather than away, and + /// it needs to keep its held banner or it won't be replyable when it + /// comes back around. + private func show(_ notification: SystemNotification, releasingPrevious: Bool = true) { // A newer notification replaces the active one directly here rather // than going through dismissActive, so its hold was never being // released: dismissActive only releases whatever activeNotification @@ -269,7 +310,7 @@ final class SystemNotificationManager: ObservableObject { // notificationcenterui for its own real Notification Center panel, // that panel could end up opening off-screen too, which is why the // system clock stopped visibly doing anything. - if let previous = activeNotification, previous.id != notification.id { + if releasingPrevious, let previous = activeNotification, previous.id != notification.id { XPCHelperClient.shared.releaseNotification(token: previous.id) } withAnimation(.smooth) { activeNotification = notification } @@ -292,6 +333,7 @@ final class SystemNotificationManager: ObservableObject { // has moved on. if let id = activeNotification?.id { XPCHelperClient.shared.releaseNotification(token: id) + clearDraft(for: id) } withAnimation(.smooth) { activeNotification = nil } From 72bacb923fd716ae2f644fab3305131011263374 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Thu, 13 Aug 2026 00:12:15 +0530 Subject: [PATCH 42/69] Draft WhatsApp replies in the conversation instead of the clipboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp has no scripting interface, but its URL scheme opens a specific conversation with text pre-filled — much better than the clipboard, which leaves the user to find the chat and paste. Uses whatsapp:// rather than wa.me: the scheme is registered to WhatsApp.app directly (verified), so the message text doesn't detour through a browser. Needs a phone number, which the notification never carries — only a display name. Resolved through Contacts, and deliberately only when unambiguous: exactly one matching contact, and a stored number that already has a country code. A local-format number can't be made international without guessing the country, and the cost of guessing wrong is dropping someone's private reply into a stranger's chat. Anything that doesn't clear that bar falls back to the clipboard. Group chats resolve to nothing, which is correct — a group name isn't a contact and has no single number. Reported as its own outcome rather than folded into "sent": the conversation opens with the text ready, but the user still presses send, so the UI keeps the same non-committal treatment as the clipboard hand-off. --- .../Notch/NotificationLiveActivity.swift | 5 ++- .../components/NotificationDebugWindow.swift | 2 + .../managers/ContactAvatarManager.swift | 38 +++++++++++++++++++ .../managers/SystemNotificationManager.swift | 24 ++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index 50cd37346..be7479082 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -544,7 +544,10 @@ struct NotificationExpandedView: View { // both would tell the user their message went out when it's // actually sitting on the clipboard. didSend = outcome == .sent - didHandOff = outcome == .handedOffToApp + // A drafted reply isn't sent either — same honest treatment as + // the clipboard hand-off, since the user still has to press + // send in WhatsApp. + didHandOff = outcome == .handedOffToApp || outcome == .draftedInApp manager.clearDraft(for: notification.id) try? await Task.sleep(for: .milliseconds(1200)) manager.dismissActive(token: notification.id) diff --git a/boringNotch/components/NotificationDebugWindow.swift b/boringNotch/components/NotificationDebugWindow.swift index b03b0c36b..efa78cadd 100644 --- a/boringNotch/components/NotificationDebugWindow.swift +++ b/boringNotch/components/NotificationDebugWindow.swift @@ -89,6 +89,8 @@ struct NotificationDebugView: View { lastResult = "replied via the live banner" case .handedOffToApp: lastResult = "banner gone — copied to clipboard and opened the app" + case .draftedInApp: + lastResult = "banner gone — opened the conversation with the text pre-filled" } } } diff --git a/boringNotch/managers/ContactAvatarManager.swift b/boringNotch/managers/ContactAvatarManager.swift index 67fca6fd4..5abcbc75b 100644 --- a/boringNotch/managers/ContactAvatarManager.swift +++ b/boringNotch/managers/ContactAvatarManager.swift @@ -73,6 +73,44 @@ final class ContactAvatarManager: ObservableObject { return image } + /// Phone number for a contact, digits only and international, suitable + /// for a whatsapp:// deep link — or nil when it can't be resolved + /// *confidently*. + /// + /// The bar is deliberately high, because the cost of being wrong is + /// dropping someone's private reply into a stranger's chat: + /// + /// - exactly one matching contact, else the name is ambiguous + /// - the stored number must already carry a country code (leading "+"). + /// A local-format number can't be made international without guessing + /// the country, and a wrong guess is a wrong person. + /// + /// Group chats resolve to nothing here, which is correct — a group name + /// isn't a contact and has no single number to send to. + func phoneNumber(forContactNamed name: String) -> String? { + guard isAuthorized else { return nil } + + let keys = [CNContactPhoneNumbersKey as CNKeyDescriptor] + guard let contacts = try? store.unifiedContacts( + matching: CNContact.predicateForContacts(matchingName: name), + keysToFetch: keys + ), contacts.count == 1 else { return nil } + + // Prefer an explicitly mobile number; a landline won't reach + // WhatsApp at all. + let numbers = contacts[0].phoneNumbers + let preferred = numbers.first { + $0.label == CNLabelPhoneNumberMobile || $0.label == CNLabelPhoneNumberiPhone + } ?? numbers.first + + guard let raw = preferred?.value.stringValue else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("+") else { return nil } + + let digits = trimmed.filter(\.isNumber) + return digits.isEmpty ? nil : digits + } + /// Call once, e.g. when notification live activity starts, so the first /// banner isn't blocked on a permission prompt mid-render. func requestAccessIfNeeded() async { diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index 575f4f46d..99cf2c273 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -408,6 +408,10 @@ final class SystemNotificationManager: ObservableObject { /// The banner was gone, so the draft went to the clipboard and the /// app was opened for the user to paste. case handedOffToApp + /// Opened the conversation with the text already in its compose + /// box. Better than the clipboard, but still not sent — the user + /// presses send themselves. + case draftedInApp } /// Sends an inline reply. @@ -442,6 +446,26 @@ final class SystemNotificationManager: ObservableObject { return .sent } + // WhatsApp exposes no scripting interface, but its URL scheme can + // open a specific conversation with text pre-filled — far better + // than the clipboard, which leaves the user to find the chat and + // paste. Needs a phone number, which only comes from Contacts and + // only when it resolves unambiguously (see phoneNumber(for:)). + if notification.bundleID == "net.whatsapp.WhatsApp", + let sender = notification.sender, + let phone = ContactAvatarManager.shared.phoneNumber(forContactNamed: sender), + let encoded = text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + // whatsapp:// rather than wa.me — the scheme is registered to + // WhatsApp.app directly, so it opens the app instead of bouncing + // the message text through a browser. + let url = URL(string: "whatsapp://send?phone=\(phone)&text=\(encoded)") { + NSLog("[boringNotch] reply drafted in WhatsApp conversation for \(sender)") + NSWorkspace.shared.open(url) + playHandOffSound() + dismissActive(token: notification.id) + return .draftedInApp + } + NSLog("[boringNotch] reply could not be delivered (banner gone) — handing off to \(notification.appName ?? "app")") NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) From d2b661d945a2b41811c14b7d0c2eb22c0e4c0f2d Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 10:44:52 +0530 Subject: [PATCH 43/69] Phase 0 audit remediation: crash fixes, dead-code purge, OSS compliance Crash vectors fixed: - Guard NSScreen.main ?? screens.first in AppDelegate launch path - Safe fallbacks for global documents/temp dir + bundle ID constants - NotificationWatcher: CFTypeID-check window attribute before AXUIElement cast - AudioCaptureManager: degrade to flat bars if FFT setup fails (was fatalError) - AudioPlayer: guard bundle resource URL and retain sound during playback Dead code removed (~740 LOC): TestView/FluidSlider, DownloadView, ProgressIndicator, BoringNotchWindow, BoringStatusMenu, EditPanelView, sneakPeekEvent + SharedSneakPeek (contained as! Data crash), unused BatteryActivityManager closures, dead dispatch items, 4 unused keyboard shortcuts, dead sneak-peek size constants, NotchSpaceManager leftovers. VisualEffectView extracted into its own file (was hiding in deleted EditPanelView.swift, used by 5 onboarding views). OSS compliance & hygiene: - THIRD_PARTY_LICENSES: add all 12 SPM packages (Apache-2.0 block for lottie-spm/swift-collections/swift-syntax, MIT block +7, BSD block +1) - Fix broken THIRD_PARTY_LICENSES link in README - mediaremote-adapter: add provenance README (pinned at upstream v0.7.2, verified byte-identical script, rebuild/update instructions) - Rename PR template so GitHub actually renders it; delete 2 stale issue templates; fix CONTRIBUTING step numbering; drop Linux devcontainer - Commit shared boringNotch scheme; clear hardcoded DEVELOPMENT_TEAM (CI overrides it); clean up .gitignore (stop ignoring schemes) Verified: xcodebuild Debug build succeeds for app + XPC helper. --- .devcontainer/devcontainer.json | 5 - .github/ISSUE_TEMPLATE/feature_request.md | 21 -- .github/ISSUE_TEMPLATE/old_bug_report.md | 31 --- ...LL_REQUEST.md => pull_request_template.md} | 0 .gitignore | 24 +-- .../NotificationWatcher.swift | 3 +- CONTRIBUTING.md | 2 +- README.md | 2 +- THIRD_PARTY_LICENSES | 201 ++++++++++++++++++ boringNotch.xcodeproj/project.pbxproj | 28 +-- .../xcschemes/boringNotch.xcscheme | 78 +++++++ boringNotch/BoringViewCoordinator.swift | 42 ---- boringNotch/Shortcuts/ShortcutConstants.swift | 6 +- boringNotch/boringNotchApp.swift | 7 +- .../Live activities/DownloadView.swift | 52 ----- .../components/Notch/BoringNotchWindow.swift | 69 ------ .../components/ProgressIndicator.swift | 63 ------ .../components/Settings/EditPanelView.swift | 54 ----- boringNotch/components/TestView.swift | 92 -------- boringNotch/components/VisualEffectView.swift | 27 +++ boringNotch/helpers/AudioPlayer.swift | 16 +- .../managers/AudioCaptureManager.swift | 10 +- .../managers/BatteryActivityManager.swift | 20 -- boringNotch/managers/NotchSpaceManager.swift | 4 +- boringNotch/menu/StatusBarMenu.swift | 24 --- boringNotch/models/Constants.swift | 11 +- boringNotch/sizing/matters.swift | 3 - mediaremote-adapter/README.md | 60 ++++++ 28 files changed, 409 insertions(+), 546 deletions(-) delete mode 100644 .devcontainer/devcontainer.json delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 .github/ISSUE_TEMPLATE/old_bug_report.md rename .github/{PULL_REQUEST.md => pull_request_template.md} (100%) create mode 100644 boringNotch.xcodeproj/xcshareddata/xcschemes/boringNotch.xcscheme delete mode 100644 boringNotch/components/Live activities/DownloadView.swift delete mode 100644 boringNotch/components/Notch/BoringNotchWindow.swift delete mode 100644 boringNotch/components/ProgressIndicator.swift delete mode 100644 boringNotch/components/Settings/EditPanelView.swift delete mode 100644 boringNotch/components/TestView.swift create mode 100644 boringNotch/components/VisualEffectView.swift delete mode 100644 boringNotch/menu/StatusBarMenu.swift create mode 100644 mediaremote-adapter/README.md diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index ad93c14a0..000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "image": "mcr.microsoft.com/devcontainers/universal:2", - "features": { - } -} diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 4fc5911dc..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,21 +0,0 @@ - ---- -name: Feature request -about: Suggest an idea for this project -title: "[FEATURE]" -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Additional context** -Add any other context or screenshots about the feature request here. - -**Checks** -- [x] I haven't found any duplicates with my issue. diff --git a/.github/ISSUE_TEMPLATE/old_bug_report.md b/.github/ISSUE_TEMPLATE/old_bug_report.md deleted file mode 100644 index 8f20d78c4..000000000 --- a/.github/ISSUE_TEMPLATE/old_bug_report.md +++ /dev/null @@ -1,31 +0,0 @@ - ---- -name: Bug report -about: Create a report to help us improve -title: "[BUG]" -labels: 'bug,unconfirmed' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots or recordings** -If applicable, add screenshots to help explain your problem. - -**Additional context** -Add any other context about the problem here. Mentioning what version are you using. - -**Checks** -- [x] I haven't found any duplicates with my issue. diff --git a/.github/PULL_REQUEST.md b/.github/pull_request_template.md similarity index 100% rename from .github/PULL_REQUEST.md rename to .github/pull_request_template.md diff --git a/.gitignore b/.gitignore index 8ad298c04..759956ce3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,13 @@ # Xcode *.xcuserstate *.xcuserdata -*.xcscheme *.xcuserdatad +*.xcodeproj/xcuserdata +*.xcodeproj/project.xcuserdata +*.xcodeproj/xcshareddata/WorkspaceSettings.xcsettings *.pbxuser *.xccheckout -*.xcscheme *.xcplayground -*.xcuserdatad -*.xctest -*.xcuserdata -*.xcodeproj/xcshareddata/WorkspaceSettings.xcsettings - -# CocoaPods -Pods/ -podfile.lock - -# Carthage -Carthage/Build/ # Swift Package Manager .swiftpm/ @@ -42,14 +32,6 @@ build/ *.vscode/ *.idea/ -# User-specific files -*.xcuserdatad/ -*.xcscheme -*.xcodeproj/xcuserdata -*.xcodeproj/project.xcuserdata - # Build artifacts *.ipa *.xcarchive -*.dSYM - diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index b3e1198b0..4b02a4822 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -178,7 +178,8 @@ final class NotificationWatcher { guard !heldOffScreen.contains(token) else { return } heldOffScreen.insert(token) - guard let windowValue = banner[kAXWindowAttribute] else { return } + guard let windowValue = banner[kAXWindowAttribute], + CFGetTypeID(windowValue as CFTypeRef) == AXUIElementGetTypeID() else { return } let window = windowValue as! AXUIElement var target = CGPoint(x: -5000, y: -5000) if let position = AXValueCreate(.cgPoint, &target) { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f02fac50b..c9a4fc861 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ Please submit all translations to [Crowdin](https://crowdin.com/project/boring-n ``` All code contributions must be based on the `dev` branch, not `main`. Documentation changes should be based on `main` instead. -5. **Create a new feature branch**: +4. **Create a new feature branch**: ```bash git checkout -b feature/{your-feature-name} ``` diff --git a/README.md b/README.md index cdcc94625..38aaf50ee 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ We would like to express our gratitude to the authors and maintainers of the ope - **[MediaRemoteAdapter](https://github.com/ungive/mediaremote-adapter)** – An open-source project that allowed us to use the Now Playing source in macOS 15.4+ - **[NotchDrop](https://github.com/Lakr233/NotchDrop)** – An open-source project that has been instrumental in developing the first version of the "Shelf" feature in Boring Notch. -For a full list of licenses and attributions, please see the [Third-Party Licenses](./THIRD_PARTY_LICENSES.md) file. +For a full list of licenses and attributions, please see the [Third-Party Licenses](./THIRD_PARTY_LICENSES) file. ### Icon credits: [@maxtron95](https://github.com/maxtron95) ### Website credits: [@himanshhhhuv](https://github.com/himanshhhhuv) diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES index 464c84406..337403569 100644 --- a/THIRD_PARTY_LICENSES +++ b/THIRD_PARTY_LICENSES @@ -1,6 +1,7 @@ ----------------------------------------------------------------------------- BSD 3-Clause License applies to: + - AsyncXPCConnection, Copyright (c) 2023, Chime - MediaRemoteAdapter, Copyright (c) 2025, Jonas van den Berg and contributors ----------------------------------------------------------------------------- @@ -33,8 +34,20 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. The MIT License (MIT) applies to: - Calendr, Copyright (c) 2021 Carlos César Neves Enumo + - Defaults, Copyright (c) Sindre Sorhus - DynamicNotchKit, Copyright (c) 2025 Kai Azim + - KeyboardShortcuts, Copyright (c) Sindre Sorhus + - LaunchAtLogin-Modern, Copyright (c) Sindre Sorhus - NotchDrop Copyright (c) 2024 Lakr Aream + - Pow, Copyright (c) 2023 Emerge Tools, Inc. + - SkyLightWindow, Copyright (c) 2025 Lakr Aream + - Sparkle, Copyright (c) 2006-2017 Andy Matuschak, Elgato Systems GmbH, + Kornel Lesiński, Mayur Pawashe, C.W. Betts, Petroules Corporation, + Big Nerd Ranch, and the Sparkle Project contributors + (Sparkle additionally bundles bsdiff, sais-lite, ed25519 and + SUSignatureVerifier under BSD/MIT/zlib-style terms requiring the + same notice reproduction) + - SwiftUI-Introspect, Copyright 2019 Timber Software ----------------------------------------------------------------------------- Permission is hereby granted, free of charge, to any person obtaining a copy @@ -431,3 +444,191 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. + +----------------------------------------------------------------------------- + Apache License + Version 2.0, January 2004 + applies to: + - lottie-spm, Copyright 2018 Airbnb, Inc. + - swift-collections, Copyright (c) 2020 Apple Inc. and the Swift + project authors + - swift-syntax, Copyright (c) 2014 Apple Inc. and the Swift + project authors +----------------------------------------------------------------------------- + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power to, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but not + limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object + form, made available under the License, as indicated by a copyright + notice that is included in or attached to the work (an example is + provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including the + original version of the Work and any modifications or additions to + that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on + behalf of the copyright owner. For the purposes of this definition, + "submitted" means any form of electronic, verbal, or written + communication sent to the Licensor or its representatives, including + but not limited to communication on electronic mailing lists, source + code control systems, and issue tracking systems that are managed by, + on behalf of, the Licensor for the purpose of discussing the Work and + for excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received and subsequently + incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim or a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct or + contributory patent infringement, then any patent licenses granted + to You under this License for that Work shall terminate as of the + date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or for + any such Derivative Works as a whole, provided Your use, + reproduction, or distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work by + You to the Licensor, other than as expressly provided in this License, + without any additional terms and conditions. Notwithstanding the + above, nothing herein shall supersede or modify the terms of any + separate license agreement that You may have executed with Licensor + regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of your use or redistributing the Work and assume any + risks associated with your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable for any indirect, special, incidental, or consequential damages + of any character arising as a result of this License or out of the + use or inability to use the License, even if such Contributor has + been advised of the possibility of such damages, including without + limitation damages for loss of goodwill, work stoppage, computer + failure or malfunction, or any and all other commercial damages or + losses. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, and + charge a fee for, acceptance of warranty, support, indemnity or + liability obligations and/or rights consistent with this License. + However, in accepting such obligations, You may act only on Your own + behalf and on Your sole responsibility, not on behalf of any other + contributor, and only if You agree to indemnify, defend, and hold + each Contributor harmless for any liability incurred by, or claims + asserted against, such Contributor by reason of your accepting such + warranty or additional liability or indemnity. + +END OF TERMS AND CONDITIONS diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index d7da4392d..b953dcf0e 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -94,12 +94,10 @@ 11F748822ECB07A400F841DB /* MusicControlButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F748812ECB07A400F841DB /* MusicControlButton.swift */; }; 11F748842ECB27DC00F841DB /* MusicSlotConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F748832ECB27DC00F841DB /* MusicSlotConfigurationView.swift */; }; 14288DDC2C6E015000B9F80C /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14288DD62C6E015000B9F80C /* AudioPlayer.swift */; }; - 14288DE82C6E01C800B9F80C /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14288DE72C6E01C800B9F80C /* ProgressIndicator.swift */; }; 14288E0C2C6F8EC000B9F80C /* AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14288E0B2C6F8EC000B9F80C /* AppIcons.swift */; }; 1443E7F32C609DCE0027C1FC /* matters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1443E7F22C609DCE0027C1FC /* matters.swift */; }; 147163982C5D35B70068B555 /* MusicVisualizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147163972C5D35B70068B555 /* MusicVisualizer.swift */; }; 1471639A2C5D35FF0068B555 /* MusicManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147163992C5D35FF0068B555 /* MusicManager.swift */; }; - 1471A8592C6281BD0058408D /* BoringNotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */; }; 149E0B972C737D00006418B1 /* WebcamManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149E0B962C737D00006418B1 /* WebcamManager.swift */; }; 149E0B9A2C737D40006418B1 /* WebcamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149E0B992C737D40006418B1 /* WebcamView.swift */; }; 14A7E5882C64A89C008C1BE9 /* HelloAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14A7E5872C64A89C008C1BE9 /* HelloAnimation.swift */; }; @@ -122,12 +120,12 @@ 14D570CB2C5F4B2C0011E668 /* BatteryStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570CA2C5F4B2C0011E668 /* BatteryStatusViewModel.swift */; }; 14D570CD2C5F4BB70011E668 /* BoringBattery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570CC2C5F4BB70011E668 /* BoringBattery.swift */; }; 14D570D22C5F6C6A0011E668 /* BoringExtrasMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570D12C5F6C6A0011E668 /* BoringExtrasMenu.swift */; }; - 14E9FEAA2C70BF610062E83F /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14E9FEA92C70BF610062E83F /* DownloadView.swift */; }; 14E9FEAE2C7325770062E83F /* Button+Bouncing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14E9FEAD2C7325770062E83F /* Button+Bouncing.swift */; }; 14FC6E502C7DED5600C7BEA5 /* DataTypes+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14FC6E4F2C7DED5600C7BEA5 /* DataTypes+Extensions.swift */; }; 201C3E071A104384B8629328 /* AudioOutputRouteResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */; }; 3C1B922F0813671B2886CF43 /* MeetingLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA22021D89A9E4FF88A618D /* MeetingLink.swift */; }; 507266DB2C908E2E00A2D00D /* HoverButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507266DA2C908E2E00A2D00D /* HoverButton.swift */; }; + 1A8B65187F974EE9B7664F5C /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0268933F488E47DFB58E48CF /* VisualEffectView.swift */; }; 5917FD112E57891600E87F1C /* MediaKeyInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5917FD102E57891600E87F1C /* MediaKeyInterceptor.swift */; }; 5955950D2E900ED800C66711 /* ApplicationRelauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5955950C2E900ED800C66711 /* ApplicationRelauncher.swift */; }; 59D8C23C2E589FAA00147B33 /* VolumeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59D8C23B2E589FAA00147B33 /* VolumeManager.swift */; }; @@ -146,7 +144,6 @@ AA04LAS12E7A0001 /* LiveActivityStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA04LAS22E7A0001 /* LiveActivityStack.swift */; }; AA05SRM12E7A0001 /* SmartReplyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA05SRM22E7A0001 /* SmartReplyManager.swift */; }; B10348D92C74E56000475897 /* ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10348D82C74E56000475897 /* ConditionalModifier.swift */; }; - B10F84A32C6C9596009F3026 /* TestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10F84A22C6C9596009F3026 /* TestView.swift */; }; B141C2412CA5F53F00AC8CC8 /* SparkleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B141C2402CA5F53E00AC8CC8 /* SparkleView.swift */; }; B1628B922CC260C0003D8DF3 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */; }; B17266DF2C64DFA00031BA0D /* BundleInfos.swift in Sources */ = {isa = PBXBuildFile; fileRef = B17266DE2C64DFA00031BA0D /* BundleInfos.swift */; }; @@ -157,7 +154,6 @@ B19016222CC15B3D00E3F12E /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = B19016212CC15B3D00E3F12E /* Defaults */; }; B19016242CC15B5000E3F12E /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = B19016232CC15B4D00E3F12E /* Constants.swift */; }; B1A78C822C8BA08100BD51B0 /* FullscreenMediaDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A78C812C8BA08100BD51B0 /* FullscreenMediaDetection.swift */; }; - B1B112912C6A572100093D8F /* EditPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1B112902C6A572100093D8F /* EditPanelView.swift */; }; B1B112932C6A577E00093D8F /* MouseTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1B112922C6A577E00093D8F /* MouseTracker.swift */; }; B1C448962C9712C4001F0858 /* ActionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C448952C9712C4001F0858 /* ActionBar.swift */; }; B1C448982C972CC4001F0858 /* ListItemPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C448972C972CC4001F0858 /* ListItemPopover.swift */; }; @@ -294,7 +290,6 @@ 11F748812ECB07A400F841DB /* MusicControlButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicControlButton.swift; sourceTree = ""; }; 11F748832ECB27DC00F841DB /* MusicSlotConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicSlotConfigurationView.swift; sourceTree = ""; }; 14288DD62C6E015000B9F80C /* AudioPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = ""; }; - 14288DE72C6E01C800B9F80C /* ProgressIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; 14288E0B2C6F8EC000B9F80C /* AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcons.swift; sourceTree = ""; }; 1443E7F22C609DCE0027C1FC /* matters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = matters.swift; sourceTree = ""; }; 1443E7F42C609E650027C1FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -302,7 +297,6 @@ 147163992C5D35FF0068B555 /* MusicManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicManager.swift; sourceTree = ""; }; AA07ARM22E7A0001 /* AudioRouteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouteManager.swift; sourceTree = ""; }; AA07ARM12E7A0001 /* AudioRouteManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA07ARM22E7A0001 /* AudioRouteManager.swift */; }; - 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringNotchWindow.swift; sourceTree = ""; }; 149E0B962C737D00006418B1 /* WebcamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamManager.swift; sourceTree = ""; }; 149E0B992C737D40006418B1 /* WebcamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamView.swift; sourceTree = ""; }; 14A7E5872C64A89C008C1BE9 /* HelloAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelloAnimation.swift; sourceTree = ""; }; @@ -327,11 +321,11 @@ 14D570CA2C5F4B2C0011E668 /* BatteryStatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryStatusViewModel.swift; sourceTree = ""; }; 14D570CC2C5F4BB70011E668 /* BoringBattery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringBattery.swift; sourceTree = ""; }; 14D570D12C5F6C6A0011E668 /* BoringExtrasMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringExtrasMenu.swift; sourceTree = ""; }; - 14E9FEA92C70BF610062E83F /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; 14E9FEAD2C7325770062E83F /* Button+Bouncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Button+Bouncing.swift"; sourceTree = ""; }; 14FC6E4F2C7DED5600C7BEA5 /* DataTypes+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataTypes+Extensions.swift"; sourceTree = ""; }; 3CA22021D89A9E4FF88A618D /* MeetingLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MeetingLink.swift; sourceTree = ""; }; 507266DA2C908E2E00A2D00D /* HoverButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverButton.swift; sourceTree = ""; }; + 0268933F488E47DFB58E48CF /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; 5917FD102E57891600E87F1C /* MediaKeyInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaKeyInterceptor.swift; sourceTree = ""; }; 5955950C2E900ED800C66711 /* ApplicationRelauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationRelauncher.swift; sourceTree = ""; }; 59D8C23B2E589FAA00147B33 /* VolumeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeManager.swift; sourceTree = ""; }; @@ -353,7 +347,6 @@ AA05SRM22E7A0001 /* SmartReplyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartReplyManager.swift; sourceTree = ""; }; AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputRouteResolver.swift; sourceTree = ""; }; B10348D82C74E56000475897 /* ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalModifier.swift; sourceTree = ""; }; - B10F84A22C6C9596009F3026 /* TestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestView.swift; sourceTree = ""; }; B141C2402CA5F53E00AC8CC8 /* SparkleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleView.swift; sourceTree = ""; }; B17266DE2C64DFA00031BA0D /* BundleInfos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleInfos.swift; sourceTree = ""; }; B17266E02C6532560031BA0D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; @@ -361,7 +354,6 @@ B186543B2C6F49AE000B926A /* ShortcutConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutConstants.swift; sourceTree = ""; }; B19016232CC15B4D00E3F12E /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; B1A78C812C8BA08100BD51B0 /* FullscreenMediaDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenMediaDetection.swift; sourceTree = ""; }; - B1B112902C6A572100093D8F /* EditPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPanelView.swift; sourceTree = ""; }; B1B112922C6A577E00093D8F /* MouseTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MouseTracker.swift; sourceTree = ""; }; B1C448952C9712C4001F0858 /* ActionBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBar.swift; sourceTree = ""; }; B1C448972C972CC4001F0858 /* ListItemPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListItemPopover.swift; sourceTree = ""; }; @@ -645,9 +637,8 @@ 9A0887332C7AFF7E00C160EA /* Tabs */, B186542E2C6F453B000B926A /* Music */, 14D570BF2C5EA5870011E668 /* AnimatedFace.swift */, - 14288DE72C6E01C800B9F80C /* ProgressIndicator.swift */, - B10F84A22C6C9596009F3026 /* TestView.swift */, 507266DA2C908E2E00A2D00D /* HoverButton.swift */, + 0268933F488E47DFB58E48CF /* VisualEffectView.swift */, ); path = components; sourceTree = ""; @@ -864,7 +855,6 @@ 9AB0C6BB2C73C9CB00F7CD30 /* NotchHomeView.swift */, 14D570C52C5F38210011E668 /* BoringHeader.swift */, 14D570D12C5F6C6A0011E668 /* BoringExtrasMenu.swift */, - 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */, ); path = Notch; sourceTree = ""; @@ -877,7 +867,6 @@ 11C5E3152DFE88510065821E /* SettingsView.swift */, 11C5E3112DFE85970065821E /* SettingsWindowController.swift */, B1D6FD422C6603730015F173 /* SoftwareUpdater.swift */, - B1B112902C6A572100093D8F /* EditPanelView.swift */, B1C448972C972CC4001F0858 /* ListItemPopover.swift */, ); path = Settings; @@ -889,7 +878,6 @@ 14D570CC2C5F4BB70011E668 /* BoringBattery.swift */, B1C974332C642B6D0000E707 /* MarqueeTextView.swift */, B1D365CD2C6A979C0047BDBC /* LiveActivityModifier.swift */, - 14E9FEA92C70BF610062E83F /* DownloadView.swift */, ); path = "Live activities"; sourceTree = ""; @@ -1116,7 +1104,6 @@ 1153BD9A2D98824300979FB0 /* SpotifyController.swift in Sources */, 118EBE292E946B3F00D54B5A /* ShareServiceFinder.swift in Sources */, B1C974342C642B6D0000E707 /* MarqueeTextView.swift in Sources */, - 14288DE82C6E01C800B9F80C /* ProgressIndicator.swift in Sources */, 1113ABC52E80E27000EC13B2 /* ShelfItemView.swift in Sources */, A1F000012F00000100000001 /* ShelfItemInteractionView.swift in Sources */, 1113ABC62E80E27000EC13B2 /* ShelfPersistenceService.swift in Sources */, @@ -1152,8 +1139,6 @@ 11985BF42F38520A00F81585 /* DraggableProgressBar.swift in Sources */, 9AB0C6BD2C73C9CB00F7CD30 /* NotchHomeView.swift in Sources */, B172AAC02C95DA0B001623F1 /* InlineOSD.swift in Sources */, - 14E9FEAA2C70BF610062E83F /* DownloadView.swift in Sources */, - B1B112912C6A572100093D8F /* EditPanelView.swift in Sources */, 1153BD912D986DB300979FB0 /* PlaybackState.swift in Sources */, B1A78C822C8BA08100BD51B0 /* FullscreenMediaDetection.swift in Sources */, 14E9FEAE2C7325770062E83F /* Button+Bouncing.swift in Sources */, @@ -1177,7 +1162,6 @@ 14D570C62C5F38210011E668 /* BoringHeader.swift in Sources */, 14C08BB62C8DE42D000F8AA0 /* CalendarManager.swift in Sources */, 14D570CD2C5F4BB70011E668 /* BoringBattery.swift in Sources */, - B10F84A32C6C9596009F3026 /* TestView.swift in Sources */, 1443E7F32C609DCE0027C1FC /* matters.swift in Sources */, 11C5E3162DFE88510065821E /* SettingsView.swift in Sources */, 1153BD932D986E4300979FB0 /* AppleMusicController.swift in Sources */, @@ -1186,7 +1170,7 @@ 11CFC6652E09C7B300748C80 /* OnboardingFinishView.swift in Sources */, 64FA50FB2F4D6F9E00008A28 /* WebcamSettingsView.swift in Sources */, 507266DB2C908E2E00A2D00D /* HoverButton.swift in Sources */, - 1471A8592C6281BD0058408D /* BoringNotchWindow.swift in Sources */, + 1A8B65187F974EE9B7664F5C /* VisualEffectView.swift in Sources */, 14CEF4182C5CAED300855D72 /* ContentView.swift in Sources */, 9A987A0D2C73CA66005CA465 /* ShelfView.swift in Sources */, 1132E5142E777B920068732D /* YouTubeMusicNetworking.swift in Sources */, @@ -1447,7 +1431,7 @@ CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"boringNotch/Preview Content\""; - DEVELOPMENT_TEAM = JPWMG84CH8; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; @@ -1514,7 +1498,7 @@ CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"boringNotch/Preview Content\""; - DEVELOPMENT_TEAM = JPWMG84CH8; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; diff --git a/boringNotch.xcodeproj/xcshareddata/xcschemes/boringNotch.xcscheme b/boringNotch.xcodeproj/xcshareddata/xcschemes/boringNotch.xcscheme new file mode 100644 index 000000000..9ef1a248b --- /dev/null +++ b/boringNotch.xcodeproj/xcshareddata/xcschemes/boringNotch.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/boringNotch/BoringViewCoordinator.swift b/boringNotch/BoringViewCoordinator.swift index db181a448..987433782 100644 --- a/boringNotch/BoringViewCoordinator.swift +++ b/boringNotch/BoringViewCoordinator.swift @@ -29,13 +29,6 @@ struct sneakPeek { var targetScreenUUID: String? = nil } -struct SharedSneakPeek: Codable { - var show: Bool - var type: String - var value: String - var icon: String -} - enum BrowserType { case chromium case safari @@ -54,8 +47,6 @@ class BoringViewCoordinator: ObservableObject { @Published var currentView: NotchViews = .home @Published var helloAnimationRunning: Bool = false - private var sneakPeekDispatch: DispatchWorkItem? - private var expandingViewDispatch: DispatchWorkItem? private var osdEnableTask: Task? @AppStorage("firstLaunch") var firstLaunch: Bool = true @@ -203,36 +194,6 @@ class BoringViewCoordinator: ObservableObject { } } - @objc func sneakPeekEvent(_ notification: Notification) { - let decoder = JSONDecoder() - if let decodedData = try? decoder.decode( - SharedSneakPeek.self, from: notification.userInfo?.first?.value as! Data) - { - let contentType = - decodedData.type == "brightness" - ? SneakContentType.brightness - : decodedData.type == "volume" - ? SneakContentType.volume - : decodedData.type == "backlight" - ? SneakContentType.backlight - : decodedData.type == "mic" - ? SneakContentType.mic : SneakContentType.brightness - - let formatter = NumberFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.numberStyle = .decimal - let value = CGFloat((formatter.number(from: decodedData.value) ?? 0.0).floatValue) - let icon = decodedData.icon - - print("Decoded: \(decodedData), Parsed value: \(value)") - - toggleSneakPeek(status: decodedData.show, type: contentType, value: value, icon: icon) - - } else { - print("Failed to decode JSON data") - } - } - // MARK: - Per-Screen Sneak Peek Management // Dictionary to hold sneak peek state for each screen UUID @@ -240,9 +201,6 @@ class BoringViewCoordinator: ObservableObject { // Dictionary to hold hide tasks for each screen UUID private var sneakPeekTasks: [String: Task] = [:] - - // Default duration - private var defaultSneakPeekDuration: TimeInterval = 1.5 func toggleSneakPeek( status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, diff --git a/boringNotch/Shortcuts/ShortcutConstants.swift b/boringNotch/Shortcuts/ShortcutConstants.swift index 4e476a265..b574c026c 100644 --- a/boringNotch/Shortcuts/ShortcutConstants.swift +++ b/boringNotch/Shortcuts/ShortcutConstants.swift @@ -1,5 +1,5 @@ // -// Constants.swift +// ShortcutConstants.swift // boringNotch // // Created by Richard Kunkli on 16/08/2024. @@ -9,10 +9,6 @@ import KeyboardShortcuts import SwiftUI extension KeyboardShortcuts.Name { - static let clipboardHistoryPanel = Self("clipboardHistoryPanel", default: .init(.c, modifiers: [.shift, .command])) - static let toggleMicrophone = Self("toggleMicrophone", default: .init(.f5, modifiers: [.function])) - static let decreaseBacklight = Self("decreaseBacklight", default: .init(.f1, modifiers: [.command])) - static let increaseBacklight = Self("increaseBacklight", default: .init(.f2, modifiers: [.command])) static let toggleSneakPeek = Self("toggleSneakPeek", default: .init(.h, modifiers: [.command, .shift])) static let toggleNotchOpen = Self("toggleNotchOpen", default: .init(.i, modifiers: [.command, .shift])) } diff --git a/boringNotch/boringNotchApp.swift b/boringNotch/boringNotchApp.swift index bdc5c552a..572551a02 100644 --- a/boringNotch/boringNotchApp.swift +++ b/boringNotch/boringNotchApp.swift @@ -466,9 +466,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { if !Defaults[.showOnAllDisplays] { let viewModel = self.vm - let window = createBoringNotchWindow( - for: NSScreen.main ?? NSScreen.screens.first!, with: viewModel) - self.window = window + if let screen = NSScreen.main ?? NSScreen.screens.first { + let window = createBoringNotchWindow(for: screen, with: viewModel) + self.window = window + } adjustWindowPosition(changeAlpha: true) } else { adjustWindowPosition(changeAlpha: true) diff --git a/boringNotch/components/Live activities/DownloadView.swift b/boringNotch/components/Live activities/DownloadView.swift deleted file mode 100644 index bc2ecbc60..000000000 --- a/boringNotch/components/Live activities/DownloadView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// DownloadView.swift -// boringNotch -// -// Created by Harsh Vardhan Goswami on 17/08/24. -// - -import Foundation -import SwiftUI - -enum Browser { - case safari - case chrome -} - -struct DownloadFile { - var name: String - var size: Int - var formattedSize: String - var browser: Browser -} - -class DownloadWatcher: ObservableObject { - @Published var downloadFiles: [DownloadFile] = [] -} - -struct DownloadArea: View { - @EnvironmentObject var watcher: DownloadWatcher - - var body: some View { - HStack(alignment: .center) { - HStack { - if watcher.downloadFiles.first!.browser == .safari { - AppIcon(for: "com.apple.safari") - } else { - AppIcon(for: "com.google.Chrome") - } - VStack(alignment: .leading) { - Text("Download") - Text("In progress").font(.system(.footnote)).foregroundStyle(.gray) - } - } - Spacer() - HStack(spacing: 12) { - VStack(alignment: .trailing) { - Text(watcher.downloadFiles.first!.formattedSize) - Text(watcher.downloadFiles.first!.name).font(.caption2).foregroundStyle(.gray) - } - } - } - } -} diff --git a/boringNotch/components/Notch/BoringNotchWindow.swift b/boringNotch/components/Notch/BoringNotchWindow.swift deleted file mode 100644 index 886f8f74b..000000000 --- a/boringNotch/components/Notch/BoringNotchWindow.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// BoringNotchWindow.swift -// boringNotch -// -// Created by Harsh Vardhan Goswami on 06/08/24. -// - -import Cocoa - -class BoringNotchWindow: NSPanel { - override init( - contentRect: NSRect, - styleMask: NSWindow.StyleMask, - backing: NSWindow.BackingStoreType, - defer flag: Bool - ) { - super.init( - contentRect: contentRect, - styleMask: styleMask, - backing: backing, - defer: flag - ) - - isFloatingPanel = true - isOpaque = false - titleVisibility = .hidden - titlebarAppearsTransparent = true - backgroundColor = .clear - isMovable = false - - collectionBehavior = [ - .fullScreenAuxiliary, - .stationary, - .canJoinAllSpaces, - .ignoresCycle, - ] - - isReleasedWhenClosed = false - level = .mainMenu + 3 - hasShadow = false - } - - /// False by default so a click on the notch never steals focus from - /// whatever app is frontmost — that's load-bearing for every other - /// interaction (hover-to-open, music controls, OSD). But it also means - /// NO text field in this window can ever receive a keystroke: SwiftUI's - /// @FocusState/.focused() only sets the responder *within* the view - /// hierarchy, and macOS never routes real keyDown events to a window - /// that can't become key. A reply field needs this flipped on for the - /// moment it's actually being typed into, and back off immediately - /// after — never left permanently true, or every other interaction - /// regresses. - var wantsKeyForTextInput = false { - didSet { - guard wantsKeyForTextInput != oldValue else { return } - if wantsKeyForTextInput { - makeKey() - } - } - } - - override var canBecomeKey: Bool { - wantsKeyForTextInput - } - - override var canBecomeMain: Bool { - false - } -} diff --git a/boringNotch/components/ProgressIndicator.swift b/boringNotch/components/ProgressIndicator.swift deleted file mode 100644 index 00abd102c..000000000 --- a/boringNotch/components/ProgressIndicator.swift +++ /dev/null @@ -1,63 +0,0 @@ - // - // ProgressIndicator.swift - // boringNotch - // - // Created by Harsh Vardhan Goswami on 11/08/24. - // - -import Foundation -import SwiftUI - -struct CircularProgressView: View { - let progress: Double - let color: Color - - var body: some View { - ZStack { - Circle() - .stroke( - Color.white.opacity(0.2), - lineWidth: 6 - ) - Circle() - .trim(from: 0, to: progress) - .stroke( - color, - // 1 - style: StrokeStyle( - lineWidth: 6, - lineCap: .round - ) - ) - .rotationEffect(.degrees(-90)) - } - } -} - -enum ProgressIndicatorType { - case circle - case text -} - - - // based on type .circle or .text -struct ProgressIndicator: View { - var type: ProgressIndicatorType - var progress: Double - var color: Color - - var body: some View { - switch type { - case .circle: - CircularProgressView(progress: progress, color: color).frame( - width: 20, height: 20) - case .text: - Text(progress, format: .percent.precision(.fractionLength(0))) - } - } -} - -#Preview { - ProgressIndicator(type: .circle, progress: 0.8, color: Color.blue).padding() - .frame(width: 200, height: 200) -} diff --git a/boringNotch/components/Settings/EditPanelView.swift b/boringNotch/components/Settings/EditPanelView.swift deleted file mode 100644 index 0d3c0258c..000000000 --- a/boringNotch/components/Settings/EditPanelView.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// EditPanelView.swift -// boringNotch -// -// Created by Richard Kunkli on 12/08/2024. -// - -import SwiftUI - -struct EditPanelView: View { - @State var wallpaperPath: URL? - var body: some View { - VStack { - HStack { - Text("Edit layout") - .font(.system(.largeTitle, design: .rounded)) - .foregroundColor(.white.opacity(0.5)) - Spacer() - Button { - exit(0) - } label: { - Label("Close", systemImage: "xmark") - } - .controlSize(.extraLarge) - .buttonStyle(AccessoryBarButtonStyle()) - } - .padding() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } -} - -#Preview { - EditPanelView() -} - -struct VisualEffectView: NSViewRepresentable { - let material: NSVisualEffectView.Material - let blendingMode: NSVisualEffectView.BlendingMode - - func makeNSView(context _: Context) -> NSVisualEffectView { - let visualEffectView = NSVisualEffectView() - visualEffectView.material = material - visualEffectView.blendingMode = blendingMode - visualEffectView.state = NSVisualEffectView.State.active - visualEffectView.isEmphasized = true - return visualEffectView - } - - func updateNSView(_ visualEffectView: NSVisualEffectView, context _: Context) { - visualEffectView.material = material - visualEffectView.blendingMode = blendingMode - } -} diff --git a/boringNotch/components/TestView.swift b/boringNotch/components/TestView.swift deleted file mode 100644 index fbee74984..000000000 --- a/boringNotch/components/TestView.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// TestView.swift -// boringNotch -// -// Created by Richard Kunkli on 14/08/2024. -// - -import SwiftUI - -struct FluidSlider: View { - private let color: Color = Color.white - @State private var offset: CGFloat = 0 - var rectSize = CGSize(width: 300, height: 50) - var rectSize2 = CGSize(width: 200, height: 18) - var circleSize: CGFloat = 35 - @GestureState var isDragging: Bool = false - @State var previousOffset: CGFloat = 0 - @State private var isBeating: Bool = false - - var body: some View { - HStack { - slider - .frame(width: rectSize2.width, height: circleSize) - } - .padding() - .background(.black) - } - - private var slider: some View { - ZStack { - Canvas { context, size in - context.addFilter(.alphaThreshold(min: 0.5, max: 1, color: color)) - context.addFilter(.blur(radius: 10)) - - context.drawLayer { ctx in - if let rectangle = ctx.resolveSymbol(id: "Capsule") { - ctx.draw(rectangle, at: CGPoint(x: size.width/2, y: size.height/2)) - } - if let circle = ctx.resolveSymbol(id: "Circle") { - ctx.draw(circle, at: CGPoint(x: size.width/2 - rectSize2.width/2 + circleSize/2, y: size.height/2)) - } - } - } symbols: { - Capsule() - .frame(width: rectSize2.width, height: rectSize2.height, alignment: .center) - .tag("Capsule") - - Circle() - .frame(width: circleSize, height: circleSize, alignment: .center) - .offset(x: offset) - .animation(.spring(), value: isDragging) - .tag("Circle") - } - .simultaneousGesture( - DragGesture(minimumDistance: 0) - .updating($isDragging, body: { _, state, _ in - state = true - }) - .onChanged({ value in - self.offset = min(max(self.previousOffset + value.translation.width, 0), rectSize2.width - circleSize) - }) - .onEnded({ value in - self.previousOffset = self.offset - }) - ) - Circle() - .fill(Color.black) - .frame(width: circleSize * 0.6) - .overlay { - Image(systemName: "speaker.wave.2.fill") - .imageScale(.small) - } - .offset(x: (-rectSize2.width/2) + (circleSize/2)) - .offset(x: offset) - .animation(.spring(), value: isDragging) - .allowsHitTesting(false) - } - } - - - private var animation: Animation { - .spring(response: 0.5, dampingFraction: 0.6, blendDuration: 0.5) - } - - private var percentage: Int { - Int((offset) / (rectSize.width - circleSize) * 100) - } -} - -#Preview { - FluidSlider() -} diff --git a/boringNotch/components/VisualEffectView.swift b/boringNotch/components/VisualEffectView.swift new file mode 100644 index 000000000..93d4d9920 --- /dev/null +++ b/boringNotch/components/VisualEffectView.swift @@ -0,0 +1,27 @@ +// +// VisualEffectView.swift +// boringNotch +// +// Created by Richard Kunkli on 31/08/2024. +// + +import SwiftUI + +struct VisualEffectView: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context _: Context) -> NSVisualEffectView { + let visualEffectView = NSVisualEffectView() + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + visualEffectView.state = NSVisualEffectView.State.active + visualEffectView.isEmphasized = true + return visualEffectView + } + + func updateNSView(_ visualEffectView: NSVisualEffectView, context _: Context) { + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + } +} diff --git a/boringNotch/helpers/AudioPlayer.swift b/boringNotch/helpers/AudioPlayer.swift index c202873e9..029c9c7b2 100644 --- a/boringNotch/helpers/AudioPlayer.swift +++ b/boringNotch/helpers/AudioPlayer.swift @@ -8,8 +8,20 @@ import Foundation import AppKit -class AudioPlayer { +final class AudioPlayer: NSObject, NSSoundDelegate { + /// Playing sounds must be retained or playback is cut off when ARC + /// releases the instance at the end of the statement. + private var sound: NSSound? + func play(fileName: String, fileExtension: String) { - NSSound(contentsOf:Bundle.main.url(forResource: fileName, withExtension: fileExtension)!, byReference: false)?.play() + guard let url = Bundle.main.url(forResource: fileName, withExtension: fileExtension), + let sound = NSSound(contentsOf: url, byReference: false) else { return } + sound.delegate = self + self.sound = sound + sound.play() + } + + func sound(_ sound: NSSound, didFinishPlaying flag: Bool) { + self.sound = nil } } diff --git a/boringNotch/managers/AudioCaptureManager.swift b/boringNotch/managers/AudioCaptureManager.swift index 52331efad..bba1212f7 100644 --- a/boringNotch/managers/AudioCaptureManager.swift +++ b/boringNotch/managers/AudioCaptureManager.swift @@ -57,7 +57,9 @@ final class AudioCaptureManager: ObservableObject { private let levelsConsumers = NSHashTable.weakObjects() private var latestLevels: [Float]? - private let fft: vDSP.FFT + /// If setup fails (essentially impossible on real hardware) the FFT path + /// degrades to flat bars instead of taking down the whole app. + private let fft: vDSP.FFT? private let hannWindow: [Float] private let windowPowerScalar: Float private var samplesBuf: [Float] @@ -80,10 +82,7 @@ final class AudioCaptureManager: ObservableObject { ringBuffer = UnsafeMutablePointer.allocate(capacity: Self.ringCapacity) ringBuffer.initialize(repeating: 0, count: Self.ringCapacity) - guard let setup = vDSP.FFT(log2n: Self.log2n, radix: .radix2, ofType: DSPSplitComplex.self) else { - fatalError("Failed to create vDSP.FFT setup") - } - fft = setup + fft = vDSP.FFT(log2n: Self.log2n, radix: .radix2, ofType: DSPSplitComplex.self) let window = vDSP.window( ofType: Float.self, usingSequence: .hanningDenormalized, @@ -688,6 +687,7 @@ final class AudioCaptureManager: ObservableObject { } private func processFFT() { + guard let fft else { return } let n = Self.fftSize copyLatestSamples(count: n) diff --git a/boringNotch/managers/BatteryActivityManager.swift b/boringNotch/managers/BatteryActivityManager.swift index f9dab2a30..33a30c875 100644 --- a/boringNotch/managers/BatteryActivityManager.swift +++ b/boringNotch/managers/BatteryActivityManager.swift @@ -8,14 +8,6 @@ class BatteryActivityManager { static let shared = BatteryActivityManager() - var onBatteryLevelChange: ((Float) -> Void)? - var onMaxCapacityChange: ((Float?) -> Void)? - var onPowerModeChange: ((Bool) -> Void)? - var onPowerSourceChange: ((Bool) -> Void)? - var onChargingChange: ((Bool) -> Void)? - var onTimeToFullChargeChange: ((Int) -> Void)? - var onTimeToDischargeChange: ((Int) -> Void)? - private var batterySource: CFRunLoopSource? private var observers: [(BatteryEvent) -> Void] = [] private var previousBatteryInfo: BatteryInfo? @@ -196,18 +188,6 @@ class BatteryActivityManager { // Update previous battery info previousBatteryInfo = batteryInfo - - // Trigger optional callbacks - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.onBatteryLevelChange?(batteryInfo.currentCapacity) - self.onPowerSourceChange?(batteryInfo.isPluggedIn) - self.onChargingChange?(batteryInfo.isCharging) - self.onPowerModeChange?(batteryInfo.isInLowPowerMode) - self.onTimeToFullChargeChange?(batteryInfo.timeToFullCharge) - self.onTimeToDischargeChange?(batteryInfo.timeToDischarge) - self.onMaxCapacityChange?(batteryInfo.maxCapacity) - } } /// Enqueues a notification to be processed using the concurrency-based queue actor. diff --git a/boringNotch/managers/NotchSpaceManager.swift b/boringNotch/managers/NotchSpaceManager.swift index faf37efbf..1c9b44e26 100644 --- a/boringNotch/managers/NotchSpaceManager.swift +++ b/boringNotch/managers/NotchSpaceManager.swift @@ -10,9 +10,7 @@ import Foundation class NotchSpaceManager { static let shared = NotchSpaceManager() let notchSpace: CGSSpace - private var eventTap: CFMachPort? - private var runLoopSource: CFRunLoopSource? - + private init() { notchSpace = CGSSpace(level: 2147483647) // Max level } diff --git a/boringNotch/menu/StatusBarMenu.swift b/boringNotch/menu/StatusBarMenu.swift deleted file mode 100644 index 2bcbae2e0..000000000 --- a/boringNotch/menu/StatusBarMenu.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Cocoa - -class BoringStatusMenu: NSMenu { - - var statusItem: NSStatusItem! - - override init() { - super.init() - - // Initialize the status item - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - - if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "music.note", accessibilityDescription: "BoringNotch") - button.action = #selector(showMenu) - } - - // Set up the menu - let menu = NSMenu() - menu.addItem(NSMenuItem(title: "Quit", action: #selector(quitAction), keyEquivalent: "q")) - statusItem.menu = menu - } - -} diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 672ace2e9..8ed2bf5a2 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -9,14 +9,13 @@ import SwiftUI import Defaults // MARK: - File System Paths -private let availableDirectories = FileManager - .default - .urls(for: .documentDirectory, in: .userDomainMask) -let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! -let bundleIdentifier = Bundle.main.bundleIdentifier! +let documentsDirectory: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) +let bundleIdentifier: String = Bundle.main.bundleIdentifier ?? "theboringteam.boringnotch" let appVersion = "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""))" -let temporaryDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! +let temporaryDirectory: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) let spacing: CGFloat = 16 enum CalendarSelectionState: Codable, Defaults.Serializable { diff --git a/boringNotch/sizing/matters.swift b/boringNotch/sizing/matters.swift index ff5a90636..a4e35e3ec 100644 --- a/boringNotch/sizing/matters.swift +++ b/boringNotch/sizing/matters.swift @@ -9,9 +9,6 @@ import Defaults import Foundation import SwiftUI -let downloadSneakSize: CGSize = .init(width: 65, height: 1) -let batterySneakSize: CGSize = .init(width: 160, height: 1) - let shadowPadding: CGFloat = 20 let openNotchSize: CGSize = .init(width: 640, height: 190) let windowSize: CGSize = .init(width: openNotchSize.width, height: openNotchSize.height + shadowPadding) diff --git a/mediaremote-adapter/README.md b/mediaremote-adapter/README.md new file mode 100644 index 000000000..6223447f8 --- /dev/null +++ b/mediaremote-adapter/README.md @@ -0,0 +1,60 @@ +# MediaRemoteAdapter (vendored) + +This directory contains a **vendored copy** of +[ungive/mediaremote-adapter](https://github.com/ungive/mediaremote-adapter), +vendored under the terms of its BSD 3-Clause license +(see [../THIRD_PARTY_LICENSES](../THIRD_PARTY_LICENSES)). + +## Why it's here + +`MediaRemote.framework` is a private Apple framework. The sandboxed app +cannot link it directly, so `NowPlayingController` spawns +`mediaremote-adapter.pl` as a child process; the script dynamically loads +`MediaRemoteAdapter.framework`, which exposes now-playing data and commands +as a stream of JSON lines on stdout. + +| File | Used as | +|---|---| +| `mediaremote-adapter.pl` | Copied into the app bundle's Resources; spawned by `NowPlayingController` (`stream` command) | +| `MediaRemoteAdapter.framework` | Embedded in `Contents/Frameworks`; loaded by the script at runtime | +| `MediaRemoteAdapterTestClient` | Bundled diagnostic executable (`test` command) that verifies the adapter is functional/entitled on the host macOS | + +## Pinned version + +- **Upstream tag: `v0.7.2`** + (commit [`dc3ff17`](https://github.com/ungive/mediaremote-adapter/commit/dc3ff1740e2035a2490ec67d3b33322449af780a)) +- Vendored-in commit: `61487af` ("Update MediaRemoteAdapter", 2025-08-14) +- `mediaremote-adapter.pl` is byte-identical to the upstream `bin/mediaremote-adapter.pl` at that tag. +- The framework binary reports `CFBundleShortVersionString = 0.1` + (upstream does not sync this with release tags; the script match is the + authoritative pin). +- The binaries are ad-hoc signed; see "Signing caveat" below. + +## Updating to a new upstream release + +1. Check the upstream + [releases page](https://github.com/ungive/mediaremote-adapter/releases) + and pick a tag. +2. Build the framework and test client from source at that tag + (see the upstream `README.md` / `Makefile`), **or** download the + release artifacts published on the tag and verify their checksums + against the values published upstream. +3. Replace `MediaRemoteAdapter.framework`, `MediaRemoteAdapterTestClient` + and `mediaremote-adapter.pl` (upstream path: `bin/mediaremote-adapter.pl`) + in this directory. +4. Re-sign the binaries ad-hoc if you built from source + (`codesign -s - --force --deep MediaRemoteAdapter.framework`), matching + what the Xcode build phase expects. +5. Verify: run the app, confirm now-playing updates stream in, and run + `MediaRemoteAdapterTestClient` (or `mediaremote-adapter.pl … test`) + on the oldest supported macOS version. +6. **Update the pin in this README** (tag + commit hash) in the same commit. + +## Signing caveat + +The checked-in binaries were built on a maintainer's machine and are ad-hoc +signed (`codesign -dv` shows `Signature=adhoc`). They are **not** +independently verifiable bit-for-bit against the upstream source. Until the +build is wired to fetch pinned upstream release artifacts (with SHA-256 +verification) instead of committing binaries, treat this directory as +trusted-but-unverifiable and prefer rebuilding from source when updating. From 375f3a552025d27d7921e2e13ee3eb2acd445b4b Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 11:01:57 +0530 Subject: [PATCH 44/69] Phase 1 audit remediation: kill background polling, fix OSD hot paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always-on polling eliminated (~280k wakeups/day): - NotificationWatcher: adaptive poll cadence — 0.35s while banners are live, 2s when idle (was 0.35s forever, ~250k AX tree walks/day) - XPCHelperClient: replace 3s accessibility-auth XPC poll with activation-driven checks + lazy verification (was ~29k RPCs/day) Volume OSD hot path (~40 sync CoreAudio IPC calls on main per event): - Cache output-device snapshot (ID + validated property addresses), rebuilt only on default-device change - All CoreAudio IPC moved to a serial background queue; slider writes coalesce to 15Hz; UI publishes optimistically - Bonus fix: volume/mute listeners now re-register after device changes (they used to silently stop after the first switch) Brightness XPC bursts (3-4 RPCs per keypress): - adjustScreenBrightness now returns the resulting value in one RPC; target display UUID cached until display config changes - Keyboard backlight computes from cached state; repeat deltas accumulate so no key press is lost during round-trips - Protocol copies synced byte-identical between the two targets Render economy: - MusicManager: timestampDate only republishes when slider extrapolation inputs change (a pause/resume rebases to avoid overshoot) - YouTube Music: threshold position updates (>0.25s) so per-second ticks stop defeating Equatable - Drop drawingGroup() Metal re-rasterization; MusicSlider TimelineView 10Hz -> 2Hz (imperceptible on minutes-long tracks) - MediaKeyInterceptor: single-key CFPreferences read instead of parsing NSGlobalDomain per volume key; un-nest redundant Task hops Security & localization: - Remove NSAllowsArbitraryLoads (verified loopback is ATS-exempt; all app traffic is HTTPS or localhost) - 20 user-facing NSMenuItem/alert titles in ShelfItemViewModel routed through String(localized:) for Crowdin Verified: Debug build succeeds for app + XPC helper --- .../BoringNotchXPCHelper.swift | 15 +- .../BoringNotchXPCHelperProtocol.swift | 23 +- .../NotificationWatcher.swift | 25 +- boringNotch/Info.plist | 5 - .../YouTubeMusicController.swift | 6 +- .../BoringNotchXPCHelperProtocol.swift | 8 +- .../XPCHelperClient/XPCHelperClient.swift | 53 +- .../components/Notch/CompactHomeView.swift | 3 +- .../components/Notch/NotchHomeView.swift | 6 +- .../OSD/Managers/XPC/BrightnessManager.swift | 94 ++- .../OSD/Managers/XPC/VolumeManager.swift | 551 +++++++++--------- .../Shelf/ViewModels/ShelfItemViewModel.swift | 40 +- boringNotch/managers/MusicManager.swift | 14 +- .../observers/MediaKeyInterceptor.swift | 15 +- 14 files changed, 467 insertions(+), 391 deletions(-) diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift index 8c401bcd3..1cff8d841 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift @@ -293,10 +293,17 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { reply(false) } - @objc func adjustScreenBrightness(by value: Float, with reply: @escaping (Bool) -> Void) { + @objc func adjustScreenBrightness(by value: Float, with reply: @escaping (NSNumber?) -> Void) { let displayID = brightnessDisplayID() if displayServicesSetBrightnessSmooth(displayID: displayID, value: value) { - reply(true) + // Read back inside the helper so the client pays for one RPC + // instead of two (adjust + currentScreenBrightness). + var b: Float = 0 + if displayServicesGetBrightness(displayID: displayID, out: &b) { + reply(NSNumber(value: b)) + return + } + reply(nil) return } if let io = ioServiceFor(displayID: displayID) { @@ -305,12 +312,12 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { let target = max(0, min(1, ioCurrent + value)) let ok = IODisplaySetFloatParameter(io, 0, kIODisplayBrightnessKey as CFString, target) == kIOReturnSuccess IOObjectRelease(io) - reply(ok) + reply(ok ? NSNumber(value: target) : nil) return } IOObjectRelease(io) } - reply(false) + reply(nil) } // MARK: - Lunar Events diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift b/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift index 4922e2867..482f58ac7 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift @@ -52,7 +52,9 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { func displayIDForBrightness(with reply: @escaping (NSNumber?) -> Void) func currentScreenBrightness(with reply: @escaping (NSNumber?) -> Void) func setScreenBrightness(_ value: Float, with reply: @escaping (Bool) -> Void) - func adjustScreenBrightness(by value: Float, with reply: @escaping (Bool) -> Void) + /// Replies the resulting brightness in 0...1, or nil on failure. + /// Returning the value collapses the old adjust→read two-RPC dance into one call. + func adjustScreenBrightness(by value: Float, with reply: @escaping (NSNumber?) -> Void) // Lunar brightness events (performed by the helper) func isLunarAvailable(with reply: @escaping (Bool) -> Void) func startLunarEventStream(with reply: @escaping (Bool) -> Void) @@ -87,22 +89,3 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { /// object conforming to both. @objc protocol BoringNotchXPCAppDelegate: BoringNotchXPCHelperLunarListener, BoringNotchXPCHelperDelegate {} -/* - To use the service from an application or other process, use NSXPCConnection to establish a connection to the service by doing something like this: - - connectionToService = NSXPCConnection(serviceName: "theboringteam.boringnotch.BoringNotchXPCHelper") - connectionToService.remoteObjectInterface = NSXPCInterface(with: (any BoringNotchXPCHelperProtocol).self) - connectionToService.resume() - - Once you have a connection to the service, you can use it like this: - - if let proxy = connectionToService.remoteObjectProxy as? BoringNotchXPCHelperProtocol { - proxy.performCalculation(firstNumber: 23, secondNumber: 19) { result in - NSLog("Result of calculation is: \(result)") - } - } - - And, when you are finished with the service, clean up the connection like this: - - connectionToService.invalidate() -*/ diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index 4b02a4822..9b363502e 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -53,9 +53,14 @@ final class NotificationWatcher { /// missed tick can't let a held banner slip away. private let refreshInterval: TimeInterval = 2.5 - /// Banners live ~5s, so this catches every one with room to spare while - /// staying cheap — each tick is a shallow AX tree walk. - private let pollInterval: TimeInterval = 0.35 + /// Poll cadence adapts to activity: fast while a banner is live (they + /// dismiss in ~5s, so 0.35s catches every one), slow while idle. An + /// always-on 0.35s poll costs ~250k AX tree walks per day for a feature + /// that is used a handful of times; idling at 2s keeps first-detection + /// latency under a banner's lifetime while cutting that by ~85%. + private let activePollInterval: TimeInterval = 0.35 + private let idlePollInterval: TimeInterval = 2.0 + private var currentPollInterval: TimeInterval = 0 var isRunning: Bool { appElement != nil } @@ -86,10 +91,11 @@ final class NotificationWatcher { // state stays on the main queue, which is also where the helper // dispatches reply/action calls, so there's no locking to get wrong. let timer = DispatchSource.makeTimerSource(queue: .main) - timer.schedule(deadline: .now() + pollInterval, repeating: pollInterval) + timer.schedule(deadline: .now() + activePollInterval, repeating: activePollInterval) timer.setEventHandler { [weak self] in self?.scan() } timer.resume() pollTimer = timer + currentPollInterval = activePollInterval scan() return true @@ -134,6 +140,17 @@ final class NotificationWatcher { } refreshHeldBanners() + updatePollCadence() + } + + /// Drops the poll rate once nothing is on screen anymore; a slow idle + /// tick still catches the first banner of the next burst, at which + /// point the fast cadence resumes. + private func updatePollCadence() { + let wanted = (live.isEmpty && held.isEmpty) ? idlePollInterval : activePollInterval + guard wanted != currentPollInterval, let pollTimer else { return } + currentPollInterval = wanted + pollTimer.schedule(deadline: .now() + wanted, repeating: wanted) } /// Keeps held banners from timing out. diff --git a/boringNotch/Info.plist b/boringNotch/Info.plist index fe01cd831..b4a1e0019 100644 --- a/boringNotch/Info.plist +++ b/boringNotch/Info.plist @@ -4,11 +4,6 @@ CFBundleAllowMixedLocalizations - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - SUBundleName Boring Notch SUEnableAutomaticChecks diff --git a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift b/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift index c5a3b87bd..985639763 100644 --- a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift +++ b/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift @@ -276,10 +276,14 @@ final class YouTubeMusicController: MediaControllerProtocol { } guard let newPosition = position else { return } + // Threshold position updates: the websocket pushes ~1/s (often + // more), and an always-new lastUpdated defeated the Equatable + // check so every tick republished the whole playback state. + guard abs(newPosition - playbackState.currentTime) > 0.25 else { return } var copied = playbackState copied.currentTime = newPosition copied.lastUpdated = Date() - if copied != playbackState { playbackState = copied } + playbackState = copied case .repeatChanged: guard let data = message.extractData() else { return } diff --git a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift b/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift index e0ac72225..482f58ac7 100644 --- a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift +++ b/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift @@ -52,7 +52,9 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { func displayIDForBrightness(with reply: @escaping (NSNumber?) -> Void) func currentScreenBrightness(with reply: @escaping (NSNumber?) -> Void) func setScreenBrightness(_ value: Float, with reply: @escaping (Bool) -> Void) - func adjustScreenBrightness(by value: Float, with reply: @escaping (Bool) -> Void) + /// Replies the resulting brightness in 0...1, or nil on failure. + /// Returning the value collapses the old adjust→read two-RPC dance into one call. + func adjustScreenBrightness(by value: Float, with reply: @escaping (NSNumber?) -> Void) // Lunar brightness events (performed by the helper) func isLunarAvailable(with reply: @escaping (Bool) -> Void) func startLunarEventStream(with reply: @escaping (Bool) -> Void) @@ -76,7 +78,8 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { /// this as its connection's `exportedObject`. @objc protocol BoringNotchXPCHelperDelegate { /// Keys: token, appName, bundleID, title, subtitle, body, actions - /// (`actions` is newline-joined). + /// (`actions` is newline-joined). A plain string dictionary keeps the XPC + /// interface free of custom coded types. func notificationDidAppear(_ payload: [String: String]) func notificationDidDisappear(_ token: String) } @@ -85,3 +88,4 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { /// both Lunar events and notification banners — so the app vends a single /// object conforming to both. @objc protocol BoringNotchXPCAppDelegate: BoringNotchXPCHelperLunarListener, BoringNotchXPCHelperDelegate {} + diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index 66d385f02..c3ab05131 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -11,7 +11,7 @@ final class XPCHelperClient: NSObject { private var connection: NSXPCConnection? private var lastKnownAuthorization: Bool? private let notificationDelegate = NotificationXPCDelegate() - private var monitoringTask: Task? + @MainActor private var activationObserver: (any NSObjectProtocol)? private var lunarListener: BoringNotchXPCHelperLunarListener? private var hasLunarListener: Bool = false @@ -104,29 +104,37 @@ final class XPCHelperClient: NSObject { } // MARK: - Monitoring - nonisolated func startMonitoringAccessibilityAuthorization(every interval: TimeInterval = 3.0) { - // Ensure only one monitor exists + + /// AX trust has no public change notification. Polling the helper every + /// few seconds costs ~29k XPC round-trips per day for a boolean that + /// changes maybe twice a year, so instead we check once at start and + /// then on every app activation — the natural moment a user comes back + /// from System Settings after toggling the switch. Every AX-needing + /// call (isAccessibilityAuthorized/ensureAccessibilityAuthorization) + /// also re-posts changes itself via notifyAuthorizationChange. + nonisolated func startMonitoringAccessibilityAuthorization() { stopMonitoringAccessibilityAuthorization() - monitoringTask = Task.detached { [weak self] in - guard let self = self else { return } - while !Task.isCancelled { - // Call the helper method periodically which will notify on change - _ = await self.isAccessibilityAuthorized() - do { - try await Task.sleep(for: .seconds(interval)) - } catch { break } + Task { @MainActor [weak self] in + guard let self else { return } + activationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { _ = await self?.isAccessibilityAuthorized() } } } + // Initial probe so observers get the current state without waiting + // for the first activation. + Task { _ = await isAccessibilityAuthorized() } } nonisolated func stopMonitoringAccessibilityAuthorization() { - monitoringTask?.cancel() - monitoringTask = nil - } - - // Expose whether the client is actively monitoring (useful for tests/debug) - var isMonitoring: Bool { - return monitoringTask != nil + Task { @MainActor [weak self] in + guard let self, let activationObserver else { return } + NotificationCenter.default.removeObserver(activationObserver) + self.activationObserver = nil + } } // MARK: - Accessibility @@ -292,18 +300,19 @@ final class XPCHelperClient: NSObject { return false } } - nonisolated func adjustScreenBrightness(by value: Float) async -> Bool { + /// Returns the resulting brightness, or nil on failure. + nonisolated func adjustScreenBrightness(by value: Float) async -> Float? { do { let service = await MainActor.run { ensureRemoteService() } return try await service.withContinuation { service, continuation in - service.adjustScreenBrightness(by: value) { success in - continuation.resume(returning: success) + service.adjustScreenBrightness(by: value) { result in + continuation.resume(returning: result?.floatValue) } } } catch { - return false + return nil } } diff --git a/boringNotch/components/Notch/CompactHomeView.swift b/boringNotch/components/Notch/CompactHomeView.swift index 0f3001a0e..27050fbf9 100644 --- a/boringNotch/components/Notch/CompactHomeView.swift +++ b/boringNotch/components/Notch/CompactHomeView.swift @@ -138,7 +138,8 @@ struct CompactHomeView: View { // MARK: - Progress private var progressRow: some View { - TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in + // See NotchHomeView.musicSlider — 0.5s ticks are imperceptible here. + TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.5 : nil)) { timeline in MusicSliderView( sliderValue: $sliderValue, duration: $musicManager.songDuration, diff --git a/boringNotch/components/Notch/NotchHomeView.swift b/boringNotch/components/Notch/NotchHomeView.swift index c682c440b..d1996c640 100644 --- a/boringNotch/components/Notch/NotchHomeView.swift +++ b/boringNotch/components/Notch/NotchHomeView.swift @@ -22,7 +22,6 @@ struct MusicPlayerView: View { HStack { AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).frame(width: 120).padding(.all, 5 * (vm.notchSize.height / 190)) MusicControlsView(horizontalMediaGestureFeedback: horizontalMediaGestureFeedback) - .drawingGroup() .compositingGroup() } .contentShape(Rectangle()) @@ -202,7 +201,10 @@ struct MusicControlsView: View { } private var musicSlider: some View { - TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in + // 0.5s ticks are imperceptible on a minutes-long track (~1px steps) + // and the time labels only display whole seconds; the old 10Hz + // cadence re-rendered the slider 10x more than needed. + TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.5 : nil)) { timeline in MusicSliderView( sliderValue: $sliderValue, duration: $musicManager.songDuration, diff --git a/boringNotch/components/OSD/Managers/XPC/BrightnessManager.swift b/boringNotch/components/OSD/Managers/XPC/BrightnessManager.swift index f386ae975..29b58b666 100644 --- a/boringNotch/components/OSD/Managers/XPC/BrightnessManager.swift +++ b/boringNotch/components/OSD/Managers/XPC/BrightnessManager.swift @@ -15,19 +15,41 @@ final class BrightnessManager: ObservableObject { private let visibleDuration: TimeInterval = 1.2 private let client = XPCHelperClient.shared - private init() { refresh() } + /// Key repeats arriving while an XPC call is in flight accumulate here so + /// no press is lost — each press used to trigger its own 3-RPC sequence + /// (adjust + read + display lookup), and they would queue behind each other. + private var pendingDelta: Float = 0 + private var flushTask: Task? + + /// The brightness target display only changes with the display set. + private var cachedTargetUUID: String? + private var screenParametersObserver: (any NSObjectProtocol)? + + private init() { + refresh() + screenParametersObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.cachedTargetUUID = nil + } + } /// Determine which screen UUID should be used for brightness OSDs /// when the built‑in source is selected. This mirrors the logic in the /// XPC helper, which chooses the menu-bar display if it supports brightness and /// otherwise falls back to an internal panel. + /// Cached; invalidated when the display configuration changes. func brightnessTargetUUID() async -> String? { + if let cachedTargetUUID { return cachedTargetUUID } + var resolved: String? if let displayID = await client.displayIDForBrightness() { - if let screen = NSScreen.screens.first(where: { $0.cgDisplayID == displayID }) { - return screen.displayUUID - } + resolved = NSScreen.screens.first(where: { $0.cgDisplayID == displayID })?.displayUUID } - return NSScreen.main?.displayUUID + resolved = resolved ?? NSScreen.main?.displayUUID + cachedTargetUUID = resolved + return resolved } var shouldShowOverlay: Bool { Date().timeIntervalSince(lastChangeAt) < visibleDuration } @@ -41,16 +63,22 @@ final class BrightnessManager: ObservableObject { } @MainActor func setRelative(delta: Float) { - Task { @MainActor in - let ok = await client.adjustScreenBrightness(by: delta) - if ok { - let current = await client.currentScreenBrightness() ?? rawBrightness + pendingDelta += delta + guard flushTask == nil else { return } + flushTask = Task { @MainActor in + defer { flushTask = nil } + while pendingDelta != 0 { + let delta = pendingDelta + pendingDelta = 0 + // One RPC delivers both the adjustment and the resulting value. + guard let current = await client.adjustScreenBrightness(by: delta) else { + refresh() + return + } publish(brightness: current, touchDate: true) - let targetUUID = await brightnessTargetUUID() - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .brightness, value: CGFloat(current), targetScreenUUID: targetUUID) - } else { - refresh() + let uuid = await brightnessTargetUUID() + BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .brightness, value: CGFloat(current), targetScreenUUID: uuid) } } } @@ -93,6 +121,11 @@ final class KeyboardBacklightManager: ObservableObject { private let visibleDuration: TimeInterval = 1.2 private let client = XPCHelperClient.shared + /// Deltas accumulate while a set call is in flight so key repeats are + /// never lost; each flush costs exactly one XPC call. + private var pendingDelta: Float = 0 + private var flushTask: Task? + private init() { refresh() } var shouldShowOverlay: Bool { Date().timeIntervalSince(lastChangeAt) < visibleDuration } @@ -106,20 +139,29 @@ final class KeyboardBacklightManager: ObservableObject { } @MainActor func setRelative(delta: Float) { - Task { @MainActor in - let starting = await client.currentKeyboardBrightness() ?? rawBrightness - let target = max(0, min(1, starting + delta)) - let ok = await client.setKeyboardBrightness(target) - if ok { - publish(brightness: target, touchDate: true) - } else { - refresh() + pendingDelta += delta + guard flushTask == nil else { return } + flushTask = Task { @MainActor in + defer { flushTask = nil } + while pendingDelta != 0 { + let delta = pendingDelta + pendingDelta = 0 + // Compute from the cached value — the read-back RPC the old + // code did before every set doubles the cost per key press. + let target = max(0, min(1, rawBrightness + delta)) + let ok = await client.setKeyboardBrightness(target) + if ok { + publish(brightness: target, touchDate: true) + } else { + refresh() + return + } + BoringViewCoordinator.shared.toggleSneakPeek( + status: true, + type: .backlight, + value: CGFloat(target) + ) } - BoringViewCoordinator.shared.toggleSneakPeek( - status: true, - type: .backlight, - value: CGFloat(target) - ) } } diff --git a/boringNotch/components/OSD/Managers/XPC/VolumeManager.swift b/boringNotch/components/OSD/Managers/XPC/VolumeManager.swift index f8bdcc0cf..8cc4c36a9 100644 --- a/boringNotch/components/OSD/Managers/XPC/VolumeManager.swift +++ b/boringNotch/components/OSD/Managers/XPC/VolumeManager.swift @@ -19,360 +19,361 @@ final class VolumeManager: NSObject, ObservableObject { let visibleDuration: TimeInterval = 1.2 - private var didInitialFetch = false private let step: Float32 = 1.0 / 16.0 // Fallback software if hardware mute is not supported private var previousVolumeBeforeMute: Float32 = 0.2 private var softwareMuted: Bool = false + private var didInitialFetch = false + /// Main-side mirror of snapshot.supportsMute for mute-path decisions. + private var deviceSupportsMute = false + + /// All CoreAudio IPC runs on this serial queue. Every property + /// read/write is a synchronous round-trip to coreaudiod, and slider + /// drags can fire change callbacks at 60–120 Hz — none of it belongs + /// on the main thread (the old implementation ran ~40 IPC calls on + /// main *per volume event*). + private let audioQueue = DispatchQueue(label: "com.boringnotch.osd.volume", qos: .userInitiated) + + /// Cached output-device snapshot, rebuilt only when the default output + /// device changes. AudioObjectPropertyAddress values don't mutate + /// between calls, so probing Has/GetSize once per device replaces the + /// ~30 probe calls the old code made on every volume event. + private struct DeviceSnapshot { + var deviceID: AudioObjectID = kAudioObjectUnknown + var volumeElements: [UInt32] = [] + var supportsMute = false + } + /// Only touched on audioQueue. + private var snapshot = DeviceSnapshot() + + /// Writes are coalesced to 15 Hz: a drag gesture produces far more + /// callbacks than hardware (or the user) benefits from. audioQueue-only. + private var pendingWriteTarget: Float32? + private var writeFlushScheduled = false + private let writeFlushInterval: TimeInterval = 1.0 / 15.0 + + /// Volume/mute listeners must be re-registered whenever the output + /// device changes (the old code registered once at init — after a + /// device switch, live updates silently stopped). + private struct ListenerRegistration { + var deviceID: AudioObjectID + var address: AudioObjectPropertyAddress + var block: AudioObjectPropertyListenerBlock + } + private var listenerRegistrations: [ListenerRegistration] = [] private override init() { super.init() - setupAudioListener() - fetchCurrentVolume() + installDeviceChangeListener() + audioQueue.async { [self] in + rebuildSnapshotLocked() + syncFromDeviceLocked() + } } var shouldShowOverlay: Bool { Date().timeIntervalSince(lastChangeAt) < visibleDuration } // MARK: - Public Control API + @MainActor func increase(stepDivisor: Float = 1.0) { - let divisor = max(stepDivisor, 0.25) - let delta = step / Float32(divisor) - let current = readVolumeInternal() ?? rawVolume - let target = max(0, min(1, current + delta)) - setAbsolute(target) - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .volume, value: CGFloat(target)) + adjustInSteps(1, stepDivisor: stepDivisor) } @MainActor func decrease(stepDivisor: Float = 1.0) { - let divisor = max(stepDivisor, 0.25) - let delta = step / Float32(divisor) - let current = readVolumeInternal() ?? rawVolume - let target = max(0, min(1, current - delta)) - setAbsolute(target) - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .volume, value: CGFloat(target)) + adjustInSteps(-1, stepDivisor: stepDivisor) } - @MainActor func toggleMuteAction() { - // Determine expected resulting state immediately and show OSD with that value - let deviceID = systemOutputDeviceID() - var willBeMuted = false - var resultingVolume: Float32 = rawVolume + @MainActor private func adjustInSteps(_ direction: Float32, stepDivisor: Float) { + let delta = step / Float32(max(stepDivisor, 0.25)) * direction + commit(target: max(0, min(1, rawVolume + delta))) + } - if deviceID == kAudioObjectUnknown { - willBeMuted = !softwareMuted - resultingVolume = willBeMuted ? 0 : previousVolumeBeforeMute + @MainActor func toggleMuteAction() { + let willBeMuted = !isMuted + let resultingVolume: Float32 = rawVolume > 0.001 ? rawVolume : previousVolumeBeforeMute + + if willBeMuted { + if deviceSupportsMute { + enqueueHardwareMute(true) + } else { + if rawVolume > 0.001 { previousVolumeBeforeMute = rawVolume } + softwareMuted = true + requestVolumeWrite(0) + } + // Hardware mute preserves the underlying volume level. + publish(volume: deviceSupportsMute ? rawVolume : 0, muted: true, touchDate: true) + BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .volume, value: 0) } else { - let currentMuted = isMutedInternal() - willBeMuted = !currentMuted - resultingVolume = willBeMuted ? 0 : (readVolumeInternal() ?? rawVolume) + if deviceSupportsMute { + enqueueHardwareMute(false) + } else { + softwareMuted = false + requestVolumeWrite(previousVolumeBeforeMute) + } + publish(volume: deviceSupportsMute ? rawVolume : resultingVolume, muted: false, touchDate: true) + BoringViewCoordinator.shared.toggleSneakPeek( + status: true, type: .volume, value: CGFloat(resultingVolume)) } + } - toggleMuteInternal() - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .volume, value: CGFloat(willBeMuted ? 0 : resultingVolume)) + @MainActor func setAbsolute(_ value: Float32) { + commit(target: max(0, min(1, value))) } - - func refresh() { fetchCurrentVolume() } - - func adjustRelative(delta: Float32) { - if isMutedInternal() { toggleMuteInternal() } - guard let current = readVolumeInternal() else { - fetchCurrentVolume() - return + + /// Shared by keys and slider: optimistically publishes the intent (the + /// OSD bar animates instantly) and defers hardware I/O to the coalesced + /// writer — listeners confirm the ground truth afterwards. + @MainActor private func commit(target: Float32) { + if isMuted && target > 0 { + // Unmute intent: hardware unmute for mute-capable devices, and + // the volume write below restores sound on the software path. + softwareMuted = false + enqueueHardwareMute(false) + } + publish(volume: target, muted: target > 0 ? false : isMuted, touchDate: true) + if target == 0 && !isMuted { + // Historical behavior: driving volume to zero engages mute. + if deviceSupportsMute { + enqueueHardwareMute(true) + } else { + if rawVolume > 0.001 { previousVolumeBeforeMute = rawVolume } + softwareMuted = true + } + publish(volume: target, muted: true, touchDate: true) } - let target = max(0, min(1, current + delta)) - writeVolumeInternal(target) - publish(volume: target, muted: isMutedInternal(), touchDate: true) + requestVolumeWrite(target) + BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .volume, value: CGFloat(target)) } - @MainActor func setAbsolute(_ value: Float32) { - let clamped = max(0, min(1, value)) - let currentlyMuted = isMutedInternal() - if currentlyMuted && clamped > 0 { - toggleMuteInternal() + // MARK: - Hardware I/O (audioQueue) + + /// Coalesced writer: runs at most every writeFlushInterval and always + /// flushes the *latest* requested target. + private func requestVolumeWrite(_ value: Float32) { + audioQueue.async { [self] in + pendingWriteTarget = value + guard !writeFlushScheduled else { return } + writeFlushScheduled = true + audioQueue.asyncAfter(deadline: .now() + writeFlushInterval) { [self] in + writeFlushScheduled = false + guard let target = pendingWriteTarget else { return } + pendingWriteTarget = nil + writeVolumeLocked(target) + syncFromDeviceLocked() + } } + } - writeVolumeInternal(clamped) - - if clamped == 0 && !currentlyMuted { - toggleMuteInternal() + private func enqueueHardwareMute(_ muted: Bool) { + audioQueue.async { [self] in + guard snapshot.supportsMute else { return } + var value: UInt32 = muted ? 1 : 0 + var addr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyMute, + mScope: kAudioDevicePropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain + ) + AudioObjectSetPropertyData( + snapshot.deviceID, &addr, 0, nil, UInt32(MemoryLayout.size), &value) + syncFromDeviceLocked() } - - publish(volume: clamped, muted: isMutedInternal(), touchDate: true) } - // MARK: - CoreAudio Helpers - private func systemOutputDeviceID() -> AudioObjectID { - var defaultDeviceID = kAudioObjectUnknown - var propertyAddress = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultOutputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain - ) - var dataSize = UInt32(MemoryLayout.size) - let status = AudioObjectGetPropertyData( - AudioObjectID(kAudioObjectSystemObject), - &propertyAddress, - 0, - nil, - &dataSize, - &defaultDeviceID - ) - if status != noErr { return kAudioObjectUnknown } - return defaultDeviceID - } + // MARK: - Snapshot & Listeners (audioQueue) + + /// Removes listeners from the old device, probes the new one once, and + /// attaches volume/mute listeners to it. CoreAudio delivers every + /// subsequent change event-driven, so steady state costs zero polling. + private func rebuildSnapshotLocked() { + for registration in listenerRegistrations { + var address = registration.address + AudioObjectRemovePropertyListenerBlock( + registration.deviceID, &address, audioQueue, registration.block) + } + listenerRegistrations.removeAll() - private func fetchCurrentVolume() { let deviceID = systemOutputDeviceID() - guard deviceID != kAudioObjectUnknown else { return } - var volumes: [Float32] = [] - let candidateElements: [UInt32] = [kAudioObjectPropertyElementMain, 1, 2, 3, 4] - for element in candidateElements { - if let v = readValidatedScalar(deviceID: deviceID, element: element) { - volumes.append(v) + var snap = DeviceSnapshot(deviceID: deviceID) + if deviceID != kAudioObjectUnknown { + snap.volumeElements = [kAudioObjectPropertyElementMain, 1, 2, 3, 4].filter { + probeScalar(deviceID: deviceID, element: $0) } + snap.supportsMute = probeMute(deviceID: deviceID) } - if !volumes.isEmpty { - let avg = max(0, min(1, volumes.reduce(0, +) / Float32(volumes.count))) - DispatchQueue.main.async { - if self.rawVolume != avg { - if self.didInitialFetch { - self.lastChangeAt = Date() - } - } - self.rawVolume = avg - self.didInitialFetch = true + snapshot = snap - } + let supports = snap.supportsMute + DispatchQueue.main.async { [self] in + deviceSupportsMute = supports } - var muteAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyMute, + guard deviceID != kAudioObjectUnknown else { return } + // Devices without a master volume only expose per-channel scalars; + // listen on every element the snapshot validated. + for element in snap.volumeElements { + attachListenerLocked( + deviceID: deviceID, selector: kAudioDevicePropertyVolumeScalar, element: element) + } + if snap.supportsMute { + attachListenerLocked( + deviceID: deviceID, selector: kAudioDevicePropertyMute, + element: kAudioObjectPropertyElementMain) + } + } + + private func attachListenerLocked( + deviceID: AudioObjectID, selector: AudioObjectPropertySelector, element: UInt32 + ) { + var address = AudioObjectPropertyAddress( + mSelector: selector, mScope: kAudioDevicePropertyScopeOutput, - mElement: kAudioObjectPropertyElementMain + mElement: element ) - if AudioObjectHasProperty(deviceID, &muteAddr) { - var sizeNeeded: UInt32 = 0 - if AudioObjectGetPropertyDataSize(deviceID, &muteAddr, 0, nil, &sizeNeeded) == noErr, - sizeNeeded == UInt32(MemoryLayout.size) - { - var muted: UInt32 = 0 - var mSize = sizeNeeded - if AudioObjectGetPropertyData(deviceID, &muteAddr, 0, nil, &mSize, &muted) == noErr - { - let newMuted = muted != 0 - DispatchQueue.main.async { - if self.isMuted != newMuted { self.lastChangeAt = Date() } - self.isMuted = newMuted - } - } - } + let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in + // Callbacks are delivered on audioQueue already. + self?.syncFromDeviceLocked() } + guard AudioObjectAddPropertyListenerBlock(deviceID, &address, audioQueue, block) == noErr + else { return } + listenerRegistrations.append( + ListenerRegistration(deviceID: deviceID, address: address, block: block)) } - private func setupAudioListener() { - let deviceID = systemOutputDeviceID() - guard deviceID != kAudioObjectUnknown else { return } - - var defaultDevAddr = AudioObjectPropertyAddress( + /// The system-object device-change listener is permanent (registered + /// once) and is delivered on audioQueue like every other callback. + private func installDeviceChangeListener() { + var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) AudioObjectAddPropertyListenerBlock( - AudioObjectID(kAudioObjectSystemObject), &defaultDevAddr, nil - ) { _, _ in - self.fetchCurrentVolume() + AudioObjectID(kAudioObjectSystemObject), &address, audioQueue + ) { [weak self] _, _ in + self?.rebuildSnapshotLocked() + self?.syncFromDeviceLocked() } + } - var masterAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyVolumeScalar, - mScope: kAudioDevicePropertyScopeOutput, - mElement: kAudioObjectPropertyElementMain - ) - if AudioObjectHasProperty(deviceID, &masterAddr) { - AudioObjectAddPropertyListenerBlock(deviceID, &masterAddr, nil) { _, _ in - self.fetchCurrentVolume() - } - } else { - for ch in [UInt32(1), UInt32(2)] { - var chAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyVolumeScalar, - mScope: kAudioDevicePropertyScopeOutput, - mElement: ch - ) - if AudioObjectHasProperty(deviceID, &chAddr) { - AudioObjectAddPropertyListenerBlock(deviceID, &chAddr, nil) { _, _ in - self.fetchCurrentVolume() - } - } - } - } - - // Mute - var muteAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyMute, - mScope: kAudioDevicePropertyScopeOutput, - mElement: kAudioObjectPropertyElementMain - ) - if AudioObjectHasProperty(deviceID, &muteAddr) { - AudioObjectAddPropertyListenerBlock(deviceID, &muteAddr, nil) { _, _ in - self.fetchCurrentVolume() - } + /// Reads ground truth using the cached snapshot (no Has/GetSize + /// probing, unlike the old fetch path) and mirrors it to the published + /// main-side state. + private func syncFromDeviceLocked() { + guard snapshot.deviceID != kAudioObjectUnknown else { return } + let volume = readVolumeLocked() + let muted = snapshot.supportsMute ? readMuteLocked() : nil + DispatchQueue.main.async { [self] in + applyFromDevice(volume: volume, hardwareMuted: muted) } } - private func readVolumeInternal() -> Float32? { - let deviceID = systemOutputDeviceID() - if deviceID == kAudioObjectUnknown { return nil } - var collected: [Float32] = [] - for el in [kAudioObjectPropertyElementMain, 1, 2, 3, 4] { - if let v = readValidatedScalar(deviceID: deviceID, element: el) { collected.append(v) } - } - guard !collected.isEmpty else { return nil } - return collected.reduce(0, +) / Float32(collected.count) + @MainActor private func applyFromDevice(volume: Float32?, hardwareMuted: Bool?) { + let effectiveMuted = hardwareMuted ?? softwareMuted + let changed = + (volume != nil && abs(volume! - rawVolume) > 0.0005) || effectiveMuted != isMuted + // The initial fetch arms change detection without touching the date; + // only later device-reported changes bring the OSD up. + if changed && didInitialFetch { lastChangeAt = Date() } + if let volume { rawVolume = volume } + isMuted = effectiveMuted + didInitialFetch = true } - private func writeVolumeInternal(_ value: Float32) { - let deviceID = systemOutputDeviceID() - if deviceID == kAudioObjectUnknown { return } - let newVal = max(0, min(1, value)) + // MARK: - CoreAudio Primitives (audioQueue, snapshot-backed) - var written = false - if writeValidatedScalar( - deviceID: deviceID, element: kAudioObjectPropertyElementMain, value: newVal) - { - written = true - } else { - var any = false - for el in [UInt32](1...4) { - if writeValidatedScalar(deviceID: deviceID, element: el, value: newVal) { - any = true - } - } - written = any - } - if !written { - // silent fail - } + private func systemOutputDeviceID() -> AudioObjectID { + var defaultDeviceID = kAudioObjectUnknown + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var dataSize = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, + nil, + &dataSize, + &defaultDeviceID + ) + if status != noErr { return kAudioObjectUnknown } + return defaultDeviceID } - private func isMutedInternal() -> Bool { - let deviceID = systemOutputDeviceID() - if deviceID == kAudioObjectUnknown { return softwareMuted } - var muteAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyMute, + private func probeScalar(deviceID: AudioObjectID, element: UInt32) -> Bool { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyVolumeScalar, mScope: kAudioDevicePropertyScopeOutput, - mElement: kAudioObjectPropertyElementMain + mElement: element ) - guard AudioObjectHasProperty(deviceID, &muteAddr) else { return softwareMuted } + guard AudioObjectHasProperty(deviceID, &addr) else { return false } var sizeNeeded: UInt32 = 0 - guard AudioObjectGetPropertyDataSize(deviceID, &muteAddr, 0, nil, &sizeNeeded) == noErr, - sizeNeeded == UInt32(MemoryLayout.size) - else { return softwareMuted } - var muted: UInt32 = 0 - var size = sizeNeeded - if AudioObjectGetPropertyData(deviceID, &muteAddr, 0, nil, &size, &muted) == noErr { - return muted != 0 - } - return softwareMuted + return AudioObjectGetPropertyDataSize(deviceID, &addr, 0, nil, &sizeNeeded) == noErr + && sizeNeeded == UInt32(MemoryLayout.size) } - private func toggleMuteInternal() { - let deviceID = systemOutputDeviceID() - if deviceID == kAudioObjectUnknown { - performSoftwareMuteToggle(currentVolume: rawVolume) - return - } - var muteAddr = AudioObjectPropertyAddress( + private func probeMute(deviceID: AudioObjectID) -> Bool { + var addr = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyMute, mScope: kAudioDevicePropertyScopeOutput, mElement: kAudioObjectPropertyElementMain ) - if !AudioObjectHasProperty(deviceID, &muteAddr) { - let currentVol = readVolumeInternal() ?? rawVolume - performSoftwareMuteToggle(currentVolume: currentVol) - return - } + guard AudioObjectHasProperty(deviceID, &addr) else { return false } var sizeNeeded: UInt32 = 0 - guard AudioObjectGetPropertyDataSize(deviceID, &muteAddr, 0, nil, &sizeNeeded) == noErr, - sizeNeeded == UInt32(MemoryLayout.size) - else { - let currentVol = readVolumeInternal() ?? rawVolume - performSoftwareMuteToggle(currentVolume: currentVol) - return - } - var muted: UInt32 = 0 - var size = sizeNeeded - if AudioObjectGetPropertyData(deviceID, &muteAddr, 0, nil, &size, &muted) == noErr { - var newVal: UInt32 = muted == 0 ? 1 : 0 - AudioObjectSetPropertyData(deviceID, &muteAddr, 0, nil, size, &newVal) - let vol = readVolumeInternal() ?? rawVolume - publish(volume: vol, muted: newVal != 0, touchDate: true) - } else { - let currentVol = readVolumeInternal() ?? rawVolume - performSoftwareMuteToggle(currentVolume: currentVol) - } + return AudioObjectGetPropertyDataSize(deviceID, &addr, 0, nil, &sizeNeeded) == noErr + && sizeNeeded == UInt32(MemoryLayout.size) } - private func performSoftwareMuteToggle(currentVolume: Float32) { - if softwareMuted { - let restore = max(0, min(1, previousVolumeBeforeMute)) - writeVolumeInternal(restore) - softwareMuted = false - publish(volume: restore, muted: false, touchDate: true) - } else { - if currentVolume > 0.001 { previousVolumeBeforeMute = currentVolume } - writeVolumeInternal(0) - softwareMuted = true - publish(volume: 0, muted: true, touchDate: true) + private func readVolumeLocked() -> Float32? { + var collected: [Float32] = [] + for element in snapshot.volumeElements { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyVolumeScalar, + mScope: kAudioDevicePropertyScopeOutput, + mElement: element + ) + var vol = Float32(0) + var size = UInt32(MemoryLayout.size) + if AudioObjectGetPropertyData(snapshot.deviceID, &addr, 0, nil, &size, &vol) == noErr { + collected.append(vol) + } } + guard !collected.isEmpty else { return nil } + return max(0, min(1, collected.reduce(0, +) / Float32(collected.count))) } - private func readValidatedScalar(deviceID: AudioObjectID, element: UInt32) -> Float32? { - var addr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyVolumeScalar, - mScope: kAudioDevicePropertyScopeOutput, - mElement: element - ) - guard AudioObjectHasProperty(deviceID, &addr) else { return nil } - var sizeNeeded: UInt32 = 0 - guard AudioObjectGetPropertyDataSize(deviceID, &addr, 0, nil, &sizeNeeded) == noErr, - sizeNeeded == UInt32(MemoryLayout.size) - else { return nil } - var vol = Float32(0) - var size = sizeNeeded - let status = AudioObjectGetPropertyData(deviceID, &addr, 0, nil, &size, &vol) - return status == noErr ? vol : nil + private func writeVolumeLocked(_ value: Float32) { + guard snapshot.deviceID != kAudioObjectUnknown else { return } + let newVal = max(0, min(1, value)) + for element in snapshot.volumeElements { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyVolumeScalar, + mScope: kAudioDevicePropertyScopeOutput, + mElement: element + ) + var val = newVal + AudioObjectSetPropertyData( + snapshot.deviceID, &addr, 0, nil, UInt32(MemoryLayout.size), &val) + } } - private func writeValidatedScalar(deviceID: AudioObjectID, element: UInt32, value: Float32) - -> Bool - { + private func readMuteLocked() -> Bool? { var addr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyVolumeScalar, + mSelector: kAudioDevicePropertyMute, mScope: kAudioDevicePropertyScopeOutput, - mElement: element + mElement: kAudioObjectPropertyElementMain ) - guard AudioObjectHasProperty(deviceID, &addr) else { return false } - var sizeNeeded: UInt32 = 0 - guard AudioObjectGetPropertyDataSize(deviceID, &addr, 0, nil, &sizeNeeded) == noErr, - sizeNeeded == UInt32(MemoryLayout.size) - else { return false } - var val = value - return AudioObjectSetPropertyData(deviceID, &addr, 0, nil, sizeNeeded, &val) == noErr + var muted: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(snapshot.deviceID, &addr, 0, nil, &size, &muted) == noErr + else { return nil } + return muted != 0 } - private func publish(volume: Float32, muted: Bool, touchDate: Bool) { - DispatchQueue.main.async { - if touchDate { self.lastChangeAt = Date() } - self.rawVolume = volume - self.isMuted = muted - } + @MainActor private func publish(volume: Float32, muted: Bool, touchDate: Bool) { + if touchDate { lastChangeAt = Date() } + rawVolume = volume + isMuted = muted } } - -extension Array where Element == Float32 { - fileprivate var average: Float32? { isEmpty ? nil : reduce(0, +) / Float32(count) } -} - - diff --git a/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift b/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift index 69b2e2053..50187977e 100644 --- a/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift +++ b/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift @@ -263,11 +263,11 @@ final class ShelfItemViewModel: ObservableObject { } if !selectedOpenableURLs.isEmpty { - addMenuItem(title: "Open") + addMenuItem(title: String(localized: "Open")) } if !selectedOpenableURLs.isEmpty { - let openWith = NSMenuItem(title: "Open With", action: nil, keyEquivalent: "") + let openWith = NSMenuItem(title: String(localized: "Open With"), action: nil, keyEquivalent: "") let submenu = NSMenu() // Choose a representative URL to compute apps (prefer current item if not a folder) @@ -292,7 +292,7 @@ final class ShelfItemViewModel: ObservableObject { let defaultApp = defaultAppURL() if openWithApps.isEmpty { - let noApps = NSMenuItem(title: "No Compatible Apps Found", action: nil, keyEquivalent: "") + let noApps = NSMenuItem(title: String(localized: "No Compatible Apps Found"), action: nil, keyEquivalent: "") noApps.isEnabled = false submenu.addItem(noApps) } else { @@ -327,7 +327,7 @@ final class ShelfItemViewModel: ObservableObject { } submenu.addItem(NSMenuItem.separator()) - let other = NSMenuItem(title: "Other…", action: nil, keyEquivalent: "") + let other = NSMenuItem(title: String(localized: "Other…"), action: nil, keyEquivalent: "") other.representedObject = "__OTHER__" submenu.addItem(other) @@ -335,45 +335,45 @@ final class ShelfItemViewModel: ObservableObject { menu.addItem(openWith) } - if !selectedFileURLs.isEmpty { addMenuItem(title: "Show in Finder") } + if !selectedFileURLs.isEmpty { addMenuItem(title: String(localized: "Show in Finder")) } // Allow Quick Look for files and link URLs if !selectedFileURLs.isEmpty || !selectedLinkURLs.isEmpty { // Add Quick Look menu item - let quickLookItem = NSMenuItem(title: "Quick Look", action: nil, keyEquivalent: "") + let quickLookItem = NSMenuItem(title: String(localized: "Quick Look"), action: nil, keyEquivalent: "") menu.addItem(quickLookItem) // Add Slideshow as alternate menu item (shown when Option key is held) - let slideshowItem = NSMenuItem(title: "Quick Look", action: nil, keyEquivalent: "") + let slideshowItem = NSMenuItem(title: String(localized: "Quick Look"), action: nil, keyEquivalent: "") slideshowItem.isAlternate = true slideshowItem.keyEquivalentModifierMask = [.option] menu.addItem(slideshowItem) } menu.addItem(NSMenuItem.separator()) - addMenuItem(title: "Share…") + addMenuItem(title: String(localized: "Share…")) // Add image processing options for image files grouped under "Image Actions" let imageURLs = selectedFileURLs.filter { ImageProcessingService.shared.isImageFile($0) } if !imageURLs.isEmpty { menu.addItem(NSMenuItem.separator()) - let imageActions = NSMenuItem(title: "Image Actions", action: nil, keyEquivalent: "") + let imageActions = NSMenuItem(title: String(localized: "Image Actions"), action: nil, keyEquivalent: "") let imageSubmenu = NSMenu() // Remove Background - only for single images if imageURLs.count == 1 { - let removeBg = NSMenuItem(title: "Remove Background", action: nil, keyEquivalent: "") + let removeBg = NSMenuItem(title: String(localized: "Remove Background"), action: nil, keyEquivalent: "") imageSubmenu.addItem(removeBg) } // Convert Image - only for single images if imageURLs.count == 1 { - let convertItem = NSMenuItem(title: "Convert Image…", action: nil, keyEquivalent: "") + let convertItem = NSMenuItem(title: String(localized: "Convert Image…"), action: nil, keyEquivalent: "") imageSubmenu.addItem(convertItem) } // Create PDF - for one or more images - let createPDF = NSMenuItem(title: "Create PDF", action: nil, keyEquivalent: "") + let createPDF = NSMenuItem(title: String(localized: "Create PDF"), action: nil, keyEquivalent: "") imageSubmenu.addItem(createPDF) imageActions.submenu = imageSubmenu @@ -383,24 +383,24 @@ final class ShelfItemViewModel: ObservableObject { // Add compression option for files/folders (single or multiple) if !selectedFileURLs.isEmpty { - let compressItem = NSMenuItem(title: "Compress", action: nil, keyEquivalent: "") + let compressItem = NSMenuItem(title: String(localized: "Compress"), action: nil, keyEquivalent: "") menu.addItem(compressItem) } - if selectedItems.count == 1, case .file(_) = item.kind { addMenuItem(title: "Rename") } + if selectedItems.count == 1, case .file(_) = item.kind { addMenuItem(title: String(localized: "Rename")) } // Always show "Copy" for all item types - addMenuItem(title: "Copy") + addMenuItem(title: String(localized: "Copy")) // If there are file URLs, add "Copy Path" as an alternate menu item (Option key) if !selectedFileURLs.isEmpty { - let copyPathItem = NSMenuItem(title: "Copy Path", action: nil, keyEquivalent: "") + let copyPathItem = NSMenuItem(title: String(localized: "Copy Path"), action: nil, keyEquivalent: "") copyPathItem.isAlternate = true copyPathItem.keyEquivalentModifierMask = [.option] menu.addItem(copyPathItem) } menu.addItem(NSMenuItem.separator()) - addMenuItem(title: "Remove") + addMenuItem(title: String(localized: "Remove")) let actionTarget = MenuActionTarget(item: item, view: view, viewModel: self) @@ -840,7 +840,7 @@ final class ShelfItemViewModel: ObservableObject { } } catch { print("❌ Failed to remove background: \(error.localizedDescription)") - showErrorAlert(title: "Background Removal Failed", message: error.localizedDescription) + showErrorAlert(title: String(localized: "Background Removal Failed"), message: error.localizedDescription) } } } @@ -870,7 +870,7 @@ final class ShelfItemViewModel: ObservableObject { } } catch { print("❌ Failed to create PDF: \(error.localizedDescription)") - showErrorAlert(title: "PDF Creation Failed", message: error.localizedDescription) + showErrorAlert(title: String(localized: "PDF Creation Failed"), message: error.localizedDescription) } } } @@ -1078,7 +1078,7 @@ final class ShelfItemViewModel: ObservableObject { } } catch { print("❌ Failed to convert image: \(error.localizedDescription)") - showErrorAlert(title: "Image Conversion Failed", message: error.localizedDescription) + showErrorAlert(title: String(localized: "Image Conversion Failed"), message: error.localizedDescription) } } } diff --git a/boringNotch/managers/MusicManager.swift b/boringNotch/managers/MusicManager.swift index 275dbdcab..a138464d3 100644 --- a/boringNotch/managers/MusicManager.swift +++ b/boringNotch/managers/MusicManager.swift @@ -186,7 +186,8 @@ class MusicManager: ObservableObject { @MainActor private func updateFromPlaybackState(_ state: PlaybackState) { // Check for playback state changes (playing/paused) - if state.isPlaying != self.isPlaying { + let playingStateChanged = state.isPlaying != self.isPlaying + if playingStateChanged { NSLog("Playback state changed: \(state.isPlaying ? "Playing" : "Paused")") withAnimation(.smooth) { self.isPlaying = state.isPlaying @@ -299,8 +300,15 @@ class MusicManager: ObservableObject { if volumeChanged { self.volume = state.volume } - - self.timestampDate = state.lastUpdated + + // The slider extrapolates from (elapsedTime, timestampDate); only + // republish when an extrapolation input actually changed — otherwise + // every no-op stream event invalidates the whole view tree. A pause/ + // resume must rebase it too, or the estimate overshoots by the pause + // duration. + if timeChanged || playbackRateChanged || playingStateChanged { + self.timestampDate = state.lastUpdated + } } func toggleFavoriteTrack() { diff --git a/boringNotch/observers/MediaKeyInterceptor.swift b/boringNotch/observers/MediaKeyInterceptor.swift index c75c7d7c1..992d34a58 100644 --- a/boringNotch/observers/MediaKeyInterceptor.swift +++ b/boringNotch/observers/MediaKeyInterceptor.swift @@ -250,8 +250,13 @@ final class MediaKeyInterceptor { } private func playFeedbackSound() { - guard let feedback = UserDefaults.standard.persistentDomain(forName: "NSGlobalDomain")?["com.apple.sound.beep.feedback"] as? Int, - feedback == 1 else { return } + // Single-key lookup — persistentDomain(forName:) materialized the + // entire NSGlobalDomain on every volume key press. + let feedback = CFPreferencesCopyAppValue( + "com.apple.sound.beep.feedback" as CFString, + kCFPreferencesAnyApplication + ) as? Int + guard feedback == 1 else { return } prepareAudioPlayerIfNeeded() guard let player = audioPlayer else { @@ -319,10 +324,8 @@ final class MediaKeyInterceptor { BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .backlight, value: CGFloat(v)) } else { let v = BrightnessManager.shared.rawBrightness - Task { @MainActor in - let target = await BrightnessManager.shared.brightnessTargetUUID() - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .brightness, value: CGFloat(v), targetScreenUUID: target) - } + let target = await BrightnessManager.shared.brightnessTargetUUID() + BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .brightness, value: CGFloat(v), targetScreenUUID: target) } case .keyboardBrightnessUp, .keyboardBrightnessDown: let v = KeyboardBacklightManager.shared.rawBrightness From 6bd0b1d0d43880cad9276dfbc334c30642057ea6 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 11:34:36 +0530 Subject: [PATCH 45/69] Phase 2 audit remediation: break the coordinator cycle, decouple architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.1 Manager->Coordinator cycle broken via NotchUIEventBus: - Managers (Volume/Brightness/KeyboardBacklight/BetterDisplay/Lunar/ Music/Battery) now publish presentation events; the coordinator is the single subscriber and keeps all show/hide policy. Managers are now testable without the UI stack. - Removed nested @ObservedObject coordinator from MusicManager and BatteryStatusViewModel (kills whole-tree republishing of both). 2.2 Defaults<->MusicManager init cycle removed: - New MediaEnvironment owns the NowPlaying availability probe (resolved eagerly at launch, persisted for static key defaults). - Defaults.Keys.mediaController default reads only UserDefaults — can no longer re-enter MusicManager's lazy init (latent fatal error). 2.5 XPC contract single-sourced + helper health: - Both targets now compile the SAME files via a Shared/ synchronized group: BoringNotchXPCHelperProtocol.swift + unified JSONLinesPipeHandler (the app/helper copies had drifted; helper also lost its dead NSCoder init). - New XPCHelperError type; client tracks connection health and Settings shows a 'Helper Service Unavailable' warning when the helper dies instead of features silently degrading. 2.3 AppDelegate god object split (671 -> ~330 lines): - New NotchWindowManager owns window/view-model/drag-detector lifecycle; parallel [UUID] dictionaries unified into one ScreenContext per screen. - Fixes latent leak: windowScreenDidChange observers are now removed before re-registration (used to leak one per window recreation). 2.4 ShelfItemViewModel god object split (1155 -> 182 lines): - AppKit context-menu construction + dispatch extracted to ShelfContextMenu.swift via closure bridge (no singleton back-ref). - Menu dispatch is now tag-based (ContextMenuAction rawValue) instead of matching NSMenuItem.title — which silently broke under any non-English locale, and would have broken as soon as titles were localized. - Menu strings now use the file's existing translated Strings table (Shelf.ContextMenu.* keys present in the catalog) instead of literals. - Removed dead loadOpenWithApps. 2.6 Concurrency hygiene: - MusicManager is now @MainActor (was publishing from arbitrary tasks). - WebcamManager: removed @Published-duplicate didSet objectWillChange sends. Verified: Debug build succeeds for app + XPC helper. --- .../BoringNotchXPCHelper.swift | 80 -- .../BoringNotchXPCHelperProtocol.swift | 0 Shared/JSONLinesPipeHandler.swift | 88 ++ boringNotch.xcodeproj/project.pbxproj | 23 +- boringNotch/BoringViewCoordinator.swift | 19 + .../NowPlayingController.swift | 77 -- .../BoringNotchXPCHelperProtocol.swift | 91 -- .../XPCHelperClient/XPCHelperClient.swift | 32 +- boringNotch/boringNotchApp.swift | 361 +------ .../OSD/Managers/BetterDisplayManager.swift | 9 +- .../OSD/Managers/LunarManager.swift | 5 +- .../OSD/Managers/XPC/BrightnessManager.swift | 10 +- .../OSD/Managers/XPC/VolumeManager.swift | 7 +- .../Settings/Views/OSDSettingsView.swift | 18 + .../Shelf/ViewModels/ShelfItemViewModel.swift | 971 +---------------- .../Shelf/Views/ShelfContextMenu.swift | 973 ++++++++++++++++++ boringNotch/helpers/MediaEnvironment.swift | 46 + .../managers/AudioCaptureManager.swift | 48 +- boringNotch/managers/MusicManager.swift | 56 +- boringNotch/managers/NotchWindowManager.swift | 388 +++++++ boringNotch/managers/WebcamManager.swift | 36 +- .../models/BatteryStatusViewModel.swift | 6 +- boringNotch/models/Constants.swift | 7 +- boringNotch/models/NotchUIEvent.swift | 33 + 24 files changed, 1730 insertions(+), 1654 deletions(-) rename {BoringNotchXPCHelper => Shared}/BoringNotchXPCHelperProtocol.swift (100%) create mode 100644 Shared/JSONLinesPipeHandler.swift delete mode 100644 boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift create mode 100644 boringNotch/components/Shelf/Views/ShelfContextMenu.swift create mode 100644 boringNotch/helpers/MediaEnvironment.swift create mode 100644 boringNotch/managers/NotchWindowManager.swift create mode 100644 boringNotch/models/NotchUIEvent.swift diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift index 1cff8d841..f7a259a10 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift @@ -519,85 +519,5 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { private struct LunarBrightnessEvent: Decodable { let brightness: Double let display: Int - - init(from decoder: NSCoder) { - display = decoder.decodeInteger(forKey: "display") - brightness = decoder.decodeDouble(forKey: "brightness") - } } -private actor JSONLinesPipeHandler { - nonisolated let pipe: Pipe - private let fileHandle: FileHandle - private var buffer = "" - private let decoder: JSONDecoder - - init(decoder: JSONDecoder = JSONDecoder()) { - let pipe = Pipe() - self.pipe = pipe - self.fileHandle = pipe.fileHandleForReading - self.decoder = decoder - } - - nonisolated func getPipe() -> Pipe { - return pipe - } - - func readJSONLines(as type: T.Type, onLine: @escaping (T) -> Void) async { - do { - try await processLines(as: type) { decodedObject in - onLine(decodedObject) - } - } catch { - // Ignore stream errors to keep the helper lightweight. - } - } - - private func processLines(as type: T.Type, onLine: @escaping (T) -> Void) async throws { - while true { - let data = try await readData() - guard !data.isEmpty else { break } - - if let chunk = String(data: data, encoding: .utf8) { - buffer.append(chunk) - - while let range = buffer.range(of: "\n") { - let line = String(buffer[..(_ line: String, as type: T.Type, onLine: @escaping (T) -> Void) { - guard let data = line.data(using: .utf8) else { return } - if let decodedObject = try? decoder.decode(T.self, from: data) { - onLine(decodedObject) - } - } - - private func readData() async throws -> Data { - return try await withCheckedThrowingContinuation { continuation in - fileHandle.readabilityHandler = { handle in - let data = handle.availableData - handle.readabilityHandler = nil - continuation.resume(returning: data) - } - } - } - - func close() async { - do { - fileHandle.readabilityHandler = nil - - try fileHandle.close() - try pipe.fileHandleForWriting.close() - } catch { - // Ignore close errors. - } - } -} diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift b/Shared/BoringNotchXPCHelperProtocol.swift similarity index 100% rename from BoringNotchXPCHelper/BoringNotchXPCHelperProtocol.swift rename to Shared/BoringNotchXPCHelperProtocol.swift diff --git a/Shared/JSONLinesPipeHandler.swift b/Shared/JSONLinesPipeHandler.swift new file mode 100644 index 000000000..9c995457a --- /dev/null +++ b/Shared/JSONLinesPipeHandler.swift @@ -0,0 +1,88 @@ +// +// JSONLinesPipeHandler.swift +// boringNotch / BoringNotchXPCHelper +// +// Shared source compiled into BOTH targets (via the Shared synchronized +// group). There is intentionally one copy — edit once, both sides build it. +// + +import Foundation + +/// Streams newline-delimited JSON from a pipe, decoding each line. +/// Used by the app (mediaremote-adapter now-playing stream) and the XPC +/// helper (Lunar daemon event stream). +actor JSONLinesPipeHandler { + nonisolated let pipe: Pipe + private let fileHandle: FileHandle + private var buffer = "" + private let decoder: JSONDecoder + + init(decoder: JSONDecoder = JSONDecoder()) { + let pipe = Pipe() + self.pipe = pipe + self.fileHandle = pipe.fileHandleForReading + self.decoder = decoder + } + + nonisolated func getPipe() -> Pipe { + pipe + } + + func readJSONLines(as type: T.Type, onLine: @escaping (T) async -> Void) async { + do { + try await processLines(as: type, onLine: onLine) + } catch { + print("JSONLinesPipeHandler stream error: \(error)") + } + } + + private func processLines(as type: T.Type, onLine: @escaping (T) async -> Void) async throws { + while true { + let data = try await readData() + guard !data.isEmpty else { break } + + if let chunk = String(data: data, encoding: .utf8) { + buffer.append(chunk) + + while let range = buffer.range(of: "\n") { + let line = String(buffer[..(_ line: String, as type: T.Type, onLine: @escaping (T) async -> Void) async { + guard let data = line.data(using: .utf8) else { return } + do { + let decodedObject = try decoder.decode(T.self, from: data) + await onLine(decodedObject) + } catch { + // Ignore lines that can't be decoded. + } + } + + private func readData() async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + fileHandle.readabilityHandler = { handle in + let data = handle.availableData + handle.readabilityHandler = nil + continuation.resume(returning: data) + } + } + } + + func close() async { + do { + fileHandle.readabilityHandler = nil + try fileHandle.close() + try pipe.fileHandleForWriting.close() + } catch { + // Ignore close errors. + } + } +} diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index b953dcf0e..76e049fd1 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 1100292E2E86940F00035A57 /* QuickShareService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1100292D2E86940F00035A57 /* QuickShareService.swift */; }; 1113ABC52E80E27000EC13B2 /* ShelfItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1113ABC32E80E27000EC13B2 /* ShelfItemView.swift */; }; A1F000012F00000100000001 /* ShelfItemInteractionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F000022F00000100000001 /* ShelfItemInteractionView.swift */; }; + AFA82F21304B406590337862 /* ShelfContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E49B58CAC648403F864E6D78 /* ShelfContextMenu.swift */; }; 1113ABC62E80E27000EC13B2 /* ShelfPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1113ABBE2E80E27000EC13B2 /* ShelfPersistenceService.swift */; }; 1113ABC82E80E27000EC13B2 /* ShelfItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1113ABB72E80E27000EC13B2 /* ShelfItem.swift */; }; 1113ABCA2E80E27000EC13B2 /* ShelfSelectionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1113ABC02E80E27000EC13B2 /* ShelfSelectionModel.swift */; }; @@ -31,6 +32,7 @@ 112B0EB92E30DD0F00562D6C /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 112B0EB32E30DD0F00562D6C /* mediaremote-adapter.pl */; }; 112B0EBB2E30DD5000562D6C /* MediaRemoteAdapter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 112B0EBA2E30DD5000562D6C /* MediaRemoteAdapter.framework */; }; 112FB7352CCF16F70015238C /* NotchSpaceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 112FB7342CCF16F70015238C /* NotchSpaceManager.swift */; }; + 90ED6E81035940418671DCEA /* NotchWindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB1C3E8B149445A9E57C6AF /* NotchWindowManager.swift */; }; 1132E5122E777B6E0068732D /* YouTubeMusicModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1132E5102E777B6E0068732D /* YouTubeMusicModels.swift */; }; 1132E5142E777B920068732D /* YouTubeMusicNetworking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1132E5132E777B920068732D /* YouTubeMusicNetworking.swift */; }; 1132E5162E777C140068732D /* YouTubeMusicAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1132E5152E777C140068732D /* YouTubeMusicAuthentication.swift */; }; @@ -64,6 +66,7 @@ 11985BEF2F37E48900F81585 /* OSDIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11985BEE2F37E48900F81585 /* OSDIconView.swift */; }; 11985BF42F38520A00F81585 /* DraggableProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11985BF32F38520A00F81585 /* DraggableProgressBar.swift */; }; 11A45C792E34E63100CEB175 /* MediaChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A45C782E34E63100CEB175 /* MediaChecker.swift */; }; + F80A422BE2974CF6808C84CA /* MediaEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D24CBD49614ECE88F062B5 /* MediaEnvironment.swift */; }; 11C5E3132DFE85970065821E /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C5E3112DFE85970065821E /* SettingsWindowController.swift */; }; 11C5E3162DFE88510065821E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C5E3152DFE88510065821E /* SettingsView.swift */; }; 11CC44A22CEE614100C7244B /* BoringViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CC44A12CEE614100C7244B /* BoringViewCoordinator.swift */; }; @@ -88,7 +91,6 @@ 11EFCD702E8E92D600D0B974 /* ShelfItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11EFCD6F2E8E92D600D0B974 /* ShelfItemViewModel.swift */; }; 11F747CE2EC75CEA00F841DB /* DragPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F747CD2EC75CEA00F841DB /* DragPreviewView.swift */; }; 11F7485B2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 11F7484F2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 11F748682EC9AC9600F841DB /* BoringNotchXPCHelperProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F748652EC9AC9600F841DB /* BoringNotchXPCHelperProtocol.swift */; }; 11F748692EC9AC9600F841DB /* XPCHelperClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F748662EC9AC9600F841DB /* XPCHelperClient.swift */; }; 11F748732EC9DA9300F841DB /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 11F748722EC9DA9300F841DB /* Lottie */; }; 11F748822ECB07A400F841DB /* MusicControlButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F748812ECB07A400F841DB /* MusicControlButton.swift */; }; @@ -118,6 +120,7 @@ 14D570C92C5F38890011E668 /* BoringViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570C82C5F38890011E668 /* BoringViewModel.swift */; }; C0D300012F60000100000001 /* DropInteractionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D300022F60000100000001 /* DropInteractionState.swift */; }; 14D570CB2C5F4B2C0011E668 /* BatteryStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570CA2C5F4B2C0011E668 /* BatteryStatusViewModel.swift */; }; + A7F4A06476BC4B029B6BECEA /* NotchUIEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E354C7D712441284085CEA /* NotchUIEvent.swift */; }; 14D570CD2C5F4BB70011E668 /* BoringBattery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570CC2C5F4BB70011E668 /* BoringBattery.swift */; }; 14D570D22C5F6C6A0011E668 /* BoringExtrasMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570D12C5F6C6A0011E668 /* BoringExtrasMenu.swift */; }; 14E9FEAE2C7325770062E83F /* Button+Bouncing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14E9FEAD2C7325770062E83F /* Button+Bouncing.swift */; }; @@ -223,6 +226,7 @@ 1113ABC02E80E27000EC13B2 /* ShelfSelectionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfSelectionModel.swift; sourceTree = ""; }; 1113ABC32E80E27000EC13B2 /* ShelfItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfItemView.swift; sourceTree = ""; }; A1F000022F00000100000001 /* ShelfItemInteractionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfItemInteractionView.swift; sourceTree = ""; }; + E49B58CAC648403F864E6D78 /* ShelfContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfContextMenu.swift; sourceTree = ""; }; 1113ABCF2E80E6BB00EC13B2 /* ThumbnailService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailService.swift; sourceTree = ""; }; 111BE9942ECF2DEF0079DD4E /* DragDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragDetector.swift; sourceTree = ""; }; 111BEA602ED09B1B0079DD4E /* NSScreen+UUID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScreen+UUID.swift"; sourceTree = ""; }; @@ -230,6 +234,7 @@ 112B0EB52E30DD0F00562D6C /* MediaRemoteAdapterTestClient */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = MediaRemoteAdapterTestClient; sourceTree = ""; }; 112B0EBA2E30DD5000562D6C /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = "mediaremote-adapter/MediaRemoteAdapter.framework"; sourceTree = ""; }; 112FB7342CCF16F70015238C /* NotchSpaceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchSpaceManager.swift; sourceTree = ""; }; + EAB1C3E8B149445A9E57C6AF /* NotchWindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchWindowManager.swift; sourceTree = ""; }; 1132E5102E777B6E0068732D /* YouTubeMusicModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeMusicModels.swift; sourceTree = ""; }; 1132E5132E777B920068732D /* YouTubeMusicNetworking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeMusicNetworking.swift; sourceTree = ""; }; 1132E5152E777C140068732D /* YouTubeMusicAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeMusicAuthentication.swift; sourceTree = ""; }; @@ -261,6 +266,7 @@ 11985BEE2F37E48900F81585 /* OSDIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDIconView.swift; sourceTree = ""; }; 11985BF32F38520A00F81585 /* DraggableProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableProgressBar.swift; sourceTree = ""; }; 11A45C782E34E63100CEB175 /* MediaChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaChecker.swift; sourceTree = ""; }; + 54D24CBD49614ECE88F062B5 /* MediaEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEnvironment.swift; sourceTree = ""; }; 11C5E3112DFE85970065821E /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = ""; }; 11C5E3152DFE88510065821E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 11CC44A12CEE614100C7244B /* BoringViewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringViewCoordinator.swift; sourceTree = ""; }; @@ -285,7 +291,6 @@ 11EFCD6F2E8E92D600D0B974 /* ShelfItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfItemViewModel.swift; sourceTree = ""; }; 11F747CD2EC75CEA00F841DB /* DragPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragPreviewView.swift; sourceTree = ""; }; 11F7484F2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = BoringNotchXPCHelper.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; - 11F748652EC9AC9600F841DB /* BoringNotchXPCHelperProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringNotchXPCHelperProtocol.swift; sourceTree = ""; }; 11F748662EC9AC9600F841DB /* XPCHelperClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XPCHelperClient.swift; sourceTree = ""; }; 11F748812ECB07A400F841DB /* MusicControlButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicControlButton.swift; sourceTree = ""; }; 11F748832ECB27DC00F841DB /* MusicSlotConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicSlotConfigurationView.swift; sourceTree = ""; }; @@ -319,6 +324,7 @@ 14D570C82C5F38890011E668 /* BoringViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringViewModel.swift; sourceTree = ""; }; C0D300022F60000100000001 /* DropInteractionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropInteractionState.swift; sourceTree = ""; }; 14D570CA2C5F4B2C0011E668 /* BatteryStatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryStatusViewModel.swift; sourceTree = ""; }; + 80E354C7D712441284085CEA /* NotchUIEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchUIEvent.swift; sourceTree = ""; }; 14D570CC2C5F4BB70011E668 /* BoringBattery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringBattery.swift; sourceTree = ""; }; 14D570D12C5F6C6A0011E668 /* BoringExtrasMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringExtrasMenu.swift; sourceTree = ""; }; 14E9FEAD2C7325770062E83F /* Button+Bouncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Button+Bouncing.swift"; sourceTree = ""; }; @@ -382,6 +388,7 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 112FB72F2CCF12CC0015238C /* private */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = private; sourceTree = ""; }; 11F748502EC9AABA00F841DB /* BoringNotchXPCHelper */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (11F7485C2EC9AABA00F841DB /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = BoringNotchXPCHelper; sourceTree = ""; }; + 8137A8BA990F4D9CBA56CC7A /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; A5167213301F85B40018095A /* boringNotchTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = boringNotchTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -465,6 +472,7 @@ 110029292E8691B400035A57 /* FileShareView.swift */, 1113ABC32E80E27000EC13B2 /* ShelfItemView.swift */, A1F000022F00000100000001 /* ShelfItemInteractionView.swift */, + E49B58CAC648403F864E6D78 /* ShelfContextMenu.swift */, 9A987A032C73CA66005CA465 /* ShelfView.swift */, ); path = Views; @@ -583,7 +591,6 @@ 11F748672EC9AC9600F841DB /* XPCHelperClient */ = { isa = PBXGroup; children = ( - 11F748652EC9AC9600F841DB /* BoringNotchXPCHelperProtocol.swift */, 11F748662EC9AC9600F841DB /* XPCHelperClient.swift */, ); path = XPCHelperClient; @@ -594,6 +601,7 @@ children = ( 118EBE242E92DCCB00D54B5A /* AssociatedObject.swift */, 11A45C782E34E63100CEB175 /* MediaChecker.swift */, + 54D24CBD49614ECE88F062B5 /* MediaEnvironment.swift */, 1153BD972D9881F900979FB0 /* AppleScriptHelper.swift */, 14288DD62C6E015000B9F80C /* AudioPlayer.swift */, 5955950C2E900ED800C66711 /* ApplicationRelauncher.swift */, @@ -650,6 +658,7 @@ 11D58EA12E760AE100FA8377 /* ImageService.swift */, F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */, 112FB7342CCF16F70015238C /* NotchSpaceManager.swift */, + EAB1C3E8B149445A9E57C6AF /* NotchWindowManager.swift */, AA01SNM22E7A0001 /* SystemNotificationManager.swift */, AA02CAM22E7A0001 /* ContactAvatarManager.swift */, 147163992C5D35FF0068B555 /* MusicManager.swift */, @@ -776,6 +785,7 @@ 14D570C82C5F38890011E668 /* BoringViewModel.swift */, C0D300022F60000100000001 /* DropInteractionState.swift */, 14D570CA2C5F4B2C0011E668 /* BatteryStatusViewModel.swift */, + 80E354C7D712441284085CEA /* NotchUIEvent.swift */, 1153BD902D986DB300979FB0 /* PlaybackState.swift */, 3CA22021D89A9E4FF88A618D /* MeetingLink.swift */, ); @@ -907,6 +917,7 @@ ); fileSystemSynchronizedGroups = ( 11F748502EC9AABA00F841DB /* BoringNotchXPCHelper */, + 8137A8BA990F4D9CBA56CC7A /* Shared */, ); name = BoringNotchXPCHelper; productName = BoringNotchXPCHelper; @@ -930,6 +941,7 @@ ); fileSystemSynchronizedGroups = ( 112FB72F2CCF12CC0015238C /* private */, + 8137A8BA990F4D9CBA56CC7A /* Shared */, ); name = boringNotch; packageProductDependencies = ( @@ -1079,6 +1091,7 @@ 11CC44A22CEE614100C7244B /* BoringViewCoordinator.swift in Sources */, B186543C2C6F49AE000B926A /* ShortcutConstants.swift in Sources */, 11A45C792E34E63100CEB175 /* MediaChecker.swift in Sources */, + F80A422BE2974CF6808C84CA /* MediaEnvironment.swift in Sources */, B1D365CE2C6A979C0047BDBC /* LiveActivityModifier.swift in Sources */, 1113ABD02E80E6BB00EC13B2 /* ThumbnailService.swift in Sources */, 11CFC65B2E097E9D00748C80 /* WelcomeView.swift in Sources */, @@ -1089,6 +1102,7 @@ 1194E87C2EA19E09009C82D6 /* ImageProcessingService.swift in Sources */, 1194E8872EA6DDA7009C82D6 /* BoringNotchSkyLightWindow.swift in Sources */, 14D570CB2C5F4B2C0011E668 /* BatteryStatusViewModel.swift in Sources */, + A7F4A06476BC4B029B6BECEA /* NotchUIEvent.swift in Sources */, 9A0887322C7A693000C160EA /* TabButton.swift in Sources */, 1153BD9C2D98853B00979FB0 /* NowPlayingController.swift in Sources */, 11985BEF2F37E48900F81585 /* OSDIconView.swift in Sources */, @@ -1106,6 +1120,7 @@ B1C974342C642B6D0000E707 /* MarqueeTextView.swift in Sources */, 1113ABC52E80E27000EC13B2 /* ShelfItemView.swift in Sources */, A1F000012F00000100000001 /* ShelfItemInteractionView.swift in Sources */, + AFA82F21304B406590337862 /* ShelfContextMenu.swift in Sources */, 1113ABC62E80E27000EC13B2 /* ShelfPersistenceService.swift in Sources */, 11DB26662EDD0BE1001EA0CF /* LyricsService.swift in Sources */, 1113ABC82E80E27000EC13B2 /* ShelfItem.swift in Sources */, @@ -1185,7 +1200,6 @@ 118D1FD12E98FF5F00A2FF63 /* SharingStateManager.swift in Sources */, 11985BE62F37A3FC00F81585 /* BetterDisplayNotificationModels.swift in Sources */, 118EBE252E92DCCB00D54B5A /* AssociatedObject.swift in Sources */, - 11F748682EC9AC9600F841DB /* BoringNotchXPCHelperProtocol.swift in Sources */, 11F748692EC9AC9600F841DB /* XPCHelperClient.swift in Sources */, 118EBE2D2E97165600D54B5A /* Bookmark.swift in Sources */, 11985BE02F37A3C800F81585 /* BetterDisplayManager.swift in Sources */, @@ -1194,6 +1208,7 @@ 11F748842ECB27DC00F841DB /* MusicSlotConfigurationView.swift in Sources */, 9A0887352C7AFF8E00C160EA /* TabSelectionView.swift in Sources */, 112FB7352CCF16F70015238C /* NotchSpaceManager.swift in Sources */, + 90ED6E81035940418671DCEA /* NotchWindowManager.swift in Sources */, 14FC6E502C7DED5600C7BEA5 /* DataTypes+Extensions.swift in Sources */, 1153BD982D9881F900979FB0 /* AppleScriptHelper.swift in Sources */, 11CFC6612E097F6800748C80 /* PermissionsRequestView.swift in Sources */, diff --git a/boringNotch/BoringViewCoordinator.swift b/boringNotch/BoringViewCoordinator.swift index 987433782..aaffdc97d 100644 --- a/boringNotch/BoringViewCoordinator.swift +++ b/boringNotch/BoringViewCoordinator.swift @@ -93,6 +93,7 @@ class BoringViewCoordinator: ObservableObject { private var boringShelfCancellable: AnyCancellable? private var osdSourceCancellables: [AnyCancellable] = [] private var notificationLiveActivityCancellable: AnyCancellable? + private var uiEventCancellable: AnyCancellable? private init() { // Perform migration from name-based to UUID-based storage @@ -130,6 +131,24 @@ class BoringViewCoordinator: ObservableObject { XPCHelperClient.shared.startMonitoringAccessibilityAuthorization() + // Managers publish presentation events through the bus instead of + // calling into the coordinator directly; the coordinator is the + // single presenter (and keeps all show/hide policy in one place). + uiEventCancellable = NotchUIEventBus.events + .sink { [weak self] event in + Task { @MainActor in + guard let self else { return } + switch event { + case .sneakPeek(let type, let value, let icon, let accent, let uuid): + self.toggleSneakPeek( + status: true, type: type, value: value, + icon: icon, accent: accent, targetScreenUUID: uuid) + case .expandingView(let type): + self.toggleExpandingView(status: true, type: type) + } + } + } + // Observe changes to osdReplacement osdReplacementCancellable = Defaults.publisher(.osdReplacement) .sink { [weak self] change in diff --git a/boringNotch/MediaControllers/NowPlayingController.swift b/boringNotch/MediaControllers/NowPlayingController.swift index 739899078..3fa5aadee 100644 --- a/boringNotch/MediaControllers/NowPlayingController.swift +++ b/boringNotch/MediaControllers/NowPlayingController.swift @@ -374,80 +374,3 @@ struct NowPlayingPayload: Codable { let volume: Double? } -actor JSONLinesPipeHandler { - private let pipe: Pipe - private let fileHandle: FileHandle - private var buffer = "" - - init() { - self.pipe = Pipe() - self.fileHandle = pipe.fileHandleForReading - } - - func getPipe() -> Pipe { - return pipe - } - - func readJSONLines(as type: T.Type, onLine: @escaping (T) async -> Void) async { - do { - try await self.processLines(as: type) { decodedObject in - await onLine(decodedObject) - } - } catch { - print("Error processing JSON stream: \(error)") - } - } - - private func processLines(as type: T.Type, onLine: @escaping (T) async -> Void) async throws { - while true { - let data = try await readData() - guard !data.isEmpty else { break } - - if let chunk = String(data: data, encoding: .utf8) { - buffer.append(chunk) - - while let range = buffer.range(of: "\n") { - let line = String(buffer[..(_ line: String, as type: T.Type, onLine: @escaping (T) async -> Void) async { - guard let data = line.data(using: .utf8) else { - return - } - do { - let decodedObject = try JSONDecoder().decode(T.self, from: data) - await onLine(decodedObject) - } catch { - // Ignore lines that can't be decoded - } - } - - private func readData() async throws -> Data { - return try await withCheckedThrowingContinuation { continuation in - - fileHandle.readabilityHandler = { handle in - let data = handle.availableData - handle.readabilityHandler = nil - continuation.resume(returning: data) - } - } - } - - func close() async { - do { - fileHandle.readabilityHandler = nil - try fileHandle.close() - try pipe.fileHandleForWriting.close() - } catch { - print("Error closing pipe handler: \(error)") - } - } -} diff --git a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift b/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift deleted file mode 100644 index 482f58ac7..000000000 --- a/boringNotch/XPCHelperClient/BoringNotchXPCHelperProtocol.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// BoringNotchXPCHelperProtocol.swift -// BoringNotchXPCHelper -// -// Created by Alexander on 2025-11-16. -// - -import Foundation - -/// The protocol that this service will vend as its API. This protocol will also need to be visible to the process hosting the service. -@objc protocol BoringNotchXPCHelperLunarListener { - func lunarEventDidUpdate(_ event: BNLunarBrightnessEvent) - func lunarStreamDidStop(_ reason: String?) -} - -@objc(BNLunarBrightnessEvent) -final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { - static var supportsSecureCoding: Bool { true } - - let brightness: Double - let display: Int - - init(brightness: Double, display: Int) { - self.brightness = brightness - self.display = display - super.init() - } - - required init?(coder: NSCoder) { - brightness = coder.decodeDouble(forKey: "brightness") - display = coder.decodeInteger(forKey: "display") - super.init() - } - - func encode(with coder: NSCoder) { - coder.encode(brightness, forKey: "brightness") - coder.encode(display, forKey: "display") - } -} - -@objc protocol BoringNotchXPCHelperProtocol { - func isAccessibilityAuthorized(with reply: @escaping (Bool) -> Void) - func requestAccessibilityAuthorization() - func ensureAccessibilityAuthorization(_ promptIfNeeded: Bool, with reply: @escaping (Bool) -> Void) - // Keyboard backlight / CoreBrightness access (performed by the helper) - func isKeyboardBrightnessAvailable(with reply: @escaping (Bool) -> Void) - func currentKeyboardBrightness(with reply: @escaping (NSNumber?) -> Void) - func setKeyboardBrightness(_ value: Float, with reply: @escaping (Bool) -> Void) - // Screen brightness access (performed by the helper) - func isScreenBrightnessAvailable(with reply: @escaping (Bool) -> Void) - // returns the displayID that will be used for built-in brightness operations (main or internal fallback) - func displayIDForBrightness(with reply: @escaping (NSNumber?) -> Void) - func currentScreenBrightness(with reply: @escaping (NSNumber?) -> Void) - func setScreenBrightness(_ value: Float, with reply: @escaping (Bool) -> Void) - /// Replies the resulting brightness in 0...1, or nil on failure. - /// Returning the value collapses the old adjust→read two-RPC dance into one call. - func adjustScreenBrightness(by value: Float, with reply: @escaping (NSNumber?) -> Void) - // Lunar brightness events (performed by the helper) - func isLunarAvailable(with reply: @escaping (Bool) -> Void) - func startLunarEventStream(with reply: @escaping (Bool) -> Void) - func stopLunarEventStream() - /// Write Lunar's hideOSD preference (disable/enable Lunar's OSD when we replace it). - func setLunarOSDHidden(_ hide: Bool, with reply: @escaping (Bool) -> Void) - // Notification Center banner observation (performed by the helper) - func startNotificationWatching(with reply: @escaping (Bool) -> Void) - func stopNotificationWatching() - func replyToNotification(_ token: String, text: String, with reply: @escaping (Bool) -> Void) - func sendIMessage(_ text: String, toChatNamed name: String, with reply: @escaping (Bool) -> Void) - func performNotificationAction(_ token: String, name: String, with reply: @escaping (Bool) -> Void) - func openNotification(_ token: String, with reply: @escaping (Bool) -> Void) - func dismissNotification(_ token: String, with reply: @escaping (Bool) -> Void) - func holdNotification(_ token: String) - func releaseNotification(_ token: String) - func notificationDebugDump(with reply: @escaping (String) -> Void) -} - -/// Pushed from the helper back to the app. The app sets an object conforming to -/// this as its connection's `exportedObject`. -@objc protocol BoringNotchXPCHelperDelegate { - /// Keys: token, appName, bundleID, title, subtitle, body, actions - /// (`actions` is newline-joined). A plain string dictionary keeps the XPC - /// interface free of custom coded types. - func notificationDidAppear(_ payload: [String: String]) - func notificationDidDisappear(_ token: String) -} - -/// A connection has exactly one exported object, and the helper calls back for -/// both Lunar events and notification banners — so the app vends a single -/// object conforming to both. -@objc protocol BoringNotchXPCAppDelegate: BoringNotchXPCHelperLunarListener, BoringNotchXPCHelperDelegate {} - diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index c3ab05131..883acc794 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -2,10 +2,29 @@ import Foundation import Cocoa import AsyncXPCConnection -final class XPCHelperClient: NSObject { +/// Why a helper call failed. Most methods still degrade to false/nil for +/// backward compatibility, but the cause is recorded in `lastError` and +/// rolled into `helperAvailable` for Settings to surface. +enum XPCHelperError: Error { + /// The XPC service could not be reached (crashed or restarting). + case unavailable + /// The helper refused the request (e.g. accessibility not granted). + case declined + /// Connection dropped mid-call. + case transport(underlying: Error) +} + +final class XPCHelperClient: NSObject, ObservableObject { nonisolated static let shared = XPCHelperClient() - + private let serviceName = "theboringteam.boringnotch.BoringNotchXPCHelper" + + /// Coarse, UI-friendly view of helper connectivity. Flips to false from + /// the connection's interruption/invalidation handlers so a crashed + /// helper is visible in Settings instead of features silently degrading; + /// flips back to true when a live connection is (re)established. + @MainActor @Published private(set) var helperAvailable = true + @MainActor private(set) var lastError: XPCHelperError? private var remoteService: RemoteXPCService? private var connection: NSXPCConnection? @@ -37,6 +56,7 @@ final class XPCHelperClient: NSObject { if let existing = remoteService { notificationDelegate.lunarListener = lunarListener hasLunarListener = hasLunarListener || (needsListener && lunarListener != nil) + helperAvailable = true return existing } @@ -53,14 +73,18 @@ final class XPCHelperClient: NSObject { self?.connection = nil self?.remoteService = nil self?.hasLunarListener = false + self?.helperAvailable = false + self?.lastError = .unavailable } } - + conn.invalidationHandler = { [weak self] in Task { @MainActor in self?.connection = nil self?.remoteService = nil self?.hasLunarListener = false + self?.helperAvailable = false + self?.lastError = .unavailable } } @@ -73,6 +97,8 @@ final class XPCHelperClient: NSObject { connection = conn remoteService = service + helperAvailable = true + lastError = nil return service } diff --git a/boringNotch/boringNotchApp.swift b/boringNotch/boringNotchApp.swift index 572551a02..98b992460 100644 --- a/boringNotch/boringNotchApp.swift +++ b/boringNotch/boringNotchApp.swift @@ -76,26 +76,26 @@ final class BoringSparkleUpdaterDelegate: NSObject, SPUUpdaterDelegate { } } +/// App-lifecycle glue: shortcuts, onboarding, termination, observer wiring. +/// All notch-window / per-screen view-model / drag-detector lifecycle lives +/// in `NotchWindowManager` (see managers/NotchWindowManager.swift). class AppDelegate: NSObject, NSApplicationDelegate { var statusItem: NSStatusItem? - var windows: [String: NSWindow] = [:] // UUID -> NSWindow - var viewModels: [String: BoringViewModel] = [:] // UUID -> BoringViewModel - var window: NSWindow? - let vm: BoringViewModel = .init() @ObservedObject var coordinator = BoringViewCoordinator.shared var quickShareService = QuickShareService.shared - var whatsNewWindow: NSWindow? - var timer: Timer? var closeNotchTask: Task? - private var previousScreens: [NSScreen]? + private let windowManager = NotchWindowManager.shared private var onboardingWindowController: NSWindowController? private var screenLockedObserver: Any? private var screenUnlockedObserver: Any? - private var isScreenLocked: Bool = false - private var windowScreenDidChangeObserver: Any? - private var dragDetectors: [String: DragDetector] = [:] // UUID -> DragDetector private var observers: [Any] = [] + /// Kept for existing internal readers; the state itself moved to the manager. + var windows: [String: NSWindow] { windowManager.windows } + var viewModels: [String: BoringViewModel] { windowManager.viewModels } + var window: NSWindow? { windowManager.window } + var vm: BoringViewModel { windowManager.primaryViewModel } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return false } @@ -113,217 +113,35 @@ class AppDelegate: NSObject, NSApplicationDelegate { DistributedNotificationCenter.default().removeObserver(observer) screenUnlockedObserver = nil } - MusicManager.shared.destroy() - cleanupDragDetectors() - cleanupWindows() + MainActor.assumeIsolated { + MusicManager.shared.destroy() + windowManager.cleanup() + } BetterDisplayManager.shared.stopObserving() LunarManager.shared.stopListening() LunarManager.shared.configureLunarOSD(hide: false) XPCHelperClient.shared.stopMonitoringAccessibilityAuthorization() - + observers.forEach { NotificationCenter.default.removeObserver($0) } observers.removeAll() } @MainActor func onScreenLocked(_ notification: Notification) { - isScreenLocked = true - if !Defaults[.showOnLockScreen] { - cleanupWindows() - } else { - enableSkyLightOnAllWindows() - } + windowManager.screenLocked() } @MainActor func onScreenUnlocked(_ notification: Notification) { - isScreenLocked = false - if !Defaults[.showOnLockScreen] { - adjustWindowPosition(changeAlpha: true) - } else { - disableSkyLightOnAllWindows() - } - } - - @MainActor - private func enableSkyLightOnAllWindows() { - if Defaults[.showOnAllDisplays] { - windows.values.forEach { window in - if let skyWindow = window as? BoringNotchSkyLightWindow { - skyWindow.enableSkyLight() - } - } - } else { - if let skyWindow = window as? BoringNotchSkyLightWindow { - skyWindow.enableSkyLight() - } - } - } - - @MainActor - private func disableSkyLightOnAllWindows() { - // Delay disabling SkyLight to avoid flicker during unlock transition - Task { - try? await Task.sleep(for: .milliseconds(150)) - await MainActor.run { - if Defaults[.showOnAllDisplays] { - self.windows.values.forEach { window in - if let skyWindow = window as? BoringNotchSkyLightWindow { - skyWindow.disableSkyLight() - } - } - } else { - if let skyWindow = self.window as? BoringNotchSkyLightWindow { - skyWindow.disableSkyLight() - } - } - } - } - } - - private func cleanupWindows(shouldInvert: Bool = false) { - let shouldCleanupMulti = shouldInvert ? !Defaults[.showOnAllDisplays] : Defaults[.showOnAllDisplays] - - if shouldCleanupMulti { - windows.values.forEach { window in - window.close() - NotchSpaceManager.shared.notchSpace.windows.remove(window) - } - windows.removeAll() - viewModels.removeAll() - } else if let window = window { - window.close() - NotchSpaceManager.shared.notchSpace.windows.remove(window) - if let obs = windowScreenDidChangeObserver { - NotificationCenter.default.removeObserver(obs) - windowScreenDidChangeObserver = nil - } - self.window = nil - } - - // ensure OSD integration reflects the current window state - coordinator.applyOSDSources() - } - - private func cleanupDragDetectors() { - dragDetectors.values.forEach { detector in - detector.stopMonitoring() - } - dragDetectors.removeAll() - } - - private func setupDragDetectors() { - cleanupDragDetectors() - - guard Defaults[.expandedDragDetection] else { return } - - if Defaults[.showOnAllDisplays] { - for screen in NSScreen.screens { - setupDragDetectorForScreen(screen) - } - } else { - let preferredScreen: NSScreen? = window?.screen - ?? NSScreen.screen(withUUID: coordinator.selectedScreenUUID) - ?? NSScreen.main - - if let screen = preferredScreen { - setupDragDetectorForScreen(screen) - } - } - } - - private func setupDragDetectorForScreen(_ screen: NSScreen) { - guard let uuid = screen.displayUUID else { return } - - let screenFrame = screen.frame - let notchHeight = openNotchSize.height - let notchWidth = openNotchSize.width - - // Create notch region at the top-center of the screen where an open notch would occupy - let notchRegion = CGRect( - x: screenFrame.midX - notchWidth / 2, - y: screenFrame.maxY - notchHeight, - width: notchWidth, - height: notchHeight - ) - - let detector = DragDetector(notchRegion: notchRegion) - - detector.onDragEntersNotchRegion = { [weak self] in - Task { @MainActor in - self?.handleDragEntersNotchRegion(onScreen: screen) - } - } - - dragDetectors[uuid] = detector - detector.startMonitoring() - } - - private func handleDragEntersNotchRegion(onScreen screen: NSScreen) { - guard Defaults[.boringShelf] else { return } - guard let uuid = screen.displayUUID else { return } - - if Defaults[.showOnAllDisplays], let viewModel = viewModels[uuid] { - if viewModel.open() { - coordinator.currentView = .shelf - } - } else if !Defaults[.showOnAllDisplays], let windowScreen = window?.screen, screen == windowScreen { - if vm.open() { - coordinator.currentView = .shelf - } - } - } - - private func createBoringNotchWindow(for screen: NSScreen, with viewModel: BoringViewModel) -> NSWindow { - let rect = NSRect(x: 0, y: 0, width: windowSize.width, height: windowSize.height) - let styleMask: NSWindow.StyleMask = [.borderless, .nonactivatingPanel, .utilityWindow, .hudWindow] - - let window = BoringNotchSkyLightWindow(contentRect: rect, styleMask: styleMask, backing: .buffered, defer: false) - - // Enable SkyLight only when screen is locked - if isScreenLocked { - window.enableSkyLight() - } else { - window.disableSkyLight() - } - - window.contentView = NSHostingView( - rootView: ContentView() - .environmentObject(viewModel) - ) - - window.orderFrontRegardless() - NotchSpaceManager.shared.notchSpace.windows.insert(window) - - // Observe when the window's screen changes so we can update drag detectors - windowScreenDidChangeObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didChangeScreenNotification, - object: window, - queue: .main) { [weak self] _ in - Task { @MainActor in - self?.setupDragDetectors() - } - } - return window - } - - @MainActor - private func positionWindow(_ window: NSWindow, on screen: NSScreen, changeAlpha: Bool = false) { - if changeAlpha { - window.alphaValue = 0 - } - - let screenFrame = screen.frame - window.setFrameOrigin( - NSPoint( - x: screenFrame.origin.x + (screenFrame.width / 2) - window.frame.width / 2, - y: screenFrame.origin.y + screenFrame.height - window.frame.height - )) - window.alphaValue = 1 + windowManager.screenUnlocked() } func applicationDidFinishLaunching(_ notification: Notification) { + // Kick the environment probe eagerly; its persisted result feeds + // Defaults key defaults and MediaEnvironment consumers. + MediaEnvironment.shared.resolve() + NotificationCenter.default.addObserver( self, selector: #selector(screenConfigurationDidChange), @@ -335,8 +153,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { forName: Notification.Name.selectedScreenChanged, object: nil, queue: nil ) { [weak self] _ in Task { @MainActor in - self?.adjustWindowPosition(changeAlpha: true) - self?.setupDragDetectors() + self?.windowManager.adjustWindowPosition(changeAlpha: true) + self?.windowManager.setupDragDetectors() } }) @@ -344,8 +162,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { forName: Notification.Name.notchHeightChanged, object: nil, queue: nil ) { [weak self] _ in Task { @MainActor in - self?.adjustWindowPosition() - self?.setupDragDetectors() + self?.windowManager.adjustWindowPosition() + self?.windowManager.setupDragDetectors() } }) @@ -363,9 +181,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) { [weak self] _ in Task { @MainActor in guard let self = self else { return } - self.cleanupWindows(shouldInvert: true) - self.adjustWindowPosition(changeAlpha: true) - self.setupDragDetectors() + self.windowManager.cleanupWindows(shouldInvert: true) + self.windowManager.adjustWindowPosition(changeAlpha: true) + self.windowManager.setupDragDetectors() } }) @@ -373,7 +191,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { forName: Notification.Name.expandedDragDetectionChanged, object: nil, queue: nil ) { [weak self] _ in Task { @MainActor in - self?.setupDragDetectors() + self?.windowManager.setupDragDetectors() } }) @@ -463,26 +281,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Sync notch height with real value on app launch if mode is matchRealNotchSize syncNotchHeightIfNeeded() - - if !Defaults[.showOnAllDisplays] { - let viewModel = self.vm - if let screen = NSScreen.main ?? NSScreen.screens.first { - let window = createBoringNotchWindow(for: screen, with: viewModel) - self.window = window - } - adjustWindowPosition(changeAlpha: true) - } else { - adjustWindowPosition(changeAlpha: true) - } - setupDragDetectors() + windowManager.prepareInitialWindows() if coordinator.firstLaunch { DispatchQueue.main.async { self.showOnboardingWindow() } playWelcomeSound() - } else if MusicManager.shared.isNowPlayingDeprecated + } else if MediaEnvironment.shared.isNowPlayingDeprecated && Defaults[.mediaController] == .nowPlaying { DispatchQueue.main.async { @@ -490,8 +297,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - previousScreens = NSScreen.screens - // make sure OSD subsystems are in the right state now that initial // notch windows have been created/cleaned up coordinator.applyOSDSources() @@ -502,110 +307,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { audioPlayer.play(fileName: "boring", fileExtension: "m4a") } - func deviceHasNotch() -> Bool { - if #available(macOS 12.0, *) { - for screen in NSScreen.screens { - if screen.safeAreaInsets.top > 0 { - return true - } - } - } - return false - } - @objc func screenConfigurationDidChange() { - let currentScreens = NSScreen.screens - - let screensChanged = - currentScreens.count != previousScreens?.count - || Set(currentScreens.compactMap { $0.displayUUID }) - != Set(previousScreens?.compactMap { $0.displayUUID } ?? []) - || Set(currentScreens.map { $0.frame }) != Set(previousScreens?.map { $0.frame } ?? []) - - previousScreens = currentScreens - - if screensChanged { - DispatchQueue.main.async { [weak self] in - // Sync notch height with real value if mode is matchRealNotchSize - syncNotchHeightIfNeeded() - - self?.cleanupWindows() - self?.adjustWindowPosition() - self?.setupDragDetectors() - } - } - } - - @objc func adjustWindowPosition(changeAlpha: Bool = false) { - if Defaults[.showOnAllDisplays] { - let currentScreenUUIDs = Set(NSScreen.screens.compactMap { $0.displayUUID }) - - // Remove windows for screens that no longer exist - for uuid in windows.keys where !currentScreenUUIDs.contains(uuid) { - if let window = windows[uuid] { - window.close() - NotchSpaceManager.shared.notchSpace.windows.remove(window) - windows.removeValue(forKey: uuid) - viewModels.removeValue(forKey: uuid) - } - } - - // Create or update windows for all screens - for screen in NSScreen.screens { - guard let uuid = screen.displayUUID else { continue } - - if windows[uuid] == nil { - let viewModel = BoringViewModel(screenUUID: uuid) - let window = createBoringNotchWindow(for: screen, with: viewModel) - - windows[uuid] = window - viewModels[uuid] = viewModel - } - - if let window = windows[uuid], let viewModel = viewModels[uuid] { - positionWindow(window, on: screen, changeAlpha: changeAlpha) - - if viewModel.notchState == .closed { - viewModel.close() - } - } - } - } else { - let selectedScreen: NSScreen - - if let preferredScreen = NSScreen.screen(withUUID: coordinator.preferredScreenUUID ?? "") { - coordinator.selectedScreenUUID = coordinator.preferredScreenUUID ?? "" - selectedScreen = preferredScreen - } else if Defaults[.automaticallySwitchDisplay], let mainScreen = NSScreen.main, - let mainUUID = mainScreen.displayUUID { - coordinator.selectedScreenUUID = mainUUID - selectedScreen = mainScreen - } else { - if let window = window { - window.alphaValue = 0 - } - return - } - - vm.screenUUID = selectedScreen.displayUUID - vm.notchSize = getClosedNotchSize(screenUUID: selectedScreen.displayUUID) - - if window == nil { - window = createBoringNotchWindow(for: selectedScreen, with: vm) - } - - if let window = window { - positionWindow(window, on: selectedScreen, changeAlpha: changeAlpha) - - if vm.notchState == .closed { - vm.close() - } - } - } - - // windows might have been added/removed during the earlier logic – - // update the OSD subsystems accordingly. - coordinator.applyOSDSources() + windowManager.screenConfigurationDidChange() } @objc func togglePopover(_ sender: Any?) { diff --git a/boringNotch/components/OSD/Managers/BetterDisplayManager.swift b/boringNotch/components/OSD/Managers/BetterDisplayManager.swift index f313d1ca5..615a78475 100644 --- a/boringNotch/components/OSD/Managers/BetterDisplayManager.swift +++ b/boringNotch/components/OSD/Managers/BetterDisplayManager.swift @@ -150,23 +150,22 @@ final class BetterDisplayManager { await MainActor.run { brightnessValue = Float(rawValue) lastChangeAt = Date() - BoringViewCoordinator.shared.toggleSneakPeek( - status: true, + NotchUIEventBus.events.send(.sneakPeek( type: .brightness, value: CGFloat(rawValue / maxVal), targetScreenUUID: targetScreenUUID - ) + )) } case .volume: let normalized = maxVal > 0 ? Float(rawValue / maxVal) : Float(rawValue) await MainActor.run { - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .volume, value: CGFloat(normalized)) + NotchUIEventBus.events.send(.sneakPeek(type: .volume, value: CGFloat(normalized))) } case .mute: await MainActor.run { - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .volume, value: CGFloat(rawValue)) + NotchUIEventBus.events.send(.sneakPeek(type: .volume, value: CGFloat(rawValue))) } case .other: diff --git a/boringNotch/components/OSD/Managers/LunarManager.swift b/boringNotch/components/OSD/Managers/LunarManager.swift index c28ffc3b9..e5952339b 100644 --- a/boringNotch/components/OSD/Managers/LunarManager.swift +++ b/boringNotch/components/OSD/Managers/LunarManager.swift @@ -106,14 +106,13 @@ final class LunarManager { } Task { @MainActor in - BoringViewCoordinator.shared.toggleSneakPeek( - status: true, + NotchUIEventBus.events.send(.sneakPeek( type: .brightness, value: CGFloat(normalizedBrightness), icon: iconString, accent: accentColor, targetScreenUUID: targetScreenUUID - ) + )) } } diff --git a/boringNotch/components/OSD/Managers/XPC/BrightnessManager.swift b/boringNotch/components/OSD/Managers/XPC/BrightnessManager.swift index 29b58b666..0c71e3d1e 100644 --- a/boringNotch/components/OSD/Managers/XPC/BrightnessManager.swift +++ b/boringNotch/components/OSD/Managers/XPC/BrightnessManager.swift @@ -78,7 +78,7 @@ final class BrightnessManager: ObservableObject { publish(brightness: current, touchDate: true) let uuid = await brightnessTargetUUID() - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .brightness, value: CGFloat(current), targetScreenUUID: uuid) + NotchUIEventBus.events.send(.sneakPeek(type: .brightness, value: CGFloat(current), targetScreenUUID: uuid)) } } } @@ -91,7 +91,7 @@ final class BrightnessManager: ObservableObject { publish(brightness: clamped, touchDate: true) // optionally show peek when user uses slider/controls let targetUUID = await brightnessTargetUUID() - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .brightness, value: CGFloat(clamped), targetScreenUUID: targetUUID) + NotchUIEventBus.events.send(.sneakPeek(type: .brightness, value: CGFloat(clamped), targetScreenUUID: targetUUID)) } else { refresh() } @@ -156,11 +156,7 @@ final class KeyboardBacklightManager: ObservableObject { refresh() return } - BoringViewCoordinator.shared.toggleSneakPeek( - status: true, - type: .backlight, - value: CGFloat(target) - ) + NotchUIEventBus.events.send(.sneakPeek(type: .backlight, value: CGFloat(target))) } } } diff --git a/boringNotch/components/OSD/Managers/XPC/VolumeManager.swift b/boringNotch/components/OSD/Managers/XPC/VolumeManager.swift index 8cc4c36a9..59aa793a0 100644 --- a/boringNotch/components/OSD/Managers/XPC/VolumeManager.swift +++ b/boringNotch/components/OSD/Managers/XPC/VolumeManager.swift @@ -102,7 +102,7 @@ final class VolumeManager: NSObject, ObservableObject { } // Hardware mute preserves the underlying volume level. publish(volume: deviceSupportsMute ? rawVolume : 0, muted: true, touchDate: true) - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .volume, value: 0) + NotchUIEventBus.events.send(.sneakPeek(type: .volume, value: 0)) } else { if deviceSupportsMute { enqueueHardwareMute(false) @@ -111,8 +111,7 @@ final class VolumeManager: NSObject, ObservableObject { requestVolumeWrite(previousVolumeBeforeMute) } publish(volume: deviceSupportsMute ? rawVolume : resultingVolume, muted: false, touchDate: true) - BoringViewCoordinator.shared.toggleSneakPeek( - status: true, type: .volume, value: CGFloat(resultingVolume)) + NotchUIEventBus.events.send(.sneakPeek(type: .volume, value: CGFloat(resultingVolume))) } } @@ -142,7 +141,7 @@ final class VolumeManager: NSObject, ObservableObject { publish(volume: target, muted: true, touchDate: true) } requestVolumeWrite(target) - BoringViewCoordinator.shared.toggleSneakPeek(status: true, type: .volume, value: CGFloat(target)) + NotchUIEventBus.events.send(.sneakPeek(type: .volume, value: CGFloat(target))) } // MARK: - Hardware I/O (audioQueue) diff --git a/boringNotch/components/Settings/Views/OSDSettingsView.swift b/boringNotch/components/Settings/Views/OSDSettingsView.swift index 7df5400c8..02c9f4c90 100644 --- a/boringNotch/components/Settings/Views/OSDSettingsView.swift +++ b/boringNotch/components/Settings/Views/OSDSettingsView.swift @@ -18,6 +18,7 @@ struct OSDSettings: View { @Default(.osdVolumeSource) private var osdVolumeSourceDefault @State private var isAccessibilityAuthorized = true @State private var menuBarBrightnessSupported = true + @ObservedObject private var xpcClient = XPCHelperClient.shared var body: some View { Form { @@ -75,6 +76,23 @@ struct OSDSettings: View { Text(OSDControlSource.builtin.localizedString) } HelpText("Keyboard brightness currently supports the built-in source only.") + if !xpcClient.helperAvailable { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.title) + .foregroundStyle(.yellow) + + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "Helper Service Unavailable")) + .font(.headline) + Text(String(localized: "The background helper crashed or was closed by macOS. It restarts automatically on the next OSD event.")) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 4) + } if !isAccessibilityAuthorized { HStack(alignment: .center, spacing: 12) { Image(systemName: "accessibility") diff --git a/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift b/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift index 50187977e..318caa329 100644 --- a/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift +++ b/boringNotch/components/Shelf/ViewModels/ShelfItemViewModel.swift @@ -16,40 +16,6 @@ import ObjectiveC final class ShelfItemViewModel: ObservableObject { @Published private(set) var item: ShelfItem - // MARK: - Localization helpers - private struct Strings { - static let open = NSLocalizedString("Shelf.ContextMenu.Open", comment: "Context menu item: Open") - static let openWith = NSLocalizedString("Shelf.ContextMenu.OpenWith", comment: "Context menu item: Open With") - static let noCompatibleApps = NSLocalizedString("Shelf.ContextMenu.NoCompatibleAppsFound", comment: "Context menu item: No Compatible Apps Found") - static let other = NSLocalizedString("Shelf.ContextMenu.Other", comment: "Context menu item: Other…") - static let showInFinder = NSLocalizedString("Shelf.ContextMenu.ShowInFinder", comment: "Context menu item: Show in Finder") - static let quickLook = NSLocalizedString("Shelf.ContextMenu.QuickLook", comment: "Context menu item: Quick Look") - static let share = NSLocalizedString("Shelf.ContextMenu.Share", comment: "Context menu item: Share…") - static let imageActions = NSLocalizedString("Shelf.ContextMenu.ImageActions", comment: "Context menu item: Image Actions") - static let removeBackground = NSLocalizedString("Shelf.ContextMenu.RemoveBackground", comment: "Context menu item: Remove Background") - static let convertImage = NSLocalizedString("Shelf.ContextMenu.ConvertImage", comment: "Context menu item: Convert Image…") - static let createPDF = NSLocalizedString("Shelf.ContextMenu.CreatePDF", comment: "Context menu item: Create PDF") - static let compress = NSLocalizedString("Shelf.ContextMenu.Compress", comment: "Context menu item: Compress") - static let rename = NSLocalizedString("Shelf.ContextMenu.Rename", comment: "Context menu item: Rename") - static let copy = NSLocalizedString("Shelf.ContextMenu.Copy", comment: "Context menu item: Copy") - static let copyPath = NSLocalizedString("Shelf.ContextMenu.CopyPath", comment: "Context menu item: Copy Path") - static let remove = NSLocalizedString("Shelf.ContextMenu.Remove", comment: "Context menu item: Remove") - } - - private enum ContextMenuAction: String { - case quickLook - case open - case share - case rename - case showInFinder - case copyPath - case copy - case remove - case removeBackground - case convertImage - case createPDF - case compress - } @Published var thumbnail: NSImage? @Published var isDropTargeted: Bool = false @Published var isRenaming: Bool = false @@ -57,7 +23,6 @@ final class ShelfItemViewModel: ObservableObject { private var sharingLifecycle: SharingLifecycleDelegate? private var quickShareLifecycle: SharingLifecycleDelegate? private var sharingAccessingURLs: [URL] = [] - private static var copiedURLs: [URL] = [] private let selection = ShelfSelectionModel.shared @@ -145,7 +110,11 @@ final class ShelfItemViewModel: ObservableObject { func handleRightClick(event: NSEvent, view: NSView) { if !selection.isSelected(item.id) { selection.selectSingle(item) } - presentContextMenu(event: event, in: view) + ShelfContextMenuBuilder.present( + item: item, event: event, in: view, + onShare: { [weak self] v in self?.shareItem(from: v) }, + onQuickLook: { [weak self] urls in self?.onQuickLookRequest?(urls) } + ) } func handleDoubleClick() { @@ -207,934 +176,4 @@ final class ShelfItemViewModel: ObservableObject { /// Call this closure to request a QuickLook preview for the given URLs. var onQuickLookRequest: (([URL]) -> Void)? - - // MARK: - Context Menu helpers (extracted from view) - func loadOpenWithApps() -> [URL] { - // Support both files and link items. For link items we ask NSWorkspace for apps that can open the URL (browsers). - if let fileURL = item.fileURL { - var results: [URL] = NSWorkspace.shared.urlsForApplications(toOpen: fileURL) - if results.isEmpty { - if let uti = try? fileURL.resourceValues(forKeys: [.contentTypeKey]).contentType { - results = NSWorkspace.shared.urlsForApplications(toOpen: uti) - } - } - let unique = Array(Set(results)) - let sorted = unique.sorted { appDisplayName(for: $0) < appDisplayName(for: $1) } - return sorted - } else if case .link(let url) = item.kind { - var results: [URL] = NSWorkspace.shared.urlsForApplications(toOpen: url) - if results.isEmpty { - if let uti = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType { - results = NSWorkspace.shared.urlsForApplications(toOpen: uti) - } - } - let unique = Array(Set(results)) - let sorted = unique.sorted { appDisplayName(for: $0) < appDisplayName(for: $1) } - return sorted - } - return [] - } - - private func ensureContextMenuSelection() { - if !selection.isSelected(item.id) { selection.selectSingle(item) } - } - - func presentContextMenu(event: NSEvent, in view: NSView) { - ensureContextMenuSelection() - let menu = NSMenu() - - func addMenuItem(title: String) { - let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "") - menu.addItem(mi) - } - - let selectedItems = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let selectedFileURLs = selectedItems.compactMap { $0.fileURL } - let selectedLinkURLs: [URL] = selectedItems.compactMap { itm in - if case .link(let url) = itm.kind { return url } - return nil - } - let selectedFolderURLs = selectedFileURLs.filter { isDirectory($0) } - // URLs valid for Open/Open With (exclude folders) - let selectedOpenableURLs = selectedItems.compactMap { itm -> URL? in - if let u = itm.fileURL { return isDirectory(u) ? nil : u } - if case .link(let url) = itm.kind { return url } - return nil - } - - if !selectedOpenableURLs.isEmpty { - addMenuItem(title: String(localized: "Open")) - } - - if !selectedOpenableURLs.isEmpty { - let openWith = NSMenuItem(title: String(localized: "Open With"), action: nil, keyEquivalent: "") - let submenu = NSMenu() - - // Choose a representative URL to compute apps (prefer current item if not a folder) - let baseURLForApps: URL? = { - if let u = item.fileURL, !isDirectory(u) { return u } - if case .link(let u) = item.kind { return u } - return selectedOpenableURLs.first - }() - - let openWithApps: [URL] = { - guard let u = baseURLForApps else { return [] } - if u.isFileURL { - var results = NSWorkspace.shared.urlsForApplications(toOpen: u) - if results.isEmpty, let uti = try? u.resourceValues(forKeys: [.contentTypeKey]).contentType { - results = NSWorkspace.shared.urlsForApplications(toOpen: uti) - } - return Array(Set(results)) - } else { - return Array(Set(NSWorkspace.shared.urlsForApplications(toOpen: u))) - } - }() - let defaultApp = defaultAppURL() - - if openWithApps.isEmpty { - let noApps = NSMenuItem(title: String(localized: "No Compatible Apps Found"), action: nil, keyEquivalent: "") - noApps.isEnabled = false - submenu.addItem(noApps) - } else { - if let defaultApp = defaultApp { - let appName = appDisplayName(for: defaultApp) - let def = NSMenuItem(title: appName, action: nil, keyEquivalent: "") - def.representedObject = defaultApp - def.image = nsAppIcon(for: defaultApp, size: 16) - - let title = NSMutableAttributedString(string: appName, attributes: [ - .font: NSFont.menuFont(ofSize: 0), - .foregroundColor: NSColor.labelColor - ]) - let defaultPart = NSAttributedString(string: " (default)", attributes: [ - .font: NSFont.menuFont(ofSize: 0), - .foregroundColor: NSColor.secondaryLabelColor - ]) - title.append(defaultPart) - def.attributedTitle = title - submenu.addItem(def) - - if openWithApps.count > 1 || !openWithApps.contains(defaultApp) { - submenu.addItem(NSMenuItem.separator()) - } - } - for appURL in openWithApps where appURL != defaultApp { - let mi = NSMenuItem(title: appDisplayName(for: appURL), action: nil, keyEquivalent: "") - mi.representedObject = appURL - mi.image = nsAppIcon(for: appURL, size: 16) - submenu.addItem(mi) - } - } - - submenu.addItem(NSMenuItem.separator()) - let other = NSMenuItem(title: String(localized: "Other…"), action: nil, keyEquivalent: "") - other.representedObject = "__OTHER__" - submenu.addItem(other) - - openWith.submenu = submenu - menu.addItem(openWith) - } - - if !selectedFileURLs.isEmpty { addMenuItem(title: String(localized: "Show in Finder")) } - // Allow Quick Look for files and link URLs - if !selectedFileURLs.isEmpty || !selectedLinkURLs.isEmpty { - // Add Quick Look menu item - let quickLookItem = NSMenuItem(title: String(localized: "Quick Look"), action: nil, keyEquivalent: "") - menu.addItem(quickLookItem) - - // Add Slideshow as alternate menu item (shown when Option key is held) - let slideshowItem = NSMenuItem(title: String(localized: "Quick Look"), action: nil, keyEquivalent: "") - slideshowItem.isAlternate = true - slideshowItem.keyEquivalentModifierMask = [.option] - menu.addItem(slideshowItem) - } - - menu.addItem(NSMenuItem.separator()) - addMenuItem(title: String(localized: "Share…")) - - // Add image processing options for image files grouped under "Image Actions" - let imageURLs = selectedFileURLs.filter { ImageProcessingService.shared.isImageFile($0) } - if !imageURLs.isEmpty { - menu.addItem(NSMenuItem.separator()) - - let imageActions = NSMenuItem(title: String(localized: "Image Actions"), action: nil, keyEquivalent: "") - let imageSubmenu = NSMenu() - - // Remove Background - only for single images - if imageURLs.count == 1 { - let removeBg = NSMenuItem(title: String(localized: "Remove Background"), action: nil, keyEquivalent: "") - imageSubmenu.addItem(removeBg) - } - - // Convert Image - only for single images - if imageURLs.count == 1 { - let convertItem = NSMenuItem(title: String(localized: "Convert Image…"), action: nil, keyEquivalent: "") - imageSubmenu.addItem(convertItem) - } - - // Create PDF - for one or more images - let createPDF = NSMenuItem(title: String(localized: "Create PDF"), action: nil, keyEquivalent: "") - imageSubmenu.addItem(createPDF) - - imageActions.submenu = imageSubmenu - menu.addItem(imageActions) - menu.addItem(NSMenuItem.separator()) - } - - // Add compression option for files/folders (single or multiple) - if !selectedFileURLs.isEmpty { - let compressItem = NSMenuItem(title: String(localized: "Compress"), action: nil, keyEquivalent: "") - menu.addItem(compressItem) - } - - if selectedItems.count == 1, case .file(_) = item.kind { addMenuItem(title: String(localized: "Rename")) } - - // Always show "Copy" for all item types - addMenuItem(title: String(localized: "Copy")) - // If there are file URLs, add "Copy Path" as an alternate menu item (Option key) - if !selectedFileURLs.isEmpty { - let copyPathItem = NSMenuItem(title: String(localized: "Copy Path"), action: nil, keyEquivalent: "") - copyPathItem.isAlternate = true - copyPathItem.keyEquivalentModifierMask = [.option] - menu.addItem(copyPathItem) - } - - menu.addItem(NSMenuItem.separator()) - addMenuItem(title: String(localized: "Remove")) - - let actionTarget = MenuActionTarget(item: item, view: view, viewModel: self) - - for menuItem in menu.items { - if menuItem.isSeparatorItem { continue } - menuItem.target = actionTarget - menuItem.action = #selector(MenuActionTarget.handle(_:)) - - if let submenu = menuItem.submenu { - for subItem in submenu.items { - if !subItem.isSeparatorItem { - subItem.target = actionTarget - subItem.action = #selector(MenuActionTarget.handle(_:)) - } - } - } - } - - menu.retainActionTarget(actionTarget) - - NSMenu.popUpContextMenu(menu, with: event, for: view) - } - - private func isDirectory(_ url: URL) -> Bool { - return url.accessSecurityScopedResource { scoped in - (try? scoped.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false - } - } - - private final class MenuActionTarget: NSObject { - let item: ShelfItem - weak var view: NSView? - weak var viewModel: ShelfItemViewModel? - - // Keep associated objects (like accessory view handlers) without magic keys - private static var sliderHandlerAssoc = AssociatedObject() - - init(item: ShelfItem, view: NSView, viewModel: ShelfItemViewModel) { - self.item = item - self.view = view - self.viewModel = viewModel - } - - @MainActor @objc func handle(_ sender: NSMenuItem) { - let title = sender.title - - if let marker = sender.representedObject as? String, marker == "__OTHER__" { - openWithPanel() - return - } - - if let appURL = sender.representedObject as? URL { - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - - Task { - var allSelectedURLs: [URL] = [] - - for itm in selected { - if let fileURL = itm.fileURL { - allSelectedURLs.append(fileURL) - } else if case .link(let url) = itm.kind { - allSelectedURLs.append(url) - } - } - - guard !allSelectedURLs.isEmpty else { return } - - let config = NSWorkspace.OpenConfiguration() - - let fileURLs = allSelectedURLs.filter { $0.isFileURL } - do { - if !fileURLs.isEmpty { - _ = try await fileURLs.accessSecurityScopedResources { _ in - try await NSWorkspace.shared.open(allSelectedURLs, withApplicationAt: appURL, configuration: config) - } - } else { - try await NSWorkspace.shared.open(allSelectedURLs, withApplicationAt: appURL, configuration: config) - } - } catch { - print("❌ Failed to open with application: \(error.localizedDescription)") - } - } - return - } - - switch title { - case "Quick Look": - // Handle all selected items for Quick Look, not just the clicked item - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let urls: [URL] = selected.compactMap { item in - if let fileURL = item.fileURL { - return fileURL - } - if case .link(let url) = item.kind { - return url - } - return nil - } - if !urls.isEmpty { - viewModel?.onQuickLookRequest?(urls) - } - - case "Open": - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - for it in selected { ShelfActionService.open(it) } - - case "Share…": - viewModel?.shareItem(from: view) - - case "Rename": - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - if selected.count == 1, let single = selected.first { showRenameDialog(for: single) } - - case "Show in Finder": - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - Task { - let urls = await selected.asyncCompactMap { item -> URL? in - if case .file = item.kind { - // Use immediate update for user-initiated menu action - return ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) - } - return nil - } - if !urls.isEmpty { - await urls.accessSecurityScopedResources { accessibleURLs in - NSWorkspace.shared.activateFileViewerSelecting(accessibleURLs) - } - } - } - - case "Copy Path": - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let paths = selected.compactMap { $0.fileURL?.path } - if !paths.isEmpty { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(paths.joined(separator: "\n"), forType: .string) - } - - case "Copy": - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let pb = NSPasteboard.general - - // Stop accessing previously copied URLs - for url in ShelfItemViewModel.copiedURLs { - url.stopAccessingSecurityScopedResource() - } - ShelfItemViewModel.copiedURLs.removeAll() - - pb.clearContents() - Task { - let fileURLs = await selected.asyncCompactMap { item -> URL? in - if case .file = item.kind { - return ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) - } - return nil - } - if !fileURLs.isEmpty { - // Start security-scoped access for all URLs and keep them active - ShelfItemViewModel.copiedURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() } - NSLog("🔐 Started security-scoped access for \(ShelfItemViewModel.copiedURLs.count) copied files") - - // Write to pasteboard - pb.writeObjects(fileURLs as [NSURL]) - } else { - let strings = selected.map { $0.displayName } - if !strings.isEmpty { - pb.setString(strings.joined(separator: "\n"), forType: .string) - } - } - } - - case "Remove": - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - for it in selected { ShelfActionService.remove(it) } - - case "Remove Background": - handleRemoveBackground() - - case "Convert Image…": - showConvertImageDialog() - - case "Create PDF": - handleCreatePDF() - - case "Compress": - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let fileURLs = selected.compactMap { $0.fileURL } - guard !fileURLs.isEmpty else { break } - - Task { - // Create ZIP in a temporary location while holding access to selected resources - if let zipTempURL = await fileURLs.accessSecurityScopedResources(accessor: { urls in - await TemporaryFileStorageService.shared.createZip(from: urls) - }) { - if let bookmark = try? Bookmark(url: zipTempURL) { - let newItem = ShelfItem(kind: .file(bookmark: bookmark.data), isTemporary: true) - ShelfStateViewModel.shared.add([newItem]) - } else { - // Fallback: reveal the temporary file in Finder - NSWorkspace.shared.activateFileViewerSelecting([zipTempURL]) - } - } - } - - default: - break - } - } - - @MainActor - private func openWithPanel() { - // Support both file items and link items - let targetURL: URL? - let needsSecurityScope: Bool - - if let fileURL = item.fileURL { - targetURL = fileURL - needsSecurityScope = true - } else if case .link(let url) = item.kind { - targetURL = url - needsSecurityScope = false - } else { - targetURL = nil - needsSecurityScope = false - } - guard let fileURL = targetURL else { return } - - let panel = NSOpenPanel() - panel.title = "Choose Application" - panel.message = "Choose an application to open the document \"\(item.displayName)\"." - panel.prompt = "Open" - panel.allowsMultipleSelection = false - panel.canChooseFiles = true - panel.canChooseDirectories = false - panel.resolvesAliases = true - if #available(macOS 12.0, *) { - panel.allowedContentTypes = [.application] - } - panel.directoryURL = URL(fileURLWithPath: "/Applications") - - // Compute recommended applications for the selected target - let recommendedApps: Set = { - let apps: [URL] - if let uti = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType { - apps = NSWorkspace.shared.urlsForApplications(toOpen: uti) - } else { - apps = NSWorkspace.shared.urlsForApplications(toOpen: fileURL) - } - return Set(apps.map { $0.standardizedFileURL }) - }() - - // Delegate to filter entries when in "Recommended Applications" mode - final class AppChooserDelegate: NSObject, NSOpenSavePanelDelegate { - enum Mode { case recommended, all } - var mode: Mode = .recommended - let recommended: Set - init(recommended: Set) { self.recommended = recommended } - - func panel(_ sender: Any, shouldEnable url: URL) -> Bool { - let ext = url.pathExtension.lowercased() - if ext == "app" { - switch mode { - case .all: - return true - case .recommended: - // Standardize URLs for reliable comparison - let std = url.standardizedFileURL - return recommended.contains(std) - } - } - - var isDirectory: ObjCBool = false - if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue { - return true - } - - return false - } - } - - let chooserDelegate = AppChooserDelegate(recommended: recommendedApps) - panel.delegate = chooserDelegate - - let enableLabel = NSTextField(labelWithString: "Enable:") - enableLabel.font = .systemFont(ofSize: NSFont.systemFontSize) - enableLabel.alignment = .natural - enableLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) - - let popup = NSPopUpButton(frame: .zero, pullsDown: false) - popup.addItems(withTitles: ["Recommended Applications", "All Applications"]) - popup.font = .systemFont(ofSize: NSFont.systemFontSize) - popup.selectItem(at: 0) - - popup.setContentHuggingPriority(.defaultLow, for: .horizontal) - popup.widthAnchor.constraint(greaterThanOrEqualToConstant: 200).isActive = true - - let alwaysCheckbox = NSButton(checkboxWithTitle: "Always Open With", target: nil, action: nil) - alwaysCheckbox.font = .systemFont(ofSize: NSFont.systemFontSize) - alwaysCheckbox.setContentHuggingPriority(.defaultLow, for: .horizontal) - - let row = NSStackView(views: [enableLabel, popup]) - row.orientation = .horizontal - row.spacing = 8 - row.alignment = .centerY - row.distribution = .fill - - let column = NSStackView(views: [row, alwaysCheckbox]) - column.orientation = .vertical - column.spacing = 12 - column.alignment = .centerX - column.distribution = .fill - column.edgeInsets = NSEdgeInsets(top: 16, left: 20, bottom: 16, right: 20) - - panel.accessoryView = column - panel.isAccessoryViewDisclosed = true - - // Wire up popup to switch filter mode - class PopupBinder: NSObject { - weak var popup: NSPopUpButton? - weak var chooserDelegate: AppChooserDelegate? - weak var panel: NSOpenPanel? - init(popup: NSPopUpButton, chooserDelegate: AppChooserDelegate, panel: NSOpenPanel) { - self.popup = popup - self.chooserDelegate = chooserDelegate - self.panel = panel - } - @MainActor @objc func changed(_ sender: Any?) { - if popup?.indexOfSelectedItem == 1 { - chooserDelegate?.mode = .all - } else { - chooserDelegate?.mode = .recommended - } - if let panel = panel { - panel.validateVisibleColumns() - let currentDir = panel.directoryURL - panel.directoryURL = currentDir - } - } - } - let binder = PopupBinder(popup: popup, chooserDelegate: chooserDelegate, panel: panel) - popup.target = binder - popup.action = #selector(PopupBinder.changed(_:)) - - panel.begin { response in - if response == .OK, let appURL = panel.url { - Task { - do { - let config = NSWorkspace.OpenConfiguration() - if alwaysCheckbox.state == .on, let bundleID = Bundle(url: appURL)?.bundleIdentifier { - if let contentType = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType { - let status = LSSetDefaultRoleHandlerForContentType(contentType.identifier as CFString, LSRolesMask.all, bundleID as CFString) - if status != noErr { print("⚠️ Failed to set default handler for \(contentType.identifier): \(status)") } - } else if let scheme = fileURL.scheme { - let status = LSSetDefaultHandlerForURLScheme(scheme as CFString, bundleID as CFString) - if status != noErr { print("⚠️ Failed to set default handler for scheme \(scheme): \(status)") } - } - } - - if needsSecurityScope { - _ = try await fileURL.accessSecurityScopedResource { accessibleURL in - try await NSWorkspace.shared.open([accessibleURL], withApplicationAt: appURL, configuration: config) - } - } else { - try await NSWorkspace.shared.open([fileURL], withApplicationAt: appURL, configuration: config) - } - } catch { - print("❌ Failed to open with application: \(error.localizedDescription)") - } - } - } - // Keep binder/delegate alive until panel finishes - _ = binder - _ = chooserDelegate - } - } - - @MainActor - private func showRenameDialog(for item: ShelfItem) { - guard case let .file(bookmarkData) = item.kind else { return } - Task { - let bookmark = Bookmark(data: bookmarkData) - if let fileURL = bookmark.resolvedURL { - // Start security-scoped access and keep it active until rename completes. - let didStart = fileURL.startAccessingSecurityScopedResource() - - let savePanel = NSSavePanel() - savePanel.title = "Rename File" - savePanel.prompt = "Rename" - savePanel.nameFieldStringValue = fileURL.lastPathComponent - savePanel.directoryURL = fileURL.deletingLastPathComponent() - savePanel.begin { response in - if response == .OK, let newURL = savePanel.url { - Task { - do { - NSLog("🔐 Rename: moving from \(fileURL.path) to \(newURL.path) (securityScope=\(didStart))") - - try FileManager.default.moveItem(at: fileURL, to: newURL) - - if let newBookmark = try? Bookmark(url: newURL) { - ShelfStateViewModel.shared.updateBookmark(for: item, bookmark: newBookmark.data) - } - } catch { - print("❌ Failed to rename file: \(error.localizedDescription)") - } - if didStart { fileURL.stopAccessingSecurityScopedResource() } - } - } else { - if didStart { fileURL.stopAccessingSecurityScopedResource() } - } - } - } - } - } - - @MainActor - private func handleRemoveBackground() { - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } - - guard let imageURL = imageURLs.first else { return } - - Task { - do { - let resultURL = try await imageURL.accessSecurityScopedResource { url in - try await ImageProcessingService.shared.removeBackground(from: url) - } - - if let resultURL = resultURL { - // Create bookmark and add to shelf as temporary item - if let bookmark = try? Bookmark(url: resultURL) { - let newItem = ShelfItem( - kind: .file(bookmark: bookmark.data), - isTemporary: true - ) - ShelfStateViewModel.shared.add([newItem]) - } - } - } catch { - print("❌ Failed to remove background: \(error.localizedDescription)") - showErrorAlert(title: String(localized: "Background Removal Failed"), message: error.localizedDescription) - } - } - } - - @MainActor - private func handleCreatePDF() { - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } - - guard !imageURLs.isEmpty else { return } - - Task { - do { - let resultURL = try await imageURLs.accessSecurityScopedResources { urls in - try await ImageProcessingService.shared.createPDF(from: urls) - } - - if let resultURL = resultURL { - // Create bookmark and add to shelf as temporary item - if let bookmark = try? Bookmark(url: resultURL) { - let newItem = ShelfItem( - kind: .file(bookmark: bookmark.data), - isTemporary: true - ) - ShelfStateViewModel.shared.add([newItem]) - } - } - } catch { - print("❌ Failed to create PDF: \(error.localizedDescription)") - showErrorAlert(title: String(localized: "PDF Creation Failed"), message: error.localizedDescription) - } - } - } - - @MainActor - private func showConvertImageDialog() { - let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) - let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } - - guard let imageURL = imageURLs.first else { return } - - // Create and show conversion options dialog with better layout - let alert = NSAlert() - alert.messageText = "Convert Image" - alert.alertStyle = .informational - alert.addButton(withTitle: "Convert") - alert.addButton(withTitle: "Cancel") - - // Create accessory view with better spacing and organization - let accessoryView = NSView(frame: NSRect(x: 0, y: 0, width: 380, height: 180)) - accessoryView.wantsLayer = true - - // MARK: Format Row - let formatLabel = NSTextField(labelWithString: "Format:") - formatLabel.frame = NSRect(x: 0, y: 145, width: 100, height: 20) - formatLabel.font = .systemFont(ofSize: 12, weight: .medium) - accessoryView.addSubview(formatLabel) - - let formatPopup = NSPopUpButton(frame: NSRect(x: 120, y: 140, width: 250, height: 28)) - formatPopup.addItems(withTitles: ["PNG", "JPEG", "HEIC", "TIFF", "BMP"]) - formatPopup.selectItem(at: 0) - formatPopup.font = .systemFont(ofSize: 12) - accessoryView.addSubview(formatPopup) - - // MARK: Image Size Row - let imageSizeLabel = NSTextField(labelWithString: "Image Size:") - imageSizeLabel.frame = NSRect(x: 0, y: 105, width: 100, height: 20) - imageSizeLabel.font = .systemFont(ofSize: 12, weight: .medium) - accessoryView.addSubview(imageSizeLabel) - - let imageSizePopup = NSPopUpButton(frame: NSRect(x: 120, y: 100, width: 160, height: 28)) - imageSizePopup.addItems(withTitles: ["Actual Size", "Large", "Medium", "Small", "Custom..."]) - imageSizePopup.selectItem(at: 0) - imageSizePopup.font = .systemFont(ofSize: 12) - accessoryView.addSubview(imageSizePopup) - - // Custom size field (initially hidden) - let customSizeField = NSTextField(frame: NSRect(x: 285, y: 103, width: 85, height: 22)) - customSizeField.placeholderString = "e.g., 1920" - customSizeField.font = .systemFont(ofSize: 12) - customSizeField.isHidden = true - accessoryView.addSubview(customSizeField) - - // MARK: Preserve Metadata Checkbox - let metadataCheckbox = NSButton(checkboxWithTitle: "Preserve Metadata", target: nil, action: nil) - metadataCheckbox.frame = NSRect(x: 120, y: 65, width: 200, height: 20) - metadataCheckbox.font = .systemFont(ofSize: 12) - metadataCheckbox.state = .on - accessoryView.addSubview(metadataCheckbox) - - // MARK: Separator line - let separatorLine = NSView(frame: NSRect(x: 0, y: 50, width: 380, height: 1)) - separatorLine.wantsLayer = true - separatorLine.layer?.backgroundColor = NSColor.separatorColor.cgColor - accessoryView.addSubview(separatorLine) - - // MARK: Format-specific options (shown/hidden based on format selection) - let qualityRow = NSView(frame: NSRect(x: 0, y: 15, width: 380, height: 30)) - qualityRow.wantsLayer = true - - let qualityLabel = NSTextField(labelWithString: "Compression:") - qualityLabel.frame = NSRect(x: 0, y: 7, width: 100, height: 20) - qualityLabel.font = .systemFont(ofSize: 12, weight: .medium) - qualityRow.addSubview(qualityLabel) - - let qualitySlider = NSSlider(frame: NSRect(x: 120, y: 12, width: 200, height: 20)) - qualitySlider.minValue = 0.0 - qualitySlider.maxValue = 1.0 - qualitySlider.doubleValue = 0.85 - accessoryView.addSubview(qualitySlider) - - let qualityValueLabel = NSTextField(labelWithString: "85%") - qualityValueLabel.frame = NSRect(x: 325, y: 7, width: 55, height: 20) - qualityValueLabel.font = .systemFont(ofSize: 12) - qualityValueLabel.alignment = .left - accessoryView.addSubview(qualityValueLabel) - - // Update quality label and hide/show compression row based on format - let updateQualityLabel = { - let value = Int(qualitySlider.doubleValue * 100) - qualityValueLabel.stringValue = "\(value)%" - } - - let updateCompressionVisibility = { - let formatIndex = formatPopup.indexOfSelectedItem - let showCompression = formatIndex == 1 || formatIndex == 2 // JPEG or HEIC - qualitySlider.isHidden = !showCompression - qualityValueLabel.isHidden = !showCompression - qualityLabel.isHidden = !showCompression - } - - let updateCustomSizeVisibility = { - let sizeIndex = imageSizePopup.indexOfSelectedItem - customSizeField.isHidden = sizeIndex != 4 // Show only for "Custom..." - } - - // Create a target object to handle slider value changes - class SliderHandler: NSObject { - let updateLabel: () -> Void - let updateVisibility: () -> Void - let updateCustomSize: () -> Void - init(updateLabel: @escaping () -> Void, updateVisibility: @escaping () -> Void, updateCustomSize: @escaping () -> Void) { - self.updateLabel = updateLabel - self.updateVisibility = updateVisibility - self.updateCustomSize = updateCustomSize - } - @objc func sliderChanged(_ sender: NSSlider) { - updateLabel() - } - @objc func formatChanged(_ sender: NSPopUpButton) { - updateVisibility() - } - @objc func sizeChanged(_ sender: NSPopUpButton) { - updateCustomSize() - } - } - - let handler = SliderHandler(updateLabel: updateQualityLabel, updateVisibility: updateCompressionVisibility, updateCustomSize: updateCustomSizeVisibility) - qualitySlider.target = handler - qualitySlider.action = #selector(SliderHandler.sliderChanged(_:)) - qualitySlider.isContinuous = true - - formatPopup.target = handler - formatPopup.action = #selector(SliderHandler.formatChanged(_:)) - - imageSizePopup.target = handler - imageSizePopup.action = #selector(SliderHandler.sizeChanged(_:)) - - updateCompressionVisibility() - updateQualityLabel() - updateCustomSizeVisibility() - - // Keep the handler alive using the `AssociatedObject` helper instead of a magic string key - MenuActionTarget.sliderHandlerAssoc[accessoryView] = handler - - alert.accessoryView = accessoryView - - let response = alert.runModal() - - if response == .alertFirstButtonReturn { - // Get selected options - let formatIndex = formatPopup.indexOfSelectedItem - let format: ImageConversionOptions.ImageFormat - switch formatIndex { - case 0: format = .png - case 1: format = .jpeg - case 2: format = .heic - case 3: format = .tiff - case 4: format = .bmp - default: format = .png - } - - let quality = qualitySlider.doubleValue - - // Get max dimension based on image size selection - let maxDimension: CGFloat? = { - let sizeIndex = imageSizePopup.indexOfSelectedItem - switch sizeIndex { - case 0: return nil // Actual Size - case 1: return 1280 // Large - case 2: return 640 // Medium - case 3: return 320 // Small - case 4: // Custom (user-specified) - let text = customSizeField.stringValue.trimmingCharacters(in: .whitespaces) - guard !text.isEmpty, let value = Double(text), value > 0 else { return nil } - return CGFloat(value) - default: return nil - } - }() - - let removeMetadata = metadataCheckbox.state == .off // Note: we invert this - - let options = ImageConversionOptions( - format: format, - compressionQuality: quality, - maxDimension: maxDimension, - removeMetadata: removeMetadata - ) - - Task { - do { - let resultURL = try await imageURL.accessSecurityScopedResource { url in - try await ImageProcessingService.shared.convertImage(from: url, options: options) - } - - if let resultURL = resultURL { - // Create bookmark and add to shelf as temporary item - if let bookmark = try? Bookmark(url: resultURL) { - let newItem = ShelfItem( - kind: .file(bookmark: bookmark.data), - isTemporary: true - ) - ShelfStateViewModel.shared.add([newItem]) - } - } - } catch { - print("❌ Failed to convert image: \(error.localizedDescription)") - showErrorAlert(title: String(localized: "Image Conversion Failed"), message: error.localizedDescription) - } - } - } - } - - @MainActor - private func showErrorAlert(title: String, message: String) { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.alertStyle = .warning - alert.addButton(withTitle: "OK") - alert.runModal() - } - } - - // MARK: - Private helpers - private func appDisplayName(for appURL: URL) -> String { - (try? appURL.resourceValues(forKeys: [.localizedNameKey]).localizedName) ?? appURL.lastPathComponent - } - - private func nsAppIcon(for appURL: URL, size: CGFloat) -> NSImage? { - let baseIcon = NSWorkspace.shared.icon(forFile: appURL.path) - baseIcon.isTemplate = false - - let targetSize = NSSize(width: size, height: size) - let rendered = NSImage(size: targetSize, flipped: false) { rect in - NSGraphicsContext.current?.imageInterpolation = .high - baseIcon.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1.0, respectFlipped: true, hints: [ - .interpolation: NSImageInterpolation.high.rawValue - ]) - return true - } - - rendered.size = targetSize - return rendered - } - - private func defaultAppURL() -> URL? { - if let fileURL = item.fileURL { - return NSWorkspace.shared.urlForApplication(toOpen: fileURL) - } else if case .link(let url) = item.kind { - return NSWorkspace.shared.urlForApplication(toOpen: url) - } - return nil - } -} - -fileprivate extension Sequence { - func asyncCompactMap(_ transform: (Element) async -> T?) async -> [T] { - var result: [T] = [] - for element in self { - if let transformed = await transform(element) { - result.append(transformed) - } - } - return result - } } diff --git a/boringNotch/components/Shelf/Views/ShelfContextMenu.swift b/boringNotch/components/Shelf/Views/ShelfContextMenu.swift new file mode 100644 index 000000000..6264f3ceb --- /dev/null +++ b/boringNotch/components/Shelf/Views/ShelfContextMenu.swift @@ -0,0 +1,973 @@ +// +// ShelfContextMenu.swift +// boringNotch +// +// AppKit context-menu construction and action dispatch for shelf items. +// Extracted from ShelfItemViewModel: the VM keeps item state and routes +// clicks here. Behavior preserved verbatim. +// + +import Foundation +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import CoreServices +import ObjectiveC + +// MARK: - Localization helpers +struct Strings { + static let open = NSLocalizedString("Shelf.ContextMenu.Open", comment: "Context menu item: Open") + static let openWith = NSLocalizedString("Shelf.ContextMenu.OpenWith", comment: "Context menu item: Open With") + static let noCompatibleApps = NSLocalizedString("Shelf.ContextMenu.NoCompatibleAppsFound", comment: "Context menu item: No Compatible Apps Found") + static let other = NSLocalizedString("Shelf.ContextMenu.Other", comment: "Context menu item: Other…") + static let showInFinder = NSLocalizedString("Shelf.ContextMenu.ShowInFinder", comment: "Context menu item: Show in Finder") + static let quickLook = NSLocalizedString("Shelf.ContextMenu.QuickLook", comment: "Context menu item: Quick Look") + static let share = NSLocalizedString("Shelf.ContextMenu.Share", comment: "Context menu item: Share…") + static let imageActions = NSLocalizedString("Shelf.ContextMenu.ImageActions", comment: "Context menu item: Image Actions") + static let removeBackground = NSLocalizedString("Shelf.ContextMenu.RemoveBackground", comment: "Context menu item: Remove Background") + static let convertImage = NSLocalizedString("Shelf.ContextMenu.ConvertImage", comment: "Context menu item: Convert Image…") + static let createPDF = NSLocalizedString("Shelf.ContextMenu.CreatePDF", comment: "Context menu item: Create PDF") + static let compress = NSLocalizedString("Shelf.ContextMenu.Compress", comment: "Context menu item: Compress") + static let rename = NSLocalizedString("Shelf.ContextMenu.Rename", comment: "Context menu item: Rename") + static let copy = NSLocalizedString("Shelf.ContextMenu.Copy", comment: "Context menu item: Copy") + static let copyPath = NSLocalizedString("Shelf.ContextMenu.CopyPath", comment: "Context menu item: Copy Path") + static let remove = NSLocalizedString("Shelf.ContextMenu.Remove", comment: "Context menu item: Remove") +} + +enum ContextMenuAction: String { + case quickLook + case open + case share + case rename + case showInFinder + case copyPath + case copy + case remove + case removeBackground + case convertImage + case createPDF + case compress +} + +@MainActor +enum ShelfContextMenuBuilder { +static func present( + item: ShelfItem, event: NSEvent, in view: NSView, + onShare: @escaping (NSView?) -> Void, onQuickLook: @escaping ([URL]) -> Void +) { + let selection = ShelfSelectionModel.shared + if !selection.isSelected(item.id) { selection.selectSingle(item) } + let menu = NSMenu() + + func addMenuItem(title: String, contextAction: ContextMenuAction? = nil) { + let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "") + if let contextAction { + mi.representedObject = contextAction.rawValue + } + menu.addItem(mi) + } + + let selectedItems = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let selectedFileURLs = selectedItems.compactMap { $0.fileURL } + let selectedLinkURLs: [URL] = selectedItems.compactMap { itm in + if case .link(let url) = itm.kind { return url } + return nil + } + let selectedFolderURLs = selectedFileURLs.filter { isDirectory($0) } + // URLs valid for Open/Open With (exclude folders) + let selectedOpenableURLs = selectedItems.compactMap { itm -> URL? in + if let u = itm.fileURL { return isDirectory(u) ? nil : u } + if case .link(let url) = itm.kind { return url } + return nil + } + + if !selectedOpenableURLs.isEmpty { + addMenuItem(title: Strings.open, contextAction: .open) + } + + if !selectedOpenableURLs.isEmpty { + let openWith = NSMenuItem(title: Strings.openWith, action: nil, keyEquivalent: "") + let submenu = NSMenu() + + // Choose a representative URL to compute apps (prefer current item if not a folder) + let baseURLForApps: URL? = { + if let u = item.fileURL, !isDirectory(u) { return u } + if case .link(let u) = item.kind { return u } + return selectedOpenableURLs.first + }() + + let openWithApps: [URL] = { + guard let u = baseURLForApps else { return [] } + if u.isFileURL { + var results = NSWorkspace.shared.urlsForApplications(toOpen: u) + if results.isEmpty, let uti = try? u.resourceValues(forKeys: [.contentTypeKey]).contentType { + results = NSWorkspace.shared.urlsForApplications(toOpen: uti) + } + return Array(Set(results)) + } else { + return Array(Set(NSWorkspace.shared.urlsForApplications(toOpen: u))) + } + }() + let defaultApp = defaultAppURL(for: item) + + if openWithApps.isEmpty { + let noApps = NSMenuItem(title: Strings.noCompatibleApps, action: nil, keyEquivalent: "") + noApps.isEnabled = false + submenu.addItem(noApps) + } else { + if let defaultApp = defaultApp { + let appName = appDisplayName(for: defaultApp) + let def = NSMenuItem(title: appName, action: nil, keyEquivalent: "") + def.representedObject = defaultApp + def.image = nsAppIcon(for: defaultApp, size: 16) + + let title = NSMutableAttributedString(string: appName, attributes: [ + .font: NSFont.menuFont(ofSize: 0), + .foregroundColor: NSColor.labelColor + ]) + let defaultPart = NSAttributedString(string: " (default)", attributes: [ + .font: NSFont.menuFont(ofSize: 0), + .foregroundColor: NSColor.secondaryLabelColor + ]) + title.append(defaultPart) + def.attributedTitle = title + submenu.addItem(def) + + if openWithApps.count > 1 || !openWithApps.contains(defaultApp) { + submenu.addItem(NSMenuItem.separator()) + } + } + for appURL in openWithApps where appURL != defaultApp { + let mi = NSMenuItem(title: appDisplayName(for: appURL), action: nil, keyEquivalent: "") + mi.representedObject = appURL + mi.image = nsAppIcon(for: appURL, size: 16) + submenu.addItem(mi) + } + } + + submenu.addItem(NSMenuItem.separator()) + let other = NSMenuItem(title: Strings.other, action: nil, keyEquivalent: "") + other.representedObject = "__OTHER__" + submenu.addItem(other) + + openWith.submenu = submenu + menu.addItem(openWith) + } + + if !selectedFileURLs.isEmpty { addMenuItem(title: Strings.showInFinder, contextAction: .showInFinder) } + // Allow Quick Look for files and link URLs + if !selectedFileURLs.isEmpty || !selectedLinkURLs.isEmpty { + // Add Quick Look menu item + let quickLookItem = NSMenuItem(title: Strings.quickLook, action: nil, keyEquivalent: "") + quickLookItem.representedObject = ContextMenuAction.quickLook.rawValue + menu.addItem(quickLookItem) + + // Add Slideshow as alternate menu item (shown when Option key is held) + let slideshowItem = NSMenuItem(title: Strings.quickLook, action: nil, keyEquivalent: "") + slideshowItem.representedObject = ContextMenuAction.quickLook.rawValue + slideshowItem.isAlternate = true + slideshowItem.keyEquivalentModifierMask = [.option] + menu.addItem(slideshowItem) + } + + menu.addItem(NSMenuItem.separator()) + addMenuItem(title: Strings.share, contextAction: .share) + + // Add image processing options for image files grouped under "Image Actions" + let imageURLs = selectedFileURLs.filter { ImageProcessingService.shared.isImageFile($0) } + if !imageURLs.isEmpty { + menu.addItem(NSMenuItem.separator()) + + let imageActions = NSMenuItem(title: Strings.imageActions, action: nil, keyEquivalent: "") + let imageSubmenu = NSMenu() + + // Remove Background - only for single images + if imageURLs.count == 1 { + let removeBg = NSMenuItem(title: Strings.removeBackground, action: nil, keyEquivalent: "") + removeBg.representedObject = ContextMenuAction.removeBackground.rawValue + imageSubmenu.addItem(removeBg) + } + + // Convert Image - only for single images + if imageURLs.count == 1 { + let convertItem = NSMenuItem(title: Strings.convertImage, action: nil, keyEquivalent: "") + convertItem.representedObject = ContextMenuAction.convertImage.rawValue + imageSubmenu.addItem(convertItem) + } + + // Create PDF - for one or more images + let createPDF = NSMenuItem(title: Strings.createPDF, action: nil, keyEquivalent: "") + createPDF.representedObject = ContextMenuAction.createPDF.rawValue + imageSubmenu.addItem(createPDF) + + imageActions.submenu = imageSubmenu + menu.addItem(imageActions) + menu.addItem(NSMenuItem.separator()) + } + + // Add compression option for files/folders (single or multiple) + if !selectedFileURLs.isEmpty { + let compressItem = NSMenuItem(title: Strings.compress, action: nil, keyEquivalent: "") + compressItem.representedObject = ContextMenuAction.compress.rawValue + menu.addItem(compressItem) + } + + if selectedItems.count == 1, case .file(_) = item.kind { addMenuItem(title: Strings.rename, contextAction: .rename) } + + // Always show "Copy" for all item types + addMenuItem(title: Strings.copy, contextAction: .copy) + // If there are file URLs, add "Copy Path" as an alternate menu item (Option key) + if !selectedFileURLs.isEmpty { + let copyPathItem = NSMenuItem(title: Strings.copyPath, action: nil, keyEquivalent: "") + copyPathItem.representedObject = ContextMenuAction.copyPath.rawValue + copyPathItem.isAlternate = true + copyPathItem.keyEquivalentModifierMask = [.option] + menu.addItem(copyPathItem) + } + + menu.addItem(NSMenuItem.separator()) + addMenuItem(title: Strings.remove, contextAction: .remove) + + let actionTarget = MenuActionTarget(item: item, view: view, onShare: onShare, onQuickLook: onQuickLook) + + for menuItem in menu.items { + if menuItem.isSeparatorItem { continue } + menuItem.target = actionTarget + menuItem.action = #selector(MenuActionTarget.handle(_:)) + + if let submenu = menuItem.submenu { + for subItem in submenu.items { + if !subItem.isSeparatorItem { + subItem.target = actionTarget + subItem.action = #selector(MenuActionTarget.handle(_:)) + } + } + } + } + + menu.retainActionTarget(actionTarget) + + NSMenu.popUpContextMenu(menu, with: event, for: view) + } +} + +private func isDirectory(_ url: URL) -> Bool { + url.accessSecurityScopedResource { scoped in + (try? scoped.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + } +} + +func appDisplayName(for appURL: URL) -> String { + (try? appURL.resourceValues(forKeys: [.localizedNameKey]).localizedName) ?? appURL.lastPathComponent +} + +func nsAppIcon(for appURL: URL, size: CGFloat) -> NSImage? { + let baseIcon = NSWorkspace.shared.icon(forFile: appURL.path) + baseIcon.isTemplate = false + + let targetSize = NSSize(width: size, height: size) + let rendered = NSImage(size: targetSize, flipped: false) { rect in + NSGraphicsContext.current?.imageInterpolation = .high + baseIcon.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1.0, respectFlipped: true, hints: [ + .interpolation: NSImageInterpolation.high.rawValue + ]) + return true + } + + rendered.size = targetSize + return rendered +} + +@MainActor +func defaultAppURL(for item: ShelfItem) -> URL? { + if let fileURL = item.fileURL { + return NSWorkspace.shared.urlForApplication(toOpen: fileURL) + } else if case .link(let url) = item.kind { + return NSWorkspace.shared.urlForApplication(toOpen: url) + } + return nil +} + + +private final class MenuActionTarget: NSObject { + private static var copiedURLs: [URL] = [] + let item: ShelfItem + weak var view: NSView? + let onShare: (NSView?) -> Void + let onQuickLook: ([URL]) -> Void + + // Keep associated objects (like accessory view handlers) without magic keys + private static var sliderHandlerAssoc = AssociatedObject() + + init(item: ShelfItem, view: NSView, onShare: @escaping (NSView?) -> Void, onQuickLook: @escaping ([URL]) -> Void) { + self.item = item + self.view = view + self.onShare = onShare + self.onQuickLook = onQuickLook + } + + @MainActor @objc func handle(_ sender: NSMenuItem) { + if let marker = sender.representedObject as? String, marker == "__OTHER__" { + openWithPanel() + return + } + + // Dispatch on the action tag, never the (localized) title. + let actionRaw = sender.representedObject as? String + let action = actionRaw.flatMap { ContextMenuAction(rawValue: $0) } + + if let appURL = sender.representedObject as? URL { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + + Task { + var allSelectedURLs: [URL] = [] + + for itm in selected { + if let fileURL = itm.fileURL { + allSelectedURLs.append(fileURL) + } else if case .link(let url) = itm.kind { + allSelectedURLs.append(url) + } + } + + guard !allSelectedURLs.isEmpty else { return } + + let config = NSWorkspace.OpenConfiguration() + + let fileURLs = allSelectedURLs.filter { $0.isFileURL } + do { + if !fileURLs.isEmpty { + _ = try await fileURLs.accessSecurityScopedResources { _ in + try await NSWorkspace.shared.open(allSelectedURLs, withApplicationAt: appURL, configuration: config) + } + } else { + try await NSWorkspace.shared.open(allSelectedURLs, withApplicationAt: appURL, configuration: config) + } + } catch { + print("❌ Failed to open with application: \(error.localizedDescription)") + } + } + return + } + + switch action { + case .quickLook?: + // Handle all selected items for Quick Look, not just the clicked item + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let urls: [URL] = selected.compactMap { item in + if let fileURL = item.fileURL { + return fileURL + } + if case .link(let url) = item.kind { + return url + } + return nil + } + if !urls.isEmpty { + onQuickLook(urls) + } + + case .open?: + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + for it in selected { ShelfActionService.open(it) } + + case .share?: + onShare(view) + + case .rename?: + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + if selected.count == 1, let single = selected.first { showRenameDialog(for: single) } + + case .showInFinder?: + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + Task { + let urls = await selected.asyncCompactMap { item -> URL? in + if case .file = item.kind { + // Use immediate update for user-initiated menu action + return ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) + } + return nil + } + if !urls.isEmpty { + await urls.accessSecurityScopedResources { accessibleURLs in + NSWorkspace.shared.activateFileViewerSelecting(accessibleURLs) + } + } + } + + case .copyPath?: + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let paths = selected.compactMap { $0.fileURL?.path } + if !paths.isEmpty { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(paths.joined(separator: "\n"), forType: .string) + } + + case .copy?: + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let pb = NSPasteboard.general + + // Stop accessing previously copied URLs + for url in MenuActionTarget.copiedURLs { + url.stopAccessingSecurityScopedResource() + } + MenuActionTarget.copiedURLs.removeAll() + + pb.clearContents() + Task { + let fileURLs = await selected.asyncCompactMap { item -> URL? in + if case .file = item.kind { + return ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) + } + return nil + } + if !fileURLs.isEmpty { + // Start security-scoped access for all URLs and keep them active + MenuActionTarget.copiedURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() } + NSLog("🔐 Started security-scoped access for \(MenuActionTarget.copiedURLs.count) copied files") + + // Write to pasteboard + pb.writeObjects(fileURLs as [NSURL]) + } else { + let strings = selected.map { $0.displayName } + if !strings.isEmpty { + pb.setString(strings.joined(separator: "\n"), forType: .string) + } + } + } + + case .remove?: + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + for it in selected { ShelfActionService.remove(it) } + + case .removeBackground?: + handleRemoveBackground() + + case .convertImage?: + showConvertImageDialog() + + case .createPDF?: + handleCreatePDF() + + case .compress?: + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let fileURLs = selected.compactMap { $0.fileURL } + guard !fileURLs.isEmpty else { break } + + Task { + // Create ZIP in a temporary location while holding access to selected resources + if let zipTempURL = await fileURLs.accessSecurityScopedResources(accessor: { urls in + await TemporaryFileStorageService.shared.createZip(from: urls) + }) { + if let bookmark = try? Bookmark(url: zipTempURL) { + let newItem = ShelfItem(kind: .file(bookmark: bookmark.data), isTemporary: true) + ShelfStateViewModel.shared.add([newItem]) + } else { + // Fallback: reveal the temporary file in Finder + NSWorkspace.shared.activateFileViewerSelecting([zipTempURL]) + } + } + } + + case .none: + break + } + } + + @MainActor + private func openWithPanel() { + // Support both file items and link items + let targetURL: URL? + let needsSecurityScope: Bool + + if let fileURL = item.fileURL { + targetURL = fileURL + needsSecurityScope = true + } else if case .link(let url) = item.kind { + targetURL = url + needsSecurityScope = false + } else { + targetURL = nil + needsSecurityScope = false + } + guard let fileURL = targetURL else { return } + + let panel = NSOpenPanel() + panel.title = "Choose Application" + panel.message = "Choose an application to open the document \"\(item.displayName)\"." + panel.prompt = "Open" + panel.allowsMultipleSelection = false + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.resolvesAliases = true + if #available(macOS 12.0, *) { + panel.allowedContentTypes = [.application] + } + panel.directoryURL = URL(fileURLWithPath: "/Applications") + + // Compute recommended applications for the selected target + let recommendedApps: Set = { + let apps: [URL] + if let uti = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType { + apps = NSWorkspace.shared.urlsForApplications(toOpen: uti) + } else { + apps = NSWorkspace.shared.urlsForApplications(toOpen: fileURL) + } + return Set(apps.map { $0.standardizedFileURL }) + }() + + // Delegate to filter entries when in "Recommended Applications" mode + final class AppChooserDelegate: NSObject, NSOpenSavePanelDelegate { + enum Mode { case recommended, all } + var mode: Mode = .recommended + let recommended: Set + init(recommended: Set) { self.recommended = recommended } + + func panel(_ sender: Any, shouldEnable url: URL) -> Bool { + let ext = url.pathExtension.lowercased() + if ext == "app" { + switch mode { + case .all: + return true + case .recommended: + // Standardize URLs for reliable comparison + let std = url.standardizedFileURL + return recommended.contains(std) + } + } + + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue { + return true + } + + return false + } + } + + let chooserDelegate = AppChooserDelegate(recommended: recommendedApps) + panel.delegate = chooserDelegate + + let enableLabel = NSTextField(labelWithString: "Enable:") + enableLabel.font = .systemFont(ofSize: NSFont.systemFontSize) + enableLabel.alignment = .natural + enableLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + let popup = NSPopUpButton(frame: .zero, pullsDown: false) + popup.addItems(withTitles: ["Recommended Applications", "All Applications"]) + popup.font = .systemFont(ofSize: NSFont.systemFontSize) + popup.selectItem(at: 0) + + popup.setContentHuggingPriority(.defaultLow, for: .horizontal) + popup.widthAnchor.constraint(greaterThanOrEqualToConstant: 200).isActive = true + + let alwaysCheckbox = NSButton(checkboxWithTitle: "Always Open With", target: nil, action: nil) + alwaysCheckbox.font = .systemFont(ofSize: NSFont.systemFontSize) + alwaysCheckbox.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let row = NSStackView(views: [enableLabel, popup]) + row.orientation = .horizontal + row.spacing = 8 + row.alignment = .centerY + row.distribution = .fill + + let column = NSStackView(views: [row, alwaysCheckbox]) + column.orientation = .vertical + column.spacing = 12 + column.alignment = .centerX + column.distribution = .fill + column.edgeInsets = NSEdgeInsets(top: 16, left: 20, bottom: 16, right: 20) + + panel.accessoryView = column + panel.isAccessoryViewDisclosed = true + + // Wire up popup to switch filter mode + class PopupBinder: NSObject { + weak var popup: NSPopUpButton? + weak var chooserDelegate: AppChooserDelegate? + weak var panel: NSOpenPanel? + init(popup: NSPopUpButton, chooserDelegate: AppChooserDelegate, panel: NSOpenPanel) { + self.popup = popup + self.chooserDelegate = chooserDelegate + self.panel = panel + } + @MainActor @objc func changed(_ sender: Any?) { + if popup?.indexOfSelectedItem == 1 { + chooserDelegate?.mode = .all + } else { + chooserDelegate?.mode = .recommended + } + if let panel = panel { + panel.validateVisibleColumns() + let currentDir = panel.directoryURL + panel.directoryURL = currentDir + } + } + } + let binder = PopupBinder(popup: popup, chooserDelegate: chooserDelegate, panel: panel) + popup.target = binder + popup.action = #selector(PopupBinder.changed(_:)) + + panel.begin { response in + if response == .OK, let appURL = panel.url { + Task { + do { + let config = NSWorkspace.OpenConfiguration() + if alwaysCheckbox.state == .on, let bundleID = Bundle(url: appURL)?.bundleIdentifier { + if let contentType = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType { + let status = LSSetDefaultRoleHandlerForContentType(contentType.identifier as CFString, LSRolesMask.all, bundleID as CFString) + if status != noErr { print("⚠️ Failed to set default handler for \(contentType.identifier): \(status)") } + } else if let scheme = fileURL.scheme { + let status = LSSetDefaultHandlerForURLScheme(scheme as CFString, bundleID as CFString) + if status != noErr { print("⚠️ Failed to set default handler for scheme \(scheme): \(status)") } + } + } + + if needsSecurityScope { + _ = try await fileURL.accessSecurityScopedResource { accessibleURL in + try await NSWorkspace.shared.open([accessibleURL], withApplicationAt: appURL, configuration: config) + } + } else { + try await NSWorkspace.shared.open([fileURL], withApplicationAt: appURL, configuration: config) + } + } catch { + print("❌ Failed to open with application: \(error.localizedDescription)") + } + } + } + // Keep binder/delegate alive until panel finishes + _ = binder + _ = chooserDelegate + } + } + + @MainActor + private func showRenameDialog(for item: ShelfItem) { + guard case let .file(bookmarkData) = item.kind else { return } + Task { + let bookmark = Bookmark(data: bookmarkData) + if let fileURL = bookmark.resolvedURL { + // Start security-scoped access and keep it active until rename completes. + let didStart = fileURL.startAccessingSecurityScopedResource() + + let savePanel = NSSavePanel() + savePanel.title = "Rename File" + savePanel.prompt = "Rename" + savePanel.nameFieldStringValue = fileURL.lastPathComponent + savePanel.directoryURL = fileURL.deletingLastPathComponent() + savePanel.begin { response in + if response == .OK, let newURL = savePanel.url { + Task { + do { + NSLog("🔐 Rename: moving from \(fileURL.path) to \(newURL.path) (securityScope=\(didStart))") + + try FileManager.default.moveItem(at: fileURL, to: newURL) + + if let newBookmark = try? Bookmark(url: newURL) { + ShelfStateViewModel.shared.updateBookmark(for: item, bookmark: newBookmark.data) + } + } catch { + print("❌ Failed to rename file: \(error.localizedDescription)") + } + if didStart { fileURL.stopAccessingSecurityScopedResource() } + } + } else { + if didStart { fileURL.stopAccessingSecurityScopedResource() } + } + } + } + } + } + + @MainActor + private func handleRemoveBackground() { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } + + guard let imageURL = imageURLs.first else { return } + + Task { + do { + let resultURL = try await imageURL.accessSecurityScopedResource { url in + try await ImageProcessingService.shared.removeBackground(from: url) + } + + if let resultURL = resultURL { + // Create bookmark and add to shelf as temporary item + if let bookmark = try? Bookmark(url: resultURL) { + let newItem = ShelfItem( + kind: .file(bookmark: bookmark.data), + isTemporary: true + ) + ShelfStateViewModel.shared.add([newItem]) + } + } + } catch { + print("❌ Failed to remove background: \(error.localizedDescription)") + showErrorAlert(title: String(localized: "Background Removal Failed"), message: error.localizedDescription) + } + } + } + + @MainActor + private func handleCreatePDF() { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } + + guard !imageURLs.isEmpty else { return } + + Task { + do { + let resultURL = try await imageURLs.accessSecurityScopedResources { urls in + try await ImageProcessingService.shared.createPDF(from: urls) + } + + if let resultURL = resultURL { + // Create bookmark and add to shelf as temporary item + if let bookmark = try? Bookmark(url: resultURL) { + let newItem = ShelfItem( + kind: .file(bookmark: bookmark.data), + isTemporary: true + ) + ShelfStateViewModel.shared.add([newItem]) + } + } + } catch { + print("❌ Failed to create PDF: \(error.localizedDescription)") + showErrorAlert(title: String(localized: "PDF Creation Failed"), message: error.localizedDescription) + } + } + } + + @MainActor + private func showConvertImageDialog() { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } + + guard let imageURL = imageURLs.first else { return } + + // Create and show conversion options dialog with better layout + let alert = NSAlert() + alert.messageText = "Convert Image" + alert.alertStyle = .informational + alert.addButton(withTitle: "Convert") + alert.addButton(withTitle: "Cancel") + + // Create accessory view with better spacing and organization + let accessoryView = NSView(frame: NSRect(x: 0, y: 0, width: 380, height: 180)) + accessoryView.wantsLayer = true + + // MARK: Format Row + let formatLabel = NSTextField(labelWithString: "Format:") + formatLabel.frame = NSRect(x: 0, y: 145, width: 100, height: 20) + formatLabel.font = .systemFont(ofSize: 12, weight: .medium) + accessoryView.addSubview(formatLabel) + + let formatPopup = NSPopUpButton(frame: NSRect(x: 120, y: 140, width: 250, height: 28)) + formatPopup.addItems(withTitles: ["PNG", "JPEG", "HEIC", "TIFF", "BMP"]) + formatPopup.selectItem(at: 0) + formatPopup.font = .systemFont(ofSize: 12) + accessoryView.addSubview(formatPopup) + + // MARK: Image Size Row + let imageSizeLabel = NSTextField(labelWithString: "Image Size:") + imageSizeLabel.frame = NSRect(x: 0, y: 105, width: 100, height: 20) + imageSizeLabel.font = .systemFont(ofSize: 12, weight: .medium) + accessoryView.addSubview(imageSizeLabel) + + let imageSizePopup = NSPopUpButton(frame: NSRect(x: 120, y: 100, width: 160, height: 28)) + imageSizePopup.addItems(withTitles: ["Actual Size", "Large", "Medium", "Small", "Custom..."]) + imageSizePopup.selectItem(at: 0) + imageSizePopup.font = .systemFont(ofSize: 12) + accessoryView.addSubview(imageSizePopup) + + // Custom size field (initially hidden) + let customSizeField = NSTextField(frame: NSRect(x: 285, y: 103, width: 85, height: 22)) + customSizeField.placeholderString = "e.g., 1920" + customSizeField.font = .systemFont(ofSize: 12) + customSizeField.isHidden = true + accessoryView.addSubview(customSizeField) + + // MARK: Preserve Metadata Checkbox + let metadataCheckbox = NSButton(checkboxWithTitle: "Preserve Metadata", target: nil, action: nil) + metadataCheckbox.frame = NSRect(x: 120, y: 65, width: 200, height: 20) + metadataCheckbox.font = .systemFont(ofSize: 12) + metadataCheckbox.state = .on + accessoryView.addSubview(metadataCheckbox) + + // MARK: Separator line + let separatorLine = NSView(frame: NSRect(x: 0, y: 50, width: 380, height: 1)) + separatorLine.wantsLayer = true + separatorLine.layer?.backgroundColor = NSColor.separatorColor.cgColor + accessoryView.addSubview(separatorLine) + + // MARK: Format-specific options (shown/hidden based on format selection) + let qualityRow = NSView(frame: NSRect(x: 0, y: 15, width: 380, height: 30)) + qualityRow.wantsLayer = true + + let qualityLabel = NSTextField(labelWithString: "Compression:") + qualityLabel.frame = NSRect(x: 0, y: 7, width: 100, height: 20) + qualityLabel.font = .systemFont(ofSize: 12, weight: .medium) + qualityRow.addSubview(qualityLabel) + + let qualitySlider = NSSlider(frame: NSRect(x: 120, y: 12, width: 200, height: 20)) + qualitySlider.minValue = 0.0 + qualitySlider.maxValue = 1.0 + qualitySlider.doubleValue = 0.85 + accessoryView.addSubview(qualitySlider) + + let qualityValueLabel = NSTextField(labelWithString: "85%") + qualityValueLabel.frame = NSRect(x: 325, y: 7, width: 55, height: 20) + qualityValueLabel.font = .systemFont(ofSize: 12) + qualityValueLabel.alignment = .left + accessoryView.addSubview(qualityValueLabel) + + // Update quality label and hide/show compression row based on format + let updateQualityLabel = { + let value = Int(qualitySlider.doubleValue * 100) + qualityValueLabel.stringValue = "\(value)%" + } + + let updateCompressionVisibility = { + let formatIndex = formatPopup.indexOfSelectedItem + let showCompression = formatIndex == 1 || formatIndex == 2 // JPEG or HEIC + qualitySlider.isHidden = !showCompression + qualityValueLabel.isHidden = !showCompression + qualityLabel.isHidden = !showCompression + } + + let updateCustomSizeVisibility = { + let sizeIndex = imageSizePopup.indexOfSelectedItem + customSizeField.isHidden = sizeIndex != 4 // Show only for "Custom..." + } + + // Create a target object to handle slider value changes + class SliderHandler: NSObject { + let updateLabel: () -> Void + let updateVisibility: () -> Void + let updateCustomSize: () -> Void + init(updateLabel: @escaping () -> Void, updateVisibility: @escaping () -> Void, updateCustomSize: @escaping () -> Void) { + self.updateLabel = updateLabel + self.updateVisibility = updateVisibility + self.updateCustomSize = updateCustomSize + } + @objc func sliderChanged(_ sender: NSSlider) { + updateLabel() + } + @objc func formatChanged(_ sender: NSPopUpButton) { + updateVisibility() + } + @objc func sizeChanged(_ sender: NSPopUpButton) { + updateCustomSize() + } + } + + let handler = SliderHandler(updateLabel: updateQualityLabel, updateVisibility: updateCompressionVisibility, updateCustomSize: updateCustomSizeVisibility) + qualitySlider.target = handler + qualitySlider.action = #selector(SliderHandler.sliderChanged(_:)) + qualitySlider.isContinuous = true + + formatPopup.target = handler + formatPopup.action = #selector(SliderHandler.formatChanged(_:)) + + imageSizePopup.target = handler + imageSizePopup.action = #selector(SliderHandler.sizeChanged(_:)) + + updateCompressionVisibility() + updateQualityLabel() + updateCustomSizeVisibility() + + // Keep the handler alive using the `AssociatedObject` helper instead of a magic string key + MenuActionTarget.sliderHandlerAssoc[accessoryView] = handler + + alert.accessoryView = accessoryView + + let response = alert.runModal() + + if response == .alertFirstButtonReturn { + // Get selected options + let formatIndex = formatPopup.indexOfSelectedItem + let format: ImageConversionOptions.ImageFormat + switch formatIndex { + case 0: format = .png + case 1: format = .jpeg + case 2: format = .heic + case 3: format = .tiff + case 4: format = .bmp + default: format = .png + } + + let quality = qualitySlider.doubleValue + + // Get max dimension based on image size selection + let maxDimension: CGFloat? = { + let sizeIndex = imageSizePopup.indexOfSelectedItem + switch sizeIndex { + case 0: return nil // Actual Size + case 1: return 1280 // Large + case 2: return 640 // Medium + case 3: return 320 // Small + case 4: // Custom (user-specified) + let text = customSizeField.stringValue.trimmingCharacters(in: .whitespaces) + guard !text.isEmpty, let value = Double(text), value > 0 else { return nil } + return CGFloat(value) + default: return nil + } + }() + + let removeMetadata = metadataCheckbox.state == .off // Note: we invert this + + let options = ImageConversionOptions( + format: format, + compressionQuality: quality, + maxDimension: maxDimension, + removeMetadata: removeMetadata + ) + + Task { + do { + let resultURL = try await imageURL.accessSecurityScopedResource { url in + try await ImageProcessingService.shared.convertImage(from: url, options: options) + } + + if let resultURL = resultURL { + // Create bookmark and add to shelf as temporary item + if let bookmark = try? Bookmark(url: resultURL) { + let newItem = ShelfItem( + kind: .file(bookmark: bookmark.data), + isTemporary: true + ) + ShelfStateViewModel.shared.add([newItem]) + } + } + } catch { + print("❌ Failed to convert image: \(error.localizedDescription)") + showErrorAlert(title: String(localized: "Image Conversion Failed"), message: error.localizedDescription) + } + } + } + } + + @MainActor + private func showErrorAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.runModal() + } +} + + +fileprivate extension Sequence { + func asyncCompactMap(_ transform: (Element) async -> T?) async -> [T] { + var result: [T] = [] + for element in self { + if let transformed = await transform(element) { + result.append(transformed) + } + } + return result + } +} diff --git a/boringNotch/helpers/MediaEnvironment.swift b/boringNotch/helpers/MediaEnvironment.swift new file mode 100644 index 000000000..00df20d45 --- /dev/null +++ b/boringNotch/helpers/MediaEnvironment.swift @@ -0,0 +1,46 @@ +// +// MediaEnvironment.swift +// boringNotch +// +// Created as part of the architecture remediation. +// + +import Foundation + +/// Owns the launch-time MediaRemote (Now Playing) availability probe. +/// +/// Split out of MusicManager so nothing needs to instantiate the whole +/// music stack just to learn the flag: `Defaults.Keys.mediaController`'s +/// default previously read `MusicManager.shared.isNowPlayingDeprecated` +/// while MusicManager itself reads that key — reading the key from inside +/// MusicManager's lazy init could re-enter its own initialization and trap. +/// The result is persisted so static Defaults defaults are meaningful even +/// before this launch's probe finishes. +@MainActor +final class MediaEnvironment: ObservableObject { + static let shared = MediaEnvironment() + + @Published private(set) var isNowPlayingDeprecated: Bool + + private let checker = MediaChecker() + private var resolveTask: Task? + + static let persistenceKey = "MediaEnvironment.isNowPlayingDeprecated" + + private init() { + isNowPlayingDeprecated = UserDefaults.standard.bool(forKey: Self.persistenceKey) + } + + /// Probe once; concurrent calls coalesce behind the first. + func resolve() { + guard resolveTask == nil else { return } + resolveTask = Task { @MainActor in + defer { resolveTask = nil } + let resolved = (try? await checker.checkDeprecationStatus()) ?? false + if resolved != isNowPlayingDeprecated { + isNowPlayingDeprecated = resolved + } + UserDefaults.standard.set(resolved, forKey: Self.persistenceKey) + } + } +} diff --git a/boringNotch/managers/AudioCaptureManager.swift b/boringNotch/managers/AudioCaptureManager.swift index bba1212f7..ff42410fb 100644 --- a/boringNotch/managers/AudioCaptureManager.swift +++ b/boringNotch/managers/AudioCaptureManager.swift @@ -154,28 +154,32 @@ final class AudioCaptureManager: ObservableObject { // MARK: - State observation private func observeState() { - let music = MusicManager.shared - let enabledPublisher = Defaults.publisher(.realtimeAudioWaveform) - .map(\.newValue) - .prepend(Defaults[.realtimeAudioWaveform]) - .removeDuplicates() - - Publishers.CombineLatest4( - music.$isPlaying.removeDuplicates(), - music.$bundleIdentifier.removeDuplicates(), - music.$audioCaptureBundleIdentifiers.removeDuplicates(), - enabledPublisher - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] isPlaying, bundleID, captureBundleIDs, enabled in - self?.evaluate( - isPlaying: isPlaying, - displayBundleID: bundleID, - captureBundleIDs: captureBundleIDs, - enabled: enabled - ) - } - .store(in: &cancellables) + // MusicManager is @MainActor — wire the publishers on the main + // actor; delivery then continues via receive(on:) as before. + Task { @MainActor in + let music = MusicManager.shared + let enabledPublisher = Defaults.publisher(.realtimeAudioWaveform) + .map(\.newValue) + .prepend(Defaults[.realtimeAudioWaveform]) + .removeDuplicates() + + Publishers.CombineLatest4( + music.$isPlaying.removeDuplicates(), + music.$bundleIdentifier.removeDuplicates(), + music.$audioCaptureBundleIdentifiers.removeDuplicates(), + enabledPublisher + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isPlaying, bundleID, captureBundleIDs, enabled in + self?.evaluate( + isPlaying: isPlaying, + displayBundleID: bundleID, + captureBundleIDs: captureBundleIDs, + enabled: enabled + ) + } + .store(in: &cancellables) + } } private func evaluate( diff --git a/boringNotch/managers/MusicManager.swift b/boringNotch/managers/MusicManager.swift index a138464d3..e1794634b 100644 --- a/boringNotch/managers/MusicManager.swift +++ b/boringNotch/managers/MusicManager.swift @@ -14,6 +14,7 @@ let defaultImage: NSImage = .init( accessibilityDescription: "Album Art" )! +@MainActor class MusicManager: ObservableObject { // MARK: - Properties static let shared = MusicManager() @@ -21,9 +22,9 @@ class MusicManager: ObservableObject { private var controllerCancellables = Set() private var debounceIdleTask: Task? - // Helper to check if macOS has removed support for NowPlayingController + // Whether macOS has removed support for NowPlayingController. + // Mirrored from MediaEnvironment, which owns the probe. public private(set) var isNowPlayingDeprecated: Bool = false - private let mediaChecker = MediaChecker() // Active controller private var activeController: (any MediaControllerProtocol)? @@ -47,7 +48,6 @@ class MusicManager: ObservableObject { @Published var repeatMode: RepeatMode = .off @Published var volume: Double = 0.5 @Published var volumeControlSupported: Bool = true - @ObservedObject var coordinator = BoringViewCoordinator.shared @Published var usingAppIconForArtwork: Bool = false @Published var canFavoriteTrack: Bool = false @@ -77,29 +77,30 @@ class MusicManager: ObservableObject { // Listen for changes to the default controller preference NotificationCenter.default.publisher(for: Notification.Name.mediaControllerChanged) .sink { [weak self] _ in - self?.setActiveControllerBasedOnPreference() + Task { @MainActor in + self?.setActiveControllerBasedOnPreference() + } } .store(in: &cancellables) - // Initialize deprecation check asynchronously - Task { @MainActor in - do { - self.isNowPlayingDeprecated = try await self.mediaChecker.checkDeprecationStatus() - print("Deprecation check completed: \(self.isNowPlayingDeprecated)") - } catch { - print("Failed to check deprecation status: \(error). Defaulting to false.") - self.isNowPlayingDeprecated = false + // The NowPlaying availability probe is owned by MediaEnvironment + // (resolved eagerly at launch); mirror it so existing consumers of + // MusicManager.shared.isNowPlayingDeprecated keep working. + isNowPlayingDeprecated = MediaEnvironment.shared.isNowPlayingDeprecated + MediaEnvironment.shared.$isNowPlayingDeprecated + .sink { [weak self] resolved in + Task { @MainActor in + self?.isNowPlayingDeprecated = resolved + self?.setActiveControllerBasedOnPreference() + } } - - // Initialize the active controller after deprecation check - self.setActiveControllerBasedOnPreference() - } - } + .store(in: &cancellables) - deinit { - destroy() + setActiveControllerBasedOnPreference() } - + + // Singleton: no deinit-based teardown. App teardown calls destroy() + // explicitly from applicationWillTerminate. public func destroy() { debounceIdleTask?.cancel() cancellables.removeAll() @@ -144,7 +145,9 @@ class MusicManager: ObservableObject { .sink { [weak self] state in guard let self = self, self.activeController === controller else { return } - self.updateFromPlaybackState(state) + Task { @MainActor in + self.updateFromPlaybackState(state) + } } .store(in: &controllerCancellables) } @@ -451,12 +454,11 @@ class MusicManager: ObservableObject { } private func updateSneakPeek() { - if isPlaying && Defaults[.enableSneakPeek] { - if Defaults[.sneakPeekStyles] == .standard { - coordinator.toggleSneakPeek(status: true, type: .music) - } else { - coordinator.toggleExpandingView(status: true, type: .music) - } + guard isPlaying && Defaults[.enableSneakPeek] else { return } + if Defaults[.sneakPeekStyles] == .standard { + NotchUIEventBus.events.send(.sneakPeek(type: .music, value: 0)) + } else { + NotchUIEventBus.events.send(.expandingView(type: .music)) } } diff --git a/boringNotch/managers/NotchWindowManager.swift b/boringNotch/managers/NotchWindowManager.swift new file mode 100644 index 000000000..8024704cd --- /dev/null +++ b/boringNotch/managers/NotchWindowManager.swift @@ -0,0 +1,388 @@ +// +// NotchWindowManager.swift +// boringNotch +// +// Extracted from AppDelegate: all notch-window, per-screen view-model and +// drag-detector lifecycle in one place. AppDelegate keeps app-lifecycle +// glue (shortcuts, onboarding, termination) and forwards to this manager. +// + +import Defaults +import SwiftUI + +@MainActor +final class NotchWindowManager { + static let shared = NotchWindowManager() + + /// All per-screen state in one value — replaces the parallel + /// windows/viewModels/dragDetectors dictionaries that previously had + /// to be mutated in lockstep (a missed mutation leaked observers). + struct ScreenContext { + let viewModel: BoringViewModel + var window: NSWindow? + var dragDetector: DragDetector? + } + + private(set) var contexts: [String: ScreenContext] = [:] // UUID -> ScreenContext + private(set) var primaryWindow: NSWindow? + let primaryViewModel = BoringViewModel() + + private(set) var isScreenLocked: Bool = false + private var windowScreenDidChangeObserver: Any? + private var previousScreens: [NSScreen]? + + // MARK: - Public lookups (preserve AppDelegate's old API shape) + + var windows: [String: NSWindow] { + contexts.compactMapValues { $0.window } + } + + var viewModels: [String: BoringViewModel] { + contexts.mapValues { $0.viewModel } + } + + var window: NSWindow? { primaryWindow } + + // MARK: - Screen lock / unlock + + func screenLocked() { + isScreenLocked = true + if !Defaults[.showOnLockScreen] { + cleanupWindows() + } else { + enableSkyLightOnAllWindows() + } + } + + func screenUnlocked() { + isScreenLocked = false + if !Defaults[.showOnLockScreen] { + adjustWindowPosition(changeAlpha: true) + } else { + disableSkyLightOnAllWindows() + } + } + + private func enableSkyLightOnAllWindows() { + if Defaults[.showOnAllDisplays] { + contexts.values.forEach { context in + (context.window as? BoringNotchSkyLightWindow)?.enableSkyLight() + } + } else { + (primaryWindow as? BoringNotchSkyLightWindow)?.enableSkyLight() + } + } + + private func disableSkyLightOnAllWindows() { + // Delay disabling SkyLight to avoid flicker during unlock transition + Task { + try? await Task.sleep(for: .milliseconds(150)) + await MainActor.run { + if Defaults[.showOnAllDisplays] { + contexts.values.forEach { context in + (context.window as? BoringNotchSkyLightWindow)?.disableSkyLight() + } + } else { + (primaryWindow as? BoringNotchSkyLightWindow)?.disableSkyLight() + } + } + } + } + + // MARK: - Window lifecycle + + func cleanupWindows(shouldInvert: Bool = false) { + let shouldCleanupMulti = shouldInvert ? !Defaults[.showOnAllDisplays] : Defaults[.showOnAllDisplays] + + if shouldCleanupMulti { + for (uuid, context) in contexts { + context.window?.close() + if let window = context.window { + NotchSpaceManager.shared.notchSpace.windows.remove(window) + } + context.dragDetector?.stopMonitoring() + contexts.removeValue(forKey: uuid) + } + } else if let window = primaryWindow { + window.close() + NotchSpaceManager.shared.notchSpace.windows.remove(window) + if let obs = windowScreenDidChangeObserver { + NotificationCenter.default.removeObserver(obs) + windowScreenDidChangeObserver = nil + } + primaryWindow = nil + } + + // ensure OSD integration reflects the current window state + BoringViewCoordinator.shared.applyOSDSources() + } + + private func createBoringNotchWindow(for screen: NSScreen, with viewModel: BoringViewModel) -> NSWindow { + let rect = NSRect(x: 0, y: 0, width: windowSize.width, height: windowSize.height) + let styleMask: NSWindow.StyleMask = [.borderless, .nonactivatingPanel, .utilityWindow, .hudWindow] + + let window = BoringNotchSkyLightWindow(contentRect: rect, styleMask: styleMask, backing: .buffered, defer: false) + + // Enable SkyLight only when screen is locked + if isScreenLocked { + window.enableSkyLight() + } else { + window.disableSkyLight() + } + + window.contentView = NSHostingView( + rootView: ContentView() + .environmentObject(viewModel) + ) + + window.orderFrontRegardless() + NotchSpaceManager.shared.notchSpace.windows.insert(window) + + // Observe when the window's screen changes so we can update drag detectors. + // Remove any previous observer first — recreating windows used to + // overwrite the token and leak the earlier observer each cycle. + if let obs = windowScreenDidChangeObserver { + NotificationCenter.default.removeObserver(obs) + } + windowScreenDidChangeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didChangeScreenNotification, + object: window, + queue: .main) { [weak self] _ in + Task { @MainActor in + self?.setupDragDetectors() + } + } + return window + } + + private func positionWindow(_ window: NSWindow, on screen: NSScreen, changeAlpha: Bool = false) { + if changeAlpha { + window.alphaValue = 0 + } + + let screenFrame = screen.frame + window.setFrameOrigin( + NSPoint( + x: screenFrame.origin.x + (screenFrame.width / 2) - window.frame.width / 2, + y: screenFrame.origin.y + screenFrame.height - window.frame.height + )) + window.alphaValue = 1 + } + + func adjustWindowPosition(changeAlpha: Bool = false) { + let coordinator = BoringViewCoordinator.shared + if Defaults[.showOnAllDisplays] { + let currentScreenUUIDs = Set(NSScreen.screens.compactMap { $0.displayUUID }) + + // Remove windows for screens that no longer exist + for uuid in contexts.keys where !currentScreenUUIDs.contains(uuid) { + if let window = contexts[uuid]?.window { + window.close() + NotchSpaceManager.shared.notchSpace.windows.remove(window) + } + contexts[uuid]?.dragDetector?.stopMonitoring() + contexts.removeValue(forKey: uuid) + } + + // Create or update windows for all screens + for screen in NSScreen.screens { + guard let uuid = screen.displayUUID else { continue } + + if contexts[uuid] == nil { + contexts[uuid] = ScreenContext( + viewModel: BoringViewModel(screenUUID: uuid), + window: nil, + dragDetector: nil + ) + } + + if contexts[uuid]?.window == nil { + let viewModel = contexts[uuid]!.viewModel + let window = createBoringNotchWindow(for: screen, with: viewModel) + contexts[uuid]?.window = window + } + + if let window = contexts[uuid]?.window { + let viewModel = contexts[uuid]!.viewModel + positionWindow(window, on: screen, changeAlpha: changeAlpha) + + if viewModel.notchState == .closed { + viewModel.close() + } + } + } + } else { + let selectedScreen: NSScreen + + if let preferredScreen = NSScreen.screen(withUUID: coordinator.preferredScreenUUID ?? "") { + coordinator.selectedScreenUUID = coordinator.preferredScreenUUID ?? "" + selectedScreen = preferredScreen + } else if Defaults[.automaticallySwitchDisplay], let mainScreen = NSScreen.main, + let mainUUID = mainScreen.displayUUID { + coordinator.selectedScreenUUID = mainUUID + selectedScreen = mainScreen + } else { + if let window = primaryWindow { + window.alphaValue = 0 + } + return + } + + primaryViewModel.screenUUID = selectedScreen.displayUUID + primaryViewModel.notchSize = getClosedNotchSize(screenUUID: selectedScreen.displayUUID) + + if primaryWindow == nil { + primaryWindow = createBoringNotchWindow(for: selectedScreen, with: primaryViewModel) + } + + if let window = primaryWindow { + positionWindow(window, on: selectedScreen, changeAlpha: changeAlpha) + + if primaryViewModel.notchState == .closed { + primaryViewModel.close() + } + } + } + + // windows might have been added/removed during the earlier logic – + // update the OSD subsystems accordingly. + coordinator.applyOSDSources() + } + + func screenConfigurationDidChange() { + let currentScreens = NSScreen.screens + + let screensChanged = + currentScreens.count != previousScreens?.count + || Set(currentScreens.compactMap { $0.displayUUID }) + != Set(previousScreens?.compactMap { $0.displayUUID } ?? []) + || Set(currentScreens.map { $0.frame }) != Set(previousScreens?.map { $0.frame } ?? []) + + previousScreens = currentScreens + + if screensChanged { + DispatchQueue.main.async { [weak self] in + // Sync notch height with real value if mode is matchRealNotchSize + syncNotchHeightIfNeeded() + + self?.cleanupWindows() + self?.adjustWindowPosition() + self?.setupDragDetectors() + } + } + } + + func noteInitialScreens() { + previousScreens = NSScreen.screens + } + + // MARK: - Drag detection + + func cleanupDragDetectors() { + for (_, var context) in contexts { + context.dragDetector?.stopMonitoring() + context.dragDetector = nil + } + } + + func setupDragDetectors() { + cleanupDragDetectors() + + guard Defaults[.expandedDragDetection] else { return } + + if Defaults[.showOnAllDisplays] { + for screen in NSScreen.screens { + setupDragDetectorForScreen(screen) + } + } else { + let preferredScreen: NSScreen? = primaryWindow?.screen + ?? NSScreen.screen(withUUID: BoringViewCoordinator.shared.selectedScreenUUID) + ?? NSScreen.main + + if let screen = preferredScreen { + setupDragDetectorForScreen(screen) + } + } + } + + private func setupDragDetectorForScreen(_ screen: NSScreen) { + guard let uuid = screen.displayUUID else { return } + + let screenFrame = screen.frame + let notchHeight = openNotchSize.height + let notchWidth = openNotchSize.width + + // Create notch region at the top-center of the screen where an open notch would occupy + let notchRegion = CGRect( + x: screenFrame.midX - notchWidth / 2, + y: screenFrame.maxY - notchHeight, + width: notchWidth, + height: notchHeight + ) + + let detector = DragDetector(notchRegion: notchRegion) + + detector.onDragEntersNotchRegion = { [weak self] in + Task { @MainActor in + self?.handleDragEntersNotchRegion(onScreen: screen) + } + } + + // In single-screen mode there may be no context entry yet — only + // multi-display mode registers per-screen contexts. + if contexts[uuid] != nil { + contexts[uuid]?.dragDetector = detector + } else { + contexts[uuid] = ScreenContext(viewModel: primaryViewModel, window: nil, dragDetector: detector) + } + detector.startMonitoring() + } + + private func handleDragEntersNotchRegion(onScreen screen: NSScreen) { + guard Defaults[.boringShelf] else { return } + guard let uuid = screen.displayUUID else { return } + + let coordinator = BoringViewCoordinator.shared + if Defaults[.showOnAllDisplays], let viewModel = contexts[uuid]?.viewModel { + if viewModel.open() { + coordinator.currentView = .shelf + } + } else if !Defaults[.showOnAllDisplays], let windowScreen = primaryWindow?.screen, screen == windowScreen { + if primaryViewModel.open() { + coordinator.currentView = .shelf + } + } + } + + // MARK: - Initial setup + + /// Creates the windows for the current configuration (previously inlined + /// in applicationDidFinishLaunching). + func prepareInitialWindows() { + if !Defaults[.showOnAllDisplays] { + let viewModel = primaryViewModel + if let screen = NSScreen.main ?? NSScreen.screens.first { + primaryWindow = createBoringNotchWindow(for: screen, with: viewModel) + } + adjustWindowPosition(changeAlpha: true) + } else { + adjustWindowPosition(changeAlpha: true) + } + + setupDragDetectors() + noteInitialScreens() + } + + func togglePopover(_ sender: Any?) { + if primaryWindow?.isVisible == true { + primaryWindow?.orderOut(nil) + } else { + primaryWindow?.orderFrontRegardless() + } + } + + func cleanup() { + cleanupDragDetectors() + cleanupWindows() + } +} diff --git a/boringNotch/managers/WebcamManager.swift b/boringNotch/managers/WebcamManager.swift index ca422a1d8..8e99f5b87 100644 --- a/boringNotch/managers/WebcamManager.swift +++ b/boringNotch/managers/WebcamManager.swift @@ -11,42 +11,18 @@ import SwiftUI class WebcamManager: NSObject, ObservableObject { static let shared = WebcamManager() - @Published var previewLayer: AVCaptureVideoPreviewLayer? { - didSet { - objectWillChange.send() - } - } + @Published var previewLayer: AVCaptureVideoPreviewLayer? private var captureSession: AVCaptureSession? - @Published var isSessionRunning: Bool = false { - didSet { - objectWillChange.send() - } - } + @Published var isSessionRunning: Bool = false - @Published var authorizationStatus: AVAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) { - didSet { - objectWillChange.send() - } - } + @Published var authorizationStatus: AVAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) - @Published var cameraAvailable: Bool = false { - didSet { - objectWillChange.send() - } - } + @Published var cameraAvailable: Bool = false - @Published var availableCameras: [AVCaptureDevice] = [] { - didSet { - objectWillChange.send() - } - } + @Published var availableCameras: [AVCaptureDevice] = [] - @Published var selectedCameraID: String? { - didSet { - objectWillChange.send() - } - } + @Published var selectedCameraID: String? private let sessionQueue = DispatchQueue(label: "BoringNotch.WebcamManager.SessionQueue", qos: .userInitiated) diff --git a/boringNotch/models/BatteryStatusViewModel.swift b/boringNotch/models/BatteryStatusViewModel.swift index 4628bd55b..8335dfc72 100644 --- a/boringNotch/models/BatteryStatusViewModel.swift +++ b/boringNotch/models/BatteryStatusViewModel.swift @@ -11,8 +11,6 @@ class BatteryStatusViewModel: ObservableObject { private var powerSourceChangedCallback: IOPowerSourceCallbackType? private var runLoopSource: Unmanaged? - @ObservedObject var coordinator = BoringViewCoordinator.shared - @Published private(set) var levelBattery: Float = 0.0 @Published private(set) var maxCapacity: Float? @Published private(set) var isPluggedIn: Bool = false @@ -160,9 +158,9 @@ class BatteryStatusViewModel: ObservableObject { /// Notifies important changes in the battery status with an optional delay /// - Parameter delay: The delay before notifying the change, default is 0.0 private func notifyImportanChangeStatus(delay: Double = 0.0) { - Task { + Task { [delay] in try? await Task.sleep(for: .seconds(delay)) - self.coordinator.toggleExpandingView(status: true, type: .battery) + NotchUIEventBus.events.send(.expandingView(type: .battery)) } } diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 8ed2bf5a2..6cf32ebbc 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -368,9 +368,12 @@ extension Defaults.Keys { // Normalize scroll/gesture direction so when macOS "Natural scrolling" is disabled, it doesn't invert gestures static let normalizeGestureDirection = Key("normalizeGestureDirection", default: true) - // Helper to determine the default media controller based on NowPlaying deprecation status + // Helper to determine the default media controller based on NowPlaying deprecation status. + // Reads ONLY persisted probe data — never a singleton. Defaults key defaults are + // evaluated once, potentially before managers exist; the probe result is owned + // and refreshed by MediaEnvironment (see helpers/MediaEnvironment.swift). static var defaultMediaController: MediaControllerType { - if MusicManager.shared.isNowPlayingDeprecated { + if UserDefaults.standard.bool(forKey: MediaEnvironment.persistenceKey) { return .appleMusic } else { return .nowPlaying diff --git a/boringNotch/models/NotchUIEvent.swift b/boringNotch/models/NotchUIEvent.swift new file mode 100644 index 000000000..78b1b9581 --- /dev/null +++ b/boringNotch/models/NotchUIEvent.swift @@ -0,0 +1,33 @@ +// +// NotchUIEvent.swift +// boringNotch +// +// Created as part of the architecture remediation. +// + +import SwiftUI +import Combine + +/// UI-presentation events emitted by hardware/OS-facing managers. +/// +/// Inverts the old "manager calls `BoringViewCoordinator.shared`" direction: +/// the coordinator also *configures* those same managers (applyOSDSources), +/// so direct calls created a dependency cycle. Managers now publish events; +/// the coordinator is the single subscriber and decides what to present. +/// Presentation policy (e.g. `Defaults[.osdReplacement]`) lives on the +/// presenter side, and managers stay testable without the UI stack. +enum NotchUIEvent { + case sneakPeek( + type: SneakContentType, + value: CGFloat, + icon: String = "", + accent: Color? = nil, + targetScreenUUID: String? = nil + ) + case expandingView(type: SneakContentType) +} + +/// The event pipe. `BoringViewCoordinator` is the intended subscriber. +enum NotchUIEventBus { + static let events = PassthroughSubject() +} From 0ed306a8e7fe7e34ea4d60810862a59b8688406a Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 12:08:36 +0530 Subject: [PATCH 46/69] Phase 3 audit remediation: naming, dedup, conventions, structured logging Naming & layout: - All 12 Settings view types now match their files (Advanced -> AdvancedSettingsView, Charge -> BatterySettingsView, MirrorSettings -> WebcamSettingsView, About -> AboutView, etc.) - Lowercase 'sneakPeek' struct -> SneakPeekState; Type2Name -> osdTypeName; BrightnessSymbolString -> brightnessSymbolName; AppIcon()/ AppIconAsNSImage() -> lowerCamel; cryptic 'tvm' -> shelfState - Type/file alignment: MinimalFaceFeatures->AnimatedFace, AudioSpectrum(View)->MusicVisualizer(Model), LiveActivityStackView-> LiveActivityStack, PermissionRequestView->PermissionsRequestView, CameraPreview(Layer)View->WebcamPreview(Layer) - Removed spaces from 'Live activities/' and 'YouTube Music Controller/' directories (shell-tooling hazard), pbxproj updated Conventions & safety: - 21 classes finalized; app-target 'public' noise reduced to internal - ~20 scattered bundle-ID literals centralized in MediaAppBundleID - Deleted unused utils/Logger.swift (never compiled); fixed stale header filename in matters.swift Latent bugs fixed (3.5): - Closed-notch OSD slider now actually routes drags to hardware: the sendEventBack closure is wired into DraggableProgressBar.onChange (was silently display-only) - Calendar reloads coalesce: EventKit change bursts no longer trigger back-to-back full reloads (2s debounce) - Contact avatar + lyrics caches use NSCache (evictable) instead of session-unbounded dictionaries Dedup: - Color+AccentColor: single decode helper replaces 4 near-identical blocks - AppleScript controllers share executeCommand + player-info observation via AppleScriptControllerSupport Logging: - os.Logger backbone (helpers/Log.swift) with 11 feature categories; all ~80 production print() calls converted with level heuristics (debug vs error) Verified: Debug build succeeds for app + XPC helper --- Shared/JSONLinesPipeHandler.swift | 2 +- boringNotch.xcodeproj/project.pbxproj | 24 ++++-- boringNotch/BoringViewCoordinator.swift | 20 ++--- boringNotch/ContentView.swift | 6 +- .../AppleMusicController.swift | 15 ++-- .../AppleScriptControllerSupport.swift | 34 ++++++++ .../MediaControllers/MediaAppBundleID.swift | 15 ++++ .../NowPlayingController.swift | 22 +++--- .../MediaControllers/SpotifyController.swift | 15 ++-- .../YouTubeMusicAuthentication.swift | 0 .../YouTubeMusicController.swift | 12 +-- .../YouTubeMusicModels.swift | 2 +- .../YouTubeMusicNetworking.swift | 0 .../Providers/CalendarServiceProviding.swift | 2 +- boringNotch/animations/drop.swift | 2 +- boringNotch/boringNotchApp.swift | 2 +- boringNotch/components/AnimatedFace.swift | 4 +- .../BoringBattery.swift | 0 .../LiveActivityModifier.swift | 0 .../MarqueeTextView.swift | 0 .../components/Music/MusicVisualizer.swift | 12 +-- .../components/Notch/BoringHeader.swift | 4 +- .../components/Notch/CompactHomeView.swift | 4 +- .../components/Notch/LiveActivityStack.swift | 2 +- .../components/Notch/NotchHomeView.swift | 4 +- .../Notch/NotificationLiveActivity.swift | 6 +- .../OSD/Managers/BetterDisplayManager.swift | 2 +- .../components/OSD/Views/InlineOSD.swift | 4 +- .../components/OSD/Views/OSDIconView.swift | 4 +- .../Views/SystemEventIndicatorModifier.swift | 7 +- .../Onboarding/OnboardingView.swift | 10 +-- .../Onboarding/PermissionsRequestView.swift | 2 +- .../components/Settings/SettingsView.swift | 18 ++--- .../components/Settings/Views/AboutView.swift | 2 +- .../Settings/Views/AdvancedSettingsView.swift | 2 +- .../Views/AppearanceSettingsView.swift | 2 +- .../Settings/Views/BatterySettingsView.swift | 2 +- .../Settings/Views/MediaSettingsView.swift | 2 +- .../Views/NotificationSettingsView.swift | 2 +- .../Settings/Views/ShelfSettingsView.swift | 2 +- .../Views/ShortcutsSettingsView.swift | 2 +- .../Settings/Views/WebcamSettingsView.swift | 2 +- .../Shelf/Services/QuickShareService.swift | 4 +- .../Shelf/Services/ShareServiceFinder.swift | 2 +- .../Services/ShelfPersistenceService.swift | 12 +-- .../TemporaryFileStorageService.swift | 32 ++++---- .../Shelf/Views/ShelfContextMenu.swift | 16 ++-- .../components/Shelf/Views/ShelfView.swift | 10 +-- boringNotch/components/Tabs/TabButton.swift | 2 +- .../components/Webcam/WebcamView.swift | 8 +- boringNotch/enums/generic.swift | 6 +- .../extensions/Color+AccentColor.swift | 32 ++++---- .../NSItemProvider+LoadHelpers.swift | 12 +-- boringNotch/helpers/AppIcons.swift | 4 +- boringNotch/helpers/AppleScriptHelper.swift | 2 +- boringNotch/helpers/Log.swift | 26 +++++++ .../managers/BatteryActivityManager.swift | 10 +-- boringNotch/managers/CalendarManager.swift | 21 +++-- .../managers/ContactAvatarManager.swift | 13 ++-- boringNotch/managers/ImageService.swift | 8 +- boringNotch/managers/LyricsService.swift | 25 ++++-- boringNotch/managers/MusicManager.swift | 26 +++---- boringNotch/managers/NotchSpaceManager.swift | 2 +- boringNotch/managers/WebcamManager.swift | 2 +- .../models/BatteryStatusViewModel.swift | 26 +++---- boringNotch/models/BoringViewModel.swift | 2 +- .../observers/MediaKeyInterceptor.swift | 16 ++-- boringNotch/sizing/matters.swift | 2 +- boringNotch/utils/Logger.swift | 77 ------------------- 69 files changed, 345 insertions(+), 327 deletions(-) create mode 100644 boringNotch/MediaControllers/AppleScriptControllerSupport.swift create mode 100644 boringNotch/MediaControllers/MediaAppBundleID.swift rename boringNotch/MediaControllers/{YouTube Music Controller => YouTubeMusicController}/YouTubeMusicAuthentication.swift (100%) rename boringNotch/MediaControllers/{YouTube Music Controller => YouTubeMusicController}/YouTubeMusicController.swift (97%) rename boringNotch/MediaControllers/{YouTube Music Controller => YouTubeMusicController}/YouTubeMusicModels.swift (98%) rename boringNotch/MediaControllers/{YouTube Music Controller => YouTubeMusicController}/YouTubeMusicNetworking.swift (100%) rename boringNotch/components/{Live activities => LiveActivities}/BoringBattery.swift (100%) rename boringNotch/components/{Live activities => LiveActivities}/LiveActivityModifier.swift (100%) rename boringNotch/components/{Live activities => LiveActivities}/MarqueeTextView.swift (100%) create mode 100644 boringNotch/helpers/Log.swift delete mode 100644 boringNotch/utils/Logger.swift diff --git a/Shared/JSONLinesPipeHandler.swift b/Shared/JSONLinesPipeHandler.swift index 9c995457a..99c0792ee 100644 --- a/Shared/JSONLinesPipeHandler.swift +++ b/Shared/JSONLinesPipeHandler.swift @@ -32,7 +32,7 @@ actor JSONLinesPipeHandler { do { try await processLines(as: type, onLine: onLine) } catch { - print("JSONLinesPipeHandler stream error: \(error)") + NSLog("JSONLinesPipeHandler stream error: \(error.localizedDescription)") } } diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 76e049fd1..bd7924600 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -39,6 +39,8 @@ 1153BD8F2D986B1F00979FB0 /* MediaControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD8D2D986B1F00979FB0 /* MediaControllerProtocol.swift */; }; 1153BD912D986DB300979FB0 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD902D986DB300979FB0 /* PlaybackState.swift */; }; 1153BD932D986E4300979FB0 /* AppleMusicController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD922D986E4300979FB0 /* AppleMusicController.swift */; }; + 62A83B43C1AD411097E93D17 /* MediaAppBundleID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F20E44C65E4DA1945BFF5C /* MediaAppBundleID.swift */; }; + 80EA0C01BBCF4069AC5D133D /* AppleScriptControllerSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C26F24108F414781F79D4F /* AppleScriptControllerSupport.swift */; }; 1153BD982D9881F900979FB0 /* AppleScriptHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD972D9881F900979FB0 /* AppleScriptHelper.swift */; }; 1153BD9A2D98824300979FB0 /* SpotifyController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD992D98824300979FB0 /* SpotifyController.swift */; }; 1153BD9C2D98853B00979FB0 /* NowPlayingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD9B2D98853B00979FB0 /* NowPlayingController.swift */; }; @@ -66,6 +68,7 @@ 11985BEF2F37E48900F81585 /* OSDIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11985BEE2F37E48900F81585 /* OSDIconView.swift */; }; 11985BF42F38520A00F81585 /* DraggableProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11985BF32F38520A00F81585 /* DraggableProgressBar.swift */; }; 11A45C792E34E63100CEB175 /* MediaChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A45C782E34E63100CEB175 /* MediaChecker.swift */; }; + 7EF93DF9889B42E9BCD4634F /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033B8885979E4EA1A78E8ADC /* Log.swift */; }; F80A422BE2974CF6808C84CA /* MediaEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D24CBD49614ECE88F062B5 /* MediaEnvironment.swift */; }; 11C5E3132DFE85970065821E /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C5E3112DFE85970065821E /* SettingsWindowController.swift */; }; 11C5E3162DFE88510065821E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C5E3152DFE88510065821E /* SettingsView.swift */; }; @@ -241,6 +244,8 @@ 1153BD8D2D986B1F00979FB0 /* MediaControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaControllerProtocol.swift; sourceTree = ""; }; 1153BD902D986DB300979FB0 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = ""; }; 1153BD922D986E4300979FB0 /* AppleMusicController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleMusicController.swift; sourceTree = ""; }; + 61F20E44C65E4DA1945BFF5C /* MediaAppBundleID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaAppBundleID.swift; sourceTree = ""; }; + B0C26F24108F414781F79D4F /* AppleScriptControllerSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptControllerSupport.swift; sourceTree = ""; }; 1153BD972D9881F900979FB0 /* AppleScriptHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptHelper.swift; sourceTree = ""; }; 1153BD992D98824300979FB0 /* SpotifyController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyController.swift; sourceTree = ""; }; 1153BD9B2D98853B00979FB0 /* NowPlayingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingController.swift; sourceTree = ""; }; @@ -266,6 +271,7 @@ 11985BEE2F37E48900F81585 /* OSDIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDIconView.swift; sourceTree = ""; }; 11985BF32F38520A00F81585 /* DraggableProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableProgressBar.swift; sourceTree = ""; }; 11A45C782E34E63100CEB175 /* MediaChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaChecker.swift; sourceTree = ""; }; + 033B8885979E4EA1A78E8ADC /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; 54D24CBD49614ECE88F062B5 /* MediaEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEnvironment.swift; sourceTree = ""; }; 11C5E3112DFE85970065821E /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = ""; }; 11C5E3152DFE88510065821E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -487,7 +493,7 @@ path = "mediaremote-adapter"; sourceTree = ""; }; - 1132E5232E78D6DA0068732D /* YouTube Music Controller */ = { + 1132E5232E78D6DA0068732D /* YouTubeMusicController */ = { isa = PBXGroup; children = ( 1132E5152E777C140068732D /* YouTubeMusicAuthentication.swift */, @@ -495,15 +501,17 @@ 1132E5102E777B6E0068732D /* YouTubeMusicModels.swift */, 1153BDA62D99B22200979FB0 /* YouTubeMusicController.swift */, ); - path = "YouTube Music Controller"; + path = "YouTubeMusicController"; sourceTree = ""; }; 1153BD8E2D986B1F00979FB0 /* MediaControllers */ = { isa = PBXGroup; children = ( - 1132E5232E78D6DA0068732D /* YouTube Music Controller */, + 1132E5232E78D6DA0068732D /* YouTubeMusicController */, 1153BD8D2D986B1F00979FB0 /* MediaControllerProtocol.swift */, 1153BD922D986E4300979FB0 /* AppleMusicController.swift */, + 61F20E44C65E4DA1945BFF5C /* MediaAppBundleID.swift */, + B0C26F24108F414781F79D4F /* AppleScriptControllerSupport.swift */, 1153BD992D98824300979FB0 /* SpotifyController.swift */, 1153BD9B2D98853B00979FB0 /* NowPlayingController.swift */, ); @@ -601,6 +609,7 @@ children = ( 118EBE242E92DCCB00D54B5A /* AssociatedObject.swift */, 11A45C782E34E63100CEB175 /* MediaChecker.swift */, + 033B8885979E4EA1A78E8ADC /* Log.swift */, 54D24CBD49614ECE88F062B5 /* MediaEnvironment.swift */, 1153BD972D9881F900979FB0 /* AppleScriptHelper.swift */, 14288DD62C6E015000B9F80C /* AudioPlayer.swift */, @@ -639,7 +648,7 @@ 14C08BB72C8DE49E000F8AA0 /* Calendar */, 9A987A042C73CA66005CA465 /* Shelf */, 149E0B982C737D26006418B1 /* Webcam */, - B18654312C6F45AE000B926A /* Live activities */, + B18654312C6F45AE000B926A /* LiveActivities */, B18654302C6F4590000B926A /* Settings */, B186542F2C6F455E000B926A /* Notch */, 9A0887332C7AFF7E00C160EA /* Tabs */, @@ -882,14 +891,14 @@ path = Settings; sourceTree = ""; }; - B18654312C6F45AE000B926A /* Live activities */ = { + B18654312C6F45AE000B926A /* LiveActivities */ = { isa = PBXGroup; children = ( 14D570CC2C5F4BB70011E668 /* BoringBattery.swift */, B1C974332C642B6D0000E707 /* MarqueeTextView.swift */, B1D365CD2C6A979C0047BDBC /* LiveActivityModifier.swift */, ); - path = "Live activities"; + path = "LiveActivities"; sourceTree = ""; }; B186543A2C6F49A4000B926A /* Shortcuts */ = { @@ -1091,6 +1100,7 @@ 11CC44A22CEE614100C7244B /* BoringViewCoordinator.swift in Sources */, B186543C2C6F49AE000B926A /* ShortcutConstants.swift in Sources */, 11A45C792E34E63100CEB175 /* MediaChecker.swift in Sources */, + 7EF93DF9889B42E9BCD4634F /* Log.swift in Sources */, F80A422BE2974CF6808C84CA /* MediaEnvironment.swift in Sources */, B1D365CE2C6A979C0047BDBC /* LiveActivityModifier.swift in Sources */, 1113ABD02E80E6BB00EC13B2 /* ThumbnailService.swift in Sources */, @@ -1180,6 +1190,8 @@ 1443E7F32C609DCE0027C1FC /* matters.swift in Sources */, 11C5E3162DFE88510065821E /* SettingsView.swift in Sources */, 1153BD932D986E4300979FB0 /* AppleMusicController.swift in Sources */, + 62A83B43C1AD411097E93D17 /* MediaAppBundleID.swift in Sources */, + 80EA0C01BBCF4069AC5D133D /* AppleScriptControllerSupport.swift in Sources */, 11C5E3132DFE85970065821E /* SettingsWindowController.swift in Sources */, 110029272E84FD4C00035A57 /* TemporaryFileStorageService.swift in Sources */, 11CFC6652E09C7B300748C80 /* OnboardingFinishView.swift in Sources */, diff --git a/boringNotch/BoringViewCoordinator.swift b/boringNotch/BoringViewCoordinator.swift index aaffdc97d..32d35281f 100644 --- a/boringNotch/BoringViewCoordinator.swift +++ b/boringNotch/BoringViewCoordinator.swift @@ -20,7 +20,7 @@ enum SneakContentType { case download } -struct sneakPeek { +struct SneakPeekState { var show: Bool = false var type: SneakContentType = .music var value: CGFloat = 0 @@ -42,7 +42,7 @@ struct ExpandedItem { } @MainActor -class BoringViewCoordinator: ObservableObject { +final class BoringViewCoordinator: ObservableObject { static let shared = BoringViewCoordinator() @Published var currentView: NotchViews = .home @@ -216,7 +216,7 @@ class BoringViewCoordinator: ObservableObject { // MARK: - Per-Screen Sneak Peek Management // Dictionary to hold sneak peek state for each screen UUID - @Published var sneakPeekStates: [String: sneakPeek] = [:] + @Published var sneakPeekStates: [String: SneakPeekState] = [:] // Dictionary to hold hide tasks for each screen UUID private var sneakPeekTasks: [String: Task] = [:] @@ -237,7 +237,7 @@ class BoringViewCoordinator: ObservableObject { @MainActor func updateState(for uuid: String) { // If we don't have a state for this screen yet, initialize it - var state = self.sneakPeekStates[uuid] ?? sneakPeek(targetScreenUUID: uuid) + var state = self.sneakPeekStates[uuid] ?? SneakPeekState(targetScreenUUID: uuid) withAnimation(.smooth) { state.show = status @@ -332,17 +332,17 @@ class BoringViewCoordinator: ObservableObject { } // Helper to get state safely for binding/reading - func sneakPeekState(for screenUUID: String?) -> sneakPeek { - guard let uuid = screenUUID else { return sneakPeek() } - return sneakPeekStates[uuid] ?? sneakPeek(targetScreenUUID: uuid) + func sneakPeekState(for screenUUID: String?) -> SneakPeekState { + guard let uuid = screenUUID else { return SneakPeekState() } + return sneakPeekStates[uuid] ?? SneakPeekState(targetScreenUUID: uuid) } // Helper to get binding for SwiftUI views - func binding(for screenUUID: String?) -> Binding { + func binding(for screenUUID: String?) -> Binding { Binding( get: { [weak self] in - guard let self = self, let uuid = screenUUID else { return sneakPeek() } - return self.sneakPeekStates[uuid] ?? sneakPeek(targetScreenUUID: uuid) + guard let self = self, let uuid = screenUUID else { return SneakPeekState() } + return self.sneakPeekStates[uuid] ?? SneakPeekState(targetScreenUUID: uuid) }, set: { [weak self] newValue in guard let self = self, let uuid = screenUUID else { return } diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index f3e578dcd..46781fc12 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -438,7 +438,7 @@ struct ContentView: View { ) .transition(.opacity) } else if !liveActivities.isEmpty && vm.notchState == .closed && !vm.hideOnClosed { - LiveActivityStackView(items: liveActivities, index: $activityIndex) { item in + LiveActivityStack(items: liveActivities, index: $activityIndex) { item in switch item { case .notification(let notification): NotificationLiveActivity(notification: notification) @@ -560,7 +560,7 @@ struct ContentView: View { .fill(.black) .frame(width: vm.closedNotchSize.width + 20) let faceScale = min(1.0, displayClosedNotchHeight / 30.0) - MinimalFaceFeatures(height: 24.0 * faceScale, width: 30.0 * faceScale) + AnimatedFace(height: 24.0 * faceScale, width: 30.0 * faceScale) }.frame( height: displayClosedNotchHeight, alignment: .center @@ -672,7 +672,7 @@ struct ContentView: View { .frame(width: musicActivityCenterWidth) HStack { - AudioSpectrumView( + MusicVisualizer( isPlaying: musicManager.isPlaying, tintColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.5) diff --git a/boringNotch/MediaControllers/AppleMusicController.swift b/boringNotch/MediaControllers/AppleMusicController.swift index bd5903354..3084e5b85 100644 --- a/boringNotch/MediaControllers/AppleMusicController.swift +++ b/boringNotch/MediaControllers/AppleMusicController.swift @@ -9,10 +9,10 @@ import Foundation import Combine import SwiftUI -class AppleMusicController: MediaControllerProtocol { +final class AppleMusicController: MediaControllerProtocol { // MARK: - Properties @Published private var playbackState: PlaybackState = PlaybackState( - bundleIdentifier: "com.apple.Music", + bundleIdentifier: MediaAppBundleID.appleMusic, playbackRate: 1 ) @@ -42,11 +42,7 @@ class AppleMusicController: MediaControllerProtocol { private func setupPlaybackStateChangeObserver() { notificationTask = Task { @Sendable [weak self] in - let notifications = DistributedNotificationCenter.default().notifications( - named: NSNotification.Name("com.apple.Music.playerInfo") - ) - - for await _ in notifications { + for await _ in AppleScriptControllerSupport.playerInfoNotifications(named: "com.apple.Music.playerInfo") { await self?.updatePlaybackInfo() } } @@ -112,7 +108,7 @@ class AppleMusicController: MediaControllerProtocol { func isActive() -> Bool { let runningApps = NSWorkspace.shared.runningApplications - return runningApps.contains { $0.bundleIdentifier == "com.apple.Music" } + return runningApps.contains { $0.bundleIdentifier == MediaAppBundleID.appleMusic } } func setFavorite(_ favorite: Bool) async { @@ -154,8 +150,7 @@ class AppleMusicController: MediaControllerProtocol { // MARK: - Private Methods private func executeCommand(_ command: String) async { - let script = "tell application \"Music\" to \(command)" - try? await AppleScriptHelper.executeVoid(script) + await AppleScriptControllerSupport.executeCommand(command, appName: "Music") } private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? { diff --git a/boringNotch/MediaControllers/AppleScriptControllerSupport.swift b/boringNotch/MediaControllers/AppleScriptControllerSupport.swift new file mode 100644 index 000000000..fece681e8 --- /dev/null +++ b/boringNotch/MediaControllers/AppleScriptControllerSupport.swift @@ -0,0 +1,34 @@ +// +// AppleScriptControllerSupport.swift +// boringNotch +// +// Shared scaffolding for AppleScript-driven media controllers. +// + +import Foundation + +/// The "tell application X to command" shape and distributed player-info +/// observation that AppleMusicController and SpotifyController previously +/// copy-pasted between each other. +enum AppleScriptControllerSupport { + static func executeCommand(_ command: String, appName: String) async { + let script = "tell application \"\(appName)\" to \(command)" + try? await AppleScriptHelper.executeVoid(script) + } + + /// Streams player-info notifications from DistributedNotificationCenter + /// and cancels the underlying observer when the stream terminates. + static func playerInfoNotifications(named name: String) -> AsyncStream { + AsyncStream { continuation in + let observerTask = Task { + let notifications = DistributedNotificationCenter.default().notifications( + named: NSNotification.Name(name) + ) + for await _ in notifications { + continuation.yield() + } + } + continuation.onTermination = { _ in observerTask.cancel() } + } + } +} diff --git a/boringNotch/MediaControllers/MediaAppBundleID.swift b/boringNotch/MediaControllers/MediaAppBundleID.swift new file mode 100644 index 000000000..39fa087a6 --- /dev/null +++ b/boringNotch/MediaControllers/MediaAppBundleID.swift @@ -0,0 +1,15 @@ +// +// MediaAppBundleID.swift +// boringNotch +// +// Bundle identifiers for supported music apps — single source so typos +// can't creep into the (previously ~20) scattered literals. +// + +import Foundation + +enum MediaAppBundleID { + static let appleMusic = "com.apple.Music" + static let spotify = "com.spotify.client" + static let youTubeMusic = "com.github.th-ch.youtube-music" +} diff --git a/boringNotch/MediaControllers/NowPlayingController.swift b/boringNotch/MediaControllers/NowPlayingController.swift index 3fa5aadee..e2652c928 100644 --- a/boringNotch/MediaControllers/NowPlayingController.swift +++ b/boringNotch/MediaControllers/NowPlayingController.swift @@ -16,7 +16,7 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { // MARK: - Properties @Published private(set) var playbackState: PlaybackState = .init( - bundleIdentifier: "com.apple.Music" + bundleIdentifier: MediaAppBundleID.appleMusic ) var playbackStatePublisher: AnyPublisher { @@ -25,19 +25,19 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { var supportsVolumeControl: Bool { let bundleID = playbackState.bundleIdentifier - return bundleID == "com.apple.Music" || bundleID == "com.spotify.client" + return bundleID == MediaAppBundleID.appleMusic || bundleID == MediaAppBundleID.spotify } var supportsFavorite: Bool { let bundleID = playbackState.bundleIdentifier - return bundleID == "com.apple.Music" + return bundleID == MediaAppBundleID.appleMusic } func setFavorite(_ favorite: Bool) async { let bundleID = playbackState.bundleIdentifier - if bundleID == "com.apple.Music" { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") + if bundleID == MediaAppBundleID.appleMusic { + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: MediaAppBundleID.appleMusic) if !runningApps.isEmpty { let script = """ tell application "Music" @@ -168,14 +168,14 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { let bundleID = playbackState.bundleIdentifier if !bundleID.isEmpty { - if bundleID == "com.apple.Music" { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") + if bundleID == MediaAppBundleID.appleMusic { + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: MediaAppBundleID.appleMusic) if !runningApps.isEmpty { let script = "tell application \"Music\" to set sound volume to \(volumePercentage)" try? await AppleScriptHelper.executeVoid(script) } - } else if bundleID == "com.spotify.client" { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.spotify.client") + } else if bundleID == MediaAppBundleID.spotify { + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: MediaAppBundleID.spotify) if !runningApps.isEmpty { let script = "tell application \"Spotify\" to set sound volume to \(volumePercentage)" try? await AppleScriptHelper.executeVoid(script) @@ -319,8 +319,8 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { private func fetchFavoriteStateIfSupported() async { let bundleID = playbackState.bundleIdentifier - if bundleID == "com.apple.Music" { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") + if bundleID == MediaAppBundleID.appleMusic { + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: MediaAppBundleID.appleMusic) guard !runningApps.isEmpty else { return } let script = """ diff --git a/boringNotch/MediaControllers/SpotifyController.swift b/boringNotch/MediaControllers/SpotifyController.swift index f1a2867c5..72a1a2d84 100644 --- a/boringNotch/MediaControllers/SpotifyController.swift +++ b/boringNotch/MediaControllers/SpotifyController.swift @@ -9,14 +9,14 @@ import Foundation import Combine import SwiftUI -class SpotifyController: MediaControllerProtocol { +final class SpotifyController: MediaControllerProtocol { func setFavorite(_ favorite: Bool) async { //Placeholder } // MARK: - Properties @Published private var playbackState: PlaybackState = PlaybackState( - bundleIdentifier: "com.spotify.client" + bundleIdentifier: MediaAppBundleID.spotify ) var playbackStatePublisher: AnyPublisher { @@ -48,11 +48,7 @@ class SpotifyController: MediaControllerProtocol { private func setupPlaybackStateChangeObserver() { notificationTask = Task { @Sendable [weak self] in - let notifications = DistributedNotificationCenter.default().notifications( - named: NSNotification.Name("com.spotify.client.PlaybackStateChanged") - ) - - for await _ in notifications { + for await _ in AppleScriptControllerSupport.playerInfoNotifications(named: "com.spotify.client.PlaybackStateChanged") { await self?.updatePlaybackInfo() } } @@ -112,7 +108,7 @@ class SpotifyController: MediaControllerProtocol { let artworkURL = descriptor.atIndex(10)?.stringValue ?? "" var state = PlaybackState( - bundleIdentifier: "com.spotify.client", + bundleIdentifier: MediaAppBundleID.spotify, isPlaying: isPlaying, title: currentTrack, artist: currentTrackArtist, @@ -163,8 +159,7 @@ class SpotifyController: MediaControllerProtocol { // MARK: - Private Methods private func executeCommand(_ command: String) async { - let script = "tell application \"Spotify\" to \(command)" - try? await AppleScriptHelper.executeVoid(script) + await AppleScriptControllerSupport.executeCommand(command, appName: "Spotify") } private func executeAndRefresh(_ command: String) async { diff --git a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicAuthentication.swift b/boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicAuthentication.swift similarity index 100% rename from boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicAuthentication.swift rename to boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicAuthentication.swift diff --git a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift b/boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicController.swift similarity index 97% rename from boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift rename to boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicController.swift index 985639763..0db6a0739 100644 --- a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift +++ b/boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicController.swift @@ -39,7 +39,7 @@ final class YouTubeMusicController: MediaControllerProtocol { try? await Task.sleep(for: .milliseconds(150)) await updatePlaybackInfo() } catch { - print("[YouTubeMusicController] Failed to set favorite: \(error)") + Log.music.error("[YouTubeMusicController] Failed to set favorite: \(error)") } } @@ -146,7 +146,7 @@ final class YouTubeMusicController: MediaControllerProtocol { } catch YouTubeMusicError.authenticationRequired { await authManager.invalidateToken() } catch { - print("[YouTubeMusicController] Failed to update playback info: \(error)") + Log.music.error("[YouTubeMusicController] Failed to update playback info: \(error)") } } @@ -222,14 +222,14 @@ final class YouTubeMusicController: MediaControllerProtocol { await startPeriodicUpdates() await updatePlaybackInfo() } catch { - print("[YouTubeMusicController] Failed to initialize: \(error)") + Log.music.error("[YouTubeMusicController] Failed to initialize: \(error)") scheduleReconnect() } } private func setupWebSocketIfPossible(token: String) async { guard let wsURL = WebSocketURLBuilder.buildURL(from: configuration.baseURL) else { - print("[YouTubeMusicController] Failed to build WebSocket URL") + Log.music.error("[YouTubeMusicController] Failed to build WebSocket URL") return } @@ -246,7 +246,7 @@ final class YouTubeMusicController: MediaControllerProtocol { try await client.connect(to: wsURL, with: token) activateWebSocket(client) } catch { - print("[YouTubeMusicController] WebSocket connection failed: \(error)") + Log.music.error("[YouTubeMusicController] WebSocket connection failed: \(error)") scheduleReconnect() } } @@ -450,7 +450,7 @@ final class YouTubeMusicController: MediaControllerProtocol { } catch YouTubeMusicError.authenticationRequired { await authManager.invalidateToken() } catch { - print("[YouTubeMusicController] Command failed: \(error)") + Log.music.error("[YouTubeMusicController] Command failed: \(error)") } } diff --git a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicModels.swift b/boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicModels.swift similarity index 98% rename from boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicModels.swift rename to boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicModels.swift index 7b387dc0a..b652d8d6b 100644 --- a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicModels.swift +++ b/boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicModels.swift @@ -16,7 +16,7 @@ struct YouTubeMusicConfiguration: Sendable { static let `default` = YouTubeMusicConfiguration( baseURL: "http://localhost:26538", - bundleIdentifier: "com.github.th-ch.youtube-music", + bundleIdentifier: MediaAppBundleID.youTubeMusic, reconnectDelay: 1...60, updateInterval: 2.0 ) diff --git a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicNetworking.swift b/boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicNetworking.swift similarity index 100% rename from boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicNetworking.swift rename to boringNotch/MediaControllers/YouTubeMusicController/YouTubeMusicNetworking.swift diff --git a/boringNotch/Providers/CalendarServiceProviding.swift b/boringNotch/Providers/CalendarServiceProviding.swift index 5bf86329c..58b43ee51 100644 --- a/boringNotch/Providers/CalendarServiceProviding.swift +++ b/boringNotch/Providers/CalendarServiceProviding.swift @@ -111,7 +111,7 @@ class CalendarService: CalendarServiceProviding { do { try store.save(reminder, commit: true) } catch { - print("Failed to update reminder completion: \(error)") + Log.calendar.error("Failed to update reminder completion: \(error)") } } } diff --git a/boringNotch/animations/drop.swift b/boringNotch/animations/drop.swift index 7fc240ea7..db23a7ac8 100644 --- a/boringNotch/animations/drop.swift +++ b/boringNotch/animations/drop.swift @@ -44,7 +44,7 @@ enum StandardAnimations { static let timingCurve = Animation.timingCurve(0.16, 1, 0.3, 1, duration: 0.7) } -public class BoringAnimations { +final class BoringAnimations { @Published var notchStyle: Style = .notch var animation: Animation { diff --git a/boringNotch/boringNotchApp.swift b/boringNotch/boringNotchApp.swift index 98b992460..d79d98b0b 100644 --- a/boringNotch/boringNotchApp.swift +++ b/boringNotch/boringNotchApp.swift @@ -79,7 +79,7 @@ final class BoringSparkleUpdaterDelegate: NSObject, SPUUpdaterDelegate { /// App-lifecycle glue: shortcuts, onboarding, termination, observer wiring. /// All notch-window / per-screen view-model / drag-detector lifecycle lives /// in `NotchWindowManager` (see managers/NotchWindowManager.swift). -class AppDelegate: NSObject, NSApplicationDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate { var statusItem: NSStatusItem? @ObservedObject var coordinator = BoringViewCoordinator.shared var quickShareService = QuickShareService.shared diff --git a/boringNotch/components/AnimatedFace.swift b/boringNotch/components/AnimatedFace.swift index 4deaaac49..5d0ec97c2 100644 --- a/boringNotch/components/AnimatedFace.swift +++ b/boringNotch/components/AnimatedFace.swift @@ -6,7 +6,7 @@ import SwiftUI -struct MinimalFaceFeatures: View { +struct AnimatedFace: View { @State private var isBlinking = false @State private var blinkTask: Task? = nil var height: CGFloat = 24 @@ -93,7 +93,7 @@ struct MinimalFaceFeatures_Previews: PreviewProvider { static var previews: some View { ZStack { Color.black - MinimalFaceFeatures(height: 24, width: 30) + AnimatedFace(height: 24, width: 30) } .previewLayout(.fixed(width: 60, height: 60)) // Adjusted preview size for better visibility } diff --git a/boringNotch/components/Live activities/BoringBattery.swift b/boringNotch/components/LiveActivities/BoringBattery.swift similarity index 100% rename from boringNotch/components/Live activities/BoringBattery.swift rename to boringNotch/components/LiveActivities/BoringBattery.swift diff --git a/boringNotch/components/Live activities/LiveActivityModifier.swift b/boringNotch/components/LiveActivities/LiveActivityModifier.swift similarity index 100% rename from boringNotch/components/Live activities/LiveActivityModifier.swift rename to boringNotch/components/LiveActivities/LiveActivityModifier.swift diff --git a/boringNotch/components/Live activities/MarqueeTextView.swift b/boringNotch/components/LiveActivities/MarqueeTextView.swift similarity index 100% rename from boringNotch/components/Live activities/MarqueeTextView.swift rename to boringNotch/components/LiveActivities/MarqueeTextView.swift diff --git a/boringNotch/components/Music/MusicVisualizer.swift b/boringNotch/components/Music/MusicVisualizer.swift index 245286c0e..eca2f059e 100644 --- a/boringNotch/components/Music/MusicVisualizer.swift +++ b/boringNotch/components/Music/MusicVisualizer.swift @@ -9,7 +9,7 @@ import Cocoa import Defaults import SwiftUI -class AudioSpectrum: NSView, AudioCaptureLevelsConsumer { +class MusicVisualizerModel: NSView, AudioCaptureLevelsConsumer { private var barLayers: [CAGradientLayer] = [] private var isPlaying = false private var useRealtime = false @@ -220,14 +220,14 @@ class AudioSpectrum: NSView, AudioCaptureLevelsConsumer { } } -struct AudioSpectrumView: NSViewRepresentable { +struct MusicVisualizer: NSViewRepresentable { let isPlaying: Bool let tintColor: Color @Default(.realtimeAudioWaveform) var realtimeEnabled: Bool @ObservedObject private var audioCapture = AudioCaptureManager.shared - func makeNSView(context: Context) -> AudioSpectrum { - let spectrum = AudioSpectrum() + func makeNSView(context: Context) -> MusicVisualizerModel { + let spectrum = MusicVisualizerModel() spectrum.setTintColor(NSColor(tintColor)) spectrum.setUseRealtime(realtimeEnabled && audioCapture.isCapturing) spectrum.setPlaying(isPlaying) @@ -236,7 +236,7 @@ struct AudioSpectrumView: NSViewRepresentable { return spectrum } - func updateNSView(_ nsView: AudioSpectrum, context: Context) { + func updateNSView(_ nsView: MusicVisualizerModel, context: Context) { nsView.setTintColor(NSColor(tintColor)) nsView.setUseRealtime(realtimeEnabled && audioCapture.isCapturing) nsView.setPlaying(isPlaying) @@ -247,7 +247,7 @@ struct AudioSpectrumView: NSViewRepresentable { #Preview { ZStack { Color.black - AudioSpectrumView(isPlaying: true, tintColor: .green) + MusicVisualizer(isPlaying: true, tintColor: .green) .frame(width: 18, height: 14) } .padding() diff --git a/boringNotch/components/Notch/BoringHeader.swift b/boringNotch/components/Notch/BoringHeader.swift index 18ea71ab9..c86da4e16 100644 --- a/boringNotch/components/Notch/BoringHeader.swift +++ b/boringNotch/components/Notch/BoringHeader.swift @@ -12,11 +12,11 @@ struct BoringHeader: View { @EnvironmentObject var vm: BoringViewModel @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var coordinator = BoringViewCoordinator.shared - @StateObject var tvm = ShelfStateViewModel.shared + @StateObject var shelfState = ShelfStateViewModel.shared var body: some View { HStack(spacing: 0) { HStack { - if (!tvm.isEmpty || coordinator.alwaysShowTabs) && Defaults[.boringShelf] { + if (!shelfState.isEmpty || coordinator.alwaysShowTabs) && Defaults[.boringShelf] { TabSelectionView() } else if vm.notchState == .open { EmptyView() diff --git a/boringNotch/components/Notch/CompactHomeView.swift b/boringNotch/components/Notch/CompactHomeView.swift index 27050fbf9..21f83d75c 100644 --- a/boringNotch/components/Notch/CompactHomeView.swift +++ b/boringNotch/components/Notch/CompactHomeView.swift @@ -102,7 +102,7 @@ struct CompactHomeView: View { .frame(width: textWidth, alignment: .leading) ZStack { - AudioSpectrumView( + MusicVisualizer( isPlaying: musicManager.isPlaying, tintColor: coloredSpectrogram ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) @@ -271,7 +271,7 @@ struct CompactHomeView: View { // a +10/+10 offset, sized for the 120pt art in the full layout — // on 50pt art it spills outside the corner. if !musicManager.usingAppIconForArtwork { - AppIcon(for: musicManager.bundleIdentifier ?? "com.apple.Music") + appIcon(for: musicManager.bundleIdentifier ?? MediaAppBundleID.appleMusic) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 18, height: 18) diff --git a/boringNotch/components/Notch/LiveActivityStack.swift b/boringNotch/components/Notch/LiveActivityStack.swift index 559883192..6b07599bb 100644 --- a/boringNotch/components/Notch/LiveActivityStack.swift +++ b/boringNotch/components/Notch/LiveActivityStack.swift @@ -39,7 +39,7 @@ enum LiveActivityItem: Identifiable, Equatable { /// activity draws — `MusicLiveActivity` depends on ContentView's namespace /// and gesture state, and dragging it out here would be a much larger, /// riskier change than this feature needs. -struct LiveActivityStackView: View { +struct LiveActivityStack: View { let items: [LiveActivityItem] @Binding var index: Int @ViewBuilder let content: (LiveActivityItem) -> Content diff --git a/boringNotch/components/Notch/NotchHomeView.swift b/boringNotch/components/Notch/NotchHomeView.swift index d1996c640..ad3ebbf4e 100644 --- a/boringNotch/components/Notch/NotchHomeView.swift +++ b/boringNotch/components/Notch/NotchHomeView.swift @@ -103,7 +103,7 @@ struct AlbumArtView: View { @ViewBuilder private var appIconOverlay: some View { if vm.notchState == .open && !musicManager.usingAppIconForArtwork { - AppIcon(for: musicManager.bundleIdentifier ?? "com.apple.Music") + appIcon(for: musicManager.bundleIdentifier ?? MediaAppBundleID.appleMusic) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 30, height: 30) @@ -460,7 +460,7 @@ struct NotchHomeView: View { } if shouldShowCamera { - CameraPreviewView(webcamManager: webcamManager) + WebcamPreview(webcamManager: webcamManager) .scaledToFit() .opacity(vm.notchState == .closed ? 0 : 1) .blur(radius: vm.notchState == .closed ? 20 : 0) diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index be7479082..3cd734617 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -240,7 +240,7 @@ struct NotificationExpandedView: View { Self.personAvatarBundleIDs.contains(bundleID) { ZStack(alignment: .bottomTrailing) { PersonAvatarView(name: sender, size: 46) - AppIcon(for: bundleID) + appIcon(for: bundleID) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) @@ -294,7 +294,7 @@ struct NotificationExpandedView: View { } label: { HStack(spacing: 3) { if let bundleID = singleQueuedAppBundleID { - AppIcon(for: bundleID) + appIcon(for: bundleID) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 11, height: 11) @@ -653,7 +653,7 @@ private struct NotificationAppIcon: View { var body: some View { Group { if let bundleID { - AppIcon(for: bundleID) + appIcon(for: bundleID) .resizable() } else { Image(systemName: "bell.fill") diff --git a/boringNotch/components/OSD/Managers/BetterDisplayManager.swift b/boringNotch/components/OSD/Managers/BetterDisplayManager.swift index 615a78475..f6c688409 100644 --- a/boringNotch/components/OSD/Managers/BetterDisplayManager.swift +++ b/boringNotch/components/OSD/Managers/BetterDisplayManager.swift @@ -217,7 +217,7 @@ final class BetterDisplayManager { ) } } catch { - print("Failed to encode integration request: \(error)") + Log.osd.error("Failed to encode integration request: \(error)") } } diff --git a/boringNotch/components/OSD/Views/InlineOSD.swift b/boringNotch/components/OSD/Views/InlineOSD.swift index d38fa0971..c95b88158 100644 --- a/boringNotch/components/OSD/Views/InlineOSD.swift +++ b/boringNotch/components/OSD/Views/InlineOSD.swift @@ -21,7 +21,7 @@ struct InlineOSD: View { HStack(spacing: 5) { OSDIconView(eventType: type, icon: icon, value: value, accent: accent) - Text(Type2Name(type)) + Text(osdTypeName(type)) .font(.subheadline) .fontWeight(.medium) .lineLimit(1) @@ -79,7 +79,7 @@ struct InlineOSD: View { .frame(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center) } - func Type2Name(_ type: SneakContentType) -> String { + func osdTypeName(_ type: SneakContentType) -> String { switch(type) { case .volume: return NSLocalizedString("Volume", comment: "") diff --git a/boringNotch/components/OSD/Views/OSDIconView.swift b/boringNotch/components/OSD/Views/OSDIconView.swift index 30dac84dc..f070590a0 100644 --- a/boringNotch/components/OSD/Views/OSDIconView.swift +++ b/boringNotch/components/OSD/Views/OSDIconView.swift @@ -24,7 +24,7 @@ struct OSDIconView: View { .scaleEffect(value.isZero ? 0.85 : 1) .frame(width: 20, height: 15, alignment: .leading) case .brightness: - let symbol = icon.isEmpty ? BrightnessSymbolString(value) : icon + let symbol = icon.isEmpty ? brightnessSymbolName(value) : icon Image(systemName: symbol) .contentTransition(.interpolate) .frame(width: 20, height: 15) @@ -45,7 +45,7 @@ struct OSDIconView: View { } } -private func BrightnessSymbolString(_ value: CGFloat) -> String { +private func brightnessSymbolName(_ value: CGFloat) -> String { return value < 0.3 ? "sun.min.fill" : "sun.max.fill" } } diff --git a/boringNotch/components/OSD/Views/SystemEventIndicatorModifier.swift b/boringNotch/components/OSD/Views/SystemEventIndicatorModifier.swift index 5599ac273..83dd956a9 100644 --- a/boringNotch/components/OSD/Views/SystemEventIndicatorModifier.swift +++ b/boringNotch/components/OSD/Views/SystemEventIndicatorModifier.swift @@ -14,14 +14,15 @@ struct SystemEventIndicatorModifier: View { @Binding var value: CGFloat @Binding var icon: String @Binding var accent: Color? - let showSlider: Bool = false + /// Routed to hardware (volume/brightness) when the bar is dragged — + /// without this, the closed-notch OSD slider was display-only. var sendEventBack: (CGFloat) -> Void var body: some View { HStack(spacing: 14) { OSDIconView(eventType: eventType, icon: icon, value: value, accent: accent) - if (eventType != .mic) { - DraggableProgressBar(value: $value, accentColor: accent) + if eventType != .mic { + DraggableProgressBar(value: $value, onChange: sendEventBack, accentColor: accent) if Defaults[.showClosedNotchOSDPercentage] { Text(value, format: .percent.precision(.fractionLength(0))) .font(.system(size: 12, weight: .medium)) diff --git a/boringNotch/components/Onboarding/OnboardingView.swift b/boringNotch/components/Onboarding/OnboardingView.swift index ad36a9fee..fb5155bcc 100644 --- a/boringNotch/components/Onboarding/OnboardingView.swift +++ b/boringNotch/components/Onboarding/OnboardingView.swift @@ -42,7 +42,7 @@ struct OnboardingView: View { .transition(.opacity) case .cameraPermission: - PermissionRequestView( + PermissionsRequestView( icon: Image(systemName: "camera.fill"), title: "Enable Camera Access", description: "Boring Notch includes a mirror feature that lets you quickly check your appearance using your camera, right from the notch. Camera access is required only to show this live preview. You can turn the mirror feature on or off at any time in the app.", @@ -64,7 +64,7 @@ struct OnboardingView: View { .transition(.opacity) case .calendarPermission: - PermissionRequestView( + PermissionsRequestView( icon: Image(systemName: "calendar"), title: "Enable Calendar Access", description: "Boring Notch can show all your upcoming events in one place. Access to your calendar is needed to display your schedule.", @@ -86,7 +86,7 @@ struct OnboardingView: View { .transition(.opacity) case .remindersPermission: - PermissionRequestView( + PermissionsRequestView( icon: Image(systemName: "checklist"), title: "Enable Reminders Access", description: "Boring Notch can show your scheduled reminders alongside your calendar events. Access to Reminders is needed to display your reminders.", @@ -108,7 +108,7 @@ struct OnboardingView: View { .transition(.opacity) case .audioCapturePermission: - PermissionRequestView( + PermissionsRequestView( icon: Image(systemName: "waveform"), title: "Enable Real-Time Audio", description: "Boring Notch can analyze the audio playing from your music app to draw a live FFT waveform in the notch, with only a minimal impact on CPU usage.", @@ -133,7 +133,7 @@ struct OnboardingView: View { .transition(.opacity) case .accessibilityPermission: - PermissionRequestView( + PermissionsRequestView( icon: Image(systemName: "hand.raised.fill"), title: "Enable Accessibility Access", description: "Accessibility access is only needed when using built-in macOS control sources for OSD replacement. External sources like BetterDisplay or Lunar do not require Accessibility. You can enable it later in OSD settings if needed.", diff --git a/boringNotch/components/Onboarding/PermissionsRequestView.swift b/boringNotch/components/Onboarding/PermissionsRequestView.swift index 32501c526..e6718e804 100644 --- a/boringNotch/components/Onboarding/PermissionsRequestView.swift +++ b/boringNotch/components/Onboarding/PermissionsRequestView.swift @@ -7,7 +7,7 @@ import SwiftUI -struct PermissionRequestView: View { +struct PermissionsRequestView: View { let icon: Image let title: String let description: String diff --git a/boringNotch/components/Settings/SettingsView.swift b/boringNotch/components/Settings/SettingsView.swift index 7c8993963..3f2e9542f 100644 --- a/boringNotch/components/Settings/SettingsView.swift +++ b/boringNotch/components/Settings/SettingsView.swift @@ -88,9 +88,9 @@ struct SettingsView: View { case .general: GeneralSettings() case .appearance: - Appearance() + AppearanceSettingsView() case .media: - Media() + MediaSettingsView() case .notifications: NotificationSettingsView() case .calendar: @@ -98,21 +98,21 @@ struct SettingsView: View { case .osd: OSDSettings() case .battery: - Charge() + BatterySettingsView() case .shelf: - Shelf() + ShelfSettingsView() case .mirror: - MirrorSettings() + WebcamSettingsView() case .shortcuts: - Shortcuts() + ShortcutsSettingsView() case .advanced: - Advanced() + AdvancedSettingsView() case .about: if let controller = updaterController { - About(updaterController: controller) + AboutView(updaterController: controller) } else { // Fallback with a default controller - About( + AboutView( updaterController: SPUStandardUpdaterController( startingUpdater: false, updaterDelegate: nil, userDriverDelegate: nil)) diff --git a/boringNotch/components/Settings/Views/AboutView.swift b/boringNotch/components/Settings/Views/AboutView.swift index 14f39381f..852f18c61 100644 --- a/boringNotch/components/Settings/Views/AboutView.swift +++ b/boringNotch/components/Settings/Views/AboutView.swift @@ -9,7 +9,7 @@ import Defaults import Sparkle import SwiftUI -struct About: View { +struct AboutView: View { @State private var showBuildNumber: Bool = false let updaterController: SPUStandardUpdaterController @Environment(\.openWindow) var openWindow diff --git a/boringNotch/components/Settings/Views/AdvancedSettingsView.swift b/boringNotch/components/Settings/Views/AdvancedSettingsView.swift index 2bd918762..2328b91f1 100644 --- a/boringNotch/components/Settings/Views/AdvancedSettingsView.swift +++ b/boringNotch/components/Settings/Views/AdvancedSettingsView.swift @@ -8,7 +8,7 @@ import Defaults import SwiftUI -struct Advanced: View { +struct AdvancedSettingsView: View { @Default(.useCustomAccentColor) var useCustomAccentColor @Default(.customAccentColorData) var customAccentColorData @Default(.extendHoverArea) var extendHoverArea diff --git a/boringNotch/components/Settings/Views/AppearanceSettingsView.swift b/boringNotch/components/Settings/Views/AppearanceSettingsView.swift index d86b411d6..8d4c63c05 100644 --- a/boringNotch/components/Settings/Views/AppearanceSettingsView.swift +++ b/boringNotch/components/Settings/Views/AppearanceSettingsView.swift @@ -8,7 +8,7 @@ import Defaults import SwiftUI -struct Appearance: View { +struct AppearanceSettingsView: View { @ObservedObject var coordinator = BoringViewCoordinator.shared @Default(.sliderColor) var sliderColor diff --git a/boringNotch/components/Settings/Views/BatterySettingsView.swift b/boringNotch/components/Settings/Views/BatterySettingsView.swift index acf0aa52c..e5a64b5c5 100644 --- a/boringNotch/components/Settings/Views/BatterySettingsView.swift +++ b/boringNotch/components/Settings/Views/BatterySettingsView.swift @@ -8,7 +8,7 @@ import Defaults import SwiftUI -struct Charge: View { +struct BatterySettingsView: View { var body: some View { Form { Section { diff --git a/boringNotch/components/Settings/Views/MediaSettingsView.swift b/boringNotch/components/Settings/Views/MediaSettingsView.swift index 55410274d..744813905 100644 --- a/boringNotch/components/Settings/Views/MediaSettingsView.swift +++ b/boringNotch/components/Settings/Views/MediaSettingsView.swift @@ -8,7 +8,7 @@ import Defaults import SwiftUI -struct Media: View { +struct MediaSettingsView: View { @Default(.waitInterval) var waitInterval @Default(.mediaController) var mediaController @ObservedObject var coordinator = BoringViewCoordinator.shared diff --git a/boringNotch/components/Settings/Views/NotificationSettingsView.swift b/boringNotch/components/Settings/Views/NotificationSettingsView.swift index d177bf31c..d5488f842 100644 --- a/boringNotch/components/Settings/Views/NotificationSettingsView.swift +++ b/boringNotch/components/Settings/Views/NotificationSettingsView.swift @@ -101,7 +101,7 @@ struct NotificationSettingsView: View { @ViewBuilder private func appRow(_ app: KnownNotificationApp) -> some View { HStack { - AppIcon(for: app.bundleID) + appIcon(for: app.bundleID) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) diff --git a/boringNotch/components/Settings/Views/ShelfSettingsView.swift b/boringNotch/components/Settings/Views/ShelfSettingsView.swift index d997f1f2d..4410edf43 100644 --- a/boringNotch/components/Settings/Views/ShelfSettingsView.swift +++ b/boringNotch/components/Settings/Views/ShelfSettingsView.swift @@ -8,7 +8,7 @@ import Defaults import SwiftUI -struct Shelf: View { +struct ShelfSettingsView: View { @Default(.shelfTapToOpen) var shelfTapToOpen: Bool @Default(.quickShareProvider) var quickShareProvider diff --git a/boringNotch/components/Settings/Views/ShortcutsSettingsView.swift b/boringNotch/components/Settings/Views/ShortcutsSettingsView.swift index 7d39732b6..83ff7335b 100644 --- a/boringNotch/components/Settings/Views/ShortcutsSettingsView.swift +++ b/boringNotch/components/Settings/Views/ShortcutsSettingsView.swift @@ -8,7 +8,7 @@ import KeyboardShortcuts import SwiftUI -struct Shortcuts: View { +struct ShortcutsSettingsView: View { var body: some View { Form { Section { diff --git a/boringNotch/components/Settings/Views/WebcamSettingsView.swift b/boringNotch/components/Settings/Views/WebcamSettingsView.swift index 5aa9f2064..372686b10 100644 --- a/boringNotch/components/Settings/Views/WebcamSettingsView.swift +++ b/boringNotch/components/Settings/Views/WebcamSettingsView.swift @@ -9,7 +9,7 @@ import AVFoundation import SwiftUI import Defaults -struct MirrorSettings: View { +struct WebcamSettingsView: View { @Default(.showMirror) private var showMirror @Default(.isMirrored) private var isMirrored @Default(.mirrorShape) private var mirrorShape diff --git a/boringNotch/components/Shelf/Services/QuickShareService.swift b/boringNotch/components/Shelf/Services/QuickShareService.swift index e5fa0b055..8743f7a04 100644 --- a/boringNotch/components/Shelf/Services/QuickShareService.swift +++ b/boringNotch/components/Shelf/Services/QuickShareService.swift @@ -215,7 +215,7 @@ final class QuickShareService: ObservableObject { @MainActor func showFilePicker(for provider: QuickShareProvider, from view: NSView?) async { guard !isPickerOpen else { - print("⚠️ QuickShareService: File picker already open") + Log.shelf.error("⚠️ QuickShareService: File picker already open") return } @@ -330,7 +330,7 @@ private class SharingServiceDelegate: NSObject {} } } } - print("❌ Failed to resolve bookmark for shelf item") + Log.shelf.error("❌ Failed to resolve bookmark for shelf item") return nil } } diff --git a/boringNotch/components/Shelf/Services/ShareServiceFinder.swift b/boringNotch/components/Shelf/Services/ShareServiceFinder.swift index 745d79812..009d84cd4 100644 --- a/boringNotch/components/Shelf/Services/ShareServiceFinder.swift +++ b/boringNotch/components/Shelf/Services/ShareServiceFinder.swift @@ -42,7 +42,7 @@ class ShareServiceFinder: NSObject, NSSharingServicePickerDelegate { guard !didResume else { return } didResume = true picker.close() // Ensure picker is closed even on timeout - print("Warning: timed out waiting for sharing services") + Log.shelf.debug("Warning: timed out waiting for sharing services") continuation.resume(returning: []) } } diff --git a/boringNotch/components/Shelf/Services/ShelfPersistenceService.swift b/boringNotch/components/Shelf/Services/ShelfPersistenceService.swift index 6478de4a4..0c61620d7 100644 --- a/boringNotch/components/Shelf/Services/ShelfPersistenceService.swift +++ b/boringNotch/components/Shelf/Services/ShelfPersistenceService.swift @@ -41,7 +41,7 @@ final class ShelfPersistenceService { do { // Parse as JSON array to get individual item data guard let jsonArray = try JSONSerialization.jsonObject(with: data) as? [Any] else { - print("⚠️ Shelf persistence file is not a valid JSON array") + Log.shelf.error("⚠️ Shelf persistence file is not a valid JSON array") return [] } @@ -55,17 +55,17 @@ final class ShelfPersistenceService { validItems.append(item) } catch { failedCount += 1 - print("⚠️ Failed to decode shelf item at index \(index): \(error.localizedDescription)") + Log.shelf.error("⚠️ Failed to decode shelf item at index \(index): \(error.localizedDescription)") } } if failedCount > 0 { - print("📦 Successfully loaded \(validItems.count) shelf items, discarded \(failedCount) corrupted items") + Log.shelf.error("📦 Successfully loaded \(validItems.count) shelf items, discarded \(failedCount) corrupted items") } return validItems } catch { - print("❌ Failed to parse shelf persistence file: \(error.localizedDescription)") + Log.shelf.error("❌ Failed to parse shelf persistence file: \(error.localizedDescription)") return [] } } @@ -75,7 +75,7 @@ final class ShelfPersistenceService { let data = try encoder.encode(items) try data.write(to: fileURL, options: Data.WritingOptions.atomic) } catch { - print("Failed to save shelf items: \(error.localizedDescription)") + Log.shelf.error("Failed to save shelf items: \(error.localizedDescription)") } } @@ -85,7 +85,7 @@ final class ShelfPersistenceService { let data = try encoder.encode(items) try data.write(to: fileURL, options: Data.WritingOptions.atomic) } catch { - print("Failed to save shelf items: \(error.localizedDescription)") + Log.shelf.error("Failed to save shelf items: \(error.localizedDescription)") } }.value } diff --git a/boringNotch/components/Shelf/Services/TemporaryFileStorageService.swift b/boringNotch/components/Shelf/Services/TemporaryFileStorageService.swift index e06638ac4..10630d9da 100644 --- a/boringNotch/components/Shelf/Services/TemporaryFileStorageService.swift +++ b/boringNotch/components/Shelf/Services/TemporaryFileStorageService.swift @@ -15,7 +15,7 @@ enum TempFileType { case url(URL) } -class TemporaryFileStorageService { +final class TemporaryFileStorageService { static let shared = TemporaryFileStorageService() // MARK: - Public Interface @@ -32,7 +32,7 @@ class TemporaryFileStorageService { let tempDirectory = URL(fileURLWithPath: NSTemporaryDirectory()) guard url.path.hasPrefix(tempDirectory.path) else { - print("Attempted to remove temporary file outside temp directory: \(url.path)") + Log.shelf.debug("Attempted to remove temporary file outside temp directory: \(url.path)") return } @@ -40,18 +40,18 @@ class TemporaryFileStorageService { do { try FileManager.default.removeItem(at: url) - print("Deleted file: \(url.path)") + Log.shelf.debug("Deleted file: \(url.path)") let contents = try FileManager.default.contentsOfDirectory(atPath: folderURL.path) if contents.isEmpty { try FileManager.default.removeItem(at: folderURL) - print("Folder was empty, deleted folder: \(folderURL.path)") + Log.shelf.debug("Folder was empty, deleted folder: \(folderURL.path)") } else { - print("Folder not deleted — it still contains \(contents.count) item(s).") + Log.shelf.debug("Folder not deleted — it still contains \(contents.count) item(s).") } } catch { - print("Error: \(error.localizedDescription)") + Log.shelf.error("Error: \(error.localizedDescription)") } } @@ -72,7 +72,7 @@ class TemporaryFileStorageService { try data.write(to: fileURL) return fileURL } catch { - print("Error: \(error)") + Log.shelf.error("Error: \(error)") return nil } @@ -82,7 +82,7 @@ class TemporaryFileStorageService { let fileURL = dirURL.appendingPathComponent(filename) guard let data = string.data(using: .utf8) else { - print("❌ Failed to convert text to data") + Log.shelf.error("❌ Failed to convert text to data") return nil } @@ -91,7 +91,7 @@ class TemporaryFileStorageService { try data.write(to: fileURL) return fileURL } catch { - print("Error: \(error)") + Log.shelf.error("Error: \(error)") return nil } @@ -102,7 +102,7 @@ class TemporaryFileStorageService { let weblocContent = createWeblocContent(for: url) guard let data = weblocContent.data(using: String.Encoding.utf8) else { - print("❌ Failed to create webloc data") + Log.shelf.error("❌ Failed to create webloc data") return nil } @@ -111,7 +111,7 @@ class TemporaryFileStorageService { try data.write(to: fileURL) return fileURL } catch { - print("Error: \(error)") + Log.shelf.error("Error: \(error)") return nil } } @@ -122,7 +122,7 @@ class TemporaryFileStorageService { try data.write(to: url) return url } catch { - print("❌ Failed to create temp file at \(url.path): \(error)") + Log.shelf.error("❌ Failed to create temp file at \(url.path): \(error)") return nil } } @@ -134,7 +134,7 @@ class TemporaryFileStorageService { do { try FileManager.default.createDirectory(at: workingDir, withIntermediateDirectories: true) } catch { - print("❌ Failed to create zip working directory: \(error)") + Log.shelf.error("❌ Failed to create zip working directory: \(error)") return nil } @@ -149,7 +149,7 @@ class TemporaryFileStorageService { proc.waitUntilExit() return proc.terminationStatus == 0 } catch { - print("❌ Failed to run zip: \(error)") + Log.shelf.error("❌ Failed to run zip: \(error)") return false } } @@ -200,7 +200,7 @@ class TemporaryFileStorageService { try FileManager.default.copyItem(at: src, to: dest) } } catch { - print("⚠️ Failed to copy \(src.path) to working dir: \(error)") + Log.shelf.error("⚠️ Failed to copy \(src.path) to working dir: \(error)") } } @@ -218,7 +218,7 @@ class TemporaryFileStorageService { } } } catch { - print("⚠️ Failed to cleanup working directory after zip: \(error)") + Log.shelf.error("⚠️ Failed to cleanup working directory after zip: \(error)") } return archiveURL } else { diff --git a/boringNotch/components/Shelf/Views/ShelfContextMenu.swift b/boringNotch/components/Shelf/Views/ShelfContextMenu.swift index 6264f3ceb..76548c36f 100644 --- a/boringNotch/components/Shelf/Views/ShelfContextMenu.swift +++ b/boringNotch/components/Shelf/Views/ShelfContextMenu.swift @@ -344,7 +344,7 @@ private final class MenuActionTarget: NSObject { try await NSWorkspace.shared.open(allSelectedURLs, withApplicationAt: appURL, configuration: config) } } catch { - print("❌ Failed to open with application: \(error.localizedDescription)") + Log.shelf.error("❌ Failed to open with application: \(error.localizedDescription)") } } return @@ -616,10 +616,10 @@ private final class MenuActionTarget: NSObject { if alwaysCheckbox.state == .on, let bundleID = Bundle(url: appURL)?.bundleIdentifier { if let contentType = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType { let status = LSSetDefaultRoleHandlerForContentType(contentType.identifier as CFString, LSRolesMask.all, bundleID as CFString) - if status != noErr { print("⚠️ Failed to set default handler for \(contentType.identifier): \(status)") } + if status != noErr { Log.shelf.error("Failed to set default handler for \(contentType.identifier): \(status)") } } else if let scheme = fileURL.scheme { let status = LSSetDefaultHandlerForURLScheme(scheme as CFString, bundleID as CFString) - if status != noErr { print("⚠️ Failed to set default handler for scheme \(scheme): \(status)") } + if status != noErr { Log.shelf.error("Failed to set default handler for scheme \(scheme): \(status)") } } } @@ -631,7 +631,7 @@ private final class MenuActionTarget: NSObject { try await NSWorkspace.shared.open([fileURL], withApplicationAt: appURL, configuration: config) } } catch { - print("❌ Failed to open with application: \(error.localizedDescription)") + Log.shelf.error("❌ Failed to open with application: \(error.localizedDescription)") } } } @@ -667,7 +667,7 @@ private final class MenuActionTarget: NSObject { ShelfStateViewModel.shared.updateBookmark(for: item, bookmark: newBookmark.data) } } catch { - print("❌ Failed to rename file: \(error.localizedDescription)") + Log.shelf.error("❌ Failed to rename file: \(error.localizedDescription)") } if didStart { fileURL.stopAccessingSecurityScopedResource() } } @@ -703,7 +703,7 @@ private final class MenuActionTarget: NSObject { } } } catch { - print("❌ Failed to remove background: \(error.localizedDescription)") + Log.shelf.error("❌ Failed to remove background: \(error.localizedDescription)") showErrorAlert(title: String(localized: "Background Removal Failed"), message: error.localizedDescription) } } @@ -733,7 +733,7 @@ private final class MenuActionTarget: NSObject { } } } catch { - print("❌ Failed to create PDF: \(error.localizedDescription)") + Log.shelf.error("❌ Failed to create PDF: \(error.localizedDescription)") showErrorAlert(title: String(localized: "PDF Creation Failed"), message: error.localizedDescription) } } @@ -941,7 +941,7 @@ private final class MenuActionTarget: NSObject { } } } catch { - print("❌ Failed to convert image: \(error.localizedDescription)") + Log.shelf.error("❌ Failed to convert image: \(error.localizedDescription)") showErrorAlert(title: String(localized: "Image Conversion Failed"), message: error.localizedDescription) } } diff --git a/boringNotch/components/Shelf/Views/ShelfView.swift b/boringNotch/components/Shelf/Views/ShelfView.swift index b4d3ea0ad..bacc2b57b 100644 --- a/boringNotch/components/Shelf/Views/ShelfView.swift +++ b/boringNotch/components/Shelf/Views/ShelfView.swift @@ -12,12 +12,12 @@ import Defaults struct ShelfView: View { let dropInteraction: DropInteractionState let animation: Animation? - @StateObject var tvm = ShelfStateViewModel.shared + @StateObject var shelfState = ShelfStateViewModel.shared private let spacing: CGFloat = 8 private var displayedItems: [ShelfItem] { - Defaults[.reverseShelfOrdering] ? Array(tvm.items.reversed()) : tvm.items + Defaults[.reverseShelfOrdering] ? Array(shelfState.items.reversed()) : shelfState.items } var body: some View { @@ -38,7 +38,7 @@ struct ShelfView: View { private func handleDrop(providers: [NSItemProvider]) -> Bool { guard !ShelfSelectionModel.shared.isDragging else { return false } dropInteraction.dropEvent = true - tvm.load(providers) + shelfState.load(providers) return true } @@ -66,7 +66,7 @@ struct ShelfView: View { @Bindable var interaction = dropInteraction return Group { - if tvm.isEmpty { + if shelfState.isEmpty { VStack(spacing: 10) { Image(systemName: "tray.and.arrow.down") .symbolVariant(.fill) @@ -99,7 +99,7 @@ struct ShelfView: View { } } .onAppear { - tvm.cleanupInvalidItems() + shelfState.cleanupInvalidItems() } } } diff --git a/boringNotch/components/Tabs/TabButton.swift b/boringNotch/components/Tabs/TabButton.swift index e6eb6b45a..f6de1fa65 100644 --- a/boringNotch/components/Tabs/TabButton.swift +++ b/boringNotch/components/Tabs/TabButton.swift @@ -25,6 +25,6 @@ struct TabButton: View { #Preview { TabButton(label: "Home", icon: "tray.fill", selected: true) { - print("Tapped") + Log.general.debug("Tapped") } } diff --git a/boringNotch/components/Webcam/WebcamView.swift b/boringNotch/components/Webcam/WebcamView.swift index e506b55fe..3edc261be 100644 --- a/boringNotch/components/Webcam/WebcamView.swift +++ b/boringNotch/components/Webcam/WebcamView.swift @@ -9,7 +9,7 @@ import AVFoundation import Defaults import SwiftUI -struct CameraPreviewView: View { +struct WebcamPreview: View { @EnvironmentObject var vm: BoringViewModel @ObservedObject var webcamManager: WebcamManager @@ -21,7 +21,7 @@ struct CameraPreviewView: View { GeometryReader { geometry in ZStack { if let previewLayer = webcamManager.previewLayer { - CameraPreviewLayerView(previewLayer: previewLayer) + WebcamPreviewLayer(previewLayer: previewLayer) .scaleEffect(x: isMirrored ? -1 : 1, y: 1) .clipShape(RoundedRectangle(cornerRadius: Defaults[.mirrorShape] == .rectangle ? MusicPlayerImageSizes.cornerRadiusInset.opened : 100)) .frame(width: geometry.size.width, height: geometry.size.width) @@ -94,7 +94,7 @@ struct CameraPreviewView: View { } } -struct CameraPreviewLayerView: NSViewRepresentable { +struct WebcamPreviewLayer: NSViewRepresentable { let previewLayer: AVCaptureVideoPreviewLayer func makeNSView(context: Context) -> NSView { @@ -115,5 +115,5 @@ struct CameraPreviewLayerView: NSViewRepresentable { } #Preview { - CameraPreviewView(webcamManager: .shared) + WebcamPreview(webcamManager: .shared) } diff --git a/boringNotch/enums/generic.swift b/boringNotch/enums/generic.swift index 810969dde..7ab23a07b 100644 --- a/boringNotch/enums/generic.swift +++ b/boringNotch/enums/generic.swift @@ -8,17 +8,17 @@ import Foundation import Defaults -public enum Style { +enum Style { case notch case floating } -public enum NotchState { +enum NotchState { case closed case open } -public enum NotchViews { +enum NotchViews { case home case shelf } diff --git a/boringNotch/extensions/Color+AccentColor.swift b/boringNotch/extensions/Color+AccentColor.swift index 8b4e26f16..a202e316d 100644 --- a/boringNotch/extensions/Color+AccentColor.swift +++ b/boringNotch/extensions/Color+AccentColor.swift @@ -8,21 +8,26 @@ import SwiftUI import Defaults +/// Decodes the user's custom accent color, if custom accents are enabled. +private var customAccentNSColor: NSColor? { + guard Defaults[.useCustomAccentColor], + let colorData = Defaults[.customAccentColorData], + let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) + else { return nil } + return nsColor +} + extension Color { static var effectiveAccent: Color { - if Defaults[.useCustomAccentColor], - let colorData = Defaults[.customAccentColorData], - let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) { + if let nsColor = customAccentNSColor { return Color(nsColor: nsColor) } return .accentColor } - + /// Returns a darker version of the accent color suitable for backgrounds static var effectiveAccentBackground: Color { - if Defaults[.useCustomAccentColor], - let colorData = Defaults[.customAccentColorData], - let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) { + if let nsColor = customAccentNSColor { return Color(nsColor: nsColor.withSystemEffect(.disabled)) } return Color.effectiveAccent.opacity(0.25) @@ -31,19 +36,12 @@ extension Color { extension NSColor { static var effectiveAccent: NSColor { - if Defaults[.useCustomAccentColor], - let colorData = Defaults[.customAccentColorData], - let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) { - return nsColor - } - return NSColor.controlAccentColor + customAccentNSColor ?? NSColor.controlAccentColor } - + /// Returns a darker version of the accent color as NSColor suitable for backgrounds static var effectiveAccentBackground: NSColor { - if Defaults[.useCustomAccentColor], - let colorData = Defaults[.customAccentColorData], - let nsColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: colorData) { + if let nsColor = customAccentNSColor { return nsColor.withSystemEffect(.disabled) } return NSColor.controlAccentColor.withAlphaComponent(0.25) diff --git a/boringNotch/extensions/NSItemProvider+LoadHelpers.swift b/boringNotch/extensions/NSItemProvider+LoadHelpers.swift index 86b7cbe18..27501bb13 100644 --- a/boringNotch/extensions/NSItemProvider+LoadHelpers.swift +++ b/boringNotch/extensions/NSItemProvider+LoadHelpers.swift @@ -32,7 +32,7 @@ extension NSItemProvider { return await withCheckedContinuation { (cont: CheckedContinuation) in loadItem(forTypeIdentifier: UTType.data.identifier, options: nil) { item, error in if let error = error { - print("Error loading data for type \(UTType.data.identifier): \(error.localizedDescription)") + Log.general.error("Error loading data for type \(UTType.data.identifier): \(error.localizedDescription)") cont.resume(returning: nil) return } @@ -49,19 +49,19 @@ extension NSItemProvider { do { // Delete the file first try fileManager.removeItem(at: url) - print("Deleted file: \(url.path)") + Log.general.debug("Deleted file: \(url.path)") // Check folder contents let contents = try fileManager.contentsOfDirectory(atPath: folderURL.path) if contents.isEmpty { try fileManager.removeItem(at: folderURL) - print("Folder was empty, deleted folder: \(folderURL.path)") + Log.general.debug("Folder was empty, deleted folder: \(folderURL.path)") } else { - print("Folder not deleted — it still contains \(contents.count) item(s).") + Log.general.debug("Folder not deleted — it still contains \(contents.count) item(s).") } } catch { - print("Error: \(error.localizedDescription)") + Log.general.error("Error: \(error.localizedDescription)") } cont.resume(returning: data) @@ -104,7 +104,7 @@ extension NSItemProvider { await withCheckedContinuation { (cont: CheckedContinuation) in self.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in if let error = error { - print("❌ Error loading item for type \(typeIdentifier): \(error.localizedDescription)") + Log.general.error("❌ Error loading item for type \(typeIdentifier): \(error.localizedDescription)") cont.resume(returning: nil) return } diff --git a/boringNotch/helpers/AppIcons.swift b/boringNotch/helpers/AppIcons.swift index d9138e1ed..e3eae57d8 100644 --- a/boringNotch/helpers/AppIcons.swift +++ b/boringNotch/helpers/AppIcons.swift @@ -59,7 +59,7 @@ func normalizeBundleIdentifier(_ bundleID: String) -> String { return bundleID } -func AppIcon(for bundleID: String) -> Image { +func appIcon(for bundleID: String) -> Image { let workspace = NSWorkspace.shared let normalizedID = normalizeBundleIdentifier(bundleID) @@ -72,7 +72,7 @@ func AppIcon(for bundleID: String) -> Image { } -func AppIconAsNSImage(for bundleID: String) -> NSImage? { +func appIconAsNSImage(for bundleID: String) -> NSImage? { let workspace = NSWorkspace.shared let normalizedID = normalizeBundleIdentifier(bundleID) diff --git a/boringNotch/helpers/AppleScriptHelper.swift b/boringNotch/helpers/AppleScriptHelper.swift index d57e32666..4fe3bac19 100644 --- a/boringNotch/helpers/AppleScriptHelper.swift +++ b/boringNotch/helpers/AppleScriptHelper.swift @@ -7,7 +7,7 @@ import Foundation -class AppleScriptHelper { +final class AppleScriptHelper { @discardableResult class func execute(_ scriptText: String) async throws -> NSAppleEventDescriptor? { try await withCheckedThrowingContinuation { continuation in diff --git a/boringNotch/helpers/Log.swift b/boringNotch/helpers/Log.swift new file mode 100644 index 000000000..de07fcb68 --- /dev/null +++ b/boringNotch/helpers/Log.swift @@ -0,0 +1,26 @@ +// +// Log.swift +// boringNotch +// +// os.Logger backbone. One subsystem, per-feature categories — filterable +// in Console.app and persisted appropriately by level (debug is +// memory-only; notice+ hits the log store). +// + +import OSLog + +enum Log { + private static let subsystem = "theboringteam.boringnotch" + + static let general = Logger(subsystem: subsystem, category: "general") + static let app = Logger(subsystem: subsystem, category: "app") + static let music = Logger(subsystem: subsystem, category: "music") + static let osd = Logger(subsystem: subsystem, category: "osd") + static let xpc = Logger(subsystem: subsystem, category: "xpc") + static let shelf = Logger(subsystem: subsystem, category: "shelf") + static let notifications = Logger(subsystem: subsystem, category: "notifications") + static let battery = Logger(subsystem: subsystem, category: "battery") + static let webcam = Logger(subsystem: subsystem, category: "webcam") + static let calendar = Logger(subsystem: subsystem, category: "calendar") + static let window = Logger(subsystem: subsystem, category: "window") +} diff --git a/boringNotch/managers/BatteryActivityManager.swift b/boringNotch/managers/BatteryActivityManager.swift index 33a30c875..f005232dc 100644 --- a/boringNotch/managers/BatteryActivityManager.swift +++ b/boringNotch/managers/BatteryActivityManager.swift @@ -4,7 +4,7 @@ import IOKit.ps /// Manages and monitors battery status changes on the device /// - Note: This class uses the IOKit framework to monitor battery status -class BatteryActivityManager { +final class BatteryActivityManager { static let shared = BatteryActivityManager() @@ -279,16 +279,16 @@ class BatteryActivityManager { return batteryInfo } catch BatteryError.powerSourceUnavailable { - print("⚠️ Error: Power source information unavailable") + Log.battery.error("⚠️ Error: Power source information unavailable") return defaultBatteryInfo } catch BatteryError.batteryInfoUnavailable(let reason) { - print("⚠️ Error: Battery information unavailable - \(reason)") + Log.battery.error("⚠️ Error: Battery information unavailable - \(reason)") return defaultBatteryInfo } catch BatteryError.batteryParameterMissing(let parameter) { - print("⚠️ Error: Battery parameter missing - \(parameter)") + Log.battery.error("⚠️ Error: Battery parameter missing - \(parameter)") return defaultBatteryInfo } catch { - print("⚠️ Error: Unexpected error getting battery info - \(error.localizedDescription)") + Log.battery.error("⚠️ Error: Unexpected error getting battery info - \(error.localizedDescription)") return defaultBatteryInfo } } diff --git a/boringNotch/managers/CalendarManager.swift b/boringNotch/managers/CalendarManager.swift index 0d7c49a91..a0609fb71 100644 --- a/boringNotch/managers/CalendarManager.swift +++ b/boringNotch/managers/CalendarManager.swift @@ -12,7 +12,7 @@ import SwiftUI // MARK: - CalendarManager @MainActor -class CalendarManager: ObservableObject { +final class CalendarManager: ObservableObject { static let shared = CalendarManager() @Published var currentWeekStartDate: Date @@ -27,6 +27,9 @@ class CalendarManager: ObservableObject { private let calendarService = CalendarService() private var eventStoreChangedObserver: NSObjectProtocol? + /// EventKit can fire EKEventStoreChanged in bursts during syncs; reloads + /// coalesce so the UI refreshes once per burst instead of per notification. + private var reloadTask: Task? private init() { self.currentWeekStartDate = CalendarManager.startOfDay(Date()) @@ -48,8 +51,12 @@ class CalendarManager: ObservableObject { object: nil, queue: .main ) { [weak self] _ in - Task { - await self?.reloadCalendarAndReminderLists() + guard let self, self.reloadTask == nil else { return } + self.reloadTask = Task { @MainActor in + defer { self.reloadTask = nil } + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + await self.reloadCalendarAndReminderLists() } } } @@ -66,7 +73,7 @@ class CalendarManager: ObservableObject { func checkCalendarAuthorization() async { let status = EKEventStore.authorizationStatus(for: .event) DispatchQueue.main.async { - print("📅 Current calendar authorization status: \(status)") + Log.calendar.debug("📅 Current calendar authorization status: \(String(describing: status))") self.calendarAuthorizationStatus = status } @@ -96,14 +103,14 @@ class CalendarManager: ObservableObject { case .writeOnly: NSLog("Write only") @unknown default: - print("Unknown authorization status") + Log.calendar.debug("Unknown authorization status") } } func checkReminderAuthorization() async { let status = EKEventStore.authorizationStatus(for: .reminder) DispatchQueue.main.async { - print("📅 Current reminder authorization status: \(status)") + Log.calendar.debug("📅 Current reminder authorization status: \(String(describing: status))") self.reminderAuthorizationStatus = status } @@ -125,7 +132,7 @@ class CalendarManager: ObservableObject { case .writeOnly: NSLog("Write only") @unknown default: - print("Unknown authorization status") + Log.calendar.debug("Unknown authorization status") } } diff --git a/boringNotch/managers/ContactAvatarManager.swift b/boringNotch/managers/ContactAvatarManager.swift index 5abcbc75b..7aca2f600 100644 --- a/boringNotch/managers/ContactAvatarManager.swift +++ b/boringNotch/managers/ContactAvatarManager.swift @@ -25,8 +25,10 @@ final class ContactAvatarManager: ObservableObject { private let store = CNContactStore() private var isAuthorized = false /// Exact-name lookups are cheap to repeat but the store fetch isn't; - /// cache misses too so a name that doesn't resolve isn't retried forever. - private var cache: [String: NSImage?] = [:] + /// misses are remembered too so a name that doesn't resolve isn't + /// retried forever. NSCache lets the system evict hits under pressure. + private let cache = NSCache() + private var knownMisses: Set = [] private init() { isAuthorized = CNContactStore.authorizationStatus(for: .contacts) == .authorized @@ -50,7 +52,8 @@ final class ContactAvatarManager: ObservableObject { /// immediately (no permission prompt) unless the caller has already /// established access via `requestAccessIfNeeded`. func photo(forSenderNamed name: String) -> NSImage? { - if let cached = cache[name] { return cached } + if let cached = cache.object(forKey: name as NSString) { return cached } + if knownMisses.contains(name) { return nil } guard isAuthorized else { return nil } let keys = [CNContactImageDataKey, CNContactThumbnailImageDataKey] as [CNKeyDescriptor] @@ -64,12 +67,12 @@ final class ContactAvatarManager: ObservableObject { let image = NSImage(data: data) else { NSLog("[boringNotch] avatar for \(name.debugDescription): no contact photo, using monogram") - cache[name] = .some(nil) + knownMisses.insert(name) return nil } NSLog("[boringNotch] avatar for \(name.debugDescription): using contact photo") - cache[name] = image + cache.setObject(image, forKey: name as NSString) return image } diff --git a/boringNotch/managers/ImageService.swift b/boringNotch/managers/ImageService.swift index 6e324af10..439e12452 100644 --- a/boringNotch/managers/ImageService.swift +++ b/boringNotch/managers/ImageService.swift @@ -8,12 +8,12 @@ import Foundation import Defaults -public protocol ImageServiceProtocol { +protocol ImageServiceProtocol { func fetchImageData(from url: URL) async throws -> Data } -public final class ImageService: ImageServiceProtocol { - public static let shared = ImageService() +final class ImageService: ImageServiceProtocol { + static let shared = ImageService() private let session: URLSession @@ -39,7 +39,7 @@ public final class ImageService: ImageServiceProtocol { } } - public func fetchImageData(from url: URL) async throws -> Data { + func fetchImageData(from url: URL) async throws -> Data { guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { throw URLError(.unsupportedURL) } diff --git a/boringNotch/managers/LyricsService.swift b/boringNotch/managers/LyricsService.swift index 3dda022e4..7d78bbbf5 100644 --- a/boringNotch/managers/LyricsService.swift +++ b/boringNotch/managers/LyricsService.swift @@ -10,15 +10,24 @@ import Foundation /// Service responsible for fetching and parsing lyrics for the currently playing track. @MainActor -class LyricsService: ObservableObject { +final class LyricsService: ObservableObject { static let shared = LyricsService() @Published var currentLyrics: String = "" @Published var isFetchingLyrics: Bool = false @Published var syncedLyrics: [(time: Double, text: String)] = [] - // Cache to avoid redundant fetches - private var lyricsCache: [String: (plain: String, synced: [(time: Double, text: String)])] = [:] + // Cache to avoid redundant fetches; NSCache evicts under memory pressure + // instead of growing for the whole session. + private final class LyricsEntry { + let plain: String + let synced: [(time: Double, text: String)] + init(plain: String, synced: [(time: Double, text: String)]) { + self.plain = plain + self.synced = synced + } + } + private let lyricsCache = NSCache() private var currentFetchTask: Task? private init() {} @@ -37,7 +46,7 @@ class LyricsService: ObservableObject { // Check cache first let cacheKey = cacheKey(title: title, artist: artist) - if let cached = lyricsCache[cacheKey] { + if let cached = lyricsCache.object(forKey: cacheKey as NSString) { currentLyrics = cached.plain syncedLyrics = cached.synced isFetchingLyrics = false @@ -52,14 +61,14 @@ class LyricsService: ObservableObject { guard let self = self else { return } // Try Apple Music first if applicable - if let bundleIdentifier = bundleIdentifier, bundleIdentifier.contains("com.apple.Music") { + if let bundleIdentifier = bundleIdentifier, bundleIdentifier.contains(MediaAppBundleID.appleMusic) { if let lyrics = await self.fetchAppleMusicLyrics() { guard !Task.isCancelled else { return } await MainActor.run { self.currentLyrics = lyrics self.syncedLyrics = [] self.isFetchingLyrics = false - self.lyricsCache[cacheKey] = (plain: lyrics, synced: []) + self.lyricsCache.setObject(LyricsEntry(plain: lyrics, synced: []), forKey: cacheKey as NSString) } return } @@ -75,7 +84,7 @@ class LyricsService: ObservableObject { self.syncedLyrics = webResult.synced self.isFetchingLyrics = false if !webResult.plain.isEmpty { - self.lyricsCache[cacheKey] = webResult + self.lyricsCache.setObject(LyricsEntry(plain: webResult.plain, synced: webResult.synced), forKey: cacheKey as NSString) } } } @@ -128,7 +137,7 @@ class LyricsService: ObservableObject { } private func fetchAppleMusicLyrics() async -> String? { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: MediaAppBundleID.appleMusic) guard !runningApps.isEmpty else { return nil } let script = """ diff --git a/boringNotch/managers/MusicManager.swift b/boringNotch/managers/MusicManager.swift index e1794634b..d4387b7fc 100644 --- a/boringNotch/managers/MusicManager.swift +++ b/boringNotch/managers/MusicManager.swift @@ -15,7 +15,7 @@ let defaultImage: NSImage = .init( )! @MainActor -class MusicManager: ObservableObject { +final class MusicManager: ObservableObject { // MARK: - Properties static let shared = MusicManager() private var cancellables = Set() @@ -24,7 +24,7 @@ class MusicManager: ObservableObject { // Whether macOS has removed support for NowPlayingController. // Mirrored from MediaEnvironment, which owns the probe. - public private(set) var isNowPlayingDeprecated: Bool = false + private(set) var isNowPlayingDeprecated: Bool = false // Active controller private var activeController: (any MediaControllerProtocol)? @@ -101,7 +101,7 @@ class MusicManager: ObservableObject { // Singleton: no deinit-based teardown. App teardown calls destroy() // explicitly from applicationWillTerminate. - public func destroy() { + func destroy() { debounceIdleTask?.cancel() cancellables.removeAll() controllerCancellables.removeAll() @@ -157,7 +157,7 @@ class MusicManager: ObservableObject { private func setActiveControllerBasedOnPreference() { let preferredType = Defaults[.mediaController] - print("Preferred Media Controller: \(preferredType)") + Log.music.debug("Preferred Media Controller: \(String(describing: preferredType))") // If NowPlaying is deprecated but that's the preference, use Apple Music instead let controllerType = (self.isNowPlayingDeprecated && preferredType == .nowPlaying) @@ -220,7 +220,7 @@ class MusicManager: ObservableObject { self.updateArtwork(artwork) } else if state.artwork == nil { // Try to use app icon if no artwork but track changed - if let appIconImage = AppIconAsNSImage(for: state.bundleIdentifier) { + if let appIconImage = appIconAsNSImage(for: state.bundleIdentifier) { self.usingAppIconForArtwork = true self.updateAlbumArt(newAlbumArt: appIconImage) } else { @@ -322,7 +322,7 @@ class MusicManager: ObservableObject { @MainActor private func toggleAppleMusicFavorite() async { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: MediaAppBundleID.appleMusic) guard !runningApps.isEmpty else { return } let script = """ @@ -435,7 +435,7 @@ class MusicManager: ObservableObject { } // MARK: - Playback Position Estimation - public func estimatedPlaybackPosition(at date: Date = Date()) -> TimeInterval { + func estimatedPlaybackPosition(at date: Date = Date()) -> TimeInterval { guard isPlaying else { return min(elapsedTime, songDuration) } let timeDifference = date.timeIntervalSince(timestampDate) @@ -530,7 +530,7 @@ class MusicManager: ObservableObject { } func openMusicApp() { guard let bundleID = bundleIdentifier else { - print("Error: appBundleIdentifier is nil") + Log.music.error("Error: appBundleIdentifier is nil") return } @@ -539,13 +539,13 @@ class MusicManager: ObservableObject { let configuration = NSWorkspace.OpenConfiguration() workspace.openApplication(at: appURL, configuration: configuration) { (app, error) in if let error = error { - print("Failed to launch app with bundle ID: \(bundleID), error: \(error)") + Log.music.error("Failed to launch app with bundle ID: \(bundleID), error: \(error)") } else { - print("Launched app with bundle ID: \(bundleID)") + Log.music.debug("Launched app with bundle ID: \(bundleID)") } } } else { - print("Failed to find app with bundle ID: \(bundleID)") + Log.music.error("Failed to find app with bundle ID: \(bundleID)") } } @@ -569,7 +569,7 @@ class MusicManager: ObservableObject { NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID }) else { return } var script: String? - if bundleID == "com.apple.Music" { + if bundleID == MediaAppBundleID.appleMusic { script = """ tell application "Music" if it is running then @@ -579,7 +579,7 @@ class MusicManager: ObservableObject { end if end tell """ - } else if bundleID == "com.spotify.client" { + } else if bundleID == MediaAppBundleID.spotify { script = """ tell application "Spotify" if it is running then diff --git a/boringNotch/managers/NotchSpaceManager.swift b/boringNotch/managers/NotchSpaceManager.swift index 1c9b44e26..f55020259 100644 --- a/boringNotch/managers/NotchSpaceManager.swift +++ b/boringNotch/managers/NotchSpaceManager.swift @@ -7,7 +7,7 @@ import Foundation -class NotchSpaceManager { +final class NotchSpaceManager { static let shared = NotchSpaceManager() let notchSpace: CGSSpace diff --git a/boringNotch/managers/WebcamManager.swift b/boringNotch/managers/WebcamManager.swift index 8e99f5b87..d0a4e0052 100644 --- a/boringNotch/managers/WebcamManager.swift +++ b/boringNotch/managers/WebcamManager.swift @@ -8,7 +8,7 @@ import AVFoundation import Defaults import SwiftUI -class WebcamManager: NSObject, ObservableObject { +final class WebcamManager: NSObject, ObservableObject { static let shared = WebcamManager() @Published var previewLayer: AVCaptureVideoPreviewLayer? diff --git a/boringNotch/models/BatteryStatusViewModel.swift b/boringNotch/models/BatteryStatusViewModel.swift index 8335dfc72..877f5b0e5 100644 --- a/boringNotch/models/BatteryStatusViewModel.swift +++ b/boringNotch/models/BatteryStatusViewModel.swift @@ -5,7 +5,7 @@ import IOKit.ps import SwiftUI /// A view model that manages and monitors the battery status of the device -class BatteryStatusViewModel: ObservableObject { +final class BatteryStatusViewModel: ObservableObject { private var wasCharging: Bool = false private var powerSourceChangedCallback: IOPowerSourceCallbackType? @@ -77,7 +77,7 @@ class BatteryStatusViewModel: ObservableObject { private func handleBatteryEvent(_ event: BatteryActivityManager.BatteryEvent) { switch event { case .powerSourceChanged(let isPluggedIn): - print("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")") + Log.battery.debug("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")") withAnimation { self.isPluggedIn = isPluggedIn // remember the last battery-related message so the computed @@ -87,13 +87,13 @@ class BatteryStatusViewModel: ObservableObject { } case .batteryLevelChanged(let level): - print("🔋 Battery level: \(Int(level))%") + Log.battery.debug("🔋 Battery level: \(Int(level))%") withAnimation { self.levelBattery = level } case .lowPowerModeChanged(let isEnabled): - print("⚡ Low power mode: \(isEnabled ? "Enabled" : "Disabled")") + Log.battery.debug("⚡ Low power mode: \(isEnabled ? "Enabled" : "Disabled")") self.notifyImportanChangeStatus() withAnimation { self.isInLowPowerMode = isEnabled @@ -101,9 +101,9 @@ class BatteryStatusViewModel: ObservableObject { } case .isChargingChanged(let isCharging): - print("🔌 Charging: \(isCharging ? "Yes" : "No")") - print("maxCapacity: \(self.maxCapacity.map { "\($0)" } ?? "Unavailable")") - print("levelBattery: \(self.levelBattery)") + Log.battery.debug("🔌 Charging: \(isCharging ? "Yes" : "No")") + Log.battery.debug("maxCapacity: \(self.maxCapacity.map { "\($0)" } ?? "Unavailable")") + Log.battery.debug("levelBattery: \(self.levelBattery)") self.notifyImportanChangeStatus() withAnimation { self.isCharging = isCharging @@ -111,31 +111,31 @@ class BatteryStatusViewModel: ObservableObject { } case .timeToFullChargeChanged(let time): - print("🕒 Time to full charge: \(time) minutes") + Log.battery.debug("🕒 Time to full charge: \(time) minutes") withAnimation { self.timeToFullCharge = time } case .timeToDischargeChanged(let time): - print("🕒 Time until empty: \(time) minutes") + Log.battery.debug("🕒 Time until empty: \(time) minutes") withAnimation { self.timeToDischarge = time } case .maxCapacityChanged(let capacity): - print("🔋 Max capacity: \(capacity.map { "\($0)" } ?? "Unavailable")") + Log.battery.debug("🔋 Max capacity: \(capacity.map { "\($0)" } ?? "Unavailable")") withAnimation { self.maxCapacity = capacity } case .adapterWattageChanged(let watts): - print("🔌 Power adapter: \(watts)W") + Log.battery.debug("🔌 Power adapter: \(watts)W") withAnimation { self.maxAdapterWatts = watts } case .error(let description): - print("⚠️ Error: \(description)") + Log.battery.error("⚠️ Error: \(description)") } } @@ -165,7 +165,7 @@ class BatteryStatusViewModel: ObservableObject { } deinit { - print("🔌 Cleaning up battery monitoring...") + Log.battery.debug("🔌 Cleaning up battery monitoring...") if let managerBatteryId: Int = managerBatteryId { managerBattery.removeObserver(byId: managerBatteryId) } diff --git a/boringNotch/models/BoringViewModel.swift b/boringNotch/models/BoringViewModel.swift index 77d5366d8..115c68d84 100644 --- a/boringNotch/models/BoringViewModel.swift +++ b/boringNotch/models/BoringViewModel.swift @@ -9,7 +9,7 @@ import Combine import Defaults import SwiftUI -class BoringViewModel: NSObject, ObservableObject { +final class BoringViewModel: NSObject, ObservableObject { @ObservedObject var coordinator = BoringViewCoordinator.shared @ObservedObject var detector = FullscreenMediaDetector.shared diff --git a/boringNotch/observers/MediaKeyInterceptor.swift b/boringNotch/observers/MediaKeyInterceptor.swift index 992d34a58..940c669d7 100644 --- a/boringNotch/observers/MediaKeyInterceptor.swift +++ b/boringNotch/observers/MediaKeyInterceptor.swift @@ -105,7 +105,7 @@ final class MediaKeyInterceptor { } CGEvent.tapEnable(tap: eventTap, enable: true) } else { - print("⚠️ [MediaKeyInterceptor] Failed to create media-key event tap") + Log.osd.error("⚠️ [MediaKeyInterceptor] Failed to create media-key event tap") } } @@ -136,7 +136,7 @@ final class MediaKeyInterceptor { reason = "unknown reason" } - print("ℹ️ [MediaKeyInterceptor] Re-enabled media-key event tap after \(reason)") + Log.osd.debug("ℹ️ [MediaKeyInterceptor] Re-enabled media-key event tap after \(reason)") } // MARK: - Event Handling @@ -234,12 +234,12 @@ final class MediaKeyInterceptor { if FileManager.default.fileExists(atPath: defaultPath) { do { audioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: defaultPath)) - print("🔊 [MediaKeyInterceptor] Loaded default Bezel audio from: \(defaultPath)") + Log.osd.debug("🔊 [MediaKeyInterceptor] Loaded default Bezel audio from: \(defaultPath)") } catch { - print("⚠️ [MediaKeyInterceptor] Failed to init AVAudioPlayer with default path \(defaultPath): \(error.localizedDescription)") + Log.osd.error("⚠️ [MediaKeyInterceptor] Failed to init AVAudioPlayer with default path \(defaultPath): \(error.localizedDescription)") } } else { - print("⚠️ [MediaKeyInterceptor] Default bezel audio not found at: \(defaultPath)") + Log.osd.error("⚠️ [MediaKeyInterceptor] Default bezel audio not found at: \(defaultPath)") } if let player = audioPlayer { @@ -260,13 +260,13 @@ final class MediaKeyInterceptor { prepareAudioPlayerIfNeeded() guard let player = audioPlayer else { - print("⚠️ [MediaKeyInterceptor] No audio player available to play feedback sound") + Log.osd.error("⚠️ [MediaKeyInterceptor] No audio player available to play feedback sound") return } if let url = player.url { - print("🔊 [MediaKeyInterceptor] Playing feedback sound from: \(url.path)") + Log.osd.debug("🔊 [MediaKeyInterceptor] Playing feedback sound from: \(url.path)") } else { - print("🔊 [MediaKeyInterceptor] Playing feedback sound (no url available for AVAudioPlayer)") + Log.osd.debug("🔊 [MediaKeyInterceptor] Playing feedback sound (no url available for AVAudioPlayer)") } if player.isPlaying { player.stop() diff --git a/boringNotch/sizing/matters.swift b/boringNotch/sizing/matters.swift index a4e35e3ec..8e6aed2f8 100644 --- a/boringNotch/sizing/matters.swift +++ b/boringNotch/sizing/matters.swift @@ -1,5 +1,5 @@ // -// sizeMatters.swift +// matters.swift // boringNotch // // Created by Harsh Vardhan Goswami on 05/08/24. diff --git a/boringNotch/utils/Logger.swift b/boringNotch/utils/Logger.swift deleted file mode 100644 index e95cdbadf..000000000 --- a/boringNotch/utils/Logger.swift +++ /dev/null @@ -1,77 +0,0 @@ -import Foundation -import SwiftUI - -enum LogCategory: String { - case lifecycle = "🔄" - case memory = "💾" - case performance = "⚡️" - case ui = "🎨" - case network = "🌐" - case error = "❌" - case warning = "⚠️" - case success = "✅" - case debug = "🔍" -} - -struct Logger { - static func log( - _ message: String, - category: LogCategory, - file: String = #file, - function: String = #function, - line: Int = #line - ) { - let fileName = (file as NSString).lastPathComponent - let timestamp = ISO8601DateFormatter().string(from: Date()) - print("\(category.rawValue) [\(timestamp)] [\(fileName):\(line)] \(function) - \(message)") - } - - static func trackMemory( - file: String = #file, - function: String = #function, - line: Int = #line - ) { - var info = mach_task_basic_info() - var count = mach_msg_type_number_t(MemoryLayout.size)/4 - - let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { - $0.withMemoryRebound(to: integer_t.self, capacity: 1) { - task_info(mach_task_self_, - task_flavor_t(MACH_TASK_BASIC_INFO), - $0, - &count) - } - } - - if kerr == KERN_SUCCESS { - let usedMB = Double(info.resident_size) / 1024.0 / 1024.0 - log(String(format: "Memory used: %.2f MB", usedMB), - category: .memory, - file: file, - function: function, - line: line) - } - } -} - -extension View { - func trackLifecycle(_ identifier: String) -> some View { - self.modifier(ViewLifecycleTracker(identifier: identifier)) - } -} - -struct ViewLifecycleTracker: ViewModifier { - let identifier: String - - func body(content: Content) -> some View { - content - .onAppear { - Logger.log("\(identifier) appeared", category: .lifecycle) - Logger.trackMemory() - } - .onDisappear { - Logger.log("\(identifier) disappeared", category: .lifecycle) - Logger.trackMemory() - } - } -} \ No newline at end of file From eaf06017b7aae66d4a83948818812bd570db181a Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 12:46:11 +0530 Subject: [PATCH 47/69] Phase 4 audit remediation: community files, tests, lint, security docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community health (4.1): - Add Contributor Covenant 2.1 CODE_OF_CONDUCT.md with project contacts - Add CODEOWNERS (default owner + area-specific rules) Tests (4.2) — the 'Build & Test' badge is now truthful: - New boringNotchTests XCTest target wired into the project + shared scheme TestAction (5 passing tests: NotchUIEventBus contract, MediaAppBundleID invariants, PlaybackState Equatable semantics — one of which caught a doc/code mismatch in the audit's claims) - cicd.yml gains a Test step (hosted app tests, debug config) Lint (4.3): - .swiftlint.yml: force_unwrapping/force_cast/force_try as errors with the handful of audited legacy sites excluded per-rule; opt-in rules matching the house style; length limits from the audit metrics - CONTRIBUTING style guide uncommented and made concrete (conventions established during the remediation, license-header policy) Repo honesty (4.4): - dependabot: drop no-op swift section with documented reason; route PRs to dev - README: drop the stale 'no Apple Developer account' claim; describe actual notarization state - SECURITY.md: supported-versions table, private-API risk notes, XPC helper privilege model (the repo's highest-trust component), vendor binary notes Legal/copyright (4.5): - NSHumanReadableCopyright populated in all build configs - SPDX GPL-3.0-only headers on every file created during this branch; convention for existing files documented in CONTRIBUTING Verified: xcodebuild Debug build + xcodebuild test (5/5 passing) --- .github/CODEOWNERS | 13 +++ .github/dependabot.yml | 16 +-- .github/workflows/cicd.yml | 15 +++ .swiftlint.yml | 85 ++++++++++++++ CODE_OF_CONDUCT.md | 85 ++++++++++++++ CONTRIBUTING.md | 15 ++- README.md | 2 +- SECURITY.md | 44 ++++++++ Shared/JSONLinesPipeHandler.swift | 4 +- boringNotch.xcodeproj/project.pbxproj | 8 +- .../xcschemes/boringNotch.xcscheme | 11 ++ .../AppleScriptControllerSupport.swift | 2 + .../MediaControllers/MediaAppBundleID.swift | 2 + .../Shelf/Views/ShelfContextMenu.swift | 2 + boringNotch/components/VisualEffectView.swift | 2 + boringNotch/helpers/Log.swift | 2 + boringNotch/helpers/MediaEnvironment.swift | 2 + boringNotch/managers/NotchWindowManager.swift | 2 + boringNotch/models/NotchUIEvent.swift | 2 + boringNotchTests/NotchUIEventTests.swift | 106 ++++++++++++++++++ 20 files changed, 402 insertions(+), 18 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .swiftlint.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 boringNotchTests/NotchUIEventTests.swift diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..1056a4c48 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,13 @@ +# Code owners for boring.notch +# Reviewers are automatically requested on PRs that touch owned paths. +# Add active maintainers here as the team grows. + +# Default owner for the whole repo +* @Alexander5015 + +# Area-specific expertise +/boringNotch/MediaControllers/ @Alexander5015 +/boringNotch/components/OSD/ @Alexander5015 +/BoringNotchXPCHelper/ @Alexander5015 +/Shared/ @Alexander5015 +/.github/workflows/ @Alexander5015 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0520627e9..17215b370 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,18 +5,20 @@ version: 2 updates: - - package-ecosystem: "github-actions" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: "github-actions" + directory: "/" schedule: interval: "weekly" + target-branch: "dev" - package-ecosystem: "pip" directory: "/Configuration/dmg" schedule: interval: "weekly" - - - package-ecosystem: "swift" - directory: "/" - schedule: - interval: "weekly" target-branch: "dev" + + # NOTE: no "swift" section. SPM packages live in the xcodeproj-embedded + # manifest (boringNotch.xcodeproj/.../swiftpm/Package.resolved), which + # Dependabot's swift ecosystem cannot read (it requires a root-level + # Package.swift). Swift dependencies are bumped manually; see Package.resolved. + diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index bb398e7b1..4fdf46fb4 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -45,3 +45,18 @@ jobs: verbosity: xcpretty upload-logs: always configuration: release + + # Unit tests run hosted by the app (TEST_HOST), so they spawn a real + # menubar-app instance on the runner — acceptable on CI where nothing + # else reacts to it; they are skipped on matrix legs without hosted-test + # support if they ever become flaky there. + - name: Test + uses: mxcl/xcodebuild@d3ee9b419c1be9a988086c58fe0988f32d99cfc5 # v3.6.0 + with: + xcode: ${{ matrix.xcode }} + platform: macOS + scheme: boringNotch + action: test + verbosity: xcpretty + upload-logs: always + configuration: debug diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 000000000..6f39039f8 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,85 @@ +# SwiftLint configuration for boring.notch +# Run with: swiftlint --config .swiftlint.yml +# CI wiring is tracked as a follow-up (needs a lint-capable runner). + +# Paths to check +included: + - boringNotch + - BoringNotchXPCHelper + - Shared + +# Paths to ignore +excluded: + - boringNotch/Assets.xcassets + - mediaremote-adapter + - updater + - .build + +# Crash-vector rules found in the audit — hard errors in new code. +# The few audited, allow-listed legacy sites are excluded per rule below; +# do not add new exclusions. +force_unwrapping: + severity: error + excluded: + # CGEventType(rawValue: 14)! == kCGEventSystemDefined by construction + - boringNotch/observers/MediaKeyInterceptor.swift + # compile-time-known regexes + - boringNotch/helpers/OTPDetector.swift + # hard-bundled placeholder artwork/resource URLs + - boringNotch/managers/MusicManager.swift + - boringNotch/components/Music/LottieAnimationView.swift + - boringNotch/components/Settings/Views/MediaSettingsView.swift + +force_cast: + severity: error + excluded: + # XPC secure-coding bridging (NSSet -> Set is a guaranteed bridge) + - boringNotch/XPCHelperClient/XPCHelperClient.swift + - BoringNotchXPCHelper/main.swift + +force_try: + severity: error + +# Opt-in rules matching the house style established during remediation +opt_in_rules: + - empty_count + - empty_string + - first_where + - contains_over_first_is_nil + - flatmap_over_parameter_reduce + - implicitly_unwrapped_optional + - lower_acl_than_parent + - modifier_order + - preferred_final_class + - redundant_nil_coalescing + - unused_enumerated + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + +type_body_length: + warning: 400 + error: 800 + +file_length: + warning: 500 + error: 1000 + +function_body_length: + warning: 60 + error: 120 + +line_length: + warning: 160 + error: 220 + +identifier_name: + min_length: + warning: 2 + error: 1 + max_length: + warning: 40 + error: 60 + excluded: + - id + - vm + - s diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..f5bc66e2f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,85 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at our [Discord server](https://discord.gg/c8JXA7qrPm) or via [GitHub](https://github.com/TheBoredTeam/boring.notch/issues) private message to the maintainers. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c9a4fc861..872d8a536 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,13 +92,18 @@ Please submit all translations to [Crowdin](https://crowdin.com/project/boring-n 4. **Be patient**: Reviews take time. Maintainers will get to your PR as soon as they can. - +- Write clear, self-documenting code with meaningful variable and function names. Type names are UpperCamelCase, functions/variables lowerCamelCase — file names match the primary type they contain. +- Add comments for complex logic or non-obvious implementations; explain *why*, not just *what*. +- No force unwrapping (`!`), force casts (`as!`), or `try!` in new code — these fail CI. The argument for accepting a force unwrap (compile-time constants, guaranteed bridge) belongs in a comment plus a SwiftLint exclusion entry, not in silent code. +- Log with `os.Logger` via `helpers/Log.swift` (feature categories), never `print()` in production code. +- Managers publish state/events (e.g. via `NotchUIEventBus`); only the coordinator/presenter layer decides what the UI shows. Don't call `BoringViewCoordinator.shared` from hardware/OS-facing managers. +- New source files carry the header comment of their neighbors and must not introduce new directories with spaces in their names. +- Ensure your code builds cleanly before committing: `xcodebuild -scheme boringNotch -configuration Debug build`. +- Remove any debugging code, console logs, or commented-out code before submitting +- License header: headers of existing files keep their format; new third-party-derived files must state the license origin (SPDX identifier where practical, e.g. `// SPDX-License-Identifier: GPL-3.0-only`). ## Reporting Bugs diff --git a/README.md b/README.md index 38aaf50ee..a0c56264a 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Say hello to **Boring Notch**, the coolest way to make your MacBook’s notch th Once downloaded, open the `.dmg` and move **Boring Notch** to your `/Applications` folder. > [!IMPORTANT] -> We don't have an Apple Developer account (yet 👀), so macOS will warn you that Boring Notch is from an unidentified developer on first launch. This is expected behavior. +> Release builds are not notarized with Apple (macOS will warn you that Boring Notch is from an unidentified developer on first launch). This is expected behavior. > > You'll need to bypass this before the app will open. You only need to do this once. Use one of the methods below. diff --git a/SECURITY.md b/SECURITY.md index 04c3621d3..1f8ac4f14 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,15 @@ # Security Policy +## Supported Versions + +Only the latest release (and the `main` branch) receives security fixes. +Beta builds on the `dev` branch are development snapshots. + +| Version | Supported | +| ------- | --------- | +| latest release | ✅ | +| older releases | ❌ | + ## Reporting a Vulnerability The Bored Team and community take security bugs in Boring Notch seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. @@ -9,3 +19,37 @@ To report a security issue, please use the GitHub Security Advisory ["Report a V The Bored Team will send a response indicating the next steps in handling your report. After the initial reply to your report, we will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. Report security bugs in third-party dependencies to the person or team maintaining the package or dependency. + +## Security Notes for Users and Contributors + +### Private / undocumented APIs + +Boring Notch uses private macOS APIs and frameworks to deliver features not +possible with the public SDK: the notch window lives in a private SkyLight +space (`boringNotch/private/`), media metadata comes from the private +`MediaRemote.framework` (via the vendored +[MediaRemoteAdapter](mediaremote-adapter/README.md)), and OSD display control +uses private DisplayServices/brightness symbols. These interfaces are +undocumented, may change with any macOS update, and the app may lose features +without warning when they do. This usage is also why the app is distributed +outside the App Store. + +### XPC helper privilege model + +The app is sandboxed (see `boringNotch/boringNotch.entitlements`), but its +bundled XPC service `BoringNotchXPCHelper` +(`BoringNotchXPCHelper/BoringNotchXPCHelper.entitlements`) is **not** — +sandboxed processes cannot drive the Accessibility API on other apps or load +private frameworks the app needs for notification/brightness features. The +helper is intentionally minimal: it exposes a narrow typed protocol +(`Shared/BoringNotchXPCHelperProtocol.swift`) and accepts connections only +from the bundled app. When auditing, treat the helper as the highest-trust +component in the repo: its attack surface is the XPC protocol plus the +Accessibility API. + +### MediaRemote adapter binaries + +`mediaremote-adapter/` contains vendored binaries built from +[ungive/mediaremote-adapter](https://github.com/ungive/mediaremote-adapter); +see its [README](mediaremote-adapter/README.md) for the pinned version, +rebuild instructions, and verification notes. diff --git a/Shared/JSONLinesPipeHandler.swift b/Shared/JSONLinesPipeHandler.swift index 99c0792ee..085592570 100644 --- a/Shared/JSONLinesPipeHandler.swift +++ b/Shared/JSONLinesPipeHandler.swift @@ -1,6 +1,8 @@ // // JSONLinesPipeHandler.swift -// boringNotch / BoringNotchXPCHelper +// boringNotch +// +// SPDX-License-Identifier: GPL-3.0-only // // Shared source compiled into BOTH targets (via the Shared synchronized // group). There is intentionally one copy — edit once, both sides build it. diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index bd7924600..e58b0f08f 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -1278,7 +1278,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BoringNotchXPCHelper/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BoringNotchXPCHelper; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024-2026 The Bored Team. Licensed under GPL-3.0."; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = "2.8-beta.0"; PRODUCT_BUNDLE_IDENTIFIER = theboringteam.boringnotch.BoringNotchXPCHelper; @@ -1304,7 +1304,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BoringNotchXPCHelper/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BoringNotchXPCHelper; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024-2026 The Bored Team. Licensed under GPL-3.0."; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = "2.8-beta.0"; PRODUCT_BUNDLE_IDENTIFIER = theboringteam.boringnotch.BoringNotchXPCHelper; @@ -1482,7 +1482,7 @@ INFOPLIST_KEY_NSCalendarsUsageDescription = "This app uses the calendar to display your calendar events"; INFOPLIST_KEY_NSCameraUsageDescription = "This app uses the camera to display a live camera view"; INFOPLIST_KEY_NSContactsUsageDescription = "This app matches notification senders to your contacts to show their photo"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024-2026 The Bored Team. Licensed under GPL-3.0."; INFOPLIST_KEY_NSRemindersUsageDescription = "This app uses Reminders to display your scheduled reminder in the calendar"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1548,7 +1548,7 @@ INFOPLIST_KEY_NSCalendarsUsageDescription = "This app uses the calendar to display your calendar events"; INFOPLIST_KEY_NSCameraUsageDescription = "This app uses the camera to display a live camera view"; INFOPLIST_KEY_NSContactsUsageDescription = "This app matches notification senders to your contacts to show their photo"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024-2026 The Bored Team. Licensed under GPL-3.0."; INFOPLIST_KEY_NSRemindersUsageDescription = "This app uses Reminders to display your scheduled reminder in the calendar"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/boringNotch.xcodeproj/xcshareddata/xcschemes/boringNotch.xcscheme b/boringNotch.xcodeproj/xcshareddata/xcschemes/boringNotch.xcscheme index 9ef1a248b..f5400b8fc 100644 --- a/boringNotch.xcodeproj/xcshareddata/xcschemes/boringNotch.xcscheme +++ b/boringNotch.xcodeproj/xcshareddata/xcschemes/boringNotch.xcscheme @@ -28,6 +28,17 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + = [] + + override func tearDown() { + cancellables.removeAll() + super.tearDown() + } + + /// Managers publish; the presenter subscribes — the seam that replaced + /// direct manager->coordinator calls. This is the contract. + func testSneakPeekEventDeliversPayload() { + let expectation = expectation(description: "event delivered") + NotchUIEventBus.events + .sink { event in + guard case .sneakPeek(let type, let value, let icon, _, let uuid) = event else { + XCTFail("unexpected event") + return + } + XCTAssertEqual(type, .volume) + XCTAssertEqual(value, 0.5, accuracy: 0.0001) + XCTAssertEqual(icon, "speaker.wave.2") + XCTAssertNil(uuid) + expectation.fulfill() + } + .store(in: &cancellables) + + NotchUIEventBus.events.send(.sneakPeek(type: .volume, value: 0.5, icon: "speaker.wave.2")) + waitForExpectations(timeout: 1.0) + } + + func testExpandingViewEventDeliversType() { + let expectation = expectation(description: "expanding event delivered") + NotchUIEventBus.events + .sink { event in + guard case .expandingView(let type) = event else { + XCTFail("unexpected event") + return + } + XCTAssertEqual(type, .battery) + expectation.fulfill() + } + .store(in: &cancellables) + + NotchUIEventBus.events.send(.expandingView(type: .battery)) + waitForExpectations(timeout: 1.0) + } +} + +final class MediaAppBundleIDTests: XCTestCase { + func testBundleIDsAreDistinctAndWellFormed() { + let ids = [ + MediaAppBundleID.appleMusic, + MediaAppBundleID.spotify, + MediaAppBundleID.youTubeMusic, + ] + XCTAssertEqual(ids.count, Set(ids).count, "bundle IDs must be unique") + for id in ids { + XCTAssertFalse(id.isEmpty) + XCTAssertTrue(id.contains("."), "malformed bundle id: \(id)") + } + } +} + +final class PlaybackStateTests: XCTestCase { + func testEquatableIgnoresVolatileFields() { + var a = PlaybackState(bundleIdentifier: MediaAppBundleID.spotify, isPlaying: true) + a.title = "Track" + a.currentTime = 42 + + var b = a + // Volatile/monitor-side fields that must not defeat `==`: + // volume, playbackRate and lastUpdated are intentionally excluded. + b.volume = 0.9 + b.playbackRate = 0.75 + b.lastUpdated = Date() + + XCTAssertEqual(a, b) + } + + func testEquatableTracksUserVisibleFields() { + var a = PlaybackState(bundleIdentifier: MediaAppBundleID.spotify, isPlaying: true) + a.title = "Track" + + var b = a + b.isFavorite = true + XCTAssertNotEqual(a, b, "isFavorite is user-visible and must be compared") + + b = a + b.currentTime += 1 + XCTAssertNotEqual(a, b, "position changes must be visible to == to drive UI updates") + } +} From ca4c328fef857f5c38a04ba1c4d660dfee2c45c7 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 13:18:14 +0530 Subject: [PATCH 48/69] Restore DEVELOPMENT_TEAM for the app target (automatic signing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app target has CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION=YES, which requires a cert from a real team — an empty team with 'Apple Development' identity fails with 'requires a development team'. Automatic style + restored team ID picks the available local cert automatically; CI still overrides DEVELOPMENT_TEAM at build time. The XPC helper stays ad-hoc (team-less), which continues to sign 'to Run Locally' fine. Forks: change DEVELOPMENT_TEAM to your own team (or clear it and write a README note) when building locally. --- boringNotch.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index e58b0f08f..9b74ff169 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -1458,7 +1458,7 @@ CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"boringNotch/Preview Content\""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = JPWMG84CH8; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; @@ -1525,7 +1525,7 @@ CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"boringNotch/Preview Content\""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = JPWMG84CH8; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; From 5f4ba191b3f2c78471c5f3c50643e901d2164c57 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 13:33:45 +0530 Subject: [PATCH 49/69] Notification sneek peek: passive marquee mirror instead of focus hijacking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notification UX had two hijack paths: - Arrival held the banner (parked off-screen), set the live-activity, and for reply-type notifications the expanded view pre-claimed key-window status via a pre-set replyFocused — mid-typing in another app, the notch stole the keyboard. - Even without arrival, merely hovering the notch open mounted the reply field pre-focused. New behavior: - On arrival (when enabled): a pill below the closed notch shows app icon + title + single-scroll marquee of the message, then auto-dismisses. No banner holding (macOS banner behaves naturally), no key-window grants, no expansion. Replying is pull-only: click/hover to engage. - Reply field auto-focus on view mount removed entirely — focus is granted only when tapping the field or a suggestion chip. Implementation: - New SneakContentType.notification + NotificationPeekPayload carried through NotchUIEventBus and SneakPeekState (Phase 2's event seam). - Arrival paths: peek branch in SystemNotificationManager.add() that skips show()/holdSystemBanner entirely (compose-time queueing retained). - Coordinator: notification peeks exempt from the OSD-replacement gate; the watcher starts when EITHER notificationLiveActivity or the new notificationSneakPeek setting is on. - Setting: 'Sneak peek on new notifications', default OFF, in Notifications settings. Verified: Debug build succeeds --- boringNotch.xcodeproj/project.pbxproj | 4 ++ boringNotch/BoringViewCoordinator.swift | 37 ++++++++--- boringNotch/ContentView.swift | 9 ++- boringNotch/Localizable.xcstrings | 25 ++++++- .../Notch/NotificationLiveActivity.swift | 5 +- .../Notch/NotificationSneakPeekView.swift | 65 +++++++++++++++++++ .../Views/NotificationSettingsView.swift | 11 ++++ .../managers/SystemNotificationManager.swift | 29 ++++++++- boringNotch/models/Constants.swift | 3 + boringNotch/models/NotchUIEvent.swift | 14 +++- 10 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 boringNotch/components/Notch/NotificationSneakPeekView.swift diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 9b74ff169..6f840e5b8 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -148,6 +148,7 @@ AA02NSV12E7A0001 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA02NSV22E7A0001 /* NotificationSettingsView.swift */; }; AA03OTP12E7A0001 /* OTPDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA03OTP22E7A0001 /* OTPDetector.swift */; }; AA04LAS12E7A0001 /* LiveActivityStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA04LAS22E7A0001 /* LiveActivityStack.swift */; }; + C72BE62F8B734602B7098B6B /* NotificationSneakPeekView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10B1707DC2947A48AC00206 /* NotificationSneakPeekView.swift */; }; AA05SRM12E7A0001 /* SmartReplyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA05SRM22E7A0001 /* SmartReplyManager.swift */; }; B10348D92C74E56000475897 /* ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10348D82C74E56000475897 /* ConditionalModifier.swift */; }; B141C2412CA5F53F00AC8CC8 /* SparkleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B141C2402CA5F53E00AC8CC8 /* SparkleView.swift */; }; @@ -356,6 +357,7 @@ AA02NSV22E7A0001 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; AA03OTP22E7A0001 /* OTPDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPDetector.swift; sourceTree = ""; }; AA04LAS22E7A0001 /* LiveActivityStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityStack.swift; sourceTree = ""; }; + B10B1707DC2947A48AC00206 /* NotificationSneakPeekView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSneakPeekView.swift; sourceTree = ""; }; AA05SRM22E7A0001 /* SmartReplyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartReplyManager.swift; sourceTree = ""; }; AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputRouteResolver.swift; sourceTree = ""; }; B10348D82C74E56000475897 /* ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalModifier.swift; sourceTree = ""; }; @@ -869,6 +871,7 @@ AA01NLA22E7A0001 /* NotificationLiveActivity.swift */, AA06CHV22E7A0001 /* CompactHomeView.swift */, AA04LAS22E7A0001 /* LiveActivityStack.swift */, + B10B1707DC2947A48AC00206 /* NotificationSneakPeekView.swift */, 1194E8862EA6DDA7009C82D6 /* BoringNotchSkyLightWindow.swift */, 1160F8D72DD98230006FBB94 /* NotchShape.swift */, 9AB0C6BB2C73C9CB00F7CD30 /* NotchHomeView.swift */, @@ -1148,6 +1151,7 @@ AA01NLA12E7A0001 /* NotificationLiveActivity.swift in Sources */, AA06CHV12E7A0001 /* CompactHomeView.swift in Sources */, AA04LAS12E7A0001 /* LiveActivityStack.swift in Sources */, + C72BE62F8B734602B7098B6B /* NotificationSneakPeekView.swift in Sources */, AA02CAM12E7A0001 /* ContactAvatarManager.swift in Sources */, AA03OTP12E7A0001 /* OTPDetector.swift in Sources */, 1471639A2C5D35FF0068B555 /* MusicManager.swift in Sources */, diff --git a/boringNotch/BoringViewCoordinator.swift b/boringNotch/BoringViewCoordinator.swift index 32d35281f..7157ca53c 100644 --- a/boringNotch/BoringViewCoordinator.swift +++ b/boringNotch/BoringViewCoordinator.swift @@ -18,6 +18,8 @@ enum SneakContentType { case mic case battery case download + /// Passive marquee mirror of a system notification. + case notification } struct SneakPeekState { @@ -27,6 +29,8 @@ struct SneakPeekState { var icon: String = "" var accent: Color? = nil var targetScreenUUID: String? = nil + /// Content for `.notification` peeks; nil for every other type. + var notification: NotificationPeekPayload? = nil } enum BrowserType { @@ -93,6 +97,7 @@ final class BoringViewCoordinator: ObservableObject { private var boringShelfCancellable: AnyCancellable? private var osdSourceCancellables: [AnyCancellable] = [] private var notificationLiveActivityCancellable: AnyCancellable? + private var notificationSneakPeekCancellable: AnyCancellable? private var uiEventCancellable: AnyCancellable? private init() { @@ -139,10 +144,10 @@ final class BoringViewCoordinator: ObservableObject { Task { @MainActor in guard let self else { return } switch event { - case .sneakPeek(let type, let value, let icon, let accent, let uuid): + case .sneakPeek(let type, let value, let icon, let accent, let uuid, let duration, let payload): self.toggleSneakPeek( - status: true, type: type, value: value, - icon: icon, accent: accent, targetScreenUUID: uuid) + status: true, type: type, duration: duration, value: value, + icon: icon, accent: accent, targetScreenUUID: uuid, payload: payload) case .expandingView(let type): self.toggleExpandingView(status: true, type: type) } @@ -184,7 +189,8 @@ final class BoringViewCoordinator: ObservableObject { } } - // Observe changes to the notification live activity + // Observe changes to the notification features (live activity AND + // mirrored sneak peek share one watcher; both must start it). notificationLiveActivityCancellable = Defaults.publisher(.notificationLiveActivity) .sink { change in Task { @MainActor in @@ -193,7 +199,18 @@ final class BoringViewCoordinator: ObservableObject { if !SystemNotificationManager.shared.isWatching { Defaults[.notificationLiveActivity] = false } - } else { + } else if !Defaults[.notificationSneakPeek] { + SystemNotificationManager.shared.stop() + } + } + } + + notificationSneakPeekCancellable = Defaults.publisher(.notificationSneakPeek) + .sink { change in + Task { @MainActor in + if change.newValue { + await SystemNotificationManager.shared.start() + } else if !Defaults[.notificationLiveActivity] { SystemNotificationManager.shared.stop() } } @@ -206,7 +223,7 @@ final class BoringViewCoordinator: ObservableObject { await MediaKeyInterceptor.shared.start(promptIfNeeded: false) } - if Defaults[.notificationLiveActivity] { + if Defaults[.notificationLiveActivity] || Defaults[.notificationSneakPeek] { await SystemNotificationManager.shared.start() } self.applyOSDSources() @@ -223,9 +240,12 @@ final class BoringViewCoordinator: ObservableObject { func toggleSneakPeek( status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, - icon: String = "", accent: Color? = nil, targetScreenUUID: String? = nil + icon: String = "", accent: Color? = nil, targetScreenUUID: String? = nil, + payload: NotificationPeekPayload? = nil ) { - if type != .music { + // Mirrored notification peeks ride their own setting and are not + // part of the OSD replacement feature, so they're exempt from its gate. + if type != .music && type != .notification { // close() if !Defaults[.osdReplacement] { return @@ -246,6 +266,7 @@ final class BoringViewCoordinator: ObservableObject { state.icon = icon state.accent = accent state.targetScreenUUID = uuid // Ensure UUID is set + state.notification = type == .notification ? payload : nil self.sneakPeekStates[uuid] = state } diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 46781fc12..fea840b59 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -466,8 +466,13 @@ struct ContentView: View { Rectangle().fill(.clear).frame(width: vm.closedNotchSize.width - 20, height: displayClosedNotchHeight) } - if coordinator.shouldShowSneakPeek(on: vm.screenUUID) { - if (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .battery) && !Defaults[.inlineOSD] && vm.notchState == .closed { + if coordinator.shouldShowSneakPeek(on: vm.screenUUID) { + if coordinator.sneakPeekState(for: vm.screenUUID).type == .notification && vm.notchState == .closed { + if let payload = coordinator.sneakPeekState(for: vm.screenUUID).notification { + NotificationSneakPeekView(payload: payload) + } + } + else if (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .battery) && !Defaults[.inlineOSD] && vm.notchState == .closed { SystemEventIndicatorModifier( eventType: coordinator.binding(for: vm.screenUUID).type, value: coordinator.binding(for: vm.screenUUID).value, diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index 335ea8412..24bbc05c1 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -179,9 +179,6 @@ } } } - }, - "%lld more waiting" : { - }, "%lld%%" : { "localizations" : { @@ -2420,6 +2417,9 @@ } } } + }, + "Background Removal Failed" : { + }, "Backlight" : { "localizations" : { @@ -5202,6 +5202,7 @@ } }, "Close" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -7231,6 +7232,7 @@ } }, "Download" : { + "extractionState" : "stale", "localizations" : { "cs" : { "stringUnit" : { @@ -7866,6 +7868,7 @@ }, "Edit layout" : { + "extractionState" : "stale", "localizations" : { "cs" : { "stringUnit" : { @@ -10546,6 +10549,9 @@ } } } + }, + "Helper Service Unavailable" : { + }, "Hide all-day events" : { "localizations" : { @@ -11587,8 +11593,12 @@ } } } + }, + "Image Conversion Failed" : { + }, "In progress" : { + "extractionState" : "stale", "localizations" : { "cs" : { "stringUnit" : { @@ -17400,6 +17410,9 @@ }, "Output" : { + }, + "PDF Creation Failed" : { + }, "Pick a Color" : { "localizations" : { @@ -24775,6 +24788,12 @@ } } } + }, + "Tap to see the next of %lld waiting" : { + + }, + "The background helper crashed or was closed by macOS. It restarts automatically on the next OSD event." : { + }, "Time to Full Charge: %@" : { "localizations" : { diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index 3cd734617..fb686738b 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -157,7 +157,10 @@ struct NotificationExpandedView: View { // notification (.id below), so a half-typed reply only survives // if it's restored from the manager rather than kept in @State. replyText = manager.draft(for: notification.id) - if kind == .reply { replyFocused = true } + // Deliberately no auto-focus: mounting this view (hover, arrival, + // cycle) used to grab key-window status and pre-focus the field, + // which read as focus hijacking. Focus is pull-only — tapping the + // field or a suggestion chip sets replyFocused explicitly. } // The single teardown point for both the key-window grant and the // compose hold. Focus changes are too noisy to release on (see diff --git a/boringNotch/components/Notch/NotificationSneakPeekView.swift b/boringNotch/components/Notch/NotificationSneakPeekView.swift new file mode 100644 index 000000000..74dac2b06 --- /dev/null +++ b/boringNotch/components/Notch/NotificationSneakPeekView.swift @@ -0,0 +1,65 @@ +// +// NotificationSneakPeekView.swift +// boringNotch +// +// Passive marquee mirror of an incoming notification — shown below the +// closed notch for a few seconds, then gone. Purely visual: it never holds +// banners, never touches keyboard focus, never opens UI. +// + +import SwiftUI + +struct NotificationSneakPeekView: View { + let payload: NotificationPeekPayload + + var body: some View { + HStack(spacing: 6) { + if let bundleID = payload.bundleID, + let icon = appIconAsNSImage(for: bundleID) { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + Image(systemName: "bell.badge.fill") + .font(.system(size: 12)) + .foregroundStyle(.gray) + } + + if let title = payload.title, !title.isEmpty { + Text(title) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.white) + .lineLimit(1) + .layoutPriority(1) + } + + GeometryReader { geo in + MarqueeText( + bodyText, + font: .caption, + color: .gray, + delayDuration: 0.8, + frameWidth: geo.size.width + ) + } + } + .frame(width: 320) + .padding(.bottom, 10) + } + + private var bodyText: String { + var parts: [String] = [] + if payload.title == nil, let appName = payload.appName, !appName.isEmpty { + parts.append(appName) + } + if let body = payload.body, !body.isEmpty { + parts.append(body) + } + // Nothing but a title? Marquee the app name so the pill isn't empty. + if parts.isEmpty, let appName = payload.appName { parts.append(appName) } + return parts.joined(separator: " — ") + } +} diff --git a/boringNotch/components/Settings/Views/NotificationSettingsView.swift b/boringNotch/components/Settings/Views/NotificationSettingsView.swift index d5488f842..be37c4878 100644 --- a/boringNotch/components/Settings/Views/NotificationSettingsView.swift +++ b/boringNotch/components/Settings/Views/NotificationSettingsView.swift @@ -30,6 +30,7 @@ private let knownNotificationApps: [KnownNotificationApp] = [ struct NotificationSettingsView: View { @Default(.notificationLiveActivity) var notificationLiveActivity + @Default(.notificationSneakPeek) var notificationSneakPeek @Default(.notificationsFromAllApps) var notificationsFromAllApps @Default(.notificationAllowedApps) var allowedApps @@ -45,6 +46,16 @@ struct NotificationSettingsView: View { .foregroundStyle(.secondary) } + Section { + Defaults.Toggle(key: .notificationSneakPeek) { + Text("Sneak peek on new notifications") + } + } footer: { + Text("Briefly mirrors an incoming notification in a scrolling marquee below the notch, then dismisses it. No banner holding, no keyboard focus — purely a glance.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section { Defaults.Toggle(key: .notificationsFromAllApps) { Text("From all apps") diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index 99cf2c273..e48c09470 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -186,7 +186,34 @@ final class SystemNotificationManager: ObservableObject { } guard isAllowed(notification) else { - NSLog("[boringNotch] filtered out: \(notification.appName ?? "-") bundle=\(notification.bundleID ?? "nil")") + Log.notifications.debug("[boringNotch] filtered out: \(notification.appName ?? "-") bundle=\(notification.bundleID ?? "nil")") + return + } + + // Passive path (mirrored sneak peek): show a marquee below the notch + // and get out of the way. No banner holding (it dismisses naturally), + // no key-focus involvement, no expansion — replying stays a + // click-through action on the user's terms. + if Defaults[.notificationSneakPeek], !isComposingReply { + let message: String? = { + if let subtitle = notification.subtitle, let body = notification.body { + return subtitle + " — " + body + } + return notification.subtitle ?? notification.body + }() + NotchUIEventBus.events.send( + .sneakPeek( + type: .notification, + value: 0, + duration: 5.0, + payload: NotificationPeekPayload( + appName: notification.appName, + title: notification.title, + body: message, + bundleID: notification.bundleID + ) + ) + ) return } diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 6cf32ebbc..4b619e236 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -302,6 +302,9 @@ extension Defaults.Keys { // MARK: Notifications /// Off by default: mirroring banners needs Accessibility access. static let notificationLiveActivity = Key("notificationLiveActivity", default: false) + /// Passive marquee peek below the notch on notification arrival — + /// mirrors the banner briefly without holding it, focusing, or opening UI. + static let notificationSneakPeek = Key("notificationSneakPeek", default: false) static let notificationsFromAllApps = Key("notificationsFromAllApps", default: false) static let notificationAllowedApps = Key>( "notificationAllowedApps", diff --git a/boringNotch/models/NotchUIEvent.swift b/boringNotch/models/NotchUIEvent.swift index 6162bb280..6b1bcb5cf 100644 --- a/boringNotch/models/NotchUIEvent.swift +++ b/boringNotch/models/NotchUIEvent.swift @@ -10,6 +10,16 @@ import SwiftUI import Combine +/// Payload for `.notification` sneak peeks: the passive marquee mirror of +/// a banner's content — icon/title/body travel with the event so peek +/// rendering never needs to reach back into the notification queue. +struct NotificationPeekPayload { + var appName: String? + var title: String? + var body: String? + var bundleID: String? +} + /// UI-presentation events emitted by hardware/OS-facing managers. /// /// Inverts the old "manager calls `BoringViewCoordinator.shared`" direction: @@ -24,7 +34,9 @@ enum NotchUIEvent { value: CGFloat, icon: String = "", accent: Color? = nil, - targetScreenUUID: String? = nil + targetScreenUUID: String? = nil, + duration: TimeInterval = 1.5, + payload: NotificationPeekPayload? = nil ) case expandingView(type: SneakContentType) } From 0b6591f54d598aa910145574a8b1238cbd67efcd Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 13:50:05 +0530 Subject: [PATCH 50/69] Fix notification peek layout: stable widths, native click-through expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notification peek revealed two layout problems (see review image): the chin was widening to swallow the music inline-peek labels, and the new notification pill floated over that widened area, overlapping. - Music: the chin and the closed pill centre no longer widen by 220pt during the inline song-change peek; the combined 'title — artist' marquee-scrolls INSIDE the pill's resting width (matches the user's 'stay at the same width' call and reads like stock macOS). - Notification peek stays in its own 320pt slot below the closed notch, only while closed — no more overlap with the widened region. - Engagement promotion: a peeked notification now waits as 'mirrored' in SystemNotificationManager (12s window). Opening the notch or tapping the peek promotes it into the full expanded notification panel via promoteMirroredIfPresent(), holding the banner for actions when it's still alive — the pull-based 'built by Apple' flow. Verified: Debug build succeeds --- boringNotch/ContentView.swift | 55 +++++++------------ boringNotch/Localizable.xcstrings | 6 ++ .../Notch/NotificationSneakPeekView.swift | 11 ++++ .../managers/SystemNotificationManager.swift | 29 ++++++++++ 4 files changed, 67 insertions(+), 34 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index fea840b59..d38032d25 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -170,12 +170,10 @@ struct ContentView: View { chinWidth += (2 * max(0, vm.effectiveClosedNotchHeight - 12) + 20) case .music: chinWidth += (2 * max(0, displayClosedNotchHeight - 12) + 20 + 2 * liveActivityEdgeMargin + 2) - // The inline song-change peek widens the pill itself, so the - // chin has to grow with it — otherwise the hover region is - // narrower than what's on screen. - if showingInlineMusicPeek { - chinWidth += 2 * inlineMusicPeekLabelWidth - } + // NB: the chin deliberately does NOT widen during the inline + // song-change peek — the marquee scrolls within the existing + // pill width instead of expanding the scrub area (user-facing + // call: expanding bars read as junky). } } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] @@ -292,6 +290,12 @@ struct ContentView: View { isHovering = false } } + // A mirrored peek waiting under the notch gets promoted + // into the interactive surface the moment the user + // opens the notch — engagement, not surprise. + if newState == .open { + notificationManager.promoteMirroredIfPresent() + } } // A new notification always takes the front of the stack, // even if the user had swiped away to music. @@ -589,15 +593,14 @@ struct ContentView: View { /// closedNotchSize keeps a fixed label budget either side whatever the /// hardware is, and keeps liveActivityEdgeMargin in play so content /// clears the bezel — the inline path had dropped it entirely. + /// + /// The center no longer widens during the inline song-change peek: the + /// marquee scrolls inside the normal bounds, keeping the closed pill's + /// footprint stable. private var musicActivityCenterWidth: CGFloat { - let margin = vm.closedNotchSize.width - 4 + (2 * liveActivityEdgeMargin) - guard showingInlineMusicPeek else { return margin } - return margin + (2 * inlineMusicPeekLabelWidth) + return vm.closedNotchSize.width - 4 + (2 * liveActivityEdgeMargin) } - /// Space reserved for the title (left of the cutout) and artist (right). - private let inlineMusicPeekLabelWidth: CGFloat = 110 - @ViewBuilder func MusicLiveActivity() -> some View { HStack(spacing: 0) { @@ -634,42 +637,26 @@ struct ContentView: View { Rectangle() .fill(.black) .overlay( - // .center, not .top: the album art beside this is - // vertically centered, so top-aligned labels sat visibly - // high against it. HStack(alignment: .center) { if coordinator.expandingView.show && coordinator.expandingView.type == .music { + // Inline song-change peek: the new title + artist + // marquee-scroll INSIDE the pill's normal bounds + // — the chin stays at its resting width instead of + // expanding around the labels. MarqueeText( - musicManager.songTitle, + musicManager.songTitle + " — " + musicManager.artistName, color: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, delayDuration: 0.4, - frameWidth: inlineMusicPeekLabelWidth + frameWidth: max(40, musicActivityCenterWidth - 16) ) .opacity( (coordinator.expandingView.show && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0 ) - Spacer(minLength: vm.closedNotchSize.width) - // Song Artist - Text(musicManager.artistName) - .lineLimit(1) - .truncationMode(.tail) - .frame(width: inlineMusicPeekLabelWidth, alignment: .trailing) - .foregroundStyle( - Defaults[.coloredSpectrogram] - ? Color(nsColor: musicManager.avgColor) - : Color.gray - ) - .opacity( - (coordinator.expandingView.show - && coordinator.expandingView.type == .music - && Defaults[.sneakPeekStyles] == .inline) - ? 1 : 0 - ) } } .padding(.horizontal, 8) diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index 24bbc05c1..4d0ecce8e 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -3233,6 +3233,9 @@ } } } + }, + "Briefly mirrors an incoming notification in a scrolling marquee below the notch, then dismisses it. No banner holding, no keyboard focus — purely a glance." : { + }, "Brightness" : { "localizations" : { @@ -23615,6 +23618,9 @@ } } } + }, + "Sneak peek on new notifications" : { + }, "Sneak Peek shows the media title and artist under the notch for a few seconds." : { "localizations" : { diff --git a/boringNotch/components/Notch/NotificationSneakPeekView.swift b/boringNotch/components/Notch/NotificationSneakPeekView.swift index 74dac2b06..9386545f1 100644 --- a/boringNotch/components/Notch/NotificationSneakPeekView.swift +++ b/boringNotch/components/Notch/NotificationSneakPeekView.swift @@ -12,6 +12,9 @@ import SwiftUI struct NotificationSneakPeekView: View { let payload: NotificationPeekPayload + @EnvironmentObject var vm: BoringViewModel + @ObservedObject private var notificationManager = SystemNotificationManager.shared + var body: some View { HStack(spacing: 6) { if let bundleID = payload.bundleID, @@ -48,6 +51,14 @@ struct NotificationSneakPeekView: View { } .frame(width: 320) .padding(.bottom, 10) + .contentShape(Rectangle()) + .onTapGesture { + // macOS-banner semantics: tapping the mirror opens the content — + // here, in the expanded notification panel inside the notch. + if vm.open() { + notificationManager.promoteMirroredIfPresent() + } + } } private var bodyText: String { diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index e48c09470..99a891fea 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -75,6 +75,12 @@ final class SystemNotificationManager: ObservableObject { /// The notification the notch is currently showing, if any. @Published var activeNotification: SystemNotification? + /// The notification currently being mirrored by the peek marquee (peek- + /// mode never makes it "active"). Promoted into the interactive surface + /// when the notch opens or the peek is tapped. + @Published private(set) var mirrored: SystemNotification? + private var mirroredExpiryTask: Task? + /// Notifications that arrived while the user was mid-reply, held back /// rather than shown. Promoted one at a time once they're done. @Published private(set) var queued: [SystemNotification] = [] @@ -201,6 +207,13 @@ final class SystemNotificationManager: ObservableObject { } return notification.subtitle ?? notification.body }() + mirroredExpiryTask?.cancel() + mirrored = notification + mirroredExpiryTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(12)) + guard !Task.isCancelled, let self, self.mirrored?.id == notification.id else { return } + self.mirrored = nil + } NotchUIEventBus.events.send( .sneakPeek( type: .notification, @@ -278,6 +291,22 @@ final class SystemNotificationManager: ObservableObject { replyDrafts.removeValue(forKey: id) } + // MARK: - Mirrored peek promotion + + /// Promotes the currently mirrored peek into the interactive surface. + /// Called when the notch opens or the peek is tapped — the user has + /// engaged, so the (possibly still live) banner is held for replying. + /// If the banner already died, the expanded view simply offers the + /// non-reply actions (open app, etc.). + @MainActor + func promoteMirroredIfPresent() { + guard let mirrored, activeNotification == nil, !isComposingReply else { return } + mirroredExpiryTask?.cancel() + show(mirrored) + holdSystemBanner(mirrored) + self.mirrored = nil + } + /// Shows the oldest queued notification, if any. Oldest first so a burst /// is read in the order it arrived. private func promoteNextQueued() { From bdf6c0a69f8a53956a480c750f0308c5aa2a1bfd Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 13:54:54 +0530 Subject: [PATCH 51/69] Restore music peek layout, fix queue badge routing with peek mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the music inline-peek layout surgery from abb36f1 wholesale — both the chin widening and the label split are back to their proven geometry. I changed proven layout math blind; restoring rather than iterating on unverifiable pixel work. Real queue-badge bug (reported): with the sneak-peek toggle on, EVERY arrival took the passive path, so a notification already being showed (panel open) never queued — the +N badge died. Peek now only takes the passive path when NOTHING is displayed (activeNotification == nil); otherwise arrivals use the normal show/queue path exactly as before. Keep-from-abb36f1: mirrored promotion (tap/open promotes the peeked notification into the expanded panel). Verified: Debug build succeeds --- boringNotch/ContentView.swift | 49 +++++++++++++------ .../managers/SystemNotificationManager.swift | 6 ++- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index d38032d25..a7129af09 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -170,10 +170,12 @@ struct ContentView: View { chinWidth += (2 * max(0, vm.effectiveClosedNotchHeight - 12) + 20) case .music: chinWidth += (2 * max(0, displayClosedNotchHeight - 12) + 20 + 2 * liveActivityEdgeMargin + 2) - // NB: the chin deliberately does NOT widen during the inline - // song-change peek — the marquee scrolls within the existing - // pill width instead of expanding the scrub area (user-facing - // call: expanding bars read as junky). + // The inline song-change peek widens the pill itself, so the + // chin has to grow with it — otherwise the hover region is + // narrower than what's on screen. + if showingInlineMusicPeek { + chinWidth += 2 * inlineMusicPeekLabelWidth + } } } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] @@ -593,14 +595,15 @@ struct ContentView: View { /// closedNotchSize keeps a fixed label budget either side whatever the /// hardware is, and keeps liveActivityEdgeMargin in play so content /// clears the bezel — the inline path had dropped it entirely. - /// - /// The center no longer widens during the inline song-change peek: the - /// marquee scrolls inside the normal bounds, keeping the closed pill's - /// footprint stable. private var musicActivityCenterWidth: CGFloat { - return vm.closedNotchSize.width - 4 + (2 * liveActivityEdgeMargin) + let margin = vm.closedNotchSize.width - 4 + (2 * liveActivityEdgeMargin) + guard showingInlineMusicPeek else { return margin } + return margin + (2 * inlineMusicPeekLabelWidth) } + /// Space reserved for the title (left of the cutout) and artist (right). + private let inlineMusicPeekLabelWidth: CGFloat = 110 + @ViewBuilder func MusicLiveActivity() -> some View { HStack(spacing: 0) { @@ -637,26 +640,42 @@ struct ContentView: View { Rectangle() .fill(.black) .overlay( + // .center, not .top: the album art beside this is + // vertically centered, so top-aligned labels sat visibly + // high against it. HStack(alignment: .center) { if coordinator.expandingView.show && coordinator.expandingView.type == .music { - // Inline song-change peek: the new title + artist - // marquee-scroll INSIDE the pill's normal bounds - // — the chin stays at its resting width instead of - // expanding around the labels. MarqueeText( - musicManager.songTitle + " — " + musicManager.artistName, + musicManager.songTitle, color: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, delayDuration: 0.4, - frameWidth: max(40, musicActivityCenterWidth - 16) + frameWidth: inlineMusicPeekLabelWidth ) .opacity( (coordinator.expandingView.show && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0 ) + Spacer(minLength: vm.closedNotchSize.width) + // Song Artist + Text(musicManager.artistName) + .lineLimit(1) + .truncationMode(.tail) + .frame(width: inlineMusicPeekLabelWidth, alignment: .trailing) + .foregroundStyle( + Defaults[.coloredSpectrogram] + ? Color(nsColor: musicManager.avgColor) + : Color.gray + ) + .opacity( + (coordinator.expandingView.show + && coordinator.expandingView.type == .music + && Defaults[.sneakPeekStyles] == .inline) + ? 1 : 0 + ) } } .padding(.horizontal, 8) diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index 99a891fea..6c2b64270 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -200,7 +200,11 @@ final class SystemNotificationManager: ObservableObject { // and get out of the way. No banner holding (it dismisses naturally), // no key-focus involvement, no expansion — replying stays a // click-through action on the user's terms. - if Defaults[.notificationSneakPeek], !isComposingReply { + // + // Only when nothing is showing: if a notification is already active + // (panel open, or queue suspended mid-reply), arrivals must flow + // into the normal show/queue path so the +N badge stays alive. + if Defaults[.notificationSneakPeek], !isComposingReply, activeNotification == nil { let message: String? = { if let subtitle = notification.subtitle, let body = notification.body { return subtitle + " — " + body From ecd90adce00cfc7d26d2d8c17c49cc068f06a6af Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 13:59:49 +0530 Subject: [PATCH 52/69] Exclude notification peeks from the inline-OSD lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the reported junk: the InlineOSD render condition matched .every. non-music sneak type, including the new .notification peek — so a notification peek ALSO mounted inline-OSD (empty icon + a 0-value bar + 0% label, because peek value is 0), displacing the music live activity row (album art vanished) and stretching the pill. With inlineOSD disabled the music row stayed, but for inline users all three reported symptoms had this single origin: phantom 0% volume slider, expanded black area, missing album art. Notification peeks now never enter the OSD lane; the music album/stats row keeps rendering uninterrupted while the peek scrolls below. Verified: Debug build succeeds; remaining symptom chain closed by code reading (InlineOSD is the only consumer of opaque OSD types) --- boringNotch/ContentView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index a7129af09..f39822eb7 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -433,7 +433,10 @@ struct ContentView: View { .frame(width: 76, alignment: .trailing) } .frame(height: displayClosedNotchHeight, alignment: .center) - } else if coordinator.shouldShowSneakPeek(on: vm.screenUUID) && Defaults[.inlineOSD] && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .battery) && vm.notchState == .closed { + } else if coordinator.shouldShowSneakPeek(on: vm.screenUUID) && Defaults[.inlineOSD] && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .battery) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .notification) && vm.notchState == .closed { + // .notification is excluded: the passive peek is + // not an OSD event and would render here as a + // 0-value volume bar displacing the music pill. InlineOSD( type: coordinator.binding(for: vm.screenUUID).type, value: coordinator.binding(for: vm.screenUUID).value, From 89e4b05e40bcd12a1c0e704dfaddfb5d296e5926 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 14:02:15 +0530 Subject: [PATCH 53/69] Give the notification peek its own lane, coexisting with everything Per user request, the peek must be able to show AT THE SAME TIME as: - the closed music live activity (album art row), - a music sneak peek (standard or inline song change), - an OSD peek while the user is adjusting volume/brightness. Implementation: .notification no longer lives in the type-exclusive sneakPeekStates slot at all. BoringViewCoordinator gains notificationPeekStates + its own per-screen hide tasks; toggleSneakPeek routes .notification there transparently. ContentView renders the notification pill as an additional bottom row below whatever peek/OSD/ music row is active, gated on closed notch only. Behavior now: while cranking volume (or with the music pill showing), an incoming notification scrolls its marquee right below without replacing anything; each lane auto-hides on its own timer. Verified: Debug build succeeds --- boringNotch/BoringViewCoordinator.swift | 87 +++++++++++++++++++++++-- boringNotch/ContentView.swift | 47 +++++++------ 2 files changed, 106 insertions(+), 28 deletions(-) diff --git a/boringNotch/BoringViewCoordinator.swift b/boringNotch/BoringViewCoordinator.swift index 7157ca53c..8a7034318 100644 --- a/boringNotch/BoringViewCoordinator.swift +++ b/boringNotch/BoringViewCoordinator.swift @@ -29,8 +29,6 @@ struct SneakPeekState { var icon: String = "" var accent: Color? = nil var targetScreenUUID: String? = nil - /// Content for `.notification` peeks; nil for every other type. - var notification: NotificationPeekPayload? = nil } enum BrowserType { @@ -234,18 +232,94 @@ final class BoringViewCoordinator: ObservableObject { // Dictionary to hold sneak peek state for each screen UUID @Published var sneakPeekStates: [String: SneakPeekState] = [:] - + // Dictionary to hold hide tasks for each screen UUID private var sneakPeekTasks: [String: Task] = [:] + // MARK: - Notification peek lane + + /// Notification peek state per screen. Independent from `sneakPeekStates` + /// on purpose: OSD peeks (volume/brightness) and music peeks are + /// type-exclusive, while a notification peek must be able to show AT THE + /// SAME TIME as any of them (arriving while the user is adjusting volume, + /// while a song-change marquee is up, etc.). + struct NotificationPeekState { + var show: Bool = false + var payload: NotificationPeekPayload? = nil + var targetScreenUUID: String? = nil + } + + @Published var notificationPeekStates: [String: NotificationPeekState] = [:] + private var notificationPeekTasks: [String: Task] = [:] + private let notificationPeekDuration: TimeInterval = 5.0 + + @MainActor + private func showNotificationPeek(for uuid: String, payload: NotificationPeekPayload, duration: TimeInterval) { + var state = notificationPeekStates[uuid] ?? NotificationPeekState(targetScreenUUID: uuid) + state.show = true + state.payload = payload + state.targetScreenUUID = uuid + notificationPeekStates[uuid] = state + + notificationPeekTasks[uuid]?.cancel() + notificationPeekTasks[uuid] = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(duration)) + guard !Task.isCancelled, let self else { return } + withAnimation(.smooth) { + guard var s = self.notificationPeekStates[uuid] else { return } + s.show = false + self.notificationPeekStates[uuid] = s + } + } + } + + private func toggleNotificationPeek( + status: Bool, duration: TimeInterval, payload: NotificationPeekPayload?, targetScreenUUID: String? + ) { + guard status, let payload else { + // Dismiss request + Task { @MainActor in + if let uuid = targetScreenUUID { + notificationPeekTasks[uuid]?.cancel() + withAnimation(.smooth) { + notificationPeekStates[uuid]?.show = false + } + } + } + return + } + Task { @MainActor in + if let targetUUID = targetScreenUUID { + showNotificationPeek(for: targetUUID, payload: payload, duration: max(duration, notificationPeekDuration)) + } else { + for uuid in NSScreen.screens.compactMap({ $0.displayUUID }) { + showNotificationPeek(for: uuid, payload: payload, duration: max(duration, notificationPeekDuration)) + } + } + } + } + + /// Peek state accessors for views (notification lane). + func notificationPeekState(for screenUUID: String?) -> NotificationPeekState { + guard let uuid = screenUUID else { return NotificationPeekState() } + return notificationPeekStates[uuid] ?? NotificationPeekState(targetScreenUUID: uuid) + } + func toggleSneakPeek( status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "", accent: Color? = nil, targetScreenUUID: String? = nil, payload: NotificationPeekPayload? = nil ) { - // Mirrored notification peeks ride their own setting and are not - // part of the OSD replacement feature, so they're exempt from its gate. - if type != .music && type != .notification { + // Notification peeks live in their own lane — they may coexist with + // any OSD/music peek instead of replacing (or being replaced by) it. + if type == .notification { + toggleNotificationPeek( + status: status, duration: duration, payload: payload, + targetScreenUUID: targetScreenUUID) + return + } + + if type != .music { // close() if !Defaults[.osdReplacement] { return @@ -266,7 +340,6 @@ final class BoringViewCoordinator: ObservableObject { state.icon = icon state.accent = accent state.targetScreenUUID = uuid // Ensure UUID is set - state.notification = type == .notification ? payload : nil self.sneakPeekStates[uuid] = state } diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index f39822eb7..f1fc776af 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -475,13 +475,8 @@ struct ContentView: View { Rectangle().fill(.clear).frame(width: vm.closedNotchSize.width - 20, height: displayClosedNotchHeight) } - if coordinator.shouldShowSneakPeek(on: vm.screenUUID) { - if coordinator.sneakPeekState(for: vm.screenUUID).type == .notification && vm.notchState == .closed { - if let payload = coordinator.sneakPeekState(for: vm.screenUUID).notification { - NotificationSneakPeekView(payload: payload) - } - } - else if (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .battery) && !Defaults[.inlineOSD] && vm.notchState == .closed { + if coordinator.shouldShowSneakPeek(on: vm.screenUUID) { + if (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .battery) && !Defaults[.inlineOSD] && vm.notchState == .closed { SystemEventIndicatorModifier( eventType: coordinator.binding(for: vm.screenUUID).type, value: coordinator.binding(for: vm.screenUUID).value, @@ -502,20 +497,30 @@ struct ContentView: View { .padding(.leading, 4) .padding(.trailing, 8) } - // Old sneak peek music - else if coordinator.sneakPeekState(for: vm.screenUUID).type == .music { - if vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard { - HStack(alignment: .center) { - Image(systemName: "music.note") - GeometryReader { geo in - MarqueeText(musicManager.songTitle + " - " + musicManager.artistName, color: Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray, delayDuration: 1.0, frameWidth: geo.size.width) - } - } - .foregroundStyle(.gray) - .padding(.bottom, 10) - } - } - } + // Old sneak peek music + else if coordinator.sneakPeekState(for: vm.screenUUID).type == .music { + if vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard { + HStack(alignment: .center) { + Image(systemName: "music.note") + GeometryReader { geo in + MarqueeText(musicManager.songTitle + " - " + musicManager.artistName, color: Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray, delayDuration: 1.0, frameWidth: geo.size.width) + } + } + .foregroundStyle(.gray) + .padding(.bottom, 10) + } + } + } + + // Notification peek: its own lane below every other + // peek/live-activity row so it can coexist with OSD, + // music marquee, or the closed music pill. + if vm.notchState == .closed { + let peek = coordinator.notificationPeekState(for: vm.screenUUID) + if peek.show, let payload = peek.payload { + NotificationSneakPeekView(payload: payload) + } + } } } .conditionalModifier((coordinator.shouldShowSneakPeek(on: vm.screenUUID) && (coordinator.sneakPeekState(for: vm.screenUUID).type == .music) && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.shouldShowSneakPeek(on: vm.screenUUID) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (vm.notchState == .closed))) { view in From 9c640d50c04b1d2081463e6c6a23e27dabbdca65 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 15:52:30 +0530 Subject: [PATCH 54/69] Float the notification peek as free window-space decoration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pill was still measured by the clipped notch shape (it lived in the same VStack), so any tall state — greeting face, open notch, inline marquee — ballooned the whole black panel when a notification arrived. The peek is now an overlay pinned to the chin height on the outer layout container: it appears float-free just below the closed notch, scrolls, fades, and can coexist with literally any state the notch is in. Shape math is untouched by it. Verified: Debug build succeeds --- boringNotch/ContentView.swift | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index f1fc776af..b998ae7cb 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -511,16 +511,6 @@ struct ContentView: View { } } } - - // Notification peek: its own lane below every other - // peek/live-activity row so it can coexist with OSD, - // music marquee, or the closed music pill. - if vm.notchState == .closed { - let peek = coordinator.notificationPeekState(for: vm.screenUUID) - if peek.show, let payload = peek.payload { - NotificationSneakPeekView(payload: payload) - } - } } } .conditionalModifier((coordinator.shouldShowSneakPeek(on: vm.screenUUID) && (coordinator.sneakPeekState(for: vm.screenUUID).type == .music) && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.shouldShowSneakPeek(on: vm.screenUUID) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (vm.notchState == .closed))) { view in @@ -569,6 +559,20 @@ struct ContentView: View { .opacity(gestureProgress != 0 ? 1.0 - min(abs(gestureProgress) * 0.1, 0.3) : 1.0) } } + // Notification peek floats BELOW the chin as window-space decoration. + // It never lives inside the clipped notch shape, so it can neither + // stretch the panel vertically nor be pushed around by open/ + // greeting/inline states — it mirrors, then fades away. + .overlay(alignment: .top) { + if vm.notchState == .closed { + let peek = coordinator.notificationPeekState(for: vm.screenUUID) + if peek.show, let payload = peek.payload { + NotificationSneakPeekView(payload: payload) + .padding(.top, displayClosedNotchHeight + 24) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + } .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], delegate: GeneralDropTargetDelegate(isTargeted: $dropInteraction.generalDropTargeting)) } From 180d7359ae02449d326e20d4ad9f2effc37213c2 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 16:01:13 +0530 Subject: [PATCH 55/69] Fix peek latency regression, preserve OTP interactive flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-reported: notifications arrive slowly, only visible after hovering; sneak peeks unreliable; OTP copy flow broken. - Latency: revert Phase-1's adaptive watcher cadence (idle tick 2.0s) back to constant 0.35s — the mirrored peek must feel immediate; the idle battery saving wasn't worth a visibly-late mirror for an opt-in feature. - OTP: detected-code notifications bypass the passive peek again and use the normal show()/queue path, restoring the code + copy affordance. - Peek window 5s -> 8s so the pill isn't half-gone by the time capture fires on a slow cycle. Verified: Debug build succeeds --- .../NotificationWatcher.swift | 27 +++++-------------- .../managers/SystemNotificationManager.swift | 14 ++++++---- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index 9b363502e..c358263f8 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -53,14 +53,11 @@ final class NotificationWatcher { /// missed tick can't let a held banner slip away. private let refreshInterval: TimeInterval = 2.5 - /// Poll cadence adapts to activity: fast while a banner is live (they - /// dismiss in ~5s, so 0.35s catches every one), slow while idle. An - /// always-on 0.35s poll costs ~250k AX tree walks per day for a feature - /// that is used a handful of times; idling at 2s keeps first-detection - /// latency under a banner's lifetime while cutting that by ~85%. - private let activePollInterval: TimeInterval = 0.35 - private let idlePollInterval: TimeInterval = 2.0 - private var currentPollInterval: TimeInterval = 0 + /// Banners live ~5s, so 0.35s catches every one with room to spare. + /// (An adaptive cadence with a 2s idle tick was tried and regressed + /// UX: mirrored peeks felt delayed-to-ignored. Latency beats idle power + /// here — notification mirroring is opt-in.) + private let pollInterval: TimeInterval = 0.35 var isRunning: Bool { appElement != nil } @@ -91,11 +88,10 @@ final class NotificationWatcher { // state stays on the main queue, which is also where the helper // dispatches reply/action calls, so there's no locking to get wrong. let timer = DispatchSource.makeTimerSource(queue: .main) - timer.schedule(deadline: .now() + activePollInterval, repeating: activePollInterval) + timer.schedule(deadline: .now() + pollInterval, repeating: pollInterval) timer.setEventHandler { [weak self] in self?.scan() } timer.resume() pollTimer = timer - currentPollInterval = activePollInterval scan() return true @@ -140,17 +136,6 @@ final class NotificationWatcher { } refreshHeldBanners() - updatePollCadence() - } - - /// Drops the poll rate once nothing is on screen anymore; a slow idle - /// tick still catches the first banner of the next burst, at which - /// point the fast cadence resumes. - private func updatePollCadence() { - let wanted = (live.isEmpty && held.isEmpty) ? idlePollInterval : activePollInterval - guard wanted != currentPollInterval, let pollTimer else { return } - currentPollInterval = wanted - pollTimer.schedule(deadline: .now() + wanted, repeating: wanted) } /// Keeps held banners from timing out. diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index 6c2b64270..f40108253 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -201,10 +201,14 @@ final class SystemNotificationManager: ObservableObject { // no key-focus involvement, no expansion — replying stays a // click-through action on the user's terms. // - // Only when nothing is showing: if a notification is already active - // (panel open, or queue suspended mid-reply), arrivals must flow - // into the normal show/queue path so the +N badge stays alive. - if Defaults[.notificationSneakPeek], !isComposingReply, activeNotification == nil { + // Two carve-outs: + // - OTP/code notifications keep the interactive path (the copy + // affordance lives in the live activity, not the passive mirror). + // - If a notification is already active (panel open, or queue + // suspended mid-reply), arrivals must flow into the normal + // show/queue path so the +N badge stays alive. + if Defaults[.notificationSneakPeek], !isComposingReply, activeNotification == nil, + notification.detectedCode == nil { let message: String? = { if let subtitle = notification.subtitle, let body = notification.body { return subtitle + " — " + body @@ -222,7 +226,7 @@ final class SystemNotificationManager: ObservableObject { .sneakPeek( type: .notification, value: 0, - duration: 5.0, + duration: 8.0, payload: NotificationPeekPayload( appName: notification.appName, title: notification.title, From 0eb62336da576752015abf64e6e5963aa0b62348 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 16:10:33 +0530 Subject: [PATCH 56/69] Consolidate notification surfaces into one simple flow The layered peek/mirrored/promotion machinery had broken both surfaces: arrivals bypassed show() entirely, so the compact dot+icon never entered the chin, and the floating pill's lifecycle was entangled with the promotion hack. One flow now, three surfaces, no machinery: 1. The floating marquee pill scrolls under the notch (8s). 2. The compact dot/app-icon sits in the chin for the same 8s window (existing live-activity slot) via show(holdingBanner: false). 3. Tap either / hover open -> NotificationExpandedView of the ACTIVE notification (register step already done at arrival), pull-only focus. Deleted: mirrored state, mirroredExpiryTask, promoteMirroredIfPresent, peek-lane promotion hook in ContentView. OTP keeps its interactive path and the queue badge path is untouched. Verified: Debug build succeeds --- boringNotch/ContentView.swift | 6 -- .../Notch/NotificationSneakPeekView.swift | 9 ++- .../managers/SystemNotificationManager.swift | 56 +++++-------------- 3 files changed, 19 insertions(+), 52 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index b998ae7cb..6ac0b9ffe 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -292,12 +292,6 @@ struct ContentView: View { isHovering = false } } - // A mirrored peek waiting under the notch gets promoted - // into the interactive surface the moment the user - // opens the notch — engagement, not surprise. - if newState == .open { - notificationManager.promoteMirroredIfPresent() - } } // A new notification always takes the front of the stack, // even if the user had swiped away to music. diff --git a/boringNotch/components/Notch/NotificationSneakPeekView.swift b/boringNotch/components/Notch/NotificationSneakPeekView.swift index 9386545f1..a9d284e9b 100644 --- a/boringNotch/components/Notch/NotificationSneakPeekView.swift +++ b/boringNotch/components/Notch/NotificationSneakPeekView.swift @@ -13,7 +13,6 @@ struct NotificationSneakPeekView: View { let payload: NotificationPeekPayload @EnvironmentObject var vm: BoringViewModel - @ObservedObject private var notificationManager = SystemNotificationManager.shared var body: some View { HStack(spacing: 6) { @@ -54,10 +53,10 @@ struct NotificationSneakPeekView: View { .contentShape(Rectangle()) .onTapGesture { // macOS-banner semantics: tapping the mirror opens the content — - // here, in the expanded notification panel inside the notch. - if vm.open() { - notificationManager.promoteMirroredIfPresent() - } + // the expanded notification panel inside the notch. The arrival + // already registered the notification as the active live + // activity, so opening the notch surfaces it directly. + _ = vm.open() } } diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index f40108253..d9644b111 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -75,12 +75,6 @@ final class SystemNotificationManager: ObservableObject { /// The notification the notch is currently showing, if any. @Published var activeNotification: SystemNotification? - /// The notification currently being mirrored by the peek marquee (peek- - /// mode never makes it "active"). Promoted into the interactive surface - /// when the notch opens or the peek is tapped. - @Published private(set) var mirrored: SystemNotification? - private var mirroredExpiryTask: Task? - /// Notifications that arrived while the user was mid-reply, held back /// rather than shown. Promoted one at a time once they're done. @Published private(set) var queued: [SystemNotification] = [] @@ -196,18 +190,16 @@ final class SystemNotificationManager: ObservableObject { return } - // Passive path (mirrored sneak peek): show a marquee below the notch - // and get out of the way. No banner holding (it dismisses naturally), - // no key-focus involvement, no expansion — replying stays a - // click-through action on the user's terms. + // Passive path (mirrored sneak peek + compact dot): + // - scroll a marquee pill below the notch immediately, + // - show the compact dot/app-icon in the chin (the existing + // live-activity slot — the gesture surface for tap/hover), + // - hold NOTHING: no banner parking, no key focus, ever. // - // Two carve-outs: - // - OTP/code notifications keep the interactive path (the copy - // affordance lives in the live activity, not the passive mirror). - // - If a notification is already active (panel open, or queue - // suspended mid-reply), arrivals must flow into the normal - // show/queue path so the +N badge stays alive. - if Defaults[.notificationSneakPeek], !isComposingReply, activeNotification == nil, + // OTP/code notifications keep the interactive path (the copy + // affordance lives in the live activity, not the passive mirror), + // and mid-reply arrivals keep queueing so the +N badge lives. + if Defaults[.notificationSneakPeek], !isComposingReply, notification.detectedCode == nil { let message: String? = { if let subtitle = notification.subtitle, let body = notification.body { @@ -215,13 +207,6 @@ final class SystemNotificationManager: ObservableObject { } return notification.subtitle ?? notification.body }() - mirroredExpiryTask?.cancel() - mirrored = notification - mirroredExpiryTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: .seconds(12)) - guard !Task.isCancelled, let self, self.mirrored?.id == notification.id else { return } - self.mirrored = nil - } NotchUIEventBus.events.send( .sneakPeek( type: .notification, @@ -235,6 +220,8 @@ final class SystemNotificationManager: ObservableObject { ) ) ) + // Compact indicator in the chin without holding the banner. + show(notification, holdingBanner: false) return } @@ -299,22 +286,6 @@ final class SystemNotificationManager: ObservableObject { replyDrafts.removeValue(forKey: id) } - // MARK: - Mirrored peek promotion - - /// Promotes the currently mirrored peek into the interactive surface. - /// Called when the notch opens or the peek is tapped — the user has - /// engaged, so the (possibly still live) banner is held for replying. - /// If the banner already died, the expanded view simply offers the - /// non-reply actions (open app, etc.). - @MainActor - func promoteMirroredIfPresent() { - guard let mirrored, activeNotification == nil, !isComposingReply else { return } - mirroredExpiryTask?.cancel() - show(mirrored) - holdSystemBanner(mirrored) - self.mirrored = nil - } - /// Shows the oldest queued notification, if any. Oldest first so a burst /// is read in the order it arrived. private func promoteNextQueued() { @@ -363,7 +334,7 @@ final class SystemNotificationManager: ObservableObject { /// outgoing notification goes back into the queue rather than away, and /// it needs to keep its held banner or it won't be replyable when it /// comes back around. - private func show(_ notification: SystemNotification, releasingPrevious: Bool = true) { + private func show(_ notification: SystemNotification, releasingPrevious: Bool = true, holdingBanner: Bool = true) { // A newer notification replaces the active one directly here rather // than going through dismissActive, so its hold was never being // released: dismissActive only releases whatever activeNotification @@ -377,6 +348,9 @@ final class SystemNotificationManager: ObservableObject { if releasingPrevious, let previous = activeNotification, previous.id != notification.id { XPCHelperClient.shared.releaseNotification(token: previous.id) } + if holdingBanner { + holdSystemBanner(notification) + } withAnimation(.smooth) { activeNotification = notification } dismissTask?.cancel() dismissTask = Task { [weak self] in From 799e86b9f842d1fefdf69539b39ac240bb496483 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 16:13:57 +0530 Subject: [PATCH 57/69] Widen OTP chin width to clear the hardware notch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verification-code pill sizes itself at a flat 420, which leaves only ~85pt of wing right of a standard hardware notch — the code + copy button (~110pt) clipped under the bezel. The width is now notch-aware: chinWidth = max(420, notch + 224), i.e. ~112pt of code/copy affordance on each side of physical cutout. Verified: Debug build succeeds --- boringNotch/ContentView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 6ac0b9ffe..db7d132c5 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -163,9 +163,11 @@ struct ContentView: View { // notification's width. switch activity { case .notification(let notification) where notification.detectedCode != nil: - // Wide enough for the code itself plus a copy affordance, - // without going as far as the battery pill's 640. - chinWidth = 420 + // The code + copy button live on the wing right of the + // physical notch; a flat 420 assumes a narrow cutout, and on + // standard-notch displays the wing fell short so the code + // clipped under the hardware bezel. + chinWidth = max(420, vm.closedNotchSize.width + 2 * 112) case .notification: chinWidth += (2 * max(0, vm.effectiveClosedNotchHeight - 12) + 20) case .music: From 1d0434a9acfb534f387288b5fa13e062628a7a1f Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 16:21:32 +0530 Subject: [PATCH 58/69] Cap AXI watcher memory pressure; reinstate bounded idle cadence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user debug run was jetsam-killed after ~4 min. The helper's AX scan is the allocation-heavy path; two mitigations: - idle cadence 0.5s (halves the walk rate when nothing is on screen vs the flat 0.35s restored earlier) — still perceptibly instant - every scan now runs inside an explicit autoreleasepool so the AX- bridged object copies in a walk drain deterministically instead of whatever the dispatch-main scheduler happens to do If jetsam recurs at this cadence, repro with the Music/Lottie paths disabled to bisect by feature lane. Verified: Debug build succeeds --- .../NotificationWatcher.swift | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index c358263f8..01779683c 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -54,10 +54,13 @@ final class NotificationWatcher { private let refreshInterval: TimeInterval = 2.5 /// Banners live ~5s, so 0.35s catches every one with room to spare. - /// (An adaptive cadence with a 2s idle tick was tried and regressed - /// UX: mirrored peeks felt delayed-to-ignored. Latency beats idle power - /// here — notification mirroring is opt-in.) - private let pollInterval: TimeInterval = 0.35 + /// Idle drops to 0.5s as a compromise: first-detection cadence stays + /// perceptibly snappy while the AX walk rate halves versus flat 0.35s + /// (memory-debugging shows the scan path is the helper's + /// allocation-heavy path, so idle trimming also bounds its pressure). + private let activePollInterval: TimeInterval = 0.35 + private let idlePollInterval: TimeInterval = 0.5 + private var currentPollInterval: TimeInterval = 0 var isRunning: Bool { appElement != nil } @@ -88,10 +91,11 @@ final class NotificationWatcher { // state stays on the main queue, which is also where the helper // dispatches reply/action calls, so there's no locking to get wrong. let timer = DispatchSource.makeTimerSource(queue: .main) - timer.schedule(deadline: .now() + pollInterval, repeating: pollInterval) + timer.schedule(deadline: .now() + activePollInterval, repeating: activePollInterval) timer.setEventHandler { [weak self] in self?.scan() } timer.resume() pollTimer = timer + currentPollInterval = activePollInterval scan() return true @@ -115,6 +119,13 @@ final class NotificationWatcher { // MARK: - Scanning private func scan() { + // Every AX-bridged object copied in a walk lands in the run queue's + // autorelease pool; wrap the walk explicitly so pool lifetime never + // depends on the dispatch-main scheduler's drain behavior. + autoreleasepool { scanImpl() } + } + + private func scanImpl() { guard let appElement else { return } var seen: Set = [] @@ -136,6 +147,16 @@ final class NotificationWatcher { } refreshHeldBanners() + updatePollCadence() + } + + /// Drops the poll rate once nothing is on screen anymore; walking at + /// half rate while idle halves the allocation pressure of the scan path. + private func updatePollCadence() { + let wanted = (live.isEmpty && held.isEmpty) ? idlePollInterval : activePollInterval + guard wanted != currentPollInterval, let pollTimer else { return } + currentPollInterval = wanted + pollTimer.schedule(deadline: .now() + wanted, repeating: wanted) } /// Keeps held banners from timing out. From 2bd72a81596954c48ef9aa6ba8df44fe2c50ffb0 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 16:34:39 +0530 Subject: [PATCH 59/69] Reply delivery: per-stage timeouts with banner-first ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iMessage replies now try the native banner reply field FIRST (near-free) and only then the heavy AppleScript path, with independent budgets: - Stage 1 (AX banner): 2s race; a stalled banner returns .failed without cross-delivering through another channel (no duplicate sends) - Stage 2 (Messages AppleScript): runs ONLY after a definite failure and gets its own 4s budget (cold Messages.app launch no longer trips the send) - iMessage-stage failure: surfaced as an honest error in the reply UI; the draft is kept and the notch stays open (fixes the silent 'sending…' hang when Apple Events stall) ReplyOutcome gains .failed; NotificationExpandedView shows the error inline; debug window updated exhaustively. Verified: Debug build succeeds --- .../Notch/NotificationLiveActivity.swift | 21 +++++++ .../components/NotificationDebugWindow.swift | 5 +- .../managers/SystemNotificationManager.swift | 56 ++++++++++++++++++- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/boringNotch/components/Notch/NotificationLiveActivity.swift b/boringNotch/components/Notch/NotificationLiveActivity.swift index fb686738b..c53156124 100644 --- a/boringNotch/components/Notch/NotificationLiveActivity.swift +++ b/boringNotch/components/Notch/NotificationLiveActivity.swift @@ -116,6 +116,9 @@ struct NotificationExpandedView: View { @State private var hostWindow: BoringNotchSkyLightWindow? @State private var suggestions: [String] = [] @State private var isComposing = false + /// User-visible delivery failure — the draft is kept, the notch stays + /// open, and the user can retry or handle the message themselves. + @State private var sendError: String? private var kind: NotificationKind { .init(notification) } @@ -399,6 +402,13 @@ struct NotificationExpandedView: View { suggestionChips } replyField + if let sendError { + Text(sendError) + .font(.caption2) + .foregroundStyle(.red) + .lineLimit(2) + .transition(.opacity) + } } .task(id: notification.id) { guard Defaults[.smartRepliesEnabled], let body = notification.body else { return } @@ -539,9 +549,20 @@ struct NotificationExpandedView: View { guard canSend else { return } let text = replyText isSending = true + sendError = nil Task { let outcome = await manager.reply(to: notification, text: text) isSending = false + + if outcome == .failed { + // Don't pretend. Keep the draft, keep the notch open, and + // tell the user plainly what happened. + withAnimation(.smooth) { + sendError = String(localized: "Message couldn't be sent. Your draft is still here — try again or open Messages.") + } + return + } + replyText = "" // A hand-off is not a delivery — showing the same checkmark for // both would tell the user their message went out when it's diff --git a/boringNotch/components/NotificationDebugWindow.swift b/boringNotch/components/NotificationDebugWindow.swift index efa78cadd..b1d6e50c5 100644 --- a/boringNotch/components/NotificationDebugWindow.swift +++ b/boringNotch/components/NotificationDebugWindow.swift @@ -84,13 +84,16 @@ struct NotificationDebugView: View { let text = replyText replyText = "" Task { - switch await manager.reply(to: notification, text: text) { + let outcome = await manager.reply(to: notification, text: text) + switch outcome { case .sent: lastResult = "replied via the live banner" case .handedOffToApp: lastResult = "banner gone — copied to clipboard and opened the app" case .draftedInApp: lastResult = "banner gone — opened the conversation with the text pre-filled" + case .failed: + lastResult = "delivery failed (timeout/blocked) — draft preserved" } } } diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index d9644b111..a78cd88fa 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -450,8 +450,20 @@ final class SystemNotificationManager: ObservableObject { /// box. Better than the clipboard, but still not sent — the user /// presses send themselves. case draftedInApp + /// The reply couldn't be delivered in time. The draft is preserved + /// and surfaced as an error so the user can retry or handle it + /// themselves — never silently stuck on "sending". + case failed } + /// iMessage/AX delivery attempts can stall on an unresponsive Messages + /// process or a wedged XPC reply; race them so the UI always resolves. + /// Notifications-banner replies are nearly free; iMessage scripting is + /// expensive (Messages.app cold-launch can take seconds), so each stage + /// gets its own budget instead of one shared race. + private let bannerReplyTimeout: TimeInterval = 2.0 + private let imessageScriptTimeout: TimeInterval = 4.0 + /// Sends an inline reply. /// /// Replying types into the notification's own reply field via @@ -463,12 +475,43 @@ final class SystemNotificationManager: ObservableObject { /// goes to the clipboard and the app opens so it's one paste away. @discardableResult func reply(to notification: SystemNotification, text: String) async -> ReplyOutcome { - if await XPCHelperClient.shared.replyToNotification(token: notification.id, text: text) { + await performReply(to: notification, text: text) + } + + /// Races an operation against a timeout; whichever resolves first wins. + private func raceTimeout( + seconds: TimeInterval, fallback: T, + operation: @escaping @Sendable () async -> T + ) async -> T { + await withTaskGroup(of: T.self) { group in + group.addTask { await operation() } + group.addTask { + try? await Task.sleep(for: .seconds(seconds)) + return fallback + } + let result = await group.next() ?? fallback + group.cancelAll() + return result + } + } + + private func performReply(to notification: SystemNotification, text: String) async -> ReplyOutcome { + // Stage 1 — the banner's own reply field (native, cheap). nil means + // the attempt stalled: do NOT cross-deliver through another channel + // — the message may already have gone through. + let bannerDelivered: Bool? = await raceTimeout(seconds: bannerReplyTimeout, fallback: nil) { + await XPCHelperClient.shared.replyToNotification(token: notification.id, text: text) + } + if bannerDelivered == true { NSLog("[boringNotch] reply sent via AX for \(notification.appName ?? "-")") playSentSound() dismissActive(token: notification.id) return .sent } + if bannerDelivered == nil { + NSLog("[boringNotch] reply attempt timed out at the banner for \(notification.appName ?? "-")") + return .failed + } // The banner is gone, so AX can't deliver. Messages is the one // supported app that can still be sent to properly — it has a real @@ -477,13 +520,22 @@ final class SystemNotificationManager: ObservableObject { // WhatsApp/Telegram/Discord. if notification.bundleID == "com.apple.MobileSMS", let chatName = notification.sender, - await XPCHelperClient.shared.sendIMessage(text, toChatNamed: chatName) { + await raceTimeout(seconds: imessageScriptTimeout, fallback: false, operation: { + await XPCHelperClient.shared.sendIMessage(text, toChatNamed: chatName) + }) { NSLog("[boringNotch] reply sent via Messages scripting for \(chatName)") playSentSound() dismissActive(token: notification.id) return .sent } + // iMessage delivery definitively failed — surface the error, keep + // the user's draft, and let them handle it directly in Messages + // (no silent clipboard gymnastics). + if notification.bundleID == "com.apple.MobileSMS" { + return .failed + } + // WhatsApp exposes no scripting interface, but its URL scheme can // open a specific conversation with text pre-filled — far better // than the clipboard, which leaves the user to find the chat and From 231e390557b4436eb5be08942d8dd02d2918674c Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 18:24:55 +0530 Subject: [PATCH 60/69] Give the floating peek its own backdrop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pill was drawn with no background — readable only while inside the black notch shape, invisible floating over the desktop wallpaper (which reported as 'peek isn't working'). It now carries a dark capsule backdrop like a system banner: readable over wallpaper and over the notch shape alike. Verified: Debug build succeeds --- boringNotch/Localizable.xcstrings | 3 +++ .../components/Notch/NotificationSneakPeekView.swift | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index 4d0ecce8e..1dfc21f39 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -14057,6 +14057,9 @@ } } } + }, + "Message couldn't be sent. Your draft is still here — try again or open Messages." : { + }, "Mic" : { "localizations" : { diff --git a/boringNotch/components/Notch/NotificationSneakPeekView.swift b/boringNotch/components/Notch/NotificationSneakPeekView.swift index a9d284e9b..30670bba3 100644 --- a/boringNotch/components/Notch/NotificationSneakPeekView.swift +++ b/boringNotch/components/Notch/NotificationSneakPeekView.swift @@ -49,6 +49,12 @@ struct NotificationSneakPeekView: View { } } .frame(width: 320) + .padding(.horizontal, 10) + .padding(.vertical, 6) + // The pill floats over the wallpaper now — it needs its own backdrop + // or the title melts into whatever's below (white/gray text on a + // light background, gray-on-black over the notch shape). + .background(.black.opacity(0.8), in: Capsule()) .padding(.bottom, 10) .contentShape(Rectangle()) .onTapGesture { From 62fbe5ea5960d535295ab0d8675ec7df3605af4a Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 21:56:05 +0530 Subject: [PATCH 61/69] Render the peek inside the sneak stack (where it was visible) + lane tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floating-overlay placement rendered off-surface — user-visible as 'peek isn't working' while the data path was fine. Bus->lane chain now test-covered (7 tests), which proved placement, not logic, was broken. The pill is back inside the closed-state VStack below every other row — the exact spot the user confirmed it visually before, now with the dark backdrop, and with all other fixes retained (queue badge, OSD lane exclusion, OTP width, staged reply timeouts, captured latency). Verified: Debug build succeeds --- boringNotch/ContentView.swift | 40 ++++++++++----------- boringNotchTests/NotchUIEventTests.swift | 45 +++++++++++++++++++++++- 2 files changed, 63 insertions(+), 22 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index db7d132c5..5b7a8e003 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -507,13 +507,25 @@ struct ContentView: View { } } } - } - } - .conditionalModifier((coordinator.shouldShowSneakPeek(on: vm.screenUUID) && (coordinator.sneakPeekState(for: vm.screenUUID).type == .music) && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.shouldShowSneakPeek(on: vm.screenUUID) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (vm.notchState == .closed))) { view in - view - .fixedSize() - } - .zIndex(1) + } + + // Notification peek: render BELOW every other row in + // closed state — this is the position the pill was + // visually confirmed in (the float-overlay variant + // rendered off-surface and was invisible). + if vm.notchState == .closed { + let peek = coordinator.notificationPeekState(for: vm.screenUUID) + if peek.show, let payload = peek.payload { + NotificationSneakPeekView(payload: payload) + .transition(.opacity) + } + } + } + .conditionalModifier((coordinator.shouldShowSneakPeek(on: vm.screenUUID) && (coordinator.sneakPeekState(for: vm.screenUUID).type == .music) && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.shouldShowSneakPeek(on: vm.screenUUID) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (vm.notchState == .closed))) { view in + view + .fixedSize() + } + .zIndex(1) if vm.notchState == .open { VStack { // An open notch with a live notification is showing the @@ -555,20 +567,6 @@ struct ContentView: View { .opacity(gestureProgress != 0 ? 1.0 - min(abs(gestureProgress) * 0.1, 0.3) : 1.0) } } - // Notification peek floats BELOW the chin as window-space decoration. - // It never lives inside the clipped notch shape, so it can neither - // stretch the panel vertically nor be pushed around by open/ - // greeting/inline states — it mirrors, then fades away. - .overlay(alignment: .top) { - if vm.notchState == .closed { - let peek = coordinator.notificationPeekState(for: vm.screenUUID) - if peek.show, let payload = peek.payload { - NotificationSneakPeekView(payload: payload) - .padding(.top, displayClosedNotchHeight + 24) - .transition(.move(edge: .top).combined(with: .opacity)) - } - } - } .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], delegate: GeneralDropTargetDelegate(isTargeted: $dropInteraction.generalDropTargeting)) } diff --git a/boringNotchTests/NotchUIEventTests.swift b/boringNotchTests/NotchUIEventTests.swift index 5b59e751c..142c53f7a 100644 --- a/boringNotchTests/NotchUIEventTests.swift +++ b/boringNotchTests/NotchUIEventTests.swift @@ -26,7 +26,7 @@ final class NotchUIEventTests: XCTestCase { let expectation = expectation(description: "event delivered") NotchUIEventBus.events .sink { event in - guard case .sneakPeek(let type, let value, let icon, _, let uuid) = event else { + guard case .sneakPeek(let type, let value, let icon, _, let uuid, _, _) = event else { XCTFail("unexpected event") return } @@ -58,6 +58,49 @@ final class NotchUIEventTests: XCTestCase { NotchUIEventBus.events.send(.expandingView(type: .battery)) waitForExpectations(timeout: 1.0) } + + func testNotificationPeekLandsInLaneWithPayload() { + let expectation = expectation(description: "notification peek event carries payload") + NotchUIEventBus.events + .sink { event in + if case .sneakPeek(let type, _, _, _, _, _, let payload) = event, + type == .notification { + XCTAssertEqual(payload?.title ?? "", "Sender") + expectation.fulfill() + } + } + .store(in: &cancellables) + + NotchUIEventBus.events.send(.sneakPeek( + type: .notification, value: 0, + payload: NotificationPeekPayload( + appName: "WhatsApp", title: "Sender", body: "hello", + bundleID: "net.whatsapp.WhatsApp"))) + + waitForExpectations(timeout: 1.0) + } + + /// Full chain: peek event -> toggleSneakPeek routing -> dedicated lane state. + func testNotificationPeekEndsInLane() async throws { + NotchUIEventBus.events.send(.sneakPeek( + type: .notification, value: 0, duration: 3.0, + payload: NotificationPeekPayload( + appName: "WhatsApp", title: "Sender", body: "hello", + bundleID: "net.whatsapp.WhatsApp"))) + + try await Task.sleep(for: .milliseconds(500)) + + let visible = await MainActor.run { + BoringViewCoordinator.shared.notificationPeekStates.values.first { $0.show } + } + XCTAssertNotNil(visible, "notification peek should be visible in its lane") + XCTAssertEqual(visible?.payload?.title ?? "", "Sender") + if let uuid = visible?.targetScreenUUID { + await MainActor.run { + BoringViewCoordinator.shared.notificationPeekStates[uuid]?.show = false + } + } + } } final class MediaAppBundleIDTests: XCTestCase { From d9f1e8be374b930ae314807f75af80330f2bf96a Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 22:05:17 +0530 Subject: [PATCH 62/69] Peek pill: kill greedy GeometryReader (the giant black box) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screenshot showed the pill's capsule stretched to a huge dark box with the title at bottom and body floating top-center: the GeometryReader stretching to VStack-offered height inflated the whole HStack and its capsule backdrop. Confirmed with the bus->lane chain test still green. MarqueeText now gets a fixed 240pt frame width; the pill renders as the system-like band it was designed as — compact dot+icon in chin above, pill below, nothing else stretched. Verified: Debug build succeeds --- boringNotch/ContentView.swift | 8 ++++---- .../Notch/NotificationSneakPeekView.swift | 20 ++++++++++--------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 5b7a8e003..e49d3a841 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -509,10 +509,10 @@ struct ContentView: View { } } - // Notification peek: render BELOW every other row in - // closed state — this is the position the pill was - // visually confirmed in (the float-overlay variant - // rendered off-surface and was invisible). + // Notification peek: its lane is independent from the + // type-exclusive sneakPeekStates (a volume/music peek + // can be up at the same time); it renders simply + // below them, inside the shape like every other peek. if vm.notchState == .closed { let peek = coordinator.notificationPeekState(for: vm.screenUUID) if peek.show, let payload = peek.payload { diff --git a/boringNotch/components/Notch/NotificationSneakPeekView.swift b/boringNotch/components/Notch/NotificationSneakPeekView.swift index 30670bba3..5a5fe4471 100644 --- a/boringNotch/components/Notch/NotificationSneakPeekView.swift +++ b/boringNotch/components/Notch/NotificationSneakPeekView.swift @@ -38,15 +38,17 @@ struct NotificationSneakPeekView: View { .layoutPriority(1) } - GeometryReader { geo in - MarqueeText( - bodyText, - font: .caption, - color: .gray, - delayDuration: 0.8, - frameWidth: geo.size.width - ) - } + // Fixed width, never a GeometryReader: a greedy reader inflates + // vertically inside the notch's VStack and stretches the whole + // capsule (that's what turned the pill into a giant black box). + MarqueeText( + bodyText, + font: .caption, + color: .gray, + delayDuration: 0.8, + frameWidth: 240 + ) + .font(.caption) } .frame(width: 320) .padding(.horizontal, 10) From 24cc1238f003d41e7c7df95f83b8fafb9d8a4dcf Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 22:06:31 +0530 Subject: [PATCH 63/69] Peek: vertical layout, centered under the hardware notch, stripped text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per user direction for long messages: name row on top with the message under it, single line, truncated (no marquee — strippped like a stock macOS banner), and the pill centered horizontally under the physical notch rather than left-flanked like the music sneak peek. Layout: icon + title row -> truncated message line, rounded-rect backdrop, 380pt cap. ContentView centers the lane row. Verified: Debug build succeeds --- boringNotch/ContentView.swift | 10 +- .../Notch/NotificationSneakPeekView.swift | 91 ++++++++++--------- 2 files changed, 55 insertions(+), 46 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index e49d3a841..b487cec67 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -512,12 +512,16 @@ struct ContentView: View { // Notification peek: its lane is independent from the // type-exclusive sneakPeekStates (a volume/music peek // can be up at the same time); it renders simply - // below them, inside the shape like every other peek. + // below them, centered under the physical notch. if vm.notchState == .closed { let peek = coordinator.notificationPeekState(for: vm.screenUUID) if peek.show, let payload = peek.payload { - NotificationSneakPeekView(payload: payload) - .transition(.opacity) + HStack { + Spacer() + NotificationSneakPeekView(payload: payload) + .transition(.opacity) + Spacer() + } } } } diff --git a/boringNotch/components/Notch/NotificationSneakPeekView.swift b/boringNotch/components/Notch/NotificationSneakPeekView.swift index 5a5fe4471..e5fd05dcd 100644 --- a/boringNotch/components/Notch/NotificationSneakPeekView.swift +++ b/boringNotch/components/Notch/NotificationSneakPeekView.swift @@ -15,49 +15,57 @@ struct NotificationSneakPeekView: View { @EnvironmentObject var vm: BoringViewModel var body: some View { - HStack(spacing: 6) { - if let bundleID = payload.bundleID, - let icon = appIconAsNSImage(for: bundleID) { - Image(nsImage: icon) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 16, height: 16) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } else { - Image(systemName: "bell.badge.fill") - .font(.system(size: 12)) - .foregroundStyle(.gray) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + if let bundleID = payload.bundleID, + let icon = appIconAsNSImage(for: bundleID) { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + Image(systemName: "bell.badge.fill") + .font(.system(size: 12)) + .foregroundStyle(.gray) + } + + if let title = payload.title, !title.isEmpty { + Text(title) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.white) + .lineLimit(1) + } + + if let appName = payload.appName, !appName.isEmpty, payload.title == nil { + Text(appName) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.white) + .lineLimit(1) + } } - if let title = payload.title, !title.isEmpty { - Text(title) + // Message goes UNDER the name, single line, stripped — long + // messages are silently truncated rather than scrolled, exactly + // like a macOS banner. + if let message = strippedMessage, !message.isEmpty { + Text(message) .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.white) + .foregroundStyle(.gray) .lineLimit(1) - .layoutPriority(1) + .truncationMode(.tail) } - - // Fixed width, never a GeometryReader: a greedy reader inflates - // vertically inside the notch's VStack and stretches the whole - // capsule (that's what turned the pill into a giant black box). - MarqueeText( - bodyText, - font: .caption, - color: .gray, - delayDuration: 0.8, - frameWidth: 240 - ) - .font(.caption) } - .frame(width: 320) .padding(.horizontal, 10) .padding(.vertical, 6) // The pill floats over the wallpaper now — it needs its own backdrop // or the title melts into whatever's below (white/gray text on a // light background, gray-on-black over the notch shape). - .background(.black.opacity(0.8), in: Capsule()) - .padding(.bottom, 10) + .background(.black.opacity(0.8), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + // Cap: single stripped line, so anything longer than ~380 truncates. + .frame(maxWidth: 380) .contentShape(Rectangle()) .onTapGesture { // macOS-banner semantics: tapping the mirror opens the content — @@ -68,16 +76,13 @@ struct NotificationSneakPeekView: View { } } - private var bodyText: String { - var parts: [String] = [] - if payload.title == nil, let appName = payload.appName, !appName.isEmpty { - parts.append(appName) - } - if let body = payload.body, !body.isEmpty { - parts.append(body) - } - // Nothing but a title? Marquee the app name so the pill isn't empty. - if parts.isEmpty, let appName = payload.appName { parts.append(appName) } - return parts.joined(separator: " — ") + /// Flattened display message stripped to the essential body. + private var strippedMessage: String? { + guard let body = payload.body else { return nil } + let cleaned = body + .components(separatedBy: .newlines) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? nil : cleaned } } From a8795d477bc238fd987f98d8eb6753fce5c6a0f5 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 22:12:01 +0530 Subject: [PATCH 64/69] Peek is decoration-only again: no notch-width influence whatsoever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-width expansion came from Spacer-wrapped centering inside the shape's VStack — the spacers demanded unbounded width and pulled the whole panel. With the pill fully self-sized now (fixed anatomy, greedy GeometryReader gone), it floats back outside the clipped shape at a fixed offset under the closed notch: zero influence on the notch's horizontal or vertical metrics, caught by nothing outside its lane. Verified: Debug build succeeds --- boringNotch/ContentView.swift | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index b487cec67..c08fc26a5 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -508,22 +508,6 @@ struct ContentView: View { } } } - - // Notification peek: its lane is independent from the - // type-exclusive sneakPeekStates (a volume/music peek - // can be up at the same time); it renders simply - // below them, centered under the physical notch. - if vm.notchState == .closed { - let peek = coordinator.notificationPeekState(for: vm.screenUUID) - if peek.show, let payload = peek.payload { - HStack { - Spacer() - NotificationSneakPeekView(payload: payload) - .transition(.opacity) - Spacer() - } - } - } } .conditionalModifier((coordinator.shouldShowSneakPeek(on: vm.screenUUID) && (coordinator.sneakPeekState(for: vm.screenUUID).type == .music) && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.shouldShowSneakPeek(on: vm.screenUUID) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (vm.notchState == .closed))) { view in view @@ -571,6 +555,20 @@ struct ContentView: View { .opacity(gestureProgress != 0 ? 1.0 - min(abs(gestureProgress) * 0.1, 0.3) : 1.0) } } + // Notification peek floats BELOW the chin as window-space decoration. + // It never joins the clipped notch shape, so the notch never expands + // for it horizontally or vertically — the pill is an overlay at a + // fixed offset under the closed notch. + .overlay(alignment: .top) { + if vm.notchState == .closed { + let peek = coordinator.notificationPeekState(for: vm.screenUUID) + if peek.show, let payload = peek.payload { + NotificationSneakPeekView(payload: payload) + .padding(.top, displayClosedNotchHeight + 24) + .transition(.opacity) + } + } + } .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], delegate: GeneralDropTargetDelegate(isTargeted: $dropInteraction.generalDropTargeting)) } From dd8b86c09f588ea1db69c151da69ccf35178893a Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 22:16:36 +0530 Subject: [PATCH 65/69] Notification pill rendered as a real ZStack child, offset below chin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay-decoration variant still wasn't painting on builds even with fixed anatomy (the overlay path was the only unverified variable after lane tests proved the data path). A plain second child of the top ZStack renders in window space by construction, keeping the shape untouched. If the pill STILL doesn't appear with this build, the remaining cause is the Settings toggle state or the lane not refilling on your machine — next step then is a one-shot console trace, not more layout surgery. Verified: Debug build succeeds --- boringNotch/ContentView.swift | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index c08fc26a5..fc24d790c 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -554,17 +554,15 @@ struct ContentView: View { .allowsHitTesting(vm.notchState == .open) .opacity(gestureProgress != 0 ? 1.0 - min(abs(gestureProgress) * 0.1, 0.3) : 1.0) } - } - // Notification peek floats BELOW the chin as window-space decoration. - // It never joins the clipped notch shape, so the notch never expands - // for it horizontally or vertically — the pill is an overlay at a - // fixed offset under the closed notch. - .overlay(alignment: .top) { + // Notification pill: a plain second child of the top ZStack, + // offset below the chin. NOT an overlay and NOT in the VStack — + // so it can't stretch shape metrics but also can't be skipped + // by overlay decoration semantics. if vm.notchState == .closed { let peek = coordinator.notificationPeekState(for: vm.screenUUID) if peek.show, let payload = peek.payload { NotificationSneakPeekView(payload: payload) - .padding(.top, displayClosedNotchHeight + 24) + .offset(y: displayClosedNotchHeight + 24) .transition(.opacity) } } From 76f9c2e0b031592426de4a0ad852ba2eeaff0977 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 15 Aug 2026 22:26:35 +0530 Subject: [PATCH 66/69] Add temporary trace when notch opens (empty-open-panel diagnosis) --- boringNotch/ContentView.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index fc24d790c..1a64d00cb 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -294,6 +294,12 @@ struct ContentView: View { isHovering = false } } + // Temporary trace for the empty-open-panel report. + if newState == .open { + Log.notifications.debug( + "notch opened; activeNotification=\(SystemNotificationManager.shared.activeNotification?.id ?? "nil"), compactMode=\(Defaults[.compactMode])" + ) + } } // A new notification always takes the front of the stack, // even if the user had swiped away to music. From e6231cd062b58343d77da3469107310b76dd9bae Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Fri, 28 Aug 2026 17:06:06 +0530 Subject: [PATCH 67/69] Fix sender app icons on notifications, remove notification sneak peek Two notification UX changes: - Sender icon resolution: the XPC helper's one-shot running-apps match could leave bundleID nil forever, so every notification surface fell back to a grey bell instead of the app icon. Resolution now falls back to a direct /Applications probe and a bounded name scan (memoized, hits and misses), and SystemNotificationManager re-resolves app-side when the helper's bundleID is missing. Adds BundleIDResolverTests (8 tests). - Remove the notification sneak peek: the floating pill below the notch is gone entirely (view, coordinator peek lane, event payload, defaults key, settings toggle, strings, tests). Notifications surface as the chin pill (app icon + live dot) and the expanded card on manual open. Co-authored-by: TheBoringMajdoor --- .../NotificationWatcher.swift | 72 ++++++++- boringNotch.xcodeproj/project.pbxproj | 4 - boringNotch/BoringViewCoordinator.swift | 107 +------------- boringNotch/ContentView.swift | 19 +-- boringNotch/Localizable.xcstrings | 6 - .../Notch/NotificationSneakPeekView.swift | 88 ----------- .../Views/NotificationSettingsView.swift | 11 -- boringNotch/helpers/AppIcons.swift | 118 +++++++++++++++ .../managers/SystemNotificationManager.swift | 58 +++----- boringNotch/models/Constants.swift | 3 - boringNotch/models/NotchUIEvent.swift | 13 +- boringNotchTests/BundleIDResolverTests.swift | 138 ++++++++++++++++++ boringNotchTests/NotchUIEventTests.swift | 86 ++++++----- 13 files changed, 396 insertions(+), 327 deletions(-) delete mode 100644 boringNotch/components/Notch/NotificationSneakPeekView.swift create mode 100644 boringNotchTests/BundleIDResolverTests.swift diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index 01779683c..e310482b0 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -329,19 +329,89 @@ final class NotificationWatcher { return found } + /// Resolution results, hits and misses both, keyed by normalized name, so + /// a repeat banner from the same app never rescans the disk — the helper + /// captures every banner, and a directory walk must be once per unique + /// name. + private var bundleIDCache: [String: String] = [:] + private var bundleIDMisses: Set = [] + /// Matches on a normalized name because both sides can carry invisible /// bidi marks: WhatsApp's `localizedName` is literally "\u{200E}WhatsApp" /// (LEFT-TO-RIGHT MARK), and the banner's own description is wrapped in /// isolates. Comparing raw strings silently failed to resolve WhatsApp's /// bundle ID, which dropped every one of its notifications at the /// allow-list check. + /// + /// The running-apps match stays first, but it misses when the app was + /// renamed, a helper process owns the notification, the app quit between + /// posting and capture, or the helper's view of running apps is + /// restricted. Two on-disk fallbacks follow before giving up: a direct + /// probe of the usual install locations, then one bounded scan of + /// /Applications. private func bundleID(forAppNamed name: String) -> String? { let target = Self.normalizedAppName(name) guard !target.isEmpty else { return nil } - return NSWorkspace.shared.runningApplications.first { + + if let cached = bundleIDCache[target] { return cached } + if bundleIDMisses.contains(target) { return nil } + + let resolved = NSWorkspace.shared.runningApplications.first { guard let localizedName = $0.localizedName else { return false } return Self.normalizedAppName(localizedName) == target }?.bundleIdentifier + ?? bundleIDFromKnownLocations(named: name) + ?? bundleIDFromApplicationsScan(matching: target) + + if let resolved { + bundleIDCache[target] = resolved + } else { + bundleIDMisses.insert(target) + } + return resolved + } + + /// Fallback 1: probe the usual install locations directly — cheap when + /// the banner name matches the bundle's directory name, which is the + /// common case. Tries the name as-is plus a no-spaces variant + /// ("Google Chrome" → "GoogleChrome"). + private func bundleIDFromKnownLocations(named name: String) -> String? { + // Case-preserving sibling of normalizedAppName: paths need the real + // capitalization, just without the invisible marks. + let displayName = name.filter { !$0.unicodeScalars.allSatisfy(Self.bidiControlCharacters.contains) } + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !displayName.isEmpty else { return nil } + + let candidates = [displayName, displayName.replacingOccurrences(of: " ", with: "")] + let directories = ["/Applications", NSHomeDirectory() + "/Applications", "/System/Applications"] + for directory in directories { + for candidate in candidates where !candidate.isEmpty { + let path = directory + "/" + candidate + ".app" + if FileManager.default.fileExists(atPath: path), + let bundleID = Bundle(url: URL(fileURLWithPath: path))?.bundleIdentifier { + return bundleID + } + } + } + return nil + } + + /// Fallback 2: a single bounded pass over the top level of /Applications + /// (and ~/Applications when present), matching normalized directory names + /// minus the extension. No recursion, and the memoization above bounds it + /// to once per unique app name. + private func bundleIDFromApplicationsScan(matching target: String) -> String? { + let directories = ["/Applications", NSHomeDirectory() + "/Applications"] + for directory in directories { + let entries = (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? [] + for entry in entries where entry.hasSuffix(".app") { + guard Self.normalizedAppName(String(entry.dropLast(".app".count))) == target else { continue } + if let bundleID = Bundle(url: URL(fileURLWithPath: directory + "/" + entry))?.bundleIdentifier { + return bundleID + } + } + } + return nil } private static func normalizedAppName(_ name: String) -> String { diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 6f840e5b8..9b74ff169 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -148,7 +148,6 @@ AA02NSV12E7A0001 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA02NSV22E7A0001 /* NotificationSettingsView.swift */; }; AA03OTP12E7A0001 /* OTPDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA03OTP22E7A0001 /* OTPDetector.swift */; }; AA04LAS12E7A0001 /* LiveActivityStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA04LAS22E7A0001 /* LiveActivityStack.swift */; }; - C72BE62F8B734602B7098B6B /* NotificationSneakPeekView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10B1707DC2947A48AC00206 /* NotificationSneakPeekView.swift */; }; AA05SRM12E7A0001 /* SmartReplyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA05SRM22E7A0001 /* SmartReplyManager.swift */; }; B10348D92C74E56000475897 /* ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10348D82C74E56000475897 /* ConditionalModifier.swift */; }; B141C2412CA5F53F00AC8CC8 /* SparkleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B141C2402CA5F53E00AC8CC8 /* SparkleView.swift */; }; @@ -357,7 +356,6 @@ AA02NSV22E7A0001 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; AA03OTP22E7A0001 /* OTPDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPDetector.swift; sourceTree = ""; }; AA04LAS22E7A0001 /* LiveActivityStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityStack.swift; sourceTree = ""; }; - B10B1707DC2947A48AC00206 /* NotificationSneakPeekView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSneakPeekView.swift; sourceTree = ""; }; AA05SRM22E7A0001 /* SmartReplyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartReplyManager.swift; sourceTree = ""; }; AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputRouteResolver.swift; sourceTree = ""; }; B10348D82C74E56000475897 /* ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalModifier.swift; sourceTree = ""; }; @@ -871,7 +869,6 @@ AA01NLA22E7A0001 /* NotificationLiveActivity.swift */, AA06CHV22E7A0001 /* CompactHomeView.swift */, AA04LAS22E7A0001 /* LiveActivityStack.swift */, - B10B1707DC2947A48AC00206 /* NotificationSneakPeekView.swift */, 1194E8862EA6DDA7009C82D6 /* BoringNotchSkyLightWindow.swift */, 1160F8D72DD98230006FBB94 /* NotchShape.swift */, 9AB0C6BB2C73C9CB00F7CD30 /* NotchHomeView.swift */, @@ -1151,7 +1148,6 @@ AA01NLA12E7A0001 /* NotificationLiveActivity.swift in Sources */, AA06CHV12E7A0001 /* CompactHomeView.swift in Sources */, AA04LAS12E7A0001 /* LiveActivityStack.swift in Sources */, - C72BE62F8B734602B7098B6B /* NotificationSneakPeekView.swift in Sources */, AA02CAM12E7A0001 /* ContactAvatarManager.swift in Sources */, AA03OTP12E7A0001 /* OTPDetector.swift in Sources */, 1471639A2C5D35FF0068B555 /* MusicManager.swift in Sources */, diff --git a/boringNotch/BoringViewCoordinator.swift b/boringNotch/BoringViewCoordinator.swift index 8a7034318..95431b6cf 100644 --- a/boringNotch/BoringViewCoordinator.swift +++ b/boringNotch/BoringViewCoordinator.swift @@ -18,8 +18,6 @@ enum SneakContentType { case mic case battery case download - /// Passive marquee mirror of a system notification. - case notification } struct SneakPeekState { @@ -95,7 +93,6 @@ final class BoringViewCoordinator: ObservableObject { private var boringShelfCancellable: AnyCancellable? private var osdSourceCancellables: [AnyCancellable] = [] private var notificationLiveActivityCancellable: AnyCancellable? - private var notificationSneakPeekCancellable: AnyCancellable? private var uiEventCancellable: AnyCancellable? private init() { @@ -142,10 +139,10 @@ final class BoringViewCoordinator: ObservableObject { Task { @MainActor in guard let self else { return } switch event { - case .sneakPeek(let type, let value, let icon, let accent, let uuid, let duration, let payload): + case .sneakPeek(let type, let value, let icon, let accent, let uuid, let duration): self.toggleSneakPeek( status: true, type: type, duration: duration, value: value, - icon: icon, accent: accent, targetScreenUUID: uuid, payload: payload) + icon: icon, accent: accent, targetScreenUUID: uuid) case .expandingView(let type): self.toggleExpandingView(status: true, type: type) } @@ -187,8 +184,8 @@ final class BoringViewCoordinator: ObservableObject { } } - // Observe changes to the notification features (live activity AND - // mirrored sneak peek share one watcher; both must start it). + // Observe changes to the notification live activity toggle; it owns + // the notification watcher lifecycle. notificationLiveActivityCancellable = Defaults.publisher(.notificationLiveActivity) .sink { change in Task { @MainActor in @@ -197,18 +194,7 @@ final class BoringViewCoordinator: ObservableObject { if !SystemNotificationManager.shared.isWatching { Defaults[.notificationLiveActivity] = false } - } else if !Defaults[.notificationSneakPeek] { - SystemNotificationManager.shared.stop() - } - } - } - - notificationSneakPeekCancellable = Defaults.publisher(.notificationSneakPeek) - .sink { change in - Task { @MainActor in - if change.newValue { - await SystemNotificationManager.shared.start() - } else if !Defaults[.notificationLiveActivity] { + } else { SystemNotificationManager.shared.stop() } } @@ -221,7 +207,7 @@ final class BoringViewCoordinator: ObservableObject { await MediaKeyInterceptor.shared.start(promptIfNeeded: false) } - if Defaults[.notificationLiveActivity] || Defaults[.notificationSneakPeek] { + if Defaults[.notificationLiveActivity] { await SystemNotificationManager.shared.start() } self.applyOSDSources() @@ -236,89 +222,10 @@ final class BoringViewCoordinator: ObservableObject { // Dictionary to hold hide tasks for each screen UUID private var sneakPeekTasks: [String: Task] = [:] - // MARK: - Notification peek lane - - /// Notification peek state per screen. Independent from `sneakPeekStates` - /// on purpose: OSD peeks (volume/brightness) and music peeks are - /// type-exclusive, while a notification peek must be able to show AT THE - /// SAME TIME as any of them (arriving while the user is adjusting volume, - /// while a song-change marquee is up, etc.). - struct NotificationPeekState { - var show: Bool = false - var payload: NotificationPeekPayload? = nil - var targetScreenUUID: String? = nil - } - - @Published var notificationPeekStates: [String: NotificationPeekState] = [:] - private var notificationPeekTasks: [String: Task] = [:] - private let notificationPeekDuration: TimeInterval = 5.0 - - @MainActor - private func showNotificationPeek(for uuid: String, payload: NotificationPeekPayload, duration: TimeInterval) { - var state = notificationPeekStates[uuid] ?? NotificationPeekState(targetScreenUUID: uuid) - state.show = true - state.payload = payload - state.targetScreenUUID = uuid - notificationPeekStates[uuid] = state - - notificationPeekTasks[uuid]?.cancel() - notificationPeekTasks[uuid] = Task { @MainActor [weak self] in - try? await Task.sleep(for: .seconds(duration)) - guard !Task.isCancelled, let self else { return } - withAnimation(.smooth) { - guard var s = self.notificationPeekStates[uuid] else { return } - s.show = false - self.notificationPeekStates[uuid] = s - } - } - } - - private func toggleNotificationPeek( - status: Bool, duration: TimeInterval, payload: NotificationPeekPayload?, targetScreenUUID: String? - ) { - guard status, let payload else { - // Dismiss request - Task { @MainActor in - if let uuid = targetScreenUUID { - notificationPeekTasks[uuid]?.cancel() - withAnimation(.smooth) { - notificationPeekStates[uuid]?.show = false - } - } - } - return - } - Task { @MainActor in - if let targetUUID = targetScreenUUID { - showNotificationPeek(for: targetUUID, payload: payload, duration: max(duration, notificationPeekDuration)) - } else { - for uuid in NSScreen.screens.compactMap({ $0.displayUUID }) { - showNotificationPeek(for: uuid, payload: payload, duration: max(duration, notificationPeekDuration)) - } - } - } - } - - /// Peek state accessors for views (notification lane). - func notificationPeekState(for screenUUID: String?) -> NotificationPeekState { - guard let uuid = screenUUID else { return NotificationPeekState() } - return notificationPeekStates[uuid] ?? NotificationPeekState(targetScreenUUID: uuid) - } - func toggleSneakPeek( status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, - icon: String = "", accent: Color? = nil, targetScreenUUID: String? = nil, - payload: NotificationPeekPayload? = nil + icon: String = "", accent: Color? = nil, targetScreenUUID: String? = nil ) { - // Notification peeks live in their own lane — they may coexist with - // any OSD/music peek instead of replacing (or being replaced by) it. - if type == .notification { - toggleNotificationPeek( - status: status, duration: duration, payload: payload, - targetScreenUUID: targetScreenUUID) - return - } - if type != .music { // close() if !Defaults[.osdReplacement] { diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 1a64d00cb..0edcf74e7 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -435,11 +435,8 @@ struct ContentView: View { .frame(width: 76, alignment: .trailing) } .frame(height: displayClosedNotchHeight, alignment: .center) - } else if coordinator.shouldShowSneakPeek(on: vm.screenUUID) && Defaults[.inlineOSD] && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .battery) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .notification) && vm.notchState == .closed { - // .notification is excluded: the passive peek is - // not an OSD event and would render here as a - // 0-value volume bar displacing the music pill. - InlineOSD( + } else if coordinator.shouldShowSneakPeek(on: vm.screenUUID) && Defaults[.inlineOSD] && (coordinator.sneakPeekState(for: vm.screenUUID).type != .music) && (coordinator.sneakPeekState(for: vm.screenUUID).type != .battery) && vm.notchState == .closed { + InlineOSD( type: coordinator.binding(for: vm.screenUUID).type, value: coordinator.binding(for: vm.screenUUID).value, icon: coordinator.binding(for: vm.screenUUID).icon, @@ -560,18 +557,6 @@ struct ContentView: View { .allowsHitTesting(vm.notchState == .open) .opacity(gestureProgress != 0 ? 1.0 - min(abs(gestureProgress) * 0.1, 0.3) : 1.0) } - // Notification pill: a plain second child of the top ZStack, - // offset below the chin. NOT an overlay and NOT in the VStack — - // so it can't stretch shape metrics but also can't be skipped - // by overlay decoration semantics. - if vm.notchState == .closed { - let peek = coordinator.notificationPeekState(for: vm.screenUUID) - if peek.show, let payload = peek.payload { - NotificationSneakPeekView(payload: payload) - .offset(y: displayClosedNotchHeight + 24) - .transition(.opacity) - } - } } .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], delegate: GeneralDropTargetDelegate(isTargeted: $dropInteraction.generalDropTargeting)) } diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index 1dfc21f39..2cf786669 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -3233,9 +3233,6 @@ } } } - }, - "Briefly mirrors an incoming notification in a scrolling marquee below the notch, then dismisses it. No banner holding, no keyboard focus — purely a glance." : { - }, "Brightness" : { "localizations" : { @@ -23621,9 +23618,6 @@ } } } - }, - "Sneak peek on new notifications" : { - }, "Sneak Peek shows the media title and artist under the notch for a few seconds." : { "localizations" : { diff --git a/boringNotch/components/Notch/NotificationSneakPeekView.swift b/boringNotch/components/Notch/NotificationSneakPeekView.swift deleted file mode 100644 index e5fd05dcd..000000000 --- a/boringNotch/components/Notch/NotificationSneakPeekView.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// NotificationSneakPeekView.swift -// boringNotch -// -// Passive marquee mirror of an incoming notification — shown below the -// closed notch for a few seconds, then gone. Purely visual: it never holds -// banners, never touches keyboard focus, never opens UI. -// - -import SwiftUI - -struct NotificationSneakPeekView: View { - let payload: NotificationPeekPayload - - @EnvironmentObject var vm: BoringViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - if let bundleID = payload.bundleID, - let icon = appIconAsNSImage(for: bundleID) { - Image(nsImage: icon) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 16, height: 16) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } else { - Image(systemName: "bell.badge.fill") - .font(.system(size: 12)) - .foregroundStyle(.gray) - } - - if let title = payload.title, !title.isEmpty { - Text(title) - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.white) - .lineLimit(1) - } - - if let appName = payload.appName, !appName.isEmpty, payload.title == nil { - Text(appName) - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.white) - .lineLimit(1) - } - } - - // Message goes UNDER the name, single line, stripped — long - // messages are silently truncated rather than scrolled, exactly - // like a macOS banner. - if let message = strippedMessage, !message.isEmpty { - Text(message) - .font(.caption) - .foregroundStyle(.gray) - .lineLimit(1) - .truncationMode(.tail) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - // The pill floats over the wallpaper now — it needs its own backdrop - // or the title melts into whatever's below (white/gray text on a - // light background, gray-on-black over the notch shape). - .background(.black.opacity(0.8), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - // Cap: single stripped line, so anything longer than ~380 truncates. - .frame(maxWidth: 380) - .contentShape(Rectangle()) - .onTapGesture { - // macOS-banner semantics: tapping the mirror opens the content — - // the expanded notification panel inside the notch. The arrival - // already registered the notification as the active live - // activity, so opening the notch surfaces it directly. - _ = vm.open() - } - } - - /// Flattened display message stripped to the essential body. - private var strippedMessage: String? { - guard let body = payload.body else { return nil } - let cleaned = body - .components(separatedBy: .newlines) - .joined(separator: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - return cleaned.isEmpty ? nil : cleaned - } -} diff --git a/boringNotch/components/Settings/Views/NotificationSettingsView.swift b/boringNotch/components/Settings/Views/NotificationSettingsView.swift index be37c4878..d5488f842 100644 --- a/boringNotch/components/Settings/Views/NotificationSettingsView.swift +++ b/boringNotch/components/Settings/Views/NotificationSettingsView.swift @@ -30,7 +30,6 @@ private let knownNotificationApps: [KnownNotificationApp] = [ struct NotificationSettingsView: View { @Default(.notificationLiveActivity) var notificationLiveActivity - @Default(.notificationSneakPeek) var notificationSneakPeek @Default(.notificationsFromAllApps) var notificationsFromAllApps @Default(.notificationAllowedApps) var allowedApps @@ -46,16 +45,6 @@ struct NotificationSettingsView: View { .foregroundStyle(.secondary) } - Section { - Defaults.Toggle(key: .notificationSneakPeek) { - Text("Sneak peek on new notifications") - } - } footer: { - Text("Briefly mirrors an incoming notification in a scrolling marquee below the notch, then dismisses it. No banner holding, no keyboard focus — purely a glance.") - .font(.caption) - .foregroundStyle(.secondary) - } - Section { Defaults.Toggle(key: .notificationsFromAllApps) { Text("From all apps") diff --git a/boringNotch/helpers/AppIcons.swift b/boringNotch/helpers/AppIcons.swift index e3eae57d8..806e552fd 100644 --- a/boringNotch/helpers/AppIcons.swift +++ b/boringNotch/helpers/AppIcons.swift @@ -84,3 +84,121 @@ func appIconAsNSImage(for bundleID: String) -> NSImage? { return nil } +/// Resolves an app's bundle ID from its display name when the XPC helper's +/// own one-shot match came back empty (the notification then arrives with a +/// nil bundleID and every icon surface falls back to the grey bell). +/// Fallback chain, cheapest first: +/// 1. running applications, matched on normalized `localizedName` +/// 2. direct probe of the standard /Applications locations +/// 3. one bounded listing of those directories (filename first, then +/// CFBundleDisplayName / CFBundleName) +/// Hits and misses are memoized, so a chatty app never re-scans the disk. +final class BundleIDResolver { + static let shared = BundleIDResolver() + + /// Thread-safety: the only production caller (`SystemNotificationManager.add`) + /// is @MainActor, but the lock costs nothing and keeps the cache sound if a + /// caller ever resolves off-queue. It is only held around dictionary access, + /// never during disk I/O — a duplicate concurrent lookup can do the scan + /// twice, in which case the last (identical) write wins. + private let lock = NSLock() + private var cache: [String: String?] = [:] + + /// Probe order: /Applications, then the user's own ~/Applications, then + /// /System/Applications. The app is sandboxed, so `homeDirectoryForCurrentUser` + /// would point at the container — build the real home from the login name + /// instead. Injectable so tests can point the resolver at a fixture dir. + static let defaultSearchDirectories: [URL] = [ + URL(fileURLWithPath: "/Applications"), + URL(fileURLWithPath: "/Users/\(NSUserName())/Applications"), + URL(fileURLWithPath: "/System/Applications"), + ] + + func bundleID(forAppNamed name: String, searchDirectories: [URL] = BundleIDResolver.defaultSearchDirectories) -> String? { + let target = Self.normalizedAppName(name) + guard !target.isEmpty else { return nil } + + lock.lock() + let cached = cache[target] + lock.unlock() + if let cached { return cached } + + let resolved = resolve(target: target, name: name, searchDirectories: searchDirectories) + + lock.lock() + cache[target] = resolved + lock.unlock() + return resolved + } + + private func resolve(target: String, name: String, searchDirectories: [URL]) -> String? { + // 1. Running apps — the same match the helper attempts, redone here in + // case the app (re)launched between posting and capture. + if let running = NSWorkspace.shared.runningApplications.first(where: { + guard let localizedName = $0.localizedName else { return false } + return Self.normalizedAppName(localizedName) == target + })?.bundleIdentifier { + return running + } + + // 2. Direct probes: the name as reported, plus a space-stripped + // variant ("Google Chrome" -> "GoogleChrome.app" style installs). + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + let withoutSpaces = trimmed.replacingOccurrences(of: " ", with: "") + let candidates = withoutSpaces == trimmed ? [trimmed] : [trimmed, withoutSpaces] + for directory in searchDirectories { + for candidate in candidates { + let url = directory.appendingPathComponent(candidate).appendingPathExtension("app") + if let bundleID = bundleIdentifier(at: url) { return bundleID } + } + } + + // 3. One bounded listing per directory — no recursion into subfolders. + for directory in searchDirectories { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsSubdirectoryDescendants, .skipsHiddenFiles] + ) else { continue } + for entry in entries where entry.pathExtension == "app" { + guard let bundle = Bundle(url: entry), let bundleID = bundle.bundleIdentifier else { continue } + if Self.normalizedAppName(entry.deletingPathExtension().lastPathComponent) == target { + return bundleID + } + let infoNames = [ + bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String, + bundle.object(forInfoDictionaryKey: "CFBundleName") as? String, + ] + if infoNames.contains(where: { $0.map { Self.normalizedAppName($0) == target } ?? false }) { + return bundleID + } + } + } + return nil + } + + /// Mirrors the helper's `normalizedAppName`: both sides can carry invisible + /// bidi marks — WhatsApp's `localizedName` is literally "\u{200E}WhatsApp" — + /// so strip them along with case and surrounding whitespace before comparing. + static func normalizedAppName(_ name: String) -> String { + name.filter { !$0.unicodeScalars.allSatisfy(bidiControlCharacters.contains) } + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } + + private func bundleIdentifier(at url: URL) -> String? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return Bundle(url: url)?.bundleIdentifier + } + + /// Directional formatting characters Notification Center and app names both + /// sprinkle in: LRM/RLM, the isolate family, and the embedding/override set. + private static let bidiControlCharacters: CharacterSet = { + var set = CharacterSet() + set.insert(charactersIn: "\u{200E}\u{200F}") // LRM, RLM + set.insert(charactersIn: "\u{2066}"..."\u{2069}") // isolates + PDI + set.insert(charactersIn: "\u{202A}"..."\u{202E}") // embeddings/overrides + return set + }() +} + diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index a78cd88fa..4f325116b 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -168,10 +168,23 @@ final class SystemNotificationManager: ObservableObject { return string.isEmpty ? nil : string } + // The helper's one-shot bundle-ID match can miss (renamed app, helper + // process owning the banner, an app that quit between posting and + // capture). Re-resolve from the app name here so every icon surface + // gets the real app instead of the grey bell fallback. A + // helper-provided bundleID always wins. + let appName = value("appName") + var bundleID = value("bundleID") + if bundleID == nil, let appName, + let resolved = BundleIDResolver.shared.bundleID(forAppNamed: appName) { + bundleID = resolved + Log.notifications.debug("[boringNotch] resolved bundleID app-side: \(appName) -> \(resolved)") + } + let notification = SystemNotification( id: token, - appName: value("appName"), - bundleID: value("bundleID"), + appName: appName, + bundleID: bundleID, title: value("title"), subtitle: value("subtitle"), body: value("body"), @@ -190,41 +203,6 @@ final class SystemNotificationManager: ObservableObject { return } - // Passive path (mirrored sneak peek + compact dot): - // - scroll a marquee pill below the notch immediately, - // - show the compact dot/app-icon in the chin (the existing - // live-activity slot — the gesture surface for tap/hover), - // - hold NOTHING: no banner parking, no key focus, ever. - // - // OTP/code notifications keep the interactive path (the copy - // affordance lives in the live activity, not the passive mirror), - // and mid-reply arrivals keep queueing so the +N badge lives. - if Defaults[.notificationSneakPeek], !isComposingReply, - notification.detectedCode == nil { - let message: String? = { - if let subtitle = notification.subtitle, let body = notification.body { - return subtitle + " — " + body - } - return notification.subtitle ?? notification.body - }() - NotchUIEventBus.events.send( - .sneakPeek( - type: .notification, - value: 0, - duration: 8.0, - payload: NotificationPeekPayload( - appName: notification.appName, - title: notification.title, - body: message, - bundleID: notification.bundleID - ) - ) - ) - // Compact indicator in the chin without holding the banner. - show(notification, holdingBanner: false) - return - } - // Hold the banner either way: a queued notification still needs its // reply field alive for when it's promoted, and holding is what // keeps that possible past the banner's few seconds on screen. @@ -334,7 +312,7 @@ final class SystemNotificationManager: ObservableObject { /// outgoing notification goes back into the queue rather than away, and /// it needs to keep its held banner or it won't be replyable when it /// comes back around. - private func show(_ notification: SystemNotification, releasingPrevious: Bool = true, holdingBanner: Bool = true) { + private func show(_ notification: SystemNotification, releasingPrevious: Bool = true) { // A newer notification replaces the active one directly here rather // than going through dismissActive, so its hold was never being // released: dismissActive only releases whatever activeNotification @@ -348,9 +326,7 @@ final class SystemNotificationManager: ObservableObject { if releasingPrevious, let previous = activeNotification, previous.id != notification.id { XPCHelperClient.shared.releaseNotification(token: previous.id) } - if holdingBanner { - holdSystemBanner(notification) - } + holdSystemBanner(notification) withAnimation(.smooth) { activeNotification = notification } dismissTask?.cancel() dismissTask = Task { [weak self] in diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 4b619e236..6cf32ebbc 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -302,9 +302,6 @@ extension Defaults.Keys { // MARK: Notifications /// Off by default: mirroring banners needs Accessibility access. static let notificationLiveActivity = Key("notificationLiveActivity", default: false) - /// Passive marquee peek below the notch on notification arrival — - /// mirrors the banner briefly without holding it, focusing, or opening UI. - static let notificationSneakPeek = Key("notificationSneakPeek", default: false) static let notificationsFromAllApps = Key("notificationsFromAllApps", default: false) static let notificationAllowedApps = Key>( "notificationAllowedApps", diff --git a/boringNotch/models/NotchUIEvent.swift b/boringNotch/models/NotchUIEvent.swift index 6b1bcb5cf..d63cf885f 100644 --- a/boringNotch/models/NotchUIEvent.swift +++ b/boringNotch/models/NotchUIEvent.swift @@ -10,16 +10,6 @@ import SwiftUI import Combine -/// Payload for `.notification` sneak peeks: the passive marquee mirror of -/// a banner's content — icon/title/body travel with the event so peek -/// rendering never needs to reach back into the notification queue. -struct NotificationPeekPayload { - var appName: String? - var title: String? - var body: String? - var bundleID: String? -} - /// UI-presentation events emitted by hardware/OS-facing managers. /// /// Inverts the old "manager calls `BoringViewCoordinator.shared`" direction: @@ -35,8 +25,7 @@ enum NotchUIEvent { icon: String = "", accent: Color? = nil, targetScreenUUID: String? = nil, - duration: TimeInterval = 1.5, - payload: NotificationPeekPayload? = nil + duration: TimeInterval = 1.5 ) case expandingView(type: SneakContentType) } diff --git a/boringNotchTests/BundleIDResolverTests.swift b/boringNotchTests/BundleIDResolverTests.swift new file mode 100644 index 000000000..cd804e8c3 --- /dev/null +++ b/boringNotchTests/BundleIDResolverTests.swift @@ -0,0 +1,138 @@ +// +// BundleIDResolverTests.swift +// boringNotch +// +// SPDX-License-Identifier: GPL-3.0-only +// +// Covers the app-side bundle-ID resolver: normalization parity with the +// helper, the direct-probe and directory-scan fallbacks against an injected +// fixture directory, and memoization of hits and misses. +// + +import XCTest +@testable import boringNotch + +final class BundleIDResolverTests: XCTestCase { + + private var resolver: BundleIDResolver! + private var fixtureRoot: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + resolver = BundleIDResolver() + fixtureRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("BundleIDResolverTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: fixtureRoot, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: fixtureRoot) + fixtureRoot = nil + resolver = nil + try super.tearDownWithError() + } + + /// Builds a minimal `.app` — just `Contents/Info.plist` with a bundle ID, + /// optionally a display name — inside the fixture root. + @discardableResult + private func makeFixtureApp(named name: String, bundleID: String, displayName: String? = nil) throws -> URL { + let appURL = fixtureRoot.appendingPathComponent(name).appendingPathExtension("app") + let contents = appURL.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + var info: [String: Any] = ["CFBundleIdentifier": bundleID] + if let displayName { info["CFBundleDisplayName"] = displayName } + let data = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + try data.write(to: contents.appendingPathComponent("Info.plist")) + return appURL + } + + // MARK: - Normalization + + func testNormalizationStripsBidiMarksCaseAndWhitespace() { + XCTAssertEqual(BundleIDResolver.normalizedAppName("WhatsApp"), "whatsapp") + XCTAssertEqual(BundleIDResolver.normalizedAppName("whatsapp"), "whatsapp") + // WhatsApp's localizedName really is "\u{200E}WhatsApp" (LRM prefix). + XCTAssertEqual(BundleIDResolver.normalizedAppName("\u{200E}WhatsApp"), "whatsapp") + XCTAssertEqual(BundleIDResolver.normalizedAppName(" \u{202A}WhatsApp\u{202C}\u{2068} "), "whatsapp") + XCTAssertEqual( + BundleIDResolver.normalizedAppName("WhatsApp"), + BundleIDResolver.normalizedAppName("\u{200E}whatsapp") + ) + } + + // MARK: - Direct probe + + /// `/.app` exists: the probe must hit before any directory + /// listing is needed. + func testDirectProbeFindsFixture() throws { + let name = "ProbeFixture\(UUID().uuidString.prefix(8))" + let bundleID = "com.test.probe.\(name)" + try makeFixtureApp(named: name, bundleID: bundleID) + + XCTAssertEqual(resolver.bundleID(forAppNamed: name, searchDirectories: [fixtureRoot]), bundleID) + } + + // MARK: - Directory scan + + /// The .app's filename doesn't match the queried name, so only the scan's + /// CFBundleDisplayName comparison can find it. + func testDirectoryScanMatchesDisplayName() throws { + let tag = UUID().uuidString.prefix(8) + let displayName = "Scan Fixture \(tag)" + let bundleID = "com.test.scan.\(tag)" + try makeFixtureApp(named: "scanfixture-\(tag)", bundleID: bundleID, displayName: displayName) + + XCTAssertEqual(resolver.bundleID(forAppNamed: displayName, searchDirectories: [fixtureRoot]), bundleID) + } + + /// Filename match through normalization: lowercase query with a bidi mark + /// must still match the capitalized .app name. + func testDirectoryScanMatchesFilenameIgnoringCaseAndBidiMarks() throws { + let name = "BidiFixture\(UUID().uuidString.prefix(8))" + let bundleID = "com.test.bidi.\(name)" + try makeFixtureApp(named: name, bundleID: bundleID) + + XCTAssertEqual( + resolver.bundleID(forAppNamed: "\u{200E}\(name.lowercased())", searchDirectories: [fixtureRoot]), + bundleID + ) + } + + // MARK: - Misses + + func testNoMatchReturnsNil() { + let name = "DefinitelyNotARealApp-\(UUID().uuidString)" + XCTAssertNil(resolver.bundleID(forAppNamed: name, searchDirectories: [fixtureRoot])) + } + + func testEmptyAndWhitespaceNamesReturnNil() { + XCTAssertNil(resolver.bundleID(forAppNamed: "")) + XCTAssertNil(resolver.bundleID(forAppNamed: " ")) + XCTAssertNil(resolver.bundleID(forAppNamed: "\u{200E}")) + } + + // MARK: - Cache + + /// After the first (disk) resolution, deleting the fixture must not change + /// the answer — proof the second call never touches the directory again. + func testSecondResolutionComesFromCache() throws { + let name = "CacheFixture\(UUID().uuidString.prefix(8))" + let bundleID = "com.test.cache.\(name)" + let appURL = try makeFixtureApp(named: name, bundleID: bundleID) + + XCTAssertEqual(resolver.bundleID(forAppNamed: name, searchDirectories: [fixtureRoot]), bundleID) + + try FileManager.default.removeItem(at: appURL) + XCTAssertEqual(resolver.bundleID(forAppNamed: name, searchDirectories: [fixtureRoot]), bundleID) + } + + /// Negative results are cached too: a miss followed by the app appearing + /// on disk must still answer the cached miss rather than rescanning. + func testNegativeResultIsCached() throws { + let name = "LateFixture\(UUID().uuidString.prefix(8))" + XCTAssertNil(resolver.bundleID(forAppNamed: name, searchDirectories: [fixtureRoot])) + + try makeFixtureApp(named: name, bundleID: "com.test.late.\(name)") + XCTAssertNil(resolver.bundleID(forAppNamed: name, searchDirectories: [fixtureRoot])) + } +} diff --git a/boringNotchTests/NotchUIEventTests.swift b/boringNotchTests/NotchUIEventTests.swift index 142c53f7a..6e38b8320 100644 --- a/boringNotchTests/NotchUIEventTests.swift +++ b/boringNotchTests/NotchUIEventTests.swift @@ -9,6 +9,7 @@ import XCTest import Combine +import SwiftUI @testable import boringNotch final class NotchUIEventTests: XCTestCase { @@ -26,7 +27,7 @@ final class NotchUIEventTests: XCTestCase { let expectation = expectation(description: "event delivered") NotchUIEventBus.events .sink { event in - guard case .sneakPeek(let type, let value, let icon, _, let uuid, _, _) = event else { + guard case .sneakPeek(let type, let value, let icon, _, let uuid, _) = event else { XCTFail("unexpected event") return } @@ -58,49 +59,6 @@ final class NotchUIEventTests: XCTestCase { NotchUIEventBus.events.send(.expandingView(type: .battery)) waitForExpectations(timeout: 1.0) } - - func testNotificationPeekLandsInLaneWithPayload() { - let expectation = expectation(description: "notification peek event carries payload") - NotchUIEventBus.events - .sink { event in - if case .sneakPeek(let type, _, _, _, _, _, let payload) = event, - type == .notification { - XCTAssertEqual(payload?.title ?? "", "Sender") - expectation.fulfill() - } - } - .store(in: &cancellables) - - NotchUIEventBus.events.send(.sneakPeek( - type: .notification, value: 0, - payload: NotificationPeekPayload( - appName: "WhatsApp", title: "Sender", body: "hello", - bundleID: "net.whatsapp.WhatsApp"))) - - waitForExpectations(timeout: 1.0) - } - - /// Full chain: peek event -> toggleSneakPeek routing -> dedicated lane state. - func testNotificationPeekEndsInLane() async throws { - NotchUIEventBus.events.send(.sneakPeek( - type: .notification, value: 0, duration: 3.0, - payload: NotificationPeekPayload( - appName: "WhatsApp", title: "Sender", body: "hello", - bundleID: "net.whatsapp.WhatsApp"))) - - try await Task.sleep(for: .milliseconds(500)) - - let visible = await MainActor.run { - BoringViewCoordinator.shared.notificationPeekStates.values.first { $0.show } - } - XCTAssertNotNil(visible, "notification peek should be visible in its lane") - XCTAssertEqual(visible?.payload?.title ?? "", "Sender") - if let uuid = visible?.targetScreenUUID { - await MainActor.run { - BoringViewCoordinator.shared.notificationPeekStates[uuid]?.show = false - } - } - } } final class MediaAppBundleIDTests: XCTestCase { @@ -147,3 +105,43 @@ final class PlaybackStateTests: XCTestCase { XCTAssertNotEqual(a, b, "position changes must be visible to == to drive UI updates") } } + +@MainActor final class ExpandedViewPixelTests: XCTestCase { + + /// Renders NotificationExpandedView offscreen and counts non-black + /// pixels. Guards the reported "open notch is empty with a notification" + /// regression. + func testNotificationExpandedViewPaintsContent() throws { + let notification = SystemNotification( + id: "test-token", appName: "WhatsApp", bundleID: "net.whatsapp.WhatsApp", + title: "Sender", subtitle: nil, body: "hello", actions: [], + receivedAt: Date()) + + let view = NotificationExpandedView(notification: notification) + .environmentObject(BoringViewModel()) + .frame(width: 380, height: 132) + + let hostingView = NSHostingView(rootView: view) + hostingView.frame = CGRect(x: 0, y: 0, width: 380, height: 132) + hostingView.layoutSubtreeIfNeeded() + + guard let rep = hostingView.bitmapImageRepForCachingDisplay(in: hostingView.bounds) else { + XCTFail("could not create bitmap rep") + return + } + hostingView.cacheDisplay(in: hostingView.bounds, to: rep) + + guard let data = rep.bitmapData else { + XCTFail("no bitmap data") + return + } + var nonBlack = 0 + let bytes = rep.pixelsWide * rep.pixelsHigh * max(1, rep.samplesPerPixel) + for i in stride(from: 0, to: bytes, by: 4 * 40) { // sample every ~10th px + if data[i] > 30 || data[i + 1] > 30 || data[i + 2] > 30 { + nonBlack += 1 + } + } + XCTAssertGreaterThan(nonBlack, 20, "expanded view rendered ~empty") + } +} From dd7351b0ec294615a232f843cdd66a4f3d1aa4bc Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 29 Aug 2026 15:19:25 +0530 Subject: [PATCH 68/69] Stop banner keep-alive from stealing keyboard focus while closed The helper keeps held banners alive by re-performing their details toggle every 2.5s; the toggle expands the banner, and the expanded reply field seizes keyboard focus even parked off-screen. It fired on every arrival regardless of notch state. - Feed the helper the effective notch-open state (refcounted across screens, re-announced after XPC reconnect) via a new fire-and-forget setNotchOpen message - Skip the keep-alive expand while the notch is closed; banners then dismiss naturally (reply requires opening the notch) - Best-effort collapse of expanded held banners on open->closed so focus releases immediately - Parking held banners off-screen continues unconditionally Co-authored-by: TheBoringMajdoor --- .../BoringNotchXPCHelper.swift | 4 ++ .../NotificationWatcher.swift | 57 +++++++++++++++++++ Shared/BoringNotchXPCHelperProtocol.swift | 4 ++ boringNotch/ContentView.swift | 7 +++ .../XPCHelperClient/XPCHelperClient.swift | 38 +++++++++++++ 5 files changed, 110 insertions(+) diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift index f7a259a10..42b3739ea 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift @@ -165,6 +165,10 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { DispatchQueue.main.async { Self.watcher.release(token: token) } } + @objc func setNotchOpen(_ open: Bool) { + DispatchQueue.main.async { Self.watcher.notchOpen = open } + } + private class KeyboardBrightnessClient { private static let keyboardID: UInt64 = 1 private var clientInstance: NSObject? diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index e310482b0..e722d8344 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -49,6 +49,30 @@ final class NotificationWatcher { private var heldOffScreen: Set = [] private var lastRefresh = Date.distantPast + /// Effective notch-open state, pushed by the app over XPC. Gates every + /// focus-affecting part of the keep-alive: re-performing the details + /// toggle expands a banner, and an expanded banner's reply field seizes + /// keyboard focus even with the window parked off-screen (macOS does + /// not unfocus off-screen windows) — so the toggle may only run while + /// the notch is open. Parking in hold(token:) is focus-neutral and + /// stays unconditional. Defaults to false: never steal focus before + /// being told. + var notchOpen: Bool = false { + didSet { + if notchOpen, !oldValue { + // Re-arm the skip log for the next closed episode. + skipLogged.removeAll() + } else if oldValue, !notchOpen { + collapseHeldBanners() + } + } + } + + /// Tokens whose closed-notch keep-alive skip has already been logged, + /// so the log fires once per banner per closed episode rather than on + /// every 2.5s tick. + private var skipLogged: Set = [] + /// Comfortably inside the ~5s dismissal window the toggle resets, so a /// missed tick can't let a held banner slip away. private let refreshInterval: TimeInterval = 2.5 @@ -114,6 +138,7 @@ final class NotificationWatcher { // wrong, so clear them explicitly. held.removeAll() heldOffScreen.removeAll() + skipLogged.removeAll() } // MARK: - Scanning @@ -143,6 +168,7 @@ final class NotificationWatcher { for token in live.keys where !seen.contains(token) { live[token] = nil heldOffScreen.remove(token) + skipLogged.remove(token) onBannerGone?(token) } @@ -173,6 +199,19 @@ final class NotificationWatcher { guard now.timeIntervalSince(lastRefresh) >= refreshInterval else { return } lastRefresh = now + // The toggle expands the banner, and the expanded banner's reply + // field grabs keyboard focus even parked off-screen. Focus effects + // are only acceptable while the notch is open; with it closed the + // banner is left to dismiss naturally and flows through the usual + // onBannerGone/expiry path (an intended tradeoff). + guard notchOpen else { + for token in held where live[token] != nil && !skipLogged.contains(token) { + skipLogged.insert(token) + NSLog("[boringNotch] notch closed — skipping keep-alive toggle for held banner \(token), leaving it to dismiss naturally") + } + return + } + for token in held { guard let banner = live[token] else { continue } if let toggle = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("details") }) { @@ -181,6 +220,23 @@ final class NotificationWatcher { } } + /// Best-effort collapse of held banners left expanded, run once when + /// the notch closes: an expanded banner keeps its reply field focused, + /// and it must not stay that way after close. Looks for a "Hide …" + /// action ("Hide Details"); a banner without one is left as-is, so + /// non-English locales degrade to the pre-gate behavior. + private func collapseHeldBanners() { + let expanded = held.filter { live[$0] != nil } + guard !expanded.isEmpty else { return } + NSLog("[boringNotch] notch closed — collapsing \(expanded.count) held banner(s)") + for token in expanded { + guard let banner = live[token] else { continue } + if let hide = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("hide") }) { + AXUIElementPerformAction(banner, hide as CFString) + } + } + } + /// Holds a banner open so its reply field stays usable, moving it /// off-screen first. /// @@ -214,6 +270,7 @@ final class NotificationWatcher { func release(token: String) { held.remove(token) heldOffScreen.remove(token) + skipLogged.remove(token) guard let banner = live[token] else { return } if let close = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("close") }) { AXUIElementPerformAction(banner, close as CFString) diff --git a/Shared/BoringNotchXPCHelperProtocol.swift b/Shared/BoringNotchXPCHelperProtocol.swift index 482f58ac7..5a8a84da2 100644 --- a/Shared/BoringNotchXPCHelperProtocol.swift +++ b/Shared/BoringNotchXPCHelperProtocol.swift @@ -71,6 +71,10 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { func dismissNotification(_ token: String, with reply: @escaping (Bool) -> Void) func holdNotification(_ token: String) func releaseNotification(_ token: String) + /// Effective notch-open state (any screen), ref-counted by the client. + /// Gates the banner keep-alive's focus-affecting expansion; the helper + /// defaults to closed so it never steals focus before being told. + func setNotchOpen(_ open: Bool) func notificationDebugDump(with reply: @escaping (String) -> Void) } diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 0edcf74e7..e63240f86 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -294,6 +294,13 @@ struct ContentView: View { isHovering = false } } + // Keep the helper's banner keep-alive gate in sync + // with the effective open state (refcounted client-side). + if newState == .open { + XPCHelperClient.shared.notchOpened() + } else { + XPCHelperClient.shared.notchClosed() + } // Temporary trace for the empty-open-panel report. if newState == .open { Log.notifications.debug( diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index 883acc794..2c42b5ab7 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -33,6 +33,11 @@ final class XPCHelperClient: NSObject, ObservableObject { @MainActor private var activationObserver: (any NSObjectProtocol)? private var lunarListener: BoringNotchXPCHelperLunarListener? private var hasLunarListener: Bool = false + + /// Open-notch refcount: one ContentView per screen can hold the notch + /// open, but the helper only wants the effective state, so + /// `setNotchOpen` is sent on the 0→1 and 1→0 transitions only. + @MainActor private var notchOpenCount = 0 deinit { connection?.invalidate() @@ -99,6 +104,11 @@ final class XPCHelperClient: NSObject, ObservableObject { remoteService = service helperAvailable = true lastError = nil + // A helper restart forgets our state — re-announce an open notch so + // its banner keep-alive gate doesn't run while the notch is open. + if notchOpenCount > 0 { + Task { try? await service.withService { $0.setNotchOpen(true) } } + } return service } @@ -516,6 +526,34 @@ extension XPCHelperClient { } } + /// Feeds the helper the notch's effective open state so it can gate its + /// focus-stealing banner keep-alive expand to open-notch-only. + /// Refcounted across screens: only 0→1 sends true, only 1→0 sends false. + nonisolated func notchOpened() { + Task { @MainActor in + notchOpenCount += 1 + if notchOpenCount == 1 { sendNotchOpen(true) } + } + } + + nonisolated func notchClosed() { + Task { @MainActor in + guard notchOpenCount > 0 else { + NSLog("[boringNotch] XPCHelperClient: unmatched notchClosed ignored") + return + } + notchOpenCount -= 1 + if notchOpenCount == 0 { sendNotchOpen(false) } + } + } + + @MainActor private func sendNotchOpen(_ open: Bool) { + let service = ensureRemoteService() + Task { + try? await service.withService { $0.setNotchOpen(open) } + } + } + nonisolated func sendIMessage(_ text: String, toChatNamed name: String) async -> Bool { do { let service = await MainActor.run { ensureRemoteService() } From 455b6efaedc36f6ae26dc226e0cd7b9c93155ff3 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 29 Aug 2026 15:19:30 +0530 Subject: [PATCH 69/69] chore: accept Xcode project normalization (pbxproj dedup, entitlements/xcstrings reordering) Co-authored-by: TheBoringMajdoor --- boringNotch.xcodeproj/project.pbxproj | 71 ++++++++++++++++----------- boringNotch/Localizable.xcstrings | 22 +++++---- boringNotch/boringNotch.entitlements | 4 +- 3 files changed, 55 insertions(+), 42 deletions(-) diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 9b74ff169..65a375b8b 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -13,8 +13,6 @@ 1100292A2E8691B400035A57 /* FileShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110029292E8691B400035A57 /* FileShareView.swift */; }; 1100292E2E86940F00035A57 /* QuickShareService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1100292D2E86940F00035A57 /* QuickShareService.swift */; }; 1113ABC52E80E27000EC13B2 /* ShelfItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1113ABC32E80E27000EC13B2 /* ShelfItemView.swift */; }; - A1F000012F00000100000001 /* ShelfItemInteractionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F000022F00000100000001 /* ShelfItemInteractionView.swift */; }; - AFA82F21304B406590337862 /* ShelfContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E49B58CAC648403F864E6D78 /* ShelfContextMenu.swift */; }; 1113ABC62E80E27000EC13B2 /* ShelfPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1113ABBE2E80E27000EC13B2 /* ShelfPersistenceService.swift */; }; 1113ABC82E80E27000EC13B2 /* ShelfItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1113ABB72E80E27000EC13B2 /* ShelfItem.swift */; }; 1113ABCA2E80E27000EC13B2 /* ShelfSelectionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1113ABC02E80E27000EC13B2 /* ShelfSelectionModel.swift */; }; @@ -32,15 +30,12 @@ 112B0EB92E30DD0F00562D6C /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 112B0EB32E30DD0F00562D6C /* mediaremote-adapter.pl */; }; 112B0EBB2E30DD5000562D6C /* MediaRemoteAdapter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 112B0EBA2E30DD5000562D6C /* MediaRemoteAdapter.framework */; }; 112FB7352CCF16F70015238C /* NotchSpaceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 112FB7342CCF16F70015238C /* NotchSpaceManager.swift */; }; - 90ED6E81035940418671DCEA /* NotchWindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB1C3E8B149445A9E57C6AF /* NotchWindowManager.swift */; }; 1132E5122E777B6E0068732D /* YouTubeMusicModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1132E5102E777B6E0068732D /* YouTubeMusicModels.swift */; }; 1132E5142E777B920068732D /* YouTubeMusicNetworking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1132E5132E777B920068732D /* YouTubeMusicNetworking.swift */; }; 1132E5162E777C140068732D /* YouTubeMusicAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1132E5152E777C140068732D /* YouTubeMusicAuthentication.swift */; }; 1153BD8F2D986B1F00979FB0 /* MediaControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD8D2D986B1F00979FB0 /* MediaControllerProtocol.swift */; }; 1153BD912D986DB300979FB0 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD902D986DB300979FB0 /* PlaybackState.swift */; }; 1153BD932D986E4300979FB0 /* AppleMusicController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD922D986E4300979FB0 /* AppleMusicController.swift */; }; - 62A83B43C1AD411097E93D17 /* MediaAppBundleID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F20E44C65E4DA1945BFF5C /* MediaAppBundleID.swift */; }; - 80EA0C01BBCF4069AC5D133D /* AppleScriptControllerSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C26F24108F414781F79D4F /* AppleScriptControllerSupport.swift */; }; 1153BD982D9881F900979FB0 /* AppleScriptHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD972D9881F900979FB0 /* AppleScriptHelper.swift */; }; 1153BD9A2D98824300979FB0 /* SpotifyController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD992D98824300979FB0 /* SpotifyController.swift */; }; 1153BD9C2D98853B00979FB0 /* NowPlayingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD9B2D98853B00979FB0 /* NowPlayingController.swift */; }; @@ -68,8 +63,6 @@ 11985BEF2F37E48900F81585 /* OSDIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11985BEE2F37E48900F81585 /* OSDIconView.swift */; }; 11985BF42F38520A00F81585 /* DraggableProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11985BF32F38520A00F81585 /* DraggableProgressBar.swift */; }; 11A45C792E34E63100CEB175 /* MediaChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A45C782E34E63100CEB175 /* MediaChecker.swift */; }; - 7EF93DF9889B42E9BCD4634F /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033B8885979E4EA1A78E8ADC /* Log.swift */; }; - F80A422BE2974CF6808C84CA /* MediaEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D24CBD49614ECE88F062B5 /* MediaEnvironment.swift */; }; 11C5E3132DFE85970065821E /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C5E3112DFE85970065821E /* SettingsWindowController.swift */; }; 11C5E3162DFE88510065821E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C5E3152DFE88510065821E /* SettingsView.swift */; }; 11CC44A22CEE614100C7244B /* BoringViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CC44A12CEE614100C7244B /* BoringViewCoordinator.swift */; }; @@ -121,26 +114,30 @@ 14D570C02C5EA5870011E668 /* AnimatedFace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570BF2C5EA5870011E668 /* AnimatedFace.swift */; }; 14D570C62C5F38210011E668 /* BoringHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570C52C5F38210011E668 /* BoringHeader.swift */; }; 14D570C92C5F38890011E668 /* BoringViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570C82C5F38890011E668 /* BoringViewModel.swift */; }; - C0D300012F60000100000001 /* DropInteractionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D300022F60000100000001 /* DropInteractionState.swift */; }; 14D570CB2C5F4B2C0011E668 /* BatteryStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570CA2C5F4B2C0011E668 /* BatteryStatusViewModel.swift */; }; - A7F4A06476BC4B029B6BECEA /* NotchUIEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E354C7D712441284085CEA /* NotchUIEvent.swift */; }; 14D570CD2C5F4BB70011E668 /* BoringBattery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570CC2C5F4BB70011E668 /* BoringBattery.swift */; }; 14D570D22C5F6C6A0011E668 /* BoringExtrasMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570D12C5F6C6A0011E668 /* BoringExtrasMenu.swift */; }; 14E9FEAE2C7325770062E83F /* Button+Bouncing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14E9FEAD2C7325770062E83F /* Button+Bouncing.swift */; }; 14FC6E502C7DED5600C7BEA5 /* DataTypes+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14FC6E4F2C7DED5600C7BEA5 /* DataTypes+Extensions.swift */; }; + 1A8B65187F974EE9B7664F5C /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0268933F488E47DFB58E48CF /* VisualEffectView.swift */; }; 201C3E071A104384B8629328 /* AudioOutputRouteResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */; }; 3C1B922F0813671B2886CF43 /* MeetingLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA22021D89A9E4FF88A618D /* MeetingLink.swift */; }; 507266DB2C908E2E00A2D00D /* HoverButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507266DA2C908E2E00A2D00D /* HoverButton.swift */; }; - 1A8B65187F974EE9B7664F5C /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0268933F488E47DFB58E48CF /* VisualEffectView.swift */; }; 5917FD112E57891600E87F1C /* MediaKeyInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5917FD102E57891600E87F1C /* MediaKeyInterceptor.swift */; }; 5955950D2E900ED800C66711 /* ApplicationRelauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5955950C2E900ED800C66711 /* ApplicationRelauncher.swift */; }; 59D8C23C2E589FAA00147B33 /* VolumeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59D8C23B2E589FAA00147B33 /* VolumeManager.swift */; }; + 62A83B43C1AD411097E93D17 /* MediaAppBundleID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F20E44C65E4DA1945BFF5C /* MediaAppBundleID.swift */; }; 64FA50FB2F4D6F9E00008A28 /* WebcamSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64FA50FA2F4D6F9E00008A28 /* WebcamSettingsView.swift */; }; + 7EF93DF9889B42E9BCD4634F /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033B8885979E4EA1A78E8ADC /* Log.swift */; }; + 80EA0C01BBCF4069AC5D133D /* AppleScriptControllerSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C26F24108F414781F79D4F /* AppleScriptControllerSupport.swift */; }; + 90ED6E81035940418671DCEA /* NotchWindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB1C3E8B149445A9E57C6AF /* NotchWindowManager.swift */; }; 9A0887322C7A693000C160EA /* TabButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A0887312C7A693000C160EA /* TabButton.swift */; }; 9A0887352C7AFF8E00C160EA /* TabSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A0887342C7AFF8E00C160EA /* TabSelectionView.swift */; }; 9A987A0D2C73CA66005CA465 /* ShelfView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A987A032C73CA66005CA465 /* ShelfView.swift */; }; 9A987A102C73CA8D005CA465 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 9A987A0F2C73CA8D005CA465 /* Collections */; }; 9AB0C6BD2C73C9CB00F7CD30 /* NotchHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AB0C6BB2C73C9CB00F7CD30 /* NotchHomeView.swift */; }; + A1F000012F00000100000001 /* ShelfItemInteractionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F000022F00000100000001 /* ShelfItemInteractionView.swift */; }; + A7F4A06476BC4B029B6BECEA /* NotchUIEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E354C7D712441284085CEA /* NotchUIEvent.swift */; }; AA01NDW12E7A0001 /* NotificationDebugWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01NDW22E7A0001 /* NotificationDebugWindow.swift */; }; AA01NLA12E7A0001 /* NotificationLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01NLA22E7A0001 /* NotificationLiveActivity.swift */; }; AA01SNM12E7A0001 /* SystemNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01SNM22E7A0001 /* SystemNotificationManager.swift */; }; @@ -149,6 +146,9 @@ AA03OTP12E7A0001 /* OTPDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA03OTP22E7A0001 /* OTPDetector.swift */; }; AA04LAS12E7A0001 /* LiveActivityStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA04LAS22E7A0001 /* LiveActivityStack.swift */; }; AA05SRM12E7A0001 /* SmartReplyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA05SRM22E7A0001 /* SmartReplyManager.swift */; }; + AA06CHV12E7A0001 /* CompactHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA06CHV22E7A0001 /* CompactHomeView.swift */; }; + AA07ARM12E7A0001 /* AudioRouteManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA07ARM22E7A0001 /* AudioRouteManager.swift */; }; + AFA82F21304B406590337862 /* ShelfContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E49B58CAC648403F864E6D78 /* ShelfContextMenu.swift */; }; B10348D92C74E56000475897 /* ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10348D82C74E56000475897 /* ConditionalModifier.swift */; }; B141C2412CA5F53F00AC8CC8 /* SparkleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B141C2402CA5F53E00AC8CC8 /* SparkleView.swift */; }; B1628B922CC260C0003D8DF3 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */; }; @@ -170,8 +170,10 @@ B1D6FD432C6603730015F173 /* SoftwareUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D6FD422C6603730015F173 /* SoftwareUpdater.swift */; }; B1F0A0022E60000100000001 /* BrightnessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0A0012E60000100000001 /* BrightnessManager.swift */; }; B1FEB4992C7686630066EBBC /* PanGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1FEB4982C7686630066EBBC /* PanGesture.swift */; }; + C0D300012F60000100000001 /* DropInteractionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D300022F60000100000001 /* DropInteractionState.swift */; }; F1F2A0A100000000000000F1 /* AudioCaptureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F2A0A200000000000000F2 /* AudioCaptureManager.swift */; }; F38DE6482D8243E7008B5C6D /* BatteryActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */; }; + F80A422BE2974CF6808C84CA /* MediaEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D24CBD49614ECE88F062B5 /* MediaEnvironment.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -217,6 +219,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0268933F488E47DFB58E48CF /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; + 033B8885979E4EA1A78E8ADC /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; 1100290B2E847E2800035A57 /* NSItemProvider+LoadHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSItemProvider+LoadHelpers.swift"; sourceTree = ""; }; 110029262E84FD4C00035A57 /* TemporaryFileStorageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryFileStorageService.swift; sourceTree = ""; }; 110029292E8691B400035A57 /* FileShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileShareView.swift; sourceTree = ""; }; @@ -228,8 +232,6 @@ 1113ABBE2E80E27000EC13B2 /* ShelfPersistenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfPersistenceService.swift; sourceTree = ""; }; 1113ABC02E80E27000EC13B2 /* ShelfSelectionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfSelectionModel.swift; sourceTree = ""; }; 1113ABC32E80E27000EC13B2 /* ShelfItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfItemView.swift; sourceTree = ""; }; - A1F000022F00000100000001 /* ShelfItemInteractionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfItemInteractionView.swift; sourceTree = ""; }; - E49B58CAC648403F864E6D78 /* ShelfContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfContextMenu.swift; sourceTree = ""; }; 1113ABCF2E80E6BB00EC13B2 /* ThumbnailService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailService.swift; sourceTree = ""; }; 111BE9942ECF2DEF0079DD4E /* DragDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragDetector.swift; sourceTree = ""; }; 111BEA602ED09B1B0079DD4E /* NSScreen+UUID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScreen+UUID.swift"; sourceTree = ""; }; @@ -237,15 +239,12 @@ 112B0EB52E30DD0F00562D6C /* MediaRemoteAdapterTestClient */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = MediaRemoteAdapterTestClient; sourceTree = ""; }; 112B0EBA2E30DD5000562D6C /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = "mediaremote-adapter/MediaRemoteAdapter.framework"; sourceTree = ""; }; 112FB7342CCF16F70015238C /* NotchSpaceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchSpaceManager.swift; sourceTree = ""; }; - EAB1C3E8B149445A9E57C6AF /* NotchWindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchWindowManager.swift; sourceTree = ""; }; 1132E5102E777B6E0068732D /* YouTubeMusicModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeMusicModels.swift; sourceTree = ""; }; 1132E5132E777B920068732D /* YouTubeMusicNetworking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeMusicNetworking.swift; sourceTree = ""; }; 1132E5152E777C140068732D /* YouTubeMusicAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeMusicAuthentication.swift; sourceTree = ""; }; 1153BD8D2D986B1F00979FB0 /* MediaControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaControllerProtocol.swift; sourceTree = ""; }; 1153BD902D986DB300979FB0 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = ""; }; 1153BD922D986E4300979FB0 /* AppleMusicController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleMusicController.swift; sourceTree = ""; }; - 61F20E44C65E4DA1945BFF5C /* MediaAppBundleID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaAppBundleID.swift; sourceTree = ""; }; - B0C26F24108F414781F79D4F /* AppleScriptControllerSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptControllerSupport.swift; sourceTree = ""; }; 1153BD972D9881F900979FB0 /* AppleScriptHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptHelper.swift; sourceTree = ""; }; 1153BD992D98824300979FB0 /* SpotifyController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyController.swift; sourceTree = ""; }; 1153BD9B2D98853B00979FB0 /* NowPlayingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingController.swift; sourceTree = ""; }; @@ -271,8 +270,6 @@ 11985BEE2F37E48900F81585 /* OSDIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDIconView.swift; sourceTree = ""; }; 11985BF32F38520A00F81585 /* DraggableProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableProgressBar.swift; sourceTree = ""; }; 11A45C782E34E63100CEB175 /* MediaChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaChecker.swift; sourceTree = ""; }; - 033B8885979E4EA1A78E8ADC /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; - 54D24CBD49614ECE88F062B5 /* MediaEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEnvironment.swift; sourceTree = ""; }; 11C5E3112DFE85970065821E /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = ""; }; 11C5E3152DFE88510065821E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 11CC44A12CEE614100C7244B /* BoringViewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringViewCoordinator.swift; sourceTree = ""; }; @@ -306,8 +303,6 @@ 1443E7F42C609E650027C1FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 147163972C5D35B70068B555 /* MusicVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicVisualizer.swift; sourceTree = ""; }; 147163992C5D35FF0068B555 /* MusicManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicManager.swift; sourceTree = ""; }; - AA07ARM22E7A0001 /* AudioRouteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouteManager.swift; sourceTree = ""; }; - AA07ARM12E7A0001 /* AudioRouteManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA07ARM22E7A0001 /* AudioRouteManager.swift */; }; 149E0B962C737D00006418B1 /* WebcamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamManager.swift; sourceTree = ""; }; 149E0B992C737D40006418B1 /* WebcamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamView.swift; sourceTree = ""; }; 14A7E5872C64A89C008C1BE9 /* HelloAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelloAnimation.swift; sourceTree = ""; }; @@ -328,36 +323,38 @@ 14D570BF2C5EA5870011E668 /* AnimatedFace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedFace.swift; sourceTree = ""; }; 14D570C52C5F38210011E668 /* BoringHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringHeader.swift; sourceTree = ""; }; 14D570C82C5F38890011E668 /* BoringViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringViewModel.swift; sourceTree = ""; }; - C0D300022F60000100000001 /* DropInteractionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropInteractionState.swift; sourceTree = ""; }; 14D570CA2C5F4B2C0011E668 /* BatteryStatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryStatusViewModel.swift; sourceTree = ""; }; - 80E354C7D712441284085CEA /* NotchUIEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchUIEvent.swift; sourceTree = ""; }; 14D570CC2C5F4BB70011E668 /* BoringBattery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringBattery.swift; sourceTree = ""; }; 14D570D12C5F6C6A0011E668 /* BoringExtrasMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoringExtrasMenu.swift; sourceTree = ""; }; 14E9FEAD2C7325770062E83F /* Button+Bouncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Button+Bouncing.swift"; sourceTree = ""; }; 14FC6E4F2C7DED5600C7BEA5 /* DataTypes+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataTypes+Extensions.swift"; sourceTree = ""; }; 3CA22021D89A9E4FF88A618D /* MeetingLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MeetingLink.swift; sourceTree = ""; }; 507266DA2C908E2E00A2D00D /* HoverButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverButton.swift; sourceTree = ""; }; - 0268933F488E47DFB58E48CF /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; + 54D24CBD49614ECE88F062B5 /* MediaEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEnvironment.swift; sourceTree = ""; }; 5917FD102E57891600E87F1C /* MediaKeyInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaKeyInterceptor.swift; sourceTree = ""; }; 5955950C2E900ED800C66711 /* ApplicationRelauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationRelauncher.swift; sourceTree = ""; }; 59D8C23B2E589FAA00147B33 /* VolumeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeManager.swift; sourceTree = ""; }; + 61F20E44C65E4DA1945BFF5C /* MediaAppBundleID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaAppBundleID.swift; sourceTree = ""; }; 64FA50FA2F4D6F9E00008A28 /* WebcamSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebcamSettingsView.swift; sourceTree = ""; }; + 80E354C7D712441284085CEA /* NotchUIEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchUIEvent.swift; sourceTree = ""; }; 9A0887312C7A693000C160EA /* TabButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabButton.swift; sourceTree = ""; }; 9A0887342C7AFF8E00C160EA /* TabSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabSelectionView.swift; sourceTree = ""; }; 9A987A032C73CA66005CA465 /* ShelfView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShelfView.swift; sourceTree = ""; }; 9AB0C6BB2C73C9CB00F7CD30 /* NotchHomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotchHomeView.swift; sourceTree = ""; }; + A1F000022F00000100000001 /* ShelfItemInteractionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfItemInteractionView.swift; sourceTree = ""; }; A5167212301F85B40018095A /* boringNotchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = boringNotchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; AA01NDW22E7A0001 /* NotificationDebugWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugWindow.swift; sourceTree = ""; }; AA01NLA22E7A0001 /* NotificationLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationLiveActivity.swift; sourceTree = ""; }; - AA06CHV22E7A0001 /* CompactHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactHomeView.swift; sourceTree = ""; }; - AA06CHV12E7A0001 /* CompactHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA06CHV22E7A0001 /* CompactHomeView.swift */; }; AA01SNM22E7A0001 /* SystemNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemNotificationManager.swift; sourceTree = ""; }; AA02CAM22E7A0001 /* ContactAvatarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactAvatarManager.swift; sourceTree = ""; }; AA02NSV22E7A0001 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; AA03OTP22E7A0001 /* OTPDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPDetector.swift; sourceTree = ""; }; AA04LAS22E7A0001 /* LiveActivityStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityStack.swift; sourceTree = ""; }; AA05SRM22E7A0001 /* SmartReplyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartReplyManager.swift; sourceTree = ""; }; + AA06CHV22E7A0001 /* CompactHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactHomeView.swift; sourceTree = ""; }; + AA07ARM22E7A0001 /* AudioRouteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouteManager.swift; sourceTree = ""; }; AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputRouteResolver.swift; sourceTree = ""; }; + B0C26F24108F414781F79D4F /* AppleScriptControllerSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptControllerSupport.swift; sourceTree = ""; }; B10348D82C74E56000475897 /* ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalModifier.swift; sourceTree = ""; }; B141C2402CA5F53E00AC8CC8 /* SparkleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleView.swift; sourceTree = ""; }; B17266DE2C64DFA00031BA0D /* BundleInfos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleInfos.swift; sourceTree = ""; }; @@ -377,6 +374,9 @@ B1F0A0012E60000100000001 /* BrightnessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessManager.swift; sourceTree = ""; }; B1FEB4982C7686630066EBBC /* PanGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanGesture.swift; sourceTree = ""; }; B9B2A5E5F4D28F1DD9CD0B09 /* MeetingLinkDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MeetingLinkDetector.swift; sourceTree = ""; }; + C0D300022F60000100000001 /* DropInteractionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropInteractionState.swift; sourceTree = ""; }; + E49B58CAC648403F864E6D78 /* ShelfContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfContextMenu.swift; sourceTree = ""; }; + EAB1C3E8B149445A9E57C6AF /* NotchWindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchWindowManager.swift; sourceTree = ""; }; F1F2A0A200000000000000F2 /* AudioCaptureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioCaptureManager.swift; sourceTree = ""; }; F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActivityManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -501,7 +501,7 @@ 1132E5102E777B6E0068732D /* YouTubeMusicModels.swift */, 1153BDA62D99B22200979FB0 /* YouTubeMusicController.swift */, ); - path = "YouTubeMusicController"; + path = YouTubeMusicController; sourceTree = ""; }; 1153BD8E2D986B1F00979FB0 /* MediaControllers */ = { @@ -705,6 +705,7 @@ A5167213301F85B40018095A /* boringNotchTests */, 14CEF4132C5CAED300855D72 /* Products */, 14D031EC2C689DB70096E6A1 /* Frameworks */, + 600E881A3042DEAD00B17BFC /* Recovered References */, ); sourceTree = ""; }; @@ -801,6 +802,14 @@ path = models; sourceTree = ""; }; + 600E881A3042DEAD00B17BFC /* Recovered References */ = { + isa = PBXGroup; + children = ( + 8137A8BA990F4D9CBA56CC7A /* Shared */, + ); + name = "Recovered References"; + sourceTree = ""; + }; 9A0887332C7AFF7E00C160EA /* Tabs */ = { isa = PBXGroup; children = ( @@ -898,7 +907,7 @@ B1C974332C642B6D0000E707 /* MarqueeTextView.swift */, B1D365CD2C6A979C0047BDBC /* LiveActivityModifier.swift */, ); - path = "LiveActivities"; + path = LiveActivities; sourceTree = ""; }; B186543A2C6F49A4000B926A /* Shortcuts */ = { @@ -1453,18 +1462,19 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = boringNotch/boringNotch.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"boringNotch/Preview Content\""; - DEVELOPMENT_TEAM = JPWMG84CH8; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; ENABLE_RESOURCE_ACCESS_CAMERA = NO; - ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = YES; ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1520,18 +1530,19 @@ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = boringNotch/boringNotch.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 272; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"boringNotch/Preview Content\""; - DEVELOPMENT_TEAM = JPWMG84CH8; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; ENABLE_RESOURCE_ACCESS_CAMERA = NO; - ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = YES; ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; FRAMEWORK_SEARCH_PATHS = ( diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index 2cf786669..1cbe63a0d 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -145,9 +145,6 @@ } } }, - "%@x" : { - "comment" : "Animation speed multiplier." - }, "%@" : { "localizations" : { "en" : { @@ -164,6 +161,9 @@ } } }, + "%@x" : { + "comment" : "Animation speed multiplier." + }, "%lld" : { "localizations" : { "en" : { @@ -181,6 +181,7 @@ } }, "%lld%%" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -6077,9 +6078,6 @@ }, "Copy" : { - }, - "Copy Meeting Link" : { - }, "Copy items on drag" : { "localizations" : { @@ -6186,6 +6184,9 @@ } } } + }, + "Copy Meeting Link" : { + }, "Currently selected: %@" : { "localizations" : { @@ -9548,6 +9549,7 @@ "comment" : "A label for the shape of the mirror frame." }, "From all apps" : { + }, "Full charge" : { "localizations" : { @@ -16361,6 +16363,9 @@ }, "Open in %@" : { + }, + "Open in Calendar" : { + }, "Open Notch" : { "extractionState" : "stale", @@ -16456,9 +16461,6 @@ } } } - }, - "Open in Calendar" : { - }, "Open notch on hover" : { "localizations" : { @@ -24752,7 +24754,7 @@ } }, "System Setting" : { - "comment" : "Week starts on: follow the macOS system setting (shown with the resolved day in parentheses)", + "comment" : "Week starts on: follow the macOS system setting", "localizations" : { "de" : { "stringUnit" : { diff --git a/boringNotch/boringNotch.entitlements b/boringNotch/boringNotch.entitlements index 57acc4681..a8c08ace0 100644 --- a/boringNotch/boringNotch.entitlements +++ b/boringNotch/boringNotch.entitlements @@ -20,10 +20,10 @@ com.apple.security.network.server - com.apple.security.personal-information.calendars - com.apple.security.personal-information.addressbook + com.apple.security.personal-information.calendars + com.apple.security.temporary-exception.apple-events com.spotify.client