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(_ 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? 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(_ 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) } } }