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 }