From 8a40fc32f8dd945f6d4f56512efabdd679349928 Mon Sep 17 00:00:00 2001 From: Varun Santhanam Date: Sat, 19 Aug 2023 14:07:51 -0700 Subject: [PATCH] Add preliminary web authentication support (#24) --- .swiftformat | 2 +- Sources/SafariUI/DismissButtonStyle.swift | 2 - Sources/SafariUI/Environment.swift | 13 + Sources/SafariUI/Modifiers.swift | 28 +- Sources/SafariUI/Presentation.swift | 37 +- Sources/SafariUI/SafariUI.docc/SafariUI.md | 8 + Sources/SafariUI/SafariUI.docc/SafariView.md | 4 - .../SafariUI.docc/{Modifiers.md => View.md} | 20 +- Sources/SafariUI/SafariView.swift | 59 +-- Sources/SafariUI/WebAuthentication.swift | 343 ++++++++++++++++++ 10 files changed, 459 insertions(+), 57 deletions(-) rename Sources/SafariUI/SafariUI.docc/{Modifiers.md => View.md} (57%) create mode 100644 Sources/SafariUI/WebAuthentication.swift diff --git a/.swiftformat b/.swiftformat index f2c738d4f..08d42374b 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,4 +1,4 @@ --enable isEmpty ---disable blankLinesAtEndOfScope, blankLinesAtStartOfScope, redundantNilInit, unusedArguments, redundantParens, wrapMultilineStatementBraces, trailingCommas, braces +--disable blankLinesAtEndOfScope, blankLinesAtStartOfScope, redundantNilInit, unusedArguments, redundantParens, wrapMultilineStatementBraces, trailingCommas, braces, opaqueGenericParameters --swiftversion 5.8 --header "SafariUI\n{file}\n\nMIT License\n\nCopyright (c) 2021 Varun Santhanam\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the "Software"), to deal\n\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." \ No newline at end of file diff --git a/Sources/SafariUI/DismissButtonStyle.swift b/Sources/SafariUI/DismissButtonStyle.swift index 9661c9752..a7a5069f5 100644 --- a/Sources/SafariUI/DismissButtonStyle.swift +++ b/Sources/SafariUI/DismissButtonStyle.swift @@ -49,7 +49,5 @@ public extension SafariView { case .cancel: return .cancel } } - } - } diff --git a/Sources/SafariUI/Environment.swift b/Sources/SafariUI/Environment.swift index f1fb407a9..c45dffcee 100644 --- a/Sources/SafariUI/Environment.swift +++ b/Sources/SafariUI/Environment.swift @@ -91,6 +91,11 @@ extension EnvironmentValues { set { self[SafariViewDismissButtonStyleEnvironmentKey.self] = newValue } } + var webAuthenticationPrefersEphemeralWebBrowserSession: Bool { + get { self[WebAuthenticationPrefersEphemeralWebBrowserSessionEnvironmentKey.self] } + set { self[WebAuthenticationPrefersEphemeralWebBrowserSessionEnvironmentKey.self] = newValue } + } + } private struct SafariViewEntersReaderIfAvailableEnvironmentKey: EnvironmentKey { @@ -162,3 +167,11 @@ private struct SafariViewExcludedActivityTypesEnvironmentKey: EnvironmentKey { static let defaultValue: Value = .default } + +private struct WebAuthenticationPrefersEphemeralWebBrowserSessionEnvironmentKey: EnvironmentKey { + + typealias Value = Bool + + static let defaultValue: Bool = false + +} diff --git a/Sources/SafariUI/Modifiers.swift b/Sources/SafariUI/Modifiers.swift index 1e7f53f54..7717da2f3 100644 --- a/Sources/SafariUI/Modifiers.swift +++ b/Sources/SafariUI/Modifiers.swift @@ -202,6 +202,11 @@ public extension View { return ModifiedContent(content: self, modifier: modifier) } + func webAuthenticationPrefersEphemeralWebBrowserSession(_ prefersEphemeralWebBrowserSession: Bool = true) -> some View { + let modifer = WebAuthenticationPrefersEphemeralWebBrowserSessionModifier(prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession) + return ModifiedContent(content: self, modifier: modifer) + } + } private struct SafariViewEntersReaderIfAvailableModifier: ViewModifier { @@ -343,7 +348,7 @@ private struct SafariViewExcludedActivityTypesModifier: ViewModifier { self.activityTypes = activityTypes } - // MARK: - ViewBuilder + // MARK: - ViewModifier @ViewBuilder func body(content: Content) -> some View { @@ -356,3 +361,24 @@ private struct SafariViewExcludedActivityTypesModifier: ViewModifier { private let activityTypes: SafariView.ExcludedActivityTypes } + +private struct WebAuthenticationPrefersEphemeralWebBrowserSessionModifier: ViewModifier { + + // MARK: - Initializers + + init(prefersEphemeralWebBrowserSession: Bool) { + self.prefersEphemeralWebBrowserSession = prefersEphemeralWebBrowserSession + } + + // MARK: - ViewModifier + + @ViewBuilder + func body(content: Content) -> some View { + content + .environment(\.webAuthenticationPrefersEphemeralWebBrowserSession, prefersEphemeralWebBrowserSession) + } + + // MARK: - Private + + private let prefersEphemeralWebBrowserSession: Bool +} diff --git a/Sources/SafariUI/Presentation.swift b/Sources/SafariUI/Presentation.swift index 1d82a7bd4..69f255ade 100644 --- a/Sources/SafariUI/Presentation.swift +++ b/Sources/SafariUI/Presentation.swift @@ -196,17 +196,17 @@ public extension View { /// - onDismiss: The closure to execute when dismissing the ``SafariView`` /// - safariView: A closure that returns the ``SafariView`` to present /// - Returns: The modified view - func safari( + func safari( item: Binding, - id: KeyPath, + id: KeyPath, onDismiss: (() -> Void)? = nil, @ViewBuilder safariView: @escaping (Item) -> SafariView - ) -> some View { + ) -> some View where Identifier: Hashable { let modifier = SafariView.ItemModifier( item: item, id: id, onDismiss: onDismiss, - safariView: safariView + build: safariView ) return ModifiedContent(content: self, modifier: modifier) } @@ -274,3 +274,32 @@ public extension View { ) } } + +public extension View { + + func webAuthentication( + _ isPresented: Binding, + webAuthentication: @escaping () -> WebAuthentication + ) -> some View { + let modifier = WebAuthentication.BoolModifier(isPresented: isPresented, build: webAuthentication) + return ModifiedContent(content: self, modifier: modifier) + } + + func webAuthentication( + _ item: Binding, + webAuthentication: @escaping (Item) -> WebAuthentication + ) -> some View where Item: Identifiable { + let modifier = WebAuthentication.IdentifiableItemModitifer(item: item, build: webAuthentication) + return ModifiedContent(content: self, modifier: modifier) + } + + func webAuthentication( + _ item: Binding, + id: KeyPath, + webAuthentication: @escaping (Item) -> WebAuthentication + ) -> some View where Identifier: Hashable { + let modifier = WebAuthentication.ItemModifier(item: item, id: id, build: webAuthentication) + return ModifiedContent(content: self, modifier: modifier) + } + +} diff --git a/Sources/SafariUI/SafariUI.docc/SafariUI.md b/Sources/SafariUI/SafariUI.docc/SafariUI.md index 484045b17..b8d46586c 100644 --- a/Sources/SafariUI/SafariUI.docc/SafariUI.md +++ b/Sources/SafariUI/SafariUI.docc/SafariUI.md @@ -11,3 +11,11 @@ SafariServices in SwiftUI ### Views - ``SafariView`` + +### Structures + +- ``WebAuthentication`` + +### View Modifiers + +- ``SwiftUI/View`` diff --git a/Sources/SafariUI/SafariUI.docc/SafariView.md b/Sources/SafariUI/SafariUI.docc/SafariView.md index d322a5649..b7c09acdb 100644 --- a/Sources/SafariUI/SafariUI.docc/SafariView.md +++ b/Sources/SafariUI/SafariUI.docc/SafariView.md @@ -34,10 +34,6 @@ You can also use sheet presentation, or any other presentation mechanism of your - ``init(url:activityButton:onInitialLoad:onInitialRedirect:onOpenInBrowser:)`` - ``init(url:activityButton:eventAttribution:onInitialLoad:onInitialRedirect:onOpenInBrowser:)`` -### View Modifiers - -- ``SwiftUI/View`` - ### Appearance - ``DismissButtonStyle`` diff --git a/Sources/SafariUI/SafariUI.docc/Modifiers.md b/Sources/SafariUI/SafariUI.docc/View.md similarity index 57% rename from Sources/SafariUI/SafariUI.docc/Modifiers.md rename to Sources/SafariUI/SafariUI.docc/View.md index efa3d6915..9037ff908 100644 --- a/Sources/SafariUI/SafariUI.docc/Modifiers.md +++ b/Sources/SafariUI/SafariUI.docc/View.md @@ -1,30 +1,40 @@ # ``SwiftUI/View`` -SwiftUI view modifiers used to configure a ``SafariView`` +SwiftUI view modifiers used to configure a ``SafariView`` or a ``WebAuthentication`` ## Topics -### Configuration +### SafariView Configuration - ``SwiftUI/View/safariEntersReaderIfAvailable(_:)`` - ``SwiftUI/View/safariBarCollapsingEnabled(_:)`` -### Appearance +### SafariView Appearance - ``SwiftUI/View/safariBarTintColor(_:)`` - ``SwiftUI/View/safariControlTintColor(_:)`` - ``SwiftUI/View/safariDismissButtonStyle(_:)`` -### Presentation +### SafariView Presentation - ``SwiftUI/View/safari(isPresented:onDismiss:safariView:)`` - ``SwiftUI/View/safari(url:onDismiss:safariView:)`` - ``SwiftUI/View/safari(item:onDismiss:safariView:)`` - ``SwiftUI/View/safari(item:id:onDismiss:safariView:)`` -### Custom Activities +### SafarView Custom Activities - ``SwiftUI/View/includedSafariActivities(_:)-1yaml`` - ``SwiftUI/View/includedSafariActivities(_:)-7buso`` - ``SwiftUI/View/excludedSafariActivityTypes(_:)-5sf78`` - ``SwiftUI/View/excludedSafariActivityTypes(_:)-4omxw`` + +### WebAuthentication Configuration + +- ``SwiftUI/View/webAuthenticationPrefersEphemeralWebBrowserSession(_:)`` + +### WebAuthentication Presentation + +- ``SwiftUI/View/webAuthentication(_:webAuthentication:)-5m8qc`` +- ``SwiftUI/View/webAuthentication(_:webAuthentication:)-9e7q7`` +- ``SwiftUI/View/webAuthentication(_:id:webAuthentication:)`` diff --git a/Sources/SafariUI/SafariView.swift b/Sources/SafariUI/SafariView.swift index e168a13d4..8bf865007 100644 --- a/Sources/SafariUI/SafariView.swift +++ b/Sources/SafariUI/SafariView.swift @@ -637,69 +637,48 @@ public struct SafariView: View { struct ItemModifier: ViewModifier where Identifier: Hashable { - // MARK: - Initializers + @Binding + var item: Item? - init( - item: Binding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - @ViewBuilder safariView: @escaping (Item) -> SafariView - ) { - self.item = item - self.id = id - self.onDismiss = onDismiss - self.safariView = safariView - } + let id: KeyPath + let onDismiss: (() -> Void)? + let build: (Item) -> SafariView // MARK: - ViewModifier @ViewBuilder func body(content: Content) -> some View { content - .safari(item: binding, onDismiss: onDismiss) { wrappedItem in - safariView(wrappedItem.item) + .safari(item: wrapped, onDismiss: onDismiss) { item in + build(item.wrapped) } } // MARK: - Private - private let item: Binding - private let id: KeyPath - private let onDismiss: (() -> Void)? - private let safariView: (Item) -> SafariView + private struct WrappedItem: Identifiable { + let wrapped: Item + let path: KeyPath + var id: Identifier { wrapped[keyPath: path] } + } - private var binding: Binding { + private var wrapped: Binding { Binding { - guard let item = item.wrappedValue else { - return nil - } - return WrappedItem(item, id) + item.map(wrap) } set: { newValue in - item.wrappedValue = newValue?.item + item = newValue?.wrapped } } - private struct WrappedItem: Identifiable { - init(_ item: Item, - _ keyPath: KeyPath) { - self.item = item - self.keyPath = keyPath - } - - let item: Item - private let keyPath: KeyPath - - typealias ID = Identifier - - var id: ID { - item[keyPath: keyPath] - } + private func wrap(_ item: Item) -> WrappedItem { + .init(wrapped: item, path: id) } + } } -private extension UIView { +extension UIView { var controller: UIViewController? { if let nextResponder = next as? UIViewController { diff --git a/Sources/SafariUI/WebAuthentication.swift b/Sources/SafariUI/WebAuthentication.swift new file mode 100644 index 000000000..3db137b85 --- /dev/null +++ b/Sources/SafariUI/WebAuthentication.swift @@ -0,0 +1,343 @@ +// SafariUI +// WebAuthentication.swift +// +// MIT License +// +// Copyright (c) 2021 Varun Santhanam +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import AuthenticationServices +import SwiftUI + +/// A wrapper for `ASWebAuthenticationSession` in SwiftUI +public struct WebAuthentication { + + // MARK: - Initializers + + /// Create a web authentication session + /// - Parameters: + /// - url: The URL pointing to the authentication page + /// - callbackURLScheme: The URL scheme that the app should expect when receiving the authentication callback + /// - completionHandler: Completion Handler + public init( + url: URL, + callbackURLScheme: String?, + completionHandler: @escaping CompletionHandler + ) { + self.url = url + self.callbackURLScheme = callbackURLScheme + self.completionHandler = completionHandler + } + + // MARK: - API + + public typealias CompletionHandler = (Result) -> Void + + // MARK: - Private + + private let url: URL + private let callbackURLScheme: String? + private let completionHandler: CompletionHandler + + struct BoolModifier: ViewModifier { + + @Binding + var isPresented: Bool + + let build: () -> WebAuthentication + + @ViewBuilder + func body(content: Content) -> some View { + content + .background( + Presenter( + isPresented: $isPresented, + prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession, + build: build + ) + ) + } + + @Environment(\.webAuthenticationPrefersEphemeralWebBrowserSession) + private var prefersEphemeralWebBrowserSession: Bool + + private struct Presenter: UIViewRepresentable { + + @Binding + var isPresented: Bool + + let prefersEphemeralWebBrowserSession: Bool + let build: () -> WebAuthentication + + // MARK: - UIViewRepresentable + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIView(context: Context) -> UIView { + context.coordinator.view + } + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.parent = self + context.coordinator.isPresented = isPresented + } + + final class Coordinator: NSObject { + init(parent: Presenter) { + self.parent = parent + } + + let view = UIView() + var parent: Presenter + + var isPresented: Bool = false { + didSet { + if isPresented { + start() + } else { + session?.cancel() + } + } + } + + private lazy var contextProvider = ContextProvider(coordinator: self) + + private weak var session: ASWebAuthenticationSession? + + private func start() { + let representation = parent.build() + let session = ASWebAuthenticationSession( + url: representation.url, + callbackURLScheme: representation.callbackURLScheme + ) { callback, error in + self.parent.isPresented = false + if let callback { + representation.completionHandler(.success(callback)) + } else if let error { + representation.completionHandler(.failure(error)) + } else { + representation.completionHandler(.failure(UnknownError())) + } + } + + session.presentationContextProvider = contextProvider + session.prefersEphemeralWebBrowserSession = parent.prefersEphemeralWebBrowserSession + + self.session = session + } + + private final class ContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + + // MARK: - Initializers + + init(coordinator: Coordinator) { + self.coordinator = coordinator + } + + // MARK: - API + + unowned var coordinator: Coordinator + + // MARK: - ASWebAuthenticationPresentationContextProviding + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + coordinator.view.window ?? ASPresentationAnchor() + } + } + } + } + } + + struct IdentifiableItemModitifer: ViewModifier where Item: Identifiable { + + // MARK: - API + + @Binding + var item: Item? + + let build: (Item) -> WebAuthentication + + // MARK: - ViewModifier + + @ViewBuilder + func body(content: Content) -> some View { + content + .background( + Presenter( + item: $item, + prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession, + build: build + ) + ) + } + + // MARK: - Private + + @Environment(\.webAuthenticationPrefersEphemeralWebBrowserSession) + private var prefersEphemeralWebBrowserSession: Bool + + private struct Presenter: UIViewRepresentable { + + // MARK: - API + + @Binding + var item: Item? + + let prefersEphemeralWebBrowserSession: Bool + let build: (Item) -> WebAuthentication + + // MARK: - UIViewRepresentable + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIView(context: Context) -> UIView { + context.coordinator.view + } + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.parent = self + context.coordinator.item = item + } + + final class Coordinator: NSObject { + + // MARK: - Initializers + + init(parent: Presenter) { + self.parent = parent + } + + // MARK: - API + + let view = UIView() + var parent: Presenter + + var item: Item? { + didSet { + switch (oldValue, item) { + case (.none, .none): + break + case let (.none, .some(new)): + start(new) + case (.some, .some): + break + case (.some, .none): + session?.cancel() + } + } + } + + // MARK: - Private + + private lazy var contextProvider = ContextProvider(coordinator: self) + + private weak var session: ASWebAuthenticationSession? + + private func start(_ item: Item) { + let representation = parent.build(item) + let session = ASWebAuthenticationSession( + url: representation.url, + callbackURLScheme: representation.callbackURLScheme + ) { callback, error in + self.parent.item = nil + if let callback { + representation.completionHandler(.success(callback)) + } else if let error { + representation.completionHandler(.failure(error)) + } else { + representation.completionHandler(.failure(UnknownError())) + } + } + + session.presentationContextProvider = contextProvider + session.prefersEphemeralWebBrowserSession = parent.prefersEphemeralWebBrowserSession + + self.session = session + } + + private final class ContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + + // MARK: - Initializers + + init(coordinator: Coordinator) { + self.coordinator = coordinator + } + + // MARK: - API + + unowned var coordinator: Coordinator + + // MARK: - ASWebAuthenticationPresentationContextProviding + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + coordinator.view.window ?? ASPresentationAnchor() + } + } + } + } + } + + struct ItemModifier: ViewModifier where Identifier: Hashable { + + // MARK: - Initializers + + @Binding + var item: Item? + + let id: KeyPath + let build: (Item) -> WebAuthentication + + // MARK: - ViewModifier + + @ViewBuilder + func body(content: Content) -> some View { + content + .webAuthentication(wrapped) { item in + build(item.wrapped) + } + } + + // MARK: - Private + + private struct WrappedItem: Identifiable { + let wrapped: Item + let path: KeyPath + var id: Identifier { wrapped[keyPath: path] } + } + + private var wrapped: Binding { + Binding { + item.map(wrap) + } set: { newValue in + item = newValue?.wrapped + } + } + + private func wrap(_ item: Item) -> WrappedItem { + .init(wrapped: item, path: id) + } + } +} + +private struct UnknownError: Error {}