Files
SonarDesk/SonarDesk-iOS/Shared/Services/ICloudDriveStore.swift
2026-10-06 09:13:11 +01:00

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)
}
}
}