A trailing swipe on a board row opens Board Settings, whose first setting is location: iCloud or Local, with a confirmed move to the other side — destructive-styled only outbound, because leaving iCloud is the direction that sheds protection. The move is setUbiquitous against the real container and a coordinated move under the DEBUG stand-in; evacuation sweeps materialization first and refuses honestly while content is still downloading. The local home is the sandbox Documents folder, published to the Files app, so a local board is still a folder the user owns. With a second home the iCloud wall softens (user-ruled 2026-08-08): the index always reaches ready, cloud unavailability becomes an inline notice with a retry, creates land locally when there is no account, and LANEWORK_FORCE_NO_ICLOUD makes that state reproducible in tests regardless of the machine's sign-in. Known gap, now user-reachable: backup remains iCloud-only, so local boards sit outside it. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
201 lines
10 KiB
Swift
201 lines
10 KiB
Swift
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 {
|
|
/// `<container>/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<CloudHome, CloudHomeUnavailable> {
|
|
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<CloudHome, CloudHomeUnavailable> {
|
|
#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
|
|
}
|
|
}
|