Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ permissions:
jobs:
verify:
runs-on: macos-26
timeout-minutes: 15
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
with:
Expand All @@ -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
Expand Down
93 changes: 64 additions & 29 deletions Sources/DilatedUI/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
) {
Expand All @@ -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
Expand Down Expand Up @@ -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?()
Expand All @@ -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
{
Expand Down Expand Up @@ -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.")
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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?()
}
Expand Down
10 changes: 6 additions & 4 deletions Sources/DilatedUI/ApplicationDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
156 changes: 84 additions & 72 deletions Sources/DilatedUI/Automation/TriggerMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading