@@ -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 }
0 commit comments