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() 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",