diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..88eb040 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: Linux CI + +on: + push: + paths-ignore: + - "README.md" + - "Sources/**/*.docc/**" + pull_request: + paths-ignore: + - "README.md" + - "Sources/**/*.docc/**" + workflow_dispatch: + +jobs: + swift-tests: + name: Swift Tests (Swift ${{ matrix.swift }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + swift: ["6.3"] + container: + image: swift:${{ matrix.swift }}-noble + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Run tests + run: swift test diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..dc2e230 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,5 @@ +version: 1 +builder: + configs: + - documentation_targets: + - RuleKit diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..b8ab7ba --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "dbe167f53115cb5783f59cf5e7b26b03c67dd6c0c88404649ba584782dfabcdd", + "pins" : [ + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", + "version" : "1.8.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 2635a63..b6fe1b4 100644 --- a/Package.swift +++ b/Package.swift @@ -9,8 +9,16 @@ let package = Package( products: [ .library(name: "RuleKit", targets: ["RuleKit"]), ], + dependencies: [ + .package(url: "https://github.com/apple/swift-log.git", from: "1.7.0"), + ], targets: [ - .target(name: "RuleKit"), + .target( + name: "RuleKit", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + ] + ), .testTarget(name: "RuleKitTests", dependencies: ["RuleKit"]), ], swiftLanguageModes: [.v6] diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift deleted file mode 100644 index 85f39e6..0000000 --- a/Package@swift-5.9.swift +++ /dev/null @@ -1,22 +0,0 @@ -// swift-tools-version: 5.9 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "RuleKit", - platforms: [.iOS(.v14), .watchOS(.v7), .tvOS(.v14), .macOS(.v11)], - products: [ - .library(name: "RuleKit", targets: ["RuleKit"]), - ], - targets: [ - .target( - name: "RuleKit", - swiftSettings: [.strictConcurrency]), - .testTarget(name: "RuleKitTests", dependencies: ["RuleKit"]), - ] -) - -extension SwiftSetting { - static let strictConcurrency = enableExperimentalFeature("StrictConcurrency") -} diff --git a/README.md b/README.md index 1f80464..ac5fc2b 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ TipKit style API to trigger an arbitrary closure, or a NSNotification based on e - ... ## Requirements -- Swift 5.9+ (Xcode 15+) +- Swift 6.0+ (Xcode 16+) - iOS 14+, iPadOS 14+, tvOS 14+, watchOS 7+, macOS 11+ +- Linux ## Installation @@ -124,9 +125,11 @@ await RuleKit.Event.appStarted.reset() - `.groupContainer(identifier: String)`: Will store your event donations in the shared AppGroup container - `.url(URL)`: Provide your own URL. It should be a directory URL. +> On Linux, prefer `.url(_)`: AppGroup containers don't exist (`.groupContainer` throws) and `.applicationDefault` resolves to `~/Documents`, which may not exist on a server. Note also that `AppVersion.current` is `nil` on Linux (there is no `Info.plist`), so version-based conditions always compare against `nil`. + ### Available options: - `.triggerFrequency(_)`: Throttle down notification donation or using given period -- `.dispatchQueue(_)`: Choose the DispatchQueue you want your notification to be sent from. Defaults to main queue. +- `.dispatchQueue(_)`: Choose the DispatchQueue you want your notification to be sent from. Defaults to the main actor. - `.delay(for: _)` and `.delay(nanoseconds: _)`: Delay the trigger of a specific notification after it was fulfilled. ### Event.Donations properties available in the condition closure: diff --git a/Sources/RuleKit/Center.swift b/Sources/RuleKit/Center.swift index d9bc60e..baef1a2 100644 --- a/Sources/RuleKit/Center.swift +++ b/Sources/RuleKit/Center.swift @@ -26,13 +26,14 @@ // import Foundation -import OSLog +import Dispatch +import Logging @MainActor public final class RuleKit { static var `internal` = RuleKit() - let logger = Logger(subsystem: "RuleKit", category: "logs") + let logger = Logger(label: "RuleKit") private var _store: (any RuleStore)? var store: any RuleStore { @@ -145,8 +146,11 @@ public final class RuleKit { pendingTriggers.removeAll() } - /// Atomically claims the trigger and, if the claim succeeds, fires it on the - /// configured queue. Awaits the execution so callers can observe completion. + /// Atomically claims the trigger and, if the claim succeeds, fires it. Awaits the + /// execution so callers can observe completion. With an explicit `DispatchQueue` + /// option the trigger fires on that queue; otherwise it fires on the main actor + /// (which, unlike `DispatchQueue.main`, is serviced by the concurrency runtime on + /// every platform, so it does not rely on a running main run loop on Linux). private static func claimAndFire(rule: any Rule, trigger: any Trigger, throttle: Calendar.Component?, store: any RuleStore, logger: Logger) async { // Atomically claim the trigger: this records the fire and enforces any // frequency throttle in a single step, so concurrent donations cannot race @@ -161,11 +165,16 @@ public final class RuleKit { logger.error("Claiming trigger \(trigger.rawValue) failed with error: \(error)") return } - let queue = rule.firstOption(ofType: DispatchQueueOption.self)?.queue ?? .main - await withCheckedContinuation { continuation in - queue.async { + if let queue = rule.firstOption(ofType: DispatchQueueOption.self)?.queue { + await withCheckedContinuation { continuation in + queue.async { + trigger.execute() + continuation.resume() + } + } + } else { + await MainActor.run { trigger.execute() - continuation.resume() } } } @@ -197,7 +206,7 @@ public final class RuleKit { // appending. This keeps registration idempotent (e.g. calling setRule on // every view appearance no longer grows the rule list without bound) and // avoids two rules silently sharing one trigger record and throttle. - logger.warning("Replacing the rule already registered for trigger name \"\(trigger.rawValue, privacy: .public)\".") + logger.warning("Replacing the rule already registered for trigger name \"\(trigger.rawValue)\".") rules[index] = (rule, trigger) } else { rules.append((rule, trigger)) diff --git a/Sources/RuleKit/Option.swift b/Sources/RuleKit/Option.swift index 6da7d62..e8a2a7f 100644 --- a/Sources/RuleKit/Option.swift +++ b/Sources/RuleKit/Option.swift @@ -26,6 +26,7 @@ // import Foundation +import Dispatch public protocol RuleKitOption: Sendable { /// If this returns true, the rule will never be fulfilled, and the notification prevented @@ -90,7 +91,7 @@ public struct DispatchQueueOption: RuleKitOption { extension RuleKitOption where Self == DispatchQueueOption { /// Publish the notification on a specific dispatch queue. - /// By default, notification will be sent on the main queue. + /// By default, the notification is sent on the main actor. public static func dispatchQueue(_ queue: DispatchQueue) -> RuleKitOption { DispatchQueueOption(queue: queue) } diff --git a/Sources/RuleKit/Store.swift b/Sources/RuleKit/Store.swift index d5fd671..74ba319 100644 --- a/Sources/RuleKit/Store.swift +++ b/Sources/RuleKit/Store.swift @@ -68,10 +68,18 @@ extension RuleKit { } return url case .groupContainer(identifier: let identifier): + // App group containers are an Apple sandbox concept; + // `containerURL(forSecurityApplicationGroupIdentifier:)` does + // not exist on non-Apple platforms. Use `.url(_)` on Linux. + #if canImport(Darwin) guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier) else { throw Error.missingGroupIdentifier } return url + #else + _ = identifier + throw Error.missingGroupIdentifier + #endif case .url(let url): // The store writes a plist on disk, so the location must be a // file-scheme directory URL. (`isFileURL` is true for any diff --git a/Tests/RuleKitTests/RuleKitTests.swift b/Tests/RuleKitTests/RuleKitTests.swift index 6345f55..ca388d7 100644 --- a/Tests/RuleKitTests/RuleKitTests.swift +++ b/Tests/RuleKitTests/RuleKitTests.swift @@ -185,7 +185,10 @@ struct RuleKitTests { init() async throws { // Configure once; later calls throw storeAlreadyConfigured, which we ignore. - try? RuleKit.configure(storeLocation: .applicationDefault) + // Use a temporary directory rather than .applicationDefault so the suite is + // hermetic and works on every platform (e.g. Linux, where the document + // directory may not exist). + try? RuleKit.configure(storeLocation: .url(URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true))) // The rule list is global; start every test from a clean slate so rules // registered by other tests cannot fire during this one. Also cancel any // delayed triggers a previous test left pending.