Files
workouts/Workouts/Diagnostics/ContainerStatus.swift
T
rzen 451abb430b 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
2026-07-08 08:03:45 -04:00

142 lines
6.4 KiB
Swift

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