Add an iCloud diagnostics screen
A new Settings > Diagnostics screen reports the ubiquity container and account status, per-document download and eviction state, network readiness, and a count of documents skipped by the schema-version forward gate — surfaced to help debug why a file isn't syncing. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
@@ -0,0 +1,141 @@
|
|||||||
|
//
|
||||||
|
// ContainerStatus.swift
|
||||||
|
// Reference code — copy into your app, adjust the type/file names to taste.
|
||||||
|
//
|
||||||
|
// Adapted from the IndieDiag package (`iCloudContainerDiagnostic.swift`,
|
||||||
|
// commit bc6f475). The original resolved account status via
|
||||||
|
// `CKContainer.accountStatus`, which pulls in CloudKit for a fact
|
||||||
|
// `FileManager` already knows: `ubiquityIdentityToken` is non-nil exactly
|
||||||
|
// when an iCloud account is signed in, checked synchronously with no
|
||||||
|
// network round trip and no CloudKit entitlement required. That swap is
|
||||||
|
// the whole reason this file has no `import CloudKit`.
|
||||||
|
//
|
||||||
|
// Trade-off: `ubiquityIdentityToken` only distinguishes "signed in" from
|
||||||
|
// "not signed in." It cannot tell you *why* an account is unavailable
|
||||||
|
// (restricted, temporarily unavailable, etc.) the way CloudKit's richer
|
||||||
|
// `CKAccountStatus` can — that granularity isn't worth reintroducing
|
||||||
|
// CloudKit for a documents-only app. If you need it, it means you've
|
||||||
|
// reintroduced CloudKit somewhere else too.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Availability and configuration of the app's iCloud ubiquity container.
|
||||||
|
public struct ICloudContainerStatus: Sendable {
|
||||||
|
public let isAvailable: Bool
|
||||||
|
public let containerURL: URL?
|
||||||
|
public let containerIdentifier: String?
|
||||||
|
public let hasSignedInAccount: Bool
|
||||||
|
public let lastChecked: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
isAvailable: Bool,
|
||||||
|
containerURL: URL?,
|
||||||
|
containerIdentifier: String?,
|
||||||
|
hasSignedInAccount: Bool,
|
||||||
|
lastChecked: Date = Date()
|
||||||
|
) {
|
||||||
|
self.isAvailable = isAvailable
|
||||||
|
self.containerURL = containerURL
|
||||||
|
self.containerIdentifier = containerIdentifier
|
||||||
|
self.hasSignedInAccount = hasSignedInAccount
|
||||||
|
self.lastChecked = lastChecked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point-in-time checks for the iCloud ubiquity container: is an account
|
||||||
|
/// signed in, and can the container's URL be resolved. An `actor` because
|
||||||
|
/// `url(forUbiquityContainerIdentifier:)` is a blocking FileManager call
|
||||||
|
/// that can stall indefinitely when the account is in a bad state (password
|
||||||
|
/// reverification pending is the classic case per the icloud-sync-engine
|
||||||
|
/// skill) — actor isolation keeps that stall off whichever thread awaits it,
|
||||||
|
/// in practice the main thread.
|
||||||
|
///
|
||||||
|
/// This does point-in-time checks, not continuous monitoring. Call it when
|
||||||
|
/// a diagnostics screen appears or the user taps "Refresh" — see
|
||||||
|
/// SKILL.md's anti-patterns for why this shouldn't run on a timer.
|
||||||
|
public actor ContainerDiagnostic {
|
||||||
|
private let containerIdentifier: String?
|
||||||
|
|
||||||
|
/// - Parameter containerIdentifier: iCloud container identifier. Pass
|
||||||
|
/// `nil` to use the app's default (single) ubiquity container.
|
||||||
|
public init(containerIdentifier: String? = nil) {
|
||||||
|
self.containerIdentifier = containerIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full status snapshot: signed-in check + container URL resolution.
|
||||||
|
public func checkStatus() async -> ICloudContainerStatus {
|
||||||
|
let hasAccount = FileManager.default.ubiquityIdentityToken != nil
|
||||||
|
let containerURL = FileManager.default.url(forUbiquityContainerIdentifier: containerIdentifier)
|
||||||
|
|
||||||
|
return ICloudContainerStatus(
|
||||||
|
isAvailable: hasAccount && containerURL != nil,
|
||||||
|
containerURL: containerURL,
|
||||||
|
containerIdentifier: containerIdentifier,
|
||||||
|
hasSignedInAccount: hasAccount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience for a plain yes/no check.
|
||||||
|
public func isAvailable() async -> Bool {
|
||||||
|
await checkStatus().isAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `Documents/` subdirectory inside the container, creating it if
|
||||||
|
/// needed. Returns `nil` when the container can't be resolved — treat
|
||||||
|
/// that as "iCloud required," not as a cue to fall back to local
|
||||||
|
/// storage (see the icloud-sync-engine skill).
|
||||||
|
public func documentsURL() async -> URL? {
|
||||||
|
guard let containerURL = FileManager.default.url(forUbiquityContainerIdentifier: containerIdentifier) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let documentsURL = containerURL.appendingPathComponent("Documents", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: documentsURL, withIntermediateDirectories: true)
|
||||||
|
return documentsURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File count and total size under `Documents/`, for a diagnostics
|
||||||
|
/// screen's "N documents, X MB" line. Walks the whole tree — fine for
|
||||||
|
/// typical per-user datasets, but it's an O(files) directory walk, not
|
||||||
|
/// something to call in a loop.
|
||||||
|
public func containerStats() async -> (fileCount: Int, totalBytes: Int64)? {
|
||||||
|
guard let documentsURL = await documentsURL() else { return nil }
|
||||||
|
return Self.walk(documentsURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous, non-`async` on purpose: `FileManager.DirectoryEnumerator`
|
||||||
|
/// iteration goes through `NSEnumerator.nextObject()`, which Foundation
|
||||||
|
/// marks unavailable directly inside an `async` function body. Keeping
|
||||||
|
/// the `for case let ... in enumerator` loop in a plain sync function
|
||||||
|
/// and calling it from the `async` method above sidesteps that —
|
||||||
|
/// the blocking work still happens off the caller's thread because this
|
||||||
|
/// whole type is an actor.
|
||||||
|
private nonisolated static func walk(_ documentsURL: URL) -> (fileCount: Int, totalBytes: Int64) {
|
||||||
|
// No `.skipsHiddenFiles`: an evicted document exists on disk only as a
|
||||||
|
// hidden ".<name>.icloud" placeholder — skipping hidden files makes
|
||||||
|
// evicted documents vanish from the count (a fully-evicted container
|
||||||
|
// would report "0 documents"). Placeholders count as documents; their
|
||||||
|
// on-disk placeholder size stands in for the real size, so totalBytes
|
||||||
|
// reflects local footprint, not logical size. Other hidden files
|
||||||
|
// (.DS_Store etc.) are still skipped by name.
|
||||||
|
guard let enumerator = FileManager.default.enumerator(
|
||||||
|
at: documentsURL,
|
||||||
|
includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey],
|
||||||
|
options: []
|
||||||
|
) else { return (0, 0) }
|
||||||
|
|
||||||
|
var fileCount = 0
|
||||||
|
var totalBytes: Int64 = 0
|
||||||
|
|
||||||
|
for case let fileURL as URL in enumerator {
|
||||||
|
guard let values = try? fileURL.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]),
|
||||||
|
values.isDirectory == false else { continue }
|
||||||
|
if fileURL.lastPathComponent.hasPrefix("."), fileURL.pathExtension != "icloud" { continue }
|
||||||
|
fileCount += 1
|
||||||
|
totalBytes += Int64(values.fileSize ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (fileCount, totalBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
//
|
||||||
|
// DiagnosticsReport.swift
|
||||||
|
// Reference code — copy into your app, adjust the type/file names to taste.
|
||||||
|
//
|
||||||
|
// Adapted from the IndieDiag package (`IndieDiag.swift` + the
|
||||||
|
// `DiagnosticReport` model, commit bc6f475), trimmed to the three
|
||||||
|
// iCloud-documents-relevant pieces (container, document sync, network) —
|
||||||
|
// `cloudKitStatus` and `storageQuota` are gone along with CloudKit. This
|
||||||
|
// file shows how `ContainerDiagnostic`, `DocumentSyncInspector`, and
|
||||||
|
// `NetworkReadiness` compose into the single snapshot a settings
|
||||||
|
// "Diagnostics" screen wants — plus the optional `SchemaSkipScanner`
|
||||||
|
// (a post-package addition, see that file's header) for counting
|
||||||
|
// documents skipped by the schema-version forward gate.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Coarse overall health, derived from the three individual statuses.
|
||||||
|
public enum DiagnosticHealth: String, Sendable {
|
||||||
|
case healthy = "Healthy"
|
||||||
|
case syncing = "Syncing"
|
||||||
|
case warning = "Warning"
|
||||||
|
case critical = "Critical"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combined snapshot of container, document sync, and network status.
|
||||||
|
public struct DiagnosticsReport: Sendable {
|
||||||
|
public let containerStatus: ICloudContainerStatus
|
||||||
|
public let documentSync: DocumentSyncSnapshot
|
||||||
|
/// Present only when the coordinator was given a `SchemaSkipScanner`.
|
||||||
|
public let schemaSkips: SchemaSkipSnapshot?
|
||||||
|
public let networkStatus: NetworkStatus
|
||||||
|
public let generatedAt: Date
|
||||||
|
|
||||||
|
/// Note: schema-skipped documents deliberately do NOT degrade health.
|
||||||
|
/// A document written by a newer app version is an expected state, not
|
||||||
|
/// a fault — the data is intact and an app update recovers it.
|
||||||
|
public var overallHealth: DiagnosticHealth {
|
||||||
|
guard containerStatus.isAvailable, networkStatus.isConnected else { return .critical }
|
||||||
|
if documentSync.errorCount > 0 || documentSync.conflictCount > 0 { return .warning }
|
||||||
|
if documentSync.isSyncing { return .syncing }
|
||||||
|
return .healthy
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable summary, e.g. for a debug console or support email
|
||||||
|
/// attachment — not for display as primary UI copy.
|
||||||
|
public var summary: String {
|
||||||
|
var lines = ["Diagnostics — \(overallHealth.rawValue)"]
|
||||||
|
lines.append("iCloud: \(containerStatus.isAvailable ? "available" : "unavailable") (account signed in: \(containerStatus.hasSignedInAccount))")
|
||||||
|
lines.append("Network: \(networkStatus.isConnected ? "connected via \(networkStatus.connectionType.rawValue)" : "disconnected")")
|
||||||
|
lines.append("Documents: \(documentSync.currentCount)/\(documentSync.totalCount) current")
|
||||||
|
// Skipped and evicted are paired deliberately: evicted documents
|
||||||
|
// are never probed, so the skipped count is a floor — showing both
|
||||||
|
// numbers side by side lets the reader draw the conclusion.
|
||||||
|
if let schemaSkips, schemaSkips.skippedCount > 0 || documentSync.evictedCount > 0 {
|
||||||
|
lines.append("Skipped (newer schema): \(schemaSkips.skippedCount); evicted (unprobed): \(documentSync.evictedCount)")
|
||||||
|
} else if documentSync.evictedCount > 0 {
|
||||||
|
lines.append("Evicted: \(documentSync.evictedCount)")
|
||||||
|
}
|
||||||
|
if documentSync.errorCount > 0 { lines.append("Errors: \(documentSync.errorCount)") }
|
||||||
|
if documentSync.conflictCount > 0 { lines.append("Conflicts: \(documentSync.conflictCount)") }
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coordinates the three diagnostics into a single on-demand snapshot.
|
||||||
|
/// Construct one (it's cheap — no I/O happens until `generate()` runs),
|
||||||
|
/// call `generate()` when a diagnostics screen appears and again on a
|
||||||
|
/// manual "Refresh" action. Don't keep polling it on a timer and don't
|
||||||
|
/// call it from a hot path — see SKILL.md's anti-patterns.
|
||||||
|
@MainActor
|
||||||
|
public final class DiagnosticsCoordinator {
|
||||||
|
private let container: ContainerDiagnostic
|
||||||
|
private let documentInspector = DocumentSyncInspector()
|
||||||
|
private let network = NetworkReadiness()
|
||||||
|
private let schemaScanner: SchemaSkipScanner?
|
||||||
|
|
||||||
|
/// Pass a `SchemaSkipScanner` to also count documents this build skips
|
||||||
|
/// because their schema version is newer than it understands:
|
||||||
|
///
|
||||||
|
/// DiagnosticsCoordinator(
|
||||||
|
/// schemaScanner: SchemaSkipScanner(
|
||||||
|
/// currentSchemaVersion: RecordDocument.currentSchema))
|
||||||
|
public init(containerIdentifier: String? = nil, schemaScanner: SchemaSkipScanner? = nil) {
|
||||||
|
self.container = ContainerDiagnostic(containerIdentifier: containerIdentifier)
|
||||||
|
self.schemaScanner = schemaScanner
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs all checks concurrently and combines them into one report. The
|
||||||
|
/// schema scan (when configured) runs after the document snapshot it
|
||||||
|
/// depends on; container and network checks overlap with both.
|
||||||
|
public func generate() async -> DiagnosticsReport {
|
||||||
|
async let containerStatus = container.checkStatus()
|
||||||
|
async let networkStatus = network.currentStatus()
|
||||||
|
|
||||||
|
let syncSnapshot = await documentInspector.snapshot()
|
||||||
|
|
||||||
|
var schemaSkips: SchemaSkipSnapshot?
|
||||||
|
if let schemaScanner {
|
||||||
|
// Probe only documents that certainly have a full local copy —
|
||||||
|
// reading an evicted (or still-downloading) document would
|
||||||
|
// force a download just to inspect one integer. Evicted
|
||||||
|
// documents therefore aren't in the skipped count; the report
|
||||||
|
// already surfaces them separately as `evictedCount`.
|
||||||
|
let locallyPresent: Set<DocumentSyncState> = [.current, .uploading, .notUploaded, .conflict]
|
||||||
|
let urls = syncSnapshot.items
|
||||||
|
.filter { locallyPresent.contains($0.syncState) }
|
||||||
|
.map(\.url)
|
||||||
|
schemaSkips = await schemaScanner.scan(urls: urls)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await DiagnosticsReport(
|
||||||
|
containerStatus: containerStatus,
|
||||||
|
documentSync: syncSnapshot,
|
||||||
|
schemaSkips: schemaSkips,
|
||||||
|
networkStatus: networkStatus,
|
||||||
|
generatedAt: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
//
|
||||||
|
// DiagnosticsView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
// On-demand iCloud sync diagnostics, built on the copied indie-diag reference
|
||||||
|
// types. Reached from Settings › iCloud Sync › Diagnostics. Generates a fresh
|
||||||
|
// snapshot on appear and on pull-to-refresh — never on a timer (see the
|
||||||
|
// indie-diag skill's anti-patterns).
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class DiagnosticsViewModel {
|
||||||
|
/// One coordinator for the screen's lifetime; it's cheap (no I/O until
|
||||||
|
/// `generate()` runs) and each of its pieces tears itself down after each call.
|
||||||
|
/// The schema scanner resolves the forward-gate ceiling per file, because
|
||||||
|
/// Workouts has two document types with independent version counters — a
|
||||||
|
/// `Splits/` file is gated by `SplitDocument.currentSchemaVersion`, a
|
||||||
|
/// `Workouts/` file by `WorkoutDocument.currentSchemaVersion`, and a `Stubs/`
|
||||||
|
/// tombstone carries no versioned payload so it's never counted as skipped.
|
||||||
|
private let coordinator = DiagnosticsCoordinator(
|
||||||
|
containerIdentifier: SyncEngine.containerIdentifier,
|
||||||
|
schemaScanner: SchemaSkipScanner(
|
||||||
|
ceilingForURL: { url in
|
||||||
|
let path = url.path
|
||||||
|
if path.contains("/Splits/") { return SplitDocument.currentSchemaVersion }
|
||||||
|
if path.contains("/Workouts/") { return WorkoutDocument.currentSchemaVersion }
|
||||||
|
return .max
|
||||||
|
},
|
||||||
|
representativeCeiling: max(
|
||||||
|
SplitDocument.currentSchemaVersion,
|
||||||
|
WorkoutDocument.currentSchemaVersion
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
private(set) var report: DiagnosticsReport?
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
isRefreshing = true
|
||||||
|
report = await coordinator.generate()
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiagnosticsView: View {
|
||||||
|
@State private var viewModel = DiagnosticsViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if let report = viewModel.report {
|
||||||
|
overallSection(report)
|
||||||
|
iCloudSection(report)
|
||||||
|
documentsSection(report)
|
||||||
|
networkSection(report)
|
||||||
|
copySection(report)
|
||||||
|
} else if viewModel.isRefreshing {
|
||||||
|
Section { HStack { ProgressView(); Text("Gathering…").foregroundStyle(.secondary) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Diagnostics")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task { await viewModel.refresh() }
|
||||||
|
.refreshable { await viewModel.refresh() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sections
|
||||||
|
|
||||||
|
private func overallSection(_ report: DiagnosticsReport) -> some View {
|
||||||
|
Section("Overall") {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: healthSymbol(report.overallHealth))
|
||||||
|
.foregroundStyle(healthColor(report.overallHealth))
|
||||||
|
Text(report.overallHealth.rawValue)
|
||||||
|
Spacer()
|
||||||
|
if viewModel.isRefreshing { ProgressView() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iCloudSection(_ report: DiagnosticsReport) -> some View {
|
||||||
|
Section("iCloud") {
|
||||||
|
LabeledContent("Account", value: report.containerStatus.hasSignedInAccount ? "Signed in" : "Signed out")
|
||||||
|
LabeledContent("Container", value: report.containerStatus.isAvailable ? "Available" : "Unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func documentsSection(_ report: DiagnosticsReport) -> some View {
|
||||||
|
Section("Documents") {
|
||||||
|
LabeledContent("Current") {
|
||||||
|
Text("\(report.documentSync.currentCount) / \(report.documentSync.totalCount)")
|
||||||
|
}
|
||||||
|
if report.documentSync.isSyncing {
|
||||||
|
Label("Syncing…", systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skipped and evicted are presented as a pair: evicted documents are
|
||||||
|
// never probed, so the skipped count is a floor. Informational styling,
|
||||||
|
// not a warning color — a skipped document is an expected, recoverable
|
||||||
|
// state (update the app and it opens).
|
||||||
|
if let skips = report.schemaSkips,
|
||||||
|
skips.skippedCount > 0 || report.documentSync.evictedCount > 0 {
|
||||||
|
LabeledContent("Skipped · Evicted") {
|
||||||
|
Text("\(skips.skippedCount) · \(report.documentSync.evictedCount)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if skips.skippedCount > 0 {
|
||||||
|
Text("Skipped documents were saved by a newer version of the app — update to open them.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else if report.documentSync.evictedCount > 0 {
|
||||||
|
LabeledContent("Evicted", value: "\(report.documentSync.evictedCount)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if report.documentSync.conflictCount > 0 {
|
||||||
|
LabeledContent("Conflicts", value: "\(report.documentSync.conflictCount)")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
if report.documentSync.errorCount > 0 {
|
||||||
|
LabeledContent("Errors", value: "\(report.documentSync.errorCount)")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func networkSection(_ report: DiagnosticsReport) -> some View {
|
||||||
|
Section("Network") {
|
||||||
|
LabeledContent("Connection", value: report.networkStatus.connectionType.rawValue)
|
||||||
|
LabeledContent("Quality", value: report.networkStatus.quality.rawValue)
|
||||||
|
if report.networkStatus.isConstrained {
|
||||||
|
Label("Low Data Mode", systemImage: "exclamationmark.circle").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copySection(_ report: DiagnosticsReport) -> some View {
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
UIPasteboard.general.string = report.summary
|
||||||
|
} label: {
|
||||||
|
Label("Copy Diagnostics", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Copies a plain-text summary you can paste into a support message.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Health styling
|
||||||
|
|
||||||
|
private func healthSymbol(_ health: DiagnosticHealth) -> String {
|
||||||
|
switch health {
|
||||||
|
case .healthy: "checkmark.circle.fill"
|
||||||
|
case .syncing: "arrow.triangle.2.circlepath"
|
||||||
|
case .warning: "exclamationmark.triangle.fill"
|
||||||
|
case .critical: "xmark.octagon.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func healthColor(_ health: DiagnosticHealth) -> Color {
|
||||||
|
switch health {
|
||||||
|
case .healthy: .green
|
||||||
|
case .syncing: .secondary
|
||||||
|
case .warning, .critical: .orange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
//
|
||||||
|
// DocumentSyncInspector.swift
|
||||||
|
// Reference code — copy into your app, adjust the type/file names to taste.
|
||||||
|
//
|
||||||
|
// Adapted from the IndieDiag package (`iCloudDocumentMonitor.swift`,
|
||||||
|
// commit bc6f475). Two changes from the original:
|
||||||
|
//
|
||||||
|
// 1. One-shot, not always-on. The original ran a persistent
|
||||||
|
// `NSMetadataQuery` for the app's whole lifetime and pushed updates
|
||||||
|
// through Combine publishers. That's the icloud-sync-engine skill's
|
||||||
|
// job (see `MetadataObserver`/`ICloudFileMonitor` there), which already
|
||||||
|
// owns a query against the same scope to drive the local cache. This
|
||||||
|
// type instead starts a *second*, deliberately short-lived query only
|
||||||
|
// when a diagnostics screen wants a fresh read, and stops it once the
|
||||||
|
// first gather completes — no ongoing duplicate query competing with
|
||||||
|
// the sync engine's.
|
||||||
|
//
|
||||||
|
// 2. `@MainActor`, not `actor`. `NSMetadataQuery` must be started and read
|
||||||
|
// on the main thread — the original's plain `actor` didn't actually
|
||||||
|
// guarantee that. `@MainActor` makes it a compile-time property instead
|
||||||
|
// of a convention (see the icloud-sync-engine skill's monitor
|
||||||
|
// reference for the same rationale).
|
||||||
|
//
|
||||||
|
// Combine publishers are gone in favor of a plain `async` snapshot — a
|
||||||
|
// diagnostics screen wants "the current state," not a running feed.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Sync state for one document, as reported by `NSMetadataQuery`.
|
||||||
|
public enum DocumentSyncState: String, Sendable {
|
||||||
|
case current = "Current"
|
||||||
|
case uploading = "Uploading"
|
||||||
|
case downloading = "Downloading"
|
||||||
|
case notUploaded = "Not Uploaded"
|
||||||
|
case evicted = "Evicted"
|
||||||
|
case conflict = "Conflict"
|
||||||
|
case error = "Error"
|
||||||
|
case unknown = "Unknown"
|
||||||
|
|
||||||
|
public var isActive: Bool { self == .uploading || self == .downloading }
|
||||||
|
public var needsAttention: Bool { self == .error || self == .conflict }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A document sync/access error surfaced by `NSMetadataQuery`.
|
||||||
|
public struct DocumentSyncError: Sendable, Identifiable {
|
||||||
|
public let id = UUID()
|
||||||
|
public let url: URL
|
||||||
|
public let fileName: String
|
||||||
|
public let errorDescription: String
|
||||||
|
public let timestamp: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detailed status for one document.
|
||||||
|
public struct DocumentItemStatus: Sendable, Identifiable {
|
||||||
|
public var id: URL { url }
|
||||||
|
public let url: URL
|
||||||
|
public let fileName: String
|
||||||
|
public let syncState: DocumentSyncState
|
||||||
|
public let uploadProgress: Double?
|
||||||
|
public let downloadProgress: Double?
|
||||||
|
public let error: DocumentSyncError?
|
||||||
|
public let hasUnresolvedConflicts: Bool
|
||||||
|
public let lastModified: Date?
|
||||||
|
public let fileSize: Int64?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A point-in-time snapshot of every document's sync state.
|
||||||
|
public struct DocumentSyncSnapshot: Sendable {
|
||||||
|
public let items: [DocumentItemStatus]
|
||||||
|
public let generatedAt: Date
|
||||||
|
|
||||||
|
public var totalCount: Int { items.count }
|
||||||
|
public var currentCount: Int { items.filter { $0.syncState == .current }.count }
|
||||||
|
public var evictedCount: Int { items.filter { $0.syncState == .evicted }.count }
|
||||||
|
public var errorCount: Int { items.filter { $0.syncState == .error }.count }
|
||||||
|
public var conflictCount: Int { items.filter { $0.hasUnresolvedConflicts }.count }
|
||||||
|
public var isSyncing: Bool { items.contains { $0.syncState.isActive } }
|
||||||
|
public var errors: [DocumentSyncError] { items.compactMap(\.error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-shot inspector for per-document iCloud sync/eviction state. Meant to
|
||||||
|
/// be spun up when a diagnostics screen appears and torn down (deallocated)
|
||||||
|
/// when it disappears — not a second, continuously-running query living
|
||||||
|
/// alongside the sync engine's own `NSMetadataQuery`.
|
||||||
|
@MainActor
|
||||||
|
public final class DocumentSyncInspector {
|
||||||
|
private var query: NSMetadataQuery?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Runs a query against the Documents/ scope of the ubiquity container,
|
||||||
|
/// waits for the initial gather to complete (or for `timeout` to
|
||||||
|
/// elapse, whichever comes first), captures a snapshot, and stops the
|
||||||
|
/// query. Call this fresh each time the diagnostics screen wants an
|
||||||
|
/// update — don't leave it running and don't call it on a timer.
|
||||||
|
///
|
||||||
|
/// The timeout guards against a wedged iCloud account (the same
|
||||||
|
/// "reverification pending" state that can hang plain file operations)
|
||||||
|
/// leaving the query gathering forever; on timeout this returns an
|
||||||
|
/// empty snapshot rather than hanging the caller.
|
||||||
|
public func snapshot(
|
||||||
|
predicate: NSPredicate = NSPredicate(value: true),
|
||||||
|
timeout: Duration = .seconds(10)
|
||||||
|
) async -> DocumentSyncSnapshot {
|
||||||
|
stop()
|
||||||
|
|
||||||
|
let query = NSMetadataQuery()
|
||||||
|
query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
|
||||||
|
query.predicate = predicate
|
||||||
|
self.query = query
|
||||||
|
|
||||||
|
// Filter by identity ourselves rather than passing `query` as the
|
||||||
|
// `object:` argument: `NSMetadataQuery` is explicitly not
|
||||||
|
// `Sendable`, and this async sequence's `object:` parameter
|
||||||
|
// requires it. Filtering after the fact keeps us out of another
|
||||||
|
// query's gather-complete notification (e.g. the sync engine's own
|
||||||
|
// long-lived `NSMetadataQuery`, if the app also uses the
|
||||||
|
// icloud-sync-engine skill) without smuggling a non-Sendable type
|
||||||
|
// across the sequence's internal boundary.
|
||||||
|
let gatherComplete = NotificationCenter.default.notifications(named: .NSMetadataQueryDidFinishGathering)
|
||||||
|
// Precompute our query's identity as a `Sendable` `ObjectIdentifier` so the
|
||||||
|
// off-actor gather task can pick our notification out of the stream WITHOUT
|
||||||
|
// sending the notification's non-`Sendable` `object` (`Any?`) across the
|
||||||
|
// actor boundary — the app builds with `SWIFT_STRICT_CONCURRENCY: complete`,
|
||||||
|
// which rejects that hop. (The reference file compared via an `await
|
||||||
|
// self.isCurrentQuery(...)` call; that sends `Any?` to the main actor.)
|
||||||
|
let queryID = ObjectIdentifier(query)
|
||||||
|
query.start()
|
||||||
|
|
||||||
|
let didGather = await withTaskGroup(of: Bool.self) { group in
|
||||||
|
group.addTask {
|
||||||
|
let iterator = gatherComplete.makeAsyncIterator()
|
||||||
|
while let notification = await iterator.next() {
|
||||||
|
guard let object = notification.object,
|
||||||
|
ObjectIdentifier(object as AnyObject) == queryID else { continue }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
group.addTask {
|
||||||
|
try? await Task.sleep(for: timeout)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let first = await group.next() ?? false
|
||||||
|
group.cancelAll()
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
defer { stop() }
|
||||||
|
|
||||||
|
guard didGather, let query = self.query else {
|
||||||
|
return DocumentSyncSnapshot(items: [], generatedAt: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
query.disableUpdates()
|
||||||
|
let items = (query.results as? [NSMetadataItem] ?? []).map(Self.extractStatus)
|
||||||
|
query.enableUpdates()
|
||||||
|
|
||||||
|
return DocumentSyncSnapshot(items: items, generatedAt: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status for a single document, from a fresh snapshot. For "is this
|
||||||
|
/// one attachment still syncing?" rather than a whole-container read.
|
||||||
|
public func status(for url: URL) async -> DocumentItemStatus? {
|
||||||
|
await snapshot().items.first { $0.url == url }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stop() {
|
||||||
|
query?.stop()
|
||||||
|
query = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractStatus(from item: NSMetadataItem) -> DocumentItemStatus {
|
||||||
|
guard let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL else {
|
||||||
|
return DocumentItemStatus(
|
||||||
|
url: URL(fileURLWithPath: "/unknown"),
|
||||||
|
fileName: "Unknown",
|
||||||
|
syncState: .unknown,
|
||||||
|
uploadProgress: nil,
|
||||||
|
downloadProgress: nil,
|
||||||
|
error: nil,
|
||||||
|
hasUnresolvedConflicts: false,
|
||||||
|
lastModified: nil,
|
||||||
|
fileSize: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileName = item.value(forAttribute: NSMetadataItemFSNameKey) as? String ?? url.lastPathComponent
|
||||||
|
|
||||||
|
let isUploaded = item.value(forAttribute: NSMetadataUbiquitousItemIsUploadedKey) as? Bool ?? false
|
||||||
|
let isUploading = item.value(forAttribute: NSMetadataUbiquitousItemIsUploadingKey) as? Bool ?? false
|
||||||
|
let isDownloading = item.value(forAttribute: NSMetadataUbiquitousItemIsDownloadingKey) as? Bool ?? false
|
||||||
|
let uploadError = item.value(forAttribute: NSMetadataUbiquitousItemUploadingErrorKey) as? NSError
|
||||||
|
let downloadError = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingErrorKey) as? NSError
|
||||||
|
let hasConflicts = item.value(forAttribute: NSMetadataUbiquitousItemHasUnresolvedConflictsKey) as? Bool ?? false
|
||||||
|
|
||||||
|
// Evicted = exists in cloud, no local copy. Distinct from "not
|
||||||
|
// uploaded yet," which is the reverse (local copy, not in cloud yet).
|
||||||
|
let downloadStatus = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String
|
||||||
|
let isEvicted = downloadStatus == NSMetadataUbiquitousItemDownloadingStatusNotDownloaded
|
||||||
|
|
||||||
|
let uploadProgress = item.value(forAttribute: NSMetadataUbiquitousItemPercentUploadedKey) as? Double
|
||||||
|
let downloadProgress = item.value(forAttribute: NSMetadataUbiquitousItemPercentDownloadedKey) as? Double
|
||||||
|
let lastModified = item.value(forAttribute: NSMetadataItemFSContentChangeDateKey) as? Date
|
||||||
|
let fileSize = item.value(forAttribute: NSMetadataItemFSSizeKey) as? Int64
|
||||||
|
|
||||||
|
let syncState: DocumentSyncState
|
||||||
|
if uploadError != nil || downloadError != nil {
|
||||||
|
syncState = .error
|
||||||
|
} else if hasConflicts {
|
||||||
|
syncState = .conflict
|
||||||
|
} else if isEvicted {
|
||||||
|
syncState = .evicted
|
||||||
|
} else if isUploading {
|
||||||
|
syncState = .uploading
|
||||||
|
} else if isDownloading {
|
||||||
|
syncState = .downloading
|
||||||
|
} else if isUploaded {
|
||||||
|
syncState = .current
|
||||||
|
} else {
|
||||||
|
syncState = .notUploaded
|
||||||
|
}
|
||||||
|
|
||||||
|
let error: DocumentSyncError? = (uploadError ?? downloadError).map {
|
||||||
|
DocumentSyncError(
|
||||||
|
url: url,
|
||||||
|
fileName: fileName,
|
||||||
|
errorDescription: $0.localizedDescription,
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DocumentItemStatus(
|
||||||
|
url: url,
|
||||||
|
fileName: fileName,
|
||||||
|
syncState: syncState,
|
||||||
|
uploadProgress: uploadProgress,
|
||||||
|
downloadProgress: downloadProgress,
|
||||||
|
error: error,
|
||||||
|
hasUnresolvedConflicts: hasConflicts,
|
||||||
|
lastModified: lastModified,
|
||||||
|
fileSize: fileSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//
|
||||||
|
// NetworkReadiness.swift
|
||||||
|
// Reference code — copy into your app, adjust the type/file names to taste.
|
||||||
|
//
|
||||||
|
// Adapted from the IndieDiag package (`NetworkDiagnostic.swift`,
|
||||||
|
// commit bc6f475). Trimmed to the NWPathMonitor-based status/quality
|
||||||
|
// estimate only — dropped the original's `testCloudConnectivity()` HTTP
|
||||||
|
// probe to icloud.com as extra surface a documents-only app doesn't need;
|
||||||
|
// `NWPathMonitor`'s path status is enough to decide "is this a reasonable
|
||||||
|
// moment to sync." Combine publishers replaced with a plain `async`
|
||||||
|
// one-shot read plus an `AsyncStream` for the rare screen that wants live
|
||||||
|
// updates while visible.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// Network connection type.
|
||||||
|
public enum ConnectionType: String, Sendable {
|
||||||
|
case wifi = "Wi-Fi"
|
||||||
|
case cellular = "Cellular"
|
||||||
|
case ethernet = "Ethernet"
|
||||||
|
case unknown = "Unknown"
|
||||||
|
case none = "No Connection"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Network quality estimate for syncing.
|
||||||
|
public enum NetworkQuality: String, Sendable {
|
||||||
|
case excellent = "Excellent"
|
||||||
|
case good = "Good"
|
||||||
|
case fair = "Fair"
|
||||||
|
case poor = "Poor"
|
||||||
|
case none = "No Connection"
|
||||||
|
|
||||||
|
/// Suitable for large file uploads (e.g. media attachments).
|
||||||
|
public var canSyncLargeFiles: Bool { self == .excellent || self == .good }
|
||||||
|
|
||||||
|
/// Suitable for basic sync operations (small JSON documents).
|
||||||
|
public var canSync: Bool { self != .none }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Network connectivity status relevant to iCloud sync.
|
||||||
|
public struct NetworkStatus: Sendable {
|
||||||
|
public let isConnected: Bool
|
||||||
|
public let connectionType: ConnectionType
|
||||||
|
public let isExpensive: Bool
|
||||||
|
public let isConstrained: Bool
|
||||||
|
public let supportsCloudSync: Bool
|
||||||
|
public let lastChecked: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
isConnected: Bool,
|
||||||
|
connectionType: ConnectionType,
|
||||||
|
isExpensive: Bool = false,
|
||||||
|
isConstrained: Bool = false,
|
||||||
|
supportsCloudSync: Bool = true,
|
||||||
|
lastChecked: Date = Date()
|
||||||
|
) {
|
||||||
|
self.isConnected = isConnected
|
||||||
|
self.connectionType = connectionType
|
||||||
|
self.isExpensive = isExpensive
|
||||||
|
self.isConstrained = isConstrained
|
||||||
|
self.supportsCloudSync = supportsCloudSync
|
||||||
|
self.lastChecked = lastChecked
|
||||||
|
}
|
||||||
|
|
||||||
|
public var quality: NetworkQuality {
|
||||||
|
guard isConnected else { return .none }
|
||||||
|
if isConstrained { return .poor }
|
||||||
|
switch connectionType {
|
||||||
|
case .wifi, .ethernet: return .excellent
|
||||||
|
case .cellular: return isExpensive ? .fair : .good
|
||||||
|
case .unknown, .none: return .poor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NWPathMonitor`-based network readiness check. Two ways to use it:
|
||||||
|
///
|
||||||
|
/// - `currentStatus()` — a one-shot read for a diagnostics screen's initial
|
||||||
|
/// state, or before starting a sync operation.
|
||||||
|
/// - `statusUpdates()` — an `AsyncStream` for as long as the screen is
|
||||||
|
/// visible and iterating it. Stop iterating (or cancel the owning `Task`)
|
||||||
|
/// when the screen disappears; don't leave a monitor running for the
|
||||||
|
/// app's whole lifetime just for a diagnostics view.
|
||||||
|
public actor NetworkReadiness {
|
||||||
|
private var monitor: NWPathMonitor?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Single point-in-time read: starts a path monitor, takes the first
|
||||||
|
/// update, and cancels. Cheap enough to call every time a diagnostics
|
||||||
|
/// screen appears — don't cache this across app launches or poll it on
|
||||||
|
/// a timer (a real path change will call any *ongoing* monitor's
|
||||||
|
/// handler immediately; polling adds nothing but battery drain).
|
||||||
|
public func currentStatus() async -> NetworkStatus {
|
||||||
|
let monitor = NWPathMonitor()
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
monitor.pathUpdateHandler = { path in
|
||||||
|
continuation.resume(returning: Self.buildStatus(from: path))
|
||||||
|
monitor.cancel()
|
||||||
|
}
|
||||||
|
monitor.start(queue: DispatchQueue(label: "indie-diag.network.oneshot"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ongoing updates for as long as the returned stream is iterated.
|
||||||
|
/// Only one monitor runs at a time per `NetworkReadiness` instance —
|
||||||
|
/// starting a new stream cancels any previous one.
|
||||||
|
public func statusUpdates() -> AsyncStream<NetworkStatus> {
|
||||||
|
stop()
|
||||||
|
let monitor = NWPathMonitor()
|
||||||
|
self.monitor = monitor
|
||||||
|
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
monitor.pathUpdateHandler = { path in
|
||||||
|
continuation.yield(Self.buildStatus(from: path))
|
||||||
|
}
|
||||||
|
continuation.onTermination = { _ in
|
||||||
|
monitor.cancel()
|
||||||
|
}
|
||||||
|
monitor.start(queue: DispatchQueue(label: "indie-diag.network.monitor"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels any monitor started by `statusUpdates()`. Call this when the
|
||||||
|
/// diagnostics screen disappears if you're not relying on the stream's
|
||||||
|
/// consumer dropping out to trigger `onTermination`.
|
||||||
|
public func stop() {
|
||||||
|
monitor?.cancel()
|
||||||
|
monitor = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func buildStatus(from path: NWPath) -> NetworkStatus {
|
||||||
|
let isConnected = path.status == .satisfied
|
||||||
|
|
||||||
|
let connectionType: ConnectionType
|
||||||
|
if path.usesInterfaceType(.wifi) {
|
||||||
|
connectionType = .wifi
|
||||||
|
} else if path.usesInterfaceType(.cellular) {
|
||||||
|
connectionType = .cellular
|
||||||
|
} else if path.usesInterfaceType(.wiredEthernet) {
|
||||||
|
connectionType = .ethernet
|
||||||
|
} else if isConnected {
|
||||||
|
connectionType = .unknown
|
||||||
|
} else {
|
||||||
|
connectionType = .none
|
||||||
|
}
|
||||||
|
|
||||||
|
let isExpensive = path.isExpensive
|
||||||
|
let isConstrained = path.isConstrained
|
||||||
|
|
||||||
|
// iCloud sync works best on Wi-Fi/Ethernet, or unconstrained cellular.
|
||||||
|
let supportsCloudSync = isConnected && (!isConstrained || connectionType == .wifi || connectionType == .ethernet)
|
||||||
|
|
||||||
|
return NetworkStatus(
|
||||||
|
isConnected: isConnected,
|
||||||
|
connectionType: connectionType,
|
||||||
|
isExpensive: isExpensive,
|
||||||
|
isConstrained: isConstrained,
|
||||||
|
supportsCloudSync: supportsCloudSync
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//
|
||||||
|
// SchemaSkipScanner.swift
|
||||||
|
// Reference code copied from the indie-diag skill (2026-07-06 addition),
|
||||||
|
// adapted for Workouts.
|
||||||
|
//
|
||||||
|
// Answers a question `NSMetadataQuery` can't: of the documents that are fully
|
||||||
|
// synced and locally present, how many is this build currently *skipping*
|
||||||
|
// because their `schemaVersion` is newer than it understands (IndieSync's
|
||||||
|
// `VersionedDocument.isReadable` forward gate)? A skipped document is a
|
||||||
|
// healthy, expected state — the data is safe and fully synced, just written by
|
||||||
|
// a newer app version on another device — and updating the app recovers it.
|
||||||
|
//
|
||||||
|
// ADAPTATION FROM THE REFERENCE: the reference scanner takes a single
|
||||||
|
// `currentSchemaVersion: Int`. Workouts has TWO document types with
|
||||||
|
// INDEPENDENT version counters (`SplitDocument.currentSchemaVersion` and
|
||||||
|
// `WorkoutDocument.currentSchemaVersion`), so one global ceiling can't classify
|
||||||
|
// both correctly — a Split written at a version the workout counter has already
|
||||||
|
// reached would be misjudged. The forward-gate ceiling therefore has to be
|
||||||
|
// resolved per file (its location under `Documents/` tells us its type), so the
|
||||||
|
// scanner takes a `CeilingResolver` closure. A single-Int convenience init is
|
||||||
|
// kept so the file stays drop-in for single-document-type apps. Deliberately
|
||||||
|
// never triggers a download: callers filter to locally-present documents first
|
||||||
|
// (see `DiagnosticsCoordinator.generate()`).
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Outcome of probing one document's schema version.
|
||||||
|
public enum SchemaProbeState: Sendable, Equatable {
|
||||||
|
/// `schemaVersion` is at or below what this build understands.
|
||||||
|
case readable(version: Int)
|
||||||
|
/// `schemaVersion` is newer than this build understands — the app's
|
||||||
|
/// `isReadable` forward gate skips this document. An app update
|
||||||
|
/// recovers it; the data is intact.
|
||||||
|
case skipped(version: Int)
|
||||||
|
/// Decoded as JSON but no schema version field was found (e.g. a
|
||||||
|
/// tombstone stub or other non-versioned file in the container).
|
||||||
|
case noVersionField
|
||||||
|
/// The file couldn't be read or parsed at all.
|
||||||
|
case unreadable(description: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe result for one document.
|
||||||
|
public struct SchemaProbeResult: Sendable, Identifiable {
|
||||||
|
public var id: URL { url }
|
||||||
|
public let url: URL
|
||||||
|
public let fileName: String
|
||||||
|
public let state: SchemaProbeState
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point-in-time result of scanning locally-available documents.
|
||||||
|
///
|
||||||
|
/// Only documents that were actually probed appear here — evicted
|
||||||
|
/// documents are excluded upstream (never downloaded just to probe), so
|
||||||
|
/// `skippedCount` is a floor, not a guarantee, whenever
|
||||||
|
/// `DocumentSyncSnapshot.evictedCount` is nonzero.
|
||||||
|
public struct SchemaSkipSnapshot: Sendable {
|
||||||
|
public let results: [SchemaProbeResult]
|
||||||
|
public let currentSchemaVersion: Int
|
||||||
|
public let generatedAt: Date
|
||||||
|
|
||||||
|
public var probedCount: Int { results.count }
|
||||||
|
public var skippedCount: Int {
|
||||||
|
results.count { if case .skipped = $0.state { return true } else { return false } }
|
||||||
|
}
|
||||||
|
public var skipped: [SchemaProbeResult] {
|
||||||
|
results.filter { if case .skipped = $0.state { return true } else { return false } }
|
||||||
|
}
|
||||||
|
/// Highest schema version seen among skipped documents — i.e. the
|
||||||
|
/// newest app version that has written into this container. Useful in
|
||||||
|
/// support copy ("another device is running a newer version").
|
||||||
|
public var newestSkippedVersion: Int? {
|
||||||
|
skipped.compactMap { if case .skipped(let v) = $0.state { return v } else { return nil } }.max()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probes documents for their `schemaVersion` and counts the ones this
|
||||||
|
/// build would skip. A plain `actor` so the coordinated file reads block off
|
||||||
|
/// the caller's thread and a wedged iCloud account can't freeze the UI.
|
||||||
|
public actor SchemaSkipScanner {
|
||||||
|
/// Pulls the schema version out of a document's raw bytes. Return nil
|
||||||
|
/// if the data has no version field.
|
||||||
|
public typealias VersionExtractor = @Sendable (Data) -> Int?
|
||||||
|
|
||||||
|
/// Resolves the newest schema version THIS build can read for the document at
|
||||||
|
/// `url` — its forward-gate ceiling. Workouts resolves this from the file's
|
||||||
|
/// location under `Documents/` (Splits/ vs Workouts/); see the file header.
|
||||||
|
public typealias CeilingResolver = @Sendable (URL) -> Int
|
||||||
|
|
||||||
|
private let ceilingForURL: CeilingResolver
|
||||||
|
private let extractVersion: VersionExtractor
|
||||||
|
/// Representative "newest schema this build understands" (the max ceiling),
|
||||||
|
/// kept only for `SchemaSkipSnapshot.currentSchemaVersion`'s informational use.
|
||||||
|
private let representativeCeiling: Int
|
||||||
|
|
||||||
|
/// Per-URL ceiling — the form Workouts uses (two document types, two counters).
|
||||||
|
/// - Parameters:
|
||||||
|
/// - ceilingForURL: newest readable version for the document at a given URL.
|
||||||
|
/// - representativeCeiling: a single number for the snapshot's informational
|
||||||
|
/// field — pass the max of the app's document-type ceilings.
|
||||||
|
/// - extractVersion: override when documents don't match the default layouts.
|
||||||
|
public init(
|
||||||
|
ceilingForURL: @escaping CeilingResolver,
|
||||||
|
representativeCeiling: Int,
|
||||||
|
extractVersion: VersionExtractor? = nil
|
||||||
|
) {
|
||||||
|
self.ceilingForURL = ceilingForURL
|
||||||
|
self.representativeCeiling = representativeCeiling
|
||||||
|
self.extractVersion = extractVersion ?? Self.defaultExtractor
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single global ceiling — the reference package's original signature, kept so
|
||||||
|
/// this file stays drop-in for single-document-type apps.
|
||||||
|
public init(currentSchemaVersion: Int, extractVersion: VersionExtractor? = nil) {
|
||||||
|
self.init(
|
||||||
|
ceilingForURL: { _ in currentSchemaVersion },
|
||||||
|
representativeCeiling: currentSchemaVersion,
|
||||||
|
extractVersion: extractVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe the given document URLs. Pass only locally-present documents
|
||||||
|
/// (filter a `DocumentSyncSnapshot` down to states that imply a full
|
||||||
|
/// local copy) — this scanner reads whatever it's handed, and reading
|
||||||
|
/// an evicted document would trigger a full download.
|
||||||
|
public func scan(urls: [URL]) -> SchemaSkipSnapshot {
|
||||||
|
SchemaSkipSnapshot(
|
||||||
|
results: urls.map(probe),
|
||||||
|
currentSchemaVersion: representativeCeiling,
|
||||||
|
generatedAt: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func probe(_ url: URL) -> SchemaProbeResult {
|
||||||
|
let fileName = url.lastPathComponent
|
||||||
|
|
||||||
|
// Coordinated read, same as the sync engine's document reads — a
|
||||||
|
// plain Data(contentsOf:) can race an in-flight iCloud update.
|
||||||
|
var coordinationError: NSError?
|
||||||
|
var data: Data?
|
||||||
|
var readErrorDescription: String?
|
||||||
|
NSFileCoordinator(filePresenter: nil).coordinate(
|
||||||
|
readingItemAt: url, options: .withoutChanges, error: &coordinationError
|
||||||
|
) { actualURL in
|
||||||
|
do { data = try Data(contentsOf: actualURL) }
|
||||||
|
catch { readErrorDescription = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let data else {
|
||||||
|
let description = coordinationError?.localizedDescription
|
||||||
|
?? readErrorDescription
|
||||||
|
?? "Unknown read error"
|
||||||
|
return SchemaProbeResult(url: url, fileName: fileName, state: .unreadable(description: description))
|
||||||
|
}
|
||||||
|
guard let version = extractVersion(data) else {
|
||||||
|
return SchemaProbeResult(url: url, fileName: fileName, state: .noVersionField)
|
||||||
|
}
|
||||||
|
let ceiling = ceilingForURL(url)
|
||||||
|
let state: SchemaProbeState = version > ceiling
|
||||||
|
? .skipped(version: version)
|
||||||
|
: .readable(version: version)
|
||||||
|
return SchemaProbeResult(url: url, fileName: fileName, state: state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Covers both canonical portfolio layouts: top-level `schemaVersion`
|
||||||
|
/// (IndieSync's `VersionedDocument`) and `meta.schemaVersion`
|
||||||
|
/// (time-series-data-model's Meta+Payload). Decodes only the version
|
||||||
|
/// field — never the full document, so a future schema can't trip it.
|
||||||
|
private static let defaultExtractor: VersionExtractor = { data in
|
||||||
|
struct TopLevel: Decodable { let schemaVersion: Int }
|
||||||
|
struct MetaWrapped: Decodable {
|
||||||
|
struct Meta: Decodable { let schemaVersion: Int }
|
||||||
|
let meta: Meta
|
||||||
|
}
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
if let doc = try? decoder.decode(TopLevel.self, from: data) { return doc.schemaVersion }
|
||||||
|
if let doc = try? decoder.decode(MetaWrapped.self, from: data) { return doc.meta.schemaVersion }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,6 +129,12 @@ struct SettingsView: View {
|
|||||||
Label(error, systemImage: "exclamationmark.icloud.fill")
|
Label(error, systemImage: "exclamationmark.icloud.fill")
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
DiagnosticsView()
|
||||||
|
} label: {
|
||||||
|
Label("Diagnostics", systemImage: "stethoscope")
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("iCloud Sync")
|
Text("iCloud Sync")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user