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 }