diff --git a/Apps/iOS/Views/ConfigDocumentPicker.swift b/Apps/iOS/Views/ConfigDocumentPicker.swift new file mode 100644 index 0000000..3a68471 --- /dev/null +++ b/Apps/iOS/Views/ConfigDocumentPicker.swift @@ -0,0 +1,153 @@ +// +// ConfigDocumentPicker.swift +// CellTunnelPhone +// +// Created by Alexander Goodkind on 2026-07-23. +// Copyright © 2026, all rights reserved. +// + +#if targetEnvironment(macCatalyst) + import SwiftUI + import UniformTypeIdentifiers + import UIKit + + // MARK: - ConfigDocumentPicker + + struct ConfigDocumentPicker: UIViewControllerRepresentable { + struct Request { + let id: UUID + let allowedContentTypes: [UTType] + } + + let request: Request? + let completion: (UUID, Result<[URL], Error>) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIViewController(context: Context) -> UIViewController { + let hostController = UIViewController() + context.coordinator.attach(hostController: hostController) + return hostController + } + + func updateUIViewController( + _: UIViewController, + context: Context + ) { + context.coordinator.update(request: request, completion: completion) + } + + // MARK: - Coordinator + + @MainActor + final class Coordinator: NSObject, UIDocumentPickerDelegate, + UIAdaptivePresentationControllerDelegate + { + private var activePicker: UIDocumentPickerViewController? + private var activeRequestID: UUID? + private var completion: ((UUID, Result<[URL], Error>) -> Void)? + private var hostController: UIViewController? + private var pendingResult: Result<[URL], Error>? + + func attach(hostController: UIViewController) { + self.hostController = hostController + } + + func update( + request: Request?, + completion: @escaping (UUID, Result<[URL], Error>) -> Void + ) { + self.completion = completion + guard let request, activePicker == nil else { + return + } + presentPicker(for: request) + } + + func documentPicker( + _ controller: UIDocumentPickerViewController, + didPickDocumentsAt urls: [URL] + ) { + dismiss(controller, with: .success(urls)) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + dismiss(controller, with: .success([])) + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + guard + pendingResult == nil, + let picker = presentationController.presentedViewController + as? UIDocumentPickerViewController, + isActivePicker(picker) + else { + return + } + pendingResult = .success([]) + finishPendingResult(for: picker) + } + + private func dismiss( + _ picker: UIDocumentPickerViewController, + with result: Result<[URL], Error> + ) { + guard + isActivePicker(picker), + pendingResult == nil, + let hostController + else { + return + } + pendingResult = result + hostController.dismiss(animated: true) { [weak self, weak picker] in + guard let picker else { + return + } + self?.finishPendingResult(for: picker) + } + } + + private func presentPicker(for request: Request) { + guard let hostController, hostController.presentedViewController == nil else { + return + } + let picker = UIDocumentPickerViewController( + forOpeningContentTypes: request.allowedContentTypes, + asCopy: true + ) + picker.allowsMultipleSelection = false + picker.delegate = self + activePicker = picker + activeRequestID = request.id + pendingResult = nil + hostController.present(picker, animated: true) + picker.presentationController?.delegate = self + } + + private func isActivePicker(_ controller: UIDocumentPickerViewController) -> Bool { + activePicker === controller + } + + private func finishPendingResult(for picker: UIDocumentPickerViewController) { + guard + isActivePicker(picker), + let activeRequestID, + let completion, + let pendingResult + else { + return + } + activePicker?.presentationController?.delegate = nil + activePicker = nil + self.activeRequestID = nil + self.completion = nil + self.pendingResult = nil + completion(activeRequestID, pendingResult) + } + } + } + +#endif diff --git a/Apps/iOS/Views/ConfigLibraryView.swift b/Apps/iOS/Views/ConfigLibraryView.swift index cb90b33..35f7910 100644 --- a/Apps/iOS/Views/ConfigLibraryView.swift +++ b/Apps/iOS/Views/ConfigLibraryView.swift @@ -55,6 +55,7 @@ @Environment(RelayController.self) private var controller @State private var presentation = ConfigLibraryPresentation.idle @State private var activeImportRequestID: UUID? + @State private var pickerRequest: ConfigDocumentPicker.Request? @State private var renameText = "" // MARK: - Body @@ -65,6 +66,14 @@ actions } .frame(maxWidth: .infinity, alignment: .leading) + .background { + ConfigDocumentPicker( + request: pickerRequest, + completion: handlePickerCompletion + ) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + } .sheet(item: editorPresentationBinding) { editorPresentation in editor(for: editorPresentation) } @@ -162,18 +171,18 @@ HStack(spacing: configLibraryActionSpacing) { Spacer(minLength: 0) Button(configLibraryImportTitle) { + guard pickerRequest == nil, activeImportRequestID == nil else { + return + } presentation.presentImport() + pickerRequest = ConfigDocumentPicker.Request( + id: UUID(), + allowedContentTypes: configLibraryContentTypes + ) } .buttonStyle(.bordered) - .disabled(activeImportRequestID != nil) + .disabled(pickerRequest != nil || activeImportRequestID != nil) .cellTunnelAccessibilityIdentifier(.importConfig) - .fileImporter( - isPresented: importPresentationBinding, - allowedContentTypes: configLibraryContentTypes, - allowsMultipleSelection: false - ) { result in - handleImport(result) - } Button(configLibraryNewTitle) { presentation.presentCreate() } @@ -200,35 +209,46 @@ // MARK: - Import - private func handleImport(_ result: Result<[URL], Error>) { + private func handlePickerCompletion( + requestID: UUID, + result: Result<[URL], Error> + ) { + guard pickerRequest?.id == requestID else { + return + } + pickerRequest = nil + presentation.completeImportSelection() switch result { case .success(let urls): - presentation.completeImportSelection() guard let url = urls.first else { return } - let name = url.deletingPathExtension().lastPathComponent - guard activeImportRequestID == nil else { - return - } - let requestID = UUID() - activeImportRequestID = requestID - Task { - do { - try await controller.importConfig(url: url, name: name) - completeImport(requestID: requestID, error: nil) - } catch { - configLibraryLogger.error( - """ - config library import failed \ - details=\(String(describing: error), privacy: .public) recovery=show-import-error - """ - ) - completeImport(requestID: requestID, error: error) - } - } + beginImport(url: url) case .failure(let error): - presentation.completeImport(.failure(error)) + presentation.completeImportFailure(message: error.localizedDescription) + } + } + + private func beginImport(url: URL) { + guard activeImportRequestID == nil else { + return + } + let requestID = UUID() + let name = url.deletingPathExtension().lastPathComponent + activeImportRequestID = requestID + Task { + do { + try await controller.importConfig(url: url, name: name) + completeImport(requestID: requestID, error: nil) + } catch { + configLibraryLogger.error( + """ + config library import failed \ + details=\(String(describing: error), privacy: .public) recovery=show-import-error + """ + ) + completeImport(requestID: requestID, error: error) + } } } @@ -257,19 +277,6 @@ ) } - private var importPresentationBinding: Binding { - Binding( - get: { presentation.isImporting }, - set: { isPresented in - if isPresented { - presentation.presentImport() - } else { - presentation.dismissImport() - } - } - ) - } - private var alertPresentationBinding: Binding { Binding( get: { presentation.alertPresentation != nil },