From f2a4c205d11e1dc5538241c76d9b2e750743b6cd Mon Sep 17 00:00:00 2001 From: Brianna Zamora Date: Wed, 18 Sep 2024 22:33:31 -0700 Subject: [PATCH] fix: auth record encoding Also: * update to remote EventSource package * Add HasLogger --- .swiftpm/configuration/Package.resolved | 11 +- Package.resolved | 11 +- Package.swift | 26 +- .../PocketBaseDemo.xcodeproj/project.pbxproj | 2 + .../xcshareddata/swiftpm/Package.resolved | 9 + .../PocketBaseDemo/ContentView.swift | 85 ++--- PocketBaseDemo/PocketBaseDemo/Models.swift | 15 +- .../PocketBaseDemo.entitlements | 6 + PocketBaseDemo/PocketBaseDemo/RawrView.swift | 61 ++++ Sources/DataBase/PocketBase+SwiftData.swift | 44 +-- Sources/PocketBase/Auth/AuthRecord.swift | 2 +- Sources/PocketBase/Auth/AuthStore.swift | 11 +- .../Collections/RecordCollection+Create.swift | 4 +- .../Collections/RecordCollection+List.swift | 2 +- Sources/PocketBase/PocketBase.swift | 8 +- .../Realtime/EventSource/EventParser.swift | 106 ------- .../Realtime/EventSource/EventSource.swift | 299 ------------------ .../Realtime/EventSource/Types.swift | 111 ------- .../Realtime/EventSource/UTF8LineParser.swift | 70 ---- Sources/PocketBase/Realtime/Realtime.swift | 5 +- ...RecordCollection+RequestSubscription.swift | 1 - Sources/PocketBaseMacros/Filter.swift | 2 +- .../RecordCollectionMacro.swift | 34 +- Sources/PocketBaseMacros/Relation.swift | 1 + Sources/PocketBaseMacros/RelationType.swift | 1 + .../PocketBaseUI/Auth/Authentication.swift | 11 - .../Auth/AuthenticationModifier.swift | 9 - Sources/PocketBaseUI/Auth/LoginButton.swift | 8 +- Sources/PocketBaseUI/Auth/SignUpButton.swift | 51 +-- .../{LoginView.swift => SignedOutView.swift} | 4 +- .../Collections/RealtimeQuery.swift | 18 +- .../Collections/StaticQuery.swift | 15 +- .../PocketBaseUI/PocketBase+Environment.swift | 13 - .../IntegrationTests.swift | 2 +- .../PocketBaseMacrosTests.swift | 7 +- pb_data/data.db | Bin 147456 -> 147456 bytes pb_data/logs.db | Bin 548864 -> 548864 bytes 37 files changed, 255 insertions(+), 810 deletions(-) create mode 100644 PocketBaseDemo/PocketBaseDemo/RawrView.swift delete mode 100644 Sources/PocketBase/Realtime/EventSource/EventParser.swift delete mode 100644 Sources/PocketBase/Realtime/EventSource/EventSource.swift delete mode 100644 Sources/PocketBase/Realtime/EventSource/Types.swift delete mode 100644 Sources/PocketBase/Realtime/EventSource/UTF8LineParser.swift rename Sources/PocketBaseUI/Auth/{LoginView.swift => SignedOutView.swift} (97%) diff --git a/.swiftpm/configuration/Package.resolved b/.swiftpm/configuration/Package.resolved index f8e3b88..020c5ca 100644 --- a/.swiftpm/configuration/Package.resolved +++ b/.swiftpm/configuration/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "357e26f937dbe05b0cb89785de035d46ab83da449ac48eb5f488dd1a9f6de072", + "originHash" : "230c615f1f87047f3e8f427da26a1cc18e86b46756118b65cfa0c3e9192698d8", "pins" : [ + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/briannadoubt/EventSource.git", + "state" : { + "revision" : "67e4d952e895d848142e4b16af3b96ab1b2a09b7", + "version" : "0.1.0" + } + }, { "identity" : "keychainaccess", "kind" : "remoteSourceControl", diff --git a/Package.resolved b/Package.resolved index e1355bf..00acbbf 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "357e26f937dbe05b0cb89785de035d46ab83da449ac48eb5f488dd1a9f6de072", + "originHash" : "230c615f1f87047f3e8f427da26a1cc18e86b46756118b65cfa0c3e9192698d8", "pins" : [ + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/briannadoubt/EventSource.git", + "state" : { + "revision" : "67e4d952e895d848142e4b16af3b96ab1b2a09b7", + "version" : "0.1.0" + } + }, { "identity" : "keychainaccess", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 69e6596..fed1988 100644 --- a/Package.swift +++ b/Package.swift @@ -6,12 +6,12 @@ import CompilerPluginSupport let package = Package( name: "PocketBase", platforms: [ - .macOS(.v15), - .iOS(.v18), - .tvOS(.v18), - .watchOS(.v11), - .macCatalyst(.v18), - .visionOS(.v2), + .macOS(.v14), + .iOS(.v17), + .tvOS(.v17), + .watchOS(.v10), + .macCatalyst(.v17), + .visionOS(.v1), ], products: [ .library( @@ -22,12 +22,17 @@ let package = Package( name: "PocketBaseUI", targets: ["PocketBaseUI"] ), - .library( - name: "DataBase", - targets: ["DataBase"] - ), + // MARK: WIP +// .library( +// name: "DataBase", +// targets: ["DataBase"] +// ), ], dependencies: [ + .package( + url: "https://github.com/briannadoubt/EventSource.git", + .upToNextMinor(from: "0.1.0") + ), .package( url: "https://github.com/apple/swift-http-types.git", .upToNextMajor(from: "1.0.0") @@ -58,6 +63,7 @@ let package = Package( name: "PocketBase", dependencies: [ "PocketBaseMacros", + .product(name: "EventSource", package: "EventSource"), .product(name: "KeychainAccess", package: "KeychainAccess"), .product(name: "HTTPTypes", package: "swift-http-types"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), diff --git a/PocketBaseDemo/PocketBaseDemo.xcodeproj/project.pbxproj b/PocketBaseDemo/PocketBaseDemo.xcodeproj/project.pbxproj index 02b7f1c..fc0f9b7 100644 --- a/PocketBaseDemo/PocketBaseDemo.xcodeproj/project.pbxproj +++ b/PocketBaseDemo/PocketBaseDemo.xcodeproj/project.pbxproj @@ -436,6 +436,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2,7"; @@ -477,6 +478,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2,7"; diff --git a/PocketBaseDemo/PocketBaseDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/PocketBaseDemo/PocketBaseDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 633be0b..7d4e21a 100644 --- a/PocketBaseDemo/PocketBaseDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/PocketBaseDemo/PocketBaseDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { "originHash" : "418bba4f88620fdb3be0f429a8fc5e1de6e4a1fd499808b8a7746587a081de92", "pins" : [ + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/briannadoubt/EventSource.git", + "state" : { + "revision" : "67e4d952e895d848142e4b16af3b96ab1b2a09b7", + "version" : "0.1.0" + } + }, { "identity" : "keychainaccess", "kind" : "remoteSourceControl", diff --git a/PocketBaseDemo/PocketBaseDemo/ContentView.swift b/PocketBaseDemo/PocketBaseDemo/ContentView.swift index 81c0d55..7340b06 100644 --- a/PocketBaseDemo/PocketBaseDemo/ContentView.swift +++ b/PocketBaseDemo/PocketBaseDemo/ContentView.swift @@ -12,9 +12,7 @@ import os struct ContentView: View { @Environment(\.pocketbase) private var pocketbase - @RealtimeQuery( - sort: [.init(\.field)] - ) private var rawrs + @RealtimeQuery(sort: [.init(\.field)]) private var rawrs static let logger = Logger( subsystem: "PocketBaseDemo", @@ -34,18 +32,8 @@ struct ContentView: View { } .navigationTitle("Rawrs") .toolbar { - Button("Logout", role: .destructive) { - pocketbase.collection(User.self).logout() - } - Button("New", systemImage: "plus") { - Task { - do { - try await pocketbase.collection(Rawr.self).create(Rawr(field: "", owner: "")) - } catch { - Self.logger.error("Failed to create record with error \(error)") - } - } - } + Button("Logout", role: .destructive, action: logout) + Button("New", systemImage: "plus", action: new) } } .task { @@ -53,66 +41,31 @@ struct ContentView: View { } } - func delete(_ index: IndexSet) { - let rawrs = index.map { self.rawrs[$0] } + func logout() { Task { - for rawr in rawrs { - do { - try await pocketbase.collection(Rawr.self).delete(rawr) - } catch { - Self.logger.error("Failed to deleted record with error \(error)") - } - } + await pocketbase.collection(User.self).logout() } } -} - -struct RawrView: View { - @Environment(\.pocketbase) private var pocketbase - - private let rawr: Rawr - - @State private var isPresentingEditAlert: Bool = false - @State private var editText: String = "" - - static let logger = Logger( - subsystem: "PocketBaseDemo", - category: "ContentView" - ) - - init(rawr: Rawr) { - self.rawr = rawr - } - var body: some View { - Button(rawr.field) { - editText = rawr.field - isPresentingEditAlert = true - } - .foregroundStyle(.primary) - .alert("Update Rawr", isPresented: $isPresentingEditAlert) { - TextField("Update Rawr", text: $editText) - .onSubmit { - save() - } - Button("Cancel", role: .cancel) { - isPresentingEditAlert = false - } - Button("Save") { - save() + func new() { + Task { + do { + try await pocketbase.collection(Rawr.self).create(Rawr(field: "")) + } catch { + Self.logger.error("Failed to create record with error \(error)") } } } - private func save() { - var rawr = self.rawr - rawr.field = editText + func delete(_ index: IndexSet) { + let rawrs = index.map { self.rawrs[$0] } Task { - do { - try await pocketbase.collection(Rawr.self).update(rawr) - isPresentingEditAlert = false - } catch { - Self.logger.error("Failed to update rawr with error: \(error)") + for rawr in rawrs { + do { + try await pocketbase.collection(Rawr.self).delete(rawr) + } catch { + Self.logger.error("Failed to deleted record with error \(error)") + } } } } diff --git a/PocketBaseDemo/PocketBaseDemo/Models.swift b/PocketBaseDemo/PocketBaseDemo/Models.swift index bdc0f70..9af677e 100644 --- a/PocketBaseDemo/PocketBaseDemo/Models.swift +++ b/PocketBaseDemo/PocketBaseDemo/Models.swift @@ -8,22 +8,9 @@ import PocketBase @AuthCollection("users") -struct User { - init(username: String, email: String?) { - self.username = username - self.email = email - } -} - -@BaseCollection("posts") -struct Post { - var title: String - var body: String - @Relation var author: User? -} +struct User {} @BaseCollection("rawrs") struct Rawr { var field: String - @Relation var owner: User? } diff --git a/PocketBaseDemo/PocketBaseDemo/PocketBaseDemo.entitlements b/PocketBaseDemo/PocketBaseDemo/PocketBaseDemo.entitlements index 625af03..c1bcf60 100644 --- a/PocketBaseDemo/PocketBaseDemo/PocketBaseDemo.entitlements +++ b/PocketBaseDemo/PocketBaseDemo/PocketBaseDemo.entitlements @@ -2,11 +2,17 @@ + com.apple.developer.icloud-container-identifiers + com.apple.security.app-sandbox com.apple.security.files.user-selected.read-only com.apple.security.network.client + keychain-access-groups + + $(AppIdentifierPrefix)com.briannadoubt.PocketBaseDemo + diff --git a/PocketBaseDemo/PocketBaseDemo/RawrView.swift b/PocketBaseDemo/PocketBaseDemo/RawrView.swift new file mode 100644 index 0000000..9090e6c --- /dev/null +++ b/PocketBaseDemo/PocketBaseDemo/RawrView.swift @@ -0,0 +1,61 @@ +// +// RawrView.swift +// PocketBaseDemo +// +// Created by Brianna Zamora on 9/18/24. +// + +import PocketBase +import SwiftUI +import os + +struct RawrView: View { + @Environment(\.pocketbase) private var pocketbase + + private let rawr: Rawr + + @State private var isPresentingEditAlert: Bool = false + @State private var editText: String = "" + + static let logger = Logger( + subsystem: "PocketBaseDemo", + category: "RawrView" + ) + + init(rawr: Rawr) { + self.rawr = rawr + } + + var body: some View { + Button(rawr.field) { + editText = rawr.field + isPresentingEditAlert = true + } + .foregroundStyle(.primary) + .alert("Update Rawr", isPresented: $isPresentingEditAlert) { + TextField("Update Rawr", text: $editText) + .onSubmit { + save() + } + Button("Cancel", role: .cancel) { + isPresentingEditAlert = false + } + Button("Save") { + save() + } + } + } + + private func save() { + var rawr = self.rawr + rawr.field = editText + Task { + do { + try await pocketbase.collection(Rawr.self).update(rawr) + isPresentingEditAlert = false + } catch { + Self.logger.error("Failed to update rawr with error: \(error)") + } + } + } +} diff --git a/Sources/DataBase/PocketBase+SwiftData.swift b/Sources/DataBase/PocketBase+SwiftData.swift index 5e45899..58f7d52 100644 --- a/Sources/DataBase/PocketBase+SwiftData.swift +++ b/Sources/DataBase/PocketBase+SwiftData.swift @@ -9,7 +9,7 @@ import Foundation import SwiftData import PocketBase -extension PocketBase: @retroactive HasLogger { +extension PocketBase { /// Inspect the latest changes of the given types in a correlated `ModelContainer` instance, and sync those changes with a remote PocketBase instance through atomic network requests. /// /// This is an experimental idea to sync records from SwiftData to a remote PocketBase instance. This would enable atomic, lightning-fast lookups, and realtime updates. The idea was inspired by the official JavaScript and Dart clients, as they have client-side caching. @@ -46,6 +46,7 @@ extension PocketBase: @retroactive HasLogger { /// - Parameters: /// - modelContainer: The `ModelContainer` used to sync the most recent history to a remote PocketBase instance. /// - type: A `repeat`ing type that is iterated over and synced, type-by-type. + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, macCatalyst 18.0, visionOS 2.0, *) public func sync( to modelContainer: ModelContainer, with type: repeat (each T).Type @@ -55,38 +56,38 @@ extension PocketBase: @retroactive HasLogger { for unsynced in unsynced { for (record, operation) in repeat each unsynced { group.addTask { - let pocketbase = PocketBase() +// let pocketbase = PocketBase() switch operation { case .create: func create(_ record: R) async { - let collection = pocketbase.collection(R.self) - do { - try await collection.create(record) - } catch { - Self.logger.error("Failed to create \(R.self): \(error)") - } +// let collection = pocketbase.collection(R.self) +// do { +// try await collection.create(record) +// } catch { +// Self.logger.error("Failed to create \(R.self): \(error)") +// } } await create(record) case .update: func update(_ record: R) async { - let collection = pocketbase.collection(R.self) - do { - try await collection.update(record) - } catch { - Self.logger.error("Failed to update \(R.self): \(error)") - } +// let collection = pocketbase.collection(R.self) +// do { +// try await collection.update(record) +// } catch { +// Self.logger.error("Failed to update \(R.self): \(error)") +// } } await update(record) case .delete: func delete(_ record: R) async { - let collection = pocketbase.collection(R.self) - do { - try await collection.delete(record) - } catch { - Self.logger.error("Failed to delete \(R.self): \(error)") - } +// let collection = pocketbase.collection(R.self) +// do { +// try await collection.delete(record) +// } catch { +// Self.logger.error("Failed to delete \(R.self): \(error)") +// } } await delete(record) @@ -104,6 +105,7 @@ extension PocketBase: @retroactive HasLogger { case delete } + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, macCatalyst 18.0, visionOS 2.0, *) func findUnsynced( in modelContainer: ModelContainer ) throws -> [(repeat ((each T), Operation))] { @@ -123,6 +125,7 @@ extension PocketBase: @retroactive HasLogger { static let lastHistoryToken: String = "io.pocketbase.lastHistoryToken" + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, macCatalyst 18.0, visionOS 2.0, *) func findTransactions( in modelContainer: ModelContainer, after token: DefaultHistoryToken?, @@ -140,6 +143,7 @@ extension PocketBase: @retroactive HasLogger { return transactions } + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, macCatalyst 18.0, visionOS 2.0, *) func findOperations( in modelContainer: ModelContainer, with type: repeat (each T).Type, diff --git a/Sources/PocketBase/Auth/AuthRecord.swift b/Sources/PocketBase/Auth/AuthRecord.swift index 757f7d3..5559312 100644 --- a/Sources/PocketBase/Auth/AuthRecord.swift +++ b/Sources/PocketBase/Auth/AuthRecord.swift @@ -5,7 +5,7 @@ // Created by Brianna Zamora on 8/5/24. // -public protocol AuthRecord: Record { +public protocol AuthRecord: Record where EncodingConfiguration == RecordCollectionEncodingConfiguration { var username: String { get } var email: String? { get } var verified: Bool { get } diff --git a/Sources/PocketBase/Auth/AuthStore.swift b/Sources/PocketBase/Auth/AuthStore.swift index e976d2d..4eefcbf 100644 --- a/Sources/PocketBase/Auth/AuthStore.swift +++ b/Sources/PocketBase/Auth/AuthStore.swift @@ -6,22 +6,24 @@ // import Foundation -internal import KeychainAccess +@preconcurrency internal import KeychainAccess public struct AuthStore: Sendable { public init() {} + let keychain = Keychain(service: "io.pocketbase.auth") + public var isValid: Bool { token != nil } public var token: String? { - Keychain(service: "io.pocketbase.auth")["token"] + keychain["token"] } func set(token: String) { - Keychain(service: "io.pocketbase.auth")["token"] = token + keychain["token"] = token } public func record() throws -> T? { @@ -33,7 +35,6 @@ public struct AuthStore: Sendable { } func set(_ response: AuthResponse) throws { - // Don't use the internal PocketBase encoder becuase this will skip keys that are intended to be set by the server. let data = try JSONEncoder().encode(response, configuration: .cache) UserDefaults.pocketbase?.setValue(data, forKey: "record") } @@ -44,7 +45,7 @@ public struct AuthStore: Sendable { } public func clear() { - Keychain(service: "io.pocketbase.auth")["token"] = nil + keychain["token"] = nil UserDefaults.pocketbase?.removeObject(forKey: "record") } } diff --git a/Sources/PocketBase/Collections/RecordCollection+Create.swift b/Sources/PocketBase/Collections/RecordCollection+Create.swift index d935024..9fc74af 100644 --- a/Sources/PocketBase/Collections/RecordCollection+Create.swift +++ b/Sources/PocketBase/Collections/RecordCollection+Create.swift @@ -7,7 +7,7 @@ import Foundation -public extension RecordCollection { +public extension RecordCollection where T.EncodingConfiguration == RecordCollectionEncodingConfiguration { /// Creates a new collection Record. /// /// Depending on the collection's createRule value, the access to this action may or may not have been restricted. @@ -96,7 +96,7 @@ public extension RecordCollection where T: AuthRecord { } } -extension AuthRecord { +extension AuthRecord where EncodingConfiguration == RecordCollectionEncodingConfiguration { func createBody( password: String, passwordConfirm: String, diff --git a/Sources/PocketBase/Collections/RecordCollection+List.swift b/Sources/PocketBase/Collections/RecordCollection+List.swift index 7d6c3a4..17b7f27 100644 --- a/Sources/PocketBase/Collections/RecordCollection+List.swift +++ b/Sources/PocketBase/Collections/RecordCollection+List.swift @@ -8,7 +8,7 @@ import Foundation import Collections -public extension RecordCollection { +public extension RecordCollection where T.EncodingConfiguration == RecordCollectionEncodingConfiguration { /// Returns a paginated records list, supporting sorting and filtering. /// /// Depending on the collection's listRule value, the access to this action may or may not have been restricted. diff --git a/Sources/PocketBase/PocketBase.swift b/Sources/PocketBase/PocketBase.swift index 48510c8..3cdecec 100644 --- a/Sources/PocketBase/PocketBase.swift +++ b/Sources/PocketBase/PocketBase.swift @@ -8,7 +8,7 @@ @_exported import Foundation /// Interface with PocketBase -public struct PocketBase: Sendable { +public struct PocketBase: Sendable, HasLogger { public let url: URL public let authStore = AuthStore() @@ -17,6 +17,7 @@ public struct PocketBase: Sendable { let session: NetworkSession public init(url: URL, session: NetworkSession = URLSession.shared) { + Self.logger.trace(#function) self.url = url self.realtime = Realtime(baseUrl: url) self.session = session @@ -31,11 +32,13 @@ public struct PocketBase: Sendable { return url }() ) { + Self.logger.trace(#function) self.init(url: url) } public func collection(_ type: T.Type) -> RecordCollection { - RecordCollection(T.collection, self) + Self.logger.trace(#function) + return RecordCollection(T.collection, self) } } @@ -43,6 +46,7 @@ extension PocketBase { public static let localhost = PocketBase(url: URL.localhost) public static func set(url: URL) { + Self.logger.trace(#function) UserDefaults.pocketbase?.set(url, forKey: Self.urlKey) } diff --git a/Sources/PocketBase/Realtime/EventSource/EventParser.swift b/Sources/PocketBase/Realtime/EventSource/EventParser.swift deleted file mode 100644 index be14458..0000000 --- a/Sources/PocketBase/Realtime/EventSource/EventParser.swift +++ /dev/null @@ -1,106 +0,0 @@ -import Foundation - -actor EventParser { - private struct Constants { - static let dataLabel: Substring = "data" - static let idLabel: Substring = "id" - static let eventLabel: Substring = "event" - static let retryLabel: Substring = "retry" - } - - private let handler: EventHandler - - private var data: String = "" - private var eventType: String = "" - private var lastEventIdBuffer: String? - private var lastEventId: String - private var currentRetry: TimeInterval - - init(handler: EventHandler, initialEventId: String, initialRetry: TimeInterval) { - self.handler = handler - self.lastEventId = initialEventId - self.currentRetry = initialRetry - } - - func parse(line: String) async { - let splitByColon = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) - - switch (splitByColon[0], splitByColon[safe: 1]) { - case ("", nil): // Empty line - await dispatchEvent() - case let ("", .some(comment)): // Line starting with ':' is a comment - await handler.onComment(comment: String(comment)) - case let (field, data): - processField(field: field, value: dropLeadingSpace(str: data ?? "")) - } - } - - func getLastEventId() -> String { lastEventId } - - func reset() -> TimeInterval { - data = "" - eventType = "" - lastEventIdBuffer = nil - return currentRetry - } - - private func dropLeadingSpace(str: Substring) -> Substring { - if str.first == " " { - return str[str.index(after: str.startIndex)...] - } - return str - } - - private func processField(field: Substring, value: Substring) { - switch field { - case Constants.dataLabel: - data.append(contentsOf: value) - data.append(contentsOf: "\n") - case Constants.idLabel: - // See https://github.com/whatwg/html/issues/689 for reasoning on not setting lastEventId if the value - // contains a null code point. - if !value.contains("\u{0000}") { - lastEventIdBuffer = String(value) - } - case Constants.eventLabel: - eventType = String(value) - case Constants.retryLabel: - if - value.allSatisfy({ character in - ("0"..."9").contains(character) - }), - let reconnectionTime = Int64(value) - { - currentRetry = Double(reconnectionTime) * 0.001 - } - default: - break - } - } - - private func dispatchEvent() async { - lastEventId = lastEventIdBuffer ?? lastEventId - lastEventIdBuffer = nil - guard !data.isEmpty - else { - eventType = "" - return - } - // remove the last LF - _ = data.popLast() - let messageEvent = MessageEvent(data: data, lastEventId: lastEventId) - await handler.onMessage( - eventType: eventType.isEmpty ? "message" : eventType, - messageEvent: messageEvent - ) - data = "" - eventType = "" - } -} - -private extension Array { - /// Returns the element at the specified index if it is within bounds, otherwise nil. - subscript (safe index: Index) -> Element? { - index >= startIndex && index < endIndex ? self[index] : nil - } -} diff --git a/Sources/PocketBase/Realtime/EventSource/EventSource.swift b/Sources/PocketBase/Realtime/EventSource/EventSource.swift deleted file mode 100644 index f66e158..0000000 --- a/Sources/PocketBase/Realtime/EventSource/EventSource.swift +++ /dev/null @@ -1,299 +0,0 @@ -import Foundation - -actor EventSource: NSObject, HasLogger { - let config: Config - - private(set) var readyState: ReadyState = .raw - func set(readyState: ReadyState) { - self.readyState = readyState - } - private var urlSession: URLSession? - let utf8LineParser: UTF8LineParser = UTF8LineParser() - let eventParser: EventParser - let reconnectionTimer: ReconnectionTimer - private var sessionTask: URLSessionDataTask? - - private var delegate: EventSourceDelegate? - - func createSession() -> URLSession { - URLSession( - configuration: config.urlSessionConfiguration, - delegate: delegate, - delegateQueue: nil - ) - } - - public init(config: Config) { - self.config = config - self.eventParser = EventParser( - handler: config.handler, - initialEventId: config.lastEventId, - initialRetry: config.reconnectTime - ) - self.reconnectionTimer = ReconnectionTimer( - maxDelay: config.maxReconnectTime, - resetInterval: config.backoffResetThreshold - ) - super.init() - self.delegate = EventSourceDelegate(eventSource: self) - } - - public func start() async { - guard self.readyState == .raw else { - Self.logger.info("start() called on already-started EventSource object. Returning") - return - } - self.readyState = .connecting - self.urlSession = self.createSession() - await self.connect() - } - - public func stop() async { - let previousState = self.readyState - self.readyState = .shutdown - self.sessionTask?.cancel() - if previousState == .open { - await self.config.handler.onClosed() - } - self.urlSession?.invalidateAndCancel() - self.urlSession = nil - } - - func connect() async { - Self.logger.info("Starting EventSource client") - let request = await createRequest() - let task = urlSession?.dataTask(with: request) - task?.resume() - sessionTask = task - } - - public func getLastEventId() async -> String? { - await eventParser.getLastEventId() - } - - func createRequest() async -> URLRequest { - var urlRequest = URLRequest( - url: self.config.url, - cachePolicy: URLRequest.CachePolicy.reloadIgnoringLocalAndRemoteCacheData, - timeoutInterval: self.config.idleTimeout - ) - urlRequest.httpMethod = self.config.method - urlRequest.httpBody = self.config.body - if let lastEventId = await self.getLastEventId(), !lastEventId.isEmpty { - urlRequest.setValue(lastEventId, forHTTPHeaderField: "Last-Event-Id") - } - urlRequest.allHTTPHeaderFields = self.config.headerTransform( - urlRequest.allHTTPHeaderFields?.merging(self.config.headers) { $1 } ?? self.config.headers - ) - return urlRequest - } - - func dispatchError(error: Error) async -> ConnectionErrorAction { - let action: ConnectionErrorAction = config.connectionErrorHandler(error) - if action != .shutdown { - await config.handler.onError(error: error) - } - return action - } - - /// Struct for configuring the EventSource. - public struct Config: Sendable { - /// The `EventHandler` called in response to activity on the stream. - public let handler: EventHandler - /// The `URL` of the request used when connecting to the EventSource API. - public let url: URL - - /// The HTTP method to use for the API request. - public var method: String = "GET" - /// Optional HTTP body to be included in the API request. - public var body: Data? - /// Additional HTTP headers to be set on the request - public var headers: [String: String] = [:] - /// Transform function to allow dynamically configuring the headers on each API request. - public var headerTransform: HeaderTransform = { $0 } - /// An initial value for the last-event-id header to be sent on the initial request - public var lastEventId: String = "" - - /// The minimum amount of time to wait before reconnecting after a failure - public var reconnectTime: TimeInterval = 1.0 - /// The maximum amount of time to wait before reconnecting after a failure - public var maxReconnectTime: TimeInterval = 30.0 - /// The minimum amount of time for an `EventSource` connection to remain open before allowing the connection - /// backoff to reset. - public var backoffResetThreshold: TimeInterval = 60.0 - /// The maximum amount of time between receiving any data before considering the connection to have timed out. - public var idleTimeout: TimeInterval = 300.0 - - private var _urlSessionConfiguration: URLSessionConfiguration = URLSessionConfiguration.default - /** - The `URLSessionConfiguration` used to create the `URLSession`. - - - Important: - Note that this copies the given `URLSessionConfiguration` when set, and returns copies (updated with any - overrides specified by other configuration options) when the value is retrieved. This prevents updating the - `URLSessionConfiguration` after initializing `EventSource` with the `Config`, and prevents the `EventSource` - from updating any properties of the given `URLSessionConfiguration`. - - - Since: 1.3.0 - */ - public var urlSessionConfiguration: URLSessionConfiguration { - get { - // swiftlint:disable:next force_cast - let sessionConfig = _urlSessionConfiguration.copy() as! URLSessionConfiguration - sessionConfig.httpAdditionalHeaders = ["Accept": "text/event-stream", "Cache-Control": "no-cache"] - sessionConfig.timeoutIntervalForRequest = idleTimeout - - #if !os(Linux) && !os(Windows) - if #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) { - sessionConfig.tlsMinimumSupportedProtocolVersion = .TLSv12 - } else { - sessionConfig.tlsMinimumSupportedProtocol = .tlsProtocol12 - } - #endif - return sessionConfig - } - set { - // swiftlint:disable:next force_cast - _urlSessionConfiguration = newValue.copy() as! URLSessionConfiguration - } - } - - /** - An error handler that is called when an error occurs and can shut down the client in response. - - The default error handler will always attempt to reconnect on an - error, unless `EventSource.stop()` is called or the error code is 204. - */ - public var connectionErrorHandler: ConnectionErrorHandler = { error in - guard let unsuccessfulResponseError = error as? UnsuccessfulResponseError - else { return .proceed } - - let responseCode: Int = unsuccessfulResponseError.responseCode - if 204 == responseCode { - return .shutdown - } - return .proceed - } - - /// Create a new configuration with an `EventHandler` and a `URL` - public init(handler: EventHandler, url: URL, lastEventId: String?) { - self.handler = handler - self.url = url - self.lastEventId = lastEventId ?? "" - } - } -} - -actor ReconnectionTimer { - private let maxDelay: TimeInterval - private let resetInterval: TimeInterval - - var backoffCount: Int = 0 - var connectedTime: Date? - func set(connectedTime: Date?) { - self.connectedTime = connectedTime - } - - init(maxDelay: TimeInterval, resetInterval: TimeInterval) { - self.maxDelay = maxDelay - self.resetInterval = resetInterval - } - - func reconnectDelay(baseDelay: TimeInterval) -> TimeInterval { - backoffCount += 1 - if let connectedTime = connectedTime, Date().timeIntervalSince(connectedTime) >= resetInterval { - backoffCount = 0 - } - self.connectedTime = nil - let maxSleep = min(maxDelay, baseDelay * pow(2.0, Double(backoffCount))) - return maxSleep / 2 + Double.random(in: 0...(maxSleep / 2)) - } -} - -final class EventSourceDelegate: NSObject, URLSessionDataDelegate, HasLogger { - let eventSource: EventSource - - init(eventSource: EventSource) { - self.eventSource = eventSource - } - - // MARK: URLSession Delegates - - // Tells the delegate that the task finished transferring data. - public func urlSession( - _ session: URLSession, - task: URLSessionTask, - didCompleteWithError error: Error? - ) { - Task { - await eventSource.utf8LineParser.closeAndReset() - let currentRetry = await eventSource.eventParser.reset() - - guard await eventSource.readyState != .shutdown else { return } - - if let error = error { - if (error as NSError).code != NSURLErrorCancelled { - Self.logger.info("Connection error: \(error)") - if await eventSource.dispatchError(error: error) == .shutdown { - Self.logger.info("Connection has been explicitly shut down by error handler") - if await eventSource.readyState == .open { - await eventSource.config.handler.onClosed() - } - await eventSource.set(readyState: .shutdown) - return - } - } - } else { - Self.logger.info("Connection unexpectedly closed.") - } - - if await eventSource.readyState == .open { - await eventSource.config.handler.onClosed() - } - - await eventSource.set(readyState: .closed) - let sleep = await eventSource.reconnectionTimer.reconnectDelay(baseDelay: currentRetry) - // this formatting shenanigans is to workaround String not implementing CVarArg on Swift<5.4 on Linux - Self.logger.log("Waiting \(String(format: "%.3f", sleep)) seconds before reconnecting...") - try? await Task.sleep(for: .seconds(sleep)) - await eventSource.connect() - } - } - - public func urlSession( - _ session: URLSession, - dataTask: URLSessionDataTask, - didReceive response: URLResponse, - completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void - ) { - Self.logger.debug("Initial reply received") - Task { - // swiftlint:disable:next force_cast - let httpResponse = response as! HTTPURLResponse - let statusCode = httpResponse.statusCode - if (200..<300).contains(statusCode) && statusCode != 204 { - await eventSource.reconnectionTimer.set(connectedTime: Date()) - await eventSource.set(readyState: .open) - await eventSource.config.handler.onOpened() - completionHandler(.allow) - } else { - Self.logger.info("Unsuccessful response: \(String(format: "%d", statusCode))") - let statusCode = statusCode - let dispatchError = await eventSource.dispatchError(error: UnsuccessfulResponseError(responseCode: statusCode)) - if dispatchError == .shutdown { - Self.logger.info("Connection has been explicitly shut down by error handler") - await eventSource.set(readyState: .shutdown) - } - completionHandler(.cancel) - } - } - } - - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - Task { - for line in await eventSource.utf8LineParser.append(data) { - await eventSource.eventParser.parse(line: line) - } - } - } -} diff --git a/Sources/PocketBase/Realtime/EventSource/Types.swift b/Sources/PocketBase/Realtime/EventSource/Types.swift deleted file mode 100644 index 9ab0fa8..0000000 --- a/Sources/PocketBase/Realtime/EventSource/Types.swift +++ /dev/null @@ -1,111 +0,0 @@ -import Foundation - -/** - Type for a function that will be notified when the `EventSource` client encounters a connection failure. - - This is different from `EventHandler.onError(error:)` in that it will not be called for other kinds of errors; also, - it has the ability to tell the client to stop reconnecting by returning a `ConnectionErrorAction.shutdown`. -*/ -public typealias ConnectionErrorHandler = @Sendable (Error) -> ConnectionErrorAction - -/** - Type for a function that will take in the current HTTP headers and return a new set of HTTP headers to be used when - connecting and reconnecting to a stream. - */ -public typealias HeaderTransform = @Sendable ([String: String]) -> [String: String] - -/// Potential actions a `ConnectionErrorHandler` can return -public enum ConnectionErrorAction: Sendable { - /** - Specifies that the error should be logged normally and dispatched to the `EventHandler`. Connection retrying will - proceed normally if appropriate. - */ - case proceed - /** - Specifies that the connection should be immediately shut down and not retried. The error will not be dispatched - to the `EventHandler` - */ - case shutdown -} - -/// Struct representing received event from the stream. -public struct MessageEvent: Equatable, Hashable, Codable { - /// The event data of the event. - public let data: String - /// The last seen event id, or the event id set in the Config if none have been received. - public let lastEventId: String - - /** - Constructor for a `MessageEvent` - - - Parameter data: The `data` field of the `MessageEvent`. - - Parameter eventType: The `lastEventId` field of the `MessageEvent`. - */ - public init(data: String, lastEventId: String = "") { - self.data = data - self.lastEventId = lastEventId - } -} - -/// Protocol for an object that will receive SSE events. -public protocol EventHandler: Actor { - /// EventSource calls this method when the stream connection has been opened. - func onOpened() - - /// EventSource calls this method when the stream connection has been closed. - func onClosed() - - /** - EventSource calls this method when it has received a new event from the stream. - - - Parameter eventType: The type of the event. - - Parameter messageEvent: The data for the event. - */ - func onMessage(eventType: String, messageEvent: MessageEvent) async - - /** - EventSource calls this method when it has received a comment line from the stream. - - - Parameter comment: The comment received. - */ - func onComment(comment: String) - - /** - This method will be called for all exceptions that occur on the network connection (including an - `UnsuccessfulResponseError` if the server returns an unexpected HTTP status), but only after the - ConnectionErrorHandler (if any) has processed it. If you need to do anything that affects the state of the - connection, use ConnectionErrorHandler. - - - Parameter error: The error received. - */ - func onError(error: Error) -} - -/// Enum values representing the states of an EventSource -public enum ReadyState: String, Equatable, Sendable { - /// The `EventSource` client has not been started yet. - case raw - /// The `EventSource` client is attempting to make a connection. - case connecting - /// The `EventSource` client is active and listening for events. - case open - /// The connection has been closed or has failed, and the `EventSource` will attempt to reconnect. - case closed - /// The connection has been permanently closed and the `EventSource` not reconnect. - case shutdown -} - -/// Error class that indicates the remote server returned an unsuccessful HTTP response code. -public final class UnsuccessfulResponseError: Error { - /// The HTTP response code received. - public let responseCode: Int - - /** - Constructor for an `UnsuccessfulResponseError`. - - - Parameter responseCode: The HTTP response code of the unsuccessful response. - */ - public init(responseCode: Int) { - self.responseCode = responseCode - } -} diff --git a/Sources/PocketBase/Realtime/EventSource/UTF8LineParser.swift b/Sources/PocketBase/Realtime/EventSource/UTF8LineParser.swift deleted file mode 100644 index e774b85..0000000 --- a/Sources/PocketBase/Realtime/EventSource/UTF8LineParser.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation - -struct DataIter: IteratorProtocol { - var data: Data - var position: Data.Index { data.startIndex } - - mutating func next() -> UInt8? { - data.popFirst() - } -} - -final actor UTF8LineParser { - private let lf = Unicode.Scalar(0x0A) - private let cr = Unicode.Scalar(0x0D) - private let replacement = String(Unicode.UTF8.decode(Unicode.UTF8.encodedReplacementCharacter)) - - var utf8Parser = Unicode.UTF8.ForwardParser() - var remainder: Data = Data() - var currentString: String = "" - var seenCr = false - - func append(_ body: Data) -> [String] { - let data = remainder + body - var dataIter = DataIter(data: data) - var remainderPos = data.endIndex - var lines: [String] = [] - - Decode: while true { - switch utf8Parser.parseScalar(from: &dataIter) { - case .valid(let scalarResult): - let scalar = Unicode.UTF8.decode(scalarResult) - - if seenCr && scalar == lf { - seenCr = false - continue - } - - seenCr = scalar == cr - if scalar == cr || scalar == lf { - lines.append(currentString) - currentString = "" - } else { - currentString.append(String(scalar)) - } - case .emptyInput: - break Decode - case .error(let len): - seenCr = false - if dataIter.position == data.endIndex { - // Error at end of block, carry over in case of split code point - remainderPos = data.index(data.endIndex, offsetBy: -len) - // May as well break here as next will be .emptyInput - break Decode - } else { - // Invalid character, replace with replacement character - currentString.append(replacement) - } - } - } - - remainder = data.subdata(in: remainderPos..( diff --git a/Sources/PocketBase/Realtime/RecordCollection+RequestSubscription.swift b/Sources/PocketBase/Realtime/RecordCollection+RequestSubscription.swift index 3ca7fc3..3e9334e 100644 --- a/Sources/PocketBase/Realtime/RecordCollection+RequestSubscription.swift +++ b/Sources/PocketBase/Realtime/RecordCollection+RequestSubscription.swift @@ -5,7 +5,6 @@ // Created by Brianna Zamora on 8/18/24. // - extension RecordCollection where T: BaseRecord { public func requestSubscription( for path: String, diff --git a/Sources/PocketBaseMacros/Filter.swift b/Sources/PocketBaseMacros/Filter.swift index ca12a79..4780d53 100644 --- a/Sources/PocketBaseMacros/Filter.swift +++ b/Sources/PocketBaseMacros/Filter.swift @@ -15,7 +15,7 @@ public struct Filter: ExpressionMacro { in context: some MacroExpansionContext ) throws -> ExprSyntax { guard - let operationClosure = node.arguments.first? + let operationClosure = node.argumentList.first? .expression .as(ClosureExprSyntax.self) else { diff --git a/Sources/PocketBaseMacros/RecordCollectionMacro.swift b/Sources/PocketBaseMacros/RecordCollectionMacro.swift index d771424..12190c2 100644 --- a/Sources/PocketBaseMacros/RecordCollectionMacro.swift +++ b/Sources/PocketBaseMacros/RecordCollectionMacro.swift @@ -7,6 +7,8 @@ import SwiftSyntax import SwiftSyntaxMacros +import SwiftSyntaxMacroExpansion +import SwiftSyntaxBuilder protocol RecordCollectionMacro: MemberMacro, ExtensionMacro {} @@ -81,7 +83,7 @@ extension RecordCollectionMacro { } } - static func collectionName(_ node: AttributeSyntax) throws -> TokenSyntax? { + static func collectionName(_ node: AttributeSyntax) throws -> TokenSyntax { guard let collectionName = node .arguments? .as(LabeledExprListSyntax.self)? @@ -157,12 +159,16 @@ extension RecordCollectionMacro { signature: FunctionSignatureSyntax( parameterClause: FunctionParameterClauseSyntax( parametersBuilder: { - memberwiseInitParameters(node, variables) + for parameter in memberwiseInitParameters(node, variables) { + parameter + } } ) ) ) { - memberwiseInitMembers(node, variables) + for member in memberwiseInitMembers(node, variables) { + member + } } } @@ -257,7 +263,11 @@ extension RecordCollectionMacro { static func relations(_ variables: [Variable]) throws -> VariableDeclSyntax { try VariableDeclSyntax("static var relations: [String: any Record.Type]") { DictionaryExprSyntax { - (try? relationsDictionaryElements(variables)) ?? [] + if let elements = try? relationsDictionaryElements(variables) { + for element in elements { + element + } + } } } } @@ -278,7 +288,7 @@ extension RecordCollectionMacro { } } - static func encodeToEncoderMembers(_ variables: [Variable]) -> [CodeBlockItemSyntax] { + static func encodeToEncoderMembers(_ node: AttributeSyntax, _ variables: [Variable]) -> [CodeBlockItemSyntax] { var members: [CodeBlockItemSyntax] = [] let keysToSkipEncoding: [String] = [ "id", @@ -287,6 +297,12 @@ extension RecordCollectionMacro { "created", "updated" ] + if isAuthCollection(node) { + members.append("try container.encode(username, forKey: .username)") + members.append("try container.encode(email, forKey: .email)") + members.append("try container.encode(verified, forKey: .verified)") + members.append("try container.encode(emailVisibility, forKey: .emailVisibility)") + } for variable in variables { if keysToSkipEncoding.contains(variable.name.text) { continue @@ -319,7 +335,9 @@ extension RecordCollectionMacro { } "// Declared fields" - encodeToEncoderMembers(variables) + for member in encodeToEncoderMembers(node, variables) { + member + } } } @@ -354,7 +372,9 @@ extension RecordCollectionMacro { static func expandStruct(_ variables: [Variable]) throws -> StructDeclSyntax { try StructDeclSyntax("struct Expand: Decodable, EncodableWithConfiguration") { - expandStructRelationMembers(variables) + for member in expandStructRelationMembers(variables) { + member + } try expandStructCodingKeys(variables) try expandInitFromDecoder(variables) try expandEncodeToEncoderWithConfiguration(variables) diff --git a/Sources/PocketBaseMacros/Relation.swift b/Sources/PocketBaseMacros/Relation.swift index dc915de..40b8bf0 100644 --- a/Sources/PocketBaseMacros/Relation.swift +++ b/Sources/PocketBaseMacros/Relation.swift @@ -9,6 +9,7 @@ import Foundation import SwiftSyntax import SwiftSyntaxMacros import SwiftSyntaxBuilder +import SwiftSyntaxMacroExpansion enum RelationError { case mustBeMarkedAsOptional diff --git a/Sources/PocketBaseMacros/RelationType.swift b/Sources/PocketBaseMacros/RelationType.swift index 24c70bb..36e38ff 100644 --- a/Sources/PocketBaseMacros/RelationType.swift +++ b/Sources/PocketBaseMacros/RelationType.swift @@ -7,6 +7,7 @@ import SwiftSyntax import SwiftSyntaxMacros +import SwiftSyntaxMacroExpansion enum RelationType { case single diff --git a/Sources/PocketBaseUI/Auth/Authentication.swift b/Sources/PocketBaseUI/Auth/Authentication.swift index 9b94cf9..7ee7435 100644 --- a/Sources/PocketBaseUI/Auth/Authentication.swift +++ b/Sources/PocketBaseUI/Auth/Authentication.swift @@ -8,18 +8,12 @@ import PocketBase import SwiftUI -/// <#Description#> public struct Authentication: View where T.EncodingConfiguration == RecordCollectionEncodingConfiguration { public typealias SignedOutBuilder = ( _ collection: RecordCollection, _ authState: Binding ) -> SignedOut - /// <#Description#> - /// - Parameters: - /// - loading: <#loading description#> - /// - signedOut: <#signedOut description#> - /// - content: <#content description#> public init( @ViewBuilder loading: @escaping () -> Loading, @ViewBuilder signedOut: @escaping SignedOutBuilder, @@ -42,7 +36,6 @@ public struct Authentication public var body: some View { Group { switch authState { @@ -79,10 +72,6 @@ public struct Authentication = (_ username: String, _ email: String) -> T extension Authentication where Loading == ProgressView, SignedOut == SignedOutView { - /// <#Description#> - /// - Parameters: - /// - newUser: <#newUser description#> - /// - content: <#content description#> public init( newUser: @escaping CreateUser, content: @escaping () -> Content diff --git a/Sources/PocketBaseUI/Auth/AuthenticationModifier.swift b/Sources/PocketBaseUI/Auth/AuthenticationModifier.swift index 389e02c..7fd770e 100644 --- a/Sources/PocketBaseUI/Auth/AuthenticationModifier.swift +++ b/Sources/PocketBaseUI/Auth/AuthenticationModifier.swift @@ -9,9 +9,6 @@ import SwiftUI import PocketBase extension View { - /// <#Description#> - /// - Parameter newUser: <#newUser description#> - /// - Returns: <#description#> public func authenticated( newUser: @escaping CreateUser ) -> some View where T.EncodingConfiguration == RecordCollectionEncodingConfiguration { @@ -22,12 +19,6 @@ extension View { ) } - /// <#Description#> - /// - Parameters: - /// - Type: <#Type description#> - /// - loading: <#loading description#> - /// - signedOut: <#signedOut description#> - /// - Returns: <#description#> public func authenticated( as Type: T.Type, loading: @escaping () -> Loading, diff --git a/Sources/PocketBaseUI/Auth/LoginButton.swift b/Sources/PocketBaseUI/Auth/LoginButton.swift index 66ed1ff..abed71d 100644 --- a/Sources/PocketBaseUI/Auth/LoginButton.swift +++ b/Sources/PocketBaseUI/Auth/LoginButton.swift @@ -8,17 +8,11 @@ import PocketBase import SwiftUI -/// <#Description#> -public struct LoginButton: View where T.EncodingConfiguration == RecordCollectionEncodingConfiguration { +public struct LoginButton: View, HasLogger where T.EncodingConfiguration == RecordCollectionEncodingConfiguration { private let collection: RecordCollection @Binding private var authState: AuthState private var strategy: RecordCollection.AuthMethod - /// <#Description#> - /// - Parameters: - /// - collection: <#collection description#> - /// - authState: <#authState description#> - /// - strategy: <#strategy description#> public init( collection: RecordCollection, authState: Binding, diff --git a/Sources/PocketBaseUI/Auth/SignUpButton.swift b/Sources/PocketBaseUI/Auth/SignUpButton.swift index 5fccc7d..e46a538 100644 --- a/Sources/PocketBaseUI/Auth/SignUpButton.swift +++ b/Sources/PocketBaseUI/Auth/SignUpButton.swift @@ -7,8 +7,9 @@ import SwiftUI import PocketBase +import os -public struct SignUpButton: View where T.EncodingConfiguration == RecordCollectionEncodingConfiguration { +public struct SignUpButton: View, HasLogger where T.EncodingConfiguration == RecordCollectionEncodingConfiguration { let newRecord: T let collection: RecordCollection @Binding private var authState: AuthState @@ -26,31 +27,33 @@ public struct SignUpButton: View where T.EncodingConfiguration == self.strategy = strategy } - public var body: some View { - Button { - Task { - do { - switch strategy { - case .identity(let identity, let password, let fields): - try await collection.create( - newRecord, - password: password, - passwordConfirm: password - ) - try await collection.authWithPassword( - identity, - password: password, - fields: fields - ) - case .oauth: - fatalError("OAuth is not implemented yet") - } - authState = .signedIn - } catch { - print(error) + func signUp() { + Task { + do { + switch strategy { + case .identity(let identity, let password, let fields): + try await collection.create( + newRecord, + password: password, + passwordConfirm: password + ) + try await collection.authWithPassword( + identity, + password: password, + fields: fields + ) + case .oauth: + fatalError("OAuth is not implemented yet") } + authState = .signedIn + } catch { + Self.logger.error("Failed to sign up: \(error)") } - } label: { + } + } + + public var body: some View { + Button(action: signUp) { switch strategy { case .identity: Label("Sign Up", systemImage: "person.crop.circle.fill") diff --git a/Sources/PocketBaseUI/Auth/LoginView.swift b/Sources/PocketBaseUI/Auth/SignedOutView.swift similarity index 97% rename from Sources/PocketBaseUI/Auth/LoginView.swift rename to Sources/PocketBaseUI/Auth/SignedOutView.swift index 11c935d..c9a7061 100644 --- a/Sources/PocketBaseUI/Auth/LoginView.swift +++ b/Sources/PocketBaseUI/Auth/SignedOutView.swift @@ -1,5 +1,5 @@ // -// LoginView.swift +// SignedOutView.swift // PocketBase // // Created by Brianna Zamora on 8/13/24. @@ -14,7 +14,7 @@ public enum AuthState: Sendable, Equatable { case signedOut } -public struct SignedOutView: View where T.EncodingConfiguration == RecordCollectionEncodingConfiguration { +public struct SignedOutView: View, HasLogger where T.EncodingConfiguration == RecordCollectionEncodingConfiguration { private let collection: RecordCollection @Binding private var authState: AuthState diff --git a/Sources/PocketBaseUI/Collections/RealtimeQuery.swift b/Sources/PocketBaseUI/Collections/RealtimeQuery.swift index 540581d..912e201 100644 --- a/Sources/PocketBaseUI/Collections/RealtimeQuery.swift +++ b/Sources/PocketBaseUI/Collections/RealtimeQuery.swift @@ -9,15 +9,11 @@ import PocketBase import SwiftUI import Collections -/// <#Description#> -@MainActor @propertyWrapper public struct RealtimeQuery: DynamicProperty where T.EncodingConfiguration == RecordCollectionEncodingConfiguration { - /// <#Description#> - /// - Parameters: - /// - page: <#page description#> - /// - perPage: <#perPage description#> - /// - shouldPage: <#shouldPage description#> - /// - sort: <#sort description#> - /// - fields: <#fields description#> +@MainActor +@propertyWrapper +public struct RealtimeQuery: DynamicProperty +where T.EncodingConfiguration == RecordCollectionEncodingConfiguration { + public init( page: Int = 0, perPage: Int = 30, @@ -50,14 +46,14 @@ import Collections pocketbase.collection(T.self) } - /// <#Description#> + /// The records exposed to the view public var wrappedValue: [T] { records } @State private var mostRecentError: Error? - /// <#Description#> + /// The coodinator object that enables an interface with the realtime query from the view. public var projectedValue: Coordinator { Coordinator( error: mostRecentError diff --git a/Sources/PocketBaseUI/Collections/StaticQuery.swift b/Sources/PocketBaseUI/Collections/StaticQuery.swift index cd1f5b9..77de0d1 100644 --- a/Sources/PocketBaseUI/Collections/StaticQuery.swift +++ b/Sources/PocketBaseUI/Collections/StaticQuery.swift @@ -9,8 +9,10 @@ import SwiftUI import PocketBase import Collections -/// <#Description#> -@MainActor @propertyWrapper public struct StaticQuery: DynamicProperty where T.EncodingConfiguration == RecordCollectionEncodingConfiguration { +@MainActor +@propertyWrapper +public struct StaticQuery: DynamicProperty +where T.EncodingConfiguration == RecordCollectionEncodingConfiguration { @Environment(\.pocketbase) private var pocketbase private var collection: RecordCollection { @@ -27,13 +29,6 @@ import Collections private let sort: [SortDescriptor] private let filter: Filter? - /// <#Description#> - /// - Parameters: - /// - page: <#page description#> - /// - perPage: <#perPage description#> - /// - shouldPage: <#shouldPage description#> - /// - sort: <#sort description#> - /// - filter: <#filter description#> public init( page: Int = 1, perPage: Int = 30, @@ -48,12 +43,10 @@ import Collections self.filter = filter } - /// <#Description#> public var wrappedValue: [T] { records } - /// <#Description#> public func load() async throws { let response = try await collection.list( page: page, diff --git a/Sources/PocketBaseUI/PocketBase+Environment.swift b/Sources/PocketBaseUI/PocketBase+Environment.swift index 8220d2a..a34af07 100644 --- a/Sources/PocketBaseUI/PocketBase+Environment.swift +++ b/Sources/PocketBaseUI/PocketBase+Environment.swift @@ -9,37 +9,24 @@ import PocketBase import SwiftUI extension EnvironmentValues { - /// <#Description#> @Entry public var pocketbase = PocketBase.localhost } public extension Scene { - /// <#Description#> - /// - Parameter url: <#url description#> - /// - Returns: <#description#> @SceneBuilder func pocketbase(url: URL) -> some Scene { pocketbase(PocketBase(url: url)) } - /// <#Description#> - /// - Parameter pocketbase: <#pocketbase description#> - /// - Returns: <#description#> @SceneBuilder func pocketbase(_ pocketbase: PocketBase) -> some Scene { environment(\.pocketbase, pocketbase) } } public extension View { - /// <#Description#> - /// - Parameter url: <#url description#> - /// - Returns: <#description#> @ViewBuilder func pocketbase(url: URL) -> some View { pocketbase(PocketBase(url: url)) } - /// <#Description#> - /// - Parameter pocketbase: <#pocketbase description#> - /// - Returns: <#description#> @ViewBuilder func pocketbase(_ pocketbase: PocketBase) -> some View { environment(\.pocketbase, pocketbase) } diff --git a/Tests/PocketBaseIntegrationTests/IntegrationTests.swift b/Tests/PocketBaseIntegrationTests/IntegrationTests.swift index 04d40fe..0a20c0f 100644 --- a/Tests/PocketBaseIntegrationTests/IntegrationTests.swift +++ b/Tests/PocketBaseIntegrationTests/IntegrationTests.swift @@ -47,7 +47,7 @@ extension Testing.Tag { ) func happyPath() async throws { // Initialize pocketbase - let pb = PocketBase(url: URL(string: "http://localhost:8090")!) + let pb = PocketBase.localhost // Define the user collection let users = pb.collection(User.self) diff --git a/Tests/PocketBaseMacrosTests/PocketBaseMacrosTests.swift b/Tests/PocketBaseMacrosTests/PocketBaseMacrosTests.swift index 1c12909..ba97feb 100644 --- a/Tests/PocketBaseMacrosTests/PocketBaseMacrosTests.swift +++ b/Tests/PocketBaseMacrosTests/PocketBaseMacrosTests.swift @@ -30,9 +30,14 @@ struct BaseRelationTests { assertMacroExpansion( """ @BaseCollection("cats") - struct Cat {} + struct Cat { + + } """, expandedSource: """ + struct Cat { + + } extension Cat: BaseRecord {} """, macros: testMacros diff --git a/pb_data/data.db b/pb_data/data.db index 95d00b9db62eef6e3eff12f0e02503b4c9701313..9cf97e6f4c81dfc73df68d0a394323d6d26dc2c4 100644 GIT binary patch delta 2255 zcma)7OKcNY6!r7uQ=7!kDMd(p+9Y*bM8KJ^$74uE96Q)au=8YVJ`%)RnU7)H978PAom8HxAi4BUXBBXY~h6NH*E1^oHt!UYjPMl!7L#P;OHuLT~ z@7#0GJF~WCSzEJQKip?CnM@A-ef6CB@&o~FKU}^A5r^ezwcCEyY(MK9c<4nZ;;=recK4q(Tc6cu`fpnM zKS0L7|7>>Ddxl>4EY@*Tr}@6gcDimAPg^^B!{A9nzWPyhLA}pFt;eyerc z!QI|fy@c|hgp;BPmdLw>1XavZYAJ&)(hfZCz;H)qA-KTL3Es-QOyXHpTqw%Hxo}{@ zr%HkN@Z16uoJS`jS%H*fZ(s@w_+n*jl2xTpDD77lyfMCzTUd#5XlZILP3Nwi74$*LhLg;O|I6W<3%SBY)Lgv&|bG`o~vNiK3F zH0fRx$wh3JaXg-w9nMpw*@?(_j8|u{lHbXuQh8sNS0+)fFG@)$vIZIi}46z!z-N#L#wo~X$A1d~#hNoAKbHyRlw$`elhe3l$r&I#^Z zx)Lsx&qv~c6<_>)xL4EVP$dJX*+z!@lts4b(7-=^fas(Lx1~SdLnhzr*7Q;Y6MPOI!e2Gp z8Spdwuwf9yh75p~`sQ9_z|!h#eHwJwtq8nmg7@J?Sk-Ko!7N;TTdTI5intI7yscka zb}>_kWO;3318*2vYhvpnpx`R5+s8u^PRvEI(Ly0j6F5&QxN9j>!f3V7E>?S~KFb>i zhDP>Y#@OkSEX!1atnhe}D+{GONAF!aiLpA=#G9Zfnmv~)l$Qlg#CYDdLg!Sa$n6P5 z(c0E3_^hoX8f7a*F1Ls&a>ku561*_K6R7=>#iEef6U9xkf?QBCA|~dPL{?IkFAN$^ zA3QN=XifjplC86$oA&Yx@Hd2p^Vh!t|G<4s9cfhC`W_6}ZQUE4fAsv>ajoaB<&bs1 z{xcXww8A|Q=<3+FWx80q^Am`f#bY(_0EqRkfeJc$Zkpuk5oAe|9)K#MoA0;@u0s(X z*KEIlBXHG+bdL1?cRYj#$2=tM#7UZU3rQlKTS=s_GE*sG?Mk$l`OJHx;Ea>RQ8vd& zOSzQ1?B+?CD2r_>vQ^U|xBS{Pl8%bg~YBxzt@HhA)ysg>(0yp9O>lxD0 zZjXS`7k69TD_~wVs%>q79y{^~Zr1zGXz6UF8u0iy>@QJ}(X8qfKg9X?7&0j&k s!Km$`H7&-VtV+r5xJV{bbUe?aC4(hvkB7|%Tb5*WOAec#LulCbFMY9ln*aa+ delta 2119 zcma)+OKcif6o$Es9SdSJq-oU3O{%eDDXMJac|S-~1sq}<0vPa%$;L1YPs0o|46mrN zqjuewu^3CrRu7SHdWee;|?S?Wb8=c3Kq=`oe1)+tpkrg9fr;xA8m~w z3!M|_eS2qg`spRxSlBXbeQ2>??%3t`?L!}31SdNF<_Ki9-fhJ`{IjL24_mVK)tl@r zMjyY5uTHkFe*?UoXWPeaKo+M(A8~^FW80{kWZWd_B58(ar~=j?~cg3FRDovrF}6=ToArMEOFk9x7&I^Wl&jomp7+UkXIZ^GX6oadH;r zW-$&y2VH5ZsHhx4VpLmx7o6!j#@ra?LUEdBXd%hT6j4blxF%~a9Fuupj^KTy09udSd#f3zHPrP7R#EO{5g0eO9ike1}7#&WT$)@so# zoANRuQRLTBSfCPGBg$BHH9WVHxFXhlsld9~@qw$JV+O+s=DH8Gzdi%T4buee#xNH~ z;XFqXX|kz`6qTm&^wta0mqoTw_XMw`qByCn3rTSymd=ZzfLDy8g?vCOhq4%wB8UxTdpdaQE;xoCz>ma;4g$dcwKDP*YZ`Z6!gxsOs=_FmP^(NGz@E+TzGtQ1tX9U9WaE2h*R(yk0B_~2|Z49c%dQo2z? z6Ktlg6d1B9B&(+IddIlC)tKJrr=~9)`Pew+%bKE4Vv*nsA~d0r6sXeCTLVm2%Ku6! zoDYgpjnQPeS)wadx=xWo15gI5iYlX?Pn!88^kaQNqQcD8FePzTn#*6g!JEy~nhhz+5ax^PW-)$yE?FE7ep) zlaX8rsbob&GJcdshAq`<4I#Oe&1CE9^SlNn7#BtMP)G;+6@-Hl80#VkhL1}cA|y(N zdN=&8e6d?bDv5}^l@P>&K;QzE$MGWM;tAN>a2<_2z$!LFYN z*VQ|H$m{c#^}RhX>eOKbd;+&NoipdV7g?W>9w3nM{y7szJ&z{Zb`k3ru+MZe9*Hi8 l@p&ny3eh5CopV!>wj8NS)Bj? diff --git a/pb_data/logs.db b/pb_data/logs.db index 32a575d5ce59f547dd99c3d6048c8d486c54cb86..73cae947401aeee0eab3c5fae64bafc3126abca1 100644 GIT binary patch delta 22910 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 XI1E2Fsxx!-9McnD8oLL-{pbG&*aUJ( delta 16908 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 R%!iQO#nk3M%KqxU{|7dXHUIzs