Skip to content

Notification live activity, compact mode & audit remediation (phases 0-4) - #1496

Closed
theboringhumane wants to merge 69 commits into
devfrom
audit-remediation
Closed

Notification live activity, compact mode & audit remediation (phases 0-4)#1496
theboringhumane wants to merge 69 commits into
devfrom
audit-remediation

Conversation

@theboringhumane

Copy link
Copy Markdown
Member

Summary

67 commits rebased onto current dev. Three bodies of work:

Notification live activity

  • XPC helper captures system banners via Accessibility; chin pill on the closed notch shows the sender app icon + live dot, with a dedicated OTP code pill
  • Expanded card on manual open: working reply field (held banners, keystroke fixes, send/hand-off sounds), iMessage replies via Messages scripting, WhatsApp reply drafting in-conversation, Apple Intelligence smart-reply suggestions, per-stage delivery timeouts
  • Queueing for notifications arriving mid-reply, with a browsable stack badge
  • Reliability fixes: WhatsApp drops, XPC callback/connection liveness, watcher startup scanning, stale-notification hijacking, expiry-while-opening, held-banner leaks, AXI watcher memory pressure
  • Sender icon resolution hardened — on-disk fallbacks in the helper + app-side re-resolution, so surfaces show the real app icon instead of a grey bell
  • Sneak peek removed entirely — notifications surface only as the chin pill and the expanded card on manual open

Compact mode

  • Player-only opened notch matching Atoll's layout and dimensions (336pt), full transport row, media output device switching, shuffle/output in both layouts, cached track state instead of "Nothing Playing"

Audit remediation (phases 0-4)

  • Crash fixes, dead-code purge, OSS compliance; background polling killed, OSD hot paths fixed; coordinator cycle broken, architecture decoupled; naming/dedup/conventions/structured logging; community files, tests, lint, security docs; app-target DEVELOPMENT_TEAM restored

Test plan

  • xcodebuild -scheme boringNotch build — clean
  • 13 targeted tests pass: NotchUIEventTests, PlaybackStateTests, ExpandedViewPixelTests (expanded card paints content), BundleIDResolverTests (8)
  • Manual: receive a banner → chin pill shows app icon + live dot → open notch → expanded card → send a reply

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.
theboringhumane and others added 17 commits August 28, 2026 15:37
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>
Comment thread boringNotch/managers/SystemNotificationManager.swift Dismissed
@Alexander5015

Copy link
Copy Markdown
Member

@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.

theboringhumane and others added 2 commits August 29, 2026 15:19
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>
@theboringhumane

Copy link
Copy Markdown
Member Author

@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.

Sure

@theboringhumane

Copy link
Copy Markdown
Member Author

Superseded by the 4-PR stack for reviewability:

  1. Notification live activity on the notch [1/4] #1503 — Notification live activity on the notch
  2. Compact mode + notification queueing hardening [2/4] #1504 — Compact mode + notification queueing hardening
  3. Audit remediation, phases 0-4 [3/4] #1505 — Audit remediation, phases 0-4
  4. Notification surface consolidation + icon & keyboard-focus fixes [4/4] #1506 — Notification surface consolidation + icon & keyboard-focus fixes

The 69-commit history is preserved untouched on audit-remediation as the archive. The stack's final tree is identical to this branch's tip except for the removal of the temporary 6-line notch-open trace log, and the self-canceling notification sneak-peek saga (14 commits, later fully reverted) was erased from the presented history.

@theboringhumane
theboringhumane deleted the audit-remediation branch August 29, 2026 13:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants