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/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 ded1cc8..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 }
@@ -100,13 +101,32 @@ 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()
+ 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() }
@@ -164,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() {
@@ -182,39 +208,49 @@ final class MenuController: NSObject, NSMenuDelegate {
}
switch status.ownership {
- case .normal:
- addAction("Liddddd: Off — Start", action: #selector(startSession))
- 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))
- if let endDate = status.endDate {
- addStatus("Time left: \(remainingTime(until: endDate))")
- }
- if let battery = status.batteryPercent, let floor = status.batteryFloor {
- addStatus("Battery: \(battery)% · stops at \(floor)%")
- }
- if let temperature = status.temperatureCelsius {
- addStatus(
- String(
- format: "Temperature: %.1f°C · stops at %.0f°C",
- temperature,
- LidddddConstants.temperatureCutoffCelsius
- ),
- warning: temperature >= LidddddConstants.temperatureCutoffCelsius - 5
- )
- } else {
- addStatus("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)
@@ -229,6 +265,23 @@ final class MenuController: NSObject, NSMenuDelegate {
}
}
+ 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: "")
+ let view = SessionToggleMenuItemView(
+ isActive: isActive,
+ accessibilityLabel: accessibilityLabel,
+ target: self,
+ action: action
+ )
+ item.view = view
+ return (item, view)
+ }
+
private func buildHelperUpdateMenu(message: String) {
addHeader("Liddddd Needs an Update")
addStatus(message, warning: true)
@@ -295,6 +348,26 @@ 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())
+ }
+
let about = NSMenuItem(
title: "About Liddddd",
action: #selector(showAbout),
@@ -340,22 +413,193 @@ 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
+ ) {
+ 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(
+ systemSymbolName: systemSymbolName,
+ accessibilityDescription: accessibilityLabel
+ )
+ {
+ image.isTemplate = true
+ item.image = image
+ } else {
+ item.image = nil
+ }
+ item.setAccessibilityLabel(accessibilityLabel)
if warning {
item.attributedTitle = NSAttributedString(
string: title,
attributes: [.foregroundColor: NSColor.systemOrange]
)
+ } else {
+ item.attributedTitle = nil
+ }
+ }
+
+ 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"
}
- menu.addItem(item)
}
- 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 {
@@ -407,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
@@ -428,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) }
}
@@ -620,7 +866,140 @@ 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,
+ 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)
+ self.control = control
+
+ 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)
+ }
+
+ 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")
+ }
+}
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