Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions Apps/iOS/Views/ConfigDocumentPicker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//
// ConfigDocumentPicker.swift
// CellTunnelPhone
//
// Created by Alexander Goodkind <alex@goodkind.io> 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
95 changes: 51 additions & 44 deletions Apps/iOS/Views/ConfigLibraryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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()
}
Expand All @@ -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)
}
}
}

Expand Down Expand Up @@ -257,19 +277,6 @@
)
}

private var importPresentationBinding: Binding<Bool> {
Binding(
get: { presentation.isImporting },
set: { isPresented in
if isPresented {
presentation.presentImport()
} else {
presentation.dismissImport()
}
}
)
}

private var alertPresentationBinding: Binding<Bool> {
Binding(
get: { presentation.alertPresentation != nil },
Expand Down