Skip to content

Notification live activity on the notch [1/4] - #1503

Open
theboringhumane wants to merge 27 commits into
devfrom
stack/01-notification-live-activity
Open

Notification live activity on the notch [1/4]#1503
theboringhumane wants to merge 27 commits into
devfrom
stack/01-notification-live-activity

Conversation

@theboringhumane

Copy link
Copy Markdown
Member

Stack 1 of 4. Base for #2. Split out of #1496 for reviewability.

Summary

The notification system foundation: an XPC helper captures system notification banners via Accessibility and the notch mirrors them.

  • Closed notch: chin pill with sender app icon + live dot; dedicated OTP code pill with tap-to-copy
  • Open notch: expanded card — reply field (keystroke/focus fixes, dismiss pause while typing, keep-open while composing), Apple Intelligence smart-reply suggestions, iMessage replies via Messages scripting, send/hand-off sounds
  • Queueing: notifications arriving mid-reply are queued; dismiss/expiry lifecycle managed
  • Reliability: WhatsApp drop fix, XPC callback/connection liveness, watcher startup scanning, stale-notification hijacking fix, banner hold+off-screen parking so replies keep working

Test plan

  • xcodebuild -scheme boringNotch build at this tip — clean
  • Manual: banner → chin pill → open → reply

@Alexander5015 Alexander5015 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only had time to review a few files, here are my current comments.


guard !heldOffScreen.contains(token) else { return }
heldOffScreen.insert(token)
guard let windowValue = banner[kAXWindowAttribute] else { return }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct me if I’m wrong, but this per-banner hold appears to move Notification Center’s entire shared window off-screen, not the individual banner. scan() can find multiple banners under one window, but hold(token:) follows the banner’s kAXWindowAttribute and moves that window to (-5000, -5000). That means unrelated or filtered notifications in the same window could disappear too, and the per-token state does not track the window’s original position or restore it when watching stops. I do not think window manipulation is safe here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right on both counts, and we've reworked it (bc4906d, landed on stack tip #1506 since the branches build on each other): (1) the park is now gated on the notch being open — the same gate as the keep-alive expand — so arrivals with a closed notch are held in state only and never touch the window at all (they expire naturally, as before the hold feature); parking happens via holdActive() when the user actually opens the notch. (2) The window's original position is now recorded on first park, keyed by CFHash(window), and restored when the last held token referencing that window releases, expires, or watching stops — and if the origin can't be read, we skip parking entirely rather than make an unrestorable move.

}

private func show(_ notification: SystemNotification) {
withAnimation(.smooth) { activeNotification = notification }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this queues notifications the way the PR description implies. Every arrival immediately overwrites activeNotification, while the draft and composing/sending state live in view-local @State and .id(notification.id) rebuilds that view. If B arrives while A is being written, I think A’s draft is wiped out, and because dismissActive only releases the current notification, A’s AX target can be orphaned.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair at this snapshot — this is exactly what the later stack commits add. In #1504, 877dbf4 ("Queue notifications that arrive mid-reply instead of replacing") gates replacement behind isComposingReply and enqueues B instead of overwriting A, with a browsable stack badge; and drafts are held manager-side keyed by notification id (replyDrafts in SystemNotificationManager), not view-local @State, so the .id rebuild no longer wipes them. On the orphaned AX target: show()'s releasingPrevious path explicitly releases the superseded notification's hold before replacing it, and dd0d381 ("Fix held banner leak that could block the real Notification Center", also #1504) closes the remaining leak. Happy to pull the queueing commit forward into this PR if you'd rather review it here.

// carry the display name the notification shows ("Harsh Vardhan
// Goswami"), which is the only thing a notification gives us.
//
// The first match wins. Duplicate participant entries for the same

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback won't reply to the originating Messages conversation. It ignores chats and sends to the first global participant whose display name matches the notification title. It also will not know the account, service type, and handle, so it does not preserve iMessage/SMS/RCS routing. This can choose the wrong person, thread, handle, or transport and still return “ok”.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and fixed (d1ae121, on stack tip #1506): the script now resolves chats first — it matches a chat's participants by display name (chat name itself is missing value for 1:1 threads, which is what sent us to participants originally), prefers a single-participant chat, and sends INTO the chat object, so account, service (iMessage/SMS/RCS), handle, and thread are all preserved. The bare-participant path is now a guarded fallback: it counts matches library-wide and only sends when exactly one participant matches; zero or 2+ (the duplicate-person-across-handles case) returns false and the caller falls back to the clipboard hand-off. Nothing sends on ambiguity anymore.

/// 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is too brittle to treat as a confirmed send operation. It drives a foreign, asynchronous AX hierarchy, selects Reply/Details/Send through English substrings observed in one environment, chooses the first text-field descendant, and then sleeps for exactly 400 ms on the helper’s main queue. That blocks polling and hold refresh while still failing if the field appears later. AXUIElementPerformAction == .success only establishes that the AX request succeeded, not that the message was delivered, but the caller reports it as sent and plays a confirmation sound.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on all three points, fixed (bc4906d, on stack tip #1506): the reply now runs on a dedicated serial queue — the helper's main queue never blocks, so polling and hold refresh keep running; the fixed 400ms sleep is replaced by a bounded wait that polls for the reply field in 50ms slices up to 1.2s (false on timeout); and after the send action, a bounded verification poll (~1s) only reports success once the field clears or is destroyed with the sent banner's collapse — send-action failure or a non-clearing field reports false. To your delivery point: AX can verify the banner accepted the reply, not network delivery, and the code now says exactly that — the documented limitation stands, but we no longer report "sent" on AX success alone. Field selection also now prefers the AX-focused field over first-in-document-order.

theboringhumane added a commit that referenced this pull request Aug 30, 2026
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 <themajdoor@theboring.name>
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.
theboringhumane added a commit that referenced this pull request Aug 30, 2026
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 <themajdoor@theboring.name>
@theboringhumane
theboringhumane force-pushed the stack/01-notification-live-activity branch from c30a3db to 51b1874 Compare August 30, 2026 06:09
@theboringhumane

Copy link
Copy Markdown
Member Author

Heads up for review continuity: the stack was just rebased onto current dev (which merged #1477 — the conflict this PR showed on Localizable.xcstrings is resolved). Same 27 commits, resolutions preserved from your own rebase of this branch (c30a3db) — the only delta from it is the #1477 merge-in plus your 6-line pbxproj adjustment. New SHAs for the review-fix commits referenced in my replies above: park-gating + reply hardening bc4906daffdd12, MessagesSender chat-first routing d1ae121b948de5 (both on #1506). One resolution worth your eye in #1505: MediaEnvironment.swift was deleted — #1477 removed the deprecation-probe API it wrapped, and the old "re-show onboarding on NowPlaying-deprecated" branch is gone; the substrate's passive nowPlayingNotice fallback replaces it. Flagging in case you want the onboarding re-prompt re-implemented on top of NowPlayingAvailability.

theboringhumane added a commit that referenced this pull request Aug 30, 2026
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 <themajdoor@theboring.name>
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.

2 participants