diff --git a/README.md b/README.md
index 70797fa..4736b0b 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ Mirrors the original app's capabilities:
- **Brightness** — 0–100% (clamped to the firmware-valid floor; see below).
- **Presets** — seeded RGB + white presets, with apply / match.
- **Sync from light** — read a bulb's current state (`getPilot`) back into the UI.
-- **Settings** — accent/highlight colour and auto-sync, persisted locally.
+- **Settings** — accent/highlight colour, auto-sync, and auto-off when the Mac sleeps or shuts down, persisted locally.
## Screenshots
@@ -39,7 +39,7 @@ The native macOS app: a menu-bar dropdown for quick changes, and a full controls
-**Settings** — device info, auto-sync, and update checks:
+**Settings** — device info, auto-sync, auto-off on sleep/shutdown, and update checks:
diff --git a/apps/macos/Sources/WizApp/AppDelegate.swift b/apps/macos/Sources/WizApp/AppDelegate.swift
index 0be1e9f..f20f182 100644
--- a/apps/macos/Sources/WizApp/AppDelegate.swift
+++ b/apps/macos/Sources/WizApp/AppDelegate.swift
@@ -43,6 +43,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
setupActivationPolicyTracking()
setupStrayWindowGuard()
observeState()
+ observePowerEvents()
+ // Opt out of sudden termination so logout/shutdown routes through the quit
+ // AppleEvent → applicationShouldTerminate (which `isSystemInitiatedQuit`
+ // reads) rather than a SIGKILL, giving the shutdown power-off a chance to
+ // run. Harmless when the feature's off — we return `.terminateNow` at once.
+ ProcessInfo.processInfo.disableSuddenTermination()
// Best-effort, silent, throttled to once per 24h. Drives the "Update
// Available" item in the menu.
@@ -71,7 +77,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
_ sender: NSApplication
) -> NSApplication.TerminateReply {
if quitFromStatusBarMenu { return .terminateNow }
- if Self.isSystemInitiatedQuit() { return .terminateNow }
+ if Self.isSystemInitiatedQuit() {
+ // Logout / restart / shutdown — turn the light off first if opted in.
+ appState.powerOffForShutdown()
+ return .terminateNow
+ }
for window in NSApp.windows where window.isVisible && window.level == .normal {
window.close()
}
@@ -94,6 +104,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|| reason == kAEShutDown
}
+ // MARK: - System power events
+
+ /// Turn the light off before the Mac sleeps (gated by `AppState`'s setting).
+ /// Registered on `NSWorkspace`'s *own* notification center, which posts
+ /// `willSleepNotification` on the main thread synchronously during the sleep
+ /// transition — so this selector handler runs inline and the off datagrams
+ /// egress while Wi-Fi is still up. (A block dispatched to a queue could race
+ /// the actual sleep, and on macOS 13 there's no `MainActor.assumeIsolated` to
+ /// bridge one back synchronously.) Shutdown is handled in
+ /// `applicationShouldTerminate` instead — see `AppState.powerOffForShutdown`.
+ private func observePowerEvents() {
+ NSWorkspace.shared.notificationCenter.addObserver(
+ self, selector: #selector(systemWillSleep),
+ name: NSWorkspace.willSleepNotification, object: nil)
+ }
+
+ @objc private func systemWillSleep(_ notification: Notification) {
+ appState.powerOffForSleep()
+ }
+
deinit {
if let token = windowCloseObserver {
NotificationCenter.default.removeObserver(token)
@@ -101,6 +131,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
if let token = strayWindowObserver {
NotificationCenter.default.removeObserver(token)
}
+ NSWorkspace.shared.notificationCenter.removeObserver(self)
}
// MARK: - Stray-window guard
diff --git a/apps/macos/Sources/WizApp/AppState.swift b/apps/macos/Sources/WizApp/AppState.swift
index d9e333e..f76d7ed 100644
--- a/apps/macos/Sources/WizApp/AppState.swift
+++ b/apps/macos/Sources/WizApp/AppState.swift
@@ -40,6 +40,24 @@ final class AppState: ObservableObject {
/// Persisted UI settings (accent / highlight / auto-sync).
@Published var settings: Stores.Settings = .defaults
+ // MARK: - System power-off preferences
+
+ /// Turn the selected light off just before 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) }
+ }
+ @Published var powerOffOnShutdown: Bool {
+ didSet { UserDefaults.standard.set(powerOffOnShutdown, forKey: Self.powerOffOnShutdownKey) }
+ }
+ 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.
/// `connected` is *derived* from this (below) so the boolean and the displayed
@@ -133,6 +151,11 @@ 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)
// Seed from the engine's default before loading persisted state.
self.state = core.defaultState
loadPersisted()
@@ -800,6 +823,35 @@ final class AppState: ObservableObject {
}
}
+ // MARK: - Power off on system events
+
+ /// Called from `AppDelegate` when the Mac is about to sleep. Honours the
+ /// `powerOffOnSleep` preference.
+ func powerOffForSleep() {
+ if powerOffOnSleep { sendPowerOffNow() }
+ }
+
+ /// Called from `AppDelegate` when the Mac is shutting down / logging out /
+ /// restarting. Honours the `powerOffOnShutdown` preference.
+ func powerOffForShutdown() {
+ if powerOffOnShutdown { sendPowerOffNow() }
+ }
+
+ /// Turn the light off *synchronously*, blocking until the datagrams are on the
+ /// wire. The normal `setPower(false)` path debounces and sends on a background
+ /// queue, but the system can cut Wi-Fi the instant the sleep / terminate
+ /// handler returns — a deferred send would never leave the machine. `sendNow`
+ /// fires the off command 3× (≈240 ms) to ride out UDP loss; the optimistic
+ /// local `state.on = false` keeps the menu-bar icon correct on wake. We send
+ /// regardless of the last-known on/off so a stale "off" can't strand a lit
+ /// bulb. No-op without a selected light.
+ private func sendPowerOffNow() {
+ guard hasLight, let client = client else { return }
+ client.sendNow(["state": false])
+ state.on = false
+ bump()
+ }
+
/// Apply a preset to the current state (via the engine), then send it.
func applyPreset(_ preset: Preset) {
state = core.applyPreset(state, preset)
diff --git a/apps/macos/Sources/WizApp/Views/SettingsView.swift b/apps/macos/Sources/WizApp/Views/SettingsView.swift
index f222dc3..900a641 100644
--- a/apps/macos/Sources/WizApp/Views/SettingsView.swift
+++ b/apps/macos/Sources/WizApp/Views/SettingsView.swift
@@ -20,6 +20,8 @@ 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)
}
Section("Updates") {
UpdateRow()
diff --git a/apps/macos/build/Info.plist b/apps/macos/build/Info.plist
index 5b91077..40bfb8d 100644
--- a/apps/macos/build/Info.plist
+++ b/apps/macos/build/Info.plist
@@ -13,9 +13,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.5.0
+ 0.6.0
CFBundleVersion
- 17
+ 18
CFBundleIconFile
AppIcon
diff --git a/package.json b/package.json
index 90e5cba..e78cec0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "wiz-light-controller",
- "version": "0.5.0",
+ "version": "0.6.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 fccd67f..e09b464 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "wiz-light-cli",
- "version": "0.5.0",
+ "version": "0.6.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 92647fb..e17813f 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "wiz-light-core",
- "version": "0.5.0",
+ "version": "0.6.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",