diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0eb4e7..f0fa39c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ permissions: jobs: verify: runs-on: macos-26 - timeout-minutes: 15 + timeout-minutes: 25 steps: - uses: actions/checkout@v4 with: @@ -23,6 +23,10 @@ jobs: run: bash scripts/verify.sh - name: Website browser checks run: bash scripts/verify-website-browsers.sh + - name: MAS channel build and tests + run: | + bash scripts/check-mas-build.sh + DILATED_CHANNEL=mas bash scripts/swift-test.sh --scratch-path .build-mas --filter MASChannel - name: Upload crash reports if: failure() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/Sources/DilatedUI/AppModel.swift b/Sources/DilatedUI/AppModel.swift index da250d8..123a389 100644 --- a/Sources/DilatedUI/AppModel.swift +++ b/Sources/DilatedUI/AppModel.swift @@ -8,6 +8,14 @@ import os public enum SystemTransition: Sendable { case sleep, userSwitch, userReturn } +/// The `helperMigration:` argument of `AppModel.init`. The Mac App Store build never had the helper, so it carries no +/// migration type and the argument can only be nil. +#if MAS_CHANNEL + public typealias HelperMigrationInjection = Never +#else + public typealias HelperMigrationInjection = HelperMigration +#endif + public struct AppSettings: Codable, Equatable, Sendable { public var defaultMinutes = 0 public var allowDisplaySleep = true @@ -200,7 +208,9 @@ public struct AppSettings: Codable, Equatable, Sendable { @ObservationIgnored public var onBatteryOverrideNeeded: ((TimeInterval?) -> Void)? @ObservationIgnored public var onClosedLidBatteryWarning: (() -> Bool)? public let closedLid: ClosedLidController - public let helperMigration: HelperMigration + #if !MAS_CHANNEL + public let helperMigration: HelperMigration + #endif public let closedLidPermission: ClosedLidPermissionModel public let updater: AppUpdater @ObservationIgnored public let endpoint: SocketEndpoint @@ -275,7 +285,7 @@ public struct AppSettings: Codable, Equatable, Sendable { endpoint: SocketEndpoint = .current, power: @escaping () -> PowerState = SystemPower.read, assertions: any AssertionControlling = NativeAssertions(), closedLid: ClosedLidController? = nil, - helperMigration: HelperMigration? = nil, + helperMigration: HelperMigrationInjection? = nil, closedLidPermission: ClosedLidPermissionModel? = nil, updater: AppUpdater? = nil ) { @@ -289,8 +299,10 @@ public struct AppSettings: Codable, Equatable, Sendable { sounds: NativeFeedbackSounds(directory: endpoint.stateDirectory)) self.closedLid = closedLid ?? ClosedLidController.configured(stateDirectory: endpoint.stateDirectory) - self.helperMigration = - helperMigration ?? HelperMigration(stateDirectory: endpoint.stateDirectory) + #if !MAS_CHANNEL + self.helperMigration = + helperMigration ?? HelperMigration(stateDirectory: endpoint.stateDirectory) + #endif let lid = self.closedLid self.closedLidPermission = closedLidPermission @@ -372,15 +384,19 @@ public struct AppSettings: Codable, Equatable, Sendable { message = "Couldn't clear package preparation: \(error.localizedDescription)" } // A saved failure or pending restore shows on every launch until it's resolved. - if let record = helperMigration.record, record.needsAttention { message = record.message } + #if !MAS_CHANNEL + if let record = helperMigration.record, record.needsAttention { message = record.message } + #endif commit() if settings.startAtLaunch { automaticStart(label: "At launch") } Task { [weak self] in guard let self else { return } - await self.helperMigration.run() - if let record = self.helperMigration.record, record.needsAttention { - self.message = record.message - } + #if !MAS_CHANNEL + await self.helperMigration.run() + if let record = self.helperMigration.record, record.needsAttention { + self.message = record.message + } + #endif await self.closedLidPermission.refresh() if helperWasApproved { self.noteHelperApprovalNoLongerApplies() } self.onChange?() @@ -389,7 +405,11 @@ public struct AppSettings: Codable, Equatable, Sendable { refreshTriggers() } var updateActivity: UpdateActivity { - if closedLidPermission.busy || helperMigration.running { return .helperChange } + #if MAS_CHANNEL + if closedLidPermission.busy { return .helperChange } + #else + if closedLidPermission.busy || helperMigration.running { return .helperChange } + #endif if engine.desiredState.closedLid || closedLid.hasLease || closedLid.pendingEnable || closedLid.busy { @@ -516,8 +536,13 @@ public struct AppSettings: Codable, Equatable, Sendable { public func setClosedLid( id: UUID, enabled: Bool, allowPrompt: Bool = true, requireAutomation: Bool = false ) async throws { + #if MAS_CHANNEL + let migrationRunning = false + #else + let migrationRunning = helperMigration.running + #endif guard !stopped, !preparingToQuit, !updater.blocksNewWork, !closedLidPermission.busy, - !helperMigration.running + !migrationRunning else { throw HelperClientError.rejected("Dilated is finishing a closed-lid permission change.") } @@ -734,16 +759,20 @@ public struct AppSettings: Codable, Equatable, Sendable { let handoff = PackageHandoffStore(directory: endpoint.stateDirectory) do { try handoff.clear() - let helperWasApproved = helperMigration.helperWasApproved - // package-check needs the old helper's job gone, so finish (or retry) the migration here - // and say why if it can't be. - await helperMigration.run() - if await helperMigration.helperJobStillPresent() { - throw InputError( - helperMigration.record?.message - ?? "Dilated couldn't confirm its old background helper is gone. Turn off Dilated under Login Items & Extensions in System Settings, then try again." - ) - } + #if MAS_CHANNEL + let helperWasApproved = false + #else + let helperWasApproved = helperMigration.helperWasApproved + // package-check needs the old helper's job gone, so finish (or retry) the migration here + // and say why if it can't be. + await helperMigration.run() + if await helperMigration.helperJobStillPresent() { + throw InputError( + helperMigration.record?.message + ?? "Dilated couldn't confirm its old background helper is gone. Turn off Dilated under Login Items & Extensions in System Settings, then try again." + ) + } + #endif try await synchronizeClosedLid() try await ensureClosedLidOff() if intent == .uninstall { @@ -767,13 +796,15 @@ public struct AppSettings: Codable, Equatable, Sendable { try service.unregister() } } - if let removeCommandLine { - try removeCommandLine() - } else { - let cli = CommandLineInstallation( - source: app.appendingPathComponent("Contents/MacOS/dilated")) - if cli.isInstalled { _ = try cli.remove() } - } + #if !MAS_CHANNEL + if let removeCommandLine { + try removeCommandLine() + } else { + let cli = CommandLineInstallation( + source: app.appendingPathComponent("Contents/MacOS/dilated")) + if cli.isInstalled { _ = try cli.remove() } + } + #endif } var prepared = WakeEngine( snapshot: engine.snapshot, power: readPower(), batteryFloor: engine.batteryFloor, @@ -967,7 +998,11 @@ public struct AppSettings: Codable, Equatable, Sendable { return result } public func restoreNormalSleep() async { - if await closedLidPermission.restoreNormalSleep() == .done { helperMigration.sleepRestored() } + #if MAS_CHANNEL + _ = await closedLidPermission.restoreNormalSleep() + #else + if await closedLidPermission.restoreNormalSleep() == .done { helperMigration.sleepRestored() } + #endif message = closedLidPermission.lastError onChange?() } diff --git a/Sources/DilatedUI/ApplicationDelegate.swift b/Sources/DilatedUI/ApplicationDelegate.swift index dc26750..5233548 100644 --- a/Sources/DilatedUI/ApplicationDelegate.swift +++ b/Sources/DilatedUI/ApplicationDelegate.swift @@ -130,10 +130,12 @@ import IOKit.ps let appMenu = NSMenu(title: "Dilated") appMenu.addItem(AwakeMenu.action("About Dilated") { [weak self] in self?.showAbout() }) appMenu.addItem(AwakeMenu.action("Settings…", key: ",") { [weak self] in self?.showSettings() }) - appMenu.addItem( - AwakeMenu.action("Check for Updates…") { [weak self] in - self?.model?.updater.checkForUpdates() - }) + #if !MAS_CHANNEL + appMenu.addItem( + AwakeMenu.action("Check for Updates…") { [weak self] in + self?.model?.updater.checkForUpdates() + }) + #endif appMenu.addItem( AwakeMenu.action("Open Menu Bar Menu") { [weak self] in self?.menu?.showMenu() }) appMenu.addItem(.separator()) diff --git a/Sources/DilatedUI/Automation/TriggerMonitor.swift b/Sources/DilatedUI/Automation/TriggerMonitor.swift index 3939352..1581b11 100644 --- a/Sources/DilatedUI/Automation/TriggerMonitor.swift +++ b/Sources/DilatedUI/Automation/TriggerMonitor.swift @@ -3,50 +3,54 @@ import Darwin import DilatedCore import SystemConfiguration -struct NativeProcess: Identifiable, Sendable { - let id: Int32 - let name: String - let path: String - let identity: ProcessIdentity -} +#if !MAS_CHANNEL + struct NativeProcess: Identifiable, Sendable { + let id: Int32 + let name: String + let path: String + let identity: ProcessIdentity + } -enum ProcessCatalog { - static func pickerList(_ processes: [NativeProcess] = read()) -> [NativeProcess] { - processes.sorted { - let order = $0.name.localizedStandardCompare($1.name) - return order == .orderedSame ? $0.id < $1.id : order == .orderedAscending + enum ProcessCatalog { + static func pickerList(_ processes: [NativeProcess] = read()) -> [NativeProcess] { + processes.sorted { + let order = $0.name.localizedStandardCompare($1.name) + return order == .orderedSame ? $0.id < $1.id : order == .orderedAscending + } } - } - static func read() -> [NativeProcess] { - let count = proc_listallpids(nil, 0) - guard count > 0 else { return [] } - var pids = [Int32](repeating: 0, count: Int(count) + 128) - let found = pids.withUnsafeMutableBytes { proc_listallpids($0.baseAddress, Int32($0.count)) } - guard found > 0 else { return [] } - return pids.prefix(min(Int(found), pids.count)).compactMap { pid in - guard pid > 0 else { return nil } - var info = proc_bsdinfo() - guard - proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout.size(ofValue: info))) > 0, - info.pbi_status != SZOMB - else { return nil } - var path = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN)) - let length = proc_pidpath(pid, &path, UInt32(path.count)) - let executable = length > 0 ? String(cString: path) : "" - var name = [CChar](repeating: 0, count: 256) - guard proc_name(pid, &name, UInt32(name.count)) > 0 else { return nil } - return NativeProcess( - id: pid, - name: executable.isEmpty - ? String(cString: name) : URL(fileURLWithPath: executable).lastPathComponent, - path: executable, - identity: ProcessIdentity( - pid: pid, startedAtMicroseconds: info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec) - ) + static func read() -> [NativeProcess] { + let count = proc_listallpids(nil, 0) + guard count > 0 else { return [] } + var pids = [Int32](repeating: 0, count: Int(count) + 128) + let found = pids.withUnsafeMutableBytes { proc_listallpids($0.baseAddress, Int32($0.count)) } + guard found > 0 else { return [] } + return pids.prefix(min(Int(found), pids.count)).compactMap { pid in + guard pid > 0 else { return nil } + var info = proc_bsdinfo() + guard + proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout.size(ofValue: info))) + > 0, + info.pbi_status != SZOMB + else { return nil } + var path = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN)) + let length = proc_pidpath(pid, &path, UInt32(path.count)) + let executable = length > 0 ? String(cString: path) : "" + var name = [CChar](repeating: 0, count: 256) + guard proc_name(pid, &name, UInt32(name.count)) > 0 else { return nil } + return NativeProcess( + id: pid, + name: executable.isEmpty + ? String(cString: name) : URL(fileURLWithPath: executable).lastPathComponent, + path: executable, + identity: ProcessIdentity( + pid: pid, + startedAtMicroseconds: info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec) + ) + } } } -} +#endif @MainActor final class TriggerMonitor { private let changed: () -> Void @@ -116,16 +120,22 @@ enum ProcessCatalog { func configure(rules: [TriggerRule]) { let rules = rules.filter { $0.enabled && ($0.pausedUntil.map { $0 <= Date() } ?? true) } - processNames = Set( - rules.flatMap(\.criteria).compactMap { - if case .process(let name) = $0 { return name } - return nil - }) - processInstances = Set( - rules.flatMap(\.criteria).compactMap { - if case .processInstance(let identity) = $0 { return identity } - return nil - }) + // The Mac App Store build never reads the process list, so saved process rules stay in the data and never match. + #if MAS_CHANNEL + processNames = [] + processInstances = [] + #else + processNames = Set( + rules.flatMap(\.criteria).compactMap { + if case .process(let name) = $0 { return name } + return nil + }) + processInstances = Set( + rules.flatMap(\.criteria).compactMap { + if case .processInstance(let identity) = $0 { return identity } + return nil + }) + #endif needsProcesses = !processNames.isEmpty || !processInstances.isEmpty let criteria = rules.flatMap(\.criteria) needsCPU = criteria.contains { @@ -225,32 +235,34 @@ enum ProcessCatalog { } input.cpuPercent = lastCPUReading?.value } - if needsProcesses { - let processes = ProcessCatalog.read() - input.processInstances = Set(processes.map(\.identity)) - input.processNames = Set(processes.flatMap { [$0.name, $0.path] }.filter { !$0.isEmpty }) - let pids = Set( - processes.filter { - processNames.contains($0.name) || processNames.contains($0.path) - || processInstances.contains($0.identity) - }.map( - \.id)) - for pid in exits.keys.filter({ !pids.contains($0) }) { - exits.removeValue(forKey: pid)?.cancel() - } - for pid in pids where exits[pid] == nil { - let source = DispatchSource.makeProcessSource( - identifier: pid, eventMask: .exit, queue: .main) - source.setEventHandler { [weak self] in - MainActor.assumeIsolated { - self?.exits.removeValue(forKey: pid)?.cancel() - self?.changed() + #if !MAS_CHANNEL + if needsProcesses { + let processes = ProcessCatalog.read() + input.processInstances = Set(processes.map(\.identity)) + input.processNames = Set(processes.flatMap { [$0.name, $0.path] }.filter { !$0.isEmpty }) + let pids = Set( + processes.filter { + processNames.contains($0.name) || processNames.contains($0.path) + || processInstances.contains($0.identity) + }.map( + \.id)) + for pid in exits.keys.filter({ !pids.contains($0) }) { + exits.removeValue(forKey: pid)?.cancel() + } + for pid in pids where exits[pid] == nil { + let source = DispatchSource.makeProcessSource( + identifier: pid, eventMask: .exit, queue: .main) + source.setEventHandler { [weak self] in + MainActor.assumeIsolated { + self?.exits.removeValue(forKey: pid)?.cancel() + self?.changed() + } } + exits[pid] = source + source.resume() } - exits[pid] = source - source.resume() } - } + #endif return input } diff --git a/Sources/DilatedUI/Automation/TriggersSettings.swift b/Sources/DilatedUI/Automation/TriggersSettings.swift index 90b7504..f339eb7 100644 --- a/Sources/DilatedUI/Automation/TriggersSettings.swift +++ b/Sources/DilatedUI/Automation/TriggersSettings.swift @@ -258,6 +258,14 @@ private enum TriggerKind: String, CaseIterable { case bluetooth = "Paired Bluetooth device" case cpu = "CPU load" case idle = "Idle time" + /// The kinds the picker lists. The Mac App Store build can't read the process list, so it offers neither process kind. + static var offered: [TriggerKind] { + #if MAS_CHANNEL + allCases.filter { $0 != .process && $0 != .processInstance } + #else + allCases + #endif + } var initial: TriggerCriterion { switch self { case .application: .application(bundleID: "", frontmostOnly: false) @@ -287,7 +295,9 @@ private enum TriggerKind: String, CaseIterable { var didAppear: ((any View) -> Void)? #endif @Binding var criterion: TriggerCriterion - @State private var processes: [NativeProcess] = [] + #if !MAS_CHANNEL + @State private var processes: [NativeProcess] = [] + #endif @State private var audioOutputs: [NativeAudioOutput] = [] @State private var usbDevices: [NativeUSBDevice] = [] private var kind: TriggerKind { @@ -315,7 +325,7 @@ private enum TriggerKind: String, CaseIterable { var body: some View { VStack(alignment: .leading, spacing: 10) { Picker("Condition", selection: Binding(get: { kind }, set: { criterion = $0.initial })) { - ForEach(TriggerKind.allCases, id: \.self) { Text($0.rawValue).tag($0) } + ForEach(TriggerKind.offered, id: \.self) { Text($0.rawValue).tag($0) } } switch criterion { case .application(let bundleID, let frontmostOnly): @@ -332,34 +342,41 @@ private enum TriggerKind: String, CaseIterable { isOn: Binding( get: { frontmostOnly }, set: { criterion = .application(bundleID: bundleID, frontmostOnly: $0) })) - case .process(let name): - TextField( - "Executable name or full path", - text: Binding(get: { name }, set: { criterion = .process(name: $0) })) - Text( - "Exact name, such as make, or a full executable path. Ends when the last matching process exits." - ) - .font(.caption).foregroundStyle(.secondary) - case .processInstance(let identity): - Picker( - "Process", - selection: Binding(get: { identity }, set: { criterion = .processInstance($0) }) - ) { - if !processes.contains(where: { $0.identity == identity }) { - Text(identity.pid == 0 ? "Choose a process" : "PID \(identity.pid) (ended)").tag( - identity) + #if MAS_CHANNEL + case .process, .processInstance: + Text("Process conditions aren't available in the Mac App Store version.") + .font(.caption).foregroundStyle(.secondary) + #else + case .process(let name): + TextField( + "Executable name or full path", + text: Binding(get: { name }, set: { criterion = .process(name: $0) })) + Text( + "Exact name, such as make, or a full executable path. Ends when the last matching process exits." + ) + .font(.caption).foregroundStyle(.secondary) + case .processInstance(let identity): + Picker( + "Process", + selection: Binding(get: { identity }, set: { criterion = .processInstance($0) }) + ) { + if !processes.contains(where: { $0.identity == identity }) { + Text(identity.pid == 0 ? "Choose a process" : "PID \(identity.pid) (ended)").tag( + identity) + } + ForEach(processes) { process in + Text("\(process.name) (\(process.id))").tag(process.identity) + } } - ForEach(processes) { process in - Text("\(process.name) (\(process.id))").tag(process.identity) + Button("Refresh processes") { + processes = ProcessCatalog.pickerList() } - } - Button("Refresh processes") { - processes = ProcessCatalog.pickerList() - } - Text("Tracks this process instance. A future process with the same PID won't match.").font( - .caption - ).foregroundStyle(.secondary) - .onAppear { processes = ProcessCatalog.pickerList() } + Text("Tracks this process instance. A future process with the same PID won't match.") + .font( + .caption + ).foregroundStyle(.secondary) + .onAppear { processes = ProcessCatalog.pickerList() } + #endif case .mirrored(let mirrored): Toggle( "Displays are mirrored", diff --git a/Sources/DilatedUI/ClosedLid/ClosedLidSettings.swift b/Sources/DilatedUI/ClosedLid/ClosedLidSettings.swift index 8165187..76982f8 100644 --- a/Sources/DilatedUI/ClosedLid/ClosedLidSettings.swift +++ b/Sources/DilatedUI/ClosedLid/ClosedLidSettings.swift @@ -124,20 +124,22 @@ import SwiftUI } } } - if let record = model.helperMigration.record { - SettingsRow( - title: record.needsAttention ? "Old helper needs attention" : "Old helper removed", - detail: record.message - ) { - HStack { - if record.outcome == .needsRestore { - Button("Restore normal sleep") { Task { await model.restoreNormalSleep() } } - .disabled(permission.busy || model.closedLid.busy) + #if !MAS_CHANNEL + if let record = model.helperMigration.record { + SettingsRow( + title: record.needsAttention ? "Old helper needs attention" : "Old helper removed", + detail: record.message + ) { + HStack { + if record.outcome == .needsRestore { + Button("Restore normal sleep") { Task { await model.restoreNormalSleep() } } + .disabled(permission.busy || model.closedLid.busy) + } + Button("Dismiss") { model.helperMigration.dismiss() } } - Button("Dismiss") { model.helperMigration.dismiss() } } } - } + #endif if let state = model.closedLid.state { SettingsRow(title: "Power setting", detail: state.stopReason?.localizedDescription) { Text( diff --git a/Sources/DilatedUI/ClosedLid/HelperMigration.swift b/Sources/DilatedUI/ClosedLid/HelperMigration.swift index 7452a39..8aebedd 100644 --- a/Sources/DilatedUI/ClosedLid/HelperMigration.swift +++ b/Sources/DilatedUI/ClosedLid/HelperMigration.swift @@ -1,195 +1,197 @@ -import DilatedCore -import Foundation -import OSLog -import Observation -import ServiceManagement - -@MainActor protocol HelperUnregistering: AnyObject { - var status: SMAppService.Status { get } - func unregister() async throws -} - -@MainActor private final class SystemHelperRegistration: HelperUnregistering { - private let service = SMAppService.daemon(plistName: HelperIdentity.plistName) - var status: SMAppService.Status { service.status } - func unregister() async throws { try await service.unregister() } -} - -/// What the one-time cleanup of the retired privileged helper found. Kept on disk so Settings can -/// show it after launch, until the user dismisses it. -public struct HelperMigrationRecord: Codable, Equatable, Sendable { - public enum Outcome: String, Codable, Sendable { - /// The helper is unregistered and its job is gone. Sleep was already normal. - case removed - /// The helper is gone and Dilated turned sleep back on through the sudo rule. - case restoredSleep - /// The helper is gone but sleep is still off. "Restore normal sleep" fixes it. - case needsRestore - /// The helper couldn't be unregistered, or its job is still loaded. - case failed +#if !MAS_CHANNEL + import DilatedCore + import Foundation + import OSLog + import Observation + import ServiceManagement + + @MainActor protocol HelperUnregistering: AnyObject { + var status: SMAppService.Status { get } + func unregister() async throws } - public var outcome: Outcome - public var message: String - public var date: Date - - public var needsAttention: Bool { outcome == .needsRestore || outcome == .failed } -} - -/// Launch-time cleanup for installs that still have the old privileged helper registered. -/// Never throws and never blocks launch: the result lands in `record` for the UI. -@MainActor @Observable public final class HelperMigration { - public static let fileName = "helper-migration.json" - - public private(set) var record: HelperMigrationRecord? - public private(set) var running = false - @ObservationIgnored private let service: any HelperUnregistering - @ObservationIgnored private let jobState: @Sendable () async -> PackageHelperCheck.JobState - @ObservationIgnored private let readPower: () -> ClosedLidReadback - @ObservationIgnored private let hasSessionMarker: () -> Bool - @ObservationIgnored private let revertSleep: () async -> Bool - @ObservationIgnored private let sleep: @Sendable (Duration) async -> Void - @ObservationIgnored private let jobAttempts: Int - @ObservationIgnored private let file: URL - @ObservationIgnored private let now: () -> Date - - public convenience init(stateDirectory: URL) { - self.init( - service: SystemHelperRegistration(), stateDirectory: stateDirectory, - revertSleep: { - let driver = SerialPowerDriver(runner: SudoPowerRunner()) - return (try? await driver.setSleepDisabled(false))?.confirmedDisabled == true - }) + @MainActor private final class SystemHelperRegistration: HelperUnregistering { + private let service = SMAppService.daemon(plistName: HelperIdentity.plistName) + var status: SMAppService.Status { service.status } + func unregister() async throws { try await service.unregister() } } - init( - service: any HelperUnregistering, stateDirectory: URL, - jobState: @escaping @Sendable () async -> PackageHelperCheck.JobState = { - await PackageHelperCheck.helperJobState() - }, - readPower: @escaping () -> ClosedLidReadback = NativeClosedLidPower.readback, - hasSessionMarker: (() -> Bool)? = nil, - revertSleep: @escaping () async -> Bool, - sleep: @escaping @Sendable (Duration) async -> Void = { try? await Task.sleep(for: $0) }, - jobAttempts: Int = 10, now: @escaping () -> Date = { Date() } - ) { - self.service = service - self.jobState = jobState - self.readPower = readPower - self.hasSessionMarker = - hasSessionMarker ?? { ClosedLidMarkerStore(directory: stateDirectory).read() != nil } - self.revertSleep = revertSleep - self.sleep = sleep - self.jobAttempts = jobAttempts - self.now = now - file = stateDirectory.appendingPathComponent(Self.fileName) - record = Self.load(file) - } + /// What the one-time cleanup of the retired privileged helper found. Kept on disk so Settings can + /// show it after launch, until the user dismisses it. + public struct HelperMigrationRecord: Codable, Equatable, Sendable { + public enum Outcome: String, Codable, Sendable { + /// The helper is unregistered and its job is gone. Sleep was already normal. + case removed + /// The helper is gone and Dilated turned sleep back on through the sudo rule. + case restoredSleep + /// The helper is gone but sleep is still off. "Restore normal sleep" fixes it. + case needsRestore + /// The helper couldn't be unregistered, or its job is still loaded. + case failed + } - public var needsAttention: Bool { record?.needsAttention == true } + public var outcome: Outcome + public var message: String + public var date: Date - public func dismiss() { - record = nil - try? FileManager.default.removeItem(at: file) + public var needsAttention: Bool { outcome == .needsRestore || outcome == .failed } } - /// Called after "Restore normal sleep" succeeds, so a stale "sleep is still off" notice goes away. - public func sleepRestored() { - guard record?.outcome == .needsRestore else { return } - save(.init(outcome: .restoredSleep, message: "Normal sleep is back on.", date: now())) - } + /// Launch-time cleanup for installs that still have the old privileged helper registered. + /// Never throws and never blocks launch: the result lands in `record` for the UI. + @MainActor @Observable public final class HelperMigration { + public static let fileName = "helper-migration.json" + + public private(set) var record: HelperMigrationRecord? + public private(set) var running = false + @ObservationIgnored private let service: any HelperUnregistering + @ObservationIgnored private let jobState: @Sendable () async -> PackageHelperCheck.JobState + @ObservationIgnored private let readPower: () -> ClosedLidReadback + @ObservationIgnored private let hasSessionMarker: () -> Bool + @ObservationIgnored private let revertSleep: () async -> Bool + @ObservationIgnored private let sleep: @Sendable (Duration) async -> Void + @ObservationIgnored private let jobAttempts: Int + @ObservationIgnored private let file: URL + @ObservationIgnored private let now: () -> Date + + public convenience init(stateDirectory: URL) { + self.init( + service: SystemHelperRegistration(), stateDirectory: stateDirectory, + revertSleep: { + let driver = SerialPowerDriver(runner: SudoPowerRunner()) + return (try? await driver.setSleepDisabled(false))?.confirmedDisabled == true + }) + } - /// Whether the user had approved the old helper in Login Items before the migration removes it. - public var helperWasApproved: Bool { service.status == .enabled } - - /// True unless launchd confirms the old helper's job is gone. Package preparation uses this - /// because `package-check` refuses anything but a confirmed-absent job. - public func helperJobStillPresent() async -> Bool { await jobState() != .absent } - - public func run() async { - guard !running else { return } - running = true - defer { running = false } - let status = service.status - let before = await jobState() - guard status == .enabled || status == .requiresApproval || before == .loaded else { - // Nothing is registered and launchd has no job, so a leftover failure notice is stale. - // A pending restore stays until sleep reads back as normal. - if before == .absent, let record, record.needsAttention, - record.outcome == .failed || !Self.sleepIsDisabled(readPower()) - { - dismiss() - } - return + init( + service: any HelperUnregistering, stateDirectory: URL, + jobState: @escaping @Sendable () async -> PackageHelperCheck.JobState = { + await PackageHelperCheck.helperJobState() + }, + readPower: @escaping () -> ClosedLidReadback = NativeClosedLidPower.readback, + hasSessionMarker: (() -> Bool)? = nil, + revertSleep: @escaping () async -> Bool, + sleep: @escaping @Sendable (Duration) async -> Void = { try? await Task.sleep(for: $0) }, + jobAttempts: Int = 10, now: @escaping () -> Date = { Date() } + ) { + self.service = service + self.jobState = jobState + self.readPower = readPower + self.hasSessionMarker = + hasSessionMarker ?? { ClosedLidMarkerStore(directory: stateDirectory).read() != nil } + self.revertSleep = revertSleep + self.sleep = sleep + self.jobAttempts = jobAttempts + self.now = now + file = stateDirectory.appendingPathComponent(Self.fileName) + record = Self.load(file) } - let sleepWasDisabled = Self.sleepIsDisabled(readPower()) - var failure: String? - do { try await service.unregister() } catch { failure = error.localizedDescription } - let gone = await confirmJobGone() - guard gone else { - let detail = failure ?? "launchd still lists the job." - Logger(subsystem: HelperIdentity.appID, category: "HelperMigration").error( - "Helper still present after unregister: \(detail, privacy: .public)") - save( - .init( - outcome: .failed, - message: - "Dilated couldn't remove its old background helper (\(detail)). Turn off Dilated under Login Items & Extensions in System Settings.", - date: now())) - return + + public var needsAttention: Bool { record?.needsAttention == true } + + public func dismiss() { + record = nil + try? FileManager.default.removeItem(at: file) } - guard sleepWasDisabled, Self.sleepIsDisabled(readPower()), !hasSessionMarker() else { - save(.init(outcome: .removed, message: "Removed the old background helper.", date: now())) - return + + /// Called after "Restore normal sleep" succeeds, so a stale "sleep is still off" notice goes away. + public func sleepRestored() { + guard record?.outcome == .needsRestore else { return } + save(.init(outcome: .restoredSleep, message: "Normal sleep is back on.", date: now())) } - if await revertSleep(), !Self.sleepIsDisabled(readPower()) { - save( - .init( - outcome: .restoredSleep, - message: "Removed the old background helper and turned normal sleep back on.", - date: now())) - } else { - save( - .init( - outcome: .needsRestore, - message: - "Removed the old background helper, but it left sleep turned off. Use Restore normal sleep.", - date: now())) + + /// Whether the user had approved the old helper in Login Items before the migration removes it. + public var helperWasApproved: Bool { service.status == .enabled } + + /// True unless launchd confirms the old helper's job is gone. Package preparation uses this + /// because `package-check` refuses anything but a confirmed-absent job. + public func helperJobStillPresent() async -> Bool { await jobState() != .absent } + + public func run() async { + guard !running else { return } + running = true + defer { running = false } + let status = service.status + let before = await jobState() + guard status == .enabled || status == .requiresApproval || before == .loaded else { + // Nothing is registered and launchd has no job, so a leftover failure notice is stale. + // A pending restore stays until sleep reads back as normal. + if before == .absent, let record, record.needsAttention, + record.outcome == .failed || !Self.sleepIsDisabled(readPower()) + { + dismiss() + } + return + } + let sleepWasDisabled = Self.sleepIsDisabled(readPower()) + var failure: String? + do { try await service.unregister() } catch { failure = error.localizedDescription } + let gone = await confirmJobGone() + guard gone else { + let detail = failure ?? "launchd still lists the job." + Logger(subsystem: HelperIdentity.appID, category: "HelperMigration").error( + "Helper still present after unregister: \(detail, privacy: .public)") + save( + .init( + outcome: .failed, + message: + "Dilated couldn't remove its old background helper (\(detail)). Turn off Dilated under Login Items & Extensions in System Settings.", + date: now())) + return + } + guard sleepWasDisabled, Self.sleepIsDisabled(readPower()), !hasSessionMarker() else { + save(.init(outcome: .removed, message: "Removed the old background helper.", date: now())) + return + } + if await revertSleep(), !Self.sleepIsDisabled(readPower()) { + save( + .init( + outcome: .restoredSleep, + message: "Removed the old background helper and turned normal sleep back on.", + date: now())) + } else { + save( + .init( + outcome: .needsRestore, + message: + "Removed the old background helper, but it left sleep turned off. Use Restore normal sleep.", + date: now())) + } } - } - private func confirmJobGone() async -> Bool { - for attempt in 0.. Bool { + for attempt in 0.. Bool { - readback.effectiveDisabled == true || readback.storedDisabled == true - } + /// macOS reports that sleep is disabled. + private static func sleepIsDisabled(_ readback: ClosedLidReadback) -> Bool { + readback.effectiveDisabled == true || readback.storedDisabled == true + } - private func save(_ value: HelperMigrationRecord) { - record = value - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - do { - try FileManager.default.createDirectory( - at: file.deletingLastPathComponent(), withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700]) - try encoder.encode(value).write(to: file, options: .atomic) - } catch { - Logger(subsystem: HelperIdentity.appID, category: "HelperMigration").error( - "Couldn't save the migration result: \(error.localizedDescription, privacy: .public)") + private func save(_ value: HelperMigrationRecord) { + record = value + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + do { + try FileManager.default.createDirectory( + at: file.deletingLastPathComponent(), withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + try encoder.encode(value).write(to: file, options: .atomic) + } catch { + Logger(subsystem: HelperIdentity.appID, category: "HelperMigration").error( + "Couldn't save the migration result: \(error.localizedDescription, privacy: .public)") + } } - } - private static func load(_ file: URL) -> HelperMigrationRecord? { - guard let data = try? Data(contentsOf: file), data.count <= 16384 else { return nil } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try? decoder.decode(HelperMigrationRecord.self, from: data) + private static func load(_ file: URL) -> HelperMigrationRecord? { + guard let data = try? Data(contentsOf: file), data.count <= 16384 else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(HelperMigrationRecord.self, from: data) + } } -} +#endif diff --git a/Sources/DilatedUI/CommandLineInstallation.swift b/Sources/DilatedUI/CommandLineInstallation.swift index 2c4393f..f2ff21f 100644 --- a/Sources/DilatedUI/CommandLineInstallation.swift +++ b/Sources/DilatedUI/CommandLineInstallation.swift @@ -1,49 +1,51 @@ -import DilatedControl -import Foundation +#if !MAS_CHANNEL + import DilatedControl + import Foundation -struct CommandLineInstallation { - let source: URL - let directory: URL - private var link: URL { directory.appendingPathComponent("dilated") } - var isInstalled: Bool { - (try? FileManager.default.destinationOfSymbolicLink(atPath: link.path)) == source.path - } - - init( - source: URL = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/dilated"), - directory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( - ".local/bin") - ) { - self.source = source - self.directory = directory - } + struct CommandLineInstallation { + let source: URL + let directory: URL + private var link: URL { directory.appendingPathComponent("dilated") } + var isInstalled: Bool { + (try? FileManager.default.destinationOfSymbolicLink(atPath: link.path)) == source.path + } - func install() throws -> String { - let files = FileManager.default - guard files.isExecutableFile(atPath: source.path) else { - throw InputError("The command line tool isn't available in this copy of Dilated.") + init( + source: URL = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/dilated"), + directory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( + ".local/bin") + ) { + self.source = source + self.directory = directory } - if isInstalled { return "The command line tool is already installed." } - guard !hasEntry else { throw conflict } - try files.createDirectory(at: directory, withIntermediateDirectories: true) - try files.createSymbolicLink(at: link, withDestinationURL: source) - return "Installed the command line tool at \(link.path)." - } - func remove() throws -> String { - guard isInstalled else { + func install() throws -> String { + let files = FileManager.default + guard files.isExecutableFile(atPath: source.path) else { + throw InputError("The command line tool isn't available in this copy of Dilated.") + } + if isInstalled { return "The command line tool is already installed." } guard !hasEntry else { throw conflict } - return "The command line tool isn't installed at \(link.path)." + try files.createDirectory(at: directory, withIntermediateDirectories: true) + try files.createSymbolicLink(at: link, withDestinationURL: source) + return "Installed the command line tool at \(link.path)." } - try FileManager.default.removeItem(at: link) - return "Removed the command line tool from \(link.path)." - } - private var hasEntry: Bool { - FileManager.default.fileExists(atPath: link.path) - || (try? FileManager.default.destinationOfSymbolicLink(atPath: link.path)) != nil - } - private var conflict: InputError { - InputError("A different command already exists at \(link.path). It was left unchanged.") + func remove() throws -> String { + guard isInstalled else { + guard !hasEntry else { throw conflict } + return "The command line tool isn't installed at \(link.path)." + } + try FileManager.default.removeItem(at: link) + return "Removed the command line tool from \(link.path)." + } + + private var hasEntry: Bool { + FileManager.default.fileExists(atPath: link.path) + || (try? FileManager.default.destinationOfSymbolicLink(atPath: link.path)) != nil + } + private var conflict: InputError { + InputError("A different command already exists at \(link.path). It was left unchanged.") + } } -} +#endif diff --git a/Sources/DilatedUI/Menu/AwakeMenu.swift b/Sources/DilatedUI/Menu/AwakeMenu.swift index 1aca811..f624510 100644 --- a/Sources/DilatedUI/Menu/AwakeMenu.swift +++ b/Sources/DilatedUI/Menu/AwakeMenu.swift @@ -113,34 +113,42 @@ import DilatedCore } menu.addItem(.separator()) menu.addItem(action("Settings…", key: ",", body: openSettings)) - if model.updater.isAvailable { - let updates = action("Check for Updates…") { model.updater.checkForUpdates() } - updates.isEnabled = model.updater.canCheckForUpdates - menu.addItem(updates) - } + #if !MAS_CHANNEL + if model.updater.isAvailable { + let updates = action("Check for Updates…") { model.updater.checkForUpdates() } + updates.isEnabled = model.updater.canCheckForUpdates + menu.addItem(updates) + } + #endif menu.addItem(action("About Dilated", body: openAbout)) - menu.addItem(supportItem()) + #if !MAS_CHANNEL + menu.addItem(supportItem()) + #endif menu.addItem(action("Quit Dilated", key: "q") { NSApp.terminate(nil) }) return menu } - private static func supportItem() -> NSMenuItem { - let submenu = NSMenu(title: SupportLinks.menuTitle) - submenu.autoenablesItems = false - for destination in SupportLinks.all { - submenu.addItem(action(destination.title) { NSWorkspace.shared.open(destination.url) }) + #if !MAS_CHANNEL + private static func supportItem() -> NSMenuItem { + let submenu = NSMenu(title: SupportLinks.menuTitle) + submenu.autoenablesItems = false + for destination in SupportLinks.all { + submenu.addItem(action(destination.title) { NSWorkspace.shared.open(destination.url) }) + } + let item = NSMenuItem(title: SupportLinks.menuTitle, action: nil, keyEquivalent: "") + item.submenu = submenu + return item } - let item = NSMenuItem(title: SupportLinks.menuTitle, action: nil, keyEquivalent: "") - item.submenu = submenu - return item - } + #endif static func refreshStatus(in menu: NSMenu, model: AppModel) { menu.items.first?.title = model.statusText menu.items.first { $0.keyEquivalent == "d" }?.title = model.engine.holds.isEmpty ? "Keep Awake" : "Stop" - menu.items.first { $0.title == "Check for Updates…" }?.isEnabled = - model.updater.canCheckForUpdates + #if !MAS_CHANNEL + menu.items.first { $0.title == "Check for Updates…" }?.isEnabled = + model.updater.canCheckForUpdates + #endif } private static func information(_ title: String) -> NSMenuItem { diff --git a/Sources/DilatedUI/Settings/AboutSettings.swift b/Sources/DilatedUI/Settings/AboutSettings.swift index 572e018..1c6dcd1 100644 --- a/Sources/DilatedUI/Settings/AboutSettings.swift +++ b/Sources/DilatedUI/Settings/AboutSettings.swift @@ -55,10 +55,12 @@ enum AboutInfo { } Spacer(minLength: 0) }.padding(12) - SettingsRow(title: "App updates", detail: model.updater.statusText) { - Button("Check for Updates…") { model.updater.checkForUpdates() } - .disabled(!model.updater.canCheckForUpdates) - } + #if !MAS_CHANNEL + SettingsRow(title: "App updates", detail: model.updater.statusText) { + Button("Check for Updates…") { model.updater.checkForUpdates() } + .disabled(!model.updater.canCheckForUpdates) + } + #endif } SettingsGroup(eyebrow: "LINKS") { ForEach(AboutInfo.links, id: \.title) { link in @@ -68,9 +70,11 @@ enum AboutInfo { } } } - Text(SupportLinks.credits).font(DesignFont.sans(11.5)).foregroundStyle(DesignPalette.ink2) - .multilineTextAlignment(.center).frame(maxWidth: .infinity) - .fixedSize(horizontal: false, vertical: true) + #if !MAS_CHANNEL + Text(SupportLinks.credits).font(DesignFont.sans(11.5)).foregroundStyle(DesignPalette.ink2) + .multilineTextAlignment(.center).frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + #endif } } } diff --git a/Sources/DilatedUI/Settings/SettingsWindow.swift b/Sources/DilatedUI/Settings/SettingsWindow.swift index f40a4ec..feafb51 100644 --- a/Sources/DilatedUI/Settings/SettingsWindow.swift +++ b/Sources/DilatedUI/Settings/SettingsWindow.swift @@ -200,13 +200,17 @@ struct PageIdentity: Hashable { @State private var presetsLoaded = false @FocusState private var presetsFocused: Bool @State private var loginRefresh = 0 - @State private var cliRefresh = 0 + #if !MAS_CHANNEL + @State private var cliRefresh = 0 + #endif @State private var confirmSettingsReset = false @State private var recoveryError: String? @State private var recoveryNote: String? @Environment(\.accessibilityReduceMotion) private var reduceMotion private let loginItem = LoginItem() - private let cliInstallation = CommandLineInstallation() + #if !MAS_CHANNEL + private let cliInstallation = CommandLineInstallation() + #endif var body: some View { HStack(spacing: 0) { @@ -501,25 +505,27 @@ struct PageIdentity: Hashable { .disabled(!model.settings.stopOnUserSwitch) } } - SettingsGroup(eyebrow: "UPDATES") { - SettingsRow(title: "App updates", detail: model.updater.statusText) { - Button("Check for Updates…") { model.updater.checkForUpdates() } - .disabled(!model.updater.canCheckForUpdates) - } - if model.updater.isAvailable { - SettingsRow( - title: "Check automatically", detail: "Downloading and installing always asks first." - ) { - Toggle( - "Check automatically", - isOn: Binding( - get: { model.updater.automaticallyChecksForUpdates }, - set: { model.updater.setAutomaticChecks($0) } - ) - ).labelsHidden() + #if !MAS_CHANNEL + SettingsGroup(eyebrow: "UPDATES") { + SettingsRow(title: "App updates", detail: model.updater.statusText) { + Button("Check for Updates…") { model.updater.checkForUpdates() } + .disabled(!model.updater.canCheckForUpdates) + } + if model.updater.isAvailable { + SettingsRow( + title: "Check automatically", detail: "Downloading and installing always asks first." + ) { + Toggle( + "Check automatically", + isOn: Binding( + get: { model.updater.automaticallyChecksForUpdates }, + set: { model.updater.setAutomaticChecks($0) } + ) + ).labelsHidden() + } } } - } + #endif } } private var loginDescription: String { @@ -714,15 +720,18 @@ struct PageIdentity: Hashable { Toggle("Allow automation links", isOn: $model.settings.urlAutomation).labelsHidden() .accessibilityHint("Allows dilated://awake, dilated://stop and dilated://toggle links.") } - SettingsRow( - title: "Command line tool", - detail: "Install into ~/.local/bin. Add that directory to your PATH if needed." - ) { - Button( - cliInstallation.isInstalled ? "Remove command line tool" : "Install command line tool", - action: changeCLIInstallation - ).id(cliRefresh) - } + #if !MAS_CHANNEL + SettingsRow( + title: "Command line tool", + detail: "Install into ~/.local/bin. Add that directory to your PATH if needed." + ) { + Button( + cliInstallation.isInstalled + ? "Remove command line tool" : "Install command line tool", + action: changeCLIInstallation + ).id(cliRefresh) + } + #endif if model.engine.automationPaused { SettingsRow( title: "Automation paused", detail: "CLI starts, links and agent renewals are blocked." @@ -744,14 +753,16 @@ struct PageIdentity: Hashable { .foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) } } - private func changeCLIInstallation() { - do { - model.message = - try cliInstallation.isInstalled - ? cliInstallation.remove() : cliInstallation.install() - } catch { model.message = error.localizedDescription } - cliRefresh += 1 - } + #if !MAS_CHANNEL + private func changeCLIInstallation() { + do { + model.message = + try cliInstallation.isInstalled + ? cliInstallation.remove() : cliInstallation.install() + } catch { model.message = error.localizedDescription } + cliRefresh += 1 + } + #endif private var advanced: some View { SettingsGroup(eyebrow: "DIAGNOSTICS") { SettingsRow(title: "Active power assertions") { diff --git a/Sources/DilatedUI/SupportLinks.swift b/Sources/DilatedUI/SupportLinks.swift index ac13f05..5596d90 100644 --- a/Sources/DilatedUI/SupportLinks.swift +++ b/Sources/DilatedUI/SupportLinks.swift @@ -1,32 +1,34 @@ -import Foundation +#if !MAS_CHANNEL + import Foundation -/// Where people can chip in. Dilated is free and open source; these two links live in the menu bar menu, the About page, the -/// website footer and the README, and nowhere else. Nothing here prompts, nags or runs at launch. -enum SupportLinks { - struct Destination: Equatable { - let title: String - let url: URL - } + /// Where people can chip in. Dilated is free and open source; these two links live in the menu bar menu, the About page, the + /// website footer and the README, and nowhere else. Nothing here prompts, nags or runs at launch. + enum SupportLinks { + struct Destination: Equatable { + let title: String + let url: URL + } - static let gitHubSponsors = Destination( - title: "GitHub Sponsors", url: URL(string: "https://github.com/sponsors/CodesWhat")!) - static let donate = Destination( - title: "Donate", url: URL(string: "https://codeswhat.com/support")!) - static let all = [gitHubSponsors, donate] + static let gitHubSponsors = Destination( + title: "GitHub Sponsors", url: URL(string: "https://github.com/sponsors/CodesWhat")!) + static let donate = Destination( + title: "Donate", url: URL(string: "https://codeswhat.com/support")!) + static let all = [gitHubSponsors, donate] - static let menuTitle = "Support Dilated" + static let menuTitle = "Support Dilated" - /// The About page's credits line, split around its two links. - static let creditsLead = - "Dilated is free and open source. If it saves you time, you can support it on " - static let creditsDonateText = "donate directly" + /// The About page's credits line, split around its two links. + static let creditsLead = + "Dilated is free and open source. If it saves you time, you can support it on " + static let creditsDonateText = "donate directly" - static var credits: AttributedString { - let lead = AttributedString(creditsLead) - var sponsors = AttributedString(gitHubSponsors.title) - sponsors.link = gitHubSponsors.url - var donateLink = AttributedString(creditsDonateText) - donateLink.link = donate.url - return lead + sponsors + AttributedString(" or ") + donateLink + AttributedString(".") + static var credits: AttributedString { + let lead = AttributedString(creditsLead) + var sponsors = AttributedString(gitHubSponsors.title) + sponsors.link = gitHubSponsors.url + var donateLink = AttributedString(creditsDonateText) + donateLink.link = donate.url + return lead + sponsors + AttributedString(" or ") + donateLink + AttributedString(".") + } } -} +#endif diff --git a/Sources/DilatedUI/Updates/AppUpdater.swift b/Sources/DilatedUI/Updates/AppUpdater.swift index 2bcb88d..43d9f70 100644 --- a/Sources/DilatedUI/Updates/AppUpdater.swift +++ b/Sources/DilatedUI/Updates/AppUpdater.swift @@ -36,7 +36,13 @@ enum UpdateLaunchPolicy { && (!started || driverCanCheck) } public var statusText: String { - guard isAvailable else { return "Updates aren't enabled in this test build." } + guard isAvailable else { + #if MAS_CHANNEL + return "Updates come from the Mac App Store." + #else + return "Updates aren't enabled in this test build." + #endif + } if blocksNewWork { return "Installing the update. Dilated will reopen when it's ready." } if pendingInstall != nil { return "Waiting for closed-lid mode and permission changes to finish." diff --git a/Tests/DilatedUITests/AboutSettingsTests.swift b/Tests/DilatedUITests/AboutSettingsTests.swift index fec848d..4372a20 100644 --- a/Tests/DilatedUITests/AboutSettingsTests.swift +++ b/Tests/DilatedUITests/AboutSettingsTests.swift @@ -82,34 +82,37 @@ import ViewInspector } #endif - @Test func creditsLineLinksBothDestinations() { - let credits = SupportLinks.credits - #expect( - String(credits.characters) - == "Dilated is free and open source. If it saves you time, you can support it on GitHub Sponsors or donate directly." - ) - let links = credits.runs.compactMap { run in - run.link.map { (String(credits[run.range].characters), $0) } + #if !MAS_CHANNEL + @Test func creditsLineLinksBothDestinations() { + let credits = SupportLinks.credits + #expect( + String(credits.characters) + == "Dilated is free and open source. If it saves you time, you can support it on GitHub Sponsors or donate directly." + ) + let links = credits.runs.compactMap { run in + run.link.map { (String(credits[run.range].characters), $0) } + } + #expect(links.map(\.0) == ["GitHub Sponsors", "donate directly"]) + #expect( + links.map(\.1.absoluteString) == [ + "https://github.com/sponsors/CodesWhat", "https://codeswhat.com/support", + ]) } - #expect(links.map(\.0) == ["GitHub Sponsors", "donate directly"]) - #expect( - links.map(\.1.absoluteString) == [ - "https://github.com/sponsors/CodesWhat", "https://codeswhat.com/support", - ]) - } - @Test func aboutPageShowsTheCreditsLine() throws { - _ = NSApplication.shared - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let model = AppModel(endpoint: .init(stateDirectory: directory), power: { .init() }) - defer { model.shutdown() } - let text = try AboutSettings(model: model).inspect().find(ViewType.Text.self) { - (try? String($0.attributedString().characters).hasPrefix("Dilated is free and open source")) - == true + @Test func aboutPageShowsTheCreditsLine() throws { + _ = NSApplication.shared + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = AppModel(endpoint: .init(stateDirectory: directory), power: { .init() }) + defer { model.shutdown() } + let text = try AboutSettings(model: model).inspect().find(ViewType.Text.self) { + (try? String($0.attributedString().characters).hasPrefix("Dilated is free and open source")) + == true + } + #expect(String(try text.attributedString().characters).hasSuffix("or donate directly.")) } - #expect(String(try text.attributedString().characters).hasSuffix("or donate directly.")) - } + #endif /// Writes a PNG of the About page when `SUPPORT_RENDER_DIR` is set. A no-op otherwise. @Test func rendersAboutPage() throws { diff --git a/Tests/DilatedUITests/ClosedLidTestSupport.swift b/Tests/DilatedUITests/ClosedLidTestSupport.swift index 55cc982..2a10e5e 100644 --- a/Tests/DilatedUITests/ClosedLidTestSupport.swift +++ b/Tests/DilatedUITests/ClosedLidTestSupport.swift @@ -27,27 +27,32 @@ private struct NeverRunsScripts: PrivilegedScriptRunning { runner: NeverRunsScripts())) } -@MainActor final class FakeHelperRegistration: HelperUnregistering { - var status: SMAppService.Status - var unregisterError: (any Error)? - var unregisterCalls = 0 - /// Called when `unregister()` succeeds, so a test can flip the fake launchd state with it. - var onUnregister: (() -> Void)? - init(status: SMAppService.Status) { self.status = status } - func unregister() async throws { - unregisterCalls += 1 - if let unregisterError { throw unregisterError } - status = .notRegistered - onUnregister?() +#if MAS_CHANNEL + /// The Mac App Store build has no helper migration, so the injected value is always nil. + @MainActor func inertMigration(_ directory: URL) -> HelperMigrationInjection? { nil } +#else + @MainActor final class FakeHelperRegistration: HelperUnregistering { + var status: SMAppService.Status + var unregisterError: (any Error)? + var unregisterCalls = 0 + /// Called when `unregister()` succeeds, so a test can flip the fake launchd state with it. + var onUnregister: (() -> Void)? + init(status: SMAppService.Status) { self.status = status } + func unregister() async throws { + unregisterCalls += 1 + if let unregisterError { throw unregisterError } + status = .notRegistered + onUnregister?() + } } -} -/// A migration that finds nothing to do, for tests that call `AppModel.begin()`. -@MainActor func inertMigration(_ directory: URL) -> HelperMigration { - HelperMigration( - service: FakeHelperRegistration(status: .notRegistered), stateDirectory: directory, - jobState: { .absent }, revertSleep: { true }) -} + /// A migration that finds nothing to do, for tests that call `AppModel.begin()`. + @MainActor func inertMigration(_ directory: URL) -> HelperMigration { + HelperMigration( + service: FakeHelperRegistration(status: .notRegistered), stateDirectory: directory, + jobState: { .absent }, revertSleep: { true }) + } +#endif private actor IdleLidPower: ClosedLidPowerControlling { func setSleepDisabled(_ disabled: Bool) async throws -> ClosedLidReadback { diff --git a/Tests/DilatedUITests/CommandLineInstallationTests.swift b/Tests/DilatedUITests/CommandLineInstallationTests.swift index d1a8e36..34faaf6 100644 --- a/Tests/DilatedUITests/CommandLineInstallationTests.swift +++ b/Tests/DilatedUITests/CommandLineInstallationTests.swift @@ -1,101 +1,104 @@ -import Foundation -import Testing +#if !MAS_CHANNEL + import Foundation + import Testing -@testable import DilatedUI + @testable import DilatedUI -struct CommandLineInstallationTests { - @Test func installLinksTheBundledExecutableAndCanBeRepeated() throws { - let fixture = try CLIInstallationFixture() - defer { fixture.cleanUp() } - #expect(!fixture.installation.isInstalled) - _ = try fixture.installation.install() - #expect(fixture.installation.isInstalled) - #expect( - try FileManager.default.destinationOfSymbolicLink(atPath: fixture.link.path) - == fixture.source.path) - _ = try fixture.installation.install() - #expect(try Data(contentsOf: fixture.link) == Data("bundled CLI".utf8)) - } - - @Test func removalOnlyUnlinksTheInstalledCommand() throws { - let fixture = try CLIInstallationFixture() - defer { fixture.cleanUp() } - _ = try fixture.installation.install() - let neighbor = fixture.directory.appendingPathComponent("another-command") - try Data("keep".utf8).write(to: neighbor) - _ = try fixture.installation.remove() - #expect(!fixture.installation.isInstalled) - #expect(!FileManager.default.fileExists(atPath: fixture.link.path)) - #expect(try Data(contentsOf: fixture.source) == Data("bundled CLI".utf8)) - #expect(try Data(contentsOf: neighbor) == Data("keep".utf8)) - _ = try fixture.installation.remove() - } + struct CommandLineInstallationTests { + @Test func installLinksTheBundledExecutableAndCanBeRepeated() throws { + let fixture = try CLIInstallationFixture() + defer { fixture.cleanUp() } + #expect(!fixture.installation.isInstalled) + _ = try fixture.installation.install() + #expect(fixture.installation.isInstalled) + #expect( + try FileManager.default.destinationOfSymbolicLink(atPath: fixture.link.path) + == fixture.source.path) + _ = try fixture.installation.install() + #expect(try Data(contentsOf: fixture.link) == Data("bundled CLI".utf8)) + } - @Test func unrelatedFilesDirectoriesAndLinksSurviveBothOperations() throws { - for kind in ["file", "directory", "foreign-link", "broken-link"] { + @Test func removalOnlyUnlinksTheInstalledCommand() throws { let fixture = try CLIInstallationFixture() defer { fixture.cleanUp() } - let files = FileManager.default - try files.createDirectory(at: fixture.directory, withIntermediateDirectories: true) - let other = fixture.root.appendingPathComponent("other-app") - switch kind { - case "file": try Data("keep".utf8).write(to: fixture.link) - case "directory": - try files.createDirectory(at: fixture.link, withIntermediateDirectories: false) - default: - if kind == "foreign-link" { try Data("keep".utf8).write(to: other) } - try files.createSymbolicLink(at: fixture.link, withDestinationURL: other) + _ = try fixture.installation.install() + let neighbor = fixture.directory.appendingPathComponent("another-command") + try Data("keep".utf8).write(to: neighbor) + _ = try fixture.installation.remove() + #expect(!fixture.installation.isInstalled) + #expect(!FileManager.default.fileExists(atPath: fixture.link.path)) + #expect(try Data(contentsOf: fixture.source) == Data("bundled CLI".utf8)) + #expect(try Data(contentsOf: neighbor) == Data("keep".utf8)) + _ = try fixture.installation.remove() + } + + @Test func unrelatedFilesDirectoriesAndLinksSurviveBothOperations() throws { + for kind in ["file", "directory", "foreign-link", "broken-link"] { + let fixture = try CLIInstallationFixture() + defer { fixture.cleanUp() } + let files = FileManager.default + try files.createDirectory(at: fixture.directory, withIntermediateDirectories: true) + let other = fixture.root.appendingPathComponent("other-app") + switch kind { + case "file": try Data("keep".utf8).write(to: fixture.link) + case "directory": + try files.createDirectory(at: fixture.link, withIntermediateDirectories: false) + default: + if kind == "foreign-link" { try Data("keep".utf8).write(to: other) } + try files.createSymbolicLink(at: fixture.link, withDestinationURL: other) + } + #expect(throws: (any Error).self) { try fixture.installation.install() } + #expect(throws: (any Error).self) { try fixture.installation.remove() } + if kind.hasSuffix("link") { + #expect(try files.destinationOfSymbolicLink(atPath: fixture.link.path) == other.path) + } else { + #expect(files.fileExists(atPath: fixture.link.path)) + if kind == "file" { #expect(try Data(contentsOf: fixture.link) == Data("keep".utf8)) } + } } + } + + @Test func installationRequiresAnExecutableBeforeCreatingTheBinDirectory() throws { + let fixture = try CLIInstallationFixture() + defer { fixture.cleanUp() } + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], ofItemAtPath: fixture.source.path) + #expect(throws: (any Error).self) { try fixture.installation.install() } + #expect(!FileManager.default.fileExists(atPath: fixture.directory.path)) + try FileManager.default.removeItem(at: fixture.source) #expect(throws: (any Error).self) { try fixture.installation.install() } - #expect(throws: (any Error).self) { try fixture.installation.remove() } - if kind.hasSuffix("link") { - #expect(try files.destinationOfSymbolicLink(atPath: fixture.link.path) == other.path) - } else { - #expect(files.fileExists(atPath: fixture.link.path)) - if kind == "file" { #expect(try Data(contentsOf: fixture.link) == Data("keep".utf8)) } - } } - } - @Test func installationRequiresAnExecutableBeforeCreatingTheBinDirectory() throws { - let fixture = try CLIInstallationFixture() - defer { fixture.cleanUp() } - try FileManager.default.setAttributes( - [.posixPermissions: 0o644], ofItemAtPath: fixture.source.path) - #expect(throws: (any Error).self) { try fixture.installation.install() } - #expect(!FileManager.default.fileExists(atPath: fixture.directory.path)) - try FileManager.default.removeItem(at: fixture.source) - #expect(throws: (any Error).self) { try fixture.installation.install() } + @Test func anOwnedBrokenLinkCanStillBeRemoved() throws { + let fixture = try CLIInstallationFixture() + defer { fixture.cleanUp() } + _ = try fixture.installation.install() + try FileManager.default.removeItem(at: fixture.source) + #expect(fixture.installation.isInstalled) + _ = try fixture.installation.remove() + #expect(!fixture.installation.isInstalled) + #expect( + (try? FileManager.default.destinationOfSymbolicLink(atPath: fixture.link.path)) == nil) + } } - @Test func anOwnedBrokenLinkCanStillBeRemoved() throws { - let fixture = try CLIInstallationFixture() - defer { fixture.cleanUp() } - _ = try fixture.installation.install() - try FileManager.default.removeItem(at: fixture.source) - #expect(fixture.installation.isInstalled) - _ = try fixture.installation.remove() - #expect(!fixture.installation.isInstalled) - #expect((try? FileManager.default.destinationOfSymbolicLink(atPath: fixture.link.path)) == nil) - } -} + private struct CLIInstallationFixture { + let root: URL + let source: URL + let directory: URL + var link: URL { directory.appendingPathComponent("dilated") } + var installation: CommandLineInstallation { .init(source: source, directory: directory) } -private struct CLIInstallationFixture { - let root: URL - let source: URL - let directory: URL - var link: URL { directory.appendingPathComponent("dilated") } - var installation: CommandLineInstallation { .init(source: source, directory: directory) } + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + source = root.appendingPathComponent("Dilated.app/Contents/MacOS/dilated") + directory = root.appendingPathComponent("home/.local/bin") + try FileManager.default.createDirectory( + at: source.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("bundled CLI".utf8).write(to: source) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: source.path) + } - init() throws { - root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - source = root.appendingPathComponent("Dilated.app/Contents/MacOS/dilated") - directory = root.appendingPathComponent("home/.local/bin") - try FileManager.default.createDirectory( - at: source.deletingLastPathComponent(), withIntermediateDirectories: true) - try Data("bundled CLI".utf8).write(to: source) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: source.path) + func cleanUp() { try? FileManager.default.removeItem(at: root) } } - - func cleanUp() { try? FileManager.default.removeItem(at: root) } -} +#endif diff --git a/Tests/DilatedUITests/HelperMigrationTests.swift b/Tests/DilatedUITests/HelperMigrationTests.swift index 9f6d293..136a7c9 100644 --- a/Tests/DilatedUITests/HelperMigrationTests.swift +++ b/Tests/DilatedUITests/HelperMigrationTests.swift @@ -1,425 +1,428 @@ -import DilatedControl -import DilatedCore -import Foundation -import ServiceManagement -import Testing +#if !MAS_CHANNEL + import DilatedControl + import DilatedCore + import Foundation + import ServiceManagement + import Testing -@testable import DilatedUI + @testable import DilatedUI -/// Fake launchd, power state and sudo path behind the migration's injected seams. -private final class MigrationWorld: @unchecked Sendable { - private let lock = NSLock() - private var _job: PackageHelperCheck.JobState - private var _sleepDisabled: Bool - private var _marker = false - private var _revertWorks = true - private var _reverts = 0 - private var _jobReads = 0 - private var _powerReads = 0 + /// Fake launchd, power state and sudo path behind the migration's injected seams. + private final class MigrationWorld: @unchecked Sendable { + private let lock = NSLock() + private var _job: PackageHelperCheck.JobState + private var _sleepDisabled: Bool + private var _marker = false + private var _revertWorks = true + private var _reverts = 0 + private var _jobReads = 0 + private var _powerReads = 0 - init(job: PackageHelperCheck.JobState = .loaded, sleepDisabled: Bool = false) { - _job = job - _sleepDisabled = sleepDisabled - } + init(job: PackageHelperCheck.JobState = .loaded, sleepDisabled: Bool = false) { + _job = job + _sleepDisabled = sleepDisabled + } - var job: PackageHelperCheck.JobState { - get { lock.withLock { _job } } - set { lock.withLock { _job = newValue } } - } - var marker: Bool { - get { lock.withLock { _marker } } - set { lock.withLock { _marker = newValue } } - } - var revertWorks: Bool { - get { lock.withLock { _revertWorks } } - set { lock.withLock { _revertWorks = newValue } } - } - var reverts: Int { lock.withLock { _reverts } } - var jobReads: Int { lock.withLock { _jobReads } } - var powerReads: Int { lock.withLock { _powerReads } } - func readJob() -> PackageHelperCheck.JobState { - lock.withLock { - _jobReads += 1 - return _job + var job: PackageHelperCheck.JobState { + get { lock.withLock { _job } } + set { lock.withLock { _job = newValue } } } - } - func readPower() -> ClosedLidReadback { - lock.withLock { _powerReads += 1 } - return readback - } - var readback: ClosedLidReadback { - lock.withLock { - ClosedLidReadback(effectiveDisabled: _sleepDisabled, storedDisabled: _sleepDisabled) + var marker: Bool { + get { lock.withLock { _marker } } + set { lock.withLock { _marker = newValue } } } - } - func revert() -> Bool { - lock.withLock { - _reverts += 1 - if _revertWorks { _sleepDisabled = false } - return _revertWorks + var revertWorks: Bool { + get { lock.withLock { _revertWorks } } + set { lock.withLock { _revertWorks = newValue } } + } + var reverts: Int { lock.withLock { _reverts } } + var jobReads: Int { lock.withLock { _jobReads } } + var powerReads: Int { lock.withLock { _powerReads } } + func readJob() -> PackageHelperCheck.JobState { + lock.withLock { + _jobReads += 1 + return _job + } + } + func readPower() -> ClosedLidReadback { + lock.withLock { _powerReads += 1 } + return readback + } + var readback: ClosedLidReadback { + lock.withLock { + ClosedLidReadback(effectiveDisabled: _sleepDisabled, storedDisabled: _sleepDisabled) + } + } + func revert() -> Bool { + lock.withLock { + _reverts += 1 + if _revertWorks { _sleepDisabled = false } + return _revertWorks + } } - } -} - -@MainActor private func makeMigration( - _ service: FakeHelperRegistration, _ world: MigrationWorld, _ directory: URL, - attempts: Int = 3 -) -> HelperMigration { - HelperMigration( - service: service, stateDirectory: directory, jobState: { world.readJob() }, - readPower: { world.readPower() }, hasSessionMarker: { world.marker }, - revertSleep: { world.revert() }, sleep: { _ in }, jobAttempts: attempts) -} - -private struct UnregisterFailure: LocalizedError { - var errorDescription: String? { "Operation not permitted" } -} - -@MainActor struct HelperMigrationTests { - private func scratch() -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - } - - // Real SMAppService.unregister() with the helper executable already missing from the app bundle - // can't be faked here. Only the attended upgrade test covers it; these tests cover what the - // migration does with whatever unregister and launchd report. - @Test(arguments: [SMAppService.Status.enabled, .requiresApproval]) - func aRegisteredHelperIsUnregisteredAndItsJobConfirmedGone(status: SMAppService.Status) - async throws - { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld() - let service = FakeHelperRegistration(status: status) - service.onUnregister = { world.job = .absent } - let migration = makeMigration(service, world, directory) - await migration.run() - #expect(service.unregisterCalls == 1) - // One read before unregistering, at least one more to confirm the job left launchd. - #expect(world.jobReads >= 2) - // Sleep was read before the removal and again after it. - #expect(world.powerReads >= 1) - #expect(world.reverts == 0) - #expect(migration.record?.outcome == .removed) - #expect(migration.needsAttention == false) - // The result outlives the launch, so Settings can show it. - let reopened = makeMigration(service, world, directory) - #expect(reopened.record?.outcome == .removed) - reopened.dismiss() - #expect(reopened.record == nil) - #expect(makeMigration(service, world, directory).record == nil) } - @Test func unregisterSucceedingWhileTheJobIsStillLoadedIsAFailure() async { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld(job: .loaded, sleepDisabled: true) - let service = FakeHelperRegistration(status: .enabled) - let migration = makeMigration(service, world, directory, attempts: 3) - await migration.run() - #expect(service.unregisterCalls == 1) - // 1 initial read + 3 confirmation polls. - #expect(world.jobReads == 4) - #expect(migration.record?.outcome == .failed) - #expect(migration.record?.message.contains("launchd still lists the job") == true) - #expect(world.reverts == 0) + @MainActor private func makeMigration( + _ service: FakeHelperRegistration, _ world: MigrationWorld, _ directory: URL, + attempts: Int = 3 + ) -> HelperMigration { + HelperMigration( + service: service, stateDirectory: directory, jobState: { world.readJob() }, + readPower: { world.readPower() }, hasSessionMarker: { world.marker }, + revertSleep: { world.revert() }, sleep: { _ in }, jobAttempts: attempts) } - @Test(arguments: [SMAppService.Status.notRegistered, .notFound]) - func nothingRegisteredAndNoJobIsANoOp(status: SMAppService.Status) async { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld(job: .absent, sleepDisabled: true) - let service = FakeHelperRegistration(status: status) - let migration = makeMigration(service, world, directory) - await migration.run() - #expect(service.unregisterCalls == 0) - #expect(migration.record == nil) - #expect(world.reverts == 0) - #expect(!FileManager.default.fileExists(atPath: directory.path)) + private struct UnregisterFailure: LocalizedError { + var errorDescription: String? { "Operation not permitted" } } - @Test func aLoadedJobIsMigratedEvenWhenTheServiceReportsNotRegistered() async { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld() - let service = FakeHelperRegistration(status: .notRegistered) - service.onUnregister = { world.job = .absent } - let migration = makeMigration(service, world, directory) - await migration.run() - #expect(service.unregisterCalls == 1) - #expect(migration.record?.outcome == .removed) - } + @MainActor struct HelperMigrationTests { + private func scratch() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + } - @Test func anUnregisterFailureThatLeavesTheJobIsRecordedWithoutThrowingOrTouchingSleep() async { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld(sleepDisabled: true) - let service = FakeHelperRegistration(status: .enabled) - service.unregisterError = UnregisterFailure() - let migration = makeMigration(service, world, directory) - await migration.run() - #expect(migration.running == false) - #expect(migration.record?.outcome == .failed) - #expect(migration.needsAttention) - #expect(migration.record?.message.contains("Operation not permitted") == true) - #expect(world.reverts == 0) - } + // Real SMAppService.unregister() with the helper executable already missing from the app bundle + // can't be faked here. Only the attended upgrade test covers it; these tests cover what the + // migration does with whatever unregister and launchd report. + @Test(arguments: [SMAppService.Status.enabled, .requiresApproval]) + func aRegisteredHelperIsUnregisteredAndItsJobConfirmedGone(status: SMAppService.Status) + async throws + { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld() + let service = FakeHelperRegistration(status: status) + service.onUnregister = { world.job = .absent } + let migration = makeMigration(service, world, directory) + await migration.run() + #expect(service.unregisterCalls == 1) + // One read before unregistering, at least one more to confirm the job left launchd. + #expect(world.jobReads >= 2) + // Sleep was read before the removal and again after it. + #expect(world.powerReads >= 1) + #expect(world.reverts == 0) + #expect(migration.record?.outcome == .removed) + #expect(migration.needsAttention == false) + // The result outlives the launch, so Settings can show it. + let reopened = makeMigration(service, world, directory) + #expect(reopened.record?.outcome == .removed) + reopened.dismiss() + #expect(reopened.record == nil) + #expect(makeMigration(service, world, directory).record == nil) + } - @Test func anUnregisterErrorIsIgnoredWhenTheJobIsGoneAnyway() async { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld(job: .absent) - let service = FakeHelperRegistration(status: .requiresApproval) - service.unregisterError = UnregisterFailure() - let migration = makeMigration(service, world, directory) - await migration.run() - #expect(migration.record?.outcome == .removed) - } + @Test func unregisterSucceedingWhileTheJobIsStillLoadedIsAFailure() async { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld(job: .loaded, sleepDisabled: true) + let service = FakeHelperRegistration(status: .enabled) + let migration = makeMigration(service, world, directory, attempts: 3) + await migration.run() + #expect(service.unregisterCalls == 1) + // 1 initial read + 3 confirmation polls. + #expect(world.jobReads == 4) + #expect(migration.record?.outcome == .failed) + #expect(migration.record?.message.contains("launchd still lists the job") == true) + #expect(world.reverts == 0) + } - @Test func aJobThatNeverLeavesLaunchdIsAFailureAfterBoundedPolling() async { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld(job: .unknown) - let service = FakeHelperRegistration(status: .enabled) - let migration = makeMigration(service, world, directory, attempts: 4) - await migration.run() - #expect(migration.record?.outcome == .failed) - #expect(migration.record?.message.contains("launchd still lists the job") == true) - } + @Test(arguments: [SMAppService.Status.notRegistered, .notFound]) + func nothingRegisteredAndNoJobIsANoOp(status: SMAppService.Status) async { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld(job: .absent, sleepDisabled: true) + let service = FakeHelperRegistration(status: status) + let migration = makeMigration(service, world, directory) + await migration.run() + #expect(service.unregisterCalls == 0) + #expect(migration.record == nil) + #expect(world.reverts == 0) + #expect(!FileManager.default.fileExists(atPath: directory.path)) + } - @Test func sleepLeftOffByTheHelperIsRevertedThroughTheSudoPath() async { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld(sleepDisabled: true) - let service = FakeHelperRegistration(status: .enabled) - service.onUnregister = { world.job = .absent } - let migration = makeMigration(service, world, directory) - await migration.run() - #expect(world.reverts == 1) - #expect(migration.record?.outcome == .restoredSleep) - #expect(world.readback.confirmedDisabled) - } + @Test func aLoadedJobIsMigratedEvenWhenTheServiceReportsNotRegistered() async { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld() + let service = FakeHelperRegistration(status: .notRegistered) + service.onUnregister = { world.job = .absent } + let migration = makeMigration(service, world, directory) + await migration.run() + #expect(service.unregisterCalls == 1) + #expect(migration.record?.outcome == .removed) + } - @Test func aFailedRevertSurfacesRestoreNormalSleepAndClearsOnceItWorks() async { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld(sleepDisabled: true) - world.revertWorks = false - let service = FakeHelperRegistration(status: .enabled) - service.onUnregister = { world.job = .absent } - let migration = makeMigration(service, world, directory) - await migration.run() - #expect(migration.record?.outcome == .needsRestore) - #expect(migration.needsAttention) - migration.sleepRestored() - #expect(migration.record?.outcome == .restoredSleep && !migration.needsAttention) - // sleepRestored only touches a pending restore. - migration.dismiss() - migration.sleepRestored() - #expect(migration.record == nil) - } + @Test func anUnregisterFailureThatLeavesTheJobIsRecordedWithoutThrowingOrTouchingSleep() async { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld(sleepDisabled: true) + let service = FakeHelperRegistration(status: .enabled) + service.unregisterError = UnregisterFailure() + let migration = makeMigration(service, world, directory) + await migration.run() + #expect(migration.running == false) + #expect(migration.record?.outcome == .failed) + #expect(migration.needsAttention) + #expect(migration.record?.message.contains("Operation not permitted") == true) + #expect(world.reverts == 0) + } - @Test func sleepDisabledByADilatedSessionIsLeftToTheSessionGuard() async { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld(sleepDisabled: true) - world.marker = true - let service = FakeHelperRegistration(status: .enabled) - service.onUnregister = { world.job = .absent } - let migration = makeMigration(service, world, directory) - await migration.run() - #expect(world.reverts == 0) - #expect(migration.record?.outcome == .removed) - } + @Test func anUnregisterErrorIsIgnoredWhenTheJobIsGoneAnyway() async { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld(job: .absent) + let service = FakeHelperRegistration(status: .requiresApproval) + service.unregisterError = UnregisterFailure() + let migration = makeMigration(service, world, directory) + await migration.run() + #expect(migration.record?.outcome == .removed) + } - @Test func aStaleFailureIsClearedOnceNothingIsRegisteredAndTheJobIsGone() async { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld() - let failing = FakeHelperRegistration(status: .enabled) - failing.unregisterError = UnregisterFailure() - await makeMigration(failing, world, directory).run() - #expect(makeMigration(failing, world, directory).record?.outcome == .failed) - world.job = .absent - let later = makeMigration(FakeHelperRegistration(status: .notRegistered), world, directory) - await later.run() - #expect(later.record == nil) - #expect(makeMigration(failing, world, directory).record == nil) - } + @Test func aJobThatNeverLeavesLaunchdIsAFailureAfterBoundedPolling() async { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld(job: .unknown) + let service = FakeHelperRegistration(status: .enabled) + let migration = makeMigration(service, world, directory, attempts: 4) + await migration.run() + #expect(migration.record?.outcome == .failed) + #expect(migration.record?.message.contains("launchd still lists the job") == true) + } - @Test func aFailureIsKeptWhileLaunchdStateIsUnknown() async { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld(job: .loaded) - let failing = FakeHelperRegistration(status: .enabled) - failing.unregisterError = UnregisterFailure() - await makeMigration(failing, world, directory).run() - world.job = .unknown - let later = makeMigration(FakeHelperRegistration(status: .notRegistered), world, directory) - await later.run() - #expect(later.record?.outcome == .failed) - } + @Test func sleepLeftOffByTheHelperIsRevertedThroughTheSudoPath() async { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld(sleepDisabled: true) + let service = FakeHelperRegistration(status: .enabled) + service.onUnregister = { world.job = .absent } + let migration = makeMigration(service, world, directory) + await migration.run() + #expect(world.reverts == 1) + #expect(migration.record?.outcome == .restoredSleep) + #expect(world.readback.confirmedDisabled) + } - @Test func aPendingRestoreIsClearedOnlyOnceSleepReadsNormal() async { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld(sleepDisabled: true) - world.revertWorks = false - let service = FakeHelperRegistration(status: .enabled) - service.onUnregister = { world.job = .absent } - await makeMigration(service, world, directory).run() - let stillOff = makeMigration( - FakeHelperRegistration(status: .notRegistered), world, directory) - await stillOff.run() - #expect(stillOff.record?.outcome == .needsRestore) - world.revertWorks = true - _ = world.revert() - let restored = makeMigration( - FakeHelperRegistration(status: .notRegistered), world, directory) - await restored.run() - #expect(restored.record == nil) - } + @Test func aFailedRevertSurfacesRestoreNormalSleepAndClearsOnceItWorks() async { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld(sleepDisabled: true) + world.revertWorks = false + let service = FakeHelperRegistration(status: .enabled) + service.onUnregister = { world.job = .absent } + let migration = makeMigration(service, world, directory) + await migration.run() + #expect(migration.record?.outcome == .needsRestore) + #expect(migration.needsAttention) + migration.sleepRestored() + #expect(migration.record?.outcome == .restoredSleep && !migration.needsAttention) + // sleepRestored only touches a pending restore. + migration.dismiss() + migration.sleepRestored() + #expect(migration.record == nil) + } - @Test func helperJobStillPresentIsTrueUnlessLaunchdConfirmsAbsent() async { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld(job: .absent) - let migration = makeMigration(FakeHelperRegistration(status: .notRegistered), world, directory) - #expect(await migration.helperJobStillPresent() == false) - world.job = .unknown - #expect(await migration.helperJobStillPresent()) - world.job = .loaded - #expect(await migration.helperJobStillPresent()) - } + @Test func sleepDisabledByADilatedSessionIsLeftToTheSessionGuard() async { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld(sleepDisabled: true) + world.marker = true + let service = FakeHelperRegistration(status: .enabled) + service.onUnregister = { world.job = .absent } + let migration = makeMigration(service, world, directory) + await migration.run() + #expect(world.reverts == 0) + #expect(migration.record?.outcome == .removed) + } - @Test func theRecordSurvivesUndecodableFilesAndIgnoresThem() throws { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try Data("not json".utf8).write( - to: directory.appendingPathComponent(HelperMigration.fileName)) - let migration = makeMigration( - FakeHelperRegistration(status: .notRegistered), MigrationWorld(job: .absent), directory) - #expect(migration.record == nil) - } + @Test func aStaleFailureIsClearedOnceNothingIsRegisteredAndTheJobIsGone() async { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld() + let failing = FakeHelperRegistration(status: .enabled) + failing.unregisterError = UnregisterFailure() + await makeMigration(failing, world, directory).run() + #expect(makeMigration(failing, world, directory).record?.outcome == .failed) + world.job = .absent + let later = makeMigration(FakeHelperRegistration(status: .notRegistered), world, directory) + await later.run() + #expect(later.record == nil) + #expect(makeMigration(failing, world, directory).record == nil) + } - @Test func aFailedMigrationNeverBlocksLaunchAndShowsAMessage() async { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld() - let service = FakeHelperRegistration(status: .enabled) - service.unregisterError = UnregisterFailure() - let model = AppModel( - endpoint: .init(stateDirectory: directory), power: { .init() }, - closedLid: inertClosedLid(), - helperMigration: makeMigration(service, world, directory), - closedLidPermission: readyPermissionModel()) - defer { model.shutdown() } - model.begin() - // begin() returns before the migration finishes; the result arrives afterwards. - for _ in 0..<200 where model.helperMigration.record == nil { - try? await Task.sleep(for: .milliseconds(10)) + @Test func aFailureIsKeptWhileLaunchdStateIsUnknown() async { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld(job: .loaded) + let failing = FakeHelperRegistration(status: .enabled) + failing.unregisterError = UnregisterFailure() + await makeMigration(failing, world, directory).run() + world.job = .unknown + let later = makeMigration(FakeHelperRegistration(status: .notRegistered), world, directory) + await later.run() + #expect(later.record?.outcome == .failed) } - #expect(model.helperMigration.record?.outcome == .failed) - for _ in 0..<200 where model.message == nil { - try? await Task.sleep(for: .milliseconds(10)) + + @Test func aPendingRestoreIsClearedOnlyOnceSleepReadsNormal() async { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld(sleepDisabled: true) + world.revertWorks = false + let service = FakeHelperRegistration(status: .enabled) + service.onUnregister = { world.job = .absent } + await makeMigration(service, world, directory).run() + let stillOff = makeMigration( + FakeHelperRegistration(status: .notRegistered), world, directory) + await stillOff.run() + #expect(stillOff.record?.outcome == .needsRestore) + world.revertWorks = true + _ = world.revert() + let restored = makeMigration( + FakeHelperRegistration(status: .notRegistered), world, directory) + await restored.run() + #expect(restored.record == nil) } - #expect(model.message?.contains("old background helper") == true) - model.start(duration: 60) - #expect(model.engine.holds.count == 1) - } - @Test func aSavedRestoreNoticeShowsAgainAtEveryLaunch() async throws { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld(sleepDisabled: true) - world.revertWorks = false - let service = FakeHelperRegistration(status: .enabled) - service.onUnregister = { world.job = .absent } - await makeMigration(service, world, directory).run() - // A restart: the record is on disk, nothing is registered, sleep is still off. - let model = AppModel( - endpoint: .init(stateDirectory: directory), power: { .init() }, - closedLid: inertClosedLid(), - helperMigration: makeMigration( - FakeHelperRegistration(status: .notRegistered), world, directory), - closedLidPermission: readyPermissionModel()) - defer { model.shutdown() } - model.begin() - // Set synchronously, before the migration task has run. - #expect(model.message?.contains("Restore normal sleep") == true) - } + @Test func helperJobStillPresentIsTrueUnlessLaunchdConfirmsAbsent() async { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld(job: .absent) + let migration = makeMigration( + FakeHelperRegistration(status: .notRegistered), world, directory) + #expect(await migration.helperJobStillPresent() == false) + world.job = .unknown + #expect(await migration.helperJobStillPresent()) + world.job = .loaded + #expect(await migration.helperJobStillPresent()) + } - @Test func packagePreparationRetriesTheMigrationAndRefusesWhileTheJobRemains() async throws { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - let world = MigrationWorld() - let service = FakeHelperRegistration(status: .enabled) - service.unregisterError = UnregisterFailure() - let model = AppModel( - endpoint: .init(stateDirectory: directory), power: { .init() }, - closedLid: inertClosedLid(), - helperMigration: makeMigration(service, world, directory), - closedLidPermission: readyPermissionModel()) - defer { model.shutdown() } - let app = URL(fileURLWithPath: "/Applications/Dilated.app") - #expect(!(await model.prepareForPackage(.update, app: app))) - #expect(service.unregisterCalls == 1) - #expect(model.message?.contains("Operation not permitted") == true) - #expect(throws: (any Error).self) { - try PackageHandoffStore(directory: directory).requirePrepared(app: app) + @Test func theRecordSurvivesUndecodableFilesAndIgnoresThem() throws { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("not json".utf8).write( + to: directory.appendingPathComponent(HelperMigration.fileName)) + let migration = makeMigration( + FakeHelperRegistration(status: .notRegistered), MigrationWorld(job: .absent), directory) + #expect(migration.record == nil) } - // The user fixed it; the next attempt retries and goes through. - service.unregisterError = nil - service.onUnregister = { world.job = .absent } - #expect(await model.prepareForPackage(.update, app: app)) - #expect(service.unregisterCalls == 2) - let handoff = try PackageHandoffStore(directory: directory).requirePrepared(app: app) - #expect(handoff.helperWasApproved) - } - @Test func anApprovedHelperWithNoUsableSudoRuleGetsAOneTimeNotice() async throws { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - try PackageHandoffStore(directory: directory).save( - .init( - intent: .update, appPath: "/Applications/Dilated.app", preparedAt: Date(), - helperWasApproved: true)) - func launch() async -> String? { + @Test func aFailedMigrationNeverBlocksLaunchAndShowsAMessage() async { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld() + let service = FakeHelperRegistration(status: .enabled) + service.unregisterError = UnregisterFailure() let model = AppModel( endpoint: .init(stateDirectory: directory), power: { .init() }, - closedLid: inertClosedLid(), helperMigration: inertMigration(directory), - closedLidPermission: missingPermissionModel()) + closedLid: inertClosedLid(), + helperMigration: makeMigration(service, world, directory), + closedLidPermission: readyPermissionModel()) defer { model.shutdown() } model.begin() + // begin() returns before the migration finishes; the result arrives afterwards. + for _ in 0..<200 where model.helperMigration.record == nil { + try? await Task.sleep(for: .milliseconds(10)) + } + #expect(model.helperMigration.record?.outcome == .failed) for _ in 0..<200 where model.message == nil { try? await Task.sleep(for: .milliseconds(10)) } - return model.message + #expect(model.message?.contains("old background helper") == true) + model.start(duration: 60) + #expect(model.engine.holds.count == 1) + } + + @Test func aSavedRestoreNoticeShowsAgainAtEveryLaunch() async throws { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld(sleepDisabled: true) + world.revertWorks = false + let service = FakeHelperRegistration(status: .enabled) + service.onUnregister = { world.job = .absent } + await makeMigration(service, world, directory).run() + // A restart: the record is on disk, nothing is registered, sleep is still off. + let model = AppModel( + endpoint: .init(stateDirectory: directory), power: { .init() }, + closedLid: inertClosedLid(), + helperMigration: makeMigration( + FakeHelperRegistration(status: .notRegistered), world, directory), + closedLidPermission: readyPermissionModel()) + defer { model.shutdown() } + model.begin() + // Set synchronously, before the migration task has run. + #expect(model.message?.contains("Restore normal sleep") == true) + } + + @Test func packagePreparationRetriesTheMigrationAndRefusesWhileTheJobRemains() async throws { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let world = MigrationWorld() + let service = FakeHelperRegistration(status: .enabled) + service.unregisterError = UnregisterFailure() + let model = AppModel( + endpoint: .init(stateDirectory: directory), power: { .init() }, + closedLid: inertClosedLid(), + helperMigration: makeMigration(service, world, directory), + closedLidPermission: readyPermissionModel()) + defer { model.shutdown() } + let app = URL(fileURLWithPath: "/Applications/Dilated.app") + #expect(!(await model.prepareForPackage(.update, app: app))) + #expect(service.unregisterCalls == 1) + #expect(model.message?.contains("Operation not permitted") == true) + #expect(throws: (any Error).self) { + try PackageHandoffStore(directory: directory).requirePrepared(app: app) + } + // The user fixed it; the next attempt retries and goes through. + service.unregisterError = nil + service.onUnregister = { world.job = .absent } + #expect(await model.prepareForPackage(.update, app: app)) + #expect(service.unregisterCalls == 2) + let handoff = try PackageHandoffStore(directory: directory).requirePrepared(app: app) + #expect(handoff.helperWasApproved) } - #expect(await launch() == AppModel.helperApprovalNotice) - #expect(await launch() == nil) - } - @Test func noNoticeWhenTheSudoRuleIsAlreadyUsable() async throws { - let directory = scratch() - defer { try? FileManager.default.removeItem(at: directory) } - try PackageHandoffStore(directory: directory).save( - .init( - intent: .update, appPath: "/Applications/Dilated.app", preparedAt: Date(), - helperWasApproved: true)) - let permission = readyPermissionModel() - let model = AppModel( - endpoint: .init(stateDirectory: directory), power: { .init() }, - closedLid: inertClosedLid(), helperMigration: inertMigration(directory), - closedLidPermission: permission) - defer { model.shutdown() } - model.begin() - for _ in 0..<200 where permission.state == nil { - try? await Task.sleep(for: .milliseconds(10)) + @Test func anApprovedHelperWithNoUsableSudoRuleGetsAOneTimeNotice() async throws { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + try PackageHandoffStore(directory: directory).save( + .init( + intent: .update, appPath: "/Applications/Dilated.app", preparedAt: Date(), + helperWasApproved: true)) + func launch() async -> String? { + let model = AppModel( + endpoint: .init(stateDirectory: directory), power: { .init() }, + closedLid: inertClosedLid(), helperMigration: inertMigration(directory), + closedLidPermission: missingPermissionModel()) + defer { model.shutdown() } + model.begin() + for _ in 0..<200 where model.message == nil { + try? await Task.sleep(for: .milliseconds(10)) + } + return model.message + } + #expect(await launch() == AppModel.helperApprovalNotice) + #expect(await launch() == nil) + } + + @Test func noNoticeWhenTheSudoRuleIsAlreadyUsable() async throws { + let directory = scratch() + defer { try? FileManager.default.removeItem(at: directory) } + try PackageHandoffStore(directory: directory).save( + .init( + intent: .update, appPath: "/Applications/Dilated.app", preparedAt: Date(), + helperWasApproved: true)) + let permission = readyPermissionModel() + let model = AppModel( + endpoint: .init(stateDirectory: directory), power: { .init() }, + closedLid: inertClosedLid(), helperMigration: inertMigration(directory), + closedLidPermission: permission) + defer { model.shutdown() } + model.begin() + for _ in 0..<200 where permission.state == nil { + try? await Task.sleep(for: .milliseconds(10)) + } + #expect(permission.state == .ready) + #expect(model.message == nil) } - #expect(permission.state == .ready) - #expect(model.message == nil) } -} +#endif diff --git a/Tests/DilatedUITests/MASChannelTests.swift b/Tests/DilatedUITests/MASChannelTests.swift new file mode 100644 index 0000000..4d3a525 --- /dev/null +++ b/Tests/DilatedUITests/MASChannelTests.swift @@ -0,0 +1,265 @@ +import AppKit +import DilatedCore +import DilatedUpdates +import Foundation +import SwiftUI +import Testing +import ViewInspector + +@testable import DilatedUI + +@MainActor private final class ChannelUpdateDriver: UpdateDriving { + weak var delegate: (any UpdateDriverDelegate)? + var onChange: (() -> Void)? + var canCheckForUpdates = true + var automaticallyChecksForUpdates = false + func start() throws {} + func checkForUpdates() {} +} + +/// Every string a mounted view shows. +@MainActor private func shownStrings(_ view: some View) throws -> [String] { + try view.inspect().findAll(ViewType.Text.self).compactMap { try? $0.string() } +} + +@MainActor private func channelModel(_ directory: URL, withUpdateDriver: Bool = false) -> AppModel { + let updater = + withUpdateDriver ? AppUpdater(driver: ChannelUpdateDriver(), activity: { .idle }) : nil + let model = AppModel( + endpoint: .init(stateDirectory: directory), power: { .init() }, updater: updater) + model.settings.onboardingComplete = true + return model +} + +@MainActor private func channelDirectory() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) +} + +#if DEBUG + @MainActor private func renderChannelView(_ view: NSView, size: NSSize) throws { + view.setFrameSize(size) + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) + view.layoutSubtreeIfNeeded() + } + + /// Mounts the settings content on `page` and returns every string it shows. + @MainActor private func settingsStrings(on page: SettingsPage, model: AppModel) throws + -> [String] + { + _ = NSApplication.shared + FontRegistry.registerBundledFonts() + let navigation = SettingsNavigation() + navigation.show(page) + var mounted: SettingsContent? + defer { mounted = nil } + var content = SettingsContent(model: model, solid: true, navigation: navigation) + content.didAppear = { mounted = $0 } + let size = SettingsTheme.minimumSize + let view = NSHostingView(rootView: content.frame(width: size.width, height: size.height)) + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: size), styleMask: [], backing: .buffered, + defer: false) + window.isReleasedWhenClosed = false + window.contentView = view + defer { window.close() } + try renderChannelView(view, size: size) + let live = try #require(mounted) + return try shownStrings(live) + } + + /// What the trigger editor shows for a rule holding `criterion`: the condition picker's options, every string, and the + /// text field placeholders. + @MainActor private func editorContents(for criterion: TriggerCriterion) throws -> ( + options: [String], shown: [String], fields: [String] + ) { + _ = NSApplication.shared + FontRegistry.registerBundledFonts() + let rule = TriggerRule(name: "Channel", criteria: [criterion]) + var mounted: (any View)? + defer { mounted = nil } + var editor = TriggerRuleEditor(rule: rule, save: { _ in }, remove: {}) + editor.criterionDidAppear = { mounted = $0 } + let host = NSHostingView(rootView: editor) + try renderChannelView(host, size: NSSize(width: 560, height: 600)) + let live = try #require(mounted) + let picker = try live.inspect().find(ViewType.Picker.self) { + try $0.labelView().text().string() == "Condition" + } + let options = try picker.findAll(ViewType.Text.self).compactMap { try? $0.string() } + let fields = try live.inspect().findAll(ViewType.TextField.self).compactMap { + try? $0.labelView().text().string() + } + return (options, try shownStrings(live), fields) + } +#endif + +#if MAS_CHANNEL + @MainActor struct MASChannelSurfaceTests { + @Test func channelIsMAS() { + #expect(Distribution.current == .mas) + } + + @Test func menuHasNoSupportOrUpdateItems() { + let directory = channelDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let model = channelModel(directory, withUpdateDriver: true) + defer { model.shutdown() } + #expect(model.updater.isAvailable) + let menu = AwakeMenu.make(model: model, openSettings: {}) + let titles = menu.items.map(\.title) + #expect(titles.contains("About Dilated")) + #expect(titles.contains("Quit Dilated")) + #expect(!titles.contains("Support Dilated")) + #expect(!titles.contains("Check for Updates…")) + let submenuTitles = menu.items.flatMap { $0.submenu?.items.map(\.title) ?? [] } + #expect(!submenuTitles.contains("Donate")) + #expect(!submenuTitles.contains("GitHub Sponsors")) + } + + @Test func aboutPageHasNoCreditsLineOrUpdateRow() throws { + _ = NSApplication.shared + let directory = channelDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let model = channelModel(directory, withUpdateDriver: true) + defer { model.shutdown() } + let strings = try shownStrings(AboutSettings(model: model)) + #expect(strings.contains("Website")) + #expect(!strings.contains { $0.contains("free and open source") }) + #expect(!strings.contains { $0.contains("donate") || $0.contains("GitHub Sponsors") }) + #expect(!strings.contains("App updates")) + #expect(!strings.contains("Check for Updates…")) + } + + @Test func updaterExplainsThatUpdatesComeFromTheAppStore() { + let updater = AppUpdater(driver: nil, activity: { .idle }) + #expect(!updater.isAvailable) + #expect(updater.statusText == "Updates come from the Mac App Store.") + #expect(UpdateDriverFactory.makeDriver() == nil) + } + + @Test func helperMigrationCannotBeInjected() { + #expect(HelperMigrationInjection.self == Never.self) + } + + @Test func triggerMonitorIgnoresProcessTriggers() { + let monitor = TriggerMonitor(readPower: { .init() }, changed: {}) + defer { monitor.stop() } + let pid = ProcessInfo.processInfo.processIdentifier + monitor.configure(rules: [ + TriggerRule( + name: "Process", + criteria: [ + .process(name: "/bin/sleep"), + .processInstance(.init(pid: pid, startedAtMicroseconds: 1)), + ]) + ]) + let input = monitor.read() + #expect(input.processNames.isEmpty) + #expect(input.processInstances.isEmpty) + } + + @Test func storedProcessTriggersSurviveAndNeverMatch() throws { + let rule = TriggerRule(name: "Keep", criteria: [.process(name: "make")]) + let data = try JSONEncoder().encode(rule) + #expect(try JSONDecoder().decode(TriggerRule.self, from: data) == rule) + #expect(!rule.matches(TriggerInputs(), now: Date())) + } + + #if DEBUG + @Test func generalSettingsHaveNoUpdateControls() throws { + let directory = channelDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let model = channelModel(directory, withUpdateDriver: true) + defer { model.shutdown() } + let strings = try settingsStrings(on: .general, model: model) + #expect(strings.contains("Menu bar clicks")) + for title in ["App updates", "Check for Updates…", "Check automatically", "UPDATES"] { + #expect(!strings.contains(title)) + } + } + + @Test func automationSettingsHaveNoCommandLineInstallRow() throws { + let directory = channelDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let model = channelModel(directory) + defer { model.shutdown() } + let strings = try settingsStrings(on: .automation, model: model) + #expect(strings.contains("Allow automation links")) + #expect(!strings.contains("Command line tool")) + } + + @Test func triggerEditorOffersNoProcessKind() throws { + let result = try editorContents(for: .power(onBattery: false)) + #expect(result.options.contains("Application")) + #expect(!result.options.contains("Process")) + #expect(!result.options.contains("Specific process")) + } + + @Test func savedProcessTriggerShowsNoProcessEditor() throws { + let named = try editorContents(for: .process(name: "make")) + #expect(!named.fields.contains("Executable name or full path")) + #expect(!named.shown.contains("Refresh processes")) + #expect(named.shown.contains { $0.contains("aren't available") }) + let instance = try editorContents( + for: .processInstance(.init(pid: 1, startedAtMicroseconds: 1))) + #expect(!instance.shown.contains("Refresh processes")) + #expect(instance.shown.contains { $0.contains("aren't available") }) + } + #endif + } +#else + @MainActor struct DirectChannelSurfaceTests { + @Test func channelIsDirect() { + #expect(Distribution.current == .direct) + } + + @Test func menuKeepsSupportAndUpdateItems() { + let directory = channelDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let model = channelModel(directory, withUpdateDriver: true) + defer { model.shutdown() } + let titles = AwakeMenu.make(model: model, openSettings: {}).items.map(\.title) + #expect(titles.contains("Support Dilated")) + #expect(titles.contains("Check for Updates…")) + } + + @Test func aboutPageKeepsCreditsLineAndUpdateRow() throws { + _ = NSApplication.shared + let directory = channelDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let model = channelModel(directory, withUpdateDriver: true) + defer { model.shutdown() } + let strings = try shownStrings(AboutSettings(model: model)) + #expect(strings.contains("App updates")) + #expect(strings.contains { $0.contains("free and open source") }) + } + + @Test func updaterKeepsTheTestBuildMessage() { + let updater = AppUpdater(driver: nil, activity: { .idle }) + #expect(updater.statusText == "Updates aren't enabled in this test build.") + } + + #if DEBUG + @Test func settingsKeepUpdateAndCommandLineControls() throws { + let directory = channelDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let model = channelModel(directory, withUpdateDriver: true) + defer { model.shutdown() } + let general = try settingsStrings(on: .general, model: model) + #expect(general.contains("App updates")) + #expect(general.contains("Check automatically")) + let automation = try settingsStrings(on: .automation, model: model) + #expect(automation.contains("Command line tool")) + } + + @Test func triggerEditorOffersProcessKinds() throws { + let result = try editorContents(for: .power(onBattery: false)) + #expect(result.options.contains("Process")) + #expect(result.options.contains("Specific process")) + } + #endif + } +#endif diff --git a/Tests/DilatedUITests/MenuTests.swift b/Tests/DilatedUITests/MenuTests.swift index 9a021fa..f0c6da5 100644 --- a/Tests/DilatedUITests/MenuTests.swift +++ b/Tests/DilatedUITests/MenuTests.swift @@ -218,76 +218,81 @@ import Testing #expect(menu.items.contains { $0.title == model.attention && !$0.isEnabled }) } - @Test func supportSubmenuSitsBetweenAboutAndQuit() throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let model = AppModel(endpoint: .init(stateDirectory: directory), power: { .init() }) - defer { model.shutdown() } - let menu = AwakeMenu.make(model: model, openSettings: {}) - let titles = menu.items.map(\.title) - let about = try #require(titles.firstIndex(of: "About Dilated")) - #expect(titles[about + 1] == "Support Dilated") - #expect(titles[about + 2] == "Quit Dilated") - let support = menu.items[about + 1] - #expect(support.image == nil) - let submenu = try #require(support.submenu) - #expect(submenu.items.map(\.title) == ["GitHub Sponsors", "Donate"]) - #expect( - SupportLinks.all.map(\.url.absoluteString) == [ - "https://github.com/sponsors/CodesWhat", "https://codeswhat.com/support", - ]) - #expect(submenu.items.allSatisfy { $0.image == nil && $0.action != nil && $0.target != nil }) - if let directory = ProcessInfo.processInfo.environment["SUPPORT_RENDER_DIR"] { - try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) - let dump = (titles + ["Support Dilated >"] + submenu.items.map { " " + $0.title }) - .joined(separator: "\n") - try dump.write( - to: URL(fileURLWithPath: directory).appendingPathComponent("menu.txt"), atomically: true, - encoding: .utf8) + #if !MAS_CHANNEL + @Test func supportSubmenuSitsBetweenAboutAndQuit() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = AppModel(endpoint: .init(stateDirectory: directory), power: { .init() }) + defer { model.shutdown() } + let menu = AwakeMenu.make(model: model, openSettings: {}) + let titles = menu.items.map(\.title) + let about = try #require(titles.firstIndex(of: "About Dilated")) + #expect(titles[about + 1] == "Support Dilated") + #expect(titles[about + 2] == "Quit Dilated") + let support = menu.items[about + 1] + #expect(support.image == nil) + let submenu = try #require(support.submenu) + #expect(submenu.items.map(\.title) == ["GitHub Sponsors", "Donate"]) + #expect( + SupportLinks.all.map(\.url.absoluteString) == [ + "https://github.com/sponsors/CodesWhat", "https://codeswhat.com/support", + ]) + #expect(submenu.items.allSatisfy { $0.image == nil && $0.action != nil && $0.target != nil }) + if let directory = ProcessInfo.processInfo.environment["SUPPORT_RENDER_DIR"] { + try FileManager.default.createDirectory( + atPath: directory, withIntermediateDirectories: true) + let dump = (titles + ["Support Dilated >"] + submenu.items.map { " " + $0.title }) + .joined(separator: "\n") + try dump.write( + to: URL(fileURLWithPath: directory).appendingPathComponent("menu.txt"), atomically: true, + encoding: .utf8) + } } - } - @Test func checkForUpdatesSitsBetweenSettingsAndAboutOnlyWhenAvailable() throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let plain = AppModel( - endpoint: .init(stateDirectory: directory), power: { .init() }, - updater: AppUpdater(driver: nil, activity: { .idle })) - defer { plain.shutdown() } - #expect( - !AwakeMenu.make(model: plain, openSettings: {}).items.contains { - $0.title == "Check for Updates…" - }) + @Test func checkForUpdatesSitsBetweenSettingsAndAboutOnlyWhenAvailable() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let plain = AppModel( + endpoint: .init(stateDirectory: directory), power: { .init() }, + updater: AppUpdater(driver: nil, activity: { .idle })) + defer { plain.shutdown() } + #expect( + !AwakeMenu.make(model: plain, openSettings: {}).items.contains { + $0.title == "Check for Updates…" + }) - let driver = MenuUpdateDriver() - let model = AppModel( - endpoint: .init(stateDirectory: directory), power: { .init() }, - updater: AppUpdater(driver: driver, activity: { .idle })) - defer { model.shutdown() } - model.updater.start() - let menu = AwakeMenu.make(model: model, openSettings: {}) - let titles = menu.items.map(\.title) - let settings = try #require(titles.firstIndex(of: "Settings…")) - #expect(titles[settings + 1] == "Check for Updates…") - #expect(titles[settings + 2] == "About Dilated") - let item = menu.items[settings + 1] - #expect(item.isEnabled == model.updater.canCheckForUpdates) + let driver = MenuUpdateDriver() + let model = AppModel( + endpoint: .init(stateDirectory: directory), power: { .init() }, + updater: AppUpdater(driver: driver, activity: { .idle })) + defer { model.shutdown() } + model.updater.start() + let menu = AwakeMenu.make(model: model, openSettings: {}) + let titles = menu.items.map(\.title) + let settings = try #require(titles.firstIndex(of: "Settings…")) + #expect(titles[settings + 1] == "Check for Updates…") + #expect(titles[settings + 2] == "About Dilated") + let item = menu.items[settings + 1] + #expect(item.isEnabled == model.updater.canCheckForUpdates) - model.onChange = { AwakeMenu.refreshStatus(in: menu, model: model) } - driver.canCheckForUpdates = false - driver.onChange?() - #expect(!model.updater.canCheckForUpdates) - let disabled = AwakeMenu.make(model: model, openSettings: {}) - #expect(disabled.items.first { $0.title == "Check for Updates…" }?.isEnabled == false) - #expect(!item.isEnabled) + model.onChange = { AwakeMenu.refreshStatus(in: menu, model: model) } + driver.canCheckForUpdates = false + driver.onChange?() + #expect(!model.updater.canCheckForUpdates) + let disabled = AwakeMenu.make(model: model, openSettings: {}) + #expect(disabled.items.first { $0.title == "Check for Updates…" }?.isEnabled == false) + #expect(!item.isEnabled) - driver.canCheckForUpdates = true - driver.onChange?() - #expect(item.isEnabled) - let target = try #require(item.target as AnyObject?) - _ = target.perform(item.action) - #expect(driver.checks == 1) - } + driver.canCheckForUpdates = true + driver.onChange?() + #expect(item.isEnabled) + let target = try #require(item.target as AnyObject?) + _ = target.perform(item.action) + #expect(driver.checks == 1) + } + #endif @Test func nativeReasonsMenuStopsOnlyTheSelectedHold() throws { _ = NSApplication.shared diff --git a/Tests/DilatedUITests/MountedSettingsTests.swift b/Tests/DilatedUITests/MountedSettingsTests.swift index 2b674ca..7da0a8d 100644 --- a/Tests/DilatedUITests/MountedSettingsTests.swift +++ b/Tests/DilatedUITests/MountedSettingsTests.swift @@ -37,7 +37,7 @@ import ViewInspector #expect(window.firstResponder === editor) } - #if DEBUG + #if DEBUG && !MAS_CHANNEL @Test func specificProcessPickerLoadsWithoutPressingRefresh() throws { _ = NSApplication.shared FontRegistry.registerBundledFonts() diff --git a/Tests/DilatedUITests/TriggerMonitorTests.swift b/Tests/DilatedUITests/TriggerMonitorTests.swift index f9008e2..23383fe 100644 --- a/Tests/DilatedUITests/TriggerMonitorTests.swift +++ b/Tests/DilatedUITests/TriggerMonitorTests.swift @@ -1,66 +1,68 @@ -import Darwin -import DilatedCore -import Foundation -import Testing +#if !MAS_CHANNEL + import Darwin + import DilatedCore + import Foundation + import Testing -@testable import DilatedUI + @testable import DilatedUI -@MainActor struct TriggerMonitorTests { - @Test func nativeProcessExitIsObservedAndNoZombieMatchesRemain() async throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sleep") - process.arguments = ["15"] - try process.run() - defer { - if process.isRunning { - process.terminate() - process.waitUntilExit() + @MainActor struct TriggerMonitorTests { + @Test func nativeProcessExitIsObservedAndNoZombieMatchesRemain() async throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sleep") + process.arguments = ["15"] + try process.run() + defer { + if process.isRunning { + process.terminate() + process.waitUntilExit() + } } + #expect( + ProcessCatalog.read().contains { $0.id == process.processIdentifier && $0.name == "sleep" }) + var changes = 0 + let monitor = TriggerMonitor(readPower: { .init() }, changed: { changes += 1 }) + defer { monitor.stop() } + monitor.configure(rules: [ + TriggerRule(name: "Sleep test", criteria: [.process(name: "/bin/sleep")]) + ]) + #expect(monitor.read().processNames.contains("/bin/sleep")) + process.terminate() + let deadline = ContinuousClock.now.advanced(by: .seconds(3)) + while changes == 0, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(changes > 0) + process.waitUntilExit() + #expect(!ProcessCatalog.read().contains { $0.id == process.processIdentifier }) } - #expect( - ProcessCatalog.read().contains { $0.id == process.processIdentifier && $0.name == "sleep" }) - var changes = 0 - let monitor = TriggerMonitor(readPower: { .init() }, changed: { changes += 1 }) - defer { monitor.stop() } - monitor.configure(rules: [ - TriggerRule(name: "Sleep test", criteria: [.process(name: "/bin/sleep")]) - ]) - #expect(monitor.read().processNames.contains("/bin/sleep")) - process.terminate() - let deadline = ContinuousClock.now.advanced(by: .seconds(3)) - while changes == 0, ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(20)) - } - #expect(changes > 0) - process.waitUntilExit() - #expect(!ProcessCatalog.read().contains { $0.id == process.processIdentifier }) - } - @Test func pickerListSortsByNaturalNameThenPid() { - func native(_ id: Int32, _ name: String) -> NativeProcess { - NativeProcess( - id: id, name: name, path: "", - identity: ProcessIdentity(pid: id, startedAtMicroseconds: 1)) + @Test func pickerListSortsByNaturalNameThenPid() { + func native(_ id: Int32, _ name: String) -> NativeProcess { + NativeProcess( + id: id, name: name, path: "", + identity: ProcessIdentity(pid: id, startedAtMicroseconds: 1)) + } + let sorted = ProcessCatalog.pickerList([ + native(5, "b"), native(9, "a10"), native(3, "a9"), native(7, "a"), native(2, "a"), + ]) + #expect(sorted.map(\.id) == [2, 7, 3, 9, 5]) } - let sorted = ProcessCatalog.pickerList([ - native(5, "b"), native(9, "a10"), native(3, "a9"), native(7, "a"), native(2, "a"), - ]) - #expect(sorted.map(\.id) == [2, 7, 3, 9, 5]) - } - @Test func pickerListContainsLiveProcessWithItsCatalogIdentity() throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sleep") - process.arguments = ["15"] - try process.run() - defer { - if process.isRunning { - process.terminate() - process.waitUntilExit() + @Test func pickerListContainsLiveProcessWithItsCatalogIdentity() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sleep") + process.arguments = ["15"] + try process.run() + defer { + if process.isRunning { + process.terminate() + process.waitUntilExit() + } } + let pid = process.processIdentifier + let identity = try #require(ProcessCatalog.read().first { $0.id == pid }?.identity) + #expect(ProcessCatalog.pickerList().contains { $0.identity == identity }) } - let pid = process.processIdentifier - let identity = try #require(ProcessCatalog.read().first { $0.id == pid }?.identity) - #expect(ProcessCatalog.pickerList().contains { $0.identity == identity }) } -} +#endif diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 29356d3..0c8e71d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -355,3 +355,18 @@ armed run; crash/relaunch never restore arming. Pending replies cannot restore permission after Stop/Resume, disarm or edit. Closed-lid failures remain visible and require a new explicit arm. The native closed-lid and installed UI acceptance gates remain separate from injected tests and offscreen rendering. + +## Mac App Store build + +`DILATED_CHANNEL=mas` defines `MAS_CHANNEL` and drops Sparkle (see `Distribution.current`). These surfaces are +compiled out with `#if MAS_CHANNEL`, not hidden: + +- Donate and sponsor links: `SupportLinks`, the "Support Dilated" menu item and the About credits line. +- Command line install: the Automation row, the `CommandLineInstallation` type and its package-removal call. +- Process triggers: both process kinds in the trigger editor, `ProcessCatalog`, and process evaluation in + `TriggerMonitor`. Saved process rules stay in the data, show a note in the editor and never match. +- Helper migration: `HelperMigration` and its launch, package and Closed Lid settings call sites. +- Update controls: the General updates group, the About update row and both "Check for Updates…" menu items. + `AppUpdater.statusText` reads "Updates come from the Mac App Store." + +`scripts/check-mas-build.sh` builds this channel in `.build-mas`; CI also runs the `MASChannel` tests there. diff --git a/scripts/check-mas-build.sh b/scripts/check-mas-build.sh old mode 100644 new mode 100755 index b0e888d..5d3de54 --- a/scripts/check-mas-build.sh +++ b/scripts/check-mas-build.sh @@ -5,7 +5,7 @@ set -euo pipefail cd "$(dirname "$0")/.." scratch=.build-mas DILATED_CHANNEL=mas swift build --arch arm64 --jobs 4 --scratch-path "$scratch" --product DilatedApp -binary="$scratch/arm64-apple-macosx/debug/DilatedApp" +binary="$(DILATED_CHANNEL=mas swift build --arch arm64 --scratch-path "$scratch" --show-bin-path)/DilatedApp" test -x "$binary" if otool -L "$binary" | grep -qi sparkle; then echo "check-mas-build: $binary links Sparkle" >&2