Skip to content
Closed
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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>4</string>
<string>5</string>
<key>LSMinimumSystemVersion</key>
<string>15.0</string>
<key>NSHighResolutionCapable</key>
Expand Down
50 changes: 37 additions & 13 deletions Sources/LidddddApp/HelperClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,33 @@ import LidddddCore
final class HelperClient: @unchecked Sendable {
typealias Completion = @MainActor @Sendable (Result<HelperReply, Error>) -> 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<HelperReply, Error>) {
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)
}
Expand Down Expand Up @@ -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."
}
}
}
14 changes: 14 additions & 0 deletions Sources/LidddddApp/HelperInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
135 changes: 126 additions & 9 deletions Sources/LidddddApp/MenuController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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")
}
}
3 changes: 0 additions & 3 deletions deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down