1import 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.
5private 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.
19private 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.
45enum 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}