crossmate

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

DriveMonitor.swift (11502B)


      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 = CloudContainer.originalIdentifier
     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                     result = try PuzzleFileReader.readSource(at: readURL)
     95                 } catch {
     96                     innerError = error
     97                 }
     98             }
     99 
    100             if let readError { throw readError }
    101             if let innerError { throw innerError }
    102             guard let result else { throw CocoaError(.fileReadUnknown) }
    103             return result
    104         } catch {
    105             throw DriveError.readFailed(url, error)
    106         }
    107     }
    108 
    109 #if DEBUG
    110     /// Injects a fixed set of imported files so the marketing "Imported" tab
    111     /// renders the real `ImportedBrowseView` without a live iCloud container
    112     /// (a fresh screenshot simulator has none, so the real query would leave
    113     /// the tab in its empty state). DEBUG-only, used by the import marketing
    114     /// scene.
    115     func seedMarketingImports() {
    116         let names = ["Sunday Sample.xd", "Tournament Pack.puz", "Cryptic Practice.xd"]
    117         let base = URL(fileURLWithPath: "/marketing", isDirectory: true)
    118         let children = names.map { name -> DriveItem in
    119             let url = base.appendingPathComponent(name)
    120             return DriveItem(
    121                 id: url,
    122                 name: name,
    123                 url: url,
    124                 isDirectory: false,
    125                 isDownloaded: true,
    126                 children: []
    127             )
    128         }
    129         containerAvailable = true
    130         root = DriveItem(
    131             id: base,
    132             name: "Crossmate",
    133             url: base,
    134             isDirectory: true,
    135             isDownloaded: true,
    136             children: children
    137         )
    138     }
    139 #endif
    140 
    141     func importFile(from sourceURL: URL) throws {
    142         guard let documentsURL else { throw DriveError.containerUnavailable }
    143 
    144         let needsScopedAccess = sourceURL.startAccessingSecurityScopedResource()
    145         defer {
    146             if needsScopedAccess {
    147                 sourceURL.stopAccessingSecurityScopedResource()
    148             }
    149         }
    150 
    151         let destination = uniqueDestination(
    152             for: sourceURL.lastPathComponent,
    153             in: documentsURL
    154         )
    155 
    156         do {
    157             let coordinator = NSFileCoordinator()
    158             var coordError: NSError?
    159             var innerError: Error?
    160 
    161             coordinator.coordinate(
    162                 readingItemAt: sourceURL,
    163                 options: [.withoutChanges],
    164                 writingItemAt: destination,
    165                 options: [.forReplacing],
    166                 error: &coordError
    167             ) { readURL, writeURL in
    168                 do {
    169                     // A picked file is untrusted: bound and parse it before it
    170                     // lands in the user-visible iCloud folder, rather than
    171                     // copying an arbitrary blob and discovering the problem on
    172                     // open.
    173                     _ = try XD.parse(PuzzleFileReader.readSource(at: readURL))
    174                     try FileManager.default.copyItem(at: readURL, to: writeURL)
    175                 } catch {
    176                     innerError = error
    177                 }
    178             }
    179 
    180             if let coordError { throw coordError }
    181             if let innerError { throw innerError }
    182         } catch {
    183             throw DriveError.importFailed(sourceURL, error)
    184         }
    185     }
    186 
    187     // MARK: - Private
    188 
    189     private func resolveContainer() {
    190         guard let container = FileManager.default.url(forUbiquityContainerIdentifier: containerID) else {
    191             containerAvailable = false
    192             return
    193         }
    194         let documents = container.appendingPathComponent("Documents", isDirectory: true)
    195         if !FileManager.default.fileExists(atPath: documents.path) {
    196             try? FileManager.default.createDirectory(
    197                 at: documents,
    198                 withIntermediateDirectories: true
    199             )
    200         }
    201         ensurePlaceholder(in: documents)
    202         self.documentsURL = documents
    203         self.containerAvailable = true
    204     }
    205 
    206     private func ensurePlaceholder(in documents: URL) {
    207         let readme = documents.appendingPathComponent("README.txt")
    208         guard !FileManager.default.fileExists(atPath: readme.path) else { return }
    209         let body = """
    210         Drop .xd or .puz crossword files into this folder to import them into Crossmate.
    211 
    212         Crossmate will list any .xd or .puz files you place here (including inside subfolders) in the Imported tab of the New Game sheet.
    213         """
    214         try? body.data(using: .utf8)?.write(to: readme, options: .atomic)
    215     }
    216 
    217     private func uniqueDestination(for filename: String, in directory: URL) -> URL {
    218         let candidate = directory.appendingPathComponent(filename)
    219         if !FileManager.default.fileExists(atPath: candidate.path) {
    220             return candidate
    221         }
    222         let base = (filename as NSString).deletingPathExtension
    223         let ext = (filename as NSString).pathExtension
    224         var index = 2
    225         while true {
    226             let nextName = ext.isEmpty ? "\(base) \(index)" : "\(base) \(index).\(ext)"
    227             let next = directory.appendingPathComponent(nextName)
    228             if !FileManager.default.fileExists(atPath: next.path) {
    229                 return next
    230             }
    231             index += 1
    232         }
    233     }
    234 
    235     private func rebuildTree() {
    236         guard let documentsURL else { return }
    237 
    238         query.disableUpdates()
    239         defer { query.enableUpdates() }
    240 
    241         var files: [(url: URL, downloaded: Bool)] = []
    242         for i in 0..<query.resultCount {
    243             guard let item = query.result(at: i) as? NSMetadataItem,
    244                   let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL
    245             else { continue }
    246             let status = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String
    247             let downloaded = status == NSMetadataUbiquitousItemDownloadingStatusCurrent
    248                 || status == NSMetadataUbiquitousItemDownloadingStatusDownloaded
    249             files.append((url: url.standardizedFileURL, downloaded: downloaded))
    250         }
    251 
    252         self.root = buildTree(documentsURL: documentsURL.standardizedFileURL, files: files)
    253     }
    254 
    255     private func buildTree(documentsURL: URL, files: [(url: URL, downloaded: Bool)]) -> DriveItem {
    256         let docComponents = documentsURL.pathComponents
    257 
    258         final class Node {
    259             var children: [String: Node] = [:]
    260             var files: [(url: URL, downloaded: Bool)] = []
    261         }
    262 
    263         let root = Node()
    264         for file in files {
    265             let components = file.url.pathComponents
    266             guard components.count > docComponents.count,
    267                   Array(components.prefix(docComponents.count)) == docComponents
    268             else { continue }
    269             let dirs = components.dropFirst(docComponents.count).dropLast()
    270 
    271             var current = root
    272             for dir in dirs {
    273                 if let next = current.children[dir] {
    274                     current = next
    275                 } else {
    276                     let next = Node()
    277                     current.children[dir] = next
    278                     current = next
    279                 }
    280             }
    281             current.files.append(file)
    282         }
    283 
    284         func materialize(node: Node, url: URL, name: String) -> DriveItem {
    285             let folderChildren: [DriveItem] = node.children
    286                 .sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }
    287                 .map { key, childNode in
    288                     let childURL = url.appendingPathComponent(key, isDirectory: true)
    289                     return materialize(node: childNode, url: childURL, name: key)
    290                 }
    291             let fileChildren: [DriveItem] = node.files
    292                 .sorted {
    293                     $0.url.lastPathComponent.localizedCaseInsensitiveCompare($1.url.lastPathComponent) == .orderedAscending
    294                 }
    295                 .map { file in
    296                     DriveItem(
    297                         id: file.url,
    298                         name: file.url.deletingPathExtension().lastPathComponent,
    299                         url: file.url,
    300                         isDirectory: false,
    301                         isDownloaded: file.downloaded,
    302                         children: []
    303                     )
    304                 }
    305             return DriveItem(
    306                 id: url,
    307                 name: name,
    308                 url: url,
    309                 isDirectory: true,
    310                 isDownloaded: true,
    311                 children: folderChildren + fileChildren
    312             )
    313         }
    314 
    315         return materialize(node: root, url: documentsURL, name: documentsURL.lastPathComponent)
    316     }
    317 }