From 3e32c5461a111a587ce77c2b9bc471849327fba6 Mon Sep 17 00:00:00 2001 From: Jaewon Lee Date: Tue, 25 Aug 2026 01:52:42 +0900 Subject: [PATCH 1/4] feat: polish menu controls for build 5 --- README.md | 7 ++- Resources/Info.plist | 2 +- Sources/LidddddApp/MenuController.swift | 82 +++++++++++++++++++++++-- deployment.md | 3 - 4 files changed, 83 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2399bc5..d79e450 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,10 @@ closed. Super light & super useful. ## Remove -Before deleting the app, choose **Advanced → Remove Sleep Control…** to remove -the privileged helper and its saved session state. You can then quit Liddddd -and move `Liddddd.app` to the Trash. +Before deleting the app, choose +**Liddddd → Advanced → Remove Sleep Control…** to remove the privileged helper +and its saved session state. You can then quit Liddddd and move +`Liddddd.app` to the Trash. ## Privacy and security diff --git a/Resources/Info.plist b/Resources/Info.plist index 56b3f07..baa8081 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.1.0 CFBundleVersion - 4 + 5 LSMinimumSystemVersion 15.0 NSHighResolutionCapable diff --git a/Sources/LidddddApp/MenuController.swift b/Sources/LidddddApp/MenuController.swift index ded1cc8..675d3d4 100644 --- a/Sources/LidddddApp/MenuController.swift +++ b/Sources/LidddddApp/MenuController.swift @@ -183,19 +183,17 @@ final class MenuController: NSObject, NSMenuDelegate { switch status.ownership { case .normal: - addAction("Liddddd: Off — Start", action: #selector(startSession)) + menu.addItem(sessionToggleMenuItem(isActive: false)) menu.addItem(.separator()) menu.addItem(durationMenuItem()) menu.addItem(batteryFloorMenuItem()) - addStatus("Stops automatically if your Mac gets too hot") if let reason = status.lastStopReason { menu.addItem(.separator()) addStatus("Last stopped: \(stopReasonText(reason))") } menu.addItem(.separator()) - menu.addItem(systemHelperMenuItem()) case .managed: - addAction("Liddddd: On — Stop", action: #selector(stopSession)) + menu.addItem(sessionToggleMenuItem(isActive: true)) if let endDate = status.endDate { addStatus("Time left: \(remainingTime(until: endDate))") } @@ -229,6 +227,22 @@ final class MenuController: NSObject, NSMenuDelegate { } } + private func sessionToggleMenuItem(isActive: Bool) -> NSMenuItem { + let action = isActive ? #selector(stopSession) : #selector(startSession) + let accessibilityLabel = + isActive + ? "Pause Liddddd and restore normal Mac sleep" + : "Start Liddddd" + let item = NSMenuItem(title: "Liddddd", action: nil, keyEquivalent: "") + item.view = SessionToggleMenuItemView( + isActive: isActive, + accessibilityLabel: accessibilityLabel, + target: self, + action: action + ) + return item + } + private func buildHelperUpdateMenu(message: String) { addHeader("Liddddd Needs an Update") addStatus(message, warning: true) @@ -295,6 +309,11 @@ final class MenuController: NSObject, NSMenuDelegate { let item = NSMenuItem(title: LidddddConstants.productName, action: nil, keyEquivalent: "") let submenu = NSMenu(title: LidddddConstants.productName) + if currentStatus?.ownership == .normal { + submenu.addItem(systemHelperMenuItem()) + submenu.addItem(.separator()) + } + let about = NSMenuItem( title: "About Liddddd", action: #selector(showAbout), @@ -624,3 +643,58 @@ final class MenuController: NSObject, NSMenuDelegate { } } + +private final class SessionToggleMenuItemView: NSView { + init( + isActive: Bool, + accessibilityLabel: String, + target: AnyObject?, + action: Selector + ) { + let label = NSTextField(labelWithString: "Liddddd") + label.font = .menuFont(ofSize: NSFont.systemFontSize) + label.textColor = .labelColor + label.sizeToFit() + + let controlTitle = isActive ? "Pause Liddddd" : "Start Liddddd" + let symbolName = isActive ? "pause.fill" : "play.fill" + let icon = NSImage( + systemSymbolName: symbolName, + accessibilityDescription: controlTitle + )! + icon.isTemplate = true + + let control = NSButton(image: icon, target: target, action: action) + control.isBordered = false + control.imageScaling = .scaleProportionallyDown + control.contentTintColor = .labelColor + control.toolTip = controlTitle + control.setAccessibilityLabel(accessibilityLabel) + + let horizontalPadding: CGFloat = 16 + let controlSize = NSSize(width: 24, height: 20) + let height: CGFloat = 28 + let controlOrigin = NSPoint( + x: horizontalPadding + label.frame.width + 8, + y: (height - controlSize.height) / 2 + ) + super.init( + frame: NSRect( + x: 0, + y: 0, + width: controlOrigin.x + controlSize.width + horizontalPadding, + height: height + ) + ) + + label.frame.origin = NSPoint(x: horizontalPadding, y: (height - label.frame.height) / 2) + control.frame = NSRect(origin: controlOrigin, size: controlSize) + addSubview(label) + addSubview(control) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/deployment.md b/deployment.md index a3f9c83..75b76e2 100644 --- a/deployment.md +++ b/deployment.md @@ -21,9 +21,6 @@ plutil -lint Resources/*.plist zsh -n scripts/*.sh Resources/manage-local-helper.sh ``` -GitHub Actions runs the same formatting, test, resource, script, app-bundle, -and ad-hoc code-signing checks for pushes and pull requests. - ## Local development build ```bash From 170b341ac7d5e306b69c322f15d571a87b5528eb Mon Sep 17 00:00:00 2001 From: Jaewon Lee Date: Tue, 25 Aug 2026 21:20:24 +0900 Subject: [PATCH 2/4] ui: use icons for battery and temperature status --- Sources/LidddddApp/MenuController.swift | 51 ++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/Sources/LidddddApp/MenuController.swift b/Sources/LidddddApp/MenuController.swift index 675d3d4..105bcb3 100644 --- a/Sources/LidddddApp/MenuController.swift +++ b/Sources/LidddddApp/MenuController.swift @@ -198,19 +198,33 @@ final class MenuController: NSObject, NSMenuDelegate { addStatus("Time left: \(remainingTime(until: endDate))") } if let battery = status.batteryPercent, let floor = status.batteryFloor { - addStatus("Battery: \(battery)% · stops at \(floor)%") + addStatus( + "\(battery)% · stops at \(floor)%", + systemSymbolName: batterySymbolName(for: battery), + accessibilityLabel: "Battery: \(battery)%. Stops at \(floor)%." + ) } if let temperature = status.temperatureCelsius { addStatus( String( - format: "Temperature: %.1f°C · stops at %.0f°C", + format: "%.1f°C · stops at %.0f°C", temperature, LidddddConstants.temperatureCutoffCelsius ), - warning: temperature >= LidddddConstants.temperatureCutoffCelsius - 5 + warning: temperature >= LidddddConstants.temperatureCutoffCelsius - 5, + systemSymbolName: "thermometer.medium", + accessibilityLabel: String( + format: "Temperature: %.1f°C. Stops at %.0f°C.", + temperature, + LidddddConstants.temperatureCutoffCelsius + ) ) } else { - addStatus("Temperature unavailable · stops if your Mac gets too hot") + addStatus( + "Unavailable · stops if your Mac gets too hot", + systemSymbolName: "thermometer.medium", + accessibilityLabel: "Temperature unavailable. Stops if your Mac gets too hot." + ) } addStatus("Keep your Mac uncovered while Liddddd is on.", warning: true) case .external: @@ -359,9 +373,26 @@ final class MenuController: NSObject, NSMenuDelegate { menu.addItem(item) } - private func addStatus(_ title: String, warning: Bool = false) { + private func addStatus( + _ title: String, + warning: Bool = false, + systemSymbolName: String? = nil, + accessibilityLabel: String? = nil + ) { let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") item.isEnabled = false + if let systemSymbolName, + let image = NSImage( + systemSymbolName: systemSymbolName, + accessibilityDescription: accessibilityLabel + ) + { + image.isTemplate = true + item.image = image + } + if let accessibilityLabel { + item.setAccessibilityLabel(accessibilityLabel) + } if warning { item.attributedTitle = NSAttributedString( string: title, @@ -371,6 +402,16 @@ final class MenuController: NSObject, NSMenuDelegate { menu.addItem(item) } + private func batterySymbolName(for percent: Int) -> String { + switch percent { + case ..<13: "battery.0percent" + case ..<38: "battery.25percent" + case ..<63: "battery.50percent" + case ..<88: "battery.75percent" + default: "battery.100percent" + } + } + private func addAction(_ title: String, action: Selector) { let item = NSMenuItem(title: title, action: action, keyEquivalent: "") item.target = self From 9c58d9662f0c7518d1b1e05d3ee75551b378c693 Mon Sep 17 00:00:00 2001 From: Jaewon Lee Date: Tue, 25 Aug 2026 23:47:01 +0900 Subject: [PATCH 3/4] fix: recover when sleep control is unreachable --- Sources/LidddddApp/HelperClient.swift | 50 ++++++++++++++++++------ Sources/LidddddApp/HelperInstaller.swift | 14 +++++++ Sources/LidddddApp/MenuController.swift | 2 + 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/Sources/LidddddApp/HelperClient.swift b/Sources/LidddddApp/HelperClient.swift index 4bb7175..ad2e66d 100644 --- a/Sources/LidddddApp/HelperClient.swift +++ b/Sources/LidddddApp/HelperClient.swift @@ -4,14 +4,33 @@ import LidddddCore final class HelperClient: @unchecked Sendable { typealias Completion = @MainActor @Sendable (Result) -> Void - private final class ConnectionBox: @unchecked Sendable { - let connection: NSXPCConnection + private final class CompletionGate: @unchecked Sendable { + private let lock = NSLock() + private var didFinish = false + private let connection: NSXPCConnection + private let completion: Completion - init(_ connection: NSXPCConnection) { + init(connection: NSXPCConnection, completion: @escaping Completion) { self.connection = connection + self.completion = completion + } + + func finish(_ result: Result) { + lock.lock() + guard !didFinish else { + lock.unlock() + return + } + didFinish = true + lock.unlock() + + connection.invalidate() + Task { @MainActor in completion(result) } } } + private let requestTimeout: TimeInterval = 5 + func status(completion: @escaping Completion) { request({ proxy, reply in proxy.status(withReply: reply) }, completion: completion) } @@ -55,36 +74,41 @@ final class HelperClient: @unchecked Sendable { ) connection.remoteObjectInterface = NSXPCInterface(with: LidddddHelperProtocol.self) connection.activate() - let connectionBox = ConnectionBox(connection) + let completionGate = CompletionGate(connection: connection, completion: completion) let errorHandler: @Sendable (Error) -> Void = { error in - connectionBox.connection.invalidate() - Task { @MainActor in completion(.failure(error)) } + completionGate.finish(.failure(error)) } guard let proxy = connection.remoteObjectProxyWithErrorHandler(errorHandler) as? LidddddHelperProtocol else { - connection.invalidate() - Task { @MainActor in - completion(.failure(HelperClientError.invalidProxy)) - } + completionGate.finish(.failure(HelperClientError.invalidProxy)) return } operation(proxy) { data in - connectionBox.connection.invalidate() let result = Result { try HelperCodec.decode(data) } - Task { @MainActor in completion(result) } + completionGate.finish(result) + } + + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + requestTimeout) { + completionGate.finish(.failure(HelperClientError.timedOut)) } } } enum HelperClientError: LocalizedError { case invalidProxy + case timedOut var errorDescription: String? { - "Liddddd could not reach its sleep control." + switch self { + case .invalidProxy: + "Liddddd could not reach its sleep control." + case .timedOut: + "Liddddd's sleep control did not respond." + } } } diff --git a/Sources/LidddddApp/HelperInstaller.swift b/Sources/LidddddApp/HelperInstaller.swift index 7a7e4ab..b204ab2 100644 --- a/Sources/LidddddApp/HelperInstaller.swift +++ b/Sources/LidddddApp/HelperInstaller.swift @@ -62,6 +62,17 @@ final class HelperInstaller { return availability(for: service.status) } + func availabilityAfterConnectionFailure() -> HelperAvailability { + if legacyFilesExist { + return .localHelperRepairRequired + } + #if LIDDDDD_ALLOW_ADHOC + return .localHelperInstallRequired + #else + return .localHelperRepairRequired + #endif + } + func installApplication() throws -> URL { let sourceURL = Bundle.main.bundleURL.standardizedFileURL guard sourceURL.pathExtension == "app" else { @@ -114,6 +125,9 @@ final class HelperInstaller { guard !legacyFilesExist else { throw HelperInstallerError.partialOrExistingHelper } + if service.status != .notRegistered { + try service.unregister() + } try runLocalHelperManager(action: "install") #else throw HelperInstallerError.localInstallUnavailable diff --git a/Sources/LidddddApp/MenuController.swift b/Sources/LidddddApp/MenuController.swift index 105bcb3..d6e6cce 100644 --- a/Sources/LidddddApp/MenuController.swift +++ b/Sources/LidddddApp/MenuController.swift @@ -100,6 +100,8 @@ final class MenuController: NSObject, NSMenuDelegate { self.lastError = reply.success ? nil : reply.message } case .failure(let error): + self.currentStatus = nil + self.helperAvailability = self.helperInstaller.availabilityAfterConnectionFailure() self.lastError = error.localizedDescription } self.rebuildMenu() From e6ec267fce19760d5e85403d1464c529b4a96db4 Mon Sep 17 00:00:00 2001 From: Jaewon Lee Date: Wed, 26 Aug 2026 00:30:28 +0900 Subject: [PATCH 4/4] fix: refresh the open session menu in place Keep reusable session menu items mounted so AppKit can update the currently open menu without requiring it to close and reopen. Update the Start/Pause control, status rows, preferences, errors, info submenu, and quit action in place. Disable the toggle while the helper request is pending, and move the uncovered warning into the Liddddd submenu. --- Sources/LidddddApp/MenuController.swift | 380 ++++++++++++++++++++---- 1 file changed, 321 insertions(+), 59 deletions(-) diff --git a/Sources/LidddddApp/MenuController.swift b/Sources/LidddddApp/MenuController.swift index d6e6cce..f1bc53a 100644 --- a/Sources/LidddddApp/MenuController.swift +++ b/Sources/LidddddApp/MenuController.swift @@ -15,6 +15,7 @@ final class MenuController: NSObject, NSMenuDelegate { private var installedHelperVersion: String? private var confirmingHelperRemoval = false private var isMenuOpen = false + private var readyMenuItems: ReadyMenuItems? private var selectedDuration: TimeInterval { get { AppPreferences.duration } @@ -104,11 +105,28 @@ final class MenuController: NSObject, NSMenuDelegate { self.helperAvailability = self.helperInstaller.availabilityAfterConnectionFailure() self.lastError = error.localizedDescription } - self.rebuildMenu() + self.updateMenuAfterStateChange() } } + private func updateMenuAfterStateChange() { + guard + isMenuOpen, + helperAvailability == .ready, + helperUpdateMessage == nil, + let status = currentStatus, + readyMenuItems != nil, + status.ownership == .normal || status.ownership == .managed + else { + rebuildMenu() + return + } + + renderReadyState(status) + } + private func rebuildMenu() { + readyMenuItems = nil menu.removeAllItems() defer { updateStatusItemAppearance() } @@ -166,14 +184,20 @@ final class MenuController: NSObject, NSMenuDelegate { addAction("Try Setup Again", action: #selector(retryHelperSetup)) } - if let lastError { + if let lastError, readyMenuItems == nil { menu.addItem(.separator()) addStatus(lastError, warning: true) } menu.addItem(.separator()) - menu.addItem(informationMenuItem()) + let informationItem = informationMenuItem() + menu.addItem(informationItem) + readyMenuItems?.informationItem = informationItem menu.addItem(.separator()) - addAction(currentStatus?.isActive == true ? "Quit and Stop" : "Quit", action: #selector(quit)) + let quitItem = addAction( + currentStatus?.isActive == true ? "Quit and Stop" : "Quit", + action: #selector(quit) + ) + readyMenuItems?.quitItem = quitItem } private func buildReadyMenu() { @@ -184,51 +208,49 @@ final class MenuController: NSObject, NSMenuDelegate { } switch status.ownership { - case .normal: - menu.addItem(sessionToggleMenuItem(isActive: false)) - menu.addItem(.separator()) - menu.addItem(durationMenuItem()) - menu.addItem(batteryFloorMenuItem()) - if let reason = status.lastStopReason { - menu.addItem(.separator()) - addStatus("Last stopped: \(stopReasonText(reason))") - } - menu.addItem(.separator()) - case .managed: - menu.addItem(sessionToggleMenuItem(isActive: true)) - if let endDate = status.endDate { - addStatus("Time left: \(remainingTime(until: endDate))") - } - if let battery = status.batteryPercent, let floor = status.batteryFloor { - addStatus( - "\(battery)% · stops at \(floor)%", - systemSymbolName: batterySymbolName(for: battery), - accessibilityLabel: "Battery: \(battery)%. Stops at \(floor)%." - ) - } - if let temperature = status.temperatureCelsius { - addStatus( - String( - format: "%.1f°C · stops at %.0f°C", - temperature, - LidddddConstants.temperatureCutoffCelsius - ), - warning: temperature >= LidddddConstants.temperatureCutoffCelsius - 5, - systemSymbolName: "thermometer.medium", - accessibilityLabel: String( - format: "Temperature: %.1f°C. Stops at %.0f°C.", - temperature, - LidddddConstants.temperatureCutoffCelsius - ) - ) - } else { - addStatus( - "Unavailable · stops if your Mac gets too hot", - systemSymbolName: "thermometer.medium", - accessibilityLabel: "Temperature unavailable. Stops if your Mac gets too hot." - ) + case .normal, .managed: + let (toggleItem, toggleView) = sessionToggleMenuItem(isActive: status.isActive) + let timeItem = statusMenuItem("") + let batteryItem = statusMenuItem("") + let temperatureItem = statusMenuItem("") + let settingsSeparator = NSMenuItem.separator() + let durationItem = durationMenuItem() + let batteryFloorItem = batteryFloorMenuItem() + let lastStopSeparator = NSMenuItem.separator() + let lastStopItem = statusMenuItem("") + let errorSeparator = NSMenuItem.separator() + let errorItem = statusMenuItem("") + + for item in [ + toggleItem, + timeItem, + batteryItem, + temperatureItem, + settingsSeparator, + durationItem, + batteryFloorItem, + lastStopSeparator, + lastStopItem, + errorSeparator, + errorItem, + ] { + menu.addItem(item) } - addStatus("Keep your Mac uncovered while Liddddd is on.", warning: true) + + readyMenuItems = ReadyMenuItems( + toggleView: toggleView, + timeItem: timeItem, + batteryItem: batteryItem, + temperatureItem: temperatureItem, + settingsSeparator: settingsSeparator, + durationItem: durationItem, + batteryFloorItem: batteryFloorItem, + lastStopSeparator: lastStopSeparator, + lastStopItem: lastStopItem, + errorSeparator: errorSeparator, + errorItem: errorItem + ) + renderReadyState(status) case .external: addHeader("Liddddd Cannot Start") addStatus("Another app or command already turned off Mac sleep.", warning: true) @@ -243,20 +265,21 @@ final class MenuController: NSObject, NSMenuDelegate { } } - private func sessionToggleMenuItem(isActive: Bool) -> NSMenuItem { + private func sessionToggleMenuItem(isActive: Bool) -> (NSMenuItem, SessionToggleMenuItemView) { let action = isActive ? #selector(stopSession) : #selector(startSession) let accessibilityLabel = isActive ? "Pause Liddddd and restore normal Mac sleep" : "Start Liddddd" let item = NSMenuItem(title: "Liddddd", action: nil, keyEquivalent: "") - item.view = SessionToggleMenuItemView( + let view = SessionToggleMenuItemView( isActive: isActive, accessibilityLabel: accessibilityLabel, target: self, action: action ) - return item + item.view = view + return (item, view) } private func buildHelperUpdateMenu(message: String) { @@ -325,6 +348,21 @@ final class MenuController: NSObject, NSMenuDelegate { let item = NSMenuItem(title: LidddddConstants.productName, action: nil, keyEquivalent: "") let submenu = NSMenu(title: LidddddConstants.productName) + if currentStatus?.ownership == .managed { + let uncovered = NSMenuItem( + title: "Keep your Mac uncovered while Liddddd is on.", + action: nil, + keyEquivalent: "" + ) + uncovered.isEnabled = false + uncovered.attributedTitle = NSAttributedString( + string: uncovered.title, + attributes: [.foregroundColor: NSColor.systemOrange] + ) + submenu.addItem(uncovered) + submenu.addItem(.separator()) + } + if currentStatus?.ownership == .normal { submenu.addItem(systemHelperMenuItem()) submenu.addItem(.separator()) @@ -381,7 +419,40 @@ final class MenuController: NSObject, NSMenuDelegate { systemSymbolName: String? = nil, accessibilityLabel: String? = nil ) { + menu.addItem( + statusMenuItem( + title, + warning: warning, + systemSymbolName: systemSymbolName, + accessibilityLabel: accessibilityLabel + )) + } + + private func statusMenuItem( + _ title: String, + warning: Bool = false, + systemSymbolName: String? = nil, + accessibilityLabel: String? = nil + ) -> NSMenuItem { let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + configureStatusItem( + item, + title: title, + warning: warning, + systemSymbolName: systemSymbolName, + accessibilityLabel: accessibilityLabel + ) + return item + } + + private func configureStatusItem( + _ item: NSMenuItem, + title: String, + warning: Bool = false, + systemSymbolName: String? = nil, + accessibilityLabel: String? = nil + ) { + item.title = title item.isEnabled = false if let systemSymbolName, let image = NSImage( @@ -391,17 +462,18 @@ final class MenuController: NSObject, NSMenuDelegate { { image.isTemplate = true item.image = image + } else { + item.image = nil } - if let accessibilityLabel { - item.setAccessibilityLabel(accessibilityLabel) - } + item.setAccessibilityLabel(accessibilityLabel) if warning { item.attributedTitle = NSAttributedString( string: title, attributes: [.foregroundColor: NSColor.systemOrange] ) + } else { + item.attributedTitle = nil } - menu.addItem(item) } private func batterySymbolName(for percent: Int) -> String { @@ -414,10 +486,120 @@ final class MenuController: NSObject, NSMenuDelegate { } } - private func addAction(_ title: String, action: Selector) { + @discardableResult + private func addAction(_ title: String, action: Selector) -> NSMenuItem { let item = NSMenuItem(title: title, action: action, keyEquivalent: "") item.target = self menu.addItem(item) + return item + } + + private func renderReadyState(_ status: HelperStatus) { + guard let items = readyMenuItems else { return } + + let isActive = status.ownership == .managed + let action = isActive ? #selector(stopSession) : #selector(startSession) + let accessibilityLabel = + isActive + ? "Pause Liddddd and restore normal Mac sleep" + : "Start Liddddd" + items.toggleView.update( + isActive: isActive, + isPending: false, + accessibilityLabel: accessibilityLabel, + action: action + ) + + if let endDate = status.endDate, isActive { + configureStatusItem(items.timeItem, title: "Time left: \(remainingTime(until: endDate))") + items.timeItem.isHidden = false + } else { + items.timeItem.isHidden = true + } + + if let battery = status.batteryPercent, let floor = status.batteryFloor, isActive { + configureStatusItem( + items.batteryItem, + title: "\(battery)% · stops at \(floor)%", + systemSymbolName: batterySymbolName(for: battery), + accessibilityLabel: "Battery: \(battery)%. Stops at \(floor)%." + ) + items.batteryItem.isHidden = false + } else { + items.batteryItem.isHidden = true + } + + if isActive { + if let temperature = status.temperatureCelsius { + configureStatusItem( + items.temperatureItem, + title: String( + format: "%.1f°C · stops at %.0f°C", + temperature, + LidddddConstants.temperatureCutoffCelsius + ), + warning: temperature >= LidddddConstants.temperatureCutoffCelsius - 5, + systemSymbolName: "thermometer.medium", + accessibilityLabel: String( + format: "Temperature: %.1f°C. Stops at %.0f°C.", + temperature, + LidddddConstants.temperatureCutoffCelsius + ) + ) + } else { + configureStatusItem( + items.temperatureItem, + title: "Unavailable · stops if your Mac gets too hot", + systemSymbolName: "thermometer.medium", + accessibilityLabel: "Temperature unavailable. Stops if your Mac gets too hot." + ) + } + } + items.temperatureItem.isHidden = !isActive + + items.settingsSeparator.isHidden = isActive + items.durationItem.isHidden = isActive + items.batteryFloorItem.isHidden = isActive + + let lastStopReason = isActive ? nil : status.lastStopReason + if let lastStopReason { + configureStatusItem( + items.lastStopItem, + title: "Last stopped: \(stopReasonText(lastStopReason))" + ) + } + items.lastStopSeparator.isHidden = lastStopReason == nil + items.lastStopItem.isHidden = lastStopReason == nil + + if let lastError { + configureStatusItem(items.errorItem, title: lastError, warning: true) + items.errorSeparator.isHidden = false + items.errorItem.isHidden = false + } else { + items.errorSeparator.isHidden = true + items.errorItem.isHidden = true + } + + items.informationItem?.submenu = informationMenuItem().submenu + items.quitItem?.title = isActive ? "Quit and Stop" : "Quit" + updateStatusItemAppearance() + } + + private func updateReadyPreferenceItems() { + guard let items = readyMenuItems else { + rebuildMenu() + return + } + + items.durationItem.title = "Stop after: \(durationText(selectedDuration))" + for item in items.durationItem.submenu?.items ?? [] { + item.state = selectedDuration == TimeInterval(item.tag * 60) ? .on : .off + } + + items.batteryFloorItem.title = "Stop at battery: \(selectedBatteryFloor)%" + for item in items.batteryFloorItem.submenu?.items ?? [] { + item.state = selectedBatteryFloor == item.tag ? .on : .off + } } private func remainingTime(until endDate: Date) -> String { @@ -469,16 +651,17 @@ final class MenuController: NSObject, NSMenuDelegate { @objc private func selectDuration(_ sender: NSMenuItem) { selectedDuration = TimeInterval(sender.tag * 60) - rebuildMenu() + updateReadyPreferenceItems() } @objc private func selectBatteryFloor(_ sender: NSMenuItem) { selectedBatteryFloor = sender.tag - rebuildMenu() + updateReadyPreferenceItems() } @objc private func startSession() { lastError = nil + readyMenuItems?.toggleView.setPending(true) helperClient.start( duration: selectedDuration, batteryFloor: selectedBatteryFloor @@ -490,6 +673,7 @@ final class MenuController: NSObject, NSMenuDelegate { @objc private func stopSession() { lastError = nil + readyMenuItems?.toggleView.setPending(true) helperClient.stop { [weak self] result in self?.apply(result) } } @@ -682,12 +866,56 @@ final class MenuController: NSObject, NSMenuDelegate { case .failure(let error): lastError = error.localizedDescription } - rebuildMenu() + updateMenuAfterStateChange() } } +private final class ReadyMenuItems { + let toggleView: SessionToggleMenuItemView + let timeItem: NSMenuItem + let batteryItem: NSMenuItem + let temperatureItem: NSMenuItem + let settingsSeparator: NSMenuItem + let durationItem: NSMenuItem + let batteryFloorItem: NSMenuItem + let lastStopSeparator: NSMenuItem + let lastStopItem: NSMenuItem + let errorSeparator: NSMenuItem + let errorItem: NSMenuItem + var informationItem: NSMenuItem? + var quitItem: NSMenuItem? + + init( + toggleView: SessionToggleMenuItemView, + timeItem: NSMenuItem, + batteryItem: NSMenuItem, + temperatureItem: NSMenuItem, + settingsSeparator: NSMenuItem, + durationItem: NSMenuItem, + batteryFloorItem: NSMenuItem, + lastStopSeparator: NSMenuItem, + lastStopItem: NSMenuItem, + errorSeparator: NSMenuItem, + errorItem: NSMenuItem + ) { + self.toggleView = toggleView + self.timeItem = timeItem + self.batteryItem = batteryItem + self.temperatureItem = temperatureItem + self.settingsSeparator = settingsSeparator + self.durationItem = durationItem + self.batteryFloorItem = batteryFloorItem + self.lastStopSeparator = lastStopSeparator + self.lastStopItem = lastStopItem + self.errorSeparator = errorSeparator + self.errorItem = errorItem + } +} + private final class SessionToggleMenuItemView: NSView { + private let control: NSButton + init( isActive: Bool, accessibilityLabel: String, @@ -713,6 +941,7 @@ private final class SessionToggleMenuItemView: NSView { control.contentTintColor = .labelColor control.toolTip = controlTitle control.setAccessibilityLabel(accessibilityLabel) + self.control = control let horizontalPadding: CGFloat = 16 let controlSize = NSSize(width: 24, height: 20) @@ -736,6 +965,39 @@ private final class SessionToggleMenuItemView: NSView { addSubview(control) } + func update( + isActive: Bool, + isPending: Bool, + accessibilityLabel: String, + action: Selector + ) { + let controlTitle = isActive ? "Pause Liddddd" : "Start Liddddd" + let symbolName = isActive ? "pause.fill" : "play.fill" + guard + let icon = NSImage( + systemSymbolName: symbolName, + accessibilityDescription: controlTitle + ) + else { return } + + icon.isTemplate = true + control.image = icon + control.action = action + control.isEnabled = !isPending + control.alphaValue = isPending ? 0.5 : 1 + control.toolTip = isPending ? "Please wait…" : controlTitle + control.setAccessibilityLabel( + isPending ? "\(accessibilityLabel). Please wait." : accessibilityLabel) + } + + func setPending(_ isPending: Bool) { + control.isEnabled = !isPending + control.alphaValue = isPending ? 0.5 : 1 + if isPending { + control.toolTip = "Please wait…" + } + } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented")