From bc75c56873bba63ea54b87cb9ca4583c1d47c978 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Mon, 8 Jun 2026 12:14:33 +0200 Subject: [PATCH 1/2] feat(macos): turn the light off on system sleep / shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two opt-in toggles under Settings → Behaviour that switch the selected light off just before the Mac sleeps or shuts down, so it is not left lit in an empty room. Sleep is handled by an NSWorkspace.willSleepNotification observer (synchronous, on the main thread). Shutdown/logout/restart reuses the existing isSystemInitiatedQuit check in applicationShouldTerminate, with sudden termination disabled so that path is guaranteed to run. Both fire a synchronous WizClient.sendNow power-off so the command leaves the machine before the network drops, unlike the usual debounced async path. The prefs persist in UserDefaults (a macOS-only behaviour, kept out of the shared cross-tool settings.json, mirroring UpdateChecker.autoCheckEnabled) and default off. --- README.md | 4 +- apps/macos/Sources/WizApp/AppDelegate.swift | 33 +++++++++++- apps/macos/Sources/WizApp/AppState.swift | 52 +++++++++++++++++++ .../Sources/WizApp/Views/SettingsView.swift | 2 + 4 files changed, 88 insertions(+), 3 deletions(-) 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 Discovery sheet (annotated) -**Settings** — device info, auto-sync, and update checks: +**Settings** — device info, auto-sync, auto-off on sleep/shutdown, and update checks: Settings tab (annotated) 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() From 17b729ce289dacb0fa4749343dbc50fb63df4597 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Mon, 8 Jun 2026 12:22:36 +0200 Subject: [PATCH 2/2] chore: release v0.6.0 Bump root, wiz-light-core, wiz-light-cli, and the macOS app (CFBundleShortVersionString 0.6.0 / build 18) in lockstep, alongside the sleep/shutdown auto-off feature in this PR. --- apps/macos/build/Info.plist | 4 ++-- package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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",