diff --git a/FirebaseFunctions/Sources/Callable+Codable.swift b/FirebaseFunctions/Sources/Callable+Codable.swift index e18ac702fa7..ba768e4b4ff 100644 --- a/FirebaseFunctions/Sources/Callable+Codable.swift +++ b/FirebaseFunctions/Sources/Callable+Codable.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseSharedSwift +@preconcurrency import FirebaseSharedSwift import Foundation /// A `Callable` is a reference to a particular Callable HTTPS trigger in Cloud Functions. @@ -20,7 +20,7 @@ import Foundation /// - Note: If the Callable HTTPS trigger accepts no parameters, ``Never`` can be used for /// iOS 17.0+. Otherwise, a simple encodable placeholder type (e.g., /// `struct EmptyRequest: Encodable {}`) can be used. -public struct Callable { +public struct Callable: Sendable { /// The timeout to use when calling the function. Defaults to 70 seconds. public var timeoutInterval: TimeInterval { get { @@ -61,11 +61,10 @@ public struct Callable { /// - Parameter data: Parameters to pass to the trigger. /// - Parameter completion: The block to call when the HTTPS request has completed. public func call(_ data: Request, - completion: @escaping (Result) + completion: @escaping @MainActor (Result) -> Void) { do { let encoded = try encoder.encode(data) - callable.call(encoded) { result, error in do { if let result { @@ -81,7 +80,9 @@ public struct Callable { } } } catch { - completion(.failure(error)) + DispatchQueue.main.async { + completion(.failure(error)) + } } } @@ -108,7 +109,7 @@ public struct Callable { /// - data: Parameters to pass to the trigger. /// - completion: The block to call when the HTTPS request has completed. public func callAsFunction(_ data: Request, - completion: @escaping (Result) + completion: @escaping @MainActor (Result) -> Void) { call(data, completion: completion) } @@ -265,9 +266,9 @@ public extension Callable where Request: Sendable, Response: Sendable { /// - Returns: A stream wrapping responses yielded by the streaming callable function or /// a ``FunctionsError`` if an error occurred. func stream(_ data: Request? = nil) throws -> AsyncThrowingStream { - let encoded: Any + let encoded: SendableWrapper do { - encoded = try encoder.encode(data) + encoded = try SendableWrapper(value: encoder.encode(data)) } catch { throw FunctionsError(.invalidArgument, userInfo: [NSUnderlyingErrorKey: error]) } @@ -336,3 +337,12 @@ enum JSONStreamResponse { case message([String: Any]) case result([String: Any]) } + +// TODO(Swift 6): Remove need for below type by changing `FirebaseDataEncoder` to not returning +// `Any`. +/// This wrapper is only intended to be used for passing encoded data in the +/// `stream` function's hierarchy. When using, carefully audit that `value` is +/// only ever accessed in one isolation domain. +struct SendableWrapper: @unchecked Sendable { + let value: Any +} diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 091d63fe6f6..8f04368d8d2 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -19,18 +19,18 @@ import FirebaseMessagingInterop import FirebaseSharedSwift import Foundation #if COCOAPODS - import GTMSessionFetcher + @preconcurrency import GTMSessionFetcher #else - import GTMSessionFetcherCore + @preconcurrency import GTMSessionFetcherCore #endif internal import FirebaseCoreExtension -final class AtomicBox { - private var _value: T +final class AtomicBox: Sendable { + private nonisolated(unsafe) var _value: T private let lock = NSLock() - public init(_ value: T) { + public init(_ value: T) where T: Sendable { _value = value } @@ -68,7 +68,7 @@ enum FunctionsConstants { } /// `Functions` is the client for Cloud Functions for a Firebase project. -@objc(FIRFunctions) open class Functions: NSObject { +@objc(FIRFunctions) open class Functions: NSObject, @unchecked Sendable { // MARK: - Private Variables /// The network client to use for http requests. @@ -82,7 +82,7 @@ enum FunctionsConstants { /// A map of active instances, grouped by app. Keys are FirebaseApp names and values are arrays /// containing all instances of Functions associated with the given app. - private nonisolated(unsafe) static var instances: AtomicBox<[String: [Functions]]> = + private static let instances: AtomicBox<[String: [Functions]]> = AtomicBox([:]) /// The custom domain to use for all functions references (optional). @@ -91,10 +91,14 @@ enum FunctionsConstants { /// The region to use for all function references. let region: String + private let _emulatorOrigin: AtomicBox + // MARK: - Public APIs /// The current emulator origin, or `nil` if it is not set. - open private(set) var emulatorOrigin: String? + open var emulatorOrigin: String? { + _emulatorOrigin.value() + } /// Creates a Cloud Functions client using the default or returns a pre-existing instance if it /// already exists. @@ -318,7 +322,9 @@ enum FunctionsConstants { @objc open func useEmulator(withHost host: String, port: Int) { let prefix = host.hasPrefix("http") ? "" : "http://" let origin = String(format: "\(prefix)\(host):%li", port) - emulatorOrigin = origin + _emulatorOrigin.withLock { emulatorOrigin in + emulatorOrigin = origin + } } // MARK: - Private Funcs (or Internal for tests) @@ -365,7 +371,7 @@ enum FunctionsConstants { self.projectID = projectID self.region = region self.customDomain = customDomain - emulatorOrigin = nil + _emulatorOrigin = AtomicBox(nil) contextProvider = FunctionsContextProvider(auth: auth, messaging: messaging, appCheck: appCheck) @@ -414,7 +420,7 @@ enum FunctionsConstants { func callFunction(at url: URL, withObject data: Any?, options: HTTPSCallableOptions?, - timeout: TimeInterval) async throws -> HTTPSCallableResult { + timeout: TimeInterval) async throws -> sending HTTPSCallableResult { let context = try await contextProvider.context(options: options) let fetcher = try makeFetcher( url: url, @@ -501,7 +507,7 @@ enum FunctionsConstants { @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) func stream(at url: URL, - data: Any?, + data: SendableWrapper?, options: HTTPSCallableOptions?, timeout: TimeInterval) -> AsyncThrowingStream { @@ -512,7 +518,7 @@ enum FunctionsConstants { let context = try await contextProvider.context(options: options) urlRequest = try makeRequestForStreamableContent( url: url, - data: data, + data: data?.value, options: options, timeout: timeout, context: context diff --git a/FirebaseFunctions/Sources/FunctionsError.swift b/FirebaseFunctions/Sources/FunctionsError.swift index f495f51e68e..7d1b5d0902b 100644 --- a/FirebaseFunctions/Sources/FunctionsError.swift +++ b/FirebaseFunctions/Sources/FunctionsError.swift @@ -217,9 +217,9 @@ struct FunctionsError: CustomNSError { } if code == .OK { - // Technically, there's an edge case where a developer could explicitly return an error code - // of - // OK, and we will treat it as success, but that seems reasonable. + // Technically, there's an edge case where a developer could explicitly + // return an error code of OK, and we will treat it as success, but that + // seems reasonable. return nil } diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index 4c0555e43ce..00b6ee37463 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -29,29 +29,25 @@ open class HTTPSCallableResult: NSObject { } } -/** - * A `HTTPSCallable` is a reference to a particular Callable HTTPS trigger in Cloud Functions. - */ +/// A `HTTPSCallable` is a reference to a particular Callable HTTPS trigger in Cloud Functions. @objc(FIRHTTPSCallable) -open class HTTPSCallable: NSObject { +open class HTTPSCallable: NSObject, @unchecked Sendable { // MARK: - Private Properties - // The functions client to use for making calls. - private let functions: Functions - - private let url: URL - - private let options: HTTPSCallableOptions? + /// Until this class can be marked *checked* `Sendable`, it's implementation + /// is delegated to an auxialiary class that is checked Sendable. + private let sendableCallable: SendableHTTPSCallable // MARK: - Public Properties /// The timeout to use when calling the function. Defaults to 70 seconds. - @objc open var timeoutInterval: TimeInterval = 70 + @objc open var timeoutInterval: TimeInterval { + get { sendableCallable.timeoutInterval } + set { sendableCallable.timeoutInterval = newValue } + } init(functions: Functions, url: URL, options: HTTPSCallableOptions? = nil) { - self.functions = functions - self.url = url - self.options = options + sendableCallable = SendableHTTPSCallable(functions: functions, url: url, options: options) } /// Executes this Callable HTTPS trigger asynchronously. @@ -79,36 +75,7 @@ open class HTTPSCallable: NSObject { completion: @escaping @MainActor (HTTPSCallableResult?, Error?) -> Void) { - if #available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) { - Task { - do { - let result = try await call(data) - await completion(result, nil) - } catch { - await completion(nil, error) - } - } - } else { - // This isn’t expected to ever be called because Functions - // doesn’t officially support the older platforms. - functions.callFunction( - at: url, - withObject: data, - options: options, - timeout: timeoutInterval - ) { result in - switch result { - case let .success(callableResult): - DispatchQueue.main.async { - completion(callableResult, nil) - } - case let .failure(error): - DispatchQueue.main.async { - completion(nil, error) - } - } - } - } + sendableCallable.call(data, completion: completion) } /// Executes this Callable HTTPS trigger asynchronously. This API should only be used from @@ -124,8 +91,8 @@ open class HTTPSCallable: NSObject { /// resumes with a new FCM Token the next time you call this method. /// /// - Parameter completion: The block to call when the HTTPS request has completed. - @objc(callWithCompletion:) public func __call(completion: @escaping (HTTPSCallableResult?, - Error?) -> Void) { + @objc(callWithCompletion:) public func __call(completion: @escaping @MainActor (HTTPSCallableResult?, + Error?) -> Void) { call(nil, completion: completion) } @@ -144,13 +111,94 @@ open class HTTPSCallable: NSObject { /// - Throws: An error if the Cloud Functions invocation failed. /// - Returns: The result of the call. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) - open func call(_ data: Any? = nil) async throws -> HTTPSCallableResult { - try await functions - .callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval) + open func call(_ data: Any? = nil) async throws -> sending HTTPSCallableResult { + try await sendableCallable.call(data) } @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) - func stream(_ data: Any? = nil) -> AsyncThrowingStream { - functions.stream(at: url, data: data, options: options, timeout: timeoutInterval) + func stream(_ data: SendableWrapper? = nil) -> AsyncThrowingStream { + sendableCallable.stream(data) + } +} + +private extension HTTPSCallable { + final class SendableHTTPSCallable: Sendable { + // MARK: - Private Properties + + // The functions client to use for making calls. + private let functions: Functions + + private let url: URL + + private let options: HTTPSCallableOptions? + + // MARK: - Public Properties + + let _timeoutInterval: AtomicBox = .init(70) + + /// The timeout to use when calling the function. Defaults to 70 seconds. + var timeoutInterval: TimeInterval { + get { _timeoutInterval.value() } + set { + _timeoutInterval.withLock { timeoutInterval in + timeoutInterval = newValue + } + } + } + + init(functions: Functions, url: URL, options: HTTPSCallableOptions? = nil) { + self.functions = functions + self.url = url + self.options = options + } + + func call(_ data: sending Any? = nil, + completion: @escaping @MainActor (HTTPSCallableResult?, Error?) -> Void) { + if #available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) { + Task { + do { + let result = try await call(data) + await completion(result, nil) + } catch { + await completion(nil, error) + } + } + } else { + // This isn’t expected to ever be called because Functions + // doesn’t officially support the older platforms. + functions.callFunction( + at: url, + withObject: data, + options: options, + timeout: timeoutInterval + ) { result in + switch result { + case let .success(callableResult): + DispatchQueue.main.async { + completion(callableResult, nil) + } + case let .failure(error): + DispatchQueue.main.async { + completion(nil, error) + } + } + } + } + } + + func __call(completion: @escaping @MainActor (HTTPSCallableResult?, Error?) -> Void) { + call(nil, completion: completion) + } + + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) + func call(_ data: Any? = nil) async throws -> sending HTTPSCallableResult { + try await functions + .callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval) + } + + @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) + func stream(_ data: SendableWrapper? = nil) -> AsyncThrowingStream { + functions.stream(at: url, data: data, options: options, timeout: timeoutInterval) + } } } diff --git a/FirebaseFunctions/Tests/CombineUnit/HTTPSCallableTests.swift b/FirebaseFunctions/Tests/CombineUnit/HTTPSCallableTests.swift index 1db73d3930a..5db8b80fd77 100644 --- a/FirebaseFunctions/Tests/CombineUnit/HTTPSCallableTests.swift +++ b/FirebaseFunctions/Tests/CombineUnit/HTTPSCallableTests.swift @@ -28,14 +28,14 @@ import XCTest private let timeoutInterval: TimeInterval = 70.0 private let expectationTimeout: TimeInterval = 2 -class MockFunctions: Functions { +class MockFunctions: Functions, @unchecked Sendable { let mockCallFunction: () throws -> HTTPSCallableResult var verifyParameters: ((_ url: URL, _ data: Any?, _ timeout: TimeInterval) throws -> Void)? override func callFunction(at url: URL, withObject data: Any?, options: HTTPSCallableOptions?, - timeout: TimeInterval) async throws -> HTTPSCallableResult { + timeout: TimeInterval) async throws -> sending HTTPSCallableResult { try verifyParameters?(url, data, timeout) return try mockCallFunction() }