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
142 lines
6.4 KiB
Swift
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)
|
|
}
|
|
}
|