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