The board chooses where it lives — swipe-open settings move it between iCloud and this iPhone

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
This commit is contained in:
2026-08-08 10:56:39 -04:00
parent 1d97a2931c
commit 1c16bb4c38
10 changed files with 910 additions and 134 deletions
+80 -7
View File
@@ -1,12 +1,15 @@
import Foundation
import os
/// The one folder every board on this phone lives directly inside, and how it was reached.
/// The synced folder boards live directly inside, and how it was reached.
///
/// **iCloud is a hard requirement** (project.yml Lanework for iPhone): a board written outside the
/// ubiquity container would sync nowhere and the Mac would never see it, so there is deliberately no
/// local-only fallback a phone with no iCloud account gets a "sign into iCloud" wall instead of a
/// board list. `CloudHomeUnavailable` is the vocabulary that wall is written from.
/// **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
@@ -35,10 +38,10 @@ struct CloudHome: Sendable, Equatable {
var isWatchable: Bool { origin == .ubiquityContainer }
}
/// Why there is no home the closed set the unavailable screen switches over.
/// 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 wall's copy is aimed at.
/// 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,
@@ -79,6 +82,13 @@ enum CloudHomeResolver {
/// 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")
@@ -91,6 +101,13 @@ enum CloudHomeResolver {
/// 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)
@@ -125,3 +142,59 @@ enum CloudHomeResolver {
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
}
}