Files
cmux/Sources/ScreenLockObserver.swift
Lawrence ChenandClaude Fable 5 c3505b2061 Forward notifications to iPhone only when away from the Mac (#5912)
* Add Mac-presence gate behavior tests for phone forwarding (red)

Tests specify the desired gate: when phone forwarding is on and the mode
is "only when away", a notification must NOT be forwarded while the Mac
is actively in use (console unlocked, displays awake, no screensaver,
hardware input within the last 120 s). Lock/display-sleep/screensaver
flip to away instantly; synthetic-only input does not count as
presence; "always" mode forwards regardless.

This commit adds MacPresenceMonitor (injected clock + signal providers)
and the PhoneForwardingMode setting, but the gate itself
(PhonePushClient.shouldForward) still ignores presence and always
forwards, preserving today's behavior - so the suppression tests fail.
The fix lands in the next commit, proving the tests catch the gap.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Gate phone notification forwarding on Mac presence (green)

Implement the presence gate: with forwarding on and the new mode
"Only when away from this Mac" (the default), a notification is
forwarded to the iPhone only when the Mac is NOT actively in use.
Active means: console session unlocked, displays awake, no screensaver,
and hardware keyboard/mouse input within the last 120 s (HID system
state, so synthetic agent input never counts). Lock, display sleep, or
screensaver flip to away instantly. "Always" preserves the old
behavior. Evaluated per notification inside PhonePushClient.forward(),
before the throttle, so the phone never receives a suppressed push and
Mac-side unread accounting is unchanged.

Adds the mode picker to the Notifications page (en+ja localized) under
the existing forward toggle. The master opt-in toggle is unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Address review: bound presence sampling, Swift Testing

Skip presence sampling entirely in Always mode (the gate is constant
true there), and coalesce live presence evaluations through
MacPresenceDecisionCache so WindowServer/HID sampling runs at most once
per second under notification bursts, restoring the burst protection
the send throttle provides. Suppressed-notification semantics are
unchanged: the gate still runs before the throttle.

Convert PhonePushPresenceGateTests to Swift Testing per repo policy for
new non-UI tests, and add cache hit/expiry coverage through the
injected clock.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Never reuse a cached away presence decision

Cache only active (suppressing) decisions: away answers re-sample on
every notification so the user-return transition gates the very next
push. A stale active answer can only suppress within 1 s of leaving,
which matches the no-retroactive-forward semantics; a stale away answer
would forward terminal content while the user is back at the Mac.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Check the send throttle before sampling presence

The read-only throttle lookup now runs before live presence sampling,
so notification storms in away mode do at most one WindowServer/HID
probe per workspace+surface per second. The slot is consumed only after
the presence gate passes, preserving the suppressed-notifications-do-
not-block-forwards semantics; active-decision caching keeps suppressed
bursts cheap.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Consult both lock-state sources for the presence gate

macOS has no public synchronous screen-lock query. The dictionary key
(CGSSessionScreenIsLocked) and the com.apple.screenIsLocked distributed
notifications are both de-facto contracts, so the live provider now ORs
them: ScreenLockObserver tracks the notification pair and the session
dictionary check stays. The combined decision is a pure function
(MacPresenceMonitor.consoleSessionActiveAndUnlocked) with behavior
tests, including the absent-dictionary-key-while-locked case. If both
sources miss a lock, the 120 s hardware-idle rule still bounds the
suppression window.

Co-Authored-By: Claude Fable 5 <[email protected]>

* MainActor-isolate ScreenLockObserver

Replace the NSLock-guarded singleton with a @MainActor class observing
the lock notifications on the main queue; the live monitor's evaluation
context (PhonePushClient, main actor) is asserted explicitly via
assumeIsolated. Document why suppressed bursts re-entering forward()
stay bounded (cache TTL bounds live sampling globally; suppressed
passes are O(1)).

Co-Authored-By: Claude Fable 5 <[email protected]>

* Drop the presence cache; sample fresh per notification

Any coalescing of presence decisions creates a stale window on one side
of an away/active transition (review found both directions across
rounds). The feature spec is per-notification evaluation at delivery
time, and a fresh sample is a handful of WindowServer/HID reads, orders
of magnitude cheaper than the network send the throttle bounds, with
upstream notification cooldowns already limiting call rate. Delete
MacPresenceDecisionCache, evaluate directly, document the cost
rationale, and pin transition freshness with a test.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Settle the sampling/staleness trade: active-only decision cache

Review rounds demanded three mutually exclusive properties at sub-
second granularity: no per-notification sampling under suppressed
bursts, no stale away decisions, and no stale active decisions. Any
bounded design is up to 1 s stale on one transition side. Settle on the
asymmetric cache, severity-ranked by the review itself (hot-path cost
P1 > stale-away P2 > stale-active P3): ACTIVE decisions reused for 1 s
(suppressed bursts sample at most once per TTL), AWAY decisions never
reused (forwards always fresh; user-return gates the next push). The
residual: a notification within 1 s of locking/leaving can be
suppressed once, accepted and documented as the invariant in
MacPresenceDecisionCache.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Split new major types into their own files (Aziz policy)

PhoneForwardingMode, MacPresenceDecisionCache, and ScreenLockObserver
each move to a dedicated file; MacPresenceMonitor.swift keeps only the
monitor and its live-provider extension. No behavior change.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-06-11 16:31:40 -07:00

40 lines
1.5 KiB
Swift

import AppKit
import Foundation
/// There is no public synchronous "is the screen locked" query on macOS. Two
/// de-facto sources exist: the `CGSSessionScreenIsLocked` key in
/// `CGSessionCopyCurrentDictionary()` and the `com.apple.screenIsLocked` /
/// `com.apple.screenIsUnlocked` distributed notifications. Either can be
/// absent in a given macOS version or session context, so the live provider
/// ORs both (`MacPresenceMonitor.consoleSessionActiveAndUnlocked`). If both
/// miss a lock, the failure mode is bounded: the 120 s hardware-idle rule
/// flips the Mac to away on its own shortly after the user leaves.
@MainActor
final class ScreenLockObserver {
static let shared = ScreenLockObserver()
private(set) var isLockedObserved = false
/// Retained for the life of the process; the observer is a singleton
/// whose lifetime is the app's.
private var observerTokens: [any NSObjectProtocol] = []
private init() {
let center = DistributedNotificationCenter.default()
observerTokens.append(center.addObserver(
forName: Notification.Name("com.apple.screenIsLocked"),
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.isLockedObserved = true }
})
observerTokens.append(center.addObserver(
forName: Notification.Name("com.apple.screenIsUnlocked"),
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.isLockedObserved = false }
})
}
}