import Foundation import os /// The synced folder boards live directly inside, and how it was reached. /// /// **One of the phone's two homes, not the only one** (softened 2026-08-08). A board here syncs: it /// reaches the Mac, and it reaches the user's other devices. A board in the device home /// (`DeviceHomeResolver`) does not, and that is the whole difference between them — same package /// format, same loader, same writer. iCloud is what the app *prefers*, so a new board lands here /// whenever this resolves; it is no longer what the app *requires*, so a phone with no account gets /// an inline notice above a working local list rather than a wall in place of one. /// `CloudHomeUnavailable` is the vocabulary that notice is written from. struct CloudHome: Sendable, Equatable { /// `/Documents` — created if missing. Boards must live here and nowhere else: /// `NSMetadataQueryUbiquitousDocumentsScope` reports on this subtree alone, and /// `NSUbiquitousContainerIsDocumentScopePublic` (KanbanMobile/Info.plist) is what publishes it as /// a visible iCloud Drive folder — the same folder the Mac app opens boards out of today. let documentsURL: URL let origin: Origin enum Origin: Sendable, Equatable { /// The real ubiquity container. The only origin a shipped build can produce. case ubiquityContainer /// `LANEWORK_LOCAL_ROOT` — a plain directory standing in for the container, DEBUG only. /// /// There is no metadata query over a plain directory, so an index over this origin refreshes /// on demand rather than on notification, and every download state reads `.unknown`. That is /// the whole difference; the loader, the writer and the coordination brackets are identical, /// which is what makes the override worth having for simulator work and for UI tests that /// must not depend on an iCloud account. case localOverride } /// Whether a `NSMetadataQuery` can watch this home. False under the DEBUG override, where the /// index enumerates instead. var isWatchable: Bool { origin == .ubiquityContainer } } /// Why there is no synced home — the closed set the iCloud notice switches over. enum CloudHomeUnavailable: Error, Sendable, Equatable, CustomStringConvertible { /// No iCloud account is signed in on the device (`ubiquityIdentityToken` is nil). The one case /// the user can actually fix, and the one the notice's copy is aimed at. case noAccount /// An account exists but the container did not resolve — provisioning not yet propagated, /// restricted by a profile, or iCloud Drive switched off for this app. case containerUnreachable /// The container resolved but its `Documents/` subdirectory could not be created. case documentsUnavailable(message: String) var description: String { switch self { case .noAccount: "no iCloud account is signed in" case .containerUnreachable: "the iCloud container could not be reached" case let .documentsUnavailable(message): "the container's Documents folder is unusable: \(message)" } } } /// Resolves the container, once, off the main thread. /// /// **Off-main is not an optimization.** `FileManager.url(forUbiquityContainerIdentifier:)` blocks — /// seconds on a first launch while the daemon materializes the container, and indefinitely against a /// wedged account. Called on the main actor it is a hang, so the blocking half lives in a /// `nonisolated` function and the only entry point is `async`. enum CloudHomeResolver { /// The container this app is a tenant of — named after the *Mac* app's bundle id, deliberately /// (KanbanMobile.entitlements states why). Hard-coded rather than read back from the /// entitlements at runtime: a mismatch between this string and the entitlement is a /// provisioning error, and the loudest place for it is a container that does not resolve. static let containerIdentifier = "iCloud.dev.rzen.indie.Kanban" #if DEBUG /// The DEBUG escape hatch: a filesystem path to use *instead of* the ubiquity container, whole. /// Present so the simulator and future UI tests can drive the real loader and writer without an /// iCloud account; absent everywhere else, and compiled out of Release entirely. static let localRootEnvironmentKey = "LANEWORK_LOCAL_ROOT" /// The other DEBUG escape hatch: refuse the container outright, whatever the device would /// actually answer. A UI test of the no-iCloud surfaces cannot get there by simply leaving /// `LANEWORK_LOCAL_ROOT` unset — a simulator signed into an account resolves the real container /// and the test would pass or fail on whose machine it ran. This makes "no account" a launch /// argument instead of an environment. static let forceNoCloudEnvironmentKey = "LANEWORK_FORCE_NO_ICLOUD" #endif private static let logger = Logger(subsystem: "dev.rzen.indie.KanbanMobile", category: "cloud") static func resolve() async -> Result { await Task.detached(priority: .userInitiated) { resolveBlocking() }.value } /// The blocking half. `nonisolated` and free of any stored state, so it is safe from any /// executor — and so a caller that already has a background context can use it directly. nonisolated static func resolveBlocking() -> Result { #if DEBUG // Ahead of the local-root override on purpose: a test that asks for no iCloud must get no // iCloud even if a stray root is also set. if let forced = ProcessInfo.processInfo.environment[forceNoCloudEnvironmentKey], !forced.isEmpty { logger.notice("LANEWORK_FORCE_NO_ICLOUD is set — reporting no account") return .failure(.noAccount) } if let override = ProcessInfo.processInfo.environment[localRootEnvironmentKey], !override.isEmpty { let root = URL(fileURLWithPath: override, isDirectory: true) do { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) } catch { return .failure(.documentsUnavailable(message: error.localizedDescription)) } logger.notice("using LANEWORK_LOCAL_ROOT instead of the ubiquity container") return .success(CloudHome(documentsURL: root, origin: .localOverride)) } #endif // Cheap and non-blocking, and it is the one distinction the wall's copy turns on: "sign into // iCloud" is only the right sentence when there is no account, not when a provisioned // container has failed to appear. guard FileManager.default.ubiquityIdentityToken != nil else { return .failure(.noAccount) } guard let container = FileManager.default.url(forUbiquityContainerIdentifier: containerIdentifier) else { return .failure(.containerUnreachable) } let documents = container.appendingPathComponent("Documents", isDirectory: true) do { try FileManager.default.createDirectory(at: documents, withIntermediateDirectories: true) } catch { return .failure(.documentsUnavailable(message: error.localizedDescription)) } return .success(CloudHome(documentsURL: documents, origin: .ubiquityContainer)) } } /// The phone's other home: the app sandbox's `Documents/`, where a board that is not in iCloud /// lives. /// /// **This one cannot fail**, which is the property the whole soften-the-wall arrangement rests on. /// There is no account to be signed into, no daemon to reach and no provisioning to propagate — the /// directory is part of the container the app was installed with — so there is no `Result` here and /// no unavailable case to render. A board list can therefore always be shown, and a board can always /// be created, whatever iCloud is doing. /// /// `UIFileSharingEnabled` (KanbanMobile/Info.plist) publishes this folder in the Files app under /// "On My iPhone ▸ Lanework", so a local board is as reachable, movable and backupable by hand as an /// iCloud one — the same visibility `NSUbiquitousContainerIsDocumentScopePublic` gives the cloud /// home, which is what makes "local" a real home rather than a hiding place. enum DeviceHomeResolver { #if DEBUG /// A filesystem path to use *instead of* the sandbox's `Documents/` — the device-side twin of /// `CloudHomeResolver.localRootEnvironmentKey`, and present for the same reason: a UI test drives /// real moves between two real directories it created and owns, rather than into the running /// app's own documents folder, which survives between test runs. static let deviceRootEnvironmentKey = "LANEWORK_DEVICE_ROOT" #endif private static let logger = Logger(subsystem: "dev.rzen.indie.KanbanMobile", category: "cloud") /// `async` to match `CloudHomeResolver.resolve()` and to keep both resolutions in one vocabulary, /// not because this one blocks — it is a path plus, at most, one `mkdir` on local storage. static func resolve() async -> URL { await Task.detached(priority: .userInitiated) { resolveBlocking() }.value } nonisolated static func resolveBlocking() -> URL { #if DEBUG if let override = ProcessInfo.processInfo.environment[deviceRootEnvironmentKey], !override.isEmpty { logger.notice("using LANEWORK_DEVICE_ROOT instead of the sandbox's Documents folder") return prepared(URL(fileURLWithPath: override, isDirectory: true)) } #endif return prepared(URL.documentsDirectory) } /// A failed `createDirectory` is logged and otherwise ignored: the sandbox's `Documents/` is /// always already there, so this only ever creates a DEBUG override root, and a root that could /// not be made enumerates empty and refuses writes with the storage layer's own errors — which /// are better sentences than anything invented here. private nonisolated static func prepared(_ root: URL) -> URL { do { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) } catch { logger.error("device home is not usable: \(error.localizedDescription, privacy: .public)") } return root } }