// // V2LoginView.swift // Nextcloud Cookbook iOS Client // // Created by Vincent Meilinger on 21.11.23. // import Foundation import SwiftUI import WebKit import AuthenticationServices protocol LoginStage { func next() -> Self func previous() -> Self } enum V2LoginStage: LoginStage { case login, validate func next() -> V2LoginStage { switch self { case .login: return .validate case .validate: return .validate } } func previous() -> V2LoginStage { switch self { case .login: return .login case .validate: return .login } } } struct V2LoginView: View { @Environment(\.dismiss) var dismiss @State var showAlert: Bool = false @State var alertMessage: String = "" @State var loginStage: V2LoginStage = .login @State var loginRequest: LoginV2Request? = nil @State var presentBrowser = false @State var serverAddress: String = "" @State var serverProtocol: ServerProtocol = .https @State var loginPressed: Bool = false @State var isLoading: Bool = false // Task reference for polling, to cancel if needed @State private var pollTask: Task? = nil enum ServerProtocol: String { case https="https://", http="http://" static let all = [https, http] } // TextField handling enum Field { case server case username case token } var body: some View { VStack { HStack { Button("Cancel") { dismiss() } Spacer() if isLoading { ProgressView() } }.padding() Form { Section { HStack { Text("Server address:") TextField("example.com", text: $serverAddress) .multilineTextAlignment(.trailing) .autocorrectionDisabled() .textInputAutocapitalization(.never) } Picker("Server Protocol:", selection: $serverProtocol) { ForEach(ServerProtocol.all, id: \.self) { Text($0.rawValue) } } HStack { Button("Login") { initiateLoginV2() } Spacer() Text(serverProtocol.rawValue + serverAddress.trimmingCharacters(in: .whitespacesAndNewlines)) .foregroundStyle(Color.secondary) } } header: { Text("Nextcloud Login") } footer: { Text( """ The 'Login' button will open a web browser. Please follow the login instructions provided there. After a successful login, return to this application and press 'Validate'. If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually. """ ) }.disabled(loginPressed) if let loginRequest = loginRequest { Section { Text(loginRequest.login) .font(.caption) .foregroundStyle(.secondary) Button("Copy Link") { UIPasteboard.general.string = loginRequest.login } } footer: { Text("If your browser does not open automatically, copy the link above and paste it manually. After a successful login, return to this application.") } } } } .sheet(isPresented: $presentBrowser) { if let loginReq = loginRequest { LoginBrowserView(authURL: URL(string: loginReq.login) ?? URL(string: "")!, callbackURLScheme: "nc") { result in switch result { case .success(let url): print("Login completed with URL: \(url)") dismiss() case .failure(let error): print("Login failed: \(error.localizedDescription)") self.alertMessage = error.localizedDescription self.isLoading = false self.loginPressed = false self.showAlert = true } } } else { Text("Error: Login URL not available.") } } .alert("Error", isPresented: $showAlert) { Button("Copy Error") { print("Error copied: \(alertMessage)") UIPasteboard.general.string = alertMessage isLoading = false loginPressed = false } Button("Dismiss") { print("Error dismissed.") isLoading = false loginPressed = false } } message: { Text(alertMessage) } } func initiateLoginV2() { isLoading = true loginPressed = true Task { let baseAddress = serverProtocol.rawValue + serverAddress.trimmingCharacters(in: .whitespacesAndNewlines) let (req, error) = await NextcloudApi.loginV2Request(baseAddress) if let error = error { self.alertMessage = error.localizedDescription self.showAlert = true self.isLoading = false self.loginPressed = false return } guard let req = req else { self.alertMessage = "Failed to get login URL from server." self.showAlert = true self.isLoading = false self.loginPressed = false return } self.loginRequest = req // Present the browser session presentBrowser = true // Start polling in a separate task startPolling(pollURL: req.poll.endpoint, pollToken: req.poll.token) } } func startPolling(pollURL: String, pollToken: String) { // Cancel any existing poll task first pollTask?.cancel() var pollingFailed = true pollTask = Task { let maxRetries = 60 * 10 // Poll for up to 60 * 1 second = 1 minute for _ in 0..) -> Void func makeUIViewController(context: Context) -> UIViewController { UIViewController() } func updateUIViewController(_ uiViewController: UIViewController, context: Context) { if !context.coordinator.sessionStarted { context.coordinator.sessionStarted = true let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: callbackURLScheme) { callbackURL, error in context.coordinator.sessionStarted = false // Reset for potential retry if let callbackURL = callbackURL { completion(.success(callbackURL)) } else if let error = error { completion(.failure(error)) } else { // Handle unexpected nil URL and error completion(.failure(LoginError.unknownError)) } } session.presentationContextProvider = context.coordinator session.prefersEphemeralWebBrowserSession = false session.start() } } // MARK: - Coordinator for ASWebAuthenticationPresentationContextProviding func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, ASWebAuthenticationPresentationContextProviding { var parent: LoginBrowserView var sessionStarted: Bool = false // Prevent starting multiple sessions init(_ parent: LoginBrowserView) { self.parent = parent } func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { if let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene { return windowScene.windows.first! } return ASPresentationAnchor() } } enum LoginError: Error, LocalizedError { case unknownError var errorDescription: String? { switch self { case .unknownError: return "An unknown error occurred during login." } } } } #Preview { V2LoginView() }