diff options
| author | Grégoire Duchêne <gduchene@awhk.org> | 2025-11-02 19:27:32 +0000 |
|---|---|---|
| committer | Grégoire Duchêne <gduchene@awhk.org> | 2025-11-02 19:29:30 +0000 |
| commit | c7d428455c42e8039a6aedeb032b0685d7a47ebb (patch) | |
| tree | f2b2b176fea7d019d578a873fc23af70f54f829a | |
| parent | aee698176a33879e7cb5b23e89157104c4b05c19 (diff) | |
First version of the CLI
It’s pretty basic, and it only works on macOS for now.
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Package.resolved | 78 | ||||
| -rw-r--r-- | Package.swift | 27 | ||||
| -rw-r--r-- | README | 2 | ||||
| -rw-r--r-- | Sources/Main.swift | 30 | ||||
| -rw-r--r-- | Sources/SleepInhibitor.swift | 48 | ||||
| -rw-r--r-- | Sources/Watcher.swift | 58 |
7 files changed, 244 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24e5b0a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.build diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..14edea2 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,78 @@ +{ + "originHash" : "4364692988907e9672535440f83dd48704c3b22f9efcbe4257ebb73390ebea52", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", + "version" : "1.6.2" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "a24771a4c228ff116df343c85fcf3dcfae31a06c", + "version" : "2.88.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "0fcc4c9c2d58dd98504c06f7308c86de775396ff", + "version" : "2.9.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", + "version" : "1.6.3" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..4a1ce2f --- /dev/null +++ b/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "caffeinate-downloads", + platforms: [.macOS(.v13)], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.6.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.6.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.88.0"), + .package(url: "https://github.com/apple/swift-system.git", from: "1.6.0"), + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.9.0"), + ], + targets: [ + .executableTarget( + name: "caffeinate-downloads", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), + .product(name: "SystemPackage", package: "swift-system"), + .product(name: "_NIOFileSystem", package: "swift-nio"), + ] + ) + ] +) @@ -0,0 +1,2 @@ +This is a trivial tool to prevent macOS from sleeping when Firefox is +still downloading files. diff --git a/Sources/Main.swift b/Sources/Main.swift new file mode 100644 index 0000000..9ffff7e --- /dev/null +++ b/Sources/Main.swift @@ -0,0 +1,30 @@ +import ArgumentParser +import Foundation +import Logging +import ServiceLifecycle +import SystemPackage + +@main +struct EntryPoint: AsyncParsableCommand { + @Option(help: "Directory to search for ongoing downloads.", transform: { FilePath($0) }) + var directory = FilePath("\(NSHomeDirectory())/Downloads") + + @Option(help: "Suffix of ongoing downloads.") + var suffix = "part" + + @Flag(help: "Enable verbose output.") + var verbose = false + + func run() async throws { + var logger = Logger(label: "caffeinate-downloads") + if self.verbose { + logger.logLevel = .debug + } + + try await ServiceGroup( + services: [Watcher(directory: self.directory, logger: logger, suffix: self.suffix)], + cancellationSignals: [.sigint, .sigquit], + logger: logger + ).run() + } +} diff --git a/Sources/SleepInhibitor.swift b/Sources/SleepInhibitor.swift new file mode 100644 index 0000000..0cfe895 --- /dev/null +++ b/Sources/SleepInhibitor.swift @@ -0,0 +1,48 @@ +import IOKit.pwr_mgt + +actor SleepInhibitor { + var assertionID = IOPMAssertionID?.none + var isInhibitingSleep: Bool { self.assertionID != nil } + + func create(name: String, details: String) throws { + guard self.assertionID == nil else { + return + } + + var assertionID = IOPMAssertionID(0) + let ioReturn = IOPMAssertionCreateWithDescription( + kIOPMAssertionTypePreventUserIdleSystemSleep as CFString, + name as CFString, + details as CFString, + nil, nil, 0, nil, + &assertionID + ) + guard ioReturn == kIOReturnSuccess else { + throw SleepInhibitorError(ioReturn: ioReturn) + } + self.assertionID = assertionID + } + + func release() throws { + guard let assertionID = self.assertionID else { + return + } + + let ioReturn = IOPMAssertionRelease(assertionID) + guard ioReturn == kIOReturnSuccess else { + throw SleepInhibitorError(ioReturn: ioReturn) + } + self.assertionID = nil + } +} + +struct SleepInhibitorError: CustomStringConvertible, Error { + let ioReturn: IOReturn + + var description: String { + guard let cString = mach_error_string(self.ioReturn) else { + return self.ioReturn.description + } + return String(cString: cString) + } +} diff --git a/Sources/Watcher.swift b/Sources/Watcher.swift new file mode 100644 index 0000000..c7ca85a --- /dev/null +++ b/Sources/Watcher.swift @@ -0,0 +1,58 @@ +import Logging +import ServiceLifecycle +import SystemPackage +import _NIOFileSystem + +struct Watcher: Service { + let directory: FilePath + let logger: Logger + let suffix: String + + func run() async { + let sleepInhibitor = SleepInhibitor() + + while !Task.isCancelled { + let isDownloading: Bool + do { + isDownloading = try await FileSystem.shared.withDirectoryHandle(atPath: self.directory) { + try await $0.listContents().contains { + $0.name.extension?.hasSuffix(self.suffix) ?? false + } + } + } catch is CancellationError { + return + } catch { + self.logger.error("Failed to check directory: \(error)") + return + } + + switch (isDownloading, await sleepInhibitor.isInhibitingSleep) { + case (true, false): + self.logger.debug("Ongoing downloads found, inhibiting sleep") + do { + try await sleepInhibitor.create( + name: "caffeinate-downloads", + details: "There are files being downloaded" + ) + } catch { + self.logger.error("Failed to create assertion: \(error)") + } + + case (false, true): + self.logger.debug("No ongoing downloads found, allowing sleep") + do { + try await sleepInhibitor.release() + } catch { + self.logger.error("Failed to release assertion: \(error)") + } + + default: + break + } + + guard (try? await Task.sleep(for: .seconds(30))) != nil else { + return + } + } + } +} |
