Skip to content

Commit 8eb500e

Browse files
authored
[Functions] More Swift 6 improvements (#14788)
1 parent e7eaa4e commit 8eb500e

File tree

5 files changed

+141
-77
lines changed

5 files changed

+141
-77
lines changed

FirebaseFunctions/Sources/Callable+Codable.swift

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
import FirebaseSharedSwift
15+
@preconcurrency import FirebaseSharedSwift
1616
import Foundation
1717

1818
/// A `Callable` is a reference to a particular Callable HTTPS trigger in Cloud Functions.
1919
///
2020
/// - Note: If the Callable HTTPS trigger accepts no parameters, ``Never`` can be used for
2121
/// iOS 17.0+. Otherwise, a simple encodable placeholder type (e.g.,
2222
/// `struct EmptyRequest: Encodable {}`) can be used.
23-
public struct Callable<Request: Encodable, Response: Decodable> {
23+
public struct Callable<Request: Encodable, Response: Decodable>: Sendable {
2424
/// The timeout to use when calling the function. Defaults to 70 seconds.
2525
public var timeoutInterval: TimeInterval {
2626
get {
@@ -61,11 +61,10 @@ public struct Callable<Request: Encodable, Response: Decodable> {
6161
/// - Parameter data: Parameters to pass to the trigger.
6262
/// - Parameter completion: The block to call when the HTTPS request has completed.
6363
public func call(_ data: Request,
64-
completion: @escaping (Result<Response, Error>)
64+
completion: @escaping @MainActor (Result<Response, Error>)
6565
-> Void) {
6666
do {
6767
let encoded = try encoder.encode(data)
68-
6968
callable.call(encoded) { result, error in
7069
do {
7170
if let result {
@@ -81,7 +80,9 @@ public struct Callable<Request: Encodable, Response: Decodable> {
8180
}
8281
}
8382
} catch {
84-
completion(.failure(error))
83+
DispatchQueue.main.async {
84+
completion(.failure(error))
85+
}
8586
}
8687
}
8788

@@ -108,7 +109,7 @@ public struct Callable<Request: Encodable, Response: Decodable> {
108109
/// - data: Parameters to pass to the trigger.
109110
/// - completion: The block to call when the HTTPS request has completed.
110111
public func callAsFunction(_ data: Request,
111-
completion: @escaping (Result<Response, Error>)
112+
completion: @escaping @MainActor (Result<Response, Error>)
112113
-> Void) {
113114
call(data, completion: completion)
114115
}
@@ -265,9 +266,9 @@ public extension Callable where Request: Sendable, Response: Sendable {
265266
/// - Returns: A stream wrapping responses yielded by the streaming callable function or
266267
/// a ``FunctionsError`` if an error occurred.
267268
func stream(_ data: Request? = nil) throws -> AsyncThrowingStream<Response, Error> {
268-
let encoded: Any
269+
let encoded: SendableWrapper
269270
do {
270-
encoded = try encoder.encode(data)
271+
encoded = try SendableWrapper(value: encoder.encode(data))
271272
} catch {
272273
throw FunctionsError(.invalidArgument, userInfo: [NSUnderlyingErrorKey: error])
273274
}
@@ -336,3 +337,12 @@ enum JSONStreamResponse {
336337
case message([String: Any])
337338
case result([String: Any])
338339
}
340+
341+
// TODO(Swift 6): Remove need for below type by changing `FirebaseDataEncoder` to not returning
342+
// `Any`.
343+
/// This wrapper is only intended to be used for passing encoded data in the
344+
/// `stream` function's hierarchy. When using, carefully audit that `value` is
345+
/// only ever accessed in one isolation domain.
346+
struct SendableWrapper: @unchecked Sendable {
347+
let value: Any
348+
}

FirebaseFunctions/Sources/Functions.swift

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,18 @@ import FirebaseMessagingInterop
1919
import FirebaseSharedSwift
2020
import Foundation
2121
#if COCOAPODS
22-
import GTMSessionFetcher
22+
@preconcurrency import GTMSessionFetcher
2323
#else
24-
import GTMSessionFetcherCore
24+
@preconcurrency import GTMSessionFetcherCore
2525
#endif
2626

2727
internal import FirebaseCoreExtension
2828

29-
final class AtomicBox<T> {
30-
private var _value: T
29+
final class AtomicBox<T>: Sendable {
30+
private nonisolated(unsafe) var _value: T
3131
private let lock = NSLock()
3232

33-
public init(_ value: T) {
33+
public init(_ value: T) where T: Sendable {
3434
_value = value
3535
}
3636

@@ -68,7 +68,7 @@ enum FunctionsConstants {
6868
}
6969

7070
/// `Functions` is the client for Cloud Functions for a Firebase project.
71-
@objc(FIRFunctions) open class Functions: NSObject {
71+
@objc(FIRFunctions) open class Functions: NSObject, @unchecked Sendable {
7272
// MARK: - Private Variables
7373

7474
/// The network client to use for http requests.
@@ -82,7 +82,7 @@ enum FunctionsConstants {
8282

8383
/// A map of active instances, grouped by app. Keys are FirebaseApp names and values are arrays
8484
/// containing all instances of Functions associated with the given app.
85-
private nonisolated(unsafe) static var instances: AtomicBox<[String: [Functions]]> =
85+
private static let instances: AtomicBox<[String: [Functions]]> =
8686
AtomicBox([:])
8787

8888
/// The custom domain to use for all functions references (optional).
@@ -91,10 +91,14 @@ enum FunctionsConstants {
9191
/// The region to use for all function references.
9292
let region: String
9393

94+
private let _emulatorOrigin: AtomicBox<String?>
95+
9496
// MARK: - Public APIs
9597

9698
/// The current emulator origin, or `nil` if it is not set.
97-
open private(set) var emulatorOrigin: String?
99+
open var emulatorOrigin: String? {
100+
_emulatorOrigin.value()
101+
}
98102

99103
/// Creates a Cloud Functions client using the default or returns a pre-existing instance if it
100104
/// already exists.
@@ -318,7 +322,9 @@ enum FunctionsConstants {
318322
@objc open func useEmulator(withHost host: String, port: Int) {
319323
let prefix = host.hasPrefix("http") ? "" : "http://"
320324
let origin = String(format: "\(prefix)\(host):%li", port)
321-
emulatorOrigin = origin
325+
_emulatorOrigin.withLock { emulatorOrigin in
326+
emulatorOrigin = origin
327+
}
322328
}
323329

324330
// MARK: - Private Funcs (or Internal for tests)
@@ -365,7 +371,7 @@ enum FunctionsConstants {
365371
self.projectID = projectID
366372
self.region = region
367373
self.customDomain = customDomain
368-
emulatorOrigin = nil
374+
_emulatorOrigin = AtomicBox(nil)
369375
contextProvider = FunctionsContextProvider(auth: auth,
370376
messaging: messaging,
371377
appCheck: appCheck)
@@ -414,7 +420,7 @@ enum FunctionsConstants {
414420
func callFunction(at url: URL,
415421
withObject data: Any?,
416422
options: HTTPSCallableOptions?,
417-
timeout: TimeInterval) async throws -> HTTPSCallableResult {
423+
timeout: TimeInterval) async throws -> sending HTTPSCallableResult {
418424
let context = try await contextProvider.context(options: options)
419425
let fetcher = try makeFetcher(
420426
url: url,
@@ -501,7 +507,7 @@ enum FunctionsConstants {
501507

502508
@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *)
503509
func stream(at url: URL,
504-
data: Any?,
510+
data: SendableWrapper?,
505511
options: HTTPSCallableOptions?,
506512
timeout: TimeInterval)
507513
-> AsyncThrowingStream<JSONStreamResponse, Error> {
@@ -512,7 +518,7 @@ enum FunctionsConstants {
512518
let context = try await contextProvider.context(options: options)
513519
urlRequest = try makeRequestForStreamableContent(
514520
url: url,
515-
data: data,
521+
data: data?.value,
516522
options: options,
517523
timeout: timeout,
518524
context: context

FirebaseFunctions/Sources/FunctionsError.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,9 +217,9 @@ struct FunctionsError: CustomNSError {
217217
}
218218

219219
if code == .OK {
220-
// Technically, there's an edge case where a developer could explicitly return an error code
221-
// of
222-
// OK, and we will treat it as success, but that seems reasonable.
220+
// Technically, there's an edge case where a developer could explicitly
221+
// return an error code of OK, and we will treat it as success, but that
222+
// seems reasonable.
223223
return nil
224224
}
225225

FirebaseFunctions/Sources/HTTPSCallable.swift

Lines changed: 99 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -29,29 +29,25 @@ open class HTTPSCallableResult: NSObject {
2929
}
3030
}
3131

32-
/**
33-
* A `HTTPSCallable` is a reference to a particular Callable HTTPS trigger in Cloud Functions.
34-
*/
32+
/// A `HTTPSCallable` is a reference to a particular Callable HTTPS trigger in Cloud Functions.
3533
@objc(FIRHTTPSCallable)
36-
open class HTTPSCallable: NSObject {
34+
open class HTTPSCallable: NSObject, @unchecked Sendable {
3735
// MARK: - Private Properties
3836

39-
// The functions client to use for making calls.
40-
private let functions: Functions
41-
42-
private let url: URL
43-
44-
private let options: HTTPSCallableOptions?
37+
/// Until this class can be marked *checked* `Sendable`, it's implementation
38+
/// is delegated to an auxialiary class that is checked Sendable.
39+
private let sendableCallable: SendableHTTPSCallable
4540

4641
// MARK: - Public Properties
4742

4843
/// The timeout to use when calling the function. Defaults to 70 seconds.
49-
@objc open var timeoutInterval: TimeInterval = 70
44+
@objc open var timeoutInterval: TimeInterval {
45+
get { sendableCallable.timeoutInterval }
46+
set { sendableCallable.timeoutInterval = newValue }
47+
}
5048

5149
init(functions: Functions, url: URL, options: HTTPSCallableOptions? = nil) {
52-
self.functions = functions
53-
self.url = url
54-
self.options = options
50+
sendableCallable = SendableHTTPSCallable(functions: functions, url: url, options: options)
5551
}
5652

5753
/// Executes this Callable HTTPS trigger asynchronously.
@@ -79,36 +75,7 @@ open class HTTPSCallable: NSObject {
7975
completion: @escaping @MainActor (HTTPSCallableResult?,
8076
Error?)
8177
-> Void) {
82-
if #available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) {
83-
Task {
84-
do {
85-
let result = try await call(data)
86-
await completion(result, nil)
87-
} catch {
88-
await completion(nil, error)
89-
}
90-
}
91-
} else {
92-
// This isn’t expected to ever be called because Functions
93-
// doesn’t officially support the older platforms.
94-
functions.callFunction(
95-
at: url,
96-
withObject: data,
97-
options: options,
98-
timeout: timeoutInterval
99-
) { result in
100-
switch result {
101-
case let .success(callableResult):
102-
DispatchQueue.main.async {
103-
completion(callableResult, nil)
104-
}
105-
case let .failure(error):
106-
DispatchQueue.main.async {
107-
completion(nil, error)
108-
}
109-
}
110-
}
111-
}
78+
sendableCallable.call(data, completion: completion)
11279
}
11380

11481
/// Executes this Callable HTTPS trigger asynchronously. This API should only be used from
@@ -124,8 +91,8 @@ open class HTTPSCallable: NSObject {
12491
/// resumes with a new FCM Token the next time you call this method.
12592
///
12693
/// - Parameter completion: The block to call when the HTTPS request has completed.
127-
@objc(callWithCompletion:) public func __call(completion: @escaping (HTTPSCallableResult?,
128-
Error?) -> Void) {
94+
@objc(callWithCompletion:) public func __call(completion: @escaping @MainActor (HTTPSCallableResult?,
95+
Error?) -> Void) {
12996
call(nil, completion: completion)
13097
}
13198

@@ -144,13 +111,94 @@ open class HTTPSCallable: NSObject {
144111
/// - Throws: An error if the Cloud Functions invocation failed.
145112
/// - Returns: The result of the call.
146113
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
147-
open func call(_ data: Any? = nil) async throws -> HTTPSCallableResult {
148-
try await functions
149-
.callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval)
114+
open func call(_ data: Any? = nil) async throws -> sending HTTPSCallableResult {
115+
try await sendableCallable.call(data)
150116
}
151117

152118
@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *)
153-
func stream(_ data: Any? = nil) -> AsyncThrowingStream<JSONStreamResponse, Error> {
154-
functions.stream(at: url, data: data, options: options, timeout: timeoutInterval)
119+
func stream(_ data: SendableWrapper? = nil) -> AsyncThrowingStream<JSONStreamResponse, Error> {
120+
sendableCallable.stream(data)
121+
}
122+
}
123+
124+
private extension HTTPSCallable {
125+
final class SendableHTTPSCallable: Sendable {
126+
// MARK: - Private Properties
127+
128+
// The functions client to use for making calls.
129+
private let functions: Functions
130+
131+
private let url: URL
132+
133+
private let options: HTTPSCallableOptions?
134+
135+
// MARK: - Public Properties
136+
137+
let _timeoutInterval: AtomicBox<TimeInterval> = .init(70)
138+
139+
/// The timeout to use when calling the function. Defaults to 70 seconds.
140+
var timeoutInterval: TimeInterval {
141+
get { _timeoutInterval.value() }
142+
set {
143+
_timeoutInterval.withLock { timeoutInterval in
144+
timeoutInterval = newValue
145+
}
146+
}
147+
}
148+
149+
init(functions: Functions, url: URL, options: HTTPSCallableOptions? = nil) {
150+
self.functions = functions
151+
self.url = url
152+
self.options = options
153+
}
154+
155+
func call(_ data: sending Any? = nil,
156+
completion: @escaping @MainActor (HTTPSCallableResult?, Error?) -> Void) {
157+
if #available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) {
158+
Task {
159+
do {
160+
let result = try await call(data)
161+
await completion(result, nil)
162+
} catch {
163+
await completion(nil, error)
164+
}
165+
}
166+
} else {
167+
// This isn’t expected to ever be called because Functions
168+
// doesn’t officially support the older platforms.
169+
functions.callFunction(
170+
at: url,
171+
withObject: data,
172+
options: options,
173+
timeout: timeoutInterval
174+
) { result in
175+
switch result {
176+
case let .success(callableResult):
177+
DispatchQueue.main.async {
178+
completion(callableResult, nil)
179+
}
180+
case let .failure(error):
181+
DispatchQueue.main.async {
182+
completion(nil, error)
183+
}
184+
}
185+
}
186+
}
187+
}
188+
189+
func __call(completion: @escaping @MainActor (HTTPSCallableResult?, Error?) -> Void) {
190+
call(nil, completion: completion)
191+
}
192+
193+
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
194+
func call(_ data: Any? = nil) async throws -> sending HTTPSCallableResult {
195+
try await functions
196+
.callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval)
197+
}
198+
199+
@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *)
200+
func stream(_ data: SendableWrapper? = nil) -> AsyncThrowingStream<JSONStreamResponse, Error> {
201+
functions.stream(at: url, data: data, options: options, timeout: timeoutInterval)
202+
}
155203
}
156204
}

FirebaseFunctions/Tests/CombineUnit/HTTPSCallableTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,14 @@ import XCTest
2828
private let timeoutInterval: TimeInterval = 70.0
2929
private let expectationTimeout: TimeInterval = 2
3030

31-
class MockFunctions: Functions {
31+
class MockFunctions: Functions, @unchecked Sendable {
3232
let mockCallFunction: () throws -> HTTPSCallableResult
3333
var verifyParameters: ((_ url: URL, _ data: Any?, _ timeout: TimeInterval) throws -> Void)?
3434

3535
override func callFunction(at url: URL,
3636
withObject data: Any?,
3737
options: HTTPSCallableOptions?,
38-
timeout: TimeInterval) async throws -> HTTPSCallableResult {
38+
timeout: TimeInterval) async throws -> sending HTTPSCallableResult {
3939
try verifyParameters?(url, data, timeout)
4040
return try mockCallFunction()
4141
}

0 commit comments

Comments
 (0)