diff --git a/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift b/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift new file mode 100644 index 0000000..4a0bfae --- /dev/null +++ b/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 tdhooghe +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// +/// Presents the Nextcloud Login Flow v2 authentication UI using the system browser. +/// +/// Uses SwiftUI's `webAuthenticationSession`, which handles cross-domain OIDC redirects, +/// passkeys, and deep links on both iOS and macOS. +/// +/// Credentials are obtained via the host app's polling mechanism. The browser session has +/// no callback to complete, so it is cancelled once polling reports success (when +/// `isPresented` becomes `false`). +/// +struct LoginSheet: ViewModifier { + @Environment(\.webAuthenticationSession) private var webAuthenticationSession + + let onDismiss: () -> Void + + @Binding var loginURL: URL? + @Binding var isPresented: Bool + + @State private var authenticationTask: Task? + + init(loginURL: Binding, isPresented: Binding, onDismiss: @escaping () -> Void) { + self._loginURL = loginURL + self._isPresented = isPresented + self.onDismiss = onDismiss + } + + func body(content: Content) -> some View { + content.onChange(of: isPresented) { _, presented in + if presented, let loginURL { + startAuthentication(url: loginURL) + } else { + authenticationTask?.cancel() + authenticationTask = nil + } + } + } + + private func startAuthentication(url: URL) { + authenticationTask = Task { + defer { authenticationTask = nil } + do { + _ = try await webAuthenticationSession.authenticate( + using: url, + callbackURLScheme: "nc", + preferredBrowserSession: .ephemeral) + } catch { + if !Task.isCancelled { + onDismiss() + } + } + } + } +} + +extension View { + /// + /// Present the Nextcloud login authentication UI using the system browser. + /// + /// See ``LoginSheet`` for the implementation. + /// + func loginSheet(loginURL: Binding, isPresented: Binding, onDismiss: @escaping () -> Void) -> some View { + modifier(LoginSheet(loginURL: loginURL, isPresented: isPresented, onDismiss: onDismiss)) + } +} diff --git a/Sources/SwiftNextcloudUI/Views/ServerAddressView.swift b/Sources/SwiftNextcloudUI/Views/ServerAddressView.swift index 272b893..88c05eb 100644 --- a/Sources/SwiftNextcloudUI/Views/ServerAddressView.swift +++ b/Sources/SwiftNextcloudUI/Views/ServerAddressView.swift @@ -216,7 +216,7 @@ public struct ServerAddressView: View, QRCodeParsing, URLSanitizing { .safeAreaPadding(.all) } .ignoresSafeArea() - .webSheet(initialURL: $loginAddress, isPresented: $isPresentingWebView, userAgent: userAgent, onDismiss: endWebView) + .loginSheet(loginURL: $loginAddress, isPresented: $isPresentingWebView, onDismiss: endWebView) .alert(String(localized: "Login Failed", comment: "Alert title"), isPresented: $isPresentingAlert) { Button(role: .cancel) { errorMessage = nil @@ -294,12 +294,12 @@ public struct ServerAddressView: View, QRCodeParsing, URLSanitizing { } /// - /// Dismisses the sheet with the web view. + /// Dismisses the login UI. /// /// Multiple paths can lead to here. In example: /// - /// - The user dismisses the sheet without logging in. - /// - The login completed successfully. + /// - The user dismisses the session without logging in. + /// - The login completed successfully (detected by polling). /// func endWebView() { if let pollingToken {