crossmate

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

commit c2bc617cfc408c754003ca2cde67bbdb5b75fa11
parent c4534b5531f266f8db128efdde3006301629c0fd
Author: Michael Camilleri <[email protected]>
Date:   Fri, 17 Apr 2026 15:32:48 +0900

Rename UbiquityMonitor to DriveMonitor

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++----
MCrossmate/CrossmateApp.swift | 2+-
MCrossmate/Services/AppServices.swift | 10+++++-----
ACrossmate/Services/DriveMonitor.swift | 277+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DCrossmate/Services/UbiquityMonitor.swift | 277-------------------------------------------------------------------------------
MCrossmate/Views/ImportedBrowseView.swift | 14+++++++-------
6 files changed, 294 insertions(+), 294 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -19,7 +19,6 @@ 4A99624B75CDD821BF173621 /* OutboxRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13AB28AA016F8A3DF53E6AA /* OutboxRecorder.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; - 5AB52A0BA2934922EB94E5D1 /* UbiquityMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9876B49C8BE966CC2A37BF52 /* UbiquityMonitor.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; 6BFB5945FCCDEC64C431C2AC /* SyncDiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C90BA83B6DC7F435A7CF24 /* SyncDiagnosticsView.swift */; }; 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; @@ -32,6 +31,7 @@ 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; 9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; }; + 978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; }; 97D77230A98330DCB757FA81 /* sample.xd in Resources */ = {isa = PBXBuildFile; fileRef = 5C63A148D98E2D37EABF2CF5 /* sample.xd */; }; 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; }; 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; }; @@ -89,6 +89,7 @@ 5C63A148D98E2D37EABF2CF5 /* sample.xd */ = {isa = PBXFileReference; path = sample.xd; sourceTree = "<group>"; }; 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangeTests.swift; sourceTree = "<group>"; }; 64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; }; + 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveMonitor.swift; sourceTree = "<group>"; }; 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; }; 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; }; @@ -98,7 +99,6 @@ 927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CrossmateModel.xcdatamodel; sourceTree = "<group>"; }; 93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; }; 9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; - 9876B49C8BE966CC2A37BF52 /* UbiquityMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UbiquityMonitor.swift; sourceTree = "<group>"; }; 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; }; 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; }; A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; }; @@ -263,11 +263,11 @@ isa = PBXGroup; children = ( CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */, + 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */, 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */, A253416F4FEA271A80B22A73 /* NYTAuthService.swift */, B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */, BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */, - 9876B49C8BE966CC2A37BF52 /* UbiquityMonitor.swift */, ); path = Services; sourceTree = "<group>"; @@ -397,6 +397,7 @@ CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */, DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */, C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */, + 978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */, 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */, 818B1F2693962832BE14578E /* GameListView.swift in Sources */, 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */, @@ -430,7 +431,6 @@ 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */, CE033A7502E71066DB51EF0D /* SyncMonitor.swift in Sources */, F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */, - 5AB52A0BA2934922EB94E5D1 /* UbiquityMonitor.swift in Sources */, 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */, 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */, ); diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -18,7 +18,7 @@ struct CrossmateApp: App { ) .environment(\.managedObjectContext, services.persistence.viewContext) .environment(services.nytAuth) - .environment(services.ubiquityMonitor) + .environment(services.driveMonitor) .environment(services.syncMonitor) .environment(\.nytPuzzleFetcher, services.nytFetcher) .environment(\.syncEngine, services.syncEngine) diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -8,7 +8,7 @@ final class AppServices { let syncEngine: SyncEngine let syncMonitor: SyncMonitor let nytAuth: NYTAuthService - let ubiquityMonitor: UbiquityMonitor + let driveMonitor: DriveMonitor let nytFetcher: NYTPuzzleFetcher private let outboxRecorder: OutboxRecorder @@ -24,7 +24,7 @@ final class AppServices { ) self.syncMonitor = SyncMonitor() self.nytAuth = NYTAuthService() - self.ubiquityMonitor = UbiquityMonitor() + self.driveMonitor = DriveMonitor() self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookie() } } @@ -33,7 +33,7 @@ final class AppServices { started = true nytAuth.loadStoredSession() - ubiquityMonitor.start() + driveMonitor.start() store.onPendingChangesAvailable = { Task { @@ -94,8 +94,8 @@ final class AppServices { return nil } - if ubiquityMonitor.containerAvailable { - try? ubiquityMonitor.importFile(from: url) + if driveMonitor.containerAvailable { + try? driveMonitor.importFile(from: url) } if let existing = store.findGameID(matching: source) { diff --git a/Crossmate/Services/DriveMonitor.swift b/Crossmate/Services/DriveMonitor.swift @@ -0,0 +1,277 @@ +import Foundation +import Observation + +struct DriveItem: Identifiable, Hashable { + let id: URL + let name: String + let url: URL + let isDirectory: Bool + let isDownloaded: Bool + let children: [DriveItem] + + var fileExtension: String { url.pathExtension.lowercased() } +} + +enum DriveError: LocalizedError { + case containerUnavailable + case readFailed(URL, Error) + case importFailed(URL, Error) + + var errorDescription: String? { + switch self { + case .containerUnavailable: + "iCloud Drive is not available. Make sure you're signed in to iCloud and iCloud Drive is enabled." + case .readFailed(let url, let error): + "Couldn't read \(url.lastPathComponent): \(error.localizedDescription)" + case .importFailed(let url, let error): + "Couldn't import \(url.lastPathComponent): \(error.localizedDescription)" + } + } +} + +@MainActor +@Observable +final class DriveMonitor { + private(set) var root: DriveItem? + private(set) var containerAvailable: Bool = false + + private let containerID = "iCloud.net.inqk.crossmate" + private var documentsURL: URL? + private let query = NSMetadataQuery() + private var observers: [NSObjectProtocol] = [] + + init() { + resolveContainer() + } + + func start() { + guard containerAvailable, !query.isStarted else { return } + + query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] + query.predicate = NSPredicate(format: "%K LIKE[c] %@", NSMetadataItemFSNameKey, "*.xd") + query.sortDescriptors = [NSSortDescriptor(key: NSMetadataItemFSNameKey, ascending: true)] + + let center = NotificationCenter.default + let handler: @Sendable (Notification) -> Void = { [weak self] _ in + Task { @MainActor in + self?.rebuildTree() + } + } + + observers.append(center.addObserver( + forName: .NSMetadataQueryDidFinishGathering, + object: query, + queue: .main, + using: handler + )) + observers.append(center.addObserver( + forName: .NSMetadataQueryDidUpdate, + object: query, + queue: .main, + using: handler + )) + + query.start() + } + + func startDownloading(_ item: DriveItem) { + guard !item.isDownloaded else { return } + try? FileManager.default.startDownloadingUbiquitousItem(at: item.url) + } + + func readSource(at url: URL) throws -> String { + do { + let coordinator = NSFileCoordinator() + var readError: NSError? + var result: String? + var innerError: Error? + + coordinator.coordinate(readingItemAt: url, options: [], error: &readError) { readURL in + do { + result = try String(contentsOf: readURL, encoding: .utf8) + } catch { + innerError = error + } + } + + if let readError { throw readError } + if let innerError { throw innerError } + guard let result else { throw CocoaError(.fileReadUnknown) } + return result + } catch { + throw DriveError.readFailed(url, error) + } + } + + func importFile(from sourceURL: URL) throws { + guard let documentsURL else { throw DriveError.containerUnavailable } + + let needsScopedAccess = sourceURL.startAccessingSecurityScopedResource() + defer { + if needsScopedAccess { + sourceURL.stopAccessingSecurityScopedResource() + } + } + + let destination = uniqueDestination( + for: sourceURL.lastPathComponent, + in: documentsURL + ) + + do { + let coordinator = NSFileCoordinator() + var coordError: NSError? + var innerError: Error? + + coordinator.coordinate( + readingItemAt: sourceURL, + options: [.withoutChanges], + writingItemAt: destination, + options: [.forReplacing], + error: &coordError + ) { readURL, writeURL in + do { + try FileManager.default.copyItem(at: readURL, to: writeURL) + } catch { + innerError = error + } + } + + if let coordError { throw coordError } + if let innerError { throw innerError } + } catch { + throw DriveError.importFailed(sourceURL, error) + } + } + + // MARK: - Private + + private func resolveContainer() { + guard let container = FileManager.default.url(forUbiquityContainerIdentifier: containerID) else { + containerAvailable = false + return + } + let documents = container.appendingPathComponent("Documents", isDirectory: true) + if !FileManager.default.fileExists(atPath: documents.path) { + try? FileManager.default.createDirectory( + at: documents, + withIntermediateDirectories: true + ) + } + ensurePlaceholder(in: documents) + self.documentsURL = documents + self.containerAvailable = true + } + + private func ensurePlaceholder(in documents: URL) { + let readme = documents.appendingPathComponent("README.txt") + guard !FileManager.default.fileExists(atPath: readme.path) else { return } + let body = """ + Drop .xd crossword files into this folder to import them into Crossmate. + + Crossmate will list any .xd files you place here (including inside subfolders) in the Imported tab of the New Game sheet. + """ + try? body.data(using: .utf8)?.write(to: readme, options: .atomic) + } + + private func uniqueDestination(for filename: String, in directory: URL) -> URL { + let candidate = directory.appendingPathComponent(filename) + if !FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + let base = (filename as NSString).deletingPathExtension + let ext = (filename as NSString).pathExtension + var index = 2 + while true { + let nextName = ext.isEmpty ? "\(base) \(index)" : "\(base) \(index).\(ext)" + let next = directory.appendingPathComponent(nextName) + if !FileManager.default.fileExists(atPath: next.path) { + return next + } + index += 1 + } + } + + private func rebuildTree() { + guard let documentsURL else { return } + + query.disableUpdates() + defer { query.enableUpdates() } + + var files: [(url: URL, downloaded: Bool)] = [] + for i in 0..<query.resultCount { + guard let item = query.result(at: i) as? NSMetadataItem, + let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL + else { continue } + let status = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String + let downloaded = status == NSMetadataUbiquitousItemDownloadingStatusCurrent + || status == NSMetadataUbiquitousItemDownloadingStatusDownloaded + files.append((url: url.standardizedFileURL, downloaded: downloaded)) + } + + self.root = buildTree(documentsURL: documentsURL.standardizedFileURL, files: files) + } + + private func buildTree(documentsURL: URL, files: [(url: URL, downloaded: Bool)]) -> DriveItem { + let docComponents = documentsURL.pathComponents + + final class Node { + var children: [String: Node] = [:] + var files: [(url: URL, downloaded: Bool)] = [] + } + + let root = Node() + for file in files { + let components = file.url.pathComponents + guard components.count > docComponents.count, + Array(components.prefix(docComponents.count)) == docComponents + else { continue } + let dirs = components.dropFirst(docComponents.count).dropLast() + + var current = root + for dir in dirs { + if let next = current.children[dir] { + current = next + } else { + let next = Node() + current.children[dir] = next + current = next + } + } + current.files.append(file) + } + + func materialize(node: Node, url: URL, name: String) -> DriveItem { + let folderChildren: [DriveItem] = node.children + .sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } + .map { key, childNode in + let childURL = url.appendingPathComponent(key, isDirectory: true) + return materialize(node: childNode, url: childURL, name: key) + } + let fileChildren: [DriveItem] = node.files + .sorted { + $0.url.lastPathComponent.localizedCaseInsensitiveCompare($1.url.lastPathComponent) == .orderedAscending + } + .map { file in + DriveItem( + id: file.url, + name: file.url.deletingPathExtension().lastPathComponent, + url: file.url, + isDirectory: false, + isDownloaded: file.downloaded, + children: [] + ) + } + return DriveItem( + id: url, + name: name, + url: url, + isDirectory: true, + isDownloaded: true, + children: folderChildren + fileChildren + ) + } + + return materialize(node: root, url: documentsURL, name: documentsURL.lastPathComponent) + } +} diff --git a/Crossmate/Services/UbiquityMonitor.swift b/Crossmate/Services/UbiquityMonitor.swift @@ -1,277 +0,0 @@ -import Foundation -import Observation - -struct UbiquityItem: Identifiable, Hashable { - let id: URL - let name: String - let url: URL - let isDirectory: Bool - let isDownloaded: Bool - let children: [UbiquityItem] - - var fileExtension: String { url.pathExtension.lowercased() } -} - -enum UbiquityError: LocalizedError { - case containerUnavailable - case readFailed(URL, Error) - case importFailed(URL, Error) - - var errorDescription: String? { - switch self { - case .containerUnavailable: - "iCloud Drive is not available. Make sure you're signed in to iCloud and iCloud Drive is enabled." - case .readFailed(let url, let error): - "Couldn't read \(url.lastPathComponent): \(error.localizedDescription)" - case .importFailed(let url, let error): - "Couldn't import \(url.lastPathComponent): \(error.localizedDescription)" - } - } -} - -@MainActor -@Observable -final class UbiquityMonitor { - private(set) var root: UbiquityItem? - private(set) var containerAvailable: Bool = false - - private let containerID = "iCloud.net.inqk.crossmate" - private var documentsURL: URL? - private let query = NSMetadataQuery() - private var observers: [NSObjectProtocol] = [] - - init() { - resolveContainer() - } - - func start() { - guard containerAvailable, !query.isStarted else { return } - - query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] - query.predicate = NSPredicate(format: "%K LIKE[c] %@", NSMetadataItemFSNameKey, "*.xd") - query.sortDescriptors = [NSSortDescriptor(key: NSMetadataItemFSNameKey, ascending: true)] - - let center = NotificationCenter.default - let handler: @Sendable (Notification) -> Void = { [weak self] _ in - Task { @MainActor in - self?.rebuildTree() - } - } - - observers.append(center.addObserver( - forName: .NSMetadataQueryDidFinishGathering, - object: query, - queue: .main, - using: handler - )) - observers.append(center.addObserver( - forName: .NSMetadataQueryDidUpdate, - object: query, - queue: .main, - using: handler - )) - - query.start() - } - - func startDownloading(_ item: UbiquityItem) { - guard !item.isDownloaded else { return } - try? FileManager.default.startDownloadingUbiquitousItem(at: item.url) - } - - func readSource(at url: URL) throws -> String { - do { - let coordinator = NSFileCoordinator() - var readError: NSError? - var result: String? - var innerError: Error? - - coordinator.coordinate(readingItemAt: url, options: [], error: &readError) { readURL in - do { - result = try String(contentsOf: readURL, encoding: .utf8) - } catch { - innerError = error - } - } - - if let readError { throw readError } - if let innerError { throw innerError } - guard let result else { throw CocoaError(.fileReadUnknown) } - return result - } catch { - throw UbiquityError.readFailed(url, error) - } - } - - func importFile(from sourceURL: URL) throws { - guard let documentsURL else { throw UbiquityError.containerUnavailable } - - let needsScopedAccess = sourceURL.startAccessingSecurityScopedResource() - defer { - if needsScopedAccess { - sourceURL.stopAccessingSecurityScopedResource() - } - } - - let destination = uniqueDestination( - for: sourceURL.lastPathComponent, - in: documentsURL - ) - - do { - let coordinator = NSFileCoordinator() - var coordError: NSError? - var innerError: Error? - - coordinator.coordinate( - readingItemAt: sourceURL, - options: [.withoutChanges], - writingItemAt: destination, - options: [.forReplacing], - error: &coordError - ) { readURL, writeURL in - do { - try FileManager.default.copyItem(at: readURL, to: writeURL) - } catch { - innerError = error - } - } - - if let coordError { throw coordError } - if let innerError { throw innerError } - } catch { - throw UbiquityError.importFailed(sourceURL, error) - } - } - - // MARK: - Private - - private func resolveContainer() { - guard let container = FileManager.default.url(forUbiquityContainerIdentifier: containerID) else { - containerAvailable = false - return - } - let documents = container.appendingPathComponent("Documents", isDirectory: true) - if !FileManager.default.fileExists(atPath: documents.path) { - try? FileManager.default.createDirectory( - at: documents, - withIntermediateDirectories: true - ) - } - ensurePlaceholder(in: documents) - self.documentsURL = documents - self.containerAvailable = true - } - - private func ensurePlaceholder(in documents: URL) { - let readme = documents.appendingPathComponent("README.txt") - guard !FileManager.default.fileExists(atPath: readme.path) else { return } - let body = """ - Drop .xd crossword files into this folder to import them into Crossmate. - - Crossmate will list any .xd files you place here (including inside subfolders) in the Imported tab of the New Game sheet. - """ - try? body.data(using: .utf8)?.write(to: readme, options: .atomic) - } - - private func uniqueDestination(for filename: String, in directory: URL) -> URL { - let candidate = directory.appendingPathComponent(filename) - if !FileManager.default.fileExists(atPath: candidate.path) { - return candidate - } - let base = (filename as NSString).deletingPathExtension - let ext = (filename as NSString).pathExtension - var index = 2 - while true { - let nextName = ext.isEmpty ? "\(base) \(index)" : "\(base) \(index).\(ext)" - let next = directory.appendingPathComponent(nextName) - if !FileManager.default.fileExists(atPath: next.path) { - return next - } - index += 1 - } - } - - private func rebuildTree() { - guard let documentsURL else { return } - - query.disableUpdates() - defer { query.enableUpdates() } - - var files: [(url: URL, downloaded: Bool)] = [] - for i in 0..<query.resultCount { - guard let item = query.result(at: i) as? NSMetadataItem, - let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL - else { continue } - let status = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String - let downloaded = status == NSMetadataUbiquitousItemDownloadingStatusCurrent - || status == NSMetadataUbiquitousItemDownloadingStatusDownloaded - files.append((url: url.standardizedFileURL, downloaded: downloaded)) - } - - self.root = buildTree(documentsURL: documentsURL.standardizedFileURL, files: files) - } - - private func buildTree(documentsURL: URL, files: [(url: URL, downloaded: Bool)]) -> UbiquityItem { - let docComponents = documentsURL.pathComponents - - final class Node { - var children: [String: Node] = [:] - var files: [(url: URL, downloaded: Bool)] = [] - } - - let root = Node() - for file in files { - let components = file.url.pathComponents - guard components.count > docComponents.count, - Array(components.prefix(docComponents.count)) == docComponents - else { continue } - let dirs = components.dropFirst(docComponents.count).dropLast() - - var current = root - for dir in dirs { - if let next = current.children[dir] { - current = next - } else { - let next = Node() - current.children[dir] = next - current = next - } - } - current.files.append(file) - } - - func materialize(node: Node, url: URL, name: String) -> UbiquityItem { - let folderChildren: [UbiquityItem] = node.children - .sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } - .map { key, childNode in - let childURL = url.appendingPathComponent(key, isDirectory: true) - return materialize(node: childNode, url: childURL, name: key) - } - let fileChildren: [UbiquityItem] = node.files - .sorted { - $0.url.lastPathComponent.localizedCaseInsensitiveCompare($1.url.lastPathComponent) == .orderedAscending - } - .map { file in - UbiquityItem( - id: file.url, - name: file.url.deletingPathExtension().lastPathComponent, - url: file.url, - isDirectory: false, - isDownloaded: file.downloaded, - children: [] - ) - } - return UbiquityItem( - id: url, - name: name, - url: url, - isDirectory: true, - isDownloaded: true, - children: folderChildren + fileChildren - ) - } - - return materialize(node: root, url: documentsURL, name: documentsURL.lastPathComponent) - } -} diff --git a/Crossmate/Views/ImportedBrowseView.swift b/Crossmate/Views/ImportedBrowseView.swift @@ -4,7 +4,7 @@ import UniformTypeIdentifiers struct ImportedBrowseView: View { let onSelected: (String) -> Void - @Environment(UbiquityMonitor.self) private var monitor + @Environment(DriveMonitor.self) private var monitor @State private var errorMessage: String? @State private var showingImporter = false @@ -19,7 +19,7 @@ struct ImportedBrowseView: View { } else if let root = monitor.root, !root.children.isEmpty { List { ForEach(root.children) { item in - UbiquityItemRow(item: item, onOpen: open) + DriveItemRow(item: item, onOpen: open) } } } else { @@ -71,7 +71,7 @@ struct ImportedBrowseView: View { } } - private func open(_ item: UbiquityItem) { + private func open(_ item: DriveItem) { if !item.isDownloaded { monitor.startDownloading(item) errorMessage = "This puzzle is still downloading from iCloud. Try again in a moment." @@ -86,15 +86,15 @@ struct ImportedBrowseView: View { } } -private struct UbiquityItemRow: View { - let item: UbiquityItem - let onOpen: (UbiquityItem) -> Void +private struct DriveItemRow: View { + let item: DriveItem + let onOpen: (DriveItem) -> Void var body: some View { if item.isDirectory { DisclosureGroup { ForEach(item.children) { child in - UbiquityItemRow(item: child, onOpen: onOpen) + DriveItemRow(item: child, onOpen: onOpen) } } label: { Label(item.name, systemImage: "folder")