mirror of
https://github.com/samsonjs/NotificationTask.git
synced 2026-03-25 09:15:55 +00:00
First commit
This commit is contained in:
commit
084aadad84
4 changed files with 175 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/configuration/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
||||||
28
Package.swift
Normal file
28
Package.swift
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// swift-tools-version: 6.0
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "NotificationTask",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v16),
|
||||||
|
.macOS(.v12),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "NotificationTask",
|
||||||
|
targets: ["NotificationTask"]),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||||
|
// Targets can depend on other targets in this package and products from dependencies.
|
||||||
|
.target(
|
||||||
|
name: "NotificationTask"),
|
||||||
|
.testTarget(
|
||||||
|
name: "NotificationTaskTests",
|
||||||
|
dependencies: ["NotificationTask"]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
55
Sources/NotificationTask/NotificationTask.swift
Normal file
55
Sources/NotificationTask/NotificationTask.swift
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
public import Foundation
|
||||||
|
|
||||||
|
extension Notification: @unchecked @retroactive Sendable {}
|
||||||
|
|
||||||
|
/// Manages a task that observes notifications. The tasks's lifetime is tied to the lifetime of the `NotificationTask` instance, so you
|
||||||
|
/// don't need to explicitly cancel anything. As long as you don't create a reference cycle in the given closure/block then everything will
|
||||||
|
/// work smoothly.
|
||||||
|
///
|
||||||
|
/// When you don't need to worry about reference cycles because the closure is dead simple then just pass in the notification name to
|
||||||
|
/// ``init(name:center:performing:)`` and then do your work in the closure.
|
||||||
|
///
|
||||||
|
/// In other cases you need to be more careful, and there's a second initializer that accepts a context object (typically self) and holds a
|
||||||
|
/// weak reference to it. Whenever that context object is deallocated then everything stops and is cleaned up automatically. Your closure
|
||||||
|
/// always receives a strong reference. This one is called ``init(name:context:center:performing:)``.
|
||||||
|
///
|
||||||
|
/// ``NotificationTask`` is bound to the main actor and is intended to be used in your view layer. This keeps it simple
|
||||||
|
@MainActor public final class NotificationTask {
|
||||||
|
var task: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(task: Task<Void, Never>) {
|
||||||
|
self.task = task
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: Notification.Name,
|
||||||
|
center: NotificationCenter = .default,
|
||||||
|
performing block: @escaping (Notification) async -> Void
|
||||||
|
) {
|
||||||
|
self.task = Task {
|
||||||
|
for await notification in center.notifications(named: name) {
|
||||||
|
await block(notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages the weak reference to your context so you don't leak by mistake.
|
||||||
|
public init<Context: AnyObject>(
|
||||||
|
name: Notification.Name,
|
||||||
|
context: Context,
|
||||||
|
center: NotificationCenter = .default,
|
||||||
|
performing block: @escaping (Context, Notification) async -> Void
|
||||||
|
) {
|
||||||
|
self.task = Task { [weak context] in
|
||||||
|
for await notification in center.notifications(named: name) {
|
||||||
|
guard let context else { break }
|
||||||
|
await block(context, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
task?.cancel()
|
||||||
|
task = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
84
Tests/NotificationTaskTests/NotificationTaskTests.swift
Normal file
84
Tests/NotificationTaskTests/NotificationTaskTests.swift
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import NotificationTask
|
||||||
|
|
||||||
|
@MainActor class NotificationTaskTests {
|
||||||
|
let center = NotificationCenter()
|
||||||
|
let name = Notification.Name("a random notification")
|
||||||
|
|
||||||
|
private var subject: NotificationTask?
|
||||||
|
|
||||||
|
@Test func callsBlockWhenNotificationsArePosted() async throws {
|
||||||
|
await withCheckedContinuation { [center, name] continuation in
|
||||||
|
subject = NotificationTask(name: name, center: center) { notification in
|
||||||
|
#expect(notification.name == name)
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
center.post(name: name, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func doesNotCallBlockWhenOtherNotificationsArePosted() async throws {
|
||||||
|
subject = NotificationTask(name: name, center: center) { notification in
|
||||||
|
Issue.record("Called for irrelevant notification \(notification.name)")
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
center.post(name: Notification.Name("something else"), object: nil)
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stopsCallingBlockWhenDeallocated() async throws {
|
||||||
|
subject = NotificationTask(name: name, center: center) { notification in
|
||||||
|
Issue.record("Called after deallocation")
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
subject = nil
|
||||||
|
center.post(name: name, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
class Owner {
|
||||||
|
let deinitHook: () -> Void
|
||||||
|
|
||||||
|
private var task: NotificationTask?
|
||||||
|
|
||||||
|
@MainActor init(center: NotificationCenter, deinitHook: @escaping () -> Void) {
|
||||||
|
self.deinitHook = deinitHook
|
||||||
|
let name = Notification.Name("irrelevant name")
|
||||||
|
self.task = NotificationTask(name: name, context: self, center: center) { _, _ in }
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
deinitHook()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var owner: Owner?
|
||||||
|
|
||||||
|
@Test(.timeLimit(.minutes(1))) func doesNotCreateReferenceCyclesWithContext() async throws {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
self.owner = Owner(center: center) {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
self.owner = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stopsCallingBlockWhenContextIsDeallocated() async throws {
|
||||||
|
var context: NSObject? = NSObject()
|
||||||
|
subject = NotificationTask(name: name, context: context!, center: center) { context, notification in
|
||||||
|
Issue.record("Called after context was deallocated")
|
||||||
|
}
|
||||||
|
context = nil
|
||||||
|
Task {
|
||||||
|
center.post(name: name, object: nil)
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue