diff --git a/native/swift/Example/Example.xcodeproj/project.pbxproj b/native/swift/Example/Example.xcodeproj/project.pbxproj index 4fae5374..f861ddd6 100644 --- a/native/swift/Example/Example.xcodeproj/project.pbxproj +++ b/native/swift/Example/Example.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 242132C82CE69CE80021D8E8 /* WordPressAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 242132C72CE69CE80021D8E8 /* WordPressAPI */; }; + 242CA0C12D03A7E200C0DD68 /* LoginReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242CA0C02D03A7DF00C0DD68 /* LoginReport.swift */; }; 242D648E2C3602C1007CA96C /* ListViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D648D2C3602C1007CA96C /* ListViewData.swift */; }; 242D64922C360687007CA96C /* RootListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D64912C360687007CA96C /* RootListView.swift */; }; 242D64942C3608C6007CA96C /* ListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D64932C3608C6007CA96C /* ListView.swift */; }; @@ -23,6 +24,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 242CA0C02D03A7DF00C0DD68 /* LoginReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginReport.swift; sourceTree = ""; }; 242D648D2C3602C1007CA96C /* ListViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewData.swift; sourceTree = ""; }; 242D64912C360687007CA96C /* RootListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootListView.swift; sourceTree = ""; }; 242D64932C3608C6007CA96C /* ListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListView.swift; sourceTree = ""; }; @@ -55,6 +57,7 @@ 242D64972C363960007CA96C /* UI */ = { isa = PBXGroup; children = ( + 242CA0C02D03A7DF00C0DD68 /* LoginReport.swift */, 2479BF872B621CB70014A01D /* Preview Content */, 242D64932C3608C6007CA96C /* ListView.swift */, 242D64912C360687007CA96C /* RootListView.swift */, @@ -191,6 +194,7 @@ files = ( 242D64922C360687007CA96C /* RootListView.swift in Sources */, 2479BF812B621CB60014A01D /* ExampleApp.swift in Sources */, + 242CA0C12D03A7E200C0DD68 /* LoginReport.swift in Sources */, 24A3C32F2BA8F96F00162AD1 /* LoginView.swift in Sources */, 2479BF932B621E9B0014A01D /* ListViewModel.swift in Sources */, 24A3C3362BAA874C00162AD1 /* LoginManager.swift in Sources */, diff --git a/native/swift/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/native/swift/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e911cd24..c0e68523 100644 --- a/native/swift/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/native/swift/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -40,10 +40,10 @@ { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/swiftlang/swift-syntax.git", "state" : { - "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", - "version" : "510.0.2" + "revision" : "cb53fa1bd3219b0b23ded7dfdd3b2baff266fd25", + "version" : "600.0.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/SwiftLint", "state" : { - "revision" : "b515723b16eba33f15c4677ee65f3fef2ce8c255", - "version" : "0.55.1" + "revision" : "25f2776977e663305bee71309ea1e34d435065f1", + "version" : "0.57.1" } }, { diff --git a/native/swift/Example/Example/UI/LoginReport.swift b/native/swift/Example/Example/UI/LoginReport.swift new file mode 100644 index 00000000..47012e44 --- /dev/null +++ b/native/swift/Example/Example/UI/LoginReport.swift @@ -0,0 +1,141 @@ +import Foundation +import SwiftUI +import WordPressAPI +import AuthenticationServices +import WordPressAPIInternal + +struct AutoDiscoveryResultView: View { + let results: [AutoDiscoveryAttemptViewData] + + var body: some View { + Text("N") + } +} + +struct AutoDiscoveryStepView: View { + let icon: String + let label: String + + var body: some View { + VStack(alignment: .leading) { + HStack(alignment: .firstTextBaseline) { + Image(systemName: icon) + .font(.title) + Text(label).font(.title) + Spacer() + } + }.padding(.horizontal) + } +} + +struct AutoDiscoveryErrorView: View { + let errorMessage: String + + var body: some View { + Text(errorMessage) + } +} + +struct AutodiscoveryReportView: View { + let report: AutoDiscoveryResult? + + var body: some View { + if let report { + if let success = report.successfulAttempt { + if let url = success.domainWithSubdomain { + Text(url) + } + + if success.couldConnectToUrl { + AutoDiscoveryStepView(icon: "checkmark.circle", label: "Site Connection") + } + + if success.couldUseHttps { + AutoDiscoveryStepView(icon: "checkmark.circle", label: "Can connect using HTTPS") + } + + if success.foundApiRoot { + AutoDiscoveryStepView(icon: "checkmark.circle", label: "Supports JSON API") + } + + if success.foundAuthenticationUrl { + AutoDiscoveryStepView(icon: "checkmark.circle", label: "Found authentication URL") + } + } else { + Text("Unable to connect") + } + } else { + ProgressView() + } + } +} + +struct AutoDiscoveryAttemptViewData { + + enum ErrorType { + case network + case missingRootUrl + } + + enum StepType { + case siteWasFound + case supportsHTTPS + case canFindRootURL + case apiRootHasAuthUrl + } + +// let userInput: String + + let isError: Bool + + let errorType: ErrorType? + let errorMessage: String? + + let step: StepType + +// var url: URL? { +// URL(string: userInput) +// } + + var icon: String { +// if url?.scheme == "https" { +// return "checkmark.shield" +// } + + return switch errorType { + case .network: "network.slash" + case .missingRootUrl: "questionmark.circle.dashed" + case nil: "checkmark.circle" + } + } + + var attemptTypeString: String { + switch step { + case .siteWasFound: "Site Connection" + case .supportsHTTPS: "Can connect using HTTPS" + case .canFindRootURL: "Supports JSON API" + case .apiRootHasAuthUrl: "Found authentication URL" + } + } +} + +#Preview("Live data") { + + struct AsyncTestView: View { + + @State var report: AutoDiscoveryResult? + + private let loginApi = WordPressLoginClient(requestExecutor: URLSession.shared) + + var body: some View { + AutodiscoveryReportView(report: report) + .task { + let result = await loginApi.autodiscoveryResult(forSite: "http://vanilla.wpmt.co") + + self.report = result + } + } + } + + return AsyncTestView() +} diff --git a/native/swift/Example/Example/UI/LoginView.swift b/native/swift/Example/Example/UI/LoginView.swift index 721e5582..98f416b9 100644 --- a/native/swift/Example/Example/UI/LoginView.swift +++ b/native/swift/Example/Example/UI/LoginView.swift @@ -9,13 +9,13 @@ struct LoginView: View { private var url: String = "" @State - private var isLoggingIn: Bool = false + private var isLoading: Bool = false @State private var loginError: String? @State - private var loginTask: Task? + private var currentTask: Task? @Environment(\.webAuthenticationSession) private var webAuthenticationSession @@ -43,14 +43,14 @@ struct LoginView: View { #endif HStack { - if isLoggingIn { + if isLoading { ProgressView() .progressViewStyle(.circular) .controlSize(.small) .padding() } else { - Button(action: self.startLogin, label: { - Text("Sign In") + Button(action: self.startAutodiscovery, label: { + Text("Next") }) } } @@ -58,28 +58,44 @@ struct LoginView: View { .padding() } - func startLogin() { - self.loginError = nil - self.isLoggingIn = true + func startAutodiscovery() { + self.currentTask = Task { + self.isLoading = true - self.loginTask = Task { do { - let loginClient = WordPressLoginClient(urlSession: .shared) - let loginDetails = try await loginClient.login( - site: url, - appName: "WordPress SDK Example App", - appId: nil - ) + let loginClient = WordPressLoginClient(requestExecutor: URLSession.shared) + let loginDetails = await loginClient.autodiscoveryResult(forSite: url) + debugPrint(loginDetails) - try await loginManager.setLoginCredentials(to: loginDetails) - } catch let err { - handleLoginError(err) } + + self.isLoading = false } } + func startLogin() { + self.loginError = nil + self.isLoading = true + +// self.currentTask = Task { +// do { +//// let loginClient = WordPressLoginClient(requestExecutor: URLSession.shared) +//// let loginDetails = try await loginClient.login( +//// site: url, +//// appName: "WordPress SDK Example App", +//// appId: nil, +//// contextProvider: AuthenticationHelper() +//// ).get() +//// debugPrint(loginDetails) +//// try await loginManager.setLoginCredentials(to: loginDetails) +// } catch let err { +// handleLoginError(err) +// } +// } + } + private func handleLoginError(_ error: Error) { - self.isLoggingIn = false + self.isLoading = false self.loginError = error.localizedDescription } } @@ -88,4 +104,6 @@ class AuthenticationHelper: NSObject, ASWebAuthenticationPresentationContextProv func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { ASPresentationAnchor() } + +// LoginView().environmentObject(LoginManager()) } diff --git a/native/swift/Sources/wordpress-api/Exports.swift b/native/swift/Sources/wordpress-api/Exports.swift index c08b2c74..c4a7627a 100644 --- a/native/swift/Sources/wordpress-api/Exports.swift +++ b/native/swift/Sources/wordpress-api/Exports.swift @@ -20,9 +20,7 @@ public typealias WpNetworkHeaderMap = WordPressAPIInternal.WpNetworkHeaderMap public typealias WpApiApplicationPasswordDetails = WordPressAPIInternal.WpApiApplicationPasswordDetails public typealias WpAuthentication = WordPressAPIInternal.WpAuthentication -public typealias UrlDiscoveryError = WordPressAPIInternal.UrlDiscoveryError -public typealias UrlDiscoverySuccess = WordPressAPIInternal.UrlDiscoverySuccess -public typealias UrlDiscoveryAttemptError = WordPressAPIInternal.UrlDiscoveryAttemptError +public typealias AutoDiscoveryResult = WordPressAPIInternal.AutoDiscoveryUniffiResult // MARK: - Users @@ -124,4 +122,6 @@ public typealias WpSiteHealthTestsRequestExecutor = WordPressAPIInternal.WpSiteH extension WpSiteHealthTestsRequestExecutor: @unchecked Sendable {} // swiftlint:enable line_length +extension AutoDiscoveryResult: @unchecked Sendable {} + #endif diff --git a/native/swift/Sources/wordpress-api/Foundation+Extensions.swift b/native/swift/Sources/wordpress-api/Foundation+Extensions.swift index 760feba2..f4ddc391 100644 --- a/native/swift/Sources/wordpress-api/Foundation+Extensions.swift +++ b/native/swift/Sources/wordpress-api/Foundation+Extensions.swift @@ -17,3 +17,13 @@ public extension Date { wordpressDateFormatter.date(from: string) } } + +public extension URL { + var schemeAndHost: String? { + guard let scheme = self.scheme, let host = self.host else { + return nil + } + + return scheme.uppercased() + "" + "://" + host + } +} diff --git a/native/swift/Sources/wordpress-api/LoginAPI.swift b/native/swift/Sources/wordpress-api/LoginAPI.swift index ac34d4e3..3f400cc1 100644 --- a/native/swift/Sources/wordpress-api/LoginAPI.swift +++ b/native/swift/Sources/wordpress-api/LoginAPI.swift @@ -8,72 +8,80 @@ import WordPressAPIInternal import FoundationNetworking #endif -public final class WordPressLoginClient { - - public protocol AuthenticatorProtocol { - func authenticate(url: URL, callbackURL: URL) async throws(WordPressLoginClientError) -> URL - } - - private static let callbackURL = URL(string: "x-wordpress-app://login-callback")! +public actor WordPressLoginClient { private let requestExecutor: SafeRequestExecutor - private let client: UniffiWpLoginClient - public convenience init(urlSession: URLSession) { - self.init(requestExecutor: urlSession) + public enum Error: Swift.Error { + case invalidSiteAddress + case missingLoginUrl + case authenticationError(OAuthResponseUrlError) + case invalidApplicationPasswordCallback + case cancelled + case unknown(Swift.Error) + + /// We don't have anything useful to tell the user – this is basically "Something went wrong, please try again" + case generic } - init(requestExecutor: SafeRequestExecutor) { + public init(requestExecutor: SafeRequestExecutor) { self.requestExecutor = requestExecutor - self.client = UniffiWpLoginClient(requestExecutor: requestExecutor) } - public func login( - site: String, + /// Perform login autodiscovery and build a login URL + /// + public func authenticationUrl( + forSite proposedSiteUrl: String, appName: String, appId: WpUuid?, - authenticator: AuthenticatorProtocol - ) async throws -> WpApiApplicationPasswordDetails { - let loginURL = try await self.loginURL(forSite: site) - let authURL = createApplicationPasswordAuthenticationUrl( - loginUrl: loginURL, + callbackUrl: URL + ) async throws -> ParsedUrl { + guard let urlString = await UniffiWpLoginClient(requestExecutor: self.requestExecutor) + .apiDiscovery(siteUrl: proposedSiteUrl) + .successfulAttempt? + .apiDetails()? + .findApplicationPasswordsAuthenticationUrl() + else { + throw Error.invalidSiteAddress + } + + return createApplicationPasswordAuthenticationUrl( + loginUrl: try ParsedUrl.parse(input: urlString), appName: appName, appId: appId, - successUrl: Self.callbackURL.absoluteString, - rejectUrl: Self.callbackURL.absoluteString + successUrl: callbackUrl.absoluteString, + rejectUrl: callbackUrl.absoluteString ) - .asURL() - - let urlWithToken = try await authenticator.authenticate(url: authURL, callbackURL: Self.callbackURL) - return try handleAuthenticationCallback(urlWithToken) } - private func loginURL(forSite proposedSiteUrl: String) async throws(WordPressLoginClientError) -> ParsedUrl { + private func handleAuthenticationCallback( + _ urlWithToken: URL + ) throws(WordPressLoginClientError) -> WpApiApplicationPasswordDetails { + guard let parsed = try? ParsedUrl.from(url: urlWithToken) else { + throw .invalidApplicationPasswordCallback + } do { - let client = UniffiWpLoginClient(requestExecutor: self.requestExecutor) - let discoveryResult = try await client.apiDiscovery(siteUrl: proposedSiteUrl) - - // All sites should have some form of authentication we can use - guard - let passwordAuthenticationUrl = discoveryResult.apiDetails.findApplicationPasswordsAuthenticationUrl(), - let parsedLoginUrl = try? ParsedUrl.parse(input: passwordAuthenticationUrl) - else { - throw WordPressLoginClientError.missingLoginUrl - } - - return parsedLoginUrl - - } catch let error as UrlDiscoveryError { - throw WordPressLoginClientError.invalidSiteAddress(error) + return try extractLoginDetailsFromUrl(url: parsed) + } catch let error as OAuthResponseUrlError { + throw .authenticationError(error) } catch { - throw WordPressLoginClientError.unknown(error) + throw .unknown(error) } } - private func handleAuthenticationCallback( + /// Perform login autodiscovery and get the raw data about the process + /// + public func autodiscoveryResult(forSite proposedSiteUrl: String) async -> AutoDiscoveryResult { + await UniffiWpLoginClient(requestExecutor: self.requestExecutor) + .apiDiscovery(siteUrl: proposedSiteUrl) + } + + /// Parse the URL we get back from the WordPress website, turning it into login details + /// + public func parseAuthenticationCallback( _ urlWithToken: URL - ) throws(WordPressLoginClientError) -> WpApiApplicationPasswordDetails { + ) throws(Error) -> WpApiApplicationPasswordDetails { guard let parsed = try? ParsedUrl.from(url: urlWithToken) else { throw .invalidApplicationPasswordCallback } @@ -88,65 +96,41 @@ public final class WordPressLoginClient { } } -#if os(iOS) || os(macOS) +public extension AutoDiscoveryAttemptResult { + + public var couldConnectToUrl: Bool { + // TODO + return false + } -import AuthenticationServices + public var couldUseHttps: Bool { + self.authenticationUrl?.scheme == "https" + } -extension WordPressLoginClient { + public var foundApiRoot: Bool { + self.apiRootUrl() != nil + } - class AuthenticationServiceAuthenticator: NSObject, AuthenticatorProtocol, - ASWebAuthenticationPresentationContextProviding { - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - ASPresentationAnchor() - } + public var foundAuthenticationUrl: Bool { + self.apiDetails()?.findApplicationPasswordsAuthenticationUrl() != nil + } - @MainActor - func authenticate(url: URL, callbackURL: URL) async throws(WordPressLoginClientError) -> URL { - do { - return try await withCheckedThrowingContinuation { continuation in - let session = ASWebAuthenticationSession( - url: url, - callbackURLScheme: callbackURL.scheme! - ) { url, error in - if let url { - continuation.resume(returning: url) - } else if let error = error as? ASWebAuthenticationSessionError { - switch error.code { - case .canceledLogin: - continuation.resume(throwing: WordPressLoginClientError.cancelled) - case .presentationContextInvalid, .presentationContextNotProvided: - assertionFailure("An unexpected error received: \(error)") - continuation.resume(throwing: WordPressLoginClientError.cancelled) - @unknown default: - continuation.resume(throwing: WordPressLoginClientError.cancelled) - } - } else { - continuation.resume(throwing: WordPressLoginClientError.invalidApplicationPasswordCallback) - } - } - session.presentationContextProvider = self - session.start() - } - } catch { - // swiftlint:disable:next force_cast - throw error as! WordPressLoginClientError - } + public var authenticationUrl: URL? { + guard + let string = apiDetails()?.findApplicationPasswordsAuthenticationUrl(), + let url = URL(string: string) + else { + return nil } + + return url } - public func login( - site: String, - appName: String, - appId: WpUuid? - ) async throws -> WpApiApplicationPasswordDetails { - let provider = await AuthenticationServiceAuthenticator() - return try await login( - site: site, - appName: appName, - appId: appId, - authenticator: provider - ) + public var domainWithSubdomain: String? { + guard let scheme = authenticationUrl?.scheme, let host = authenticationUrl?.host else { + return nil + } + + return scheme + "://" + host } } - -#endif diff --git a/native/swift/Sources/wordpress-api/WordPressLoginClientError.swift b/native/swift/Sources/wordpress-api/WordPressLoginClientError.swift index 11301c2e..34b8e47d 100644 --- a/native/swift/Sources/wordpress-api/WordPressLoginClientError.swift +++ b/native/swift/Sources/wordpress-api/WordPressLoginClientError.swift @@ -9,69 +9,10 @@ import FoundationNetworking #endif public enum WordPressLoginClientError: Swift.Error { - case invalidSiteAddress(UrlDiscoveryError) + case invalidSiteAddress case missingLoginUrl case authenticationError(OAuthResponseUrlError) case invalidApplicationPasswordCallback case cancelled case unknown(Swift.Error) - - func isAutodiscoveryError() -> Bool { - guard case let .invalidSiteAddress(urlDiscoveryError) = self else { - return false - } - - guard case .UrlDiscoveryFailed = urlDiscoveryError else { - return false - } - - return true - } - - var isFailedToFetchApiDetails: Bool { - guard - case let .invalidSiteAddress(urlDiscoveryError) = self, - case .UrlDiscoveryFailed(let attempts) = urlDiscoveryError - else { - return false - } - - return attempts.values.contains { state in - return switch state { - case .failure(let error): urlDiscoverErrorIsFetchApiDetailsFailed(error) - default: false - } - } - } - - var isFailedToFetchApiRoot: Bool { - guard - case let .invalidSiteAddress(urlDiscoveryError) = self, - case .UrlDiscoveryFailed(let attempts) = urlDiscoveryError - else { - return false - } - - return attempts.values.contains { state in - if case let .failure(error) = state { - return isFetchRootUrlFailedError(error) - } - - return false - } - } - - private func urlDiscoverErrorIsFetchApiDetailsFailed(_ error: UrlDiscoveryAttemptError) -> Bool { - return switch error { - case .fetchApiDetailsFailed: true - default: false - } - } - - private func isFetchRootUrlFailedError(_ error: UrlDiscoveryAttemptError) -> Bool { - return switch error { - case .fetchApiRootUrlFailed: true - default: false - } - } }