crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

DriveMonitor.swift (10374B)


      1 import Foundation
      2 import Observation
      3 
      4 struct DriveItem: Identifiable, Hashable {
      5     let id: URL
      6     let name: String
      7     let url: URL
      8     let isDirectory: Bool
      9     let isDownloaded: Bool
     10     let children: [DriveItem]
     11 
     12     var fileExtension: String { url.pathExtension.lowercased() }
     13 }
     14 
     15 enum DriveError: LocalizedError {
     16     case containerUnavailable
     17     case readFailed(URL, Error)
     18     case importFailed(URL, Error)
     19 
     20     var errorDescription: String? {
     21         switch self {
     22         case .containerUnavailable:
     23             "iCloud Drive is not available. Make sure you're signed in to iCloud and iCloud Drive is enabled."
     24         case .readFailed(let url, let error):
     25             "Couldn't read \(url.lastPathComponent): \(error.localizedDescription)"
     26         case .importFailed(let url, let error):
     27             "Couldn't import \(url.lastPathComponent): \(error.localizedDescription)"
     28         }
     29     }
     30 }
     31 
     32 @MainActor
     33 @Observable
     34 final class DriveMonitor {
     35     private(set) var root: DriveItem?
     36     private(set) var containerAvailable: Bool = false
     37 
     38     private let containerID = "iCloud.net.inqk.crossmate"
     39     private var documentsURL: URL?
     40     private let query = NSMetadataQuery()
     41     private var observers: [NSObjectProtocol] = []
     42 
     43     init() {
     44         resolveContainer()
     45     }
     46 
     47     func start() {
     48         guard containerAvailable, !query.isStarted else { return }
     49 
     50         query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
     51         query.predicate = NSCompoundPredicate(orPredicateWithSubpredicates: [
     52             NSPredicate(format: "%K LIKE[c] %@", NSMetadataItemFSNameKey, "*.xd"),
     53             NSPredicate(format: "%K LIKE[c] %@", NSMetadataItemFSNameKey, "*.puz")
     54         ])
     55         query.sortDescriptors = [NSSortDescriptor(key: NSMetadataItemFSNameKey, ascending: true)]
     56 
     57         let center = NotificationCenter.default
     58         let handler: @Sendable (Notification) -> Void = { [weak self] _ in
     59             Task { @MainActor in
     60                 self?.rebuildTree()
     61             }
     62         }
     63 
     64         observers.append(center.addObserver(
     65             forName: .NSMetadataQueryDidFinishGathering,
     66             object: query,
     67             queue: .main,
     68             using: handler
     69         ))
     70         observers.append(center.addObserver(
     71             forName: .NSMetadataQueryDidUpdate,
     72             object: query,
     73             queue: .main,
     74             using: handler
     75         ))
     76 
     77         query.start()
     78     }
     79 
     80     func startDownloading(_ item: DriveItem) {
     81         guard !item.isDownloaded else { return }
     82         try? FileManager.default.startDownloadingUbiquitousItem(at: item.url)
     83     }
     84 
     85     func readSource(at url: URL) throws -> String {
     86         do {
     87             let coordinator = NSFileCoordinator()
     88             var readError: NSError?
     89             var result: String?
     90             var innerError: Error?
     91 
     92             coordinator.coordinate(readingItemAt: url, options: [], error: &readError) { readURL in
     93                 do {
     94                     switch readURL.pathExtension.lowercased() {
     95                     case "xd":
     96                         result = try String(contentsOf: readURL, encoding: .utf8)
     97                     case "puz":
     98                         result = try PUZToXDConverter.convert(puzData: Data(contentsOf: readURL))
     99                     default:
    100                         throw CocoaError(.fileReadUnsupportedScheme)
    101                     }
    102                 } catch {
    103                     innerError = error
    104                 }
    105             }
    106 
    107             if let readError { throw readError }
    108             if let innerError { throw innerError }
    109             guard let result else { throw CocoaError(.fileReadUnknown) }
    110             return result
    111         } catch {
    112             throw DriveError.readFailed(url, error)
    113         }
    114     }
    115 
    116     func importFile(from sourceURL: URL) throws {
    117         guard let documentsURL else { throw DriveError.containerUnavailable }
    118 
    119         let needsScopedAccess = sourceURL.startAccessingSecurityScopedResource()
    120         defer {
    121             if needsScopedAccess {
    122                 sourceURL.stopAccessingSecurityScopedResource()
    123             }
    124         }
    125 
    126         let destination = uniqueDestination(
    127             for: sourceURL.lastPathComponent,
    128             in: documentsURL
    129         )
    130 
    131         do {
    132             let coordinator = NSFileCoordinator()
    133             var coordError: NSError?
    134             var innerError: Error?
    135 
    136             coordinator.coordinate(
    137                 readingItemAt: sourceURL,
    138                 options: [.withoutChanges],
    139                 writingItemAt: destination,
    140                 options: [.forReplacing],
    141                 error: &coordError
    142             ) { readURL, writeURL in
    143                 do {
    144                     try FileManager.default.copyItem(at: readURL, to: writeURL)
    145                 } catch {
    146                     innerError = error
    147                 }
    148             }
    149 
    150             if let coordError { throw coordError }
    151             if let innerError { throw innerError }
    152         } catch {
    153             throw DriveError.importFailed(sourceURL, error)
    154         }
    155     }
    156 
    157     // MARK: - Private
    158 
    159     private func resolveContainer() {
    160         guard let container = FileManager.default.url(forUbiquityContainerIdentifier: containerID) else {
    161             containerAvailable = false
    162             return
    163         }
    164         let documents = container.appendingPathComponent("Documents", isDirectory: true)
    165         if !FileManager.default.fileExists(atPath: documents.path) {
    166             try? FileManager.default.createDirectory(
    167                 at: documents,
    168                 withIntermediateDirectories: true
    169             )
    170         }
    171         ensurePlaceholder(in: documents)
    172         self.documentsURL = documents
    173         self.containerAvailable = true
    174     }
    175 
    176     private func ensurePlaceholder(in documents: URL) {
    177         let readme = documents.appendingPathComponent("README.txt")
    178         guard !FileManager.default.fileExists(atPath: readme.path) else { return }
    179         let body = """
    180         Drop .xd or .puz crossword files into this folder to import them into Crossmate.
    181 
    182         Crossmate will list any .xd or .puz files you place here (including inside subfolders) in the Imported tab of the New Game sheet.
    183         """
    184         try? body.data(using: .utf8)?.write(to: readme, options: .atomic)
    185     }
    186 
    187     private func uniqueDestination(for filename: String, in directory: URL) -> URL {
    188         let candidate = directory.appendingPathComponent(filename)
    189         if !FileManager.default.fileExists(atPath: candidate.path) {
    190             return candidate
    191         }
    192         let base = (filename as NSString).deletingPathExtension
    193         let ext = (filename as NSString).pathExtension
    194         var index = 2
    195         while true {
    196             let nextName = ext.isEmpty ? "\(base) \(index)" : "\(base) \(index).\(ext)"
    197             let next = directory.appendingPathComponent(nextName)
    198             if !FileManager.default.fileExists(atPath: next.path) {
    199                 return next
    200             }
    201             index += 1
    202         }
    203     }
    204 
    205     private func rebuildTree() {
    206         guard let documentsURL else { return }
    207 
    208         query.disableUpdates()
    209         defer { query.enableUpdates() }
    210 
    211         var files: [(url: URL, downloaded: Bool)] = []
    212         for i in 0..<query.resultCount {
    213             guard let item = query.result(at: i) as? NSMetadataItem,
    214                   let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL
    215             else { continue }
    216             let status = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String
    217             let downloaded = status == NSMetadataUbiquitousItemDownloadingStatusCurrent
    218                 || status == NSMetadataUbiquitousItemDownloadingStatusDownloaded
    219             files.append((url: url.standardizedFileURL, downloaded: downloaded))
    220         }
    221 
    222         self.root = buildTree(documentsURL: documentsURL.standardizedFileURL, files: files)
    223     }
    224 
    225     private func buildTree(documentsURL: URL, files: [(url: URL, downloaded: Bool)]) -> DriveItem {
    226         let docComponents = documentsURL.pathComponents
    227 
    228         final class Node {
    229             var children: [String: Node] = [:]
    230             var files: [(url: URL, downloaded: Bool)] = []
    231         }
    232 
    233         let root = Node()
    234         for file in files {
    235             let components = file.url.pathComponents
    236             guard components.count > docComponents.count,
    237                   Array(components.prefix(docComponents.count)) == docComponents
    238             else { continue }
    239             let dirs = components.dropFirst(docComponents.count).dropLast()
    240 
    241             var current = root
    242             for dir in dirs {
    243                 if let next = current.children[dir] {
    244                     current = next
    245                 } else {
    246                     let next = Node()
    247                     current.children[dir] = next
    248                     current = next
    249                 }
    250             }
    251             current.files.append(file)
    252         }
    253 
    254         func materialize(node: Node, url: URL, name: String) -> DriveItem {
    255             let folderChildren: [DriveItem] = node.children
    256                 .sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }
    257                 .map { key, childNode in
    258                     let childURL = url.appendingPathComponent(key, isDirectory: true)
    259                     return materialize(node: childNode, url: childURL, name: key)
    260                 }
    261             let fileChildren: [DriveItem] = node.files
    262                 .sorted {
    263                     $0.url.lastPathComponent.localizedCaseInsensitiveCompare($1.url.lastPathComponent) == .orderedAscending
    264                 }
    265                 .map { file in
    266                     DriveItem(
    267                         id: file.url,
    268                         name: file.url.deletingPathExtension().lastPathComponent,
    269                         url: file.url,
    270                         isDirectory: false,
    271                         isDownloaded: file.downloaded,
    272                         children: []
    273                     )
    274                 }
    275             return DriveItem(
    276                 id: url,
    277                 name: name,
    278                 url: url,
    279                 isDirectory: true,
    280                 isDownloaded: true,
    281                 children: folderChildren + fileChildren
    282             )
    283         }
    284 
    285         return materialize(node: root, url: documentsURL, name: documentsURL.lastPathComponent)
    286     }
    287 }