Sources/OrgDocument/FileStorage.swift
92 lines · 4275 bytes
1import CryptoKit
2import Foundation
3
4/// Reads and replaces files under `NSFileCoordinator`, so iCloud and other coordinating
5/// writers see a consistent file.
6public struct CoordinatedFileSystem: FileSystem {
7 public init() {}
8
9 public func read(_ url: URL) throws -> [UInt8]? {
10 try coordinate(reading: url) { url in
11 FileManager.default.fileExists(atPath: url.path) ? [UInt8](try Data(contentsOf: url)) : nil
12 }
13 }
14
15 public func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]? {
16 try coordinate(writing: url) { url in
17 let manager = FileManager.default
18 let folder = url.deletingLastPathComponent()
19 let temporary = folder.appendingPathComponent(".\(url.lastPathComponent).orgstar-\(UUID().uuidString)")
20 try Data(bytes).write(to: temporary)
21 guard manager.fileExists(atPath: url.path) else {
22 try manager.moveItem(at: temporary, to: url)
23 return nil
24 }
25 // The backup is the file as it was at the moment of replacement, including any
26 // write that landed after our last check.
27 let backupName = ".\(url.lastPathComponent).orgstar-backup-\(UUID().uuidString)"
28 _ = try manager.replaceItemAt(url, withItemAt: temporary, backupItemName: backupName, options: .withoutDeletingBackupItem)
29 let backup = folder.appendingPathComponent(backupName)
30 defer { try? manager.removeItem(at: backup) }
31 return [UInt8](try Data(contentsOf: backup))
32 }
33 }
34
35 private func coordinate<T>(reading url: URL, _ body: (URL) throws -> T) throws -> T {
36 var coordinationError: NSError?
37 var result: Result<T, Error>?
38 NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: url, options: [], error: &coordinationError) { url in
39 result = Result { try body(url) }
40 }
41 if let coordinationError { throw coordinationError }
42 return try result!.get()
43 }
44
45 private func coordinate<T>(writing url: URL, _ body: (URL) throws -> T) throws -> T {
46 var coordinationError: NSError?
47 var result: Result<T, Error>?
48 NSFileCoordinator(filePresenter: nil).coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { url in
49 result = Result { try body(url) }
50 }
51 if let coordinationError { throw coordinationError }
52 return try result!.get()
53 }
54}
55
56/// Keeps the last `limit` displaced versions per file in `directory/<hash of path>/`.
57public struct FileRecoveryStore: RecoveryStore {
58 public let directory: URL
59 public let limit: Int
60
61 public init(directory: URL, limit: Int = 20) {
62 self.directory = directory
63 self.limit = limit
64 }
65
66 public func folder(for url: URL) -> URL {
67 let digest = SHA256.hash(data: Data(url.standardizedFileURL.path.utf8))
68 let name = digest.prefix(8).map { String(format: "%02x", $0) }.joined()
69 return directory.appendingPathComponent(name, isDirectory: true)
70 }
71
72 public func keep(_ bytes: [UInt8], for url: URL, label: String) throws {
73 let folder = folder(for: url)
74 let manager = FileManager.default
75 try manager.createDirectory(at: folder, withIntermediateDirectories: true)
76 // Zero-padded wall-clock nanoseconds sort by time; the UUID keeps same-instant names apart.
77 let stamp = String(format: "%020llu", UInt64(Date().timeIntervalSince1970 * 1_000_000_000))
78 let name = "\(stamp)-\(label)-\(UUID().uuidString.prefix(8))-\(url.lastPathComponent)"
79 try Data(bytes).write(to: folder.appendingPathComponent(name))
80 let kept = try manager.contentsOfDirectory(atPath: folder.path).sorted()
81 for old in kept.dropLast(limit) {
82 try manager.removeItem(at: folder.appendingPathComponent(old))
83 }
84 }
85
86 /// Kept versions, oldest first.
87 public func versions(for url: URL) throws -> [URL] {
88 let folder = folder(for: url)
89 guard FileManager.default.fileExists(atPath: folder.path) else { return [] }
90 return try FileManager.default.contentsOfDirectory(atPath: folder.path).sorted().map { folder.appendingPathComponent($0) }
91 }
92}