diff --git a/Examples/SlackClone/AppView.swift b/Examples/SlackClone/AppView.swift index 74313acf..4442bb6c 100644 --- a/Examples/SlackClone/AppView.swift +++ b/Examples/SlackClone/AppView.swift @@ -14,6 +14,8 @@ final class AppViewModel { var session: Session? var selectedChannel: Channel? + var realtimeConnectionStatus: RealtimeClientV2.Status? + init() { Task { [weak self] in for await (event, session) in await supabase.auth.authStateChanges { @@ -27,23 +29,46 @@ final class AppViewModel { } } } + + Task { + for await status in await supabase.realtimeV2.statusChange { + realtimeConnectionStatus = status + } + } } } @MainActor struct AppView: View { @Bindable var model: AppViewModel + let log = LogStore.shared + + @State var logPresented = false @ViewBuilder var body: some View { if model.session != nil { NavigationSplitView { ChannelListView(channel: $model.selectedChannel) + .toolbar { + ToolbarItem { + Button("Log") { + logPresented = true + } + } + } } detail: { if let channel = model.selectedChannel { MessagesView(channel: channel).id(channel.id) } } + .sheet(isPresented: $logPresented) { + List { + ForEach(0 ..< log.messages.count, id: \.self) { i in + Text(log.messages[i].description) + } + } + } } else { AuthView() } diff --git a/Examples/SlackClone/Logger.swift b/Examples/SlackClone/Logger.swift index 0c6cd627..396b5adf 100644 --- a/Examples/SlackClone/Logger.swift +++ b/Examples/SlackClone/Logger.swift @@ -13,11 +13,21 @@ extension Logger { static let main = Self(subsystem: "com.supabase.SlackClone", category: "app") } -final class SupabaseLoggerImpl: SupabaseLogger, @unchecked Sendable { +@Observable +final class LogStore: SupabaseLogger { private let lock = NSLock() private var loggers: [String: Logger] = [:] + static let shared = LogStore() + + @MainActor + var messages: [SupabaseLogMessage] = [] + func log(message: SupabaseLogMessage) { + Task { + await add(message: message) + } + lock.withLock { if loggers[message.system] == nil { loggers[message.system] = Logger( @@ -29,11 +39,16 @@ final class SupabaseLoggerImpl: SupabaseLogger, @unchecked Sendable { let logger = loggers[message.system]! switch message.level { - case .debug: logger.debug("\(message)") - case .error: logger.error("\(message)") - case .verbose: logger.info("\(message)") - case .warning: logger.notice("\(message)") + case .debug: logger.debug("\(message, privacy: .public)") + case .error: logger.error("\(message, privacy: .public)") + case .verbose: logger.info("\(message, privacy: .public)") + case .warning: logger.notice("\(message, privacy: .public)") } } } + + @MainActor + private func add(message: SupabaseLogMessage) { + messages.insert(message, at: 0) + } } diff --git a/Examples/SlackClone/Supabase.swift b/Examples/SlackClone/Supabase.swift index c3bb6c20..9581d3dc 100644 --- a/Examples/SlackClone/Supabase.swift +++ b/Examples/SlackClone/Supabase.swift @@ -21,10 +21,10 @@ let decoder: JSONDecoder = { }() let supabase = SupabaseClient( - supabaseURL: URL(string: "http://127.0.0.1:54321")!, + supabaseURL: URL(string: "http://localhost:54321")!, supabaseKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", options: SupabaseClientOptions( db: .init(encoder: encoder, decoder: decoder), - global: SupabaseClientOptions.GlobalOptions(logger: SupabaseLoggerImpl()) + global: SupabaseClientOptions.GlobalOptions(logger: LogStore.shared) ) ) diff --git a/Examples/SlackClone/UserStore.swift b/Examples/SlackClone/UserStore.swift index ebd34bc0..fa9d38d1 100644 --- a/Examples/SlackClone/UserStore.swift +++ b/Examples/SlackClone/UserStore.swift @@ -22,12 +22,17 @@ final class UserStore { let channel = await supabase.realtimeV2.channel("public:users") let changes = await channel.postgresChange(AnyAction.self, table: "users") - let prenseces = await channel.presenceChange() + let presences = await channel.presenceChange() await channel.subscribe() - let userId = try await supabase.auth.session.user.id - try await channel.track(UserPresence(userId: userId, onlineAt: Date())) + Task { + let statusChange = await channel.statusChange + for await _ in statusChange.filter({ $0 == .subscribed }) { + let userId = try await supabase.auth.session.user.id + try await channel.track(UserPresence(userId: userId, onlineAt: Date())) + } + } Task { for await change in changes { @@ -36,7 +41,7 @@ final class UserStore { } Task { - for await presence in prenseces { + for await presence in presences { let joins = try presence.decodeJoins(as: UserPresence.self) let leaves = try presence.decodeLeaves(as: UserPresence.self) diff --git a/Package.swift b/Package.swift index 5257af57..595c10ef 100644 --- a/Package.swift +++ b/Package.swift @@ -79,6 +79,7 @@ let package = Package( dependencies: [ "Auth", "_Helpers", + "TestHelpers", .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), ], @@ -115,6 +116,7 @@ let package = Package( name: "RealtimeTests", dependencies: [ "Realtime", + "TestHelpers", .product(name: "CustomDump", package: "swift-custom-dump"), ] ), @@ -132,6 +134,7 @@ let package = Package( ] ), .testTarget(name: "SupabaseTests", dependencies: ["Supabase"]), + .target(name: "TestHelpers"), ] ) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 0bba88a2..e885c7af 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -199,9 +199,9 @@ public actor AuthClient { public func onAuthStateChange( _ listener: @escaping AuthStateChangeListener ) async -> some AuthStateChangeListenerRegistration { - let handle = eventEmitter.attachListener(listener) - await emitInitialSession(forHandle: handle) - return handle + let token = eventEmitter.attachListener(listener) + await emitInitialSession(forToken: token) + return token } /// Listen for auth state changes. @@ -908,9 +908,9 @@ public actor AuthClient { return session } - private func emitInitialSession(forHandle handle: AuthStateChangeListenerHandle) async { + private func emitInitialSession(forToken token: ObservationToken) async { let session = try? await session - eventEmitter.emit(.initialSession, session: session, handle: handle) + eventEmitter.emit(.initialSession, session: session, token: token) } private func prepareForPKCE() -> (codeChallenge: String?, codeChallengeMethod: String?) { diff --git a/Sources/Auth/AuthStateChangeListener.swift b/Sources/Auth/AuthStateChangeListener.swift index 8b053653..5d7dff6e 100644 --- a/Sources/Auth/AuthStateChangeListener.swift +++ b/Sources/Auth/AuthStateChangeListener.swift @@ -5,35 +5,19 @@ // Created by Guilherme Souza on 17/02/24. // +import _Helpers import ConcurrencyExtras import Foundation /// A listener that can be removed by calling ``AuthStateChangeListenerRegistration/remove()``. /// /// - Note: Listener is automatically removed on deinit. -public protocol AuthStateChangeListenerRegistration: Sendable, AnyObject { +public protocol AuthStateChangeListenerRegistration: Sendable { /// Removes the listener. After the initial call, subsequent calls have no effect. func remove() } -final class AuthStateChangeListenerHandle: AuthStateChangeListenerRegistration { - let _onRemove = LockIsolated((@Sendable () -> Void)?.none) - - public func remove() { - _onRemove.withValue { - if $0 == nil { - return - } - - $0?() - $0 = nil - } - } - - deinit { - remove() - } -} +extension ObservationToken: AuthStateChangeListenerRegistration {} public typealias AuthStateChangeListener = @Sendable ( _ event: AuthChangeEvent, diff --git a/Sources/Auth/Internal/EventEmitter.swift b/Sources/Auth/Internal/EventEmitter.swift index 6b1ba017..12b50833 100644 --- a/Sources/Auth/Internal/EventEmitter.swift +++ b/Sources/Auth/Internal/EventEmitter.swift @@ -1,15 +1,16 @@ import ConcurrencyExtras import Foundation +@_spi(Internal) import _Helpers protocol EventEmitter: Sendable { func attachListener( _ listener: @escaping AuthStateChangeListener - ) -> AuthStateChangeListenerHandle + ) -> ObservationToken func emit( _ event: AuthChangeEvent, session: Session?, - handle: AuthStateChangeListenerHandle? + token: ObservationToken? ) } @@ -18,7 +19,7 @@ extension EventEmitter { _ event: AuthChangeEvent, session: Session? ) { - emit(event, session: session, handle: nil) + emit(event, session: session, token: nil) } } @@ -27,31 +28,24 @@ final class DefaultEventEmitter: EventEmitter { private init() {} - let listeners = LockIsolated<[ObjectIdentifier: AuthStateChangeListener]>([:]) + let emitter = _Helpers.EventEmitter<(AuthChangeEvent, Session?)?>( + initialEvent: nil, + emitsLastEventWhenAttaching: false + ) func attachListener( _ listener: @escaping AuthStateChangeListener - ) -> AuthStateChangeListenerHandle { - let handle = AuthStateChangeListenerHandle() - let key = ObjectIdentifier(handle) - - handle._onRemove.setValue { [weak self] in - self?.listeners.withValue { - $0[key] = nil - } - } - - listeners.withValue { - $0[key] = listener + ) -> ObservationToken { + emitter.attach { event in + guard let event else { return } + listener(event.0, event.1) } - - return handle } func emit( _ event: AuthChangeEvent, session: Session?, - handle: AuthStateChangeListenerHandle? = nil + token: ObservationToken? = nil ) { NotificationCenter.default.post( name: AuthClient.didChangeAuthStateNotification, @@ -62,14 +56,6 @@ final class DefaultEventEmitter: EventEmitter { ] ) - let listeners = listeners.value - - if let handle { - listeners[ObjectIdentifier(handle)]?(event, session) - } else { - for listener in listeners.values { - listener(event, session) - } - } + emitter.emit((event, session), to: token) } } diff --git a/Sources/Realtime/V2/PushV2.swift b/Sources/Realtime/V2/PushV2.swift index 9e694b1f..2d91d65c 100644 --- a/Sources/Realtime/V2/PushV2.swift +++ b/Sources/Realtime/V2/PushV2.swift @@ -21,7 +21,7 @@ actor PushV2 { func send() async -> PushStatus { do { - try await channel?.socket?.ws?.send(message) + try await channel?.socket?.ws.send(message) if channel?.config.broadcast.acknowledgeBroadcasts == true { return await withCheckedContinuation { diff --git a/Sources/Realtime/V2/RealtimeChannelV2.swift b/Sources/Realtime/V2/RealtimeChannelV2.swift index 1a9f2779..23ec3948 100644 --- a/Sources/Realtime/V2/RealtimeChannelV2.swift +++ b/Sources/Realtime/V2/RealtimeChannelV2.swift @@ -33,19 +33,20 @@ public actor RealtimeChannelV2 { let logger: (any SupabaseLogger)? private let callbackManager = CallbackManager() - private let statusStream = SharedStream(initialElement: .unsubscribed) + + private let statusEventEmitter = EventEmitter(initialEvent: .unsubscribed) private var clientChanges: [PostgresJoinConfig] = [] private var joinRef: String? private var pushes: [String: PushV2] = [:] public private(set) var status: Status { - get { statusStream.lastElement } - set { statusStream.yield(newValue) } + get { statusEventEmitter.lastEvent.value } + set { statusEventEmitter.emit(newValue) } } public var statusChange: AsyncStream { - statusStream.makeStream() + statusEventEmitter.stream() } init( diff --git a/Sources/Realtime/V2/RealtimeClientV2.swift b/Sources/Realtime/V2/RealtimeClientV2.swift index 363d31ba..c78a9c26 100644 --- a/Sources/Realtime/V2/RealtimeClientV2.swift +++ b/Sources/Realtime/V2/RealtimeClientV2.swift @@ -50,42 +50,57 @@ public actor RealtimeClientV2 { } } - public enum Status: Sendable { + public enum Status: Sendable, CustomStringConvertible { case disconnected case connecting case connected + + public var description: String { + switch self { + case .disconnected: "Disconnected" + case .connecting: "Connecting" + case .connected: "Connected" + } + } } + let config: Configuration + let ws: any WebSocketClient + var accessToken: String? var ref = 0 var pendingHeartbeatRef: Int? + var heartbeatTask: Task? var messageTask: Task? - var inFlightConnectionTask: Task? + var connectionTask: Task? public private(set) var subscriptions: [String: RealtimeChannelV2] = [:] - var ws: WebSocketClient? - - let config: Configuration - let makeWebSocketClient: (_ url: URL, _ headers: [String: String]) -> WebSocketClient - private let statusStream = SharedStream(initialElement: .disconnected) + private let statusEventEmitter = EventEmitter(initialEvent: .disconnected) public var statusChange: AsyncStream { - statusStream.makeStream() + statusEventEmitter.stream() } public private(set) var status: Status { - get { statusStream.lastElement } - set { statusStream.yield(newValue) } + get { statusEventEmitter.lastEvent.value } + set { statusEventEmitter.emit(newValue) } } - init( - config: Configuration, - makeWebSocketClient: @escaping (_ url: URL, _ headers: [String: String]) -> WebSocketClient - ) { + public func onStatusChange( + _ listener: @escaping @Sendable (Status) -> Void + ) -> ObservationToken { + statusEventEmitter.attach(listener) + } + + public init(config: Configuration) { + self.init(config: config, ws: WebSocket(config: config)) + } + + init(config: Configuration, ws: any WebSocketClient) { self.config = config - self.makeWebSocketClient = makeWebSocketClient + self.ws = ws if let customJWT = config.headers["Authorization"]?.split(separator: " ").last { accessToken = String(customJWT) @@ -98,22 +113,6 @@ public actor RealtimeClientV2 { heartbeatTask?.cancel() messageTask?.cancel() subscriptions = [:] - ws?.cancel() - } - - public init(config: Configuration) { - self.init( - config: config, - makeWebSocketClient: { url, headers in - let configuration = URLSessionConfiguration.default - configuration.httpAdditionalHeaders = headers - return WebSocketClient( - realtimeURL: url, - configuration: configuration, - logger: config.logger - ) - } - ) } public func connect() async { @@ -121,56 +120,75 @@ public actor RealtimeClientV2 { } func connect(reconnect: Bool) async { - if let inFlightConnectionTask { - return await inFlightConnectionTask.value - } + if status == .disconnected { + connectionTask = Task { + if reconnect { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(config.reconnectDelay)) - inFlightConnectionTask = Task { [self] in - defer { inFlightConnectionTask = nil } - if reconnect { - try? await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(config.reconnectDelay)) + if Task.isCancelled { + config.logger?.debug("Reconnect cancelled, returning") + return + } + } - if Task.isCancelled { - config.logger?.debug("reconnect cancelled, returning") + if status == .connected { + config.logger?.debug("WebsSocket already connected") return } - } - if status == .connected { - config.logger?.debug("Websocket already connected") - return - } - - status = .connecting + status = .connecting - let realtimeURL = realtimeWebSocketURL - let ws = makeWebSocketClient(realtimeURL, config.headers) - self.ws = ws + for await connectionStatus in ws.connect() { + if Task.isCancelled { + break + } - await ws.connect() + switch connectionStatus { + case .connected: + await onConnected(reconnect: reconnect) - let connectionStatus = await ws.status.first { @Sendable _ in true } + case .disconnected: + await onDisconnected() - switch connectionStatus { - case .open: - status = .connected - config.logger?.debug("Connected to realtime websocket") - listenForMessages() - startHeartbeating() - if reconnect { - await rejoinChannels() + case let .error(error): + await onError(error) + } } - - case .close, .error, nil: - config.logger?.debug( - "Error while trying to connect to realtime websocket. Trying again in \(config.reconnectDelay) seconds." - ) - disconnect() - await connect(reconnect: true) } } - await inFlightConnectionTask?.value + _ = await statusChange.first { @Sendable in $0 == .connected } + } + + private func onConnected(reconnect: Bool) async { + status = .connected + config.logger?.debug("Connected to realtime WebSocket") + listenForMessages() + startHeartbeating() + if reconnect { + await rejoinChannels() + } + } + + private func onDisconnected() async { + config.logger? + .debug( + "WebSocket disconnected. Trying again in \(config.reconnectDelay)" + ) + await reconnect() + } + + private func onError(_ error: (any Error)?) async { + config.logger? + .debug( + "WebSocket error \(error?.localizedDescription ?? ""). Trying again in \(config.reconnectDelay)" + ) + await reconnect() + } + + private func reconnect() async { + disconnect() + await connect(reconnect: true) } public func channel( @@ -209,45 +227,40 @@ public actor RealtimeClientV2 { } private func rejoinChannels() async { - await withTaskGroup(of: Void.self) { group in - for channel in subscriptions.values { - _ = group.addTaskUnlessCancelled { - await channel.subscribe() - } - - await group.waitForAll() - } + for channel in subscriptions.values { + await channel.subscribe() } } private func listenForMessages() { messageTask = Task { [weak self] in - guard let self, let ws = await ws else { return } + guard let self else { return } do { for try await message in ws.receive() { + if Task.isCancelled { + return + } + await onMessage(message) } } catch { config.logger?.debug( "Error while listening for messages. Trying again in \(config.reconnectDelay) \(error)" ) - await disconnect() - await connect(reconnect: true) + await reconnect() } } } private func startHeartbeating() { - heartbeatTask = Task { [weak self] in - guard let self else { return } - + heartbeatTask = Task { [weak self, config] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(config.heartbeatInterval)) if Task.isCancelled { break } - await sendHeartbeat() + await self?.sendHeartbeat() } } } @@ -255,9 +268,9 @@ public actor RealtimeClientV2 { private func sendHeartbeat() async { if pendingHeartbeatRef != nil { pendingHeartbeatRef = nil - config.logger?.debug("Heartbeat timeout. Trying to reconnect in \(config.reconnectDelay)") - disconnect() - await connect(reconnect: true) + config.logger?.debug("Heartbeat timeout") + + await reconnect() return } @@ -275,12 +288,12 @@ public actor RealtimeClientV2 { } public func disconnect() { - config.logger?.debug("Closing websocket connection") + config.logger?.debug("Closing WebSocket connection") ref = 0 messageTask?.cancel() heartbeatTask?.cancel() - ws?.cancel() - ws = nil + connectionTask?.cancel() + ws.disconnect() status = .disconnected } @@ -309,7 +322,7 @@ public actor RealtimeClientV2 { func send(_ message: RealtimeMessageV2) async { do { - try await ws?.send(message) + try await ws.send(message) } catch { config.logger?.debug(""" Failed to send message: @@ -325,10 +338,12 @@ public actor RealtimeClientV2 { ref += 1 return ref } +} - private var realtimeBaseURL: URL { - guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else { - return config.url +extension RealtimeClientV2.Configuration { + var realtimeBaseURL: URL { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url } if components.scheme == "https" { @@ -338,20 +353,20 @@ public actor RealtimeClientV2 { } guard let url = components.url else { - return config.url + return url } return url } - private var realtimeWebSocketURL: URL { + var realtimeWebSocketURL: URL { guard var components = URLComponents(url: realtimeBaseURL, resolvingAgainstBaseURL: false) else { return realtimeBaseURL } components.queryItems = components.queryItems ?? [] - components.queryItems!.append(URLQueryItem(name: "apikey", value: config.apiKey)) + components.queryItems!.append(URLQueryItem(name: "apikey", value: apiKey)) components.queryItems!.append(URLQueryItem(name: "vsn", value: "1.0.0")) components.path.append("/websocket") @@ -365,40 +380,6 @@ public actor RealtimeClientV2 { } private var broadcastURL: URL { - config.url.appendingPathComponent("api/broadcast") - } -} - -struct TimeoutError: Error {} - -func withThrowingTimeout( - seconds: TimeInterval, - body: @escaping @Sendable () async throws -> R -) async throws -> R { - try await withThrowingTaskGroup(of: R.self) { group in - group.addTask { - try await body() - } - - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds) * NSEC_PER_SEC) - throw TimeoutError() - } - - let result = try await group.next()! - group.cancelAll() - return result - } -} - -extension Task where Success: Sendable, Failure == any Error { - init( - priority: TaskPriority? = nil, - timeout: TimeInterval, - operation: @escaping @Sendable () async throws -> Success - ) { - self = Task(priority: priority) { - try await withThrowingTimeout(seconds: timeout, body: operation) - } + url.appendingPathComponent("api/broadcast") } } diff --git a/Sources/Realtime/V2/SharedStream.swift b/Sources/Realtime/V2/SharedStream.swift deleted file mode 100644 index a6d3365e..00000000 --- a/Sources/Realtime/V2/SharedStream.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// SharedStream.swift -// -// -// Created by Guilherme Souza on 12/01/24. -// - -import ConcurrencyExtras -import Foundation - -final class SharedStream: Sendable where Element: Sendable { - private let storage = LockIsolated<[UUID: AsyncStream.Continuation]>([:]) - private let _value: LockIsolated - - var lastElement: Element { _value.value } - - init(initialElement: Element) { - _value = LockIsolated(initialElement) - } - - func makeStream() -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream() - let id = UUID() - - continuation.onTermination = { _ in - self.storage.withValue { - $0[id] = nil - } - } - - storage.withValue { - $0[id] = continuation - } - - continuation.yield(lastElement) - - return stream - } - - func yield(_ value: Element) { - _value.setValue(value) - for continuation in storage.value.values { - continuation.yield(value) - } - } - - func finish() { - for continuation in storage.value.values { - continuation.finish() - } - } -} diff --git a/Sources/Realtime/V2/WebSocketClient.swift b/Sources/Realtime/V2/WebSocketClient.swift index 7013b84a..44f2cda0 100644 --- a/Sources/Realtime/V2/WebSocketClient.swift +++ b/Sources/Realtime/V2/WebSocketClient.swift @@ -13,89 +13,69 @@ import Foundation import FoundationNetworking #endif -struct WebSocketClient: Sendable { - enum ConnectionStatus: Sendable { - case open - case close - case error(any Error) - } - - var status: AsyncStream +enum ConnectionStatus { + case connected + case disconnected(reason: String, code: URLSessionWebSocketTask.CloseCode) + case error((any Error)?) +} - var send: @Sendable (_ message: RealtimeMessageV2) async throws -> Void - var receive: @Sendable () -> AsyncThrowingStream - var connect: @Sendable () async -> Void - var cancel: @Sendable () -> Void +protocol WebSocketClient: Sendable { + func send(_ message: RealtimeMessageV2) async throws + func receive() -> AsyncThrowingStream + func connect() -> AsyncStream + func disconnect(closeCode: URLSessionWebSocketTask.CloseCode) } extension WebSocketClient { - init(realtimeURL: URL, configuration: URLSessionConfiguration, logger: (any SupabaseLogger)?) { - let client = LiveWebSocketClient( - realtimeURL: realtimeURL, - configuration: configuration, - logger: logger - ) - self.init( - status: client.status, - send: { try await client.send($0) }, - receive: { client.receive() }, - connect: { await client.connect() }, - cancel: { client.cancel() } - ) + func disconnect() { + disconnect(closeCode: .normalClosure) } } -private actor LiveWebSocketClient { +final class WebSocket: NSObject, URLSessionWebSocketDelegate, WebSocketClient, @unchecked Sendable { private let realtimeURL: URL private let configuration: URLSessionConfiguration private let logger: (any SupabaseLogger)? - private var delegate: Delegate? - private var session: URLSession? - private var task: URLSessionWebSocketTask? - - init(realtimeURL: URL, configuration: URLSessionConfiguration, logger: (any SupabaseLogger)?) { - self.realtimeURL = realtimeURL - self.configuration = configuration + struct MutableState { + var task: URLSessionWebSocketTask? + var continuation: AsyncStream.Continuation? + } - let (stream, continuation) = AsyncStream.makeStream() - status = stream - self.continuation = continuation + let mutableState = LockIsolated(MutableState()) - self.logger = logger - } + init(config: RealtimeClientV2.Configuration) { + realtimeURL = config.realtimeWebSocketURL - deinit { - task?.cancel() - continuation.finish() + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.httpAdditionalHeaders = config.headers + configuration = sessionConfiguration + logger = config.logger } - let continuation: AsyncStream.Continuation - nonisolated let status: AsyncStream + func connect() -> AsyncStream { + mutableState.withValue { state in + let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + state.task = session.webSocketTask(with: realtimeURL) + state.task?.resume() - func connect() { - delegate = Delegate { [weak self] status in - self?.continuation.yield(status) + let (stream, continuation) = AsyncStream.makeStream() + state.continuation = continuation + return stream } - session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) - task = session?.webSocketTask(with: realtimeURL) - task?.resume() - } - - nonisolated func cancel() { - Task { await _cancel() } } - private func _cancel() { - task?.cancel() - continuation.finish() + func disconnect(closeCode: URLSessionWebSocketTask.CloseCode) { + mutableState.withValue { state in + state.task?.cancel(with: closeCode, reason: nil) + } } - nonisolated func receive() -> AsyncThrowingStream { + func receive() -> AsyncThrowingStream { let (stream, continuation) = AsyncThrowingStream.makeStream() Task { - while let message = try await self.task?.receive() { + while let message = try await mutableState.task?.receive() { do { switch message { case let .string(stringMessage): @@ -117,6 +97,8 @@ private actor LiveWebSocketClient { continuation.finish(throwing: error) } } + + continuation.finish() } return stream @@ -127,41 +109,38 @@ private actor LiveWebSocketClient { let string = String(decoding: data, as: UTF8.self) logger?.verbose("Sending message: \(string)") - try await task?.send(.string(string)) + try await mutableState.task?.send(.string(string)) } - final class Delegate: NSObject, URLSessionWebSocketDelegate { - let onStatusChange: (_ status: WebSocketClient.ConnectionStatus) -> Void + // MARK: - URLSessionWebSocketDelegate - init(onStatusChange: @escaping (_ status: WebSocketClient.ConnectionStatus) -> Void) { - self.onStatusChange = onStatusChange - } + func urlSession( + _: URLSession, + webSocketTask _: URLSessionWebSocketTask, + didOpenWithProtocol _: String? + ) { + mutableState.continuation?.yield(.connected) + } - func urlSession( - _: URLSession, - webSocketTask _: URLSessionWebSocketTask, - didOpenWithProtocol _: String? - ) { - onStatusChange(.open) - } + func urlSession( + _: URLSession, + webSocketTask _: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data? + ) { + let status = ConnectionStatus.disconnected( + reason: reason.flatMap { String(data: $0, encoding: .utf8) } ?? "", + code: closeCode + ) - func urlSession( - _: URLSession, - webSocketTask _: URLSessionWebSocketTask, - didCloseWith _: URLSessionWebSocketTask.CloseCode, - reason _: Data? - ) { - onStatusChange(.close) - } + mutableState.continuation?.yield(status) + } - func urlSession( - _: URLSession, - task _: URLSessionTask, - didCompleteWithError error: (any Error)? - ) { - if let error { - onStatusChange(.error(error)) - } - } + func urlSession( + _: URLSession, + task _: URLSessionTask, + didCompleteWithError error: (any Error)? + ) { + mutableState.continuation?.yield(.error(error)) } } diff --git a/Sources/TestHelpers/WithMainSerialExecutor+Windows.swift b/Sources/TestHelpers/WithMainSerialExecutor+Windows.swift new file mode 100644 index 00000000..a40dac84 --- /dev/null +++ b/Sources/TestHelpers/WithMainSerialExecutor+Windows.swift @@ -0,0 +1,17 @@ +// +// WithMainSerialExecutor+Windows.swift +// +// +// Created by Guilherme Souza on 12/03/24. +// + +import Foundation + +#if os(Windows) + /// Calling this method on Windows has no effect. + public func withMainSerialExecutor( + @_implicitSelfCapture operation: () throws -> Void + ) rethrows { + try operation() + } +#endif diff --git a/Sources/_Helpers/EventEmitter.swift b/Sources/_Helpers/EventEmitter.swift new file mode 100644 index 00000000..378a9fee --- /dev/null +++ b/Sources/_Helpers/EventEmitter.swift @@ -0,0 +1,94 @@ +// +// EventEmitter.swift +// +// +// Created by Guilherme Souza on 08/03/24. +// + +import ConcurrencyExtras +import Foundation + +public final class ObservationToken: Sendable { + let _onRemove = LockIsolated((@Sendable () -> Void)?.none) + + public func remove() { + _onRemove.withValue { + if $0 == nil { + return + } + + $0?() + $0 = nil + } + } + + deinit { + remove() + } +} + +@_spi(Internal) +public final class EventEmitter: Sendable { + public typealias Listener = @Sendable (Event) -> Void + + let listeners = LockIsolated<[ObjectIdentifier: Listener]>([:]) + public let lastEvent: LockIsolated + + let emitsLastEventWhenAttaching: Bool + + public init( + initialEvent event: Event, + emitsLastEventWhenAttaching: Bool = true + ) { + lastEvent = LockIsolated(event) + self.emitsLastEventWhenAttaching = emitsLastEventWhenAttaching + } + + public func attach(_ listener: @escaping Listener) -> ObservationToken { + defer { + if emitsLastEventWhenAttaching { + listener(lastEvent.value) + } + } + + let token = ObservationToken() + let key = ObjectIdentifier(token) + + token._onRemove.setValue { [weak self] in + self?.listeners.withValue { + $0[key] = nil + } + } + + listeners.withValue { + $0[key] = listener + } + + return token + } + + public func emit(_ event: Event, to token: ObservationToken? = nil) { + lastEvent.setValue(event) + let listeners = listeners.value + + if let token { + listeners[ObjectIdentifier(token)]?(event) + } else { + for listener in listeners.values { + listener(event) + } + } + } + + public func stream() -> AsyncStream { + AsyncStream { continuation in + let token = attach { status in + continuation.yield(status) + } + + continuation.onTermination = { _ in + token.remove() + } + } + } +} diff --git a/Sources/_Helpers/Request.swift b/Sources/_Helpers/Request.swift index 7c5eac89..618b3baf 100644 --- a/Sources/_Helpers/Request.swift +++ b/Sources/_Helpers/Request.swift @@ -23,22 +23,28 @@ public struct HTTPClient: Sendable { .verbose( "Request [\(id)]: \(urlRequest.httpMethod ?? "") \(urlRequest.url?.absoluteString.removingPercentEncoding ?? "")" ) - let (data, response) = try await fetchHandler(urlRequest) - guard let httpResponse = response as? HTTPURLResponse else { + do { + let (data, response) = try await fetchHandler(urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + logger? + .error( + "Response [\(id)]: Expected a \(HTTPURLResponse.self) instance, but got a \(type(of: response))." + ) + throw URLError(.badServerResponse) + } + logger? - .error( - "Response [\(id)]: Expected a \(HTTPURLResponse.self) instance, but got a \(type(of: response))." + .verbose( + "Response [\(id)]: Status code: \(httpResponse.statusCode) Content-Length: \(httpResponse.expectedContentLength)" ) - throw URLError(.badServerResponse) - } - logger? - .verbose( - "Response [\(id)]: Status code: \(httpResponse.statusCode) Content-Length: \(httpResponse.expectedContentLength)" - ) - - return Response(data: data, response: httpResponse) + return Response(data: data, response: httpResponse) + } catch { + logger?.error("Response [\(id)]: Failure \(error)") + throw error + } } } diff --git a/Sources/_Helpers/SupabaseLogger.swift b/Sources/_Helpers/SupabaseLogger.swift index fe8ed7a0..de458f98 100644 --- a/Sources/_Helpers/SupabaseLogger.swift +++ b/Sources/_Helpers/SupabaseLogger.swift @@ -16,7 +16,7 @@ public enum SupabaseLogLevel: Int, Codable, CustomStringConvertible, Sendable { } } -public struct SupabaseLogMessage: Codable, CustomStringConvertible { +public struct SupabaseLogMessage: Codable, CustomStringConvertible, Sendable { public let system: String public let level: SupabaseLogLevel public let message: String diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index cb89723c..44a0f5a0 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -8,6 +8,7 @@ import XCTest @_spi(Internal) import _Helpers import ConcurrencyExtras +import TestHelpers @testable import Auth @@ -21,6 +22,12 @@ final class AuthClientTests: XCTestCase { var sut: AuthClient! + override func invokeTest() { + withMainSerialExecutor { + super.invokeTest() + } + } + override func setUp() { super.setUp() @@ -54,39 +61,19 @@ final class AuthClientTests: XCTestCase { $0.append(event) } } - addTeardownBlock { [weak handle] in - XCTAssertNil(handle, "handle should be deallocated") - } XCTAssertEqual(events.value, [.initialSession]) + + handle.remove() } func testAuthStateChanges() async throws { let session = Session.validSession sessionManager.returnSession = .success(session) - let events = ActorIsolated([AuthChangeEvent]()) - - let (stream, continuation) = AsyncStream.makeStream() - - let authStateStream = await sut.authStateChanges - - let streamTask = Task { - for await (event, _) in authStateStream { - await events.withValue { - $0.append(event) - } - - continuation.yield() - } - } - - _ = await stream.first { _ in true } - - let receivedEvents = await events.value - XCTAssertEqual(receivedEvents, [.initialSession]) - - streamTask.cancel() + let stateChange = await sut.authStateChanges.first { _ in true } + XCTAssertEqual(stateChange?.event, .initialSession) + XCTAssertEqual(stateChange?.session, session) } func testSignOut() async throws { diff --git a/Tests/AuthTests/AuthStateChangeListenerHandleTests.swift b/Tests/AuthTests/AuthStateChangeListenerHandleTests.swift index a5d9048c..fe35b692 100644 --- a/Tests/AuthTests/AuthStateChangeListenerHandleTests.swift +++ b/Tests/AuthTests/AuthStateChangeListenerHandleTests.swift @@ -5,6 +5,7 @@ // Created by Guilherme Souza on 17/02/24. // +@testable import _Helpers @testable import Auth import ConcurrencyExtras import Foundation @@ -12,7 +13,7 @@ import XCTest final class AuthStateChangeListenerHandleTests: XCTestCase { func testRemove() { - let handle = AuthStateChangeListenerHandle() + let handle = ObservationToken() let onRemoveCallCount = LockIsolated(0) handle._onRemove.setValue { @@ -28,7 +29,7 @@ final class AuthStateChangeListenerHandleTests: XCTestCase { } func testDeinit() { - var handle: AuthStateChangeListenerHandle? = AuthStateChangeListenerHandle() + var handle: ObservationToken? = ObservationToken() let onRemoveCallCount = LockIsolated(0) handle?._onRemove.setValue { diff --git a/Tests/AuthTests/Mocks/MockEventEmitter.swift b/Tests/AuthTests/Mocks/MockEventEmitter.swift index 7fe75f58..2b9cc438 100644 --- a/Tests/AuthTests/Mocks/MockEventEmitter.swift +++ b/Tests/AuthTests/Mocks/MockEventEmitter.swift @@ -5,6 +5,7 @@ // Created by Guilherme Souza on 15/02/24. // +import _Helpers @testable import Auth import ConcurrencyExtras import Foundation @@ -13,7 +14,7 @@ final class MockEventEmitter: EventEmitter { private let emitter = DefaultEventEmitter.shared func attachListener(_ listener: @escaping AuthStateChangeListener) - -> AuthStateChangeListenerHandle + -> ObservationToken { emitter.attachListener(listener) } @@ -26,12 +27,12 @@ final class MockEventEmitter: EventEmitter { func emit( _ event: AuthChangeEvent, session: Session?, - handle: AuthStateChangeListenerHandle? = nil + token: ObservationToken? = nil ) { _emitReceivedParams.withValue { $0.append((event, session)) } - emitter.emit(event, session: session, handle: handle) + emitter.emit(event, session: session, token: token) } } diff --git a/Tests/RealtimeTests/MockWebSocketClient.swift b/Tests/RealtimeTests/MockWebSocketClient.swift index a957a050..a6431f77 100644 --- a/Tests/RealtimeTests/MockWebSocketClient.swift +++ b/Tests/RealtimeTests/MockWebSocketClient.swift @@ -10,12 +10,49 @@ import Foundation @testable import Realtime import XCTestDynamicOverlay -extension WebSocketClient { - static let mock = WebSocketClient( - status: .never, - send: unimplemented("WebSocketClient.send"), - receive: unimplemented("WebSocketClient.receive"), - connect: unimplemented("WebSocketClient.connect"), - cancel: unimplemented("WebSocketClient.cancel") - ) +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +final class MockWebSocketClient: WebSocketClient { + let sentMessages = LockIsolated<[RealtimeMessageV2]>([]) + func send(_ message: RealtimeMessageV2) async throws { + sentMessages.withValue { + $0.append(message) + } + + if let callback = onCallback.value, let response = callback(message) { + mockReceive(response) + } + } + + private let receiveContinuation = + LockIsolated.Continuation?>(nil) + func mockReceive(_ message: RealtimeMessageV2) { + receiveContinuation.value?.yield(message) + } + + private let onCallback = LockIsolated<((RealtimeMessageV2) -> RealtimeMessageV2?)?>(nil) + func on(_ callback: @escaping (RealtimeMessageV2) -> RealtimeMessageV2?) { + onCallback.setValue(callback) + } + + func receive() -> AsyncThrowingStream { + let (stream, continuation) = AsyncThrowingStream.makeStream() + receiveContinuation.setValue(continuation) + return stream + } + + private let connectContinuation = LockIsolated.Continuation?>(nil) + func mockConnect(_ status: ConnectionStatus) { + connectContinuation.value?.yield(status) + } + + func connect() -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream() + connectContinuation.setValue(continuation) + return stream + } + + func disconnect(closeCode _: URLSessionWebSocketTask.CloseCode) {} } diff --git a/Tests/RealtimeTests/RealtimeTests.swift b/Tests/RealtimeTests/RealtimeTests.swift index 18fbc280..6a145f75 100644 --- a/Tests/RealtimeTests/RealtimeTests.swift +++ b/Tests/RealtimeTests/RealtimeTests.swift @@ -3,67 +3,148 @@ import XCTest import ConcurrencyExtras import CustomDump @testable import Realtime +import TestHelpers final class RealtimeTests: XCTestCase { let url = URL(string: "https://localhost:54321/realtime/v1")! let apiKey = "anon.api.key" - let accessToken = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzA1Nzc4MTAxLCJpYXQiOjE3MDU3NzQ1MDEsImlzcyI6Imh0dHA6Ly8xMjcuMC4wLjE6NTQzMjEvYXV0aC92MSIsInN1YiI6ImFiZTQ1NjMwLTM0YTAtNDBhNS04Zjg5LTQxY2NkYzJjNjQyNCIsImVtYWlsIjoib2dyc291emErbWFjQGdtYWlsLmNvbSIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZW1haWwiLCJwcm92aWRlcnMiOlsiZW1haWwiXX0sInVzZXJfbWV0YWRhdGEiOnt9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im1hZ2ljbGluayIsInRpbWVzdGFtcCI6MTcwNTYwODcxOX1dLCJzZXNzaW9uX2lkIjoiMzFmMmQ4NGQtODZmYi00NWE2LTljMTItODMyYzkwYTgyODJjIn0.RY1y5U7CK97v6buOgJj_jQNDHW_1o0THbNP2UQM1HVE" - var ref: Int = 0 - func makeRef() -> String { - ref += 1 - return "\(ref)" - } - - func testConnectAndSubscribe() async { - var mock = WebSocketClient.mock - mock.status = .init(unfolding: { .open }) - mock.connect = {} - mock.cancel = {} - - mock.receive = { - .init { - RealtimeMessageV2.messagesSubscribed - } - } - - let sentMessages: LockIsolated<[RealtimeMessageV2]> = .init([]) - mock.send = { message in - sentMessages.withValue { $0.append(message) } + override func invokeTest() { + withMainSerialExecutor { + super.invokeTest() } + } - let realtime = RealtimeClientV2( - config: RealtimeClientV2.Configuration(url: url, apiKey: apiKey), - makeWebSocketClient: { _, _ in mock } + var ws: MockWebSocketClient! + var sut: RealtimeClientV2! + + override func setUp() { + super.setUp() + + ws = MockWebSocketClient() + sut = RealtimeClientV2( + config: RealtimeClientV2.Configuration( + url: url, + apiKey: apiKey, + heartbeatInterval: 1, + reconnectDelay: 1 + ), + ws: ws ) + } - let channel = await realtime.channel("public:messages") + func testBehavior() async { + let channel = await sut.channel("public:messages") _ = await channel.postgresChange(InsertAction.self, table: "messages") _ = await channel.postgresChange(UpdateAction.self, table: "messages") _ = await channel.postgresChange(DeleteAction.self, table: "messages") - let statusChange = await realtime.statusChange + let statusChange = await sut.statusChange - await realtime.connect() - await realtime.setAuth(accessToken) + await connectSocketAndWait() let status = await statusChange.prefix(3).collect() XCTAssertEqual(status, [.disconnected, .connecting, .connected]) - let messageTask = await realtime.messageTask + let messageTask = await sut.messageTask XCTAssertNotNil(messageTask) - let heartbeatTask = await realtime.heartbeatTask + let heartbeatTask = await sut.heartbeatTask XCTAssertNotNil(heartbeatTask) - await channel.subscribe() + let subscription = Task { + await channel.subscribe() + } + await Task.megaYield() + ws.mockReceive(.messagesSubscribed) + + // Wait until channel subscribed + await subscription.value - XCTAssertNoDifference(sentMessages.value, [.subscribeToMessages]) + XCTAssertNoDifference(ws.sentMessages.value, [.subscribeToMessages]) } - func testHeartbeat() { - // TODO: test heartbeat behavior + func testHeartbeat() async throws { + let expectation = expectation(description: "heartbeat") + expectation.expectedFulfillmentCount = 2 + + ws.on { message in + if message.event == "heartbeat" { + expectation.fulfill() + return RealtimeMessageV2( + joinRef: message.joinRef, + ref: message.ref, + topic: "phoenix", + event: "phx_reply", + payload: [ + "response": [:], + "status": "ok", + ] + ) + } + + return nil + } + + await connectSocketAndWait() + + await fulfillment(of: [expectation], timeout: 3) + } + + func testHeartbeat_whenNoResponse_shouldReconnect() async throws { + let sentHeartbeatExpectation = expectation(description: "sentHeartbeat") + + ws.on { + if $0.event == "heartbeat" { + sentHeartbeatExpectation.fulfill() + } + + return nil + } + + let statuses = LockIsolated<[RealtimeClientV2.Status]>([]) + + Task { + for await status in await sut.statusChange { + statuses.withValue { + $0.append(status) + } + } + } + await Task.megaYield() + await connectSocketAndWait() + + await fulfillment(of: [sentHeartbeatExpectation], timeout: 2) + + let pendingHeartbeatRef = await sut.pendingHeartbeatRef + XCTAssertNotNil(pendingHeartbeatRef) + + // Wait until next heartbeat + try await Task.sleep(nanoseconds: NSEC_PER_SEC * 2) + + // Wait for reconnect delay + try await Task.sleep(nanoseconds: NSEC_PER_SEC * 1) + + XCTAssertEqual( + statuses.value, + [ + .disconnected, + .connecting, + .connected, + .disconnected, + .connecting, + ] + ) + } + + private func connectSocketAndWait() async { + let connection = Task { + await sut.connect() + } + await Task.megaYield() + + ws.mockConnect(.connected) + await connection.value } } @@ -80,7 +161,7 @@ extension RealtimeMessageV2 { topic: "realtime:public:messages", event: "phx_join", payload: [ - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzA1Nzc4MTAxLCJpYXQiOjE3MDU3NzQ1MDEsImlzcyI6Imh0dHA6Ly8xMjcuMC4wLjE6NTQzMjEvYXV0aC92MSIsInN1YiI6ImFiZTQ1NjMwLTM0YTAtNDBhNS04Zjg5LTQxY2NkYzJjNjQyNCIsImVtYWlsIjoib2dyc291emErbWFjQGdtYWlsLmNvbSIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZW1haWwiLCJwcm92aWRlcnMiOlsiZW1haWwiXX0sInVzZXJfbWV0YWRhdGEiOnt9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6Im1hZ2ljbGluayIsInRpbWVzdGFtcCI6MTcwNTYwODcxOX1dLCJzZXNzaW9uX2lkIjoiMzFmMmQ4NGQtODZmYi00NWE2LTljMTItODMyYzkwYTgyODJjIn0.RY1y5U7CK97v6buOgJj_jQNDHW_1o0THbNP2UQM1HVE", + "access_token": "anon.api.key", "config": [ "broadcast": [ "self": false, diff --git a/Tests/RealtimeTests/StreamManagerTests.swift b/Tests/RealtimeTests/StreamManagerTests.swift deleted file mode 100644 index 96c104ae..00000000 --- a/Tests/RealtimeTests/StreamManagerTests.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// StreamManagerTests.swift -// -// -// Created by Guilherme Souza on 18/01/24. -// - -import Foundation -@testable import Realtime -import XCTest - -final class StreamManagerTests: XCTestCase { - func testYieldInitialValue() async { - let manager = SharedStream(initialElement: 0) - - let value = await manager.makeStream().first(where: { _ in true }) - XCTAssertEqual(value, 0) - } -} diff --git a/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved b/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0f8793bb..77553e5d 100644 --- a/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/openid/AppAuth-iOS.git", "state" : { - "revision" : "71cde449f13d453227e687458144bde372d30fc7", - "version" : "1.6.2" + "revision" : "269c90515328c24f90b2ed17a67c8a796b485448", + "version" : "1.7.2" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "76d7791b5bda47df7e3d4690c4c3aaf089730707", - "version" : "1.2.1" + "revision" : "e593aba2c6222daad7c4f2732a431eed2c09bb07", + "version" : "1.3.0" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", - "version" : "1.0.6" + "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", + "version" : "1.1.0" } }, { @@ -86,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "60f13f60c4d093691934dc6cfdf5f508ada1f894", - "version" : "2.6.0" + "revision" : "f0525da24dc3c6cbb2b6b338b65042bc91cbc4bb", + "version" : "3.3.0" } }, { @@ -95,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "aedcf6f4cd486ccef5b312ccac85d4b3f6e58605", - "version" : "1.1.2" + "revision" : "3ce83179e5f0c83ad54c305779c6b438e82aaf1d", + "version" : "1.2.1" } }, { @@ -113,17 +113,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "8e68404f641300bfd0e37d478683bb275926760c", - "version" : "1.15.2" + "revision" : "5b0c434778f2c1a4c9b5ebdb8682b28e84dd69bd", + "version" : "1.15.4" } }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/apple/swift-syntax", "state" : { - "revision" : "43c802fb7f96e090dde015344a94b5e85779eff1", - "version" : "509.1.0" + "revision" : "fa8f95c2d536d6620cc2f504ebe8a6167c9fc2dd", + "version" : "510.0.1" } }, { @@ -131,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation.git", "state" : { - "revision" : "78f9d72cf667adb47e2040aa373185c88c63f0dc", - "version" : "1.2.0" + "revision" : "d9e72f3083c08375794afa216fb2f89c0114f303", + "version" : "1.2.1" } }, { @@ -140,8 +140,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", - "version" : "1.0.2" + "revision" : "b13b1d1a8e787a5ffc71ac19dcaf52183ab27ba2", + "version" : "1.1.1" } } ],