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")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
DiagnosticsView()
|
||||
} label: {
|
||||
Label("Diagnostics", systemImage: "stethoscope")
|
||||
}
|
||||
} header: {
|
||||
Text("iCloud Sync")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user