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
9 changes: 6 additions & 3 deletions apps/macos/Sources/WizApp/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
}

/// Close `window` if it's the stray placeholder-scene window: a titled,
/// non-sheet window that isn't our controller window. (The dropdown is an
/// NSMenu, whose window isn't titled, so it's never matched here.)
/// non-sheet, non-panel window that isn't our controller window. (The dropdown
/// is an NSMenu, whose window isn't titled, so it's never matched here.
/// NSPanels are excluded because system panels — NSColorPanel, NSAlert,
/// open/save — are titled too, and this observer runs for the app's lifetime;
/// the stray SwiftUI Settings window is a plain NSWindow.)
private func closeIfStray(_ window: NSWindow) {
guard window.styleMask.contains(.titled), !window.isSheet,
guard window.styleMask.contains(.titled), !window.isSheet, !(window is NSPanel),
window !== windowController?.window
else { return }
window.close()
Expand Down
63 changes: 42 additions & 21 deletions apps/macos/Sources/WizApp/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -462,13 +462,15 @@ final class AppState: ObservableObject {
presets = stores.loadPresets(defaults: core.defaultPresets())

// Only restore a *saved* light (lights are added via Discover → Save): the
// last-used one by IP, else any saved light. With nothing saved, nothing is
// selected — so we never show a phantom "press Connect to control …" for a
// light that isn't saved.
// last-used one by IP, else the first saved light *by name* — a Dictionary's
// order is randomized per process, so an unordered `first` could pick a
// different light each launch. With nothing saved, nothing is selected — so
// we never show a phantom "press Connect to control …" for a light that
// isn't saved.
let lastIp = stores.loadLastIp()
if !lastIp.isEmpty, let (mac, light) = savedLights.first(where: { $0.value.ip == lastIp }) {
selectLight(name: light.name, ip: light.ip, mac: mac, persistIp: false)
} else if let (mac, light) = savedLights.first {
} else if let (mac, light) = savedLights.min(by: { $0.value.name < $1.value.name }) {
selectLight(name: light.name, ip: light.ip, mac: mac, persistIp: false)
}

Expand Down Expand Up @@ -643,18 +645,32 @@ final class AppState: ObservableObject {
}
}

/// Fold the bulb's white channels (`c`/`w`) into the displayed RGB. The engine
/// infers control state from r/g/b alone (model.js `parsePilot`), so a colour the
/// bulb renders with its white LEDs — a pastel, or anything set from the phone
/// app — reads back over-saturated; this recombines them so the swatch and hex
/// match what the eye sees. RGB mode only, and a no-op when no white is lit.
private func perceivedState(_ parsed: LightState, from result: [String: Any]) -> LightState {
/// Fold the bulb's white channels (`c`/`w`) back into the RGB we adopt. The
/// engine infers control state from r/g/b alone (model.js `parsePilot`), so a
/// colour the bulb renders with its white LEDs reads back wrong without this.
/// Two cases:
///
/// - **Equal `c`/`w`** — the signature of our own "Brighter colours" split
/// (model.js `rgbToWhiteMixed` always drives both equally): invert it
/// *exactly*, recovering the colour the user picked. This is what keeps
/// read-backs stable — folding the *perceived* colour into `state.rgb` and
/// later re-sending it washed every colour toward pure white (the drift
/// color.js explicitly warns about).
/// - **Uneven `c`/`w`** — a foreign sender (e.g. the phone app) weighted the
/// whites separately; that split can't be inverted, so adopt the engine's
/// perceived approximation once. Display and any further edits then both
/// start from what the eye sees, and the next read-back is a fixed point.
///
/// RGB mode only, and a no-op when no white is lit.
private func foldedState(_ parsed: LightState, from result: [String: Any]) -> LightState {
guard parsed.mode == .rgb else { return parsed }
let c = (result["c"] as? NSNumber)?.intValue ?? 0
let w = (result["w"] as? NSNumber)?.intValue ?? 0
guard c != 0 || w != 0 else { return parsed }
var next = parsed
next.rgb = core.perceivedRgb(parsed.rgb, c: c, w: w)
next.rgb =
core.whiteMixedToRgb(parsed.rgb, c: c, w: w)
?? core.perceivedRgb(parsed.rgb, c: c, w: w)
return next
}

Expand All @@ -674,7 +690,7 @@ final class AppState: ObservableObject {
self.scheduleNextPoll(after: Self.healthPollInterval)
// Preserve the user's last RGB if the bulb reports white mode (so
// flipping back to RGB restores their colour rather than white).
var next = self.perceivedState(parsed, from: result)
var next = self.foldedState(parsed, from: result)
if next.mode == .white { next.rgb = self.state.rgb }
// A running scene reports no colour; keep the last one so leaving it restores.
if next.scene != nil {
Expand Down Expand Up @@ -910,7 +926,7 @@ final class AppState: ObservableObject {
if Date().timeIntervalSince(self.lastLocalEdit) > 3,
let parsed = self.core.parsePilot(result)
{
var next = self.perceivedState(parsed, from: result)
var next = self.foldedState(parsed, from: result)
if next.mode == .white { next.rgb = self.state.rgb }
if next.scene != nil {
next.rgb = self.state.rgb
Expand Down Expand Up @@ -971,7 +987,7 @@ final class AppState: ObservableObject {
self.lastLocalEdit == editToken,
let result = result, let parsed = self.core.parsePilot(result)
else { return }
var next = self.perceivedState(parsed, from: result)
var next = self.foldedState(parsed, from: result)
if next.mode == .white { next.rgb = self.state.rgb }
if next.scene != nil {
next.rgb = self.state.rgb
Expand All @@ -992,7 +1008,7 @@ final class AppState: ObservableObject {
guard let client = client else { return }
lastLocalEdit = Date()
let params = core.buildSetPilotParams(state, bounds: deviceBounds(), whiteMix: whiteMix)
client.apply(state: state, params: params)
client.send(params)
if state.mode == .rgb { scheduleDeviceRgbSave() }
bump()
}
Expand Down Expand Up @@ -1095,13 +1111,17 @@ final class AppState: ObservableObject {
/// 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.
/// handler returns — a deferred send would never leave the machine. Any pending
/// debounced send is cancelled first (waiting it out if it's mid-flight), so a
/// colour edit from moments before the lid closed can't fire *after* the off
/// and turn the light back on. `sendNow` then 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.cancelPendingSync()
client.sendNow(["state": false])
state.on = false
bump()
Expand Down Expand Up @@ -1244,7 +1264,8 @@ final class AppState: ObservableObject {
if mac == selectedMac {
// Removed the light we're using: clear the selection + disconnect, and forget
// it as the last-used light, so nothing shows as selected or connected.
stores.saveLastIp("")
// (`clearLastIp`, not `saveLastIp("")` — the latter guards empty and no-ops.)
stores.clearLastIp()
selectLight(name: "", ip: "", mac: "", persistIp: false, connect: false)
}
bump()
Expand Down
12 changes: 7 additions & 5 deletions apps/macos/Sources/WizApp/DropdownContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ final class DropdownContentView: NSView {
color.gradientColors = app.state.mode == .rgb ? hueStops() : tempStops()
}
if let speed = speedSlider, !speed.isTracking {
speed.value = Double(app.state.scene?.speed ?? 50)
speed.value = Double(app.state.scene?.speed ?? 100)
}
sceneHeaderLabel?.stringValue =
app.availableScenes.first { $0.id == app.state.scene?.id }?.name ?? "Choose a scene"
Expand All @@ -485,7 +485,7 @@ final class DropdownContentView: NSView {
? "Colour (hue) — \(Int(app.hsv.h))°" : "Colour temperature — \(app.state.temp) K"
colorRow?.toolTip = colorTip
colorSlider?.toolTip = colorTip
let speedTip = "Scene speed — \(app.state.scene?.speed ?? 50)%"
let speedTip = "Scene speed — \(app.state.scene?.speed ?? 100)%"
speedRow?.toolTip = speedTip
speedSlider?.toolTip = speedTip
powerSwitch?.toolTip = app.state.on ? "On — tap to turn off" : "Off — tap to turn on"
Expand Down Expand Up @@ -751,9 +751,11 @@ final class DropdownContentView: NSView {

private func makeSpeedRow() -> NSView {
let slider = GradientSliderControl()
slider.minValue = 1
slider.maxValue = 100
slider.value = Double(app.state.scene?.speed ?? 50)
// The engine's firmware band (10–200, 100 = normal), so the slider can't
// drift from what clampSpeed enforces.
slider.minValue = Double(app.core.speedRange.lowerBound)
slider.maxValue = Double(app.core.speedRange.upperBound)
slider.value = Double(app.state.scene?.speed ?? 100)
// A progress track: accent-filled up to the thumb, grey after it.
slider.progressFill = (filled: .controlAccentColor, unfilled: Self.nsColor([90, 90, 90]))
slider.onEditing = { [weak self] v in self?.app.setSceneSpeed(Int(v.rounded())) }
Expand Down
16 changes: 11 additions & 5 deletions apps/macos/Sources/WizApp/Views/ColorWheelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,17 @@ final class WheelNSView: NSView {

let cs = CGColorSpaceCreateDeviceRGB()
let info = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
guard let ctx = CGContext(
data: &pixels, width: dim, height: dim, bitsPerComponent: 8, bytesPerRow: dim * 4,
space: cs, bitmapInfo: info.rawValue),
let cg = ctx.makeImage()
else { return NSImage(size: bounds.size) }
// Both the context and makeImage() must run inside withUnsafeMutableBytes:
// a pointer bridged via `&pixels` is only valid for the duration of that one
// call, so holding the context past it would be undefined behaviour.
let cg: CGImage? = pixels.withUnsafeMutableBytes { buf in
guard let ctx = CGContext(
data: buf.baseAddress, width: dim, height: dim, bitsPerComponent: 8,
bytesPerRow: dim * 4, space: cs, bitmapInfo: info.rawValue)
else { return nil }
return ctx.makeImage()
}
guard let cg = cg else { return NSImage(size: bounds.size) }
return NSImage(cgImage: cg, size: bounds.size)
}

Expand Down
4 changes: 3 additions & 1 deletion apps/macos/Sources/WizApp/Views/DiscoveryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ struct DiscoveryView: View {
.foregroundStyle(.secondary)
.listRowSeparator(.hidden)
}
ForEach(unsavedDiscovered, id: \.mac) { light in
// Identified by the whole value, not `\.mac` — replies without a MAC
// all carry `""` there, which would collide as ForEach ids.
ForEach(unsavedDiscovered, id: \.self) { light in
discoveredRow(light)
.listRowSeparator(.hidden)
}
Expand Down
9 changes: 6 additions & 3 deletions apps/macos/Sources/WizApp/Views/ScenesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,18 @@ struct SceneSpeedView: View {
Label("Speed", systemImage: "speedometer")
.font(.subheadline)
Spacer()
Text("\(app.state.scene?.speed ?? 50)%")
// 100% = normal; the bulb only reports a speed once one has been set.
Text("\(app.state.scene?.speed ?? 100)%")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
GradientSlider(
value: Binding(
get: { Double(app.state.scene?.speed ?? 50) },
get: { Double(app.state.scene?.speed ?? 100) },
set: { app.setSceneSpeed(Int($0.rounded())) }),
range: 1...100,
// The engine's firmware band (10–200), so the slider can't drift from
// what clampSpeed enforces.
range: Double(app.core.speedRange.lowerBound)...Double(app.core.speedRange.upperBound),
colors: [],
progressFill: (filled: .accentColor, unfilled: Color(rgb: [90, 90, 90])))
}
Expand Down
9 changes: 7 additions & 2 deletions apps/macos/Sources/WizKit/Discovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import Foundation
/// netmask via `getifaddrs`), which materially improves the hit rate.
public enum Discovery {
/// A discovered bulb. `name` is the module name, falling back to the MAC.
public struct Light: Equatable {
/// `mac` is `""` when the reply omitted one. Hashable so lists can identify
/// rows by the whole value (two MAC-less bulbs would collide on `mac` alone).
public struct Light: Equatable, Hashable {
public let name: String
public let ip: String
public let mac: String
Expand Down Expand Up @@ -139,7 +141,10 @@ public enum Discovery {
while let cur = ptr {
defer { ptr = cur.pointee.ifa_next }
let flags = Int32(cur.pointee.ifa_flags)
guard (flags & IFF_UP) != 0, (flags & IFF_LOOPBACK) == 0,
// IFF_BROADCAST: only interfaces that actually have a broadcast address.
// A point-to-point link (utun VPNs etc.) has a /32 mask, so addr | ~mask
// would just unicast the probe back at our own address.
guard (flags & IFF_UP) != 0, (flags & IFF_LOOPBACK) == 0, (flags & IFF_BROADCAST) != 0,
let sa = cur.pointee.ifa_addr, sa.pointee.sa_family == sa_family_t(AF_INET),
let nm = cur.pointee.ifa_netmask
else { continue }
Expand Down
13 changes: 10 additions & 3 deletions apps/macos/Sources/WizKit/Resources/wiz-core.global.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 19 additions & 7 deletions apps/macos/Sources/WizKit/Stores.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import Foundation

/// Persistence layer, sharing the exact on-disk schema with the wiz-light-core CLI so
/// the two interoperate against one `~/Library/Application Support/
/// WizLightController` directory. Reads are corruption-tolerant (missing or
/// malformed → defaults); writes are atomic (temp file + `replaceItemAt`) so a
/// crash mid-write can't truncate good data.
/// Persistence layer, using the exact on-disk schema of the wiz-light-core CLI
/// stores. **Note on location:** the released app runs under the App Sandbox, so
/// `.applicationSupportDirectory` resolves *inside its container*
/// (`~/Library/Containers/com.wizlightcontroller.app/Data/Library/Application
/// Support/WizLightController`) — not the CLI's `~/Library/Application
/// Support/WizLightController`. The two tools therefore keep the same format but
/// each owns its own copy; only an unsandboxed dev run (`swift run` /
/// `swift test`, no entitlements applied) lands in the CLI's directory. Reads
/// are corruption-tolerant (missing or malformed → defaults); writes are atomic
/// (temp file + `replaceItemAt`) so a crash mid-write can't truncate good data.
///
/// File map (mirrors `wiz-light-core` stores):
/// - `settings.json` `{accent, highlight, autoSync}`
Expand Down Expand Up @@ -153,6 +158,13 @@ public final class Stores {
writeText(trimmed, to: lastIpURL)
}

/// Forget the remembered last-used IP (e.g. the selected light was removed).
/// `saveLastIp("")` can't do this — it guards against empty writes — so this
/// removes the file instead.
public func clearLastIp() {
try? FileManager.default.removeItem(at: lastIpURL)
}

/// The last colour remembered for `mac`, or `nil` when unknown — so the wheel
/// can open on this specific light's colour and a white→RGB flip restores it.
public func loadDeviceRgb(_ mac: String) -> [Int]? {
Expand All @@ -177,8 +189,8 @@ public final class Stores {
/// The bulb's identity (MAC / model / firmware) remembered for a host, so the
/// menu header and Settings → Device can show it on launch — before, or
/// without, a live connection. Stored alongside its IP and returned only when
/// the IP still matches, so an identity left over from a different address (the
/// IP changed, or the CLI repointed `last_ip`) is ignored rather than shown
/// the IP still matches, so an identity left over from a different address
/// (the IP changed, or `last_ip` was repointed) is ignored rather than shown
/// against the wrong bulb.
public func loadLastDevice(
forIp ip: String
Expand Down
Loading