172 lines
7.8 KiB
Swift
172 lines
7.8 KiB
Swift
import Foundation
|
|
#if os(macOS)
|
|
import AppKit
|
|
#endif
|
|
|
|
/// SonarDesk's user-visible iCloud Drive document store.
|
|
/// Tokens remain in iCloud Keychain and are never written to this folder.
|
|
final class ICloudDriveStore {
|
|
static let shared = ICloudDriveStore()
|
|
static let containerIdentifier = "iCloud.net.absdev.SonarDesk"
|
|
|
|
enum StoreError: LocalizedError {
|
|
case unavailable
|
|
case notDownloaded
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .unavailable:
|
|
return "SonarDesk cannot access its iCloud Drive container. Check iCloud Drive and the SonarDesk iCloud capability/signing."
|
|
case .notDownloaded:
|
|
return "The SonarDesk iCloud Drive folder is not currently available on this Mac."
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Status {
|
|
let available: Bool
|
|
let containerIdentifier: String
|
|
let documentsURL: URL?
|
|
let message: String
|
|
}
|
|
|
|
private let fm = FileManager.default
|
|
private init() {}
|
|
|
|
/// Returns the public Documents scope of the SonarDesk ubiquity container.
|
|
/// No local fallback is permitted: persistent SonarDesk files are iCloud-only.
|
|
func root(create: Bool = true) throws -> URL {
|
|
guard let container = fm.url(forUbiquityContainerIdentifier: Self.containerIdentifier) else {
|
|
throw StoreError.unavailable
|
|
}
|
|
|
|
let documents = container.appendingPathComponent("Documents", isDirectory: true)
|
|
if create {
|
|
try createHierarchy(at: documents)
|
|
// Touch a harmless marker so the public document scope is materialised immediately.
|
|
let marker = documents.appendingPathComponent(".sonardesk", isDirectory: false)
|
|
if !fm.fileExists(atPath: marker.path) {
|
|
try Data("SonarDesk iCloud Drive data store\n".utf8).write(to: marker, options: .atomic)
|
|
}
|
|
}
|
|
return documents
|
|
}
|
|
|
|
func status() -> Status {
|
|
guard let container = fm.url(forUbiquityContainerIdentifier: Self.containerIdentifier) else {
|
|
return Status(available: false,
|
|
containerIdentifier: Self.containerIdentifier,
|
|
documentsURL: nil,
|
|
message: "Unavailable — the signed app has no access to its iCloud Drive container.")
|
|
}
|
|
let documents = container.appendingPathComponent("Documents", isDirectory: true)
|
|
do {
|
|
try createHierarchy(at: documents)
|
|
return Status(available: true,
|
|
containerIdentifier: Self.containerIdentifier,
|
|
documentsURL: documents,
|
|
message: "Connected")
|
|
} catch {
|
|
return Status(available: false,
|
|
containerIdentifier: Self.containerIdentifier,
|
|
documentsURL: documents,
|
|
message: error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
func dataDirectory(create: Bool = true) throws -> URL { try directory("Data", create: create) }
|
|
func reportsDirectory(create: Bool = true) throws -> URL { try directory("Reports", create: create) }
|
|
func backupsDirectory(create: Bool = true) throws -> URL { try directory("Backups", create: create) }
|
|
func historyDirectory(create: Bool = true) throws -> URL { try directory("Data/history", create: create) }
|
|
func exportsDirectory(_ name: String, create: Bool = true) throws -> URL { try directory("Reports/\(name)", create: create) }
|
|
|
|
private func directory(_ relative: String, create: Bool) throws -> URL {
|
|
let url = try root(create: create).appendingPathComponent(relative, isDirectory: true)
|
|
if create { try fm.createDirectory(at: url, withIntermediateDirectories: true) }
|
|
return url
|
|
}
|
|
|
|
func read<T: Decodable>(_ type: T.Type, fileName: String) throws -> T? {
|
|
let url = try dataDirectory().appendingPathComponent(fileName)
|
|
guard fm.fileExists(atPath: url.path) else { return nil }
|
|
try ensureDownloaded(url)
|
|
let coordinator = NSFileCoordinator(filePresenter: nil)
|
|
var coordinationError: NSError?
|
|
var result: Result<T, Error>?
|
|
coordinator.coordinate(readingItemAt: url, options: [], error: &coordinationError) { coordinatedURL in
|
|
result = Result {
|
|
let data = try Data(contentsOf: coordinatedURL, options: [.mappedIfSafe])
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
return try decoder.decode(type, from: data)
|
|
}
|
|
}
|
|
if let coordinationError { throw coordinationError }
|
|
return try result?.get()
|
|
}
|
|
|
|
func write<T: Encodable>(_ value: T, fileName: String) throws {
|
|
let url = try dataDirectory().appendingPathComponent(fileName)
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
let data = try encoder.encode(value)
|
|
try coordinatedAtomicWrite(data, to: url, backupName: fileName)
|
|
}
|
|
|
|
func coordinatedAtomicWrite(_ data: Data, to url: URL, backupName: String? = nil) throws {
|
|
let coordinator = NSFileCoordinator(filePresenter: nil)
|
|
var coordinationError: NSError?
|
|
var writeError: Error?
|
|
coordinator.coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { coordinatedURL in
|
|
do {
|
|
if fm.fileExists(atPath: coordinatedURL.path), let backupName {
|
|
let backupDir = try backupsDirectory()
|
|
let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-")
|
|
let backup = backupDir.appendingPathComponent("\(stamp)-\(backupName)")
|
|
try? fm.copyItem(at: coordinatedURL, to: backup)
|
|
pruneBackups(named: backupName, in: backupDir)
|
|
}
|
|
try data.write(to: coordinatedURL, options: [.atomic, .completeFileProtectionUnlessOpen])
|
|
} catch { writeError = error }
|
|
}
|
|
if let coordinationError { throw coordinationError }
|
|
if let writeError { throw writeError }
|
|
}
|
|
|
|
private func ensureDownloaded(_ url: URL) throws {
|
|
let values = try url.resourceValues(forKeys: [.isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey])
|
|
guard values.isUbiquitousItem == true else { return }
|
|
|
|
let status = values.ubiquitousItemDownloadingStatus
|
|
if status != .current && status != .downloaded {
|
|
try fm.startDownloadingUbiquitousItem(at: url)
|
|
throw StoreError.notDownloaded
|
|
}
|
|
}
|
|
|
|
private func pruneBackups(named fileName: String, in directory: URL) {
|
|
guard let files = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
|
|
let matching = files.filter { $0.lastPathComponent.hasSuffix("-\(fileName)") }.sorted {
|
|
let l = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
|
let r = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
|
return l > r
|
|
}
|
|
for old in matching.dropFirst(10) { try? fm.removeItem(at: old) }
|
|
}
|
|
|
|
func revealRoot() throws {
|
|
#if os(macOS)
|
|
let url = try root()
|
|
NSWorkspace.shared.open(url)
|
|
#endif
|
|
}
|
|
|
|
private func createHierarchy(at root: URL) throws {
|
|
try fm.createDirectory(at: root, withIntermediateDirectories: true)
|
|
for relative in ["Data", "Data/history", "Reports", "Reports/PDF", "Reports/CSV", "Reports/JSON", "Backups"] {
|
|
try fm.createDirectory(at: root.appendingPathComponent(relative, isDirectory: true), withIntermediateDirectories: true)
|
|
}
|
|
}
|
|
}
|