diff --git a/apps/macos/Sources/WizApp/AppDelegate.swift b/apps/macos/Sources/WizApp/AppDelegate.swift index 0be1e9f..ccd89a6 100644 --- a/apps/macos/Sources/WizApp/AppDelegate.swift +++ b/apps/macos/Sources/WizApp/AppDelegate.swift @@ -225,10 +225,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } /// Refresh the dropdown just before it opens: re-read a connected light's values - /// (without reconnecting a disconnected one), and resize the hosted view to its - /// current content (connected vs. disconnected differ in height). + /// (or, mid auto-reconnect, bring the next attempt forward — but never resurrect + /// a manual disconnect), and resize the hosted view to its current content + /// (connected vs. disconnected differ in height). func menuWillOpen(_ menu: NSMenu) { - appState.refreshIfConnected() + appState.refreshOnOpen() dropdownContentView?.updateFrameToFit() } diff --git a/apps/macos/Sources/WizApp/AppState.swift b/apps/macos/Sources/WizApp/AppState.swift index 57159ad..a04da39 100644 --- a/apps/macos/Sources/WizApp/AppState.swift +++ b/apps/macos/Sources/WizApp/AppState.swift @@ -493,14 +493,30 @@ final class AppState: ObservableObject { syncAttempt(host: selectedIp, attempt: 0) } - /// Re-read a *connected* light's values (e.g. when the dropdown opens) without - /// reconnecting a disconnected one — so opening the menu never overrides a - /// manual disconnect. No-op while disconnected. Passes `monitorHealth: false` so - /// an off-cadence open only *reads* fresh values (and clears stale strikes on a - /// reply) — it can never itself disconnect you; only the 15 s poll drops the link. - func refreshIfConnected() { - guard connected else { return } - refreshSignal(monitorHealth: false) + /// Called when a UI surface opens (the menu-bar dropdown, or the controls + /// window under auto-sync). Two cases: + /// + /// - **Connected:** re-read the bulb's values via `monitorHealth: false`, so an + /// off-cadence open only *reads* fresh values (and clears stale strikes on a + /// reply) — it can never itself disconnect you; only the 15 s poll drops the + /// link. + /// - **Mid auto-reconnect** (dropped but not *manually* disconnected): treat the + /// open as a fresh chance to reach the bulb — reset the backoff and pull the + /// next attempt forward (~0.5 s), so the user doesn't sit watching a backoff + /// that may have grown to minutes. Same bring-forward as `handlePathChange`; + /// `pollTick` owns the connect guards and won't stack onto an in-flight + /// attempt, so we just reschedule. + /// + /// A deliberate disconnect (and a no-light state) is left alone, so opening the + /// menu never overrides it — the same invariant the poll and network-change + /// paths honour. + func refreshOnOpen() { + if connected { + refreshSignal(monitorHealth: false) + } else if hasLight, !manuallyDisconnected { + resetReconnectBackoff() + scheduleNextPoll(after: 0.5) + } } /// Warm Glow is a local overlay on white mode (the temperature follows the diff --git a/apps/macos/Sources/WizApp/Views/ControllerView.swift b/apps/macos/Sources/WizApp/Views/ControllerView.swift index bfc69c0..36953b7 100644 --- a/apps/macos/Sources/WizApp/Views/ControllerView.swift +++ b/apps/macos/Sources/WizApp/Views/ControllerView.swift @@ -54,9 +54,10 @@ struct ControllerView: View { } .frame(minWidth: 600, minHeight: 500) .onAppear { - // Refresh a connected light's values, but never reconnect a manually - // disconnected one (matches the menu-bar popover's refreshIfConnected). - if app.settings.autoSync, app.hasLight { app.refreshIfConnected() } + // Refresh a connected light's values (or, mid auto-reconnect, bring the next + // attempt forward), but never reconnect a manually disconnected one — matches + // the menu-bar popover's refreshOnOpen. + if app.settings.autoSync { app.refreshOnOpen() } } }