commit c2bc617cfc408c754003ca2cde67bbdb5b75fa11
parent c4534b5531f266f8db128efdde3006301629c0fd
Author: Michael Camilleri <[email protected]>
Date: Fri, 17 Apr 2026 15:32:48 +0900
Rename UbiquityMonitor to DriveMonitor
Diffstat:
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")