From 3e32c5461a111a587ce77c2b9bc471849327fba6 Mon Sep 17 00:00:00 2001 From: Jaewon Lee Date: Tue, 25 Aug 2026 01:52:42 +0900 Subject: [PATCH 1/3] 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/3] 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/3] 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()