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..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()
@@ -183,36 +185,48 @@ 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))")
}
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:
@@ -229,6 +243,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 +325,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),
@@ -340,9 +375,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,
@@ -352,6 +404,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
@@ -624,3 +686,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