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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .spi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
version: 1
builder:
configs:
- documentation_targets:
- RuleKit
15 changes: 15 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
22 changes: 0 additions & 22 deletions Package@swift-5.9.swift

This file was deleted.

7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
27 changes: 18 additions & 9 deletions Sources/RuleKit/Center.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
}
}
Expand Down Expand Up @@ -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))
Expand Down
3 changes: 2 additions & 1 deletion Sources/RuleKit/Option.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
8 changes: 8 additions & 0 deletions Sources/RuleKit/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion Tests/RuleKitTests/RuleKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down