From acfa573149b19aacc2697984b0828334da89920d Mon Sep 17 00:00:00 2001 From: Thomas Dhooghe <61279337+tdhooghe@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:51:10 +0100 Subject: [PATCH 1/2] fix(iOS): use ASWebAuthenticationSession instead of WKWebView for login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WKWebView silently fails to complete cross-domain OIDC redirects (e.g. when Nextcloud delegates authentication to an external IdP like Authentik). The user authenticates successfully on the IdP side, but WKWebView drops the callback redirect back to the Nextcloud origin, leaving the login flow stuck in a polling loop that never resolves. Replace WKWebView with ASWebAuthenticationSession on iOS via a new LoginSheet view modifier that encapsulates the platform difference: - iOS: ASWebAuthenticationSession (system browser, handles OIDC/passkeys) - macOS: WKWebView sheet (unchanged behavior) ServerAddressView is now platform-agnostic — it just sets isPresented and the modifier does the right thing per platform. Credentials continue to be obtained via the host app's existing polling mechanism. Ref: nextcloud/ios#3996 (same fix applied to the main iOS app) Signed-off-by: Thomas Dhooghe <61279337+tdhooghe@users.noreply.github.com> --- .../Modifiers/LoginSheet.swift | 111 ++++++++++++++++++ .../Views/ServerAddressView.swift | 8 +- 2 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift diff --git a/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift b/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift new file mode 100644 index 0000000..f705c9c --- /dev/null +++ b/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 tdhooghe +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +#if os(iOS) +import AuthenticationServices +#endif + +/// +/// Presents the Nextcloud Login Flow v2 authentication UI. +/// +/// On **iOS**, this uses `ASWebAuthenticationSession` which opens a system browser sheet +/// that properly handles cross-domain OIDC redirects, passkeys, and deep links. +/// +/// On **macOS**, this presents a ``WebView`` in a sheet. +/// +/// Credentials are obtained via the host app's polling mechanism on both platforms. +/// +struct LoginSheet: ViewModifier { + let userAgent: String? + let onDismiss: () -> Void + + @Binding var loginURL: URL? + @Binding var isPresented: Bool + + #if os(iOS) + @State private var authSession: ASWebAuthenticationSession? + @State private var sessionCoordinator = SessionCoordinator() + #endif + + init(loginURL: Binding, isPresented: Binding, userAgent: String?, onDismiss: @escaping () -> Void) { + self._loginURL = loginURL + self._isPresented = isPresented + self.userAgent = userAgent + self.onDismiss = onDismiss + } + + func body(content: Content) -> some View { + content + #if os(macOS) + .sheet(isPresented: $isPresented, onDismiss: onDismiss) { + WebView(initialURL: $loginURL, userAgent: userAgent) + .ignoresSafeArea() + .frame(minWidth: 800, minHeight: 800) + } + #else + .onChange(of: isPresented) { _, presented in + if presented, let url = loginURL { + startAuthSession(url: url) + } else { + authSession?.cancel() + authSession = nil + } + } + #endif + } + + #if os(iOS) + + private func startAuthSession(url: URL) { + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "nc") { _, error in + authSession = nil + if let error = error as? ASWebAuthenticationSessionError, error.code == .canceledLogin { + onDismiss() + } + } + + session.presentationContextProvider = sessionCoordinator + session.prefersEphemeralWebBrowserSession = true + authSession = session + session.start() + } + + #endif +} + +extension View { + /// + /// Present the login authentication UI appropriate for the current platform. + /// + /// See ``LoginSheet`` for the implementation. + /// + func loginSheet(loginURL: Binding, isPresented: Binding, userAgent: String?, onDismiss: @escaping () -> Void) -> some View { + modifier(LoginSheet(loginURL: loginURL, isPresented: isPresented, userAgent: userAgent, onDismiss: onDismiss)) + } +} + +// MARK: - ASWebAuthenticationSession Coordinator + +#if os(iOS) + +/// +/// Provides the presentation anchor for `ASWebAuthenticationSession` in SwiftUI contexts. +/// +@MainActor +private class SessionCoordinator: NSObject, ASWebAuthenticationPresentationContextProviding { + nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return MainActor.assumeIsolated { + guard let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, + let window = windowScene.windows.first(where: \.isKeyWindow) + else { + return ASPresentationAnchor() + } + return window + } + } +} + +#endif diff --git a/Sources/SwiftNextcloudUI/Views/ServerAddressView.swift b/Sources/SwiftNextcloudUI/Views/ServerAddressView.swift index 272b893..79fb90c 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, userAgent: userAgent, 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 { From a51116fb4df4cf8801de0909f118ae52ef6c58a9 Mon Sep 17 00:00:00 2001 From: Thomas Dhooghe <61279337+tdhooghe@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:31:21 +0100 Subject: [PATCH 2/2] fix(lint): match project file header pattern and use implicit return Signed-off-by: Thomas Dhooghe <61279337+tdhooghe@users.noreply.github.com> Made-with: Cursor --- Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift b/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift index f705c9c..5fd6d26 100644 --- a/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift +++ b/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2026 tdhooghe +// SPDX-FileCopyrightText: 2025 tdhooghe // SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI @@ -97,7 +97,7 @@ extension View { @MainActor private class SessionCoordinator: NSObject, ASWebAuthenticationPresentationContextProviding { nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - return MainActor.assumeIsolated { + MainActor.assumeIsolated { guard let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, let window = windowScene.windows.first(where: \.isKeyWindow) else {