aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGrégoire Duchêne <gduchene@awhk.org>2025-11-02 19:27:32 +0000
committerGrégoire Duchêne <gduchene@awhk.org>2025-11-02 19:29:30 +0000
commitc7d428455c42e8039a6aedeb032b0685d7a47ebb (patch)
treef2b2b176fea7d019d578a873fc23af70f54f829a
parentaee698176a33879e7cb5b23e89157104c4b05c19 (diff)
First version of the CLI
It’s pretty basic, and it only works on macOS for now.
-rw-r--r--.gitignore1
-rw-r--r--Package.resolved78
-rw-r--r--Package.swift27
-rw-r--r--README2
-rw-r--r--Sources/Main.swift30
-rw-r--r--Sources/SleepInhibitor.swift48
-rw-r--r--Sources/Watcher.swift58
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"),
+ ]
+ )
+ ]
+)
diff --git a/README b/README
new file mode 100644
index 0000000..aa3d7a3
--- /dev/null
+++ b/README
@@ -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
+ }
+ }
+ }
+}