From 871ab85eb19b20f2d08412e029abfbc8dd3c3a88 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 28 May 2024 17:51:34 -0300 Subject: [PATCH 1/4] feat(realtime): send broadcast events through HTTP --- Sources/Realtime/RealtimeChannel.swift | 7 ----- Sources/Realtime/V2/PushV2.swift | 7 +++++ Sources/Realtime/V2/RealtimeChannelV2.swift | 31 ++++++++++++++++++--- Sources/Realtime/V2/RealtimeClientV2.swift | 21 ++++++++++++-- Sources/Realtime/V2/Types.swift | 3 ++ 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/Sources/Realtime/RealtimeChannel.swift b/Sources/Realtime/RealtimeChannel.swift index 228c8f12..6ee97326 100644 --- a/Sources/Realtime/RealtimeChannel.swift +++ b/Sources/Realtime/RealtimeChannel.swift @@ -105,13 +105,6 @@ public struct RealtimeChannelOptions { } } -/// Represents the different status of a push -public enum PushStatus: String, Sendable { - case ok - case error - case timeout -} - public enum RealtimeSubscribeStates { case subscribed case timedOut diff --git a/Sources/Realtime/V2/PushV2.swift b/Sources/Realtime/V2/PushV2.swift index e5bc9307..77e8f1be 100644 --- a/Sources/Realtime/V2/PushV2.swift +++ b/Sources/Realtime/V2/PushV2.swift @@ -8,6 +8,13 @@ import Foundation import Helpers +/// Represents the different status of a push +public enum PushStatus: String, Sendable { + case ok + case error + case timeout +} + actor PushV2 { private weak var channel: RealtimeChannelV2? let message: RealtimeMessageV2 diff --git a/Sources/Realtime/V2/RealtimeChannelV2.swift b/Sources/Realtime/V2/RealtimeChannelV2.swift index 7ed0b25e..49cb74fb 100644 --- a/Sources/Realtime/V2/RealtimeChannelV2.swift +++ b/Sources/Realtime/V2/RealtimeChannelV2.swift @@ -202,10 +202,33 @@ public final class RealtimeChannelV2: Sendable { /// - event: Broadcast message event. /// - message: Message payload. public func broadcast(event: String, message: JSONObject) async { - assert( - status == .subscribed, - "You can only broadcast after subscribing to the channel. Did you forget to call `channel.subscribe()`?" - ) + guard let socket else { return } + + if status != .subscribed { + struct Message: Encodable { + let topic: String + let event: String + let payload: JSONObject + } + + _ = try? await socket.http.send( + HTTPRequest( + url: socket.broadcastURL, + method: .post, + headers: [ + "apikey": socket.apikey ?? "", + "content-type": "application/json", + ], + body: JSONEncoder().encode( + Message( + topic: topic, + event: event, + payload: message + ) + ) + ) + ) + } await push( RealtimeMessageV2( diff --git a/Sources/Realtime/V2/RealtimeClientV2.swift b/Sources/Realtime/V2/RealtimeClientV2.swift index f202de0d..acc15fc6 100644 --- a/Sources/Realtime/V2/RealtimeClientV2.swift +++ b/Sources/Realtime/V2/RealtimeClientV2.swift @@ -79,6 +79,7 @@ public final class RealtimeClientV2: Sendable { let options: RealtimeClientOptions let ws: any WebSocketClient let mutableState = LockIsolated(MutableState()) + let http: any HTTPClientType let apikey: String? public var subscriptions: [String: RealtimeChannelV2] { @@ -128,6 +129,12 @@ public final class RealtimeClientV2: Sendable { } public convenience init(url: URL, options: RealtimeClientOptions) { + var interceptors: [any HTTPClientInterceptor] = [] + + if let logger = options.logger { + interceptors.append(LoggerInterceptor(logger: logger)) + } + self.init( url: url, options: options, @@ -137,14 +144,24 @@ public final class RealtimeClientV2: Sendable { apikey: options.apikey ), options: options + ), + http: HTTPClient( + fetch: options.fetch ?? { try await URLSession.shared.data(for: $0) }, + interceptors: interceptors ) ) } - init(url: URL, options: RealtimeClientOptions, ws: any WebSocketClient) { + init( + url: URL, + options: RealtimeClientOptions, + ws: any WebSocketClient, + http: any HTTPClientType + ) { self.url = url self.options = options self.ws = ws + self.http = http apikey = options.apikey mutableState.withValue { @@ -471,7 +488,7 @@ public final class RealtimeClientV2: Sendable { return url } - private var broadcastURL: URL { + var broadcastURL: URL { url.appendingPathComponent("api/broadcast") } } diff --git a/Sources/Realtime/V2/Types.swift b/Sources/Realtime/V2/Types.swift index eeaab64e..7b983075 100644 --- a/Sources/Realtime/V2/Types.swift +++ b/Sources/Realtime/V2/Types.swift @@ -16,6 +16,7 @@ public struct RealtimeClientOptions: Sendable { var timeoutInterval: TimeInterval var disconnectOnSessionLoss: Bool var connectOnSubscribe: Bool + var fetch: (@Sendable (_ request: URLRequest) async throws -> (Data, URLResponse))? package var logger: (any SupabaseLogger)? public static let defaultHeartbeatInterval: TimeInterval = 15 @@ -31,6 +32,7 @@ public struct RealtimeClientOptions: Sendable { timeoutInterval: TimeInterval = Self.defaultTimeoutInterval, disconnectOnSessionLoss: Bool = Self.defaultDisconnectOnSessionLoss, connectOnSubscribe: Bool = Self.defaultConnectOnSubscribe, + fetch: (@Sendable (_ request: URLRequest) async throws -> (Data, URLResponse))? = nil, logger: (any SupabaseLogger)? = nil ) { self.headers = HTTPHeaders(headers) @@ -39,6 +41,7 @@ public struct RealtimeClientOptions: Sendable { self.timeoutInterval = timeoutInterval self.disconnectOnSessionLoss = disconnectOnSessionLoss self.connectOnSubscribe = connectOnSubscribe + self.fetch = fetch self.logger = logger } From add71c79fe136830b45eaa99e4d5f70c36680ad3 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 28 Jun 2024 16:24:37 -0300 Subject: [PATCH 2/4] rebase --- Sources/Realtime/V2/RealtimeChannelV2.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Sources/Realtime/V2/RealtimeChannelV2.swift b/Sources/Realtime/V2/RealtimeChannelV2.swift index 49cb74fb..ba4bf3f5 100644 --- a/Sources/Realtime/V2/RealtimeChannelV2.swift +++ b/Sources/Realtime/V2/RealtimeChannelV2.swift @@ -16,28 +16,34 @@ public struct RealtimeChannelConfig: Sendable { } struct Socket: Sendable { + var broadcastURL: @Sendable () -> URL var status: @Sendable () -> RealtimeClientV2.Status var options: @Sendable () -> RealtimeClientOptions var accessToken: @Sendable () -> String? + var apiKey: @Sendable () -> String? var makeRef: @Sendable () -> Int var connect: @Sendable () async -> Void var addChannel: @Sendable (_ channel: RealtimeChannelV2) -> Void var removeChannel: @Sendable (_ channel: RealtimeChannelV2) async -> Void var push: @Sendable (_ message: RealtimeMessageV2) async -> Void + var httpSend: @Sendable (_ request: HTTPRequest) async throws -> HTTPResponse } extension Socket { init(client: RealtimeClientV2) { self.init( + broadcastURL: { [weak client] in client?.broadcastURL ?? URL(string: "http://localhost")! }, status: { [weak client] in client?.status ?? .disconnected }, options: { [weak client] in client?.options ?? .init() }, accessToken: { [weak client] in client?.mutableState.accessToken }, + apiKey: { [weak client] in client?.apikey }, makeRef: { [weak client] in client?.makeRef() ?? 0 }, connect: { [weak client] in await client?.connect() }, addChannel: { [weak client] in client?.addChannel($0) }, removeChannel: { [weak client] in await client?.removeChannel($0) }, - push: { [weak client] in await client?.push($0) } + push: { [weak client] in await client?.push($0) }, + httpSend: { [weak client] in try await client?.http.send($0) ?? .init(data: Data(), response: HTTPURLResponse()) } ) } } @@ -202,8 +208,6 @@ public final class RealtimeChannelV2: Sendable { /// - event: Broadcast message event. /// - message: Message payload. public func broadcast(event: String, message: JSONObject) async { - guard let socket else { return } - if status != .subscribed { struct Message: Encodable { let topic: String @@ -211,12 +215,12 @@ public final class RealtimeChannelV2: Sendable { let payload: JSONObject } - _ = try? await socket.http.send( + _ = try? await socket.httpSend( HTTPRequest( - url: socket.broadcastURL, + url: socket.broadcastURL(), method: .post, headers: [ - "apikey": socket.apikey ?? "", + "apikey": socket.apiKey() ?? "", "content-type": "application/json", ], body: JSONEncoder().encode( From d5e4f515d6917963c454d64e8b042d1384b8fdf6 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 25 Jul 2024 06:10:17 -0300 Subject: [PATCH 3/4] Add tests --- Sources/Realtime/V2/RealtimeChannelV2.swift | 73 ++++--- Tests/HelpersTests/AnyJSONTests.swift | 6 +- .../RealtimeIntegrationTests.swift | 200 ++++++++++-------- Tests/RealtimeTests/RealtimeTests.swift | 53 ++++- Tests/RealtimeTests/_PushTests.swift | 3 +- 5 files changed, 211 insertions(+), 124 deletions(-) diff --git a/Sources/Realtime/V2/RealtimeChannelV2.swift b/Sources/Realtime/V2/RealtimeChannelV2.swift index ba4bf3f5..7eb53fe3 100644 --- a/Sources/Realtime/V2/RealtimeChannelV2.swift +++ b/Sources/Realtime/V2/RealtimeChannelV2.swift @@ -213,40 +213,59 @@ public final class RealtimeChannelV2: Sendable { let topic: String let event: String let payload: JSONObject + let `private`: Bool } - _ = try? await socket.httpSend( - HTTPRequest( - url: socket.broadcastURL(), - method: .post, - headers: [ - "apikey": socket.apiKey() ?? "", - "content-type": "application/json", - ], - body: JSONEncoder().encode( - Message( - topic: topic, - event: event, - payload: message + var headers = HTTPHeaders(["content-type": "application/json"]) + if let apiKey = socket.apiKey() { + headers["apikey"] = apiKey + } + if let accessToken = socket.accessToken() { + headers["authorization"] = "Bearer \(accessToken)" + } + + let task = Task { [headers] in + _ = try? await socket.httpSend( + HTTPRequest( + url: socket.broadcastURL(), + method: .post, + headers: headers, + body: JSONEncoder().encode( + [ + "messages": [ + Message( + topic: topic, + event: event, + payload: message, + private: config.isPrivate + ), + ], + ] ) ) ) - ) - } + } - await push( - RealtimeMessageV2( - joinRef: mutableState.joinRef, - ref: socket.makeRef().description, - topic: topic, - event: ChannelEvent.broadcast, - payload: [ - "type": "broadcast", - "event": .string(event), - "payload": .object(message), - ] + if config.broadcast.acknowledgeBroadcasts { + try? await withTimeout(interval: socket.options().timeoutInterval) { + await task.value + } + } + } else { + await push( + RealtimeMessageV2( + joinRef: mutableState.joinRef, + ref: socket.makeRef().description, + topic: topic, + event: ChannelEvent.broadcast, + payload: [ + "type": "broadcast", + "event": .string(event), + "payload": .object(message), + ] + ) ) - ) + } } public func track(_ state: some Codable) async throws { diff --git a/Tests/HelpersTests/AnyJSONTests.swift b/Tests/HelpersTests/AnyJSONTests.swift index 1369eac8..3253b34c 100644 --- a/Tests/HelpersTests/AnyJSONTests.swift +++ b/Tests/HelpersTests/AnyJSONTests.swift @@ -82,7 +82,7 @@ final class AnyJSONTests: XCTestCase { // } func testInitFromCodable() { - expectNoDifference(try AnyJSON(jsonObject), jsonObject) + try expectNoDifference(AnyJSON(jsonObject), jsonObject) let codableValue = CodableValue( integer: 1, @@ -104,8 +104,8 @@ final class AnyJSONTests: XCTestCase { "any_json": jsonObject, ] - expectNoDifference(try AnyJSON(codableValue), json) - expectNoDifference(codableValue, try json.decode(as: CodableValue.self)) + try expectNoDifference(AnyJSON(codableValue), json) + try expectNoDifference(codableValue, json.decode(as: CodableValue.self)) } } diff --git a/Tests/IntegrationTests/RealtimeIntegrationTests.swift b/Tests/IntegrationTests/RealtimeIntegrationTests.swift index 3c9daf53..07b49824 100644 --- a/Tests/IntegrationTests/RealtimeIntegrationTests.swift +++ b/Tests/IntegrationTests/RealtimeIntegrationTests.swift @@ -36,131 +36,147 @@ final class RealtimeIntegrationTests: XCTestCase { logger: Logger() ) + override func invokeTest() { + withMainSerialExecutor { + super.invokeTest() + } + } + func testBroadcast() async throws { - try await withMainSerialExecutor { - let expectation = expectation(description: "receivedBroadcastMessages") - expectation.expectedFulfillmentCount = 3 + let expectation = expectation(description: "receivedBroadcastMessages") + expectation.expectedFulfillmentCount = 3 - let channel = realtime.channel("integration") { - $0.broadcast.receiveOwnBroadcasts = true - } + let channel = realtime.channel("integration") { + $0.broadcast.receiveOwnBroadcasts = true + } - let receivedMessages = LockIsolated<[JSONObject]>([]) + let receivedMessages = LockIsolated<[JSONObject]>([]) - Task { - for await message in channel.broadcastStream(event: "test") { - receivedMessages.withValue { - $0.append(message) - } - expectation.fulfill() + Task { + for await message in channel.broadcastStream(event: "test") { + receivedMessages.withValue { + $0.append(message) } + expectation.fulfill() } + } - await Task.yield() + await Task.yield() - await channel.subscribe() + await channel.subscribe() - struct Message: Codable { - var value: Int - } + struct Message: Codable { + var value: Int + } - try await channel.broadcast(event: "test", message: Message(value: 1)) - try await channel.broadcast(event: "test", message: Message(value: 2)) - try await channel.broadcast(event: "test", message: ["value": 3, "another_value": 42]) + try await channel.broadcast(event: "test", message: Message(value: 1)) + try await channel.broadcast(event: "test", message: Message(value: 2)) + try await channel.broadcast(event: "test", message: ["value": 3, "another_value": 42]) - await fulfillment(of: [expectation], timeout: 0.5) + await fulfillment(of: [expectation], timeout: 0.5) - expectNoDifference( - receivedMessages.value, + expectNoDifference( + receivedMessages.value, + [ [ - [ - "event": "test", - "payload": [ - "value": 1, - ], - "type": "broadcast", + "event": "test", + "payload": [ + "value": 1, ], - [ - "event": "test", - "payload": [ - "value": 2, - ], - "type": "broadcast", + "type": "broadcast", + ], + [ + "event": "test", + "payload": [ + "value": 2, ], - [ - "event": "test", - "payload": [ - "value": 3, - "another_value": 42, - ], - "type": "broadcast", + "type": "broadcast", + ], + [ + "event": "test", + "payload": [ + "value": 3, + "another_value": 42, ], - ] - ) + "type": "broadcast", + ], + ] + ) + + await channel.unsubscribe() + } + + func testBroadcastWithUnsubscribedChannel() async throws { + let channel = realtime.channel("integration") { + $0.broadcast.acknowledgeBroadcasts = true + } - await channel.unsubscribe() + struct Message: Codable { + var value: Int } + + try await channel.broadcast(event: "test", message: Message(value: 1)) + try await channel.broadcast(event: "test", message: Message(value: 2)) + try await channel.broadcast(event: "test", message: ["value": 3, "another_value": 42]) } func testPresence() async throws { - try await withMainSerialExecutor { - let channel = realtime.channel("integration") { - $0.broadcast.receiveOwnBroadcasts = true - } + let channel = realtime.channel("integration") { + $0.broadcast.receiveOwnBroadcasts = true + } - let expectation = expectation(description: "presenceChange") - expectation.expectedFulfillmentCount = 4 + let expectation = expectation(description: "presenceChange") + expectation.expectedFulfillmentCount = 4 - let receivedPresenceChanges = LockIsolated<[any PresenceAction]>([]) + let receivedPresenceChanges = LockIsolated<[any PresenceAction]>([]) - Task { - for await presence in channel.presenceChange() { - receivedPresenceChanges.withValue { - $0.append(presence) - } - expectation.fulfill() + Task { + for await presence in channel.presenceChange() { + receivedPresenceChanges.withValue { + $0.append(presence) } + expectation.fulfill() } + } - await Task.yield() + await Task.yield() - await channel.subscribe() + await channel.subscribe() - struct UserState: Codable, Equatable { - let email: String - } + struct UserState: Codable, Equatable { + let email: String + } - try await channel.track(UserState(email: "test@supabase.com")) - try await channel.track(["email": "test2@supabase.com"]) + try await channel.track(UserState(email: "test@supabase.com")) + try await channel.track(["email": "test2@supabase.com"]) - await channel.untrack() + await channel.untrack() - await fulfillment(of: [expectation], timeout: 0.5) + await fulfillment(of: [expectation], timeout: 0.5) - let joins = try receivedPresenceChanges.value.map { try $0.decodeJoins(as: UserState.self) } - let leaves = try receivedPresenceChanges.value.map { try $0.decodeLeaves(as: UserState.self) } - expectNoDifference( - joins, - [ - [], // This is the first PRESENCE_STATE event. - [UserState(email: "test@supabase.com")], - [UserState(email: "test2@supabase.com")], - [], - ] - ) - - expectNoDifference( - leaves, - [ - [], // This is the first PRESENCE_STATE event. - [], - [UserState(email: "test@supabase.com")], - [UserState(email: "test2@supabase.com")], - ] - ) - - await channel.unsubscribe() - } + let joins = try receivedPresenceChanges.value.map { try $0.decodeJoins(as: UserState.self) } + let leaves = try receivedPresenceChanges.value.map { try $0.decodeLeaves(as: UserState.self) } + expectNoDifference( + joins, + [ + [], // This is the first PRESENCE_STATE event. + [UserState(email: "test@supabase.com")], + [UserState(email: "test2@supabase.com")], + [], + ] + ) + + expectNoDifference( + leaves, + [ + [], // This is the first PRESENCE_STATE event. + [], + [UserState(email: "test@supabase.com")], + [UserState(email: "test2@supabase.com")], + ] + ) + + await channel.unsubscribe() } // FIXME: Test getting stuck diff --git a/Tests/RealtimeTests/RealtimeTests.swift b/Tests/RealtimeTests/RealtimeTests.swift index f6e8fb7b..e24cf34b 100644 --- a/Tests/RealtimeTests/RealtimeTests.swift +++ b/Tests/RealtimeTests/RealtimeTests.swift @@ -16,12 +16,14 @@ final class RealtimeTests: XCTestCase { } var ws: MockWebSocketClient! + var http: HTTPClientMock! var sut: RealtimeClientV2! override func setUp() { super.setUp() ws = MockWebSocketClient() + http = HTTPClientMock() sut = RealtimeClientV2( url: url, options: RealtimeClientOptions( @@ -31,7 +33,8 @@ final class RealtimeTests: XCTestCase { timeoutInterval: 2, logger: TestLogger() ), - ws: ws + ws: ws, + http: http ) } @@ -234,6 +237,54 @@ final class RealtimeTests: XCTestCase { ) } + func testBroadcastWithHTTP() async throws { + await http.when { + $0.url.path.hasSuffix("broadcast") + } return: { _ in + HTTPResponse( + data: "{}".data(using: .utf8)!, + response: HTTPURLResponse( + url: self.sut.broadcastURL, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + } + + let channel = sut.channel("public:messages") { + $0.broadcast.acknowledgeBroadcasts = true + } + + try await channel.broadcast(event: "test", message: ["value": 42]) + + let request = await http.receivedRequests.last + expectNoDifference( + request?.headers, + [ + "content-type": "application/json", + "apikey": "anon.api.key", + "authorization": "Bearer anon.api.key", + ] + ) + + let body = try XCTUnwrap(request?.body) + let json = try JSONDecoder().decode(JSONObject.self, from: body) + expectNoDifference( + json, + [ + "messages": [ + [ + "topic": "realtime:public:messages", + "event": "test", + "payload": ["value": 42], + "private": false, + ], + ], + ] + ) + } + private func connectSocketAndWait() async { ws.mockConnect(.connected) await sut.connect() diff --git a/Tests/RealtimeTests/_PushTests.swift b/Tests/RealtimeTests/_PushTests.swift index 3c7aef5b..67efc7a1 100644 --- a/Tests/RealtimeTests/_PushTests.swift +++ b/Tests/RealtimeTests/_PushTests.swift @@ -29,7 +29,8 @@ final class _PushTests: XCTestCase { options: RealtimeClientOptions( headers: ["apiKey": "apikey"] ), - ws: ws + ws: ws, + http: HTTPClientMock() ) } From 3017c2d4572571ec57039d2cacbe0fea2553b39b Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 25 Jul 2024 09:11:12 -0300 Subject: [PATCH 4/4] fix linux test --- .swiftpm/configuration/Package.resolved | 59 ++++++++++++++++++ .../xcshareddata/xcschemes/Supabase.xcscheme | 3 + Package.resolved | 21 +++++-- Package.swift | 18 +++--- Sources/Realtime/V2/RealtimeChannelV2.swift | 15 +++++ Sources/Realtime/V2/Types.swift | 4 ++ .../xcshareddata/swiftpm/Package.resolved | 62 ++++++------------- TestPlans/AllTests.xctestplan | 4 +- Tests/RealtimeTests/RealtimeTests.swift | 4 ++ 9 files changed, 129 insertions(+), 61 deletions(-) create mode 100644 .swiftpm/configuration/Package.resolved diff --git a/.swiftpm/configuration/Package.resolved b/.swiftpm/configuration/Package.resolved new file mode 100644 index 00000000..dee6efd6 --- /dev/null +++ b/.swiftpm/configuration/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "bc1c29221f6dfeb0ebbfbc98eb95cd3d4967868e", + "version" : "3.4.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "aec6a73f5c1dc1f1be4f61888094b95cf995d973", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "c097f955b4e724690f0fc8ffb7a6d4b881c9c4e3", + "version" : "1.17.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", + "version" : "510.0.2" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", + "version" : "1.2.2" + } + } + ], + "version" : 2 +} diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Supabase.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Supabase.xcscheme index 709700c6..73d8b0ca 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Supabase.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Supabase.xcscheme @@ -35,6 +35,9 @@ + +