diff --git a/apps/macos/Sources/WizApp/AppDelegate.swift b/apps/macos/Sources/WizApp/AppDelegate.swift
index 79753c6..5ba67e3 100644
--- a/apps/macos/Sources/WizApp/AppDelegate.swift
+++ b/apps/macos/Sources/WizApp/AppDelegate.swift
@@ -115,15 +115,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
/// bridge one back synchronously.) Shutdown is handled in
/// `applicationShouldTerminate` instead — see `AppState.powerOffForShutdown`.
private func observePowerEvents() {
- NSWorkspace.shared.notificationCenter.addObserver(
+ let center = NSWorkspace.shared.notificationCenter
+ center.addObserver(
self, selector: #selector(systemWillSleep),
name: NSWorkspace.willSleepNotification, object: nil)
+ // Wake is the mirror of sleep: bring the light back on if the user opted into
+ // restore. Unlike the off (which must egress before Wi-Fi drops, hence the
+ // synchronous handler), the on can be patient — `AppState` re-probes and turns
+ // the light on once the bulb is reachable again.
+ center.addObserver(
+ self, selector: #selector(systemDidWake),
+ name: NSWorkspace.didWakeNotification, object: nil)
}
@objc private func systemWillSleep(_ notification: Notification) {
appState.powerOffForSleep()
}
+ @objc private func systemDidWake(_ notification: Notification) {
+ appState.lightShouldRestoreOnWake()
+ }
+
deinit {
if let token = windowCloseObserver {
NotificationCenter.default.removeObserver(token)
diff --git a/apps/macos/Sources/WizApp/AppState.swift b/apps/macos/Sources/WizApp/AppState.swift
index ad61754..8a09e7d 100644
--- a/apps/macos/Sources/WizApp/AppState.swift
+++ b/apps/macos/Sources/WizApp/AppState.swift
@@ -43,21 +43,90 @@ final class AppState: ObservableObject {
// MARK: - System power-off preferences
- /// Turn the selected light off just before the Mac sleeps / shuts down. These
+ /// What to do with the selected light when the Mac sleeps / shuts down. These
/// are macOS-only behaviours with no meaning to the shared cross-tool store,
/// so — like `UpdateChecker.autoCheckEnabled` — they live in `UserDefaults`
/// rather than `settings.json` (whose schema the CLI mirrors and would strip
- /// unknown keys from). Both default off: turning off a physical light
- /// unprompted would surprise a user who hadn't opted in. Read once in `init`;
- /// `didSet` persists every later change. The triggers live in `AppDelegate`.
- @Published var powerOffOnSleep: Bool {
- didSet { UserDefaults.standard.set(powerOffOnSleep, forKey: Self.powerOffOnSleepKey) }
+ /// unknown keys from). Both default to `.turnOffThenRestore` (and the app opens
+ /// at login) so out of the box the light follows the Mac's power state and comes
+ /// back as you left it; an explicit choice, once made, is preserved. Read once in
+ /// `init` (migrating the pre-5.x Bool prefs forward); `didSet` persists every
+ /// later change. The triggers live in `AppDelegate`.
+ enum PowerEventAction: String {
+ /// Leave the light as it is.
+ case doNothing
+ /// Turn the light off; leave it off when the Mac returns.
+ case turnOff
+ /// Turn the light off, then back on when the Mac wakes / next launches. The
+ /// bulb remembers its own colour/brightness, so "on" restores the prior look.
+ case turnOffThenRestore
+ }
+ @Published var sleepAction: PowerEventAction {
+ didSet { UserDefaults.standard.set(sleepAction.rawValue, forKey: Self.sleepActionKey) }
+ }
+ @Published var shutdownAction: PowerEventAction {
+ didSet {
+ UserDefaults.standard.set(shutdownAction.rawValue, forKey: Self.shutdownActionKey)
+ // Restoring after a full shutdown needs the app running again at boot — so
+ // opt into launching at login. Only ever auto-*enable* (never tear down a
+ // login item the user may want for other reasons); they can still turn the
+ // toggle back off, and the Settings hint then warns restore won't fire.
+ if shutdownAction == .turnOffThenRestore, !openAtLogin { openAtLogin = true }
+ }
+ }
+ /// Relaunch the app at login via `SMAppService` (see `LoginItem`). Surfaced as a
+ /// Settings toggle and auto-enabled when `shutdownAction` is `.turnOffThenRestore`
+ /// (the only way to be running at boot to restore the light). Defaults on; the
+ /// user's intent is persisted so an explicit opt-out sticks, and `init` reconciles
+ /// the actual registration to it (registering on first launch).
+ @Published var openAtLogin: Bool {
+ didSet {
+ guard openAtLogin != oldValue else { return }
+ UserDefaults.standard.set(openAtLogin, forKey: Self.openAtLoginKey)
+ LoginItem.setEnabled(openAtLogin)
+ }
}
- @Published var powerOffOnShutdown: Bool {
- didSet { UserDefaults.standard.set(powerOffOnShutdown, forKey: Self.powerOffOnShutdownKey) }
+ private static let openAtLoginKey = "com.wizlightcontroller.openAtLogin"
+ private static let sleepActionKey = "com.wizlightcontroller.poweroff.sleepAction"
+ private static let shutdownActionKey = "com.wizlightcontroller.poweroff.shutdownAction"
+ /// Pre-5.x Bool prefs, migrated forward on first read (see `loadPowerAction`).
+ private static let legacySleepKey = "com.wizlightcontroller.poweroff.onSleep"
+ private static let legacyShutdownKey = "com.wizlightcontroller.poweroff.onShutdown"
+ /// Set just before a shutdown power-off when restore is on, so the next launch
+ /// knows to turn the light back on. Cleared on read in `init`.
+ private static let pendingBootRestoreKey = "com.wizlightcontroller.poweroff.pendingBootRestore"
+
+ /// A pending "turn the light back on" intent for a `.turnOffThenRestore` event,
+ /// honoured only until this deadline — past it the bulb stayed unreachable and
+ /// we drop the intent rather than switch it on at a surprising later time. Armed
+ /// at wake / boot, consumed by `syncAttempt` the moment the bulb is reachable.
+ private var restoreOnDeadline: Date?
+ /// Set at sleep when we turned an on, reachable light off with restore enabled;
+ /// consumed at wake to arm `restoreOnDeadline`.
+ private var pendingWakeRestore = false
+ /// Grace windows for the restore-on intent. Boot gets longer than wake — login
+ /// + Wi-Fi association after a cold start take longer than a wake from sleep.
+ private static let restoreWindowAfterWake: TimeInterval = 120
+ private static let restoreWindowAfterBoot: TimeInterval = 300
+ /// Max system uptime (seconds since boot) at launch for a pending boot-restore to
+ /// count as a real login-launch, vs. the user opening the app by hand long after.
+ private static let bootRestoreUptimeWindow: TimeInterval = 300
+
+ /// Read a power-event action. With no stored choice it defaults to
+ /// `.turnOffThenRestore`. A pre-5.x Bool pref is migrated forward the first time
+ /// (legacy `true` → `.turnOff`, an explicit legacy `false` → `.doNothing`), so a
+ /// user who'd touched the old toggle keeps that choice. The new key, once written
+ /// by `didSet`, wins.
+ private static func loadPowerAction(_ key: String, legacyBool legacyKey: String) -> PowerEventAction {
+ let defaults = UserDefaults.standard
+ if let raw = defaults.string(forKey: key), let action = PowerEventAction(rawValue: raw) {
+ return action
+ }
+ if defaults.object(forKey: legacyKey) != nil {
+ return defaults.bool(forKey: legacyKey) ? .turnOff : .doNothing
+ }
+ return .turnOffThenRestore
}
- private static let powerOffOnSleepKey = "com.wizlightcontroller.poweroff.onSleep"
- private static let powerOffOnShutdownKey = "com.wizlightcontroller.poweroff.onShutdown"
/// Connection lifecycle for the selected light — the single source of truth for
/// the status dot/text in both the controls window and the menu-bar popover.
@@ -162,13 +231,31 @@ final class AppState: ObservableObject {
private var lastBrightness = 100
init() {
- // Restore the macOS-only power-off prefs (default off when unset). Assigning
- // a `didSet` property here doesn't fire the observer, so the default isn't
- // written back to disk.
- powerOffOnSleep = UserDefaults.standard.bool(forKey: Self.powerOffOnSleepKey)
- powerOffOnShutdown = UserDefaults.standard.bool(forKey: Self.powerOffOnShutdownKey)
+ // Restore the macOS-only power prefs, migrating the pre-5.x Bool prefs forward
+ // (see `loadPowerAction`). Assigning a `didSet` property here doesn't fire the
+ // observer, so nothing is written back to disk and `shutdownAction`'s
+ // auto-enable of the login item doesn't run spuriously at launch.
+ sleepAction = Self.loadPowerAction(Self.sleepActionKey, legacyBool: Self.legacySleepKey)
+ shutdownAction = Self.loadPowerAction(Self.shutdownActionKey, legacyBool: Self.legacyShutdownKey)
+ // Open at login by default; honour an explicit opt-out once the user sets one.
+ let wantsLogin = UserDefaults.standard.object(forKey: Self.openAtLoginKey) as? Bool ?? true
+ openAtLogin = wantsLogin
// Seed from the engine's default before loading persisted state.
self.state = core.defaultState
+ // Match the actual login-item registration to that intent (the assignment above
+ // doesn't fire `didSet`): registers on first launch, or undoes a drift.
+ if wantsLogin != LoginItem.isEnabled { LoginItem.setEnabled(wantsLogin) }
+ // Boot-restore: if we turned the light off for a shutdown last time with
+ // restore on, arm a one-shot turn-on — but only when we actually launched at
+ // boot (short system uptime), so opening the app by hand long after a normal
+ // start-up doesn't surprise-toggle the light. The flag is cleared on read
+ // either way; `loadPersisted`'s connect below consumes the deadline.
+ if UserDefaults.standard.bool(forKey: Self.pendingBootRestoreKey) {
+ UserDefaults.standard.set(false, forKey: Self.pendingBootRestoreKey)
+ if ProcessInfo.processInfo.systemUptime < Self.bootRestoreUptimeWindow {
+ restoreOnDeadline = Date().addingTimeInterval(Self.restoreWindowAfterBoot)
+ }
+ }
loadPersisted()
startReconnectPolling()
startNetworkMonitor()
@@ -599,6 +686,10 @@ final class AppState: ObservableObject {
if next.on, next.brightness > 0 { self.lastBrightness = next.brightness }
self.deviceInfo.rssi = (result["rssi"] as? NSNumber)?.intValue ?? self.deviceInfo.rssi
self.loadDeviceConfig(host: host, client: client)
+ // Reachable again — if a wake/boot restore is pending, turn the light
+ // back on now (after the read above, so our turn-on wins over the bulb's
+ // reported off).
+ self.consumeRestoreOn()
self.bump()
} else if attempt + 1 < Self.maxSyncAttempts {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.3) { [weak self] in
@@ -688,6 +779,7 @@ final class AppState: ObservableObject {
/// auto-reconnect poll until the user reconnects (Reconnect / re-select / Sync).
func disconnect() {
guard hasLight else { return }
+ cancelPendingRestore() // user took control; don't auto-restore behind their back
manuallyDisconnected = true
status = .disconnected
healthFailures = 0
@@ -923,6 +1015,7 @@ final class AppState: ObservableObject {
/// return at the ~1% passed through on the way to off) and re-sends the full
/// state; turning off is a bare power-off.
func setPower(_ on: Bool) {
+ cancelPendingRestore() // an explicit power choice supersedes a pending restore
lastLocalEdit = Date()
if on {
if lastBrightness > 0 { setBrightness(lastBrightness) } // also recomputes Warm Glow temp
@@ -936,18 +1029,67 @@ final class AppState: ObservableObject {
}
}
- // MARK: - Power off on system events
+ // MARK: - Power off / restore on system events
- /// Called from `AppDelegate` when the Mac is about to sleep. Honours the
- /// `powerOffOnSleep` preference.
+ /// Called from `AppDelegate` when the Mac is about to sleep. Turns the light off
+ /// per `sleepAction`, and — for `.turnOffThenRestore` — remembers to bring it
+ /// back on at wake, but only if it was actually on and reachable now (so a light
+ /// we'd left off, or whose state we couldn't confirm, isn't switched on later).
func powerOffForSleep() {
- if powerOffOnSleep { sendPowerOffNow() }
+ guard sleepAction != .doNothing else { return }
+ pendingWakeRestore = sleepAction == .turnOffThenRestore && connected && state.on
+ sendPowerOffNow()
}
/// Called from `AppDelegate` when the Mac is shutting down / logging out /
- /// restarting. Honours the `powerOffOnShutdown` preference.
+ /// restarting. Turns the light off per `shutdownAction`; for `.turnOffThenRestore`
+ /// (and only if it was on and reachable) it persists a flag so the next launch
+ /// turns it back on — see the boot-restore block in `init`.
func powerOffForShutdown() {
- if powerOffOnShutdown { sendPowerOffNow() }
+ guard shutdownAction != .doNothing else { return }
+ if shutdownAction == .turnOffThenRestore, connected, state.on {
+ UserDefaults.standard.set(true, forKey: Self.pendingBootRestoreKey)
+ // We return `.terminateNow` right after this and the process is killed during
+ // shutdown — force the flag to disk now, since UserDefaults' async flush may
+ // not finish first (which would silently drop the restore next boot).
+ UserDefaults.standard.synchronize()
+ }
+ sendPowerOffNow()
+ }
+
+ /// Called from `AppDelegate` on `NSWorkspace.didWakeNotification`. If we turned
+ /// the light off for sleep with restore on, arm a one-shot turn-on: Wi-Fi may
+ /// still be reassociating, so re-probe now and let the connect path (or a later
+ /// backoff retry / network-change reconnect) deliver the turn-on once the bulb is
+ /// reachable, within `restoreWindowAfterWake`.
+ func lightShouldRestoreOnWake() {
+ guard pendingWakeRestore else { return }
+ pendingWakeRestore = false
+ restoreOnDeadline = Date().addingTimeInterval(Self.restoreWindowAfterWake)
+ resetReconnectBackoff()
+ sync()
+ }
+
+ /// Turn the light back on for a pending wake/boot restore, if still within the
+ /// grace window. Called from `syncAttempt` the moment the bulb is first reachable
+ /// again — the connect read just before this already folded the bulb's remembered
+ /// colour/brightness into `state` (WiZ reports them even while off), so we only
+ /// flip it on; a bare power-on lets the bulb light back up to that same look.
+ private func consumeRestoreOn() {
+ guard let deadline = restoreOnDeadline else { return }
+ restoreOnDeadline = nil
+ guard Date() < deadline, let client = client else { return }
+ state.on = true
+ client.power(true)
+ bump()
+ }
+
+ /// Drop any pending wake/boot restore. Called when the user takes explicit
+ /// control of power (toggling the light, or disconnecting) so an armed restore
+ /// can't later override that deliberate choice during its grace window.
+ private func cancelPendingRestore() {
+ pendingWakeRestore = false
+ restoreOnDeadline = nil
}
/// Turn the light off *synchronously*, blocking until the datagrams are on the
diff --git a/apps/macos/Sources/WizApp/LoginItem.swift b/apps/macos/Sources/WizApp/LoginItem.swift
new file mode 100644
index 0000000..f72e110
--- /dev/null
+++ b/apps/macos/Sources/WizApp/LoginItem.swift
@@ -0,0 +1,32 @@
+import Foundation
+import ServiceManagement
+
+/// Launch-at-login for the app itself, via `SMAppService.mainApp` (macOS 13+).
+///
+/// Used so the "restore on startup" power option can bring the light back after a
+/// full shutdown: the app must be running again at boot to send the turn-on, so it
+/// registers itself as a login item. `SMAppService.mainApp` registers the *main
+/// bundle* (no embedded helper, works under the App Sandbox), and because the app
+/// is `LSUIElement` a login launch just brings up the menu-bar item — no window.
+///
+/// Note: registration needs a properly signed build to take effect; under the
+/// ad-hoc signing used for local dev it may silently fail to register.
+enum LoginItem {
+ /// Whether the app is currently registered to open at login.
+ static var isEnabled: Bool { SMAppService.mainApp.status == .enabled }
+
+ /// Register / unregister the app as a login item. No-ops when already in the
+ /// desired state; failures are logged, not surfaced — the Settings toggle stays
+ /// optimistic and re-syncs from `isEnabled` on the next launch.
+ static func setEnabled(_ enabled: Bool) {
+ do {
+ if enabled {
+ if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() }
+ } else {
+ if SMAppService.mainApp.status == .enabled { try SMAppService.mainApp.unregister() }
+ }
+ } catch {
+ NSLog("LoginItem.setEnabled(\(enabled)) failed: \(error.localizedDescription)")
+ }
+ }
+}
diff --git a/apps/macos/Sources/WizApp/Views/SettingsView.swift b/apps/macos/Sources/WizApp/Views/SettingsView.swift
index 900a641..cd338b6 100644
--- a/apps/macos/Sources/WizApp/Views/SettingsView.swift
+++ b/apps/macos/Sources/WizApp/Views/SettingsView.swift
@@ -1,7 +1,8 @@
import SwiftUI
import WizKit
-/// App settings: the auto-sync toggle and the update-check controls, persisted.
+/// App settings: auto-sync, the sleep/shutdown power actions, open-at-login, and
+/// the update-check controls, all persisted.
struct SettingsView: View {
@EnvironmentObject var app: AppState
@@ -20,8 +21,22 @@ struct SettingsView: View {
}
Section("Behaviour") {
Toggle("Auto-sync from the light on launch", isOn: autoSyncBinding)
- Toggle("Turn the light off when the Mac sleeps", isOn: $app.powerOffOnSleep)
- Toggle("Turn the light off when the Mac shuts down", isOn: $app.powerOffOnShutdown)
+ Picker("When the Mac sleeps", selection: $app.sleepAction) {
+ Text("Do nothing").tag(AppState.PowerEventAction.doNothing)
+ Text("Turn off").tag(AppState.PowerEventAction.turnOff)
+ Text("Turn off, then restore on wake").tag(AppState.PowerEventAction.turnOffThenRestore)
+ }
+ Picker("When the Mac shuts down", selection: $app.shutdownAction) {
+ Text("Do nothing").tag(AppState.PowerEventAction.doNothing)
+ Text("Turn off").tag(AppState.PowerEventAction.turnOff)
+ Text("Turn off, then restore on startup").tag(AppState.PowerEventAction.turnOffThenRestore)
+ }
+ Toggle("Open at login", isOn: $app.openAtLogin)
+ if app.shutdownAction == .turnOffThenRestore, !app.openAtLogin {
+ Text("Restoring on startup needs the app to open at login.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
}
Section("Updates") {
UpdateRow()
diff --git a/apps/macos/build/Info.plist b/apps/macos/build/Info.plist
index 4abf051..6800db2 100644
--- a/apps/macos/build/Info.plist
+++ b/apps/macos/build/Info.plist
@@ -13,9 +13,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 5.0.1
+ 5.1.0
CFBundleVersion
- 18
+ 19
CFBundleIconFile
AppIcon
diff --git a/package.json b/package.json
index 5a0da40..06814a3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "wiz-light-controller",
- "version": "5.0.1",
+ "version": "5.1.0",
"private": true,
"description": "Fast, local, cloud-free controller for Philips WiZ lights — a modular JavaScript engine (reused by a CLI and a native macOS app via JavaScriptCore).",
"license": "GPL-3.0-or-later",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index a9defdc..860117a 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "wiz-light-cli",
- "version": "5.0.1",
+ "version": "5.1.0",
"description": "Fast, local, cloud-free command-line controller for Philips WiZ lights.",
"license": "GPL-3.0-or-later",
"homepage": "https://github.com/MegaManSec/wiz-light-controller#readme",
diff --git a/packages/core/package.json b/packages/core/package.json
index 7413d91..6a61883 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "wiz-light-core",
- "version": "5.0.1",
+ "version": "5.1.0",
"description": "Local WiZ light engine: protocol, discovery, colour math, and persisted state. Zero runtime dependencies.",
"license": "GPL-3.0-or-later",
"homepage": "https://github.com/MegaManSec/wiz-light-controller#readme",