Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions Candela/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1290,6 +1290,64 @@ final class AppModel {
}
}

@ObservationIgnored private let restoreLog = Logger(
subsystem: "com.rydersel.Candela", category: "restore"
)

/// Puts back the brightness of a display a previous process left dimmed.
/// Externals only (the built-in has no DDC register to strand) and brightness
/// only: the dim never touched contrast or volume.
func recoverInterruptedDims() {
let evaluated = displays.count
var reasserted = 0
// Twins share a marker, but each has its own wire. Snapshot before any
// clearing so discovery order cannot decide which one gets recovered.
let markedKeys = Set(displays.map(\.display.persistenceKey)).filter {
DisplayPrefs(persistenceKey: $0, safeMode: safeMode).temporaryDimEngaged
}
var keysToClear: Set<String> = []
for state in displays {
let key = state.display.persistenceKey
switch InterruptedDimRecovery.action(
markerSurvived: markedKeys.contains(key),
dimIsLive: state.controller.temporaryDimFactor != nil,
hasStoredValue: state.controller.hasStoredValue,
isSafeMode: safeMode
) {
case .leave:
continue
case .clearOnly:
keysToClear.insert(key)
case .reassert:
// Without the memo reset the re-assert is duplicate-skipped and never
// reaches the wire.
state.controller.resetWriteMemo()
state.controller.reassertHardware()
keysToClear.insert(key)
reasserted += 1
// The tag, never the persistence key: a key without an EDID UUID embeds
// the panel's serial number.
restoreLog.info("""
interrupted dim recovered on display \
\(DisplayLogging.tag(for: key), privacy: .public)
""")
}
}
// A recovered twin cannot consume the signal a still-live dim needs if
// this process crashes next. The whole pass is synchronous on the main actor.
let liveDimKeys = Set(displays.filter { $0.controller.temporaryDimFactor != nil }
.map(\.display.persistenceKey))
for key in keysToClear.subtracting(liveDimKeys) {
DisplayPrefs(persistenceKey: key, safeMode: safeMode).temporaryDimEngaged = false
}
// `.info`, not `.debug`: macOS does not persist debug records. This line is
// what separates "nothing needed recovering" from "the pass never ran".
restoreLog.info("""
interrupted dim pass: \(evaluated, privacy: .public) evaluated, \
\(reasserted, privacy: .public) reasserted
""")
}

/// One write-restore pass: every duplicate memo reset FIRST, then re-write
/// (brightness DDC leg, contrast, volume, plus the mute companion inside
/// `restoreToHardware`). All three legs restore only ever-touched commands: a
Expand Down
10 changes: 10 additions & 0 deletions Candela/App/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,16 @@ final class StatusItemController: NSObject, NSApplicationDelegate, NSMenuDelegat
refreshTapConfig()
updateStatusItemVisibility()
wireInterferenceHooks()
// Crash-while-dimmed recovery, the wire companion to the gamma reset
// above. Ahead of the restore pass so the two never queue writes for one
// display out of order. Safe mode sends no unattended DDC and must not
// consume the marker: the next normal launch still needs it.
//
// LAUNCH ONLY, never on reconfigure. The topology loop above calls
// `oledCare.displaysReconfigured()` first, which drops the lock dim for at
// least one care tick; a recovery in that window would see no live dim,
// write the undimmed value to a locked screen, and spend the marker.
if !isSafeMode { model.recoverInterruptedDims() }
restoreCoordinator.noteLaunchOrReconfigure()
// Before the first open, for the same reason the display list is warmed
// here: nothing the panel starts can be relied on to run while the menu
Expand Down
49 changes: 39 additions & 10 deletions Candela/OledCare/OledCareCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1283,8 +1283,9 @@ final class OledCareCoordinator: CheckupCareHolding {
switch decision {
case let .dim(factor):
if !state.lockDimEngaged {
// The lock edge: fade in over ~1.2 s rather than stepping, which is the
// only place a ramp is wanted. Everything else here jumps.
// The lock edge: ramp over ~1.2 s rather than stepping, because an OLED
// bands visibly when the register jumps. The only DDC ramp wanted here,
// and no lift ever fades.
state.lockDimRamp = controller.rampTemporaryDim(to: factor)
state.lockDimEngaged = true
} else if controller.temporaryDimFactor == nil {
Expand Down Expand Up @@ -1417,7 +1418,11 @@ final class OledCareCoordinator: CheckupCareHolding {
}
}

guard overlay.apply(alpha: alpha, mask: mask, blackout: blackout, on: id) else {
guard
overlay.apply(
alpha: alpha, mask: mask, blackout: blackout,
mayFadeIn: OverlayFade.fadesInOnEntry(to: dimState), on: id)
else {
// No NSScreen matched: nothing reached the screen, so there is nothing to
// verify and no state to memoise. The next tick retries, and the overlay
// rate-limits its own warning.
Expand Down Expand Up @@ -1469,11 +1474,13 @@ final class OledCareCoordinator: CheckupCareHolding {
adaptiveProtection[key] = protection
}

private enum VerifyOutcome {
enum VerifyOutcome: Equatable {
case agreed
case mismatched
/// A close the server has not finished reporting; neither an attempt nor a
/// mismatch. Bounded by `OledOverlay.closeGrace`.
/// In flight, so neither an attempt nor a mismatch: a close the server has
/// not reported yet (bounded by `OledOverlay.closeGrace`), or a nudge
/// declined while an entry fade toward this same state arrives (bounded by
/// `OledOverlay.fadeDeclineWindow`).
case settling
}

Expand All @@ -1482,13 +1489,35 @@ final class OledCareCoordinator: CheckupCareHolding {
/// is `reassert(on:)` (NEVER a repeat apply, which is a no-op by construction
/// against the overlay's memo) and re-verification waits for the NEXT tick:
/// one nudge per detected mismatch, log, don't loop.
///
/// The nudge declines while an entry fade toward the same state is in flight,
/// so a reconcile cannot snap it.
private func verifyLastRender(of state: PerDisplay, on id: CGDirectDisplayID) -> VerifyOutcome {
let wanted = state.lastAppliedAlpha != nil
switch (wanted, overlay.verifyPresence(on: id)) {
let presence = overlay.verifyPresence(on: id)
var reasserted = false
if wanted, presence == .absent {
// Silent on a decline: an entry fade spans four fast ticks, so logging it
// would put four errors in the log for every dim that arrives normally.
reasserted = overlay.reassert(on: id)
if reasserted {
log.error("OLED care overlay for display \(id, privacy: .public) not on screen after apply; reasserting")
}
}
return Self.verifyOutcome(wanted: wanted, presence: presence, reasserted: reasserted)
}

/// Split out of `verifyLastRender` so the one row that can regress silently
/// is pinnable from the app suite.
static func verifyOutcome(
wanted: Bool, presence: OledOverlay.Presence, reasserted: Bool
) -> VerifyOutcome {
switch (wanted, presence) {
case (true, .absent):
log.error("OLED care overlay for display \(id, privacy: .public) not on screen after apply; reasserting")
overlay.reassert(on: id)
return .mismatched
// A declined nudge is our own entry fade, not a structural mismatch.
// Counting it would burn four of the five attempts on a fade nothing is
// failing at; `.settling` retries later with the budget intact.
return reasserted ? .mismatched : .settling
case (false, .present):
// A removal the server has not honoured: verifyPresence already
// re-closed the strand and logged. Check again next tick.
Expand Down
118 changes: 111 additions & 7 deletions Candela/OledCare/OledOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,27 @@ final class OledOverlay {

private var lastApplied: [CGDirectDisplayID: AppliedState] = [:]

/// An entry fade still running on a display's overlay. Kept out of
/// `AppliedState` on purpose: folding it in would make an unchanged state
/// compare unequal while the fade arrives, putting the 10 Hz loop straight
/// back on the window server.
private struct InFlightFade {
let target: AppliedState
let startedAt: ContinuousClock.Instant
}

private var fadingTo: [CGDirectDisplayID: InFlightFade] = [:]

/// Supersession counter: a superseded fade's completion handler arrives with
/// a stale generation and must not clear a newer fade's entry.
private var fadeGeneration: [CGDirectDisplayID: UInt64] = [:]

/// How long a declined nudge stays declined: twice the fade. Past the bound
/// the entry is dropped and the nudge proceeds, because the completion handler
/// can fail to arrive at all (window torn down mid-animation, display departs
/// while it fades), and a reconcile parked forever fails silently.
static let fadeDeclineWindow: Duration = .seconds(2 * OverlayFade.entrySeconds)

/// Displays already warned about for a missing `NSScreen`. The overlay is
/// re-driven on every state tick, so an unrated warning floods the log for as
/// long as the display stays gone.
Expand All @@ -96,9 +117,12 @@ final class OledOverlay {
/// what keeps the steady-state cadence off the window server.
///
/// `mask` is already in DISPLAY orientation; nil keeps the scalar behaviour.
///
/// `mayFadeIn` only permits a fade; `fadeSeconds` still requires a darkening
/// transition, so a lift stays instant even in a state that fades in.
@discardableResult
func apply(
alpha: Double?, mask: OverlayMask.Oriented? = nil, blackout: Bool,
alpha: Double?, mask: OverlayMask.Oriented? = nil, blackout: Bool, mayFadeIn: Bool,
on displayID: CGDirectDisplayID
) -> Bool {
guard let alpha else {
Expand Down Expand Up @@ -129,35 +153,112 @@ final class OledOverlay {
guard !existed || self.lastApplied[displayID] != state else {
return true
}
// Read before `lastApplied` moves: the gate compares the current alpha with
// the one this state asks for.
let fade = Self.fadeSeconds(
mayFadeIn: mayFadeIn, from: self.lastApplied[displayID]?.alpha, to: state.alpha)
self.lastApplied[displayID] = state
self.write(state, to: window)
self.write(state, to: window, on: displayID, fadingOver: fade)
return true
}

/// How long this transition fades for, or nil to land immediately. A lift
/// never animates whatever state asked for it, so every restore lands in one
/// write. A nil `current` means no overlay on screen and behaves as 0.
///
/// One transition this cannot see: with a mask the caller passes alpha 1.0 and
/// the per-cell opacity lives in the layer's contents, so a masked idle dim
/// escalating to blackout goes 1.0 to 1.0 and lands instantly.
static func fadeSeconds(mayFadeIn: Bool, from current: Double?, to target: Double) -> Double? {
guard mayFadeIn, target > (current ?? 0) else { return nil }
return OverlayFade.entrySeconds
}

/// Re-asserts the overlay's last applied state: the recovery lever for an
/// verification mismatch, where the window server dropped a window we still hold (a
/// space transition, another shielding window, a reconfiguration). `apply`
/// will not do it, since the state it is asked for is the state it has.
///
/// A no-op when the display has no overlay: creating one here would invent a
/// dim level this class does not own.
func reassert(on displayID: CGDirectDisplayID) {
///
/// Returns false only where a fade toward this exact state is in flight and
/// the nudge was declined, so the caller can tell that from a nudge that did
/// not take. A display with no overlay answers true: no fade to wait for, and
/// a decline would park the caller on one that never ends.
///
/// `now` is a parameter so the bound can be tested without waiting it out.
@discardableResult
func reassert(on displayID: CGDirectDisplayID, at now: ContinuousClock.Instant = .now) -> Bool {
guard let window = self.windows[displayID], let state = self.lastApplied[displayID] else {
return
return true
}
if let fade = self.fadingTo[displayID], fade.target == state,
now - fade.startedAt < Self.fadeDeclineWindow {
return false
}
self.write(state, to: window)
// A nudge lands on target without animating, and drops an entry that
// outlived the bound so a missing completion cannot decline forever.
self.write(state, to: window, on: displayID, fadingOver: nil)
return true
}

private func write(_ state: AppliedState, to window: NSPanel) {
private func write(
_ state: AppliedState, to window: NSPanel, on displayID: CGDirectDisplayID,
fadingOver seconds: Double?
) {
// Blackout swallows mouse input (at full black a click-through click
// is a blind click on live UI); every other level stays click-through.
//
// The swallow leads the pixels: set while the alpha is still fading in, so
// an early click in a blackout is swallowed before the screen is black.
// Delaying it to match the pixels would let that click land on live UI.
window.ignoresMouseEvents = !state.blackout
// Mask first, since it decides the alpha: 1.0 over a failed mask's flat
// black layer blacks out the panel.
let masked = Self.writeMask(state.mask, to: window)
let alpha = masked ? state.alpha : Self.fallbackAlpha(forUnrendered: state.mask)
window.contentView?.alphaValue = CGFloat(alpha)
guard let seconds else {
self.endFade(on: displayID, view: window.contentView)
window.contentView?.alphaValue = CGFloat(alpha)
window.orderFrontRegardless()
return
}
// Front first: a window that is not on screen cannot fade onto it.
window.orderFrontRegardless()
let generation = (self.fadeGeneration[displayID] ?? 0) &+ 1
self.fadeGeneration[displayID] = generation
self.fadingTo[displayID] = InFlightFade(target: state, startedAt: .now)
NSAnimationContext.runAnimationGroup { context in
context.duration = seconds
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.contentView?.animator().alphaValue = CGFloat(alpha)
} completionHandler: {
// AppKit calls this on the main thread, so the clear lands on the turn
// the animation ends.
MainActor.assumeIsolated { self.fadeDidFinish(generation, on: displayID) }
}
}

/// Clears the in-flight entry only for the fade the display is still waiting
/// on; a superseded handler carries a stale generation and leaves it alone.
private func fadeDidFinish(_ generation: UInt64, on displayID: CGDirectDisplayID) {
guard self.fadeGeneration[displayID] == generation else { return }
self.fadingTo.removeValue(forKey: displayID)
}

/// Drops the fade state and stops an animation still running. Assignment
/// alone would not: the animation keeps driving the presentation layer toward
/// its own target and only then snaps, so a lift issued mid-fade would keep
/// darkening first.
///
/// `removeAllAnimations` is safe because ours is the only animation this layer
/// carries: the mask goes straight to `contents`, and a layer-backed view runs
/// no implicit animations outside an animation context.
private func endFade(on displayID: CGDirectDisplayID, view: NSView?) {
guard self.fadingTo.removeValue(forKey: displayID) != nil else { return }
self.fadeGeneration[displayID] = (self.fadeGeneration[displayID] ?? 0) &+ 1
view?.layer?.removeAllAnimations()
}

/// Alpha for a mask that did not reach the layer. The caller's 1.0 (the mask
Expand Down Expand Up @@ -238,6 +339,9 @@ final class OledOverlay {
return
}
self.lastApplied.removeValue(forKey: displayID)
// The window is going and its fade goes with it: the completion handler
// may never arrive to say so.
self.endFade(on: displayID, view: window.contentView)
// Retained for the stranded-overlay check and its recovery; cleared once
// the server confirms the window is gone, or when a new overlay supersedes
// it. A window that never reached the screen has no number to watch (0 is
Expand Down
Loading
Loading