Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -39,7 +39,7 @@ The native macOS app: a menu-bar dropdown for quick changes, and a full controls

<img src="assets/screenshot-discover.png" alt="Discovery sheet (annotated)" width="480">

**Settings** — device info, auto-sync, and update checks:
**Settings** — device info, auto-sync, auto-off on sleep/shutdown, and update checks:

<img src="assets/screenshot-settings.png" alt="Settings tab (annotated)" width="500">

Expand Down
33 changes: 32 additions & 1 deletion apps/macos/Sources/WizApp/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
}
Expand All @@ -94,13 +104,34 @@ 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)
}
if let token = strayWindowObserver {
NotificationCenter.default.removeObserver(token)
}
NSWorkspace.shared.notificationCenter.removeObserver(self)
}

// MARK: - Stray-window guard
Expand Down
52 changes: 52 additions & 0 deletions apps/macos/Sources/WizApp/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions apps/macos/Sources/WizApp/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions apps/macos/build/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.5.0</string>
<string>0.6.0</string>
<key>CFBundleVersion</key>
<string>17</string>
<string>18</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<!-- Menu-bar / accessory app: no Dock icon until the controller window opens. -->
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down