Skip to content

Commit 76a6ef1

Browse files
authored
Preserve brightness across recovery, updates, and relaunch (#120)
* Preserve brightness through interrupted HDR dims for 1.0.4 * Update hardware checks for resolved monitor capabilities * Preserve brightness across quit and updates; verify rotation roundtrips
1 parent 5fd6cc2 commit 76a6ef1

21 files changed

Lines changed: 1233 additions & 109 deletions

‎Candela/App/AppModel.swift‎

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,9 @@ final class AppModel {
609609
/// deliberately not observable.
610610
var isSafeMode: Bool { safeMode }
611611

612+
/// Eligibility captured before Sparkle's relaunch UI mark is consumed.
613+
@ObservationIgnored private var recoverLegacyUpdateHandbacks: Bool
614+
612615
var volumeMode: MultiKeyboardVolume { appPrefs.multiKeyboardVolume }
613616

614617
/// Bumped by the propagation seam on any pref write a view renders. The panel
@@ -704,6 +707,7 @@ final class AppModel {
704707
hdrToggling: (any HDRToggling)? = nil,
705708
audioDevices: (any AudioDeviceProviding)? = nil,
706709
safeMode: Bool = false,
710+
recoverLegacyUpdateHandbacks: Bool = false,
707711
discoverDisplays: @escaping (Set<CGDirectDisplayID>) -> DisplayDiscoverySurvey = {
708712
DisplayDiscovery.survey(excluding: $0)
709713
}
@@ -713,6 +717,7 @@ final class AppModel {
713717
self.hdrToggling = hdrToggling ?? MonitorPanelService()
714718
self.audioDevices = audioDevices ?? CoreAudioDeviceProvider()
715719
self.safeMode = safeMode
720+
self.recoverLegacyUpdateHandbacks = recoverLegacyUpdateHandbacks
716721
self.discoverDisplays = discoverDisplays
717722
appPrefs = DisplayPrefs(persistenceKey: "app", safeMode: safeMode)
718723
}
@@ -1308,18 +1313,22 @@ final class AppModel {
13081313
)
13091314

13101315
/// Puts back the brightness of a display a previous process left dimmed.
1311-
/// Externals only (the built-in has no DDC register to strand) and brightness
1312-
/// only: the dim never touched contrast or volume.
1313-
func recoverInterruptedDims() {
1314-
let evaluated = displays.count
1316+
/// Externals only, on their DDC or native HDR leg. Polling starts after the
1317+
/// queued restores finish, so an old dim cannot be mistaken for a user change.
1318+
func recoverInterruptedDims() async {
1319+
let recoveryDisplays = displays
1320+
let evaluated = recoveryDisplays.count
13151321
var reasserted = 0
1322+
var recovering: [(key: String, controller: BrightnessController)] = []
1323+
let liveDimKeysAtStart = Set(recoveryDisplays.filter { $0.controller.temporaryDimFactor != nil }
1324+
.map(\.display.persistenceKey))
13161325
// Twins share a marker, but each has its own wire. Snapshot before any
13171326
// clearing so discovery order cannot decide which one gets recovered.
1318-
let markedKeys = Set(displays.map(\.display.persistenceKey)).filter {
1327+
let markedKeys = Set(recoveryDisplays.map(\.display.persistenceKey)).filter {
13191328
DisplayPrefs(persistenceKey: $0, safeMode: safeMode).temporaryDimEngaged
13201329
}
13211330
var keysToClear: Set<String> = []
1322-
for state in displays {
1331+
for state in recoveryDisplays {
13231332
let key = state.display.persistenceKey
13241333
switch InterruptedDimRecovery.action(
13251334
markerSurvived: markedKeys.contains(key),
@@ -1336,21 +1345,35 @@ final class AppModel {
13361345
// reaches the wire.
13371346
state.controller.resetWriteMemo()
13381347
state.controller.reassertHardware()
1348+
recovering.append((key, state.controller))
13391349
keysToClear.insert(key)
1350+
}
1351+
}
1352+
// Submission alone does not mean the panel has moved. Keep the marker
1353+
// through the drain so opening a surface cannot adopt the old native value.
1354+
var failedKeys: Set<String> = []
1355+
for (key, controller) in recovering {
1356+
if await controller.drainPendingWrites() {
13401357
reasserted += 1
1341-
// The tag, never the persistence key: a key without an EDID UUID embeds
1342-
// the panel's serial number.
13431358
restoreLog.info("""
1344-
interrupted dim recovered on display \
1359+
interrupted dim recovery applied on display \
1360+
\(DisplayLogging.tag(for: key), privacy: .public)
1361+
""")
1362+
} else {
1363+
failedKeys.insert(key)
1364+
restoreLog.warning("""
1365+
interrupted dim recovery not applied; retaining marker on display \
13451366
\(DisplayLogging.tag(for: key), privacy: .public)
13461367
""")
13471368
}
13481369
}
13491370
// A recovered twin cannot consume the signal a still-live dim needs if
1350-
// this process crashes next. The whole pass is synchronous on the main actor.
1351-
let liveDimKeys = Set(displays.filter { $0.controller.temporaryDimFactor != nil }
1371+
// this process crashes next. Retain departed controllers too: a dim can
1372+
// begin while the drain is suspended, then disappear from discovery.
1373+
let liveDimKeys = liveDimKeysAtStart.union((recoveryDisplays + displays)
1374+
.filter { $0.controller.temporaryDimFactor != nil }
13521375
.map(\.display.persistenceKey))
1353-
for key in keysToClear.subtracting(liveDimKeys) {
1376+
for key in keysToClear.subtracting(liveDimKeys).subtracting(failedKeys) {
13541377
DisplayPrefs(persistenceKey: key, safeMode: safeMode).temporaryDimEngaged = false
13551378
}
13561379
// `.info`, not `.debug`: macOS does not persist debug records. This line is
@@ -1359,6 +1382,8 @@ final class AppModel {
13591382
interrupted dim pass: \(evaluated, privacy: .public) evaluated, \
13601383
\(reasserted, privacy: .public) reasserted
13611384
""")
1385+
hasCompletedLaunchBrightnessRecovery = true
1386+
restartPoller()
13621387
}
13631388

13641389
/// One write-restore pass: every duplicate memo reset FIRST, then re-write
@@ -1398,6 +1423,10 @@ final class AppModel {
13981423
/// must never outlive the pass that dropped it.
13991424
@ObservationIgnored private var pollerTask: Task<Void, Never>?
14001425

1426+
@ObservationIgnored private var hasCompletedLaunchBrightnessRecovery = false
1427+
1428+
var isNativeBrightnessPolling: Bool { pollerTask != nil }
1429+
14011430
/// Whether a surface showing a brightness value is on screen; one of the
14021431
/// poller's cadence signals.
14031432
let surfaceVisibility = SurfaceVisibility()
@@ -1493,6 +1522,10 @@ final class AppModel {
14931522
/// before the drain task exits, so no explicit `waitForPendingWrites()` is
14941523
/// needed.
14951524
private func performRefresh(settling: Bool = false) async -> [CGDirectDisplayID] {
1525+
// The updater hint belongs only to the initial discovery, even if no panels
1526+
// are attached. Consume it before any suspension or later topology pass.
1527+
let recoverLegacyHandbacks = recoverLegacyUpdateHandbacks
1528+
recoverLegacyUpdateHandbacks = false
14961529
redactRecentDisplayEvents()
14971530
// The display set is about to be re-derived, so a memoized "discovery does
14981531
// not know this id" is no longer evidence about anything.
@@ -1591,6 +1624,11 @@ final class AppModel {
15911624
appeared.append(state)
15921625
return state
15931626
}
1627+
// Seed every controller before the first read can consume a shared identity's
1628+
// record. Safe Mode persists the hint but continues to skip hardware changes.
1629+
if recoverLegacyHandbacks {
1630+
for state in appeared { state.controller.noteLegacyUpdateHandback() }
1631+
}
15941632
refreshBuiltIn()
15951633
// The diagnostics ring's one insertion point for externals: `appeared` and
15961634
// whatever is LEFT in `existing` are this pass's arrivals and departures,
@@ -1786,7 +1824,7 @@ final class AppModel {
17861824
/// stay in sync on the native path.
17871825
private func restartPoller() {
17881826
pollerTask?.cancel()
1789-
guard !displays.isEmpty || builtIn != nil else {
1827+
guard hasCompletedLaunchBrightnessRecovery, !displays.isEmpty || builtIn != nil else {
17901828
pollerTask = nil
17911829
return
17921830
}

‎Candela/App/StatusItemController.swift‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ final class StatusItemController: NSObject, NSApplicationDelegate, NSMenuDelegat
6464
// instance.
6565
let monitor = GammaInterferenceMonitor(gamma: gamma, alerts: EngineAlerts())
6666
interferenceMonitor = monitor
67-
let model = AppModel(shade: shade, gamma: gamma, safeMode: safeMode)
67+
let model = AppModel(
68+
shade: shade, gamma: gamma, safeMode: safeMode,
69+
recoverLegacyUpdateHandbacks: UpdateRelaunch.needsLegacyBrightnessRecovery())
6870
self.model = model
6971
// Reporting-only handoff: built here because it needs the AppKit alert
7072
// island, read there because the diagnostics pane has no other way to say how
@@ -680,7 +682,9 @@ final class StatusItemController: NSObject, NSApplicationDelegate, NSMenuDelegat
680682
// `oledCare.displaysReconfigured()` first, which drops the lock dim for at
681683
// least one care tick; a recovery in that window would see no live dim,
682684
// write the undimmed value to a locked screen, and spend the marker.
683-
if !isSafeMode { model.recoverInterruptedDims() }
685+
// Safe Mode preserves every marker and issues no recovery writes, but
686+
// still completes this launch gate so ordinary native polling can begin.
687+
await model.recoverInterruptedDims()
684688
restoreCoordinator.noteLaunchOrReconfigure()
685689
// Before the first open, for the same reason the display list is warmed
686690
// here: nothing the panel starts can be relied on to run while the menu

‎Candela/App/UpdateRelaunch.swift‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@ enum UpdateRelaunch {
1414
defaults.set(true, forKey: defaultsKey)
1515
}
1616

17+
/// Only the shipped predecessor is known to have written full-range DDC
18+
/// brightness without recording its origin. Read this before consuming the UI mark.
19+
static func needsLegacyBrightnessRecovery(
20+
in defaults: UserDefaults = .standard, version: String = AppInfo.version
21+
) -> Bool {
22+
guard defaults.bool(forKey: defaultsKey),
23+
defaults.string(forKey: previousVersionKey) == "1.0.3" else { return false }
24+
let parts = version.split(separator: ".", omittingEmptySubsequences: false)
25+
guard parts.count == 3,
26+
parts.allSatisfy({ !$0.isEmpty && $0.utf8.allSatisfy { (48...57).contains($0) } })
27+
else { return false }
28+
let numbers = parts.compactMap { Int($0) }
29+
guard numbers.count == 3 else { return false }
30+
return [1, 0, 3].lexicographicallyPrecedes(numbers)
31+
}
32+
1733
static func consume(in defaults: UserDefaults = .standard) -> Bool {
1834
guard defaults.bool(forKey: defaultsKey) else { return false }
1935
defaults.removeObject(forKey: defaultsKey)

‎Candela/Settings/SettingsComponents.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ enum SafetySentence {
5555
/// The same sentence shown under the control. Nil for the HDR block, which
5656
/// is stated once for the whole page at the foot of Control Method, so that
5757
/// case goes into the label and nowhere else.
58-
var visibleCaption: SettingsCaption? {
58+
@MainActor var visibleCaption: SettingsCaption? {
5959
switch self {
6060
case .blankDisplay, .hardwareMute: spoken.map { SettingsCaption(verbatim: $0) }
6161
case .hdrBlock: nil

0 commit comments

Comments
 (0)