From 451abb430baef0bc225190b6909ce96b7e662810 Mon Sep 17 00:00:00 2001 From: rzen Date: Wed, 8 Jul 2026 08:03:45 -0400 Subject: [PATCH] Add an iCloud diagnostics screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Workouts/Diagnostics/ContainerStatus.swift | 141 ++++++++++ Workouts/Diagnostics/DiagnosticsReport.swift | 120 +++++++++ Workouts/Diagnostics/DiagnosticsView.swift | 174 +++++++++++++ .../Diagnostics/DocumentSyncInspector.swift | 246 ++++++++++++++++++ Workouts/Diagnostics/NetworkReadiness.swift | 164 ++++++++++++ Workouts/Diagnostics/SchemaSkipScanner.swift | 180 +++++++++++++ Workouts/Views/Settings/SettingsView.swift | 6 + 7 files changed, 1031 insertions(+) create mode 100644 Workouts/Diagnostics/ContainerStatus.swift create mode 100644 Workouts/Diagnostics/DiagnosticsReport.swift create mode 100644 Workouts/Diagnostics/DiagnosticsView.swift create mode 100644 Workouts/Diagnostics/DocumentSyncInspector.swift create mode 100644 Workouts/Diagnostics/NetworkReadiness.swift create mode 100644 Workouts/Diagnostics/SchemaSkipScanner.swift diff --git a/Workouts/Diagnostics/ContainerStatus.swift b/Workouts/Diagnostics/ContainerStatus.swift new file mode 100644 index 0000000..46af0e7 --- /dev/null +++ b/Workouts/Diagnostics/ContainerStatus.swift @@ -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 "..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) + } +} diff --git a/Workouts/Diagnostics/DiagnosticsReport.swift b/Workouts/Diagnostics/DiagnosticsReport.swift new file mode 100644 index 0000000..fda3f43 --- /dev/null +++ b/Workouts/Diagnostics/DiagnosticsReport.swift @@ -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 = [.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() + ) + } +} diff --git a/Workouts/Diagnostics/DiagnosticsView.swift b/Workouts/Diagnostics/DiagnosticsView.swift new file mode 100644 index 0000000..983ef23 --- /dev/null +++ b/Workouts/Diagnostics/DiagnosticsView.swift @@ -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 + } + } +} diff --git a/Workouts/Diagnostics/DocumentSyncInspector.swift b/Workouts/Diagnostics/DocumentSyncInspector.swift new file mode 100644 index 0000000..381d6ca --- /dev/null +++ b/Workouts/Diagnostics/DocumentSyncInspector.swift @@ -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 + ) + } +} diff --git a/Workouts/Diagnostics/NetworkReadiness.swift b/Workouts/Diagnostics/NetworkReadiness.swift new file mode 100644 index 0000000..90ae966 --- /dev/null +++ b/Workouts/Diagnostics/NetworkReadiness.swift @@ -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 { + 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 + ) + } +} diff --git a/Workouts/Diagnostics/SchemaSkipScanner.swift b/Workouts/Diagnostics/SchemaSkipScanner.swift new file mode 100644 index 0000000..1a7b259 --- /dev/null +++ b/Workouts/Diagnostics/SchemaSkipScanner.swift @@ -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 + } +} diff --git a/Workouts/Views/Settings/SettingsView.swift b/Workouts/Views/Settings/SettingsView.swift index ce4d2f9..80237d5 100644 --- a/Workouts/Views/Settings/SettingsView.swift +++ b/Workouts/Views/Settings/SettingsView.swift @@ -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") }