import Foundation import Observation import os /// The app's root model: which boards exist on this phone, where each one lives, and what state it /// is in. /// /// **One per process, owned by `MobileApp` and handed down through `.environment`.** It holds both /// homes, the one `NSMetadataQuery` the app runs, and the `BoardSession` cache — all three are /// process-wide facts, and a second instance would mean a second query over the same scope for no /// gain. /// /// ### Two homes, one list /// /// Boards live either in the ubiquity container (`CloudHome`) or in the app's own `Documents/` /// (`DeviceHomeResolver`), and the list is the merge of both, each entry tagged with where it came /// from. The device home always resolves, so **the list is always reachable**: a phone with no iCloud /// account gets an inline notice above its local boards rather than a wall in place of them /// (softened 2026-08-08 — `cloudUnavailable` is what carries that notice, and `phase` no longer has /// an unavailable case to carry it instead). /// /// The two sides are found differently and always will be: the container is watched, the device /// folder is enumerated. Every scan pass therefore takes whatever the cloud side has to offer — a /// query result set, a directory listing under the DEBUG override, or nothing at all — and walks the /// device root itself before merging. /// /// ### The observer flow /// /// The query is the only change signal the cloud side has on this platform (there is no FSEvents /// here — see project.yml ▸ Lanework for iPhone). Both of its notifications are handled identically: /// bracket the result set, pull `Sendable` descriptors out of it on the main actor, then scan and /// assemble off it. The delta keys are deliberately unused — the board count is small enough that a /// full re-enumeration is cheaper than the bookkeeping a delta needs, and re-enumeration cannot drift /// out of sync with the container the way a maintained baseline can. /// /// A gather is not treated as an arrival: it is the first full picture, and it publishes summaries /// exactly as an update does. That differs from the sync-engine skill's monitor, which must baseline /// silently because its consumer replays events into a cache — here the published state *is* the /// query's result set merged with the device folder, so there is no replay to protect. @MainActor @Observable final class BoardIndexStore { /// The three states the boards tab renders. **No unavailable case**: the device home cannot fail, /// so there is always a list to reach, and the one home that *can* fail reports through /// `cloudUnavailable` alongside a live list rather than in place of it. enum Phase: Sendable, Equatable { /// `start()` has not run. case idle /// Resolving the homes, or waiting for the first scan to land. case loading /// The list is live. `boards` may be empty; an empty phone is not a failure, it is the state /// the create-a-board bootstrap exists for. case ready } private(set) var phase: Phase = .idle /// Resolved at most once per process, and `nil` for as long as it has not been — which is a state /// the app runs in perfectly well, minus syncing. private(set) var home: CloudHome? /// Why there is no `home`, or `nil` when there is one. **The notice's whole input**: non-`nil` /// alongside `phase == .ready` is the ordinary no-iCloud run, not an error state. private(set) var cloudUnavailable: CloudHomeUnavailable? /// The sandbox's `Documents/` — the home that always answers. `nil` only before `start()`. private(set) var deviceRoot: URL? /// Every `.kanban` package in either home, sorted by title (case- and diacritic-insensitively, /// tie-broken by path so the order is total and stable across refreshes). Location is a tag on /// each row, not a grouping: two homes are an implementation detail of where a board's bytes are, /// and a person looking for a board is looking for its name. private(set) var boards: [BoardSummary] = [] /// A scan is in flight. Distinct from `phase == .loading`, which is about there being nothing to /// show yet: this stays true through refreshes of a list that is already on screen. private(set) var isScanning = false /// The most recent failure that did not cost the whole list — a refused board create, a move that /// would not land. Cleared by the next successful operation of the same kind. private(set) var lastError: String? private var query: NSMetadataQuery? private var observers: [NSObjectProtocol] = [] /// The in-flight container resolution, so the notice's retry button — and the two tabs that each /// call `start()` on appearance — cannot stack three of them on the daemon at once. private var cloudResolve: Task? /// Only the newest scan applies. Serialization makes staleness unlikely rather than impossible — /// a metadata update landing while a scan runs starts a second one — so the guard is enforced /// rather than assumed, exactly as `BoardStore` does on the Mac. private var scanGeneration = 0 /// The previous pass's **merged** entries, for the unchanged-notification gate in `scan(cloud:force:)`. /// Merged rather than cloud-only on purpose: gating on the query's answer alone would mean a board /// that appeared, vanished or was moved in the device home never reached the list, because nothing /// about it can change what the query reports. private var lastEntries: [BoardIndexEntry]? /// Open sessions, keyed by standardized root URL. Cached so navigating out of a board and back /// into it does not re-materialize and re-walk a package the app already has in hand. private var sessions: [URL: BoardSession] = [:] private static let logger = Logger(subsystem: "dev.rzen.indie.KanbanMobile", category: "cloud") init() {} // MARK: - Lifecycle /// Resolves both homes and starts watching. /// /// The first call does the work. A later one is only ever the iCloud notice's retry — the one home /// that can fail, asked again — which is why it is safe for every screen to call this on /// appearance. func start() { guard case .idle = phase else { resolveCloudHome() return } phase = .loading Task { [weak self] in let root = await DeviceHomeResolver.resolve() guard let self else { return } self.deviceRoot = root self.resolveCloudHome() } } /// Asks for the ubiquity container: once at a time, and never again once it has answered with a /// home. A failure leaves the door open, because signing into iCloud is a thing that happens while /// an app is running. private func resolveCloudHome() { guard home == nil, cloudResolve == nil else { return } cloudResolve = Task { [weak self] in let resolution = await CloudHomeResolver.resolve() guard let self else { return } self.cloudResolve = nil switch resolution { case let .success(home): self.home = home self.cloudUnavailable = nil self.adopt(home) case let .failure(reason): self.cloudUnavailable = reason Self.logger.error("no cloud home: \(reason.description, privacy: .public)") // The device home is still a home, and landing its scan is what keeps both the list // and the phase real without iCloud. The notice above the list carries the rest. self.refresh() } } } /// Re-reads both homes now, whether or not anything looks different. /// /// On the cloud side under the metadata query this is a courtesy — the query already reports every /// change — and it is what pull-to-refresh calls. On the device side it is the *only* refresh there /// is, since a plain directory sends no notifications, and the same is true of the cloud side under /// `LANEWORK_LOCAL_ROOT`. func refresh() { scan(cloud: cloudSource(), force: true) } private func adopt(_ home: CloudHome) { guard home.isWatchable else { scan(cloud: .directory(home.documentsURL), force: true) return } startQuery() } /// What the cloud half of the next scan should read: the query's answer where there is a query, a /// directory listing under the DEBUG override, and nothing at all when iCloud never resolved. private func cloudSource() -> CloudScanSource { guard let home else { return .absent } if home.isWatchable, let query { return .query(readEntries(from: query)) } return .directory(home.documentsURL) } // MARK: - The metadata query /// The predicate is a filename match on `*.kanban`, and it matches **one item per board** rather /// than one per file inside it: the app exports `dev.rzen.indie.kanban-board` conforming to /// `com.apple.package` (KanbanMobile/Info.plist), which is what makes the daemon treat a `.kanban` /// directory as a single document. Without that export this query would return every `index.md` /// in every board. private func startQuery() { guard query == nil else { return } let query = NSMetadataQuery() query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] query.predicate = NSPredicate(format: "%K LIKE %@", NSMetadataItemFSNameKey, "*.kanban") self.query = query observers = [ observe(.NSMetadataQueryDidFinishGathering, from: query), observe(.NSMetadataQueryDidUpdate, from: query), ] // `NSMetadataQuery` is main-thread-only and delivers on the runloop that started it, which is // why this whole type is `@MainActor` — the isolation is the guarantee, not a convention. query.start() } /// The observer block is `@Sendable` and must not carry the `Notification` anywhere: the result /// set is re-read from the query instead, on the main thread the block is already on /// (`queue: .main` is what makes `assumeIsolated` sound here). private func observe(_ name: Notification.Name, from query: NSMetadataQuery) -> NSObjectProtocol { NotificationCenter.default.addObserver(forName: name, object: query, queue: .main) { [weak self] _ in MainActor.assumeIsolated { self?.queryDidFire() } } } /// Gather and update are handled identically — see the type's note on why there is no delta /// bookkeeping here. private func queryDidFire() { guard let query else { return } scan(cloud: .query(readEntries(from: query)), force: false) } /// Pulls plain values out of the result set. Bracketed by `disableUpdates`/`enableUpdates` /// because the query is free to swap its own storage mid-iteration otherwise, and nothing that /// leaves here is an `NSMetadataItem` — that type is not `Sendable` and must never cross to the /// scan. private func readEntries(from query: NSMetadataQuery) -> [BoardIndexEntry] { query.disableUpdates() defer { query.enableUpdates() } var entries: [BoardIndexEntry] = [] entries.reserveCapacity(query.resultCount) for index in 0 ..< query.resultCount { guard let item = query.result(at: index) as? NSMetadataItem, let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL else { continue } entries.append(BoardIndexEntry( rootURL: url.standardizedFileURL, modified: item.value(forAttribute: NSMetadataItemFSContentChangeDateKey) as? Date, download: Self.downloadState(of: item), location: .icloud )) } return entries } private static func downloadState(of item: NSMetadataItem) -> BoardDownloadState { let status = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String let isDownloading = item.value(forAttribute: NSMetadataUbiquitousItemIsDownloadingKey) as? Bool ?? false let percent = item.value(forAttribute: NSMetadataUbiquitousItemPercentDownloadedKey) as? Double if isDownloading { return .downloading(fraction: percent.map { $0 / 100 }) } switch status { case NSMetadataUbiquitousItemDownloadingStatusCurrent, NSMetadataUbiquitousItemDownloadingStatusDownloaded: return .current case NSMetadataUbiquitousItemDownloadingStatusNotDownloaded: return .notDownloaded default: return .unknown } } // MARK: - Scanning /// Where the cloud half of one scan pass comes from. The device half never varies — it is always /// an enumeration of `deviceRoot` — so only this side needs saying. private enum CloudScanSource: Sendable { /// No container. The pass covers the device home alone. case absent /// The metadata query's answer, already flattened on the main actor. case query([BoardIndexEntry]) /// A plain directory standing in for the container (`LANEWORK_LOCAL_ROOT`), enumerated off-main /// with the device root so one pass makes one hop. case directory(URL) } /// One pass: merge both homes, gate, request what is missing, summarize, land. /// /// - Parameter force: run even when the merged answer is identical to the last one. Notifications /// do not set it, because `NSMetadataQueryDidUpdate` fires on upload and download *progress* as /// well as on real changes and a repeat scan of an unchanged container is pure churn — a /// directory walk and a file read per board. An explicit `refresh()` does set it: a /// pull-to-refresh that provably does nothing is worse than a wasted walk. /// /// The gate runs off-main, against a snapshot of `lastEntries` taken here, because the merge it /// gates on is not knowable until the device root has been walked and that walk does not belong on /// the main actor. Two passes racing past a stale snapshot costs one redundant scan and cannot /// land out of order — the generation guard in `land` is what makes that true. private func scan(cloud: CloudScanSource, force: Bool) { scanGeneration += 1 let generation = scanGeneration let previous = lastEntries let device = deviceRoot isScanning = true Task.detached(priority: .userInitiated) { [weak self] in var entries: [BoardIndexEntry] switch cloud { case .absent: entries = [] case let .query(reported): entries = reported case let .directory(root): entries = Self.enumerateBoards(under: root, location: .icloud) } if let device { entries.append(contentsOf: Self.enumerateBoards(under: device, location: .local)) } guard force || entries != previous else { await self?.scanFoundNoChange(generation: generation) return } // The one write this whole path makes: a board that is in the cloud and not here is asked // for. Requested off-main with the scan because `startDownloadingUbiquitousItem` talks to // the daemon, and requested on every pass because the daemon drops requests under memory // pressure and a repeat is free. for entry in entries where entry.download == .notDownloaded { try? FileManager.default.startDownloadingUbiquitousItem(at: entry.rootURL) } let summaries = entries.map(BoardSummaryScanner.scan).sorted(by: Self.displayOrder) await self?.land(summaries, entries: entries, generation: generation) } } private func land(_ summaries: [BoardSummary], entries: [BoardIndexEntry], generation: Int) { guard generation == scanGeneration else { return } lastEntries = entries boards = summaries isScanning = false phase = .ready // Every open session hears about it: the metadata query is also the only signal a board's // *contents* changed remotely, and a session waiting on materialization is waiting on // exactly this notification. let live = Set(summaries.map(\.rootURL)) for (root, session) in sessions where live.contains(root) { session.containerDidUpdate() } } /// The gated pass's only effect. `phase` is deliberately untouched: a gate can only fire against a /// non-`nil` `lastEntries`, which means a previous pass already landed and already said `.ready`. private func scanFoundNoChange(generation: Int) { guard generation == scanGeneration else { return } isScanning = false } /// Title order, case- and diacritic-insensitive, tie-broken by path. Total and stable, so a /// refresh never reshuffles rows that did not change — and so the two homes interleave by name /// rather than clumping by where the merge happened to put them. private nonisolated static func displayOrder(_ lhs: BoardSummary, _ rhs: BoardSummary) -> Bool { switch lhs.title.localizedStandardCompare(rhs.title) { case .orderedAscending: true case .orderedDescending: false case .orderedSame: lhs.rootURL.path < rhs.rootURL.path } } /// `.kanban` directories directly inside `root`, tagged with the home they were found in. The name /// gate matches the metadata query's predicate exactly — an extension-less board folder loads fine /// but is not a *document*, and the index is a list of documents. private nonisolated static func enumerateBoards(under root: URL, location: BoardLocation) -> [BoardIndexEntry] { let contents = (try? FileManager.default.contentsOfDirectory( at: root, includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], options: [.skipsHiddenFiles] )) ?? [] return contents.compactMap { url in guard url.pathExtension == "kanban", let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .contentModificationDateKey]), values.isDirectory == true else { return nil } return BoardIndexEntry( rootURL: url.standardizedFileURL, modified: values.contentModificationDate, download: .unknown, location: location ) } } // MARK: - Creating a board /// Creates an empty board and answers where it landed — the empty-phone bootstrap, and one of the /// two writes this store makes. /// /// **iCloud when there is iCloud, this phone otherwise.** There is no picker: a board the user can /// sync is always the better default, and the one case where that is not available is the one case /// where asking would be a question with a single answer. Moving it afterwards is what /// `relocateBoard(at:to:)` is for, and the create prompt says which home it is about to use. /// /// The folder name comes from the title, because on this platform the document name *is* what the /// user sees in Files.app (01-storage-format.md § Board naming — the app writes the title key and /// names the folder to match). A collision appends a counter rather than failing: two boards /// called "Work" is a thing a person may reasonably want. /// /// Coordinated as a write on the new package's URL, then a refresh — the query would report the /// new board on its own within a second, but a create that does not immediately show its result /// is a create that looks broken, and a board created on the device is reported by nothing at all. @discardableResult func createBoard(titled title: String) async -> Result { guard let documents = home?.documentsURL ?? deviceRoot else { return .failure(.noHome) } let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) let outcome: Result = await Task.detached(priority: .userInitiated) { let rootURL = Self.availableBoardURL(for: trimmed, in: documents) let coordinated = CoordinatedFileAccess.write(itemAt: rootURL) { resolved -> Result in do throws(BoardWriteError) { try BoardWriter.createBoard(at: resolved, title: trimmed.isEmpty ? nil : trimmed) return .success(resolved) } catch { return .failure(error) } } switch coordinated { case let .failure(failure): return .failure(.coordination(failure)) case let .success(inner): return inner.mapError(BoardCreateFailure.write) } }.value switch outcome { case let .success(url): lastError = nil refresh() return .success(url.standardizedFileURL) case let .failure(failure): lastError = failure.description Self.logger.error("board create failed: \(failure.description, privacy: .public)") return .failure(failure) } } /// A free `.kanban` under `documents`, for a board being named from a title. private nonisolated static func availableBoardURL(for title: String, in documents: URL) -> URL { availableBoardURL(named: sanitizedFolderName(title), in: documents) } /// A free `.kanban` under `documents`. Blocking (it stats), so it runs with the write it is /// choosing a destination for — and it is shared by create and move so the two cannot disagree /// about what "already taken" means. private nonisolated static func availableBoardURL(named base: String, in documents: URL) -> URL { var candidate = documents.appendingPathComponent("\(base).kanban", isDirectory: true) var counter = 2 while FileManager.default.fileExists(atPath: candidate.path) { candidate = documents.appendingPathComponent("\(base) \(counter).kanban", isDirectory: true) counter += 1 } return candidate } /// Path separators and colons out (the two characters a file name cannot survive), leading dots /// out (a board must not be hidden from its own index), length capped well under the 255-byte /// limit so the `.kanban` suffix and a collision counter always fit. private nonisolated static func sanitizedFolderName(_ title: String) -> String { let stripped = title .components(separatedBy: CharacterSet(charactersIn: "/:\\")) .joined(separator: " ") .trimmingCharacters(in: .whitespacesAndNewlines) let unhidden = stripped.drop(while: { $0 == "." }) let name = unhidden.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { return "Board" } return String(name.prefix(120)) } // MARK: - Moving a board between homes /// Moves one board to the other home and answers where it landed. /// /// **The only operation in the app that changes a board's identity.** A board *is* its root URL /// (`BoardSummary.id`, `BoardSession.rootURL`), so a move retires one identity and mints another; /// the cached session is closed and dropped first, and the settings sheet this is called from /// dismisses on success, because the alternative is a screen holding a URL that no longer names /// anything. /// /// **`setUbiquitous` is the move**, not `moveItem`. Dragging bytes across the ubiquity boundary /// with `FileManager.moveItem` produces a folder in the right place with the wrong status — still /// ubiquitous, or never ubiquitous — and the daemon is then entitled to reconcile it away. The one /// API that transfers ubiquity along with the bytes is this one, and it is off-main because it /// talks to the daemon for as long as that takes. /// /// Evacuating iCloud asks for every byte at once, so the materialization sweep runs first — the /// same sweep a load runs, which also *requests* whatever is missing. A board that is not all here /// yet is a "try again in a moment", not a dead end. @discardableResult func relocateBoard(at rootURL: URL, to destination: BoardLocation) async -> Result { let source = rootURL.standardizedFileURL guard let deviceRoot else { return .failure(.noHome) } let destinationRoot: URL switch destination { case .icloud: guard let documents = home?.documentsURL else { return .failure(.noCloudHome) } destinationRoot = documents case .local: destinationRoot = deviceRoot } guard source.deletingLastPathComponent().standardizedFileURL != destinationRoot.standardizedFileURL else { return .failure(.alreadyThere) } forgetSession(forBoardAt: source) let base = source.deletingPathExtension().lastPathComponent // Only a real container has ubiquity to transfer. Under `LANEWORK_LOCAL_ROOT` both homes are // ordinary directories, so the move is an ordinary move — which is also the path the UI tests // exercise, and the reason they can exercise it without an iCloud account. let isUbiquitous = home?.origin == .ubiquityContainer let outcome: Result = await Task.detached(priority: .userInitiated) { let target = Self.availableBoardURL(named: base, in: destinationRoot) guard isUbiquitous else { let coordinated = CoordinatedFileAccess.write(itemAt: source) { resolved -> Result in do { try FileManager.default.moveItem(at: resolved, to: target) return .success(target) } catch { return .failure(.move(message: error.localizedDescription)) } } switch coordinated { case let .failure(failure): return .failure(.coordination(failure)) case let .success(inner): return inner } } if destination == .local { let progress = PackageMaterialization.sweep(packageAt: source) guard progress.isComplete else { return .failure(.stillDownloading) } } do { try FileManager.default.setUbiquitous( destination == .icloud, itemAt: source, destinationURL: target ) return .success(target) } catch let error as CocoaError where error.code == .ubiquitousFileUnavailable { // The sweep said everything was here and the daemon disagreed — a race with an // eviction, or a file that went dataless between the walk and the move. Same sentence // either way, because the same thing fixes it: wait, then ask again. return .failure(.stillDownloading) } catch { return .failure(.ubiquity(message: error.localizedDescription)) } }.value switch outcome { case let .success(url): lastError = nil refresh() return .success(url.standardizedFileURL) case let .failure(failure): lastError = failure.description Self.logger.error("board move failed: \(failure.description, privacy: .public)") return .failure(failure) } } // MARK: - Sessions /// The session for one board, minted on first ask and kept. /// /// Returned unopened: a view calls `open()` when it appears, which is what starts the /// materialization sweep. Two callers asking for the same root get the same object, so a /// navigation stack that holds a board list and a lane screen shares one snapshot. func session(forBoardAt rootURL: URL) -> BoardSession { let key = rootURL.standardizedFileURL if let existing = sessions[key] { return existing } let session = BoardSession(rootURL: key) sessions[key] = session return session } /// Drops a cached session and stops its retry timer. Call when a board's screen is gone for good — /// or when the board's URL is about to stop naming it, which is what a move does; keeping it /// otherwise costs one snapshot's memory, which is why nothing calls this automatically. func forgetSession(forBoardAt rootURL: URL) { let key = rootURL.standardizedFileURL sessions[key]?.close() sessions[key] = nil } } /// Why a board create did not happen. enum BoardCreateFailure: Error, Sendable, Equatable, CustomStringConvertible { /// Neither home has resolved yet — the create was asked for before `start()` finished, which is /// the only window in which the app has nowhere at all to put a board. case noHome case coordination(CoordinationFailure) case write(BoardWriteError) var description: String { switch self { case .noHome: "there is nowhere to put a board yet" case let .coordination(failure): failure.description case let .write(error): error.description } } } /// Why a board did not move. Same shape as `BoardCreateFailure`: a closed set, each case a sentence /// the settings sheet can show as-is. enum BoardRelocateFailure: Error, Sendable, Equatable, CustomStringConvertible { /// The device home has not resolved — before `start()` finished, and nowhere else. case noHome /// iCloud was asked for as a destination and there is none. case noCloudHome /// The board is already in the home it was asked to move to. case alreadyThere /// Bytes are still coming down from iCloud, so there is nothing here to move out. case stillDownloading case coordination(CoordinationFailure) /// A plain move refused — the DEBUG stand-in path. case move(message: String) /// `setUbiquitous` refused for a reason other than missing bytes. case ubiquity(message: String) var description: String { switch self { case .noHome: "there is nowhere to move the board to yet" case .noCloudHome: "iCloud Drive is not available" case .alreadyThere: "the board is already stored there" case .stillDownloading: "still downloading from iCloud — try again once it finishes" case let .coordination(failure): failure.description case let .move(message): message case let .ubiquity(message): message } } }