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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user