From cabb3b3ca2d1ec2030dfcb85be25bcb0add08383 Mon Sep 17 00:00:00 2001 From: Nick Cooke Date: Fri, 9 May 2025 12:10:06 -0400 Subject: [PATCH 1/5] [Functions] Build with Swift 6 --- .../Sources/FunctionsError.swift | 4 ++- FirebaseFunctions/Sources/HTTPSCallable.swift | 30 +++++++++++++++++++ .../Internal/FunctionsSerializer.swift | 14 +++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/FirebaseFunctions/Sources/FunctionsError.swift b/FirebaseFunctions/Sources/FunctionsError.swift index 7d1b5d0902b..9da9d4a6da7 100644 --- a/FirebaseFunctions/Sources/FunctionsError.swift +++ b/FirebaseFunctions/Sources/FunctionsError.swift @@ -151,8 +151,10 @@ private extension FunctionsErrorCode { } } +// TODO(ncooke3): Revisit this unchecked Sendable conformance. + /// The object used to report errors that occur during a function’s execution. -struct FunctionsError: CustomNSError { +struct FunctionsError: CustomNSError, @unchecked Sendable { static let errorDomain = FunctionsErrorDomain let code: FunctionsErrorCode diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index 00b6ee37463..ab1c02378e8 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -71,10 +71,39 @@ open class HTTPSCallable: NSObject, @unchecked Sendable { /// - Parameters: /// - data: Parameters to pass to the trigger. /// - completion: The block to call when the HTTPS request has completed. + @available(swift 1000.0) // Objective-C only API @objc(callWithObject:completion:) open func call(_ data: Any? = nil, completion: @escaping @MainActor (HTTPSCallableResult?, Error?) -> Void) { + sendableCallable.call(SendableWrapper(value: data as Any), completion: completion) + } + + /// Executes this Callable HTTPS trigger asynchronously. + /// + /// The data passed into the trigger can be any of the following types: + /// - `nil` or `NSNull` + /// - `String` + /// - `NSNumber`, or any Swift numeric type bridgeable to `NSNumber` + /// - `[Any]`, where the contained objects are also one of these types. + /// - `[String: Any]` where the values are also one of these types. + /// + /// The request to the Cloud Functions backend made by this method automatically includes a + /// Firebase Installations ID token to identify the app instance. If a user is logged in with + /// Firebase Auth, an auth ID token for the user is also automatically included. + /// + /// Firebase Cloud Messaging sends data to the Firebase backend periodically to collect + /// information + /// regarding the app instance. To stop this, see `Messaging.deleteData()`. It + /// resumes with a new FCM Token the next time you call this method. + /// + /// - Parameters: + /// - data: Parameters to pass to the trigger. + /// - completion: The block to call when the HTTPS request has completed. + @nonobjc open func call(_ data: sending Any? = nil, + completion: @escaping @MainActor (HTTPSCallableResult?, + Error?) + -> Void) { sendableCallable.call(data, completion: completion) } @@ -154,6 +183,7 @@ private extension HTTPSCallable { func call(_ data: sending Any? = nil, completion: @escaping @MainActor (HTTPSCallableResult?, Error?) -> Void) { + let data = (data as? SendableWrapper)?.value ?? data if #available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) { Task { do { diff --git a/FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift b/FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift index 6eda0720cb9..00415cfa341 100644 --- a/FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift +++ b/FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift @@ -31,6 +31,13 @@ extension FunctionsSerializer { final class FunctionsSerializer: Sendable { // MARK: - Internal APIs + // This function only supports the following types and will otherwise throw + // an error. + // - NSNull (note: `nil` collection values from a Swift caller will be treated as NSNull) + // - NSNumber + // - NSString + // - NSDicionary + // - NSArray func encode(_ object: Any) throws -> Any { if object is NSNull { return object @@ -53,6 +60,13 @@ final class FunctionsSerializer: Sendable { } } + // This function only supports the following types and will otherwise throw + // an error. + // - NSNull (note: `nil` collection values from a Swift caller will be treated as NSNull) + // - NSNumber + // - NSString + // - NSDicionary + // - NSArray func decode(_ object: Any) throws -> Any { // Return these types as is. PORTING NOTE: Moved from the bottom of the func for readability. if let dict = object as? NSDictionary { From 154112fcf2c97996d4f7bb37adedc2938c9e5084 Mon Sep 17 00:00:00 2001 From: Nick Cooke Date: Fri, 9 May 2025 13:59:05 -0400 Subject: [PATCH 2/5] CI --- .github/workflows/functions.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/functions.yml b/.github/workflows/functions.yml index a44b1a603bb..4eb11902c6d 100644 --- a/.github/workflows/functions.yml +++ b/.github/workflows/functions.yml @@ -31,6 +31,7 @@ jobs: strategy: matrix: target: [ios, tvos, macos, watchos] + swift_version: [5.9, 6.0] build-env: - os: macos-15 xcode: Xcode_16.3 @@ -44,6 +45,8 @@ jobs: run: scripts/setup_bundler.sh - name: Integration Test Server run: FirebaseFunctions/Backend/start.sh synchronous + - name: Set Swift swift_version + run: sed -i "" "s/s.swift_version[[:space:]]*=[[:space:]]*'5.9'/s.swift_version = '${{ matrix.build-env.swift_version }}'/" FirebaseFunctions.podspec - name: Build and test run: | scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseFunctions.podspec \ From 0471a35337471769392764611c7dd1cdea2f2960 Mon Sep 17 00:00:00 2001 From: Nick Cooke Date: Fri, 9 May 2025 14:05:43 -0400 Subject: [PATCH 3/5] fix yml --- .github/workflows/functions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/functions.yml b/.github/workflows/functions.yml index 4eb11902c6d..ffb82914e53 100644 --- a/.github/workflows/functions.yml +++ b/.github/workflows/functions.yml @@ -46,7 +46,7 @@ jobs: - name: Integration Test Server run: FirebaseFunctions/Backend/start.sh synchronous - name: Set Swift swift_version - run: sed -i "" "s/s.swift_version[[:space:]]*=[[:space:]]*'5.9'/s.swift_version = '${{ matrix.build-env.swift_version }}'/" FirebaseFunctions.podspec + run: sed -i "" "s/s.swift_version[[:space:]]*=[[:space:]]*'5.9'/s.swift_version = '${{ matrix.swift_version }}'/" FirebaseFunctions.podspec - name: Build and test run: | scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseFunctions.podspec \ From 615caa510804655c654289556ba910390be58823 Mon Sep 17 00:00:00 2001 From: Nick Cooke Date: Fri, 9 May 2025 14:17:21 -0400 Subject: [PATCH 4/5] Add fix from SharedSwift --- .../third_party/FirebaseDataEncoder/FirebaseDataEncoder.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseSharedSwift/Sources/third_party/FirebaseDataEncoder/FirebaseDataEncoder.swift b/FirebaseSharedSwift/Sources/third_party/FirebaseDataEncoder/FirebaseDataEncoder.swift index cc3dc36bd30..b881486388e 100644 --- a/FirebaseSharedSwift/Sources/third_party/FirebaseDataEncoder/FirebaseDataEncoder.swift +++ b/FirebaseSharedSwift/Sources/third_party/FirebaseDataEncoder/FirebaseDataEncoder.swift @@ -286,7 +286,7 @@ public class FirebaseDataEncoder { /// - returns: A new `Data` value containing the encoded JSON data. /// - throws: `EncodingError.invalidValue` if a non-conforming floating-point value is encountered during encoding, and the encoding strategy is `.throw`. /// - throws: An error if any value throws an error during encoding. - open func encode(_ value: T) throws -> Any { + open func encode(_ value: T) throws -> sending Any { let encoder = __JSONEncoder(options: self.options) guard let topLevel = try encoder.box_(value) else { From ba6faf74f19ce205ccebb5f07d1f80a856fb45a7 Mon Sep 17 00:00:00 2001 From: Nick Cooke Date: Fri, 9 May 2025 18:32:48 -0400 Subject: [PATCH 5/5] more testing fixes --- .../Sources/Callable+Codable.swift | 3 +- .../Tests/Integration/IntegrationTests.swift | 64 ++++++++++--------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/FirebaseFunctions/Sources/Callable+Codable.swift b/FirebaseFunctions/Sources/Callable+Codable.swift index ba768e4b4ff..4938dad1ab3 100644 --- a/FirebaseFunctions/Sources/Callable+Codable.swift +++ b/FirebaseFunctions/Sources/Callable+Codable.swift @@ -175,7 +175,8 @@ private protocol StreamResponseProtocol {} /// This can be used as the generic `Response` parameter to ``Callable`` to receive both the /// yielded messages and final return value of the streaming callable function. @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) -public enum StreamResponse: Decodable, +public enum StreamResponse: Decodable, + Sendable, StreamResponseProtocol { /// The message yielded by the callable function. case message(Message) diff --git a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift index 0fa9c21e862..3032c700bc1 100644 --- a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift +++ b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift @@ -83,7 +83,7 @@ class IntegrationTests: XCTestCase { return URL(string: "http://localhost:5005/functions-integration-test/us-central1/\(funcName)")! } - func testData() { + @MainActor func testData() { let data = DataTestRequest( bool: true, int: 2, @@ -148,7 +148,7 @@ class IntegrationTests: XCTestCase { } } - func testScalar() { + @MainActor func testScalar() { let byName = functions.httpsCallable( "scalarTest", requestAs: Int16.self, @@ -203,7 +203,7 @@ class IntegrationTests: XCTestCase { } } - func testToken() { + @MainActor func testToken() { // Recreate functions with a token. let functions = Functions( projectID: "functions-integration-test", @@ -271,7 +271,7 @@ class IntegrationTests: XCTestCase { } } - func testFCMToken() { + @MainActor func testFCMToken() { let byName = functions.httpsCallable( "FCMTokenTest", requestAs: [String: Int].self, @@ -316,7 +316,7 @@ class IntegrationTests: XCTestCase { } } - func testNull() { + @MainActor func testNull() { let byName = functions.httpsCallable( "nullTest", requestAs: Int?.self, @@ -361,7 +361,7 @@ class IntegrationTests: XCTestCase { } } - func testMissingResult() { + @MainActor func testMissingResult() { let byName = functions.httpsCallable( "missingResultTest", requestAs: Int?.self, @@ -415,7 +415,7 @@ class IntegrationTests: XCTestCase { } } - func testUnhandledError() { + @MainActor func testUnhandledError() { let byName = functions.httpsCallable( "unhandledErrorTest", requestAs: [Int].self, @@ -469,7 +469,7 @@ class IntegrationTests: XCTestCase { } } - func testUnknownError() { + @MainActor func testUnknownError() { let byName = functions.httpsCallable( "unknownErrorTest", requestAs: [Int].self, @@ -522,7 +522,7 @@ class IntegrationTests: XCTestCase { } } - func testExplicitError() { + @MainActor func testExplicitError() { let byName = functions.httpsCallable( "explicitErrorTest", requestAs: [Int].self, @@ -579,7 +579,7 @@ class IntegrationTests: XCTestCase { } } - func testHttpError() { + @MainActor func testHttpError() { let byName = functions.httpsCallable( "httpErrorTest", requestAs: [Int].self, @@ -631,7 +631,7 @@ class IntegrationTests: XCTestCase { } } - func testThrowError() { + @MainActor func testThrowError() { let byName = functions.httpsCallable( "throwTest", requestAs: [Int].self, @@ -685,7 +685,7 @@ class IntegrationTests: XCTestCase { } } - func testTimeout() { + @MainActor func testTimeout() { let byName = functions.httpsCallable( "timeoutTest", requestAs: [Int].self, @@ -743,7 +743,7 @@ class IntegrationTests: XCTestCase { } } - func testCallAsFunction() { + @MainActor func testCallAsFunction() { let data = DataTestRequest( bool: true, int: 2, @@ -808,7 +808,7 @@ class IntegrationTests: XCTestCase { } } - func testInferredTypes() { + @MainActor func testInferredTypes() { let data = DataTestRequest( bool: true, int: 2, @@ -868,7 +868,7 @@ class IntegrationTests: XCTestCase { } } - func testFunctionsReturnsOnMainThread() { + @MainActor func testFunctionsReturnsOnMainThread() { let expectation = expectation(description: #function) functions.httpsCallable( "scalarTest", @@ -884,7 +884,7 @@ class IntegrationTests: XCTestCase { waitForExpectations(timeout: 5) } - func testFunctionsThrowsOnMainThread() { + @MainActor func testFunctionsThrowsOnMainThread() { let expectation = expectation(description: #function) functions.httpsCallable( "httpErrorTest", @@ -908,7 +908,7 @@ class IntegrationTests: XCTestCase { /// /// This can be used as the generic `Request` parameter to ``Callable`` to /// indicate the callable function does not accept parameters. -private struct EmptyRequest: Encodable {} +private struct EmptyRequest: Encodable, Sendable {} @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) extension IntegrationTests { @@ -1100,18 +1100,21 @@ extension IntegrationTests { ) } - func testStream_Canceled() async throws { - let task = Task.detached { [self] in - let callable: Callable = functions.httpsCallable("genStream") - let stream = try callable.stream() - // Since we cancel the call we are expecting an empty array. - return try await stream.reduce([]) { $0 + [$1] } as [String] + // Concurrency rules prevent easily testing this feature. + #if swift(<6) + func testStream_Canceled() async throws { + let task = Task.detached { [self] in + let callable: Callable = functions.httpsCallable("genStream") + let stream = try callable.stream() + // Since we cancel the call we are expecting an empty array. + return try await stream.reduce([]) { $0 + [$1] } as [String] + } + // We cancel the task and we expect a null response even if the stream was initiated. + task.cancel() + let respone = try await task.value + XCTAssertEqual(respone, []) } - // We cancel the task and we expect a null response even if the stream was initiated. - task.cancel() - let respone = try await task.value - XCTAssertEqual(respone, []) - } + #endif func testStream_NonexistentFunction() async throws { let callable: Callable = functions.httpsCallable( @@ -1163,7 +1166,8 @@ extension IntegrationTests { func testStream_ResultIsOnlyExposedInStreamResponse() async throws { // The implementation is copied from `StreamResponse`. The only difference is the do-catch is // removed from the decoding initializer. - enum MyStreamResponse: Decodable { + enum MyStreamResponse: Decodable, + Sendable { /// The message yielded by the callable function. case message(Message) /// The final result returned by the callable function. @@ -1248,7 +1252,7 @@ extension IntegrationTests { } func testStream_ResultOnly_StreamResponse() async throws { - struct EmptyResponse: Decodable {} + struct EmptyResponse: Decodable, Sendable {} let callable: Callable> = functions .httpsCallable( "genStreamResultOnly"