* 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]>
40 lines
1.5 KiB
Swift
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 }
|
|
})
|
|
}
|
|
}
|