From 87727b06b396e5a3f5165928c8afd9241564f8d0 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 29 Aug 2026 15:39:05 +0530 Subject: [PATCH 01/13] Consolidate notification surfaces, cap watcher memory, harden reply delivery - Consolidate notification surfaces into one flow: banner -> chin pill -> expanded card, with an unconditional hold in show() so a superseded banner never parks off-screen unreleased - Widen the OTP chin width to clear the hardware notch - Cap AXI watcher memory pressure (autoreleasepool per scan) and reinstate a bounded 0.5s idle cadence - Reply delivery: per-stage timeouts with banner-first ordering, and a send-failure error state ("Message couldn't be sent. Your draft is still here - try again or open Messages.") - Remove the reply-focused auto-focus hijack from the notification live activity - Structured logging for notification filtering decisions - Add a duration parameter to the sneak-peek UI event Co-authored-by: TheBoringMajdoor --- .../NotificationWatcher.swift | 24 +++++--- boringNotch/BoringViewCoordinator.swift | 9 +-- boringNotch/ContentView.swift | 56 +++++++++--------- boringNotch/Localizable.xcstrings | 28 ++++++++- .../Notch/NotificationLiveActivity.swift | 26 +++++++- .../components/NotificationDebugWindow.swift | 5 +- .../managers/SystemNotificationManager.swift | 59 ++++++++++++++++++- boringNotch/models/NotchUIEvent.swift | 3 +- boringNotchTests/NotchUIEventTests.swift | 2 +- 9 files changed, 162 insertions(+), 50 deletions(-) diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index 9b363502e..01779683c 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -53,13 +53,13 @@ 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%. + /// Banners live ~5s, so 0.35s catches every one with room to spare. + /// 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 = 2.0 + private let idlePollInterval: TimeInterval = 0.5 private var currentPollInterval: TimeInterval = 0 var isRunning: Bool { appElement != nil } @@ -119,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 = [] @@ -143,9 +150,8 @@ final class NotificationWatcher { 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. + /// 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 } diff --git a/boringNotch/BoringViewCoordinator.swift b/boringNotch/BoringViewCoordinator.swift index 32d35281f..95431b6cf 100644 --- a/boringNotch/BoringViewCoordinator.swift +++ b/boringNotch/BoringViewCoordinator.swift @@ -139,9 +139,9 @@ 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): self.toggleSneakPeek( - status: true, type: type, value: value, + status: true, type: type, duration: duration, value: value, icon: icon, accent: accent, targetScreenUUID: uuid) case .expandingView(let type): self.toggleExpandingView(status: true, type: type) @@ -184,7 +184,8 @@ final class BoringViewCoordinator: ObservableObject { } } - // Observe changes to the notification live activity + // Observe changes to the notification live activity toggle; it owns + // the notification watcher lifecycle. notificationLiveActivityCancellable = Defaults.publisher(.notificationLiveActivity) .sink { change in Task { @MainActor in @@ -217,7 +218,7 @@ 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] = [:] diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index e3711fb0a..59c332f11 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -166,9 +166,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: @@ -449,8 +451,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) && vm.notchState == .closed { - 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, @@ -488,8 +490,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 != .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, @@ -510,27 +512,27 @@ 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) + } + } + } + } + } + .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() } - } - } - .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) + .zIndex(1) if vm.notchState == .open { VStack { // An open notch with a live notification is showing the diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index a7fad5fe2..21d0881fd 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -179,9 +179,6 @@ } } } - }, - "%lld more waiting" : { - }, "%lld%%" : { "localizations" : { @@ -2432,6 +2429,9 @@ } } } + }, + "Background Removal Failed" : { + }, "Backlight" : { "localizations" : { @@ -5277,6 +5277,7 @@ } }, "Close" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -7328,6 +7329,7 @@ } }, "Download" : { + "extractionState" : "stale", "localizations" : { "cs" : { "stringUnit" : { @@ -7963,6 +7965,7 @@ }, "Edit layout" : { + "extractionState" : "stale", "localizations" : { "cs" : { "stringUnit" : { @@ -10643,6 +10646,9 @@ } } } + }, + "Helper Service Unavailable" : { + }, "Hide all-day events" : { "localizations" : { @@ -11685,8 +11691,12 @@ } } } + }, + "Image Conversion Failed" : { + }, "In progress" : { + "extractionState" : "stale", "localizations" : { "cs" : { "stringUnit" : { @@ -14142,6 +14152,9 @@ } } } + }, + "Message couldn't be sent. Your draft is still here — try again or open Messages." : { + }, "Mic" : { "localizations" : { @@ -17542,6 +17555,9 @@ }, "Output" : { + }, + "PDF Creation Failed" : { + }, "Pick a Color" : { "localizations" : { @@ -24963,6 +24979,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..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) } @@ -157,7 +160,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 @@ -396,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 } @@ -536,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 99cf2c273..b00fc4254 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -186,7 +186,7 @@ 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 } @@ -313,6 +313,7 @@ final class SystemNotificationManager: ObservableObject { if releasingPrevious, let previous = activeNotification, previous.id != notification.id { XPCHelperClient.shared.releaseNotification(token: previous.id) } + holdSystemBanner(notification) withAnimation(.smooth) { activeNotification = notification } dismissTask?.cancel() dismissTask = Task { [weak self] in @@ -412,8 +413,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 @@ -425,12 +438,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 @@ -439,13 +483,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 diff --git a/boringNotch/models/NotchUIEvent.swift b/boringNotch/models/NotchUIEvent.swift index 6162bb280..d63cf885f 100644 --- a/boringNotch/models/NotchUIEvent.swift +++ b/boringNotch/models/NotchUIEvent.swift @@ -24,7 +24,8 @@ enum NotchUIEvent { value: CGFloat, icon: String = "", accent: Color? = nil, - targetScreenUUID: String? = nil + targetScreenUUID: String? = nil, + duration: TimeInterval = 1.5 ) case expandingView(type: SneakContentType) } diff --git a/boringNotchTests/NotchUIEventTests.swift b/boringNotchTests/NotchUIEventTests.swift index 5b59e751c..41748cada 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 } From a9acdc35959ad4efb6e527e1a7ffbb70b9c1566b Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 29 Aug 2026 15:39:29 +0530 Subject: [PATCH 02/13] Fix sender app icons on notifications The XPC helper's one-shot running-apps match could leave bundleID nil forever (renamed app, helper process owning the banner, an app that quit between posting and capture), 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 in add() when the helper's bundleID is missing. A helper-provided bundleID always wins. Adds BundleIDResolverTests (8 tests: normalization parity, direct probe, directory scan, memoization) and ExpandedViewPixelTests, a render regression guard for the expanded notification card. Co-authored-by: TheBoringMajdoor --- .../NotificationWatcher.swift | 72 ++++++++- boringNotch/helpers/AppIcons.swift | 118 +++++++++++++++ .../managers/SystemNotificationManager.swift | 17 ++- boringNotchTests/BundleIDResolverTests.swift | 138 ++++++++++++++++++ boringNotchTests/NotchUIEventTests.swift | 41 ++++++ 5 files changed, 383 insertions(+), 3 deletions(-) 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/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 b00fc4254..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"), 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 41748cada..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 { @@ -104,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 0f0fccb0ae642836f18a66b0e9123b8737dd6ab2 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 29 Aug 2026 15:40:01 +0530 Subject: [PATCH 03/13] 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 b1823691d..4c2175bb3 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 59c332f11..f96d14451 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -312,6 +312,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() + } } // A new notification always takes the front of the stack, // even if the user had swiped away to music. diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index 50930fb42..7afd44c74 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -38,6 +38,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 // MARK: - Connection Management (Main Actor Isolated) @@ -98,6 +103,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 } @@ -474,6 +484,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 99877ef885c956d5be8db7cab30cac80eeb688e5 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 29 Aug 2026 15:40:09 +0530 Subject: [PATCH 04/13] chore: accept Xcode project normalization (pbxproj dedup, entitlements/xcstrings reordering) Co-authored-by: TheBoringMajdoor --- boringNotch.xcodeproj/project.pbxproj | 65 ++++++++++++++++----------- boringNotch/Localizable.xcstrings | 14 +++--- boringNotch/boringNotch.entitlements | 4 +- 3 files changed, 48 insertions(+), 35 deletions(-) diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 48a6e1dd8..12c03ce46 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 */; }; @@ -121,26 +116,29 @@ 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 */; }; + 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 +147,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 +171,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 +220,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 +233,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 +240,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 = ""; }; @@ -272,7 +272,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 = ""; }; 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 +305,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 +325,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 +376,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 +503,7 @@ 1132E5102E777B6E0068732D /* YouTubeMusicModels.swift */, 1153BDA62D99B22200979FB0 /* YouTubeMusicController.swift */, ); - path = "YouTubeMusicController"; + path = YouTubeMusicController; sourceTree = ""; }; 1153BD8E2D986B1F00979FB0 /* MediaControllers */ = { @@ -705,6 +707,7 @@ A5167213301F85B40018095A /* boringNotchTests */, 14CEF4132C5CAED300855D72 /* Products */, 14D031EC2C689DB70096E6A1 /* Frameworks */, + 600E881A3042DEAD00B17BFC /* Recovered References */, ); sourceTree = ""; }; @@ -801,6 +804,14 @@ path = models; sourceTree = ""; }; + 600E881A3042DEAD00B17BFC /* Recovered References */ = { + isa = PBXGroup; + children = ( + 8137A8BA990F4D9CBA56CC7A /* Shared */, + ); + name = "Recovered References"; + sourceTree = ""; + }; 9A0887332C7AFF7E00C160EA /* Tabs */ = { isa = PBXGroup; children = ( @@ -898,7 +909,7 @@ B1C974332C642B6D0000E707 /* MarqueeTextView.swift */, B1D365CD2C6A979C0047BDBC /* LiveActivityModifier.swift */, ); - path = "LiveActivities"; + path = LiveActivities; sourceTree = ""; }; B186543A2C6F49A4000B926A /* Shortcuts */ = { @@ -1459,13 +1470,13 @@ 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; @@ -1527,13 +1538,13 @@ 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 21d0881fd..6a1831e63 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -181,6 +181,7 @@ } }, "%lld%%" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -6174,9 +6175,6 @@ }, "Copy" : { - }, - "Copy Meeting Link" : { - }, "Copy items on drag" : { "localizations" : { @@ -6283,6 +6281,9 @@ } } } + }, + "Copy Meeting Link" : { + }, "Currently selected: %@" : { "localizations" : { @@ -9645,6 +9646,7 @@ "comment" : "A label for the shape of the mirror frame." }, "From all apps" : { + }, "Full charge" : { "localizations" : { @@ -16481,6 +16483,9 @@ }, "Open in %@" : { + }, + "Open in Calendar" : { + }, "Open Notch" : { "extractionState" : "stale", @@ -16576,9 +16581,6 @@ } } } - }, - "Open in Calendar" : { - }, "Open notch on hover" : { "localizations" : { 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 From 8f814aded745bb0a5857a0e754dbd82597511301 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 29 Aug 2026 19:05:53 +0530 Subject: [PATCH 05/13] Fix test-target signing so bare xcodebuild test works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boringNotchTests target inherited the macOS default ad-hoc identity, so whenever the app target is signed with the real team cert the test bundle's Team ID differs from its TEST_HOST and dlopen refuses to load it ('different Team IDs') before any test runs — every test invocation needed command-line overrides CODE_SIGN_IDENTITY/DEVELOPMENT_TEAM to pass. Give the test target the app target's ca4c328 choice explicitly: CODE_SIGN_IDENTITY = Apple Development, DEVELOPMENT_TEAM = JPWMG84CH8 (automatic style). Bare 'xcodebuild test' now loads the bundle and runs green with no overrides; CI can still override at build time. Co-authored-by: TheBoringMajdoor --- boringNotch.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 12c03ce46..66cda2adf 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -1592,8 +1592,10 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JPWMG84CH8; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.5; MARKETING_VERSION = 1.0; @@ -1612,8 +1614,10 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = JPWMG84CH8; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.5; MARKETING_VERSION = 1.0; From d96864c4bc404a3c6aefb8909ee920f72a7a4af7 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 29 Aug 2026 19:07:10 +0530 Subject: [PATCH 06/13] Hold each banner once: drop the redundant hold in show() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add() holds every notification at arrival — before the enqueue branch — and every path into show() (add, promoteNextQueued, cycleToNextQueued) passes one of those already-held notifications. The second hold in show() was a no-op against the helper's held set and an extra XPC round-trip per banner. The add() hold stays: it covers the enqueue path too. Co-authored-by: TheBoringMajdoor --- boringNotch/managers/SystemNotificationManager.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/boringNotch/managers/SystemNotificationManager.swift b/boringNotch/managers/SystemNotificationManager.swift index 4f325116b..bffe423bd 100644 --- a/boringNotch/managers/SystemNotificationManager.swift +++ b/boringNotch/managers/SystemNotificationManager.swift @@ -326,7 +326,10 @@ final class SystemNotificationManager: ObservableObject { if releasingPrevious, let previous = activeNotification, previous.id != notification.id { XPCHelperClient.shared.releaseNotification(token: previous.id) } - holdSystemBanner(notification) + // No holdSystemBanner here: every path in — add(), promoteNextQueued(), + // cycleToNextQueued() — passes a notification that was already held at + // arrival in add(), and re-holding is a no-op against the helper's + // held set. The add() hold stays: it also covers the enqueue path. withAnimation(.smooth) { activeNotification = notification } dismissTask?.cancel() dismissTask = Task { [weak self] in From fe502af2f7f9d623aebdbf5a945d866881830147 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 29 Aug 2026 19:10:30 +0530 Subject: [PATCH 07/13] Restore app-target DEVELOPMENT_TEAM undone by the Xcode churn commit 8cbb4fc accepted Xcode's project normalization, which silently re-cleared the app target's DEVELOPMENT_TEAM and re-added the sdk-qualified ad-hoc identity that ca4c328 had removed. Restore the team on the app target (Debug + Release); the helper target's long-standing ad-hoc config is unchanged. App and test targets now both sign JPWMG84CH8, so bare xcodebuild build/test works with real identities. Co-authored-by: TheBoringMajdoor --- boringNotch.xcodeproj/project.pbxproj | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 66cda2adf..475c4a28a 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -1464,13 +1464,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 = Automatic; COMBINE_HIDPI_IMAGES = YES; 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; @@ -1532,13 +1531,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 = Automatic; COMBINE_HIDPI_IMAGES = YES; 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 aef94a9ec9b973e06ef26e7586230cd4a93ddda0 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 29 Aug 2026 23:30:00 +0530 Subject: [PATCH 08/13] Balance the notch-open refcount on teardown; converge gate on reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the XPCHelperClient merge found the refcount could leak: cleanupWindows() (screen lock with showOnLockScreen off, or the all-displays toggle) tears windows down without closing the view model, so an open notch never fires its open->closed onChange and the helper's focus gate would stay stuck open forever — silently re-enabling the keyboard-focus steal the gate exists to prevent. - ContentView gains a state-guarded .onDisappear that decrements when a view dies while its notch is open (balanced against the onChange path; no double-decrement since a prior close clears the state first) - ensureRemoteService now always re-announces the effective gate state on a fresh connection instead of only re-sending true, so helper state converges to ours after helper restarts or app-quit-while-open Co-authored-by: TheBoringMajdoor --- boringNotch/ContentView.swift | 9 +++++++++ boringNotch/XPCHelperClient/XPCHelperClient.swift | 9 ++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index f96d14451..ae920616a 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -320,6 +320,15 @@ struct ContentView: View { XPCHelperClient.shared.notchClosed() } } + .onDisappear { + // Balance the refcount: torn down while open (screen + // lock, display-set change, window teardown) means the + // open->closed onChange never fires — without this the + // helper's focus gate would stay stuck open forever. + if vm.notchState == .open { + XPCHelperClient.shared.notchClosed() + } + } // 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 diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index 7afd44c74..0b72ea665 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -103,11 +103,10 @@ 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) } } - } + // A helper restart forgets our state — always re-announce the + // effective notch-open state so its banner keep-alive gate converges + // to ours, whether we currently count open notches or not. + Task { try? await service.withService { $0.setNotchOpen(notchOpenCount > 0) } } return service } From a9cdcf51418b3dacffa4784f4bfe9c64dc935a2b Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 29 Aug 2026 23:51:40 +0530 Subject: [PATCH 09/13] XPCHelperClient hygiene: drop dead surface, wire lastError, synchronize lunarListener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove dead private getRemoteService() — zero callers repo-wide. 2. Remove vestigial write-only hasLunarListener (property + all writes); the needsListener parameter existed only to feed it, so it goes too. 3. Make lastError truthful: every catch and former try? site now records .transport(underlying:) — MainActor sites set it directly, nonisolated sites hop via MainActor.run. Header comment rewritten to describe the actual behavior. No control flow or return values changed. 4. Remove zero-call-site APIs at all three layers: dismissNotification, isScreenBrightnessAvailable, isKeyboardBrightnessAvailable from client, protocol, and helper @objc wrappers (plus the now-unused KeyboardBrightnessClient.isAvailable). NotificationWatcher.dismiss stays — shared watcher internals are out of this pass's scope. 5. Synchronize NotificationXPCDelegate.lunarListener with an NSLock- guarded backing property: written on MainActor, read on the XPC delivery queue; listener still invoked on the delivery queue. 6. Fix helperAvailable transient: new connectionInterrupted flag set by interruption/invalidation hops, cleared when a fresh connection is stored; existing-connection path reports helperAvailable = !connectionInterrupted instead of unconditional true. Co-authored-by: TheBoringMajdoor --- .../BoringNotchXPCHelper.swift | 16 -- Shared/BoringNotchXPCHelperProtocol.swift | 3 - .../XPCHelperClient/XPCHelperClient.swift | 156 +++++++++++------- 3 files changed, 93 insertions(+), 82 deletions(-) diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift index 4c2175bb3..08b9c791a 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift @@ -153,10 +153,6 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { 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)) } - } - @objc func holdNotification(_ token: String) { DispatchQueue.main.async { Self.watcher.hold(token: token) } } @@ -191,8 +187,6 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { } } - var isAvailable: Bool { clientInstance != nil } - func currentBrightness() -> Float? { guard let clientInstance, let fn: BrightnessGetter = methodIMP(on: clientInstance, selector: getSelector, as: BrightnessGetter.self) @@ -221,10 +215,6 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { private static let keyboardClient = KeyboardBrightnessClient() - @objc func isKeyboardBrightnessAvailable(with reply: @escaping (Bool) -> Void) { - reply(Self.keyboardClient.isAvailable) - } - @objc func currentKeyboardBrightness(with reply: @escaping (NSNumber?) -> Void) { reply(Self.keyboardClient.currentBrightness().map { NSNumber(value: $0) }) } @@ -256,12 +246,6 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { return mainDisplayID } - @objc func isScreenBrightnessAvailable(with reply: @escaping (Bool) -> Void) { - let displayID = brightnessDisplayID() - var b: Float = 0 - reply(displayServicesGetBrightness(displayID: displayID, out: &b) || ioServiceFor(displayID: displayID) != nil) - } - @objc func currentScreenBrightness(with reply: @escaping (NSNumber?) -> Void) { let displayID = brightnessDisplayID() var b: Float = 0 diff --git a/Shared/BoringNotchXPCHelperProtocol.swift b/Shared/BoringNotchXPCHelperProtocol.swift index 5a8a84da2..63e1715de 100644 --- a/Shared/BoringNotchXPCHelperProtocol.swift +++ b/Shared/BoringNotchXPCHelperProtocol.swift @@ -43,11 +43,9 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { 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) @@ -68,7 +66,6 @@ final class BNLunarBrightnessEvent: NSObject, NSSecureCoding { 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) /// Effective notch-open state (any screen), ref-counted by the client. diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index 0b72ea665..e453e18d9 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -2,9 +2,13 @@ import Foundation import Cocoa import AsyncXPCConnection -/// 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. +/// Why a helper call failed. Methods still degrade to false/nil for +/// backward compatibility, but never silently: every transport failure — +/// a thrown XPC error or a dropped connection — is recorded in `lastError` +/// (interruption/invalidation record `.unavailable`), and connection loss +/// also flips `helperAvailable` for Settings to surface. A helper that +/// answers, even with false/nil, is a result, not an error, and leaves +/// `lastError` untouched. enum XPCHelperError: Error { /// The XPC service could not be reached (crashed or restarting). case unavailable @@ -33,11 +37,15 @@ final class XPCHelperClient: NSObject, ObservableObject { private var remoteService: RemoteXPCService? private var connection: NSXPCConnection? + /// Set by the interruption/invalidation hops, cleared when a fresh + /// connection is stored. Lets the existing-connection path report + /// `helperAvailable` without a true→false flicker while an + /// interruption's MainActor hop is still in flight. + private var connectionInterrupted = false private var lastKnownAuthorization: Bool? private let notificationDelegate = NotificationXPCDelegate() @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 @@ -46,7 +54,7 @@ final class XPCHelperClient: NSObject, ObservableObject { // MARK: - Connection Management (Main Actor Isolated) - private func ensureRemoteService(needsListener: Bool = false) -> RemoteXPCService { + private func ensureRemoteService() -> RemoteXPCService { // 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 @@ -59,8 +67,10 @@ final class XPCHelperClient: NSObject, ObservableObject { // captured in the helper and silently never arrived in the app. if let existing = remoteService { notificationDelegate.lunarListener = lunarListener - hasLunarListener = hasLunarListener || (needsListener && lunarListener != nil) - helperAvailable = true + // An interruption's MainActor hop may not have drained yet — + // don't flip availability true-then-false for a connection we + // already know was interrupted. + helperAvailable = !connectionInterrupted return existing } @@ -70,13 +80,12 @@ final class XPCHelperClient: NSObject, ObservableObject { notificationDelegate.lunarListener = lunarListener conn.exportedInterface = makeAppDelegateInterface() conn.exportedObject = notificationDelegate - hasLunarListener = needsListener && lunarListener != nil conn.interruptionHandler = { [weak self] in Task { @MainActor in self?.connection = nil self?.remoteService = nil - self?.hasLunarListener = false + self?.connectionInterrupted = true self?.helperAvailable = false self?.lastError = .unavailable } @@ -86,7 +95,7 @@ final class XPCHelperClient: NSObject, ObservableObject { Task { @MainActor in self?.connection = nil self?.remoteService = nil - self?.hasLunarListener = false + self?.connectionInterrupted = true self?.helperAvailable = false self?.lastError = .unavailable } @@ -101,18 +110,21 @@ final class XPCHelperClient: NSObject, ObservableObject { connection = conn remoteService = service + connectionInterrupted = false helperAvailable = true lastError = nil // A helper restart forgets our state — always re-announce the // effective notch-open state so its banner keep-alive gate converges // to ours, whether we currently count open notches or not. - Task { try? await service.withService { $0.setNotchOpen(notchOpenCount > 0) } } + Task { + do { + try await service.withService { $0.setNotchOpen(notchOpenCount > 0) } + } catch { + lastError = .transport(underlying: error) + } + } return service } - - private func getRemoteService() -> RemoteXPCService? { - remoteService - } private func makeAppDelegateInterface() -> NSXPCInterface { let interface = NSXPCInterface(with: (any BoringNotchXPCAppDelegate).self) @@ -171,8 +183,12 @@ final class XPCHelperClient: NSObject, ObservableObject { nonisolated func requestAccessibilityAuthorization() { Task { @MainActor in let service = ensureRemoteService() - try? await service.withService { service in - service.requestAccessibilityAuthorization() + do { + try await service.withService { service in + service.requestAccessibilityAuthorization() + } + } catch { + lastError = .transport(underlying: error) } } } @@ -188,6 +204,7 @@ final class XPCHelperClient: NSObject, ObservableObject { notifyAuthorizationChange(result) return result } catch { + lastError = .transport(underlying: error) return false } } @@ -203,25 +220,13 @@ final class XPCHelperClient: NSObject, ObservableObject { notifyAuthorizationChange(result) return result } catch { + lastError = .transport(underlying: error) return false } } // MARK: - Keyboard Brightness - func isKeyboardBrightnessAvailable() async -> Bool { - do { - let service = ensureRemoteService() - return try await service.withContinuation { service, continuation in - service.isKeyboardBrightnessAvailable { available in - continuation.resume(returning: available) - } - } - } catch { - return false - } - } - func currentKeyboardBrightness() async -> Float? { do { let service = ensureRemoteService() @@ -232,6 +237,7 @@ final class XPCHelperClient: NSObject, ObservableObject { } return result?.floatValue } catch { + lastError = .transport(underlying: error) return nil } } @@ -245,25 +251,13 @@ final class XPCHelperClient: NSObject, ObservableObject { } } } catch { + lastError = .transport(underlying: error) return false } } // MARK: - Screen Brightness - func isScreenBrightnessAvailable() async -> Bool { - do { - let service = ensureRemoteService() - return try await service.withContinuation { service, continuation in - service.isScreenBrightnessAvailable { available in - continuation.resume(returning: available) - } - } - } catch { - return false - } - } - func currentScreenBrightness() async -> Float? { do { let service = ensureRemoteService() @@ -274,6 +268,7 @@ final class XPCHelperClient: NSObject, ObservableObject { } return result?.floatValue } catch { + lastError = .transport(underlying: error) return nil } } @@ -289,6 +284,7 @@ final class XPCHelperClient: NSObject, ObservableObject { guard let num = result else { return nil } return CGDirectDisplayID(num.uint32Value) } catch { + lastError = .transport(underlying: error) return nil } } @@ -302,6 +298,7 @@ final class XPCHelperClient: NSObject, ObservableObject { } } } catch { + lastError = .transport(underlying: error) return false } } @@ -315,6 +312,7 @@ final class XPCHelperClient: NSObject, ObservableObject { } } } catch { + lastError = .transport(underlying: error) return nil } } @@ -330,6 +328,7 @@ final class XPCHelperClient: NSObject, ObservableObject { } } } catch { + lastError = .transport(underlying: error) return false } } @@ -341,24 +340,26 @@ final class XPCHelperClient: NSObject, ObservableObject { // which case this is the only path that hooks Lunar events up. notificationDelegate.lunarListener = listener do { - let service = ensureRemoteService(needsListener: true) + let service = ensureRemoteService() return try await service.withContinuation { service, continuation in service.startLunarEventStream { started in continuation.resume(returning: started) } } } catch { + lastError = .transport(underlying: error) return false } } func stopLunarEventStream() async { do { - let service = ensureRemoteService(needsListener: true) + let service = ensureRemoteService() try await service.withService { service in service.stopLunarEventStream() } } catch { + lastError = .transport(underlying: error) return } } @@ -372,6 +373,7 @@ final class XPCHelperClient: NSObject, ObservableObject { } } } catch { + lastError = .transport(underlying: error) return false } } @@ -383,7 +385,26 @@ final class XPCHelperClient: NSObject, ObservableObject { /// 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? + /// Written on the MainActor (connection setup, `startLunarEventStream`), + /// read on the XPC connection's private delivery queue. The lock + /// synchronizes cross-thread publication; the listener itself is still + /// invoked on the delivery queue — no per-event actor hop on this hot + /// path. + private let lunarListenerLock = NSLock() + private var _lunarListener: BoringNotchXPCHelperLunarListener? + + var lunarListener: BoringNotchXPCHelperLunarListener? { + get { + lunarListenerLock.lock() + defer { lunarListenerLock.unlock() } + return _lunarListener + } + set { + lunarListenerLock.lock() + _lunarListener = newValue + lunarListenerLock.unlock() + } + } func lunarEventDidUpdate(_ event: BNLunarBrightnessEvent) { lunarListener?.lunarEventDidUpdate(event) @@ -417,6 +438,7 @@ extension XPCHelperClient { } } } catch { + await MainActor.run { self.lastError = .transport(underlying: error) } return false } } @@ -424,7 +446,11 @@ extension XPCHelperClient { nonisolated func stopNotificationWatching() { Task { let service = await MainActor.run { ensureRemoteService() } - try? await service.withService { $0.stopNotificationWatching() } + do { + try await service.withService { $0.stopNotificationWatching() } + } catch { + await MainActor.run { self.lastError = .transport(underlying: error) } + } } } @@ -437,6 +463,7 @@ extension XPCHelperClient { } } } catch { + await MainActor.run { self.lastError = .transport(underlying: error) } return false } } @@ -450,6 +477,7 @@ extension XPCHelperClient { } } } catch { + await MainActor.run { self.lastError = .transport(underlying: error) } return false } } @@ -463,6 +491,7 @@ extension XPCHelperClient { } } } catch { + await MainActor.run { self.lastError = .transport(underlying: error) } return false } } @@ -472,14 +501,22 @@ extension XPCHelperClient { nonisolated func holdNotification(token: String) { Task { let service = await MainActor.run { ensureRemoteService() } - try? await service.withService { $0.holdNotification(token) } + do { + try await service.withService { $0.holdNotification(token) } + } catch { + await MainActor.run { self.lastError = .transport(underlying: error) } + } } } nonisolated func releaseNotification(token: String) { Task { let service = await MainActor.run { ensureRemoteService() } - try? await service.withService { $0.releaseNotification(token) } + do { + try await service.withService { $0.releaseNotification(token) } + } catch { + await MainActor.run { self.lastError = .transport(underlying: error) } + } } } @@ -507,7 +544,11 @@ extension XPCHelperClient { @MainActor private func sendNotchOpen(_ open: Bool) { let service = ensureRemoteService() Task { - try? await service.withService { $0.setNotchOpen(open) } + do { + try await service.withService { $0.setNotchOpen(open) } + } catch { + lastError = .transport(underlying: error) + } } } @@ -520,19 +561,7 @@ extension XPCHelperClient { } } } 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 { + await MainActor.run { self.lastError = .transport(underlying: error) } return false } } @@ -546,6 +575,7 @@ extension XPCHelperClient { } } } catch { + await MainActor.run { self.lastError = .transport(underlying: error) } return "xpc error: \(error)" } } From abd46421bb683867633e52b9faf136db7e7af9b7 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sat, 29 Aug 2026 23:52:46 +0530 Subject: [PATCH 10/13] Remove watcher.dismiss(token:) left dead by the RPC surface cleanup Co-authored-by: TheBoringMajdoor --- BoringNotchXPCHelper/NotificationWatcher.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index e722d8344..488914a04 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -556,15 +556,6 @@ final class NotificationWatcher { 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 = element(for: 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. From 3e3b9bbebd62d4ec6f5fc6dcd7d9b39de54f33ef Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sun, 30 Aug 2026 09:45:27 +0530 Subject: [PATCH 11/13] Drop inert connectionInterrupted flag; identity-guard XPC invalidation Audit of the hygiene commit found the connectionInterrupted flag was observably inert: the interruption hop nils remoteService in the same MainActor task that sets the flag, so the existing-connection path can never read it true. Remove the dead logic instead of layering more state on a one-runloop transient. The same review surfaced a real pre-existing bug: the interruption and invalidation handlers nilled connection/remoteService with no identity check, so a stale handler from a deallocated connection could wipe a freshly-built one. Both handlers now capture their connection weakly and bail unless it is still the current one. Co-authored-by: TheBoringMajdoor --- .../XPCHelperClient/XPCHelperClient.swift | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/boringNotch/XPCHelperClient/XPCHelperClient.swift b/boringNotch/XPCHelperClient/XPCHelperClient.swift index e453e18d9..3140618b2 100644 --- a/boringNotch/XPCHelperClient/XPCHelperClient.swift +++ b/boringNotch/XPCHelperClient/XPCHelperClient.swift @@ -38,10 +38,6 @@ final class XPCHelperClient: NSObject, ObservableObject { private var remoteService: RemoteXPCService? private var connection: NSXPCConnection? /// Set by the interruption/invalidation hops, cleared when a fresh - /// connection is stored. Lets the existing-connection path report - /// `helperAvailable` without a true→false flicker while an - /// interruption's MainActor hop is still in flight. - private var connectionInterrupted = false private var lastKnownAuthorization: Bool? private let notificationDelegate = NotificationXPCDelegate() @MainActor private var activationObserver: (any NSObjectProtocol)? @@ -67,10 +63,7 @@ final class XPCHelperClient: NSObject, ObservableObject { // captured in the helper and silently never arrived in the app. if let existing = remoteService { notificationDelegate.lunarListener = lunarListener - // An interruption's MainActor hop may not have drained yet — - // don't flip availability true-then-false for a connection we - // already know was interrupted. - helperAvailable = !connectionInterrupted + helperAvailable = true return existing } @@ -81,23 +74,25 @@ final class XPCHelperClient: NSObject, ObservableObject { conn.exportedInterface = makeAppDelegateInterface() conn.exportedObject = notificationDelegate - conn.interruptionHandler = { [weak self] in + conn.interruptionHandler = { [weak self, weak conn] in Task { @MainActor in - self?.connection = nil - self?.remoteService = nil - self?.connectionInterrupted = true - self?.helperAvailable = false - self?.lastError = .unavailable + // Ignore stale handlers: an interruption from a deallocated + // connection must not nil a freshly-built one. + guard let self, let conn, self.connection === conn else { return } + self.connection = nil + self.remoteService = nil + self.helperAvailable = false + self.lastError = .unavailable } } - conn.invalidationHandler = { [weak self] in + conn.invalidationHandler = { [weak self, weak conn] in Task { @MainActor in - self?.connection = nil - self?.remoteService = nil - self?.connectionInterrupted = true - self?.helperAvailable = false - self?.lastError = .unavailable + guard let self, let conn, self.connection === conn else { return } + self.connection = nil + self.remoteService = nil + self.helperAvailable = false + self.lastError = .unavailable } } @@ -110,7 +105,6 @@ final class XPCHelperClient: NSObject, ObservableObject { connection = conn remoteService = service - connectionInterrupted = false helperAvailable = true lastError = nil // A helper restart forgets our state — always re-announce the From 90e3b3739a16d90075c40c8a6e4e300ae0deda27 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sun, 30 Aug 2026 10:54:49 +0530 Subject: [PATCH 12/13] MessagesSender: resolve chats first, refuse ambiguous participant sends Reply into the originating conversation instead of the first global participant match: scan chats by their participants' display names, prefer a 1:1 chat (preserves account/service/handle/thread), then a matching group chat, and only fall back to a bare participant when exactly one matches library-wide. Zero or 2+ participant matches now return distinct notfound/ambiguous statuses and map to false, so a wrong-person/wrong-thread/wrong-transport send can never report ok. Addresses Alexander5015's review on PR #1503. Co-authored-by: TheBoringMajdoor --- BoringNotchXPCHelper/MessagesSender.swift | 105 ++++++++++++++++++---- 1 file changed, 87 insertions(+), 18 deletions(-) diff --git a/BoringNotchXPCHelper/MessagesSender.swift b/BoringNotchXPCHelper/MessagesSender.swift index 31c223d87..e2d67d7f3 100644 --- a/BoringNotchXPCHelper/MessagesSender.swift +++ b/BoringNotchXPCHelper/MessagesSender.swift @@ -25,30 +25,84 @@ 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. + /// Resolves the notification's sender to a Messages conversation and + /// sends the reply INTO that conversation, so the account, service + /// (iMessage/SMS/RCS), handle, and thread of the originating chat are + /// preserved. Returns false when no conversation resolves + /// unambiguously (or automation permission was denied) — a + /// wrong-person/wrong-thread send is worse than no send, and the + /// caller falls 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. + // Chat-first, matching on the chat's PARTICIPANTS — never on the + // chat's own name: verified against a real Messages library, + // `name of chat` returns `missing value` for every 1:1 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. AppleScript's + // `is equal to` on strings ignores case by default, which is what + // we want here. // - // 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. + // Resolution order: + // 1. A chat with exactly ONE participant matching — the 1:1 + // thread is the originating conversation; sending into the + // chat keeps its account/service/handle/thread. + // 2. The first GROUP chat containing a matching participant — + // still a real conversation, so routing is preserved. + // 3. A bare participant, ONLY if exactly one participant in the + // whole library matches. A participant has no thread context, + // and duplicates for the same person across handles/services + // (e:me@…, +9198…) are normal — 2+ matches is ambiguous and + // must not be guessed. let script = """ tell application "Messages" + set targetName to "\(escape(name))" + set replyText to "\(escape(text))" + + set groupMatch to missing value + repeat with c in chats + try + set matched to false + repeat with p in participants of c + try + if (name of p as string) is equal to targetName then + set matched to true + exit repeat + end if + end try + end repeat + if matched then + if (count of participants of c) is 1 then + send replyText to c + return "ok-chat-1v1" + else if groupMatch is missing value then + set groupMatch to c + end if + end if + end try + end repeat + if groupMatch is not missing value then + send replyText to groupMatch + return "ok-chat-group" + end if + + set matchCount to 0 + set soleMatch to missing value 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" + if (name of p as string) is equal to targetName then + set matchCount to matchCount + 1 + set soleMatch to p end if end try end repeat + if matchCount is 1 then + send replyText to soleMatch + return "ok-participant" + else if matchCount is 0 then + return "notfound" + else + return "ambiguous" + end if end tell return "notfound" """ @@ -66,11 +120,26 @@ enum MessagesSender { return false } - let ok = output.stringValue == "ok" - if !ok { + // Any "ok-*" status is a successful send; the suffix says which + // resolution path delivered it so routing decisions are + // diagnosable from the log. "notfound" and "ambiguous" both map + // to false — never send when unsure. + let status = output.stringValue ?? "" + switch status { + case "ok-chat-1v1": + NSLog("[boringNotch] Messages: sent into 1:1 chat with \(name.debugDescription) (account/service/handle/thread preserved)") + case "ok-chat-group": + NSLog("[boringNotch] Messages: no 1:1 chat for \(name.debugDescription); sent into first matching group chat") + case "ok-participant": + NSLog("[boringNotch] Messages: no chat matched \(name.debugDescription); sent to the single matching participant") + case "notfound": NSLog("[boringNotch] Messages: no chat or participant named \(name.debugDescription)") + case "ambiguous": + NSLog("[boringNotch] Messages: \(name.debugDescription) matches multiple participants across handles/services — refusing to guess") + default: + NSLog("[boringNotch] Messages: unexpected status \(status.debugDescription) for \(name.debugDescription)") } - return ok + return status.hasPrefix("ok") } /// Message text is arbitrary user input going into an AppleScript From 3e8ddcbd727bf8022e7d48b728f2047ca9a3df63 Mon Sep 17 00:00:00 2001 From: theboringhumane Date: Sun, 30 Aug 2026 11:00:49 +0530 Subject: [PATCH 13/13] Park held banners only while notch open and restore origins; reply off main queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hold() inserted into held unconditionally but moved Notification Center's shared window off-screen unconditionally too, hiding unrelated banners and never restoring the position (RC1). The park is now gated on notchOpen like the keep-alive toggle, the window's origin is recorded on first park keyed by CFHash(window), and the position is restored when the last parked hold on that window ends (release, banner-gone, stop). reply() drove the banner's async AX hierarchy with a fixed 400ms Thread.sleep on the helper's main queue — stalling the banner poll and hold refresh — and equated AX action success with delivery (RC4). The reply path now runs on a serial replyQueue (the XPC wrapper completes from there), waits bounded 50ms slices for the reply field to appear (~1.2s cap), and after the send verifies the banner accepted the reply by polling for the field to clear or disappear (~1s cap), reporting false otherwise. AX still cannot verify network delivery; documented. Co-authored-by: TheBoringMajdoor --- .../BoringNotchXPCHelper.swift | 5 +- .../NotificationWatcher.swift | 245 +++++++++++++++--- 2 files changed, 211 insertions(+), 39 deletions(-) diff --git a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift index 08b9c791a..9cde1c62c 100644 --- a/BoringNotchXPCHelper/BoringNotchXPCHelper.swift +++ b/BoringNotchXPCHelper/BoringNotchXPCHelper.swift @@ -129,7 +129,10 @@ class BoringNotchXPCHelper: NSObject, BoringNotchXPCHelperProtocol { } @objc func replyToNotification(_ token: String, text: String, with reply: @escaping (Bool) -> Void) { - DispatchQueue.main.async { reply(Self.watcher.reply(token: token, text: text)) } + // The watcher's reply path does bounded waiting on the banner's AX + // hierarchy; it runs on the watcher's reply queue, never on main — + // the helper's main queue drives the banner poll and hold refresh. + Self.watcher.replyOnQueue(token: token, text: text, completion: reply) } /// Sends an iMessage directly through the Messages scripting diff --git a/BoringNotchXPCHelper/NotificationWatcher.swift b/BoringNotchXPCHelper/NotificationWatcher.swift index 488914a04..eb85981d4 100644 --- a/BoringNotchXPCHelper/NotificationWatcher.swift +++ b/BoringNotchXPCHelper/NotificationWatcher.swift @@ -44,19 +44,28 @@ final class NotificationWatcher { private var live: [String: AXUIElement] = [:] /// Banners being deliberately kept alive so their reply field stays - /// usable, and of those, the ones moved off-screen. + /// usable. private var held: Set = [] - private var heldOffScreen: Set = [] + /// Held banners whose window is currently parked off-screen: + /// token → key of the parked window. The key is + /// `Int(bitPattern: CFHash(window))` — AXUIElement hashing is + /// identity-based (the same underlying element hashes equal across the + /// fresh wrapper objects each AX copy returns), so a parked window can + /// be re-identified later by scanning the app's current windows. + private var parkedWindowByToken: [String: Int] = [:] + /// Parked window key → the position the window had before it was first + /// parked, so it can be restored when the last hold on it ends. + private var parkedWindowOrigins: [Int: CGPoint] = [:] 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. + /// not unfocus off-screen windows) — so the toggle, and the off-screen + /// park in hold(token:) that hides the expanded banner, both run only + /// while the notch is open. Defaults to false: never steal focus + /// before being told. var notchOpen: Bool = false { didSet { if notchOpen, !oldValue { @@ -86,6 +95,12 @@ final class NotificationWatcher { private let idlePollInterval: TimeInterval = 0.5 private var currentPollInterval: TimeInterval = 0 + /// Serial queue for reply work. Driving a banner's AX hierarchy needs + /// bounded waiting — for the reply field to appear, then for the send + /// to be accepted — and the helper's main queue, which runs the banner + /// poll and the hold keep-alive, must never block on that. + private let replyQueue = DispatchQueue(label: "BoringNotchXPCHelper.reply", qos: .userInitiated) + var isRunning: Bool { appElement != nil } // MARK: - Lifecycle @@ -128,6 +143,11 @@ final class NotificationWatcher { func stop() { pollTimer?.cancel() pollTimer = nil + // Put any still-parked windows back before dropping the app element + // — restoreParkedWindow needs it to re-identify the windows. + for token in Array(parkedWindowByToken.keys) { + restoreParkedWindow(forToken: token) + } appElement = nil live.removeAll() // Correctness hygiene rather than the fix for a live leak: once @@ -137,7 +157,6 @@ final class NotificationWatcher { // leaving stale tokens around after a stop/restart cycle is still // wrong, so clear them explicitly. held.removeAll() - heldOffScreen.removeAll() skipLogged.removeAll() } @@ -167,7 +186,7 @@ final class NotificationWatcher { for token in live.keys where !seen.contains(token) { live[token] = nil - heldOffScreen.remove(token) + restoreParkedWindow(forToken: token) skipLogged.remove(token) onBannerGone?(token) } @@ -237,29 +256,46 @@ final class NotificationWatcher { } } - /// Holds a banner open so its reply field stays usable, moving it - /// off-screen first. + /// Holds a banner open so its reply field stays usable, parking the + /// banner's window off-screen while the notch is open. /// - /// 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. + /// Holding keeps the banner alive via the 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 while the notch is open the + /// window is parked off-screen 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. + /// The park is gated on notchOpen, consistent with the keep-alive gate + /// in refreshHeldBanners: with the notch closed the banner keeps its + /// normal visible life and expires naturally (the app re-calls hold + /// when the notch opens, which is where a closed-notch hold gets + /// parked). The window is Notification Center's shared window — parking + /// it also hides every unrelated banner in it — so the original + /// position is recorded on first park and restored when the last + /// parked hold on it ends (release, banner-gone, stop). func hold(token: String) { guard let banner = live[token] else { return } held.insert(token) - guard !heldOffScreen.contains(token) else { return } - heldOffScreen.insert(token) + guard parkedWindowByToken[token] == nil else { return } // already parked + guard notchOpen else { return } // park only while the notch is open + guard let windowValue = banner[kAXWindowAttribute], CFGetTypeID(windowValue as CFTypeRef) == AXUIElementGetTypeID() else { return } let window = windowValue as! AXUIElement + let key = Int(bitPattern: CFHash(window)) + + // Record the original position on the FIRST park of this window — + // a second banner parking the same window must not overwrite it + // with the off-screen point. If the origin can't be read, don't + // park at all: an unrestorable move is worse than a visible banner. + if parkedWindowOrigins[key] == nil { + guard let origin = window.cgPointAttribute(kAXPositionAttribute) else { return } + parkedWindowOrigins[key] = origin + } + parkedWindowByToken[token] = key + var target = CGPoint(x: -5000, y: -5000) if let position = AXValueCreate(.cgPoint, &target) { AXUIElementSetAttributeValue(window, kAXPositionAttribute as CFString, position) @@ -269,14 +305,43 @@ final class NotificationWatcher { /// Stops holding a banner and lets it dismiss naturally. func release(token: String) { held.remove(token) - heldOffScreen.remove(token) skipLogged.remove(token) + restoreParkedWindow(forToken: token) guard let banner = live[token] else { return } if let close = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("close") }) { AXUIElementPerformAction(banner, close as CFString) } } + /// Ends the off-screen park for a token, if it had one. The window is + /// moved back to its recorded origin only when no other parked token + /// still references it; a window that no longer exists just drops the + /// bookkeeping. + private func restoreParkedWindow(forToken token: String) { + guard let key = parkedWindowByToken.removeValue(forKey: token) else { return } + guard !parkedWindowByToken.values.contains(key) else { return } + guard let origin = parkedWindowOrigins.removeValue(forKey: key) else { return } + guard let window = currentWindow(matching: key) else { return } + var point = origin + if let value = AXValueCreate(.cgPoint, &point) { + AXUIElementSetAttributeValue(window, kAXPositionAttribute as CFString, value) + } + } + + /// Re-identifies a parked window among the app's current windows by its + /// recorded CFHash key. Returns nil when the window is gone — + /// Notification Center destroys the window when its last banner leaves, + /// and a fresh one always spawns at the normal position, so nothing is + /// lost by not restoring it. + private func currentWindow(matching key: Int) -> AXUIElement? { + guard let appElement else { return nil } + for window in (appElement[kAXWindowsAttribute] as? [AXUIElement]) ?? [] { + guard window[kAXSubroleAttribute] as? String == "AXSystemDialog" else { continue } + if Int(bitPattern: CFHash(window)) == key { return window } + } + return nil + } + /// 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. @@ -490,6 +555,27 @@ final class NotificationWatcher { // MARK: - Acting on a banner + /// XPC-facing entry point for replying. Watcher state (`live`) lives on + /// the main queue, so the banner element is resolved there; the AX + /// driving — the part with bounded waits — runs on `replyQueue`, and + /// the completion is invoked from there. XPC reply blocks are safe to + /// call later and from any thread. + func replyOnQueue(token: String, text: String, completion: @escaping (Bool) -> Void) { + DispatchQueue.main.async { [weak self] in + guard let self, let banner = self.element(for: token) else { + completion(false) + return + } + self.replyQueue.async { [weak self] in + guard let self else { + completion(false) + return + } + completion(self.reply(token: token, text: text, on: 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. @@ -503,11 +589,17 @@ final class NotificationWatcher { /// 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 = element(for: token) else { return false } - - if replyField(in: banner) == nil { + /// implementations. Matching actions by English substring is a known, + /// documented limitation: a non-English system simply reports failure. + /// + /// Must run on `replyQueue` (via replyOnQueue), never on the main + /// queue: the bounded waits below would stall the banner poll and the + /// hold keep-alive. + private func reply(token: String, text: String, on banner: AXUIElement) -> Bool { + let field: AXUIElement + if let existing = replyField(in: banner) { + field = existing + } else { if let action = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("reply") }) ?? rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("details") }) { AXUIElementPerformAction(banner, action as CFString) @@ -515,26 +607,91 @@ final class NotificationWatcher { ($0[kAXTitleAttribute] as? String)?.lowercased().contains("reply") == true }) { AXUIElementPerformAction(button, kAXPressAction as CFString) + } else { + NSLog("[boringNotch] reply failed for \(token): banner exposes no reply affordance") + return false } - // 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 appeared = waitForReplyField(in: banner) else { + NSLog("[boringNotch] reply failed for \(token): reply field never appeared after expanding the banner") + return false + } + field = appeared } - 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 } + else { + NSLog("[boringNotch] reply failed for \(token): could not set the reply field's value") + return false + } + let sendAccepted: Bool if let send = rawAction(on: banner, matching: { $0.localizedCaseInsensitiveContains("send") }) { - return AXUIElementPerformAction(banner, send as CFString) == .success + sendAccepted = AXUIElementPerformAction(banner, send as CFString) == .success + } else { + sendAccepted = AXUIElementPerformAction(field, kAXConfirmAction as CFString) == .success + } + guard sendAccepted else { + NSLog("[boringNotch] reply failed for \(token): the send action was not accepted") + return false + } + + guard verifyReplyAccepted(field: field) else { + NSLog("[boringNotch] reply failed for \(token): banner did not accept the reply (field still filled after send)") + return false + } + NSLog("[boringNotch] reply sent for \(token): banner accepted it (field cleared)") + return true + } + + /// Bounded poll for the reply field to appear after the reveal action — + /// the banner rebuilds its hierarchy asynchronously, so the field shows + /// up some tens of milliseconds later, or never for banners without + /// reply support. 50ms slices, ~1.2s cap. + /// + /// 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. The sleep is safe because this + /// runs on `replyQueue` — it used to run on the main queue, stalling + /// the banner poll and the hold keep-alive, which is what moved it. + private func waitForReplyField(in banner: AXUIElement, timeout: TimeInterval = 1.2) -> AXUIElement? { + let deadline = Date().addingTimeInterval(timeout) + while true { + if let field = replyField(in: banner) { return field } + guard Date() < deadline else { return nil } + Thread.sleep(forTimeInterval: 0.05) + } + } + + /// Post-send verification: a banner that accepted the reply empties the + /// field, or tears it down entirely as the banner collapses; a field + /// still holding the text a second later means the send did not take. + /// 50ms slices, ~1s cap. + /// + /// Honest scope: this verifies the banner *accepted* the reply — the + /// part that is observable through AX. It cannot verify network + /// delivery to the recipient; that stays a documented limitation, and + /// a true result means "accepted by Notification Center", nothing more. + private func verifyReplyAccepted(field: AXUIElement, timeout: TimeInterval = 1.0) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while true { + var value: CFTypeRef? + // An AX error means the field was destroyed with the sent + // banner's collapse — that counts as accepted. + guard AXUIElementCopyAttributeValue(field, kAXValueAttribute as CFString, &value) == .success + else { return true } + if (value as? String)?.isEmpty == true { return true } + guard Date() < deadline else { return false } + Thread.sleep(forTimeInterval: 0.05) } - return AXUIElementPerformAction(field, kAXConfirmAction as CFString) == .success } - private func replyField(in element: AXUIElement, depth: Int = 0) -> AXUIElement? { - descendants(of: element, matching: [kAXTextFieldRole, kAXTextAreaRole]).first + private func replyField(in element: AXUIElement) -> AXUIElement? { + let fields = descendants(of: element, matching: [kAXTextFieldRole, kAXTextAreaRole]) + // Prefer the focused field: after the details toggle the banner + // moves focus to its reply box, so focus is a stronger signal than + // document order. Fall back to the first text descendant. + return fields.first(where: { ($0[kAXFocusedAttribute] as? Bool) == true }) ?? fields.first } /// Performs a named AX action on the banner, or presses the button with @@ -593,4 +750,16 @@ private extension AXUIElement { guard AXUIElementCopyAttributeValue(self, attribute as CFString, &value) == .success else { return nil } return value } + + /// Reads a CGPoint-valued AX attribute, guarding every cast and type + /// check on the way out. + func cgPointAttribute(_ attribute: String) -> CGPoint? { + guard let value = self[attribute], + CFGetTypeID(value as CFTypeRef) == AXValueGetTypeID() else { return nil } + let axValue = value as! AXValue + guard AXValueGetType(axValue) == .cgPoint else { return nil } + var point = CGPoint.zero + guard AXValueGetValue(axValue, .cgPoint, &point) else { return nil } + return point + } }