Notification live activity, compact mode & audit remediation (phases 0-4) - #1496
Notification live activity, compact mode & audit remediation (phases 0-4)#1496theboringhumane wants to merge 69 commits into
Conversation
Mirrors Notification Center banners (Messages, WhatsApp, Telegram, Discord, Mail, Outlook, FaceTime by default) as a closed-state pill and an expanded compose view in the notch, using the XPC helper's existing Accessibility access to read banners via AX rather than any private notification API. - NotificationWatcher (helper): AXObserver + poll fallback over com.apple.notificationcenterui, matched by banner subrole since the containment path isn't stable across macOS releases. Reply/call actions and dismiss are driven by whatever AX actions the banner actually exposes (verified live: WhatsApp uses "Show Details"/"Send", not "Reply" — action names are not what they look like). - SystemNotificationManager: app-side state, per-app allow list, auto-dismiss timer, optional per-app system-banner suppression (closes the OS banner right after capture — there's no API to stop it rendering in the first place). - NotificationLiveActivity: closed pill (icon + status dot, or a widened code pill when a verification code is detected) and an expanded view with reply, call accept/decline, or open-in-app, depending on what the banner supports. - ContactAvatarManager: resolves sender name to a Contacts photo, falls back to a colored monogram. Exact-match only — an ambiguous name match returns no photo rather than guessing. - OTPDetector: verification-code extraction, gated on a nearby keyword rather than bare digit runs, to avoid matching phone numbers/prices/invoice numbers. Covered by an assert-based self-check that runs on every debug launch. - NotificationSettingsView: per-app enable + banner-suppress toggles, its own Settings tab. Reply-send and OTP detection were both verified against live banners (WhatsApp send, and terminal-notifier-posted OTP-shaped banners run through the real capture path), not just unit-tested in isolation.
"Match real notch height" was removed as a choice for non-notch displays (no real notch to match there), but a value persisted from an older build that allowed it has no matching Picker tag anymore — SwiftUI logs "the selection is invalid" and shows no selection. Reset it to matchMenuBar on sync rather than leaving stale prefs with no valid UI representation.
Same capture pipeline as every other app — just extends the default allow list with com.anthropic.claudefordesktop (confirmed from Claude.app's own Info.plist). Also scopes the Contacts-avatar treatment to actual messaging/calling apps (Messages, FaceTime, Mail, Outlook, WhatsApp, Telegram, Discord) rather than every app's notification title — Claude's title is a session name, not a person, and would otherwise fire a pointless Contacts lookup and show a nonsensical monogram.
An earlier live test only ever captured a banner post-expansion, where "Reply" is already gone (replaced by "Show Details"/"Send") — leading to a wrong "Notification Center never exposes Reply" comment. A fresh AX dump of a collapsed WhatsApp banner shows the actual action list is ["AXPress", "Show Details", "Reply", "Close"]. Reply still works via the "Show Details" fallback either way, but now uses the more direct, purpose-built action when it's there. canReply's heuristic gets the same correction.
WhatsApp's localizedName is literally "\u{200E}WhatsApp" — it carries a
leading LEFT-TO-RIGHT MARK. capture() stripped bidi marks from the
parsed app name, then compared that stripped string against the
unstripped localizedName, so the match failed, bundleID came back nil,
and isAllowed() discarded every WhatsApp notification before it ever
reached the notch. Discord/Messages/Claude have no such mark, which is
why this went unnoticed.
Match on a normalized name (bidi marks removed, case-folded) on both
sides, and add an app-name-to-bundle-ID-suffix fallback so a lookup
miss degrades to a best-effort match instead of dropping the
notification outright.
The helper cast its callback proxy to BoringNotchXPCHelperDelegate, but the connection's remoteObjectInterface is the combined BoringNotchXPCAppDelegate. A distant-object proxy's conformance comes from the exact interface it was configured with, so casting to the parent protocol can yield nil — and since the delegate is optional, every captured banner was then silently discarded before reaching the app. Cast to the exact protocol, use remoteObjectProxyWithErrorHandler so transport errors surface instead of vanishing, and log at each hop (captured -> delivered -> filtered/shown) so a break in the chain is attributable rather than invisible.
An XPC service's main thread is driven by dispatch_main(), which services DispatchQueue.main blocks but does not run a CFRunLoop. The watcher depended on one twice — Timer + RunLoop.current.add for the poll, and CFRunLoopAddSource for the AXObserver — so neither ever fired. start() ran its single initial scan(), reported success, and then captured nothing for the rest of the process's life. From the outside this looked like "watcher started, AX trusted, no banners ever seen", which is exactly what it was. Replace both with a DispatchSourceTimer, which needs no run loop. The AXObserver goes away rather than being moved to a dedicated run-loop thread: it was already only a latency optimization over the poll that actually does the work, and polling every 0.35s catches every banner (they live ~5s). reply() had the same latent bug — RunLoop.run(until:) returns immediately here, so the wait for the reply field to appear was a no-op. Use Thread.sleep.
The helper captures its callback proxy once, when notification watching starts. ensureRemoteService invalidated and rebuilt the connection whenever Lunar/OSD asked for a listener, which left the helper holding a proxy to a dead connection — banners kept being captured and logged in the helper, and silently never arrived in the app. Nothing needs the teardown any more: since the exported object serves both callback protocols from creation, a live connection is always reusable. Register the Lunar listener on that shared object at subscribe time, since the connection may already exist and that's now the only path that wires it up. Also add a stack of closed-notch live activities: a notification takes the front and reverts to music on expiry (falling out of the list is the whole mechanism — no restore bookkeeping), with horizontal swipe to move between them. Chin width follows the selected activity rather than whichever happens to exist.
holdActive() ran on appear and cancelled the dismiss timer for as long as the expanded view existed, so opening the notch pinned the current notification indefinitely — hovering the notch minutes later still showed a long-dead message instead of the normal home view, with no reply field (the banner was long gone) and no way back except dismissing it. Hold only while the reply field has focus, which is the case that actually needs protecting from the timer. Everything else lets the notification age out and hand the notch back. Also make the expanded view fill its slot instead of clustering in the top-left corner, and scale the avatar and type to the 640x190 open notch rather than banner-sized proportions.
Two things made the notification vanish just as the notch opened: holding was scoped to reply-field focus (so a notification with no reply action was never held at all), and the countdown only paused once the expanded view existed — but opening waits out minimumHoverDuration plus an animation, so a notification near the end of its 8s could die during the gesture that was meant to reveal it. Freeze the countdown when the pointer arrives and resume when it leaves, with the open notch holding it too. holdActive now caps at maxLifetime (30s) instead of cancelling outright, so this can't regress into pinning a dead notification in the notch forever. resumeDismiss now replaces the pending task rather than bailing when one exists — otherwise holdActive's cap would survive the notch closing and keep the notification up for the full 30s.
The opened notch is sized for the home/shelf tabs, and the notification view was stretching to fill it — two lines of text floating in a 190pt-tall black slab. Shrink the notch itself to 132pt while a notification is showing, cap the content at 460pt wide, and scale the avatar and type back down so it reads as a notification rather than a page.
Several things were forcing the panel to its full 640pt regardless of how short the message was: a Spacer in the header row, another in the code and call rows, a bare TextField (which claims every point offered), maxWidth: .infinity on the text column, and BoringHeader — whose tab bar spans the whole notch — rendering above it. Drop the Spacers, bound the text column and reply field, and hide the tab bar while a notification is showing. A notification is a glance, not somewhere you switch between home and shelf. Also keep the reply box for as long as the notification is in the notch, rather than swapping it for "Open in <app>" the moment the system banner dies (~5s). Replying types into that banner's own field, so there's genuinely no way to send once it's gone — but silently dropping a typed message is worse than being useful about it: the draft goes to the clipboard, the app opens, and the button shows a clipboard glyph rather than a checkmark, since a hand-off is not a delivery.
The X sat inline in the header row at the avatar's vertical center rather than the card's actual top-right corner. Moved it to a topTrailing overlay on the whole card so it's pinned regardless of content height. The reply TextField was fixed at 200pt. It's safe to let it flex now: the containing column is already capped at 300pt (from the earlier width-fitting fix), so a flexible field fills up to that cap instead of stretching the notch the way an unbounded TextField would have. Confirmed via logging that the earlier "H for Matashree" avatar question wasn't a bug — it's a real Contacts photo on that card, not the monogram fallback. Header/tabs stay hidden during a notification, per explicit confirmation: bringing them back would force the notch to full width again, undoing the width-fitting work, since BoringHeader's layout needs the full span to make sense.
The reported "notch sits a bit off the top of the screen" only happened with a notification open, which pointed at the recent openNotchHeight change rather than window positioning (windowSize is a fixed constant, independent of content height). Confirmed the cause: .frame(height: openNotchHeight) had no alignment, so it defaulted to centering — shrinking from 190pt to 132pt pulled the visible top edge down by roughly half the difference instead of staying flush with the window's top-anchored origin. Added alignment: .top. Also: the close button was a full 30pt HoverButton, reading as a toolbar control rather than a notification's dismiss — replaced with an 18pt compact circle closer to iOS's. And a blanket 20pt trailing padding on the whole card (added only so the header text wouldn't run under the close button) was pushing the reply row's right edge in for no reason, visible as a ~43pt dead gap next to the send button — moved that reserve onto just the header row, which is the only thing it actually needs to clear.
BoringNotchWindow.canBecomeKey was hardcoded false — deliberate, so a click on the notch never steals focus from the frontmost app, but it also meant no text field in this window could ever receive a real keyDown event. @FocusState/.focused() only sets SwiftUI's internal responder within the view hierarchy; macOS never routes keystrokes to a window that can't become key. Every keystroke while "typing" into the reply field was actually going to whatever app was previously frontmost. Made canBecomeKey conditional on a new wantsKeyForTextInput flag, default false, flipped on only while the reply field is actually focused and back off the instant it isn't (focus-lost, send, or the view disappearing) — never left on, or every other notch interaction (hover-to-open, music controls) regresses to stealing focus. Also: typing now pauses the dismiss timer with no cap, versus the existing capped hold that only engaged on notch-open. A keystroke is the clearest possible "still here" signal, so maxLifetime — which exists to protect against an abandoned open notch — doesn't apply while there's an actual person composing a reply. Reverts to the capped hold the moment the field loses focus.
Researched via Apple docs/WWDC25-26 material before building: the FoundationModels framework (macOS 26+) gives direct Swift access to the on-device ~3B-parameter model powering Apple Intelligence — no network calls, entirely local. SystemLanguageModel.availability reports three distinct unavailable reasons (deviceNotEligible, appleIntelligenceNotEnabled, modelNotReady), which map directly to honest per-case messaging rather than one vague "unavailable" state. SmartReplyManager wraps LanguageModelSession + @generable guided generation to draft up to 3 short reply options from a notification's sender/body. Every touchpoint is behind @available(macOS 26.0, *) or #if canImport(FoundationModels) — this project's deployment target is macOS 14, so an unguarded reference wouldn't just lose the feature, it would risk the app failing to *launch* below macOS 26. Verified rather than assumed: built the actual binary and inspected the debug dylib's load commands directly. FoundationModels shows `weak` — Swift's availability annotations alone were enough to weak-link it correctly, no manual framework entry needed in the project file. Off by default (new Settings toggle, Notifications tab) even though it's on-device — suggestions appear as tappable chips above the reply box that fill the draft for review, never auto-send, since an AI-drafted reply going out under someone's name deserves a glance first.
The key-window fix from the previous round targeted BoringNotchWindow,
but that class is never instantiated anywhere — grepped for it, only
hits are the class definition and doc comments. The window actually
created for the notch (createBoringNotchWindow) is
BoringNotchSkyLightWindow, a separate NSPanel subclass with its own,
independent hardcoded `canBecomeKey { false }`.
WindowAccessor's `as? BoringNotchWindow` cast against the real,
running BoringNotchSkyLightWindow instance silently returned nil every
time — sibling classes, not a subclass relationship — so hostWindow
was always nil and the entire fix was a no-op. Explains the exact
symptom: mouse clicks worked (chips, which don't need a key window)
while typing didn't (needs one) and send stayed disabled (correctly —
replyText never had anything in it to send).
Moved wantsKeyForTextInput to BoringNotchSkyLightWindow, the class
that's actually live, and retargeted the cast.
Tapping the reply field closed the notch. Clicking it changes window key status, which rebuilds tracking areas and fires a spurious hover-exit — and hover-exit is a close path. Rather than trying to filter a bogus hover event, hold the notch open for the whole compose session via SharingStateManager, which every close path already honours. Begin/end are balanced through a local flag since those sessions are refcounted, and a leaked one would pin the notch open permanently. Also removed the stack-depth capsule. It was drawn behind the content as a full-width shape, but the closed pill's middle is a black rectangle masking the physical notch cutout — so the capsule was bisected and rendered as two disembodied lines flanking the notch, which is the garbled UI in the report, not a transition artifact. The geometry can't support that cue; swiping remains the way to reach the stack.
Tapping send did nothing while Enter (onSubmit) worked, and the suggestion chips — already real Buttons — worked too. That split pointed at the gesture, not at send(). The send button was a bare shape with .onTapGesture, which needs a clean mouse-down/up pair in a window whose key status isn't changing. Clicking it blurs the text field, which flips key status mid-click and swallowed the tap. Converted it to a real Button, which tracks the press properly across that change, and which is also what the working chips use. Second half of the same race: blurring the field tore down the compose hold and key status on mouse-down, which could close the notch out from under the click before mouse-up landed. That teardown is now deferred ~350ms and cancelled if focus returns, with onDisappear cancelling it outright — an orphaned task would leak a refcounted preventNotchClose hold and pin the notch open permanently.
The on-device model sometimes returns the same suggestion twice ("Got
it!"), which SwiftUI flagged as a duplicate id under ForEach(id: \.self)
— undefined rendering.
Fixed at both levels: SmartReplyManager dedupes case-insensitively
(keeping first-seen order, trimming blanks), since a repeated chip is
useless to show regardless; and the view keys by position instead of
by string value, so the UI doesn't depend on model output being
distinct.
My deferred-teardown fix broke typing. Focus is far noisier than "user is done": the suggestion chips arriving restructure the view above the text field and drop focus, and clicking Send blurs on mouse-down. Each of those scheduled a teardown that resigned the window's key status ~350ms later — mid-typing. Grant key status on first focus and release it only in onDisappear, which is the one unambiguous done signal. Same for the compose hold. That removes the timing window entirely rather than tuning the delay, and drops the cancellable-task bookkeeping it needed. Also apply a pending key grant when WindowAccessor resolves the window: it reports asynchronously, so onAppear's auto-focus could run while hostWindow was still nil and silently no-op.
While the notch is open (or hovered), the notification now stays indefinitely — it goes when the notch closes or a newer notification replaces it. This drops the 30s maxLifetime cap I'd added earlier to stop an abandoned open notch pinning a stale message. That cap is redundant: closing the notch already clears the notification, so the notch closing is what bounds the hold, and a stale one can't survive to be seen later regardless of how long it was held open. The 8s countdown still applies to the closed, unhovered pill — otherwise a notification would occupy that slot forever and music would never come back.
Replying types into the notification's AX reply field, so it only
works while that element exists. Measured what actually happens rather
than assuming:
- live banner on screen ....... element valid, "Reply" action present
- banner faded, NC closed ..... notificationcenterui has ZERO windows
- banner faded, NC panel open .. items reachable, but actions are only
[AXPress, Show Details, Close] — the
reply action does not survive the banner
I first tried retaining the AXUIElement past the banner on the theory
that the notification lives on in Notification Center. It doesn't help:
reading AXRole from a retained reference returns nil and
CopyActionNames returns empty — the element is destroyed, not detached.
Reverted, and recorded the measurement in the code so it isn't
retried.
So a reply typed after the banner fades genuinely cannot be delivered;
there is no API to send on an app's behalf. The hand-off (draft to
clipboard, open the app) stays as the honest fallback.
Sounds: Tink on a real send, Pop on hand-off. Deliberately different —
a "sent" sound when nothing was sent is a lie the user only discovers
when the reply never arrives. The orange clipboard glyph carries the
meaning; the sound is just click feedback.
Replying through the notification's AX field only works while the
banner is on screen — measured: once it fades the element is destroyed
(AXRole nil, no actions), Notification Center entries expose only
AXPress/Show Details/Close, and with NC closed the process has no
windows at all. So a reply typed after ~5s could never be delivered.
Messages is the one supported app with a real scripting dictionary
(`send <text> to <participant>`), so iMessage replies now go out
properly at any time, independent of the notification. Order is: AX
reply (while the banner lives) -> Messages scripting -> clipboard
hand-off.
Two things found by testing rather than assuming, both of which would
have shipped broken:
- `name of chat` returns `missing value` for every chat in a real
Messages library, so the obvious chat-name match can never succeed.
Participants do carry the display name the notification shows, so
the lookup uses those. Dry-ran the exact matching logic against a
real contact before wiring it up.
- Message text goes into an AppleScript string literal, so quotes and
backslashes are escaped — an apostrophe or quote in a reply would
otherwise break the script.
Also added NSAppleEventsUsageDescription to the helper's Info.plist:
the helper is the process sending the Apple event, and without a usage
string there macOS kills it instead of showing the Automation prompt.
Checked the suggested Atoll repo — it's a fork of boring.notch with no
notification mirroring at all, so nothing to borrow for capture. It did
prompt simplifying this to NSAppleScript.executeAndReturnError instead
of hand-built subroutine event descriptors.
Measured that an untouched banner dies in ~1.25s, destroying the AX
element that replying depends on — which is why a reply typed in the
notch could never be delivered. Two further measurements changed what's
possible:
- Performing the details toggle resets the dismissal timer. Re-doing
it on an interval held a banner alive for a full 30s test with its
reply field intact.
- The banner window's AXPosition is writable: set to (-5000,-5000) it
actually moves and stays there.
So the watcher now holds a banner alive for as long as the notch is
showing that notification, and for apps set to "hide system banner"
moves it off-screen first. Hiding and replying are no longer mutually
exclusive: previously suppression closed the banner outright, which hid
it but destroyed the reply field with it.
Held banners are released when the notch stops showing the
notification, so the keep-alive can't pin one indefinitely. Leaving a
window moved is recoverable regardless: with no banners showing,
notificationcenterui has zero windows — the window is per-session and
destroyed after — so a fresh one always spawns at its normal position.
Holding a banner alive works by re-performing its details toggle, which leaves it expanded — showing the system banner's own reply field, on top of everything, taking focus. Two live text fields competing for the same keystrokes is worse than no keep-alive at all. So the off-screen move is no longer conditional on the per-app "hide system banner" setting: every held banner is parked at (-5000,-5000) for the duration, leaving the notch as the only visible surface.
Every held banner is parked off-screen regardless, so the per-app toggle changed nothing. A switch that does nothing is worse than no switch — hiding is inherent to how replying works now, not a preference. Drops the toggle and its notificationSuppressedApps key; the app rows are just the on/off switch again.
Read Atoll's implementation for reference (its enableMinimalisticUI swaps openNotchSize for a smaller one and renders a dedicated player). Took the shape of the idea, not the code — its player view is ~1500 lines and assumes managers this project doesn't have. Built on the pieces the full layout already uses — MusicSliderView, HoverButton, MarqueeText, and the musicControlSlots preference — so seeking and the configured transport buttons behave identically in both layouts rather than drifting apart. Opened notch shrinks to 150pt tall, content capped at 380pt wide, and the tab bar is dropped: it switches between tabs the compact layout doesn't have, and it spans the full notch width, which would defeat the narrowing. Off by default so existing users keep their current layout. Three things the compiler caught that were assumptions on my part: this project has no useMusicVisualizer preference (removed), NotchHomeView's `padded` helper is fileprivate so the slot padding is done locally rather than widening its access, and AudioSpectrumView takes a plain Bool plus its own tintColor — the gradient-and-mask wrapper I'd written around it would have fought the colour it already applies.
The first pass was my own arrangement and looked it. This follows
Atoll's MinimalisticMusicPlayerView proportions directly — both
projects are GPL-3.0 and Atoll is itself a boring.notch fork, so this
is license-clean with attribution (credited in the file header).
Ported to MusicSliderView, additively:
- TimeLabelLayout .inline, putting the times either side of the track
instead of stacked beneath it
- TrailingLabel .remaining, which counts down ("-2:56")
- configurable resting/dragging track heights, replacing hard-coded
5/9 in CustomSlider
Every new parameter defaults to the previous behaviour, so the standard
layout renders exactly as before.
Compact layout now: 50pt art, 12/10pt title and artist, a 42pt
visualizer block sized to the trailing time label so its bars centre
over it, inline progress row, 10pt-spaced transport. Panel is 420x180,
matching the size these proportions were designed against.
Three things were making this diverge from Atoll:
- Only three buttons rendered. The row was driven by the
musicControlSlots preference, whose default is
[.none, .previous, .playPause, .next, .none] — so shuffle and media
output never appeared. Compact mode now uses a fixed five; that
preference exists to configure the full layout.
- No media-output control existed at all. Added one showing the
current route (laptop / headphones / AirPods / speaker) via a new
AudioOutputRouteResolver.outputRouteSymbol(). Atoll's opens a
popover that switches device inline, which needs its
AudioRouteManager — this project only classifies the current route,
so this opens Sound settings instead rather than faking a picker
that can't switch anything.
- The Spotify badge spilled out of the artwork. AlbumArtView's badge
is a fixed 30pt at +10/+10, sized for the 120pt art in the full
layout; on 50pt art it overflows the corner. Compact mode draws its
own art with an 18pt badge at +5/+5.
Reverts the music inline-peek layout surgery from abb36f1 wholesale — both the chin widening and the label split are back to their proven geometry. I changed proven layout math blind; restoring rather than iterating on unverifiable pixel work. Real queue-badge bug (reported): with the sneak-peek toggle on, EVERY arrival took the passive path, so a notification already being showed (panel open) never queued — the +N badge died. Peek now only takes the passive path when NOTHING is displayed (activeNotification == nil); otherwise arrivals use the normal show/queue path exactly as before. Keep-from-abb36f1: mirrored promotion (tap/open promotes the peeked notification into the expanded panel). Verified: Debug build succeeds
Root cause of the reported junk: the InlineOSD render condition matched .every. non-music sneak type, including the new .notification peek — so a notification peek ALSO mounted inline-OSD (empty icon + a 0-value bar + 0% label, because peek value is 0), displacing the music live activity row (album art vanished) and stretching the pill. With inlineOSD disabled the music row stayed, but for inline users all three reported symptoms had this single origin: phantom 0% volume slider, expanded black area, missing album art. Notification peeks now never enter the OSD lane; the music album/stats row keeps rendering uninterrupted while the peek scrolls below. Verified: Debug build succeeds; remaining symptom chain closed by code reading (InlineOSD is the only consumer of opaque OSD types)
Per user request, the peek must be able to show AT THE SAME TIME as: - the closed music live activity (album art row), - a music sneak peek (standard or inline song change), - an OSD peek while the user is adjusting volume/brightness. Implementation: .notification no longer lives in the type-exclusive sneakPeekStates slot at all. BoringViewCoordinator gains notificationPeekStates + its own per-screen hide tasks; toggleSneakPeek routes .notification there transparently. ContentView renders the notification pill as an additional bottom row below whatever peek/OSD/ music row is active, gated on closed notch only. Behavior now: while cranking volume (or with the music pill showing), an incoming notification scrolls its marquee right below without replacing anything; each lane auto-hides on its own timer. Verified: Debug build succeeds
The pill was still measured by the clipped notch shape (it lived in the same VStack), so any tall state — greeting face, open notch, inline marquee — ballooned the whole black panel when a notification arrived. The peek is now an overlay pinned to the chin height on the outer layout container: it appears float-free just below the closed notch, scrolls, fades, and can coexist with literally any state the notch is in. Shape math is untouched by it. Verified: Debug build succeeds
User-reported: notifications arrive slowly, only visible after hovering; sneak peeks unreliable; OTP copy flow broken. - Latency: revert Phase-1's adaptive watcher cadence (idle tick 2.0s) back to constant 0.35s — the mirrored peek must feel immediate; the idle battery saving wasn't worth a visibly-late mirror for an opt-in feature. - OTP: detected-code notifications bypass the passive peek again and use the normal show()/queue path, restoring the code + copy affordance. - Peek window 5s -> 8s so the pill isn't half-gone by the time capture fires on a slow cycle. Verified: Debug build succeeds
The layered peek/mirrored/promotion machinery had broken both surfaces: arrivals bypassed show() entirely, so the compact dot+icon never entered the chin, and the floating pill's lifecycle was entangled with the promotion hack. One flow now, three surfaces, no machinery: 1. The floating marquee pill scrolls under the notch (8s). 2. The compact dot/app-icon sits in the chin for the same 8s window (existing live-activity slot) via show(holdingBanner: false). 3. Tap either / hover open -> NotificationExpandedView of the ACTIVE notification (register step already done at arrival), pull-only focus. Deleted: mirrored state, mirroredExpiryTask, promoteMirroredIfPresent, peek-lane promotion hook in ContentView. OTP keeps its interactive path and the queue badge path is untouched. Verified: Debug build succeeds
The verification-code pill sizes itself at a flat 420, which leaves only ~85pt of wing right of a standard hardware notch — the code + copy button (~110pt) clipped under the bezel. The width is now notch-aware: chinWidth = max(420, notch + 224), i.e. ~112pt of code/copy affordance on each side of physical cutout. Verified: Debug build succeeds
A user debug run was jetsam-killed after ~4 min. The helper's AX scan is the allocation-heavy path; two mitigations: - idle cadence 0.5s (halves the walk rate when nothing is on screen vs the flat 0.35s restored earlier) — still perceptibly instant - every scan now runs inside an explicit autoreleasepool so the AX- bridged object copies in a walk drain deterministically instead of whatever the dispatch-main scheduler happens to do If jetsam recurs at this cadence, repro with the Music/Lottie paths disabled to bisect by feature lane. Verified: Debug build succeeds
iMessage replies now try the native banner reply field FIRST (near-free) and only then the heavy AppleScript path, with independent budgets: - Stage 1 (AX banner): 2s race; a stalled banner returns .failed without cross-delivering through another channel (no duplicate sends) - Stage 2 (Messages AppleScript): runs ONLY after a definite failure and gets its own 4s budget (cold Messages.app launch no longer trips the send) - iMessage-stage failure: surfaced as an honest error in the reply UI; the draft is kept and the notch stays open (fixes the silent 'sending…' hang when Apple Events stall) ReplyOutcome gains .failed; NotificationExpandedView shows the error inline; debug window updated exhaustively. Verified: Debug build succeeds
The pill was drawn with no background — readable only while inside the black notch shape, invisible floating over the desktop wallpaper (which reported as 'peek isn't working'). It now carries a dark capsule backdrop like a system banner: readable over wallpaper and over the notch shape alike. Verified: Debug build succeeds
…tests The floating-overlay placement rendered off-surface — user-visible as 'peek isn't working' while the data path was fine. Bus->lane chain now test-covered (7 tests), which proved placement, not logic, was broken. The pill is back inside the closed-state VStack below every other row — the exact spot the user confirmed it visually before, now with the dark backdrop, and with all other fixes retained (queue badge, OSD lane exclusion, OTP width, staged reply timeouts, captured latency). Verified: Debug build succeeds
The screenshot showed the pill's capsule stretched to a huge dark box with the title at bottom and body floating top-center: the GeometryReader stretching to VStack-offered height inflated the whole HStack and its capsule backdrop. Confirmed with the bus->lane chain test still green. MarqueeText now gets a fixed 240pt frame width; the pill renders as the system-like band it was designed as — compact dot+icon in chin above, pill below, nothing else stretched. Verified: Debug build succeeds
Per user direction for long messages: name row on top with the message under it, single line, truncated (no marquee — strippped like a stock macOS banner), and the pill centered horizontally under the physical notch rather than left-flanked like the music sneak peek. Layout: icon + title row -> truncated message line, rounded-rect backdrop, 380pt cap. ContentView centers the lane row. Verified: Debug build succeeds
The full-width expansion came from Spacer-wrapped centering inside the shape's VStack — the spacers demanded unbounded width and pulled the whole panel. With the pill fully self-sized now (fixed anatomy, greedy GeometryReader gone), it floats back outside the clipped shape at a fixed offset under the closed notch: zero influence on the notch's horizontal or vertical metrics, caught by nothing outside its lane. Verified: Debug build succeeds
The overlay-decoration variant still wasn't painting on builds even with fixed anatomy (the overlay path was the only unverified variable after lane tests proved the data path). A plain second child of the top ZStack renders in window space by construction, keeping the shape untouched. If the pill STILL doesn't appear with this build, the remaining cause is the Settings toggle state or the lane not refilling on your machine — next step then is a one-shot console trace, not more layout surgery. Verified: Debug build succeeds
Two notification UX changes: - Sender icon resolution: the XPC helper's one-shot running-apps match could leave bundleID nil forever, so every notification surface fell back to a grey bell instead of the app icon. Resolution now falls back to a direct /Applications probe and a bounded name scan (memoized, hits and misses), and SystemNotificationManager re-resolves app-side when the helper's bundleID is missing. Adds BundleIDResolverTests (8 tests). - Remove the notification sneak peek: the floating pill below the notch is gone entirely (view, coordinator peek lane, event payload, defaults key, settings toggle, strings, tests). Notifications surface as the chin pill (app icon + live dot) and the expanded card on manual open. Co-authored-by: TheBoringMajdoor <themajdoor@theboring.name>
|
@theboringhumane can we break this up so its reviewable? Wouldn't mind a stacked PR if needed if there are any dependent components between different changes, just don't accidentally stack the dev -> main release PR. |
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 <themajdoor@theboring.name>
…s/xcstrings reordering) Co-authored-by: TheBoringMajdoor <themajdoor@theboring.name>
Sure |
|
Superseded by the 4-PR stack for reviewability:
The 69-commit history is preserved untouched on |
Summary
67 commits rebased onto current
dev. Three bodies of work:Notification live activity
Compact mode
Audit remediation (phases 0-4)
Test plan
xcodebuild -scheme boringNotch build— cleanNotchUIEventTests,PlaybackStateTests,ExpandedViewPixelTests(expanded card paints content),BundleIDResolverTests(8)