| 1 | import Foundation |
| 2 | |
| 3 | /// Lists the conflict versions iCloud Drive keeps beside a section file, as it keeps another |
| 4 | /// device's commit that lost; the library merges each into the file. |
| 5 | private let listVersions: sb_versions = { path, found, context in |
| 6 | guard let path, let found else { return } |
| 7 | let url = URL(fileURLWithPath: String(cString: path)) |
| 8 | for version in NSFileVersion.unresolvedConflictVersionsOfItem(at: url) ?? [] { |
| 9 | version.url.path.withCString { id in |
| 10 | guard let device = version.localizedNameOfSavingComputer else { return found(context, id, nil) } |
| 11 | device.withCString { found(context, id, $0) } |
| 12 | } |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | /// Resolves and removes a version the library merged, first keeping it beside the file as |
| 17 | /// “Name (Device).one” when it is another section. A version already gone, as another device |
| 18 | /// resolved it, is done. |
| 19 | private let retireVersion: sb_retire = { path, id, keep in |
| 20 | guard let path, let id else { return false } |
| 21 | let url = URL(fileURLWithPath: String(cString: path)) |
| 22 | let named = String(cString: id) |
| 23 | guard let version = NSFileVersion.unresolvedConflictVersionsOfItem(at: url)?.first(where: { $0.url.path == named }) |
| 24 | else { return true } |
| 25 | var retired = false |
| 26 | var error: NSError? |
| 27 | NSFileCoordinator().coordinate(writingItemAt: url, options: .forMerging, error: &error) { url in |
| 28 | if keep { |
| 29 | let device = version.localizedNameOfSavingComputer ?? "another device" |
| 30 | let stem = url.deletingPathExtension().lastPathComponent |
| 31 | let folder = url.deletingLastPathComponent() |
| 32 | let kept = (1...).lazy.map { n in |
| 33 | folder.appendingPathComponent(n == 1 ? "\(stem) (\(device)).one" : "\(stem) (\(device) \(n)).one") |
| 34 | }.first { !FileManager.default.fileExists(atPath: $0.path) } |
| 35 | guard let kept, (try? FileManager.default.copyItem(at: version.url, to: kept)) != nil else { return } |
| 36 | } |
| 37 | version.isResolved = true |
| 38 | retired = (try? version.remove()) != nil |
| 39 | } |
| 40 | return retired |
| 41 | } |
| 42 | |
| 43 | /// Snowbound's folder in iCloud Drive: its container's Documents, which Files shows as iCloud |
| 44 | /// Drive's Snowbound folder. |
| 45 | enum ICloud { |
| 46 | /// Sent on the main thread when the folder was looked up again. |
| 47 | static let changed = Notification.Name("ICloudChanged") |
| 48 | private static let identifier = "iCloud.net.paperclover.snowbound" |
| 49 | |
| 50 | /// The folder, once looked up; nil while iCloud Drive is off, signed out, or the app lacks |
| 51 | /// the container. |
| 52 | private(set) static var documents: URL? |
| 53 | |
| 54 | /// Tooling launched the app, naming a `SNOWBOUND_` variable such as `SNOWBOUND_SCRIPT`: it |
| 55 | /// reaches no account's iCloud, only the folder `SNOWBOUND_ICLOUD` names. |
| 56 | private static let automated = ProcessInfo.processInfo.environment.keys.contains { $0.hasPrefix("SNOWBOUND_") } |
| 57 | |
| 58 | /// Whether iCloud is signed in with iCloud Drive on, container or not. |
| 59 | static var signedIn: Bool { FileManager.default.ubiquityIdentityToken != nil } |
| 60 | |
| 61 | /// Has the library merge conflict versions, follows the account, and hears of notebooks |
| 62 | /// another device adds or removes; call once at launch. |
| 63 | static func start() { |
| 64 | sb_set_versions(listVersions, retireVersion) |
| 65 | if automated { return lookUp() } |
| 66 | NotificationCenter.default.addObserver( |
| 67 | forName: .NSUbiquityIdentityDidChange, object: nil, queue: .main |
| 68 | ) { _ in lookUp() } |
| 69 | lookUp() |
| 70 | query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] |
| 71 | query.predicate = NSPredicate(format: "%K LIKE '*'", NSMetadataItemFSNameKey) |
| 72 | for name in [Notification.Name.NSMetadataQueryDidFinishGathering, .NSMetadataQueryDidUpdate] { |
| 73 | NotificationCenter.default.addObserver(forName: name, object: query, queue: .main) { _ in |
| 74 | if Notebooks.inCloudChanged { NotificationCenter.default.post(name: changed, object: nil) } |
| 75 | } |
| 76 | } |
| 77 | query.start() |
| 78 | } |
| 79 | |
| 80 | /// Reports what iCloud Drive syncs of the container, as files come from other devices. |
| 81 | private static let query = NSMetadataQuery() |
| 82 | |
| 83 | /// Looks the folder up off the main thread, as asking iCloud can take a while. |
| 84 | static func lookUp() { |
| 85 | background({ () -> URL? in |
| 86 | #if DEBUG |
| 87 | // A simulator signs into no account; `SNOWBOUND_ICLOUD` names a folder standing in. |
| 88 | if let folder = ProcessInfo.processInfo.environment["SNOWBOUND_ICLOUD"] { |
| 89 | return URL(fileURLWithPath: folder, isDirectory: true) |
| 90 | } |
| 91 | #endif |
| 92 | guard !automated, |
| 93 | let container = FileManager.default.url(forUbiquityContainerIdentifier: identifier)? |
| 94 | .appendingPathComponent("Documents", isDirectory: true) |
| 95 | else { return nil } |
| 96 | try? FileManager.default.createDirectory(at: container, withIntermediateDirectories: true) |
| 97 | return container |
| 98 | }) { found in |
| 99 | documents = found |
| 100 | NotificationCenter.default.post(name: changed, object: nil) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | } |