Skip to content
Draft
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ concurrency:

jobs:
macos:
name: macOS 13 (Xcode 14.3.1)
runs-on: macos-13
name: macOS 15 (Xcode 16.4)
runs-on: macos-15
strategy:
matrix:
config: ['debug', 'release']
steps:
- uses: actions/checkout@v3
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Update checkout action to v4 (v3 is too old on macos-15)

This will avoid runner/runtime warnings.

Apply:

-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- uses: actions/checkout@v3
- uses: actions/checkout@v4
🧰 Tools
🪛 actionlint (1.7.8)

24-24: the runner of "actions/checkout@v3" action is too old to run on GitHub Actions. update the action's version to fix this issue

(action)

🤖 Prompt for AI Agents
.github/workflows/ci.yml around line 24: the workflow uses actions/checkout@v3
which triggers runner/runtime warnings on newer macOS runners; update the step
to use actions/checkout@v4 by replacing the uses reference to
actions/checkout@v4 (ensure no other breaking inputs/options are required by v4
in your workflow).

- name: Select Xcode 14.3.1
run: sudo xcode-select -s /Applications/Xcode_14.3.1.app
- name: Select Xcode 16.4
run: sudo xcode-select -s /Applications/Xcode_16.4.app
- name: Run tests
run: make test-swift
- name: Build platforms ${{ matrix.config }}
Expand Down
8 changes: 4 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
CONFIG = debug
PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iPhone,iOS-16)
PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iOS,iPhone \d\+ Pro [^M])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix regex: grep default doesn’t support \d. Use -E and [0-9]+; tighten Pro-not-Max match

Current pattern won’t match digits; it literally searches for "d+". Also ensure we exclude "Pro Max" robustly.

Apply:

-PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iOS,iPhone \d\+ Pro [^M])
+PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iOS,iPhone [0-9]+ Pro( \(|$$))

Note: The pattern matches “… Pro (” or end-of-line, excluding “Pro Max …”.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iOS,iPhone \d\+ Pro [^M])
PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iOS,iPhone [0-9]+ Pro( \(|$$))
🤖 Prompt for AI Agents
In Makefile around line 2, the grep pattern uses the unsupported \d and can
accidentally match "Pro Max"; change to use extended regex (grep -E) with [0-9]+
for digits and tighten the "Pro" match to exclude "Pro Max" — e.g., use grep -E
'iPhone [0-9]+ Pro($| )' or use grep -E 'iPhone [0-9]+ Pro' combined with a
subsequent grep -v 'Pro Max' to ensure only "Pro" (not "Pro Max") models are
matched.

PLATFORM_MACOS = macOS
PLATFORM_MAC_CATALYST = macOS,variant=Mac Catalyst
PLATFORM_TVOS = tvOS Simulator,id=$(call udid_for,TV,tvOS-16)
PLATFORM_WATCHOS = watchOS Simulator,id=$(call udid_for,Watch,watchOS-9)
PLATFORM_TVOS = tvOS Simulator,id=$(call udid_for,tvOS,TV)
PLATFORM_WATCHOS = watchOS Simulator,id=$(call udid_for,watchOS,Watch)

default: swift-test

Expand Down Expand Up @@ -49,5 +49,5 @@ test-swift:
.PHONY: test-example test-swift build-for-library-evolution format

define udid_for
$(shell xcrun simctl list --json devices available $(1) | jq -r '.devices | to_entries | map(select(.value | add)) | sort_by(.key) | .[] | select(.key | contains("$(2)")) | .value | last.udid')
$(shell xcrun simctl list devices available '$(1)' | grep '$(2)' | sort -r | head -1 | awk -F '[()]' '{ print $$(NF-3) }')
Copy link

Copilot AI Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new shell command is complex and fragile. The awk field extraction $$(NF-3) assumes a specific format that may break if simulator output changes. Consider adding error handling or using a more robust parsing approach.

Suggested change
$(shell xcrun simctl list devices available '$(1)' | grep '$(2)' | sort -r | head -1 | awk -F '[()]' '{ print $$(NF-3) }')
$(shell xcrun simctl list devices available '$(1)' | grep '$(2)' | sort -r | head -1 | grep -Eo '[0-9A-Fa-f-]{36}' | head -1)

Copilot uses AI. Check for mistakes.
endef
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.6
// swift-tools-version:6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
Expand Down
33 changes: 18 additions & 15 deletions Sources/ComposableUserNotifications/Interface.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import UserNotifications
@preconcurrency import UserNotifications
import XCTestDynamicOverlay

/// A wrapper around UserNotifications's `UNUserNotificationCenter` that exposes its functionality through
Expand All @@ -8,17 +8,20 @@ import XCTestDynamicOverlay
@available(macOS 10.14, *)
@available(tvOS 10.0, *)
@available(watchOS 3.0, *)
public struct UserNotificationClient {
public struct UserNotificationClient: Sendable {
/// Actions that correspond to `UNUserNotificationCenterDelegate` methods.
///
/// See `UNUserNotificationCenterDelegate` for more information.
public enum DelegateAction {
public enum DelegateAction: Sendable {
case willPresentNotification(
_ notification: Notification,
completionHandler: (UNNotificationPresentationOptions) -> Void)
completionHandler: @Sendable (UNNotificationPresentationOptions) -> Void)

@available(tvOS, unavailable)
case didReceiveResponse(_ response: Notification.Response, completionHandler: () -> Void)
case didReceiveResponse(
_ response: Notification.Response,
completionHandler: @Sendable () -> Void
)

case openSettingsForNotification(_ notification: Notification?)
}
Expand All @@ -32,41 +35,41 @@ public struct UserNotificationClient {
#endif

#if !os(tvOS)
public var notificationCategories: () async -> Set<UNNotificationCategory> = unimplemented(
public var notificationCategories: @Sendable () async -> Set<UNNotificationCategory> = unimplemented(
"\(Self.self).deliveredNotifications")
Comment on lines +38 to 39
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Typo in unimplemented placeholder

Use the correct label to aid debugging.

-    public var notificationCategories: @Sendable () async -> Set<UNNotificationCategory> = unimplemented(
-      "\(Self.self).deliveredNotifications")
+    public var notificationCategories: @Sendable () async -> Set<UNNotificationCategory> = unimplemented(
+      "\(Self.self).notificationCategories")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public var notificationCategories: @Sendable () async -> Set<UNNotificationCategory> = unimplemented(
"\(Self.self).deliveredNotifications")
public var notificationCategories: @Sendable () async -> Set<UNNotificationCategory> = unimplemented(
"\(Self.self).notificationCategories")
🤖 Prompt for AI Agents
In Sources/ComposableUserNotifications/Interface.swift around lines 38–39, the
unimplemented placeholder message uses the wrong label
("deliveredNotifications"); update it to use the actual property name so the
failure message is accurate—replace the placeholder string with
"\(Self.self).notificationCategories" (or the exact property identifier) when
calling unimplemented.

#endif

public var notificationSettings: () async -> Notification.Settings = unimplemented(
public var notificationSettings: @Sendable () async -> Notification.Settings = unimplemented(
"\(Self.self).notificationSettings")

public var pendingNotificationRequests: () async -> [Notification.Request] = unimplemented(
public var pendingNotificationRequests: @Sendable () async -> [Notification.Request] = unimplemented(
"\(Self.self).pendingNotificationRequests")

#if !os(tvOS)
public var removeAllDeliveredNotifications: () async -> Void = unimplemented(
public var removeAllDeliveredNotifications: @Sendable () async -> Void = unimplemented(
"\(Self.self).removeAllDeliveredNotifications")
#endif

public var removeAllPendingNotificationRequests: () async -> Void = unimplemented(
public var removeAllPendingNotificationRequests: @Sendable () async -> Void = unimplemented(
"\(Self.self).removeAllPendingNotificationRequests")

#if !os(tvOS)
public var removeDeliveredNotificationsWithIdentifiers: ([String]) async -> Void =
public var removeDeliveredNotificationsWithIdentifiers: @Sendable ([String]) async -> Void =
unimplemented("\(Self.self).removeDeliveredNotificationsWithIdentifiers")
#endif

public var removePendingNotificationRequestsWithIdentifiers: ([String]) async -> Void =
public var removePendingNotificationRequestsWithIdentifiers: @Sendable ([String]) async -> Void =
unimplemented("\(Self.self).removePendingNotificationRequestsWithIdentifiers")

public var requestAuthorization: (UNAuthorizationOptions) async throws -> Bool =
public var requestAuthorization: @Sendable (UNAuthorizationOptions) async throws -> Bool =
unimplemented("\(Self.self).requestAuthorization")

#if !os(tvOS)
public var setNotificationCategories: (Set<UNNotificationCategory>) async -> Void =
public var setNotificationCategories: @Sendable (Set<UNNotificationCategory>) async -> Void =
unimplemented("\(Self.self).setNotificationCategories")
#endif

public var supportsContentExtensions: () -> Bool = unimplemented(
public var supportsContentExtensions: @Sendable () -> Bool = unimplemented(
"\(Self.self).supportsContentExtensions")

/// This Effect represents calls to the `UNUserNotificationCenterDelegate`.
Expand Down
17 changes: 12 additions & 5 deletions Sources/ComposableUserNotifications/LiveKey.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Dependencies
import Foundation
import UserNotifications
@preconcurrency import UserNotifications

extension UserNotificationClient: DependencyKey {
public static var liveValue: Self {
Expand Down Expand Up @@ -70,7 +70,7 @@ extension UserNotificationClient: DependencyKey {
AsyncStream { continuation in
let delegate = Delegate(continuation: continuation)
UNUserNotificationCenter.current().delegate = delegate
continuation.onTermination = { _ in
continuation.onTermination = { [delegate = UncheckedSendable(delegate)] _ in
_ = delegate
}
}
Expand All @@ -81,7 +81,7 @@ extension UserNotificationClient: DependencyKey {
}

extension UserNotificationClient {
fileprivate class Delegate: NSObject, UNUserNotificationCenterDelegate {
fileprivate final class Delegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
let continuation: AsyncStream<UserNotificationClient.DelegateAction>.Continuation

init(continuation: AsyncStream<UserNotificationClient.DelegateAction>.Continuation) {
Expand All @@ -97,7 +97,9 @@ extension UserNotificationClient {
self.continuation.yield(
.willPresentNotification(
Notification(rawValue: notification),
completionHandler: completionHandler
completionHandler: { [handler = UncheckedSendable(completionHandler)] options in
handler.value(options)
}
)
)
}
Expand All @@ -110,7 +112,12 @@ extension UserNotificationClient {
) {
let wrappedResponse = Notification.Response(rawValue: response)
self.continuation.yield(
.didReceiveResponse(wrappedResponse) { completionHandler() }
.didReceiveResponse(
wrappedResponse,
completionHandler: { [handler = UncheckedSendable(completionHandler)] in
handler.value()
}
)
)
}
#endif
Expand Down
63 changes: 34 additions & 29 deletions Sources/ComposableUserNotifications/Model.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import CoreLocation
import UserNotifications
import ConcurrencyExtras
@preconcurrency import CoreLocation
@preconcurrency import UserNotifications
import XCTestDynamicOverlay

public struct Notification: Equatable {
public let rawValue: UNNotification?
public struct Notification: Equatable, Sendable {
nonisolated(unsafe) public let rawValue: UNNotification?

public var date: Date
public var request: Request
Expand All @@ -28,11 +29,11 @@ public struct Notification: Equatable {
}

extension Notification {
public struct Request: Equatable {
public let rawValue: UNNotificationRequest?
public struct Request: Equatable, Sendable {
nonisolated(unsafe) public let rawValue: UNNotificationRequest?

public var identifier: String
public var content: UNNotificationContent
nonisolated(unsafe) public var content: UNNotificationContent
public var trigger: Trigger?

public init(rawValue: UNNotificationRequest) {
Expand Down Expand Up @@ -78,7 +79,7 @@ extension Notification {
}

extension Notification {
public enum Trigger: Equatable {
public enum Trigger: Equatable, Sendable {
case push(Push)
case timeInterval(TimeInterval)
case calendar(Calendar)
Expand Down Expand Up @@ -123,8 +124,8 @@ extension Notification.Trigger {
}
}

public struct Push: Equatable {
public var rawValue: UNPushNotificationTrigger?
public struct Push: Equatable, Sendable {
nonisolated(unsafe) public var rawValue: UNPushNotificationTrigger?

public var repeats: Bool

Expand All @@ -141,25 +142,27 @@ extension Notification.Trigger {
}
}

public struct TimeInterval: Equatable {
public let rawValue: UNTimeIntervalNotificationTrigger?
public struct TimeInterval: Equatable, Sendable {
nonisolated(unsafe) public let rawValue: UNTimeIntervalNotificationTrigger?

public var repeats: Bool
public var timeInterval: Foundation.TimeInterval
public var nextTriggerDate: () -> Date?
public var nextTriggerDate: @Sendable () -> Date?

init(rawValue: UNTimeIntervalNotificationTrigger) {
self.rawValue = rawValue

self.repeats = rawValue.repeats
self.timeInterval = rawValue.timeInterval
self.nextTriggerDate = rawValue.nextTriggerDate
self.nextTriggerDate = { [nextTriggerDate = UncheckedSendable(rawValue.nextTriggerDate)] in
nextTriggerDate.value()
}
}

public init(
repeats: Bool,
timeInterval: Foundation.TimeInterval,
nextTriggerDate: @escaping () -> Date?
nextTriggerDate: @Sendable @escaping () -> Date?
) {
self.rawValue = nil

Expand All @@ -173,25 +176,27 @@ extension Notification.Trigger {
}
}

public struct Calendar: Equatable {
public let rawValue: UNCalendarNotificationTrigger?
public struct Calendar: Equatable, Sendable {
nonisolated(unsafe) public let rawValue: UNCalendarNotificationTrigger?

public var repeats: Bool
public var dateComponents: DateComponents
public var nextTriggerDate: () -> Date?
public var nextTriggerDate: @Sendable () -> Date?

public init(rawValue: UNCalendarNotificationTrigger) {
self.rawValue = rawValue

self.repeats = rawValue.repeats
self.dateComponents = rawValue.dateComponents
self.nextTriggerDate = rawValue.nextTriggerDate
self.nextTriggerDate = { [nextTriggerDate = UncheckedSendable(rawValue.nextTriggerDate)] in
nextTriggerDate.value()
}
}

public init(
repeats: Bool,
dateComponents: DateComponents,
nextTriggerDate: @escaping () -> Date?
nextTriggerDate: @Sendable @escaping () -> Date?
) {
self.rawValue = nil

Expand All @@ -208,8 +213,8 @@ extension Notification.Trigger {
@available(macOS, unavailable)
@available(macCatalyst, unavailable)
@available(tvOS, unavailable)
public struct Location: Equatable {
public let rawValue: UNLocationNotificationTrigger?
public struct Location: Equatable, Sendable {
nonisolated(unsafe) public let rawValue: UNLocationNotificationTrigger?

public var repeats: Bool
public var region: Region
Expand All @@ -232,7 +237,7 @@ extension Notification.Trigger {

extension Notification {
@available(tvOS, unavailable)
public enum Response: Equatable {
public enum Response: Equatable, Sendable {
case user(UserAction)
case textInput(TextInputAction)
}
Expand Down Expand Up @@ -270,8 +275,8 @@ extension Notification.Response {
}
}

public struct UserAction: Equatable {
public let rawValue: UNNotificationResponse?
public struct UserAction: Equatable, Sendable {
nonisolated(unsafe) public let rawValue: UNNotificationResponse?

public var actionIdentifier: String
public var notification: Notification
Expand All @@ -290,8 +295,8 @@ extension Notification.Response {
}
}

public struct TextInputAction: Equatable {
public let rawValue: UNTextInputNotificationResponse?
public struct TextInputAction: Equatable, Sendable {
nonisolated(unsafe) public let rawValue: UNTextInputNotificationResponse?

public var actionIdentifier: String
public var notification: Notification
Expand Down Expand Up @@ -473,8 +478,8 @@ extension Notification {
}

// see https://github.com/pointfreeco/swift-composable-architecture/blob/767e1d9553fcee5a95af10e0352f20fb03b98352/Sources/ComposableCoreLocation/Models/Region.swift#L5
public struct Region: Hashable {
public let rawValue: CLRegion?
public struct Region: Hashable, Sendable {
nonisolated(unsafe) public let rawValue: CLRegion?
public var identifier: String
public var notifyOnEntry: Bool
public var notifyOnExit: Bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,4 @@ final class ComposableUserNotificationsTests: XCTestCase {
// results.
XCTAssertEqual(true, true)
}

static var allTests = [
("testExample", testExample)
]
}
9 changes: 0 additions & 9 deletions Tests/ComposableUserNotificationsTests/XCTestManifests.swift

This file was deleted.