import Foundation import os // MARK: - Timestamps /// The one shape a `Date` takes in the registry file — ISO 8601 **with fractional seconds**, UTC. /// /// **Why fractional seconds** rather than `JSONEncoder.DateEncodingStrategy.iso8601`'s whole ones: /// `lastOpened` is not a display stamp, it is the recents list's *sort key* (02-architecture.md /// § Per-board app state, "The recents list *is* this registry sorted by last-opened"). Restoring /// several boards at launch happens well inside one second, and at whole-second resolution those /// rows would tie and order arbitrarily. /// /// **Why `ISO8601DateFormatter`** and not `Date.ISO8601FormatStyle`, the modern value type that /// would be `Sendable` and need none of the ceremony below: the format style *truncates* to the /// millisecond while its own parser rounds, so `parse(format(d)) != d` for about half of all dates /// (measured, not assumed — it drifts a millisecond earlier on each round trip). This formatter is /// an exact fixed point, which is what lets `BoardRegistry.stamp(_:)` guarantee that a record in /// memory and the same record reloaded from disk are *equal*, not merely close. /// /// **Built per use rather than shared**: the formatter is a non-`Sendable` class and the encoder's /// and decoder's date strategies are `@Sendable` closures, so a captured or global instance would /// be an unchecked concurrency promise. One costs microseconds and this file holds a handful of /// records — the safe answer is also the cheap one. private func boardRegistryTimestampFormatter() -> ISO8601DateFormatter { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.timeZone = TimeZone(identifier: "UTC") return formatter } // MARK: - Records /// A board's window frame, remembered per board (02-architecture.md § Windows, "per-board frame /// memory"). /// /// Stored as four `Double`s rather than an `NSRect`/`CGRect` so the persisted JSON is a shape this /// file owns, independent of any framework's `Codable` conformance. Repositioning a frame onto a /// live screen when the saved one is gone is the window layer's job, not this type's — nothing here /// validates the numbers. public struct WindowFrame: Codable, Sendable, Equatable { public var x: Double public var y: Double public var width: Double public var height: Double public init(x: Double, y: Double, width: Double, height: Double) { self.x = x self.y = y self.width = width self.height = height } } /// One known board: everything the app keeps *about* a board that must never be written *into* it. /// /// **Files-first is absolute** (02-architecture.md § Per-board app state): no frontmatter key, no /// sidecar, no xattr — this record lives wholly in Application Support, which is also why two /// machines sharing a board through a remote each keep their own (push-on-commit and window frames /// are genuinely per-machine choices). /// /// The **bookmark is the identity**; `lastKnownPath` is display text and nothing else. Matching an /// opened folder to its record resolves the bookmark and compares file identity, so a board renamed /// or moved on the same volume keeps its settings and its place in recents. /// /// ### Evolving this struct /// /// The synthesized `Codable` conformance rejects a file missing any non-optional key, and /// `BoardRegistry` responds to a rejected file by quarantining it — every record lost. So **a key /// added here must be optional or have a decoding default**, or the addition silently empties every /// existing user's recents on upgrade. That policy, not a version field, is what keeps this format /// readable across releases. public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { public let id: UUID /// The security-scoped bookmark — **the board's identity**, refreshed on every open. Empty only /// in the degenerate case where the system refused to make one at all, which is a record born /// orphaned: it shows in recents with Forget and never matches an open. public var bookmark: Data /// Last-known title or folder name, for the recents row. Display only. public var displayName: String /// Where the board was last seen, for the recents row when the bookmark no longer resolves and /// there is nothing else to show. **Never used for matching** — that is the bookmark's job, and /// a path that matched would reintroduce exactly the identity-by-string bug this design excludes. public var lastKnownPath: String public var lastOpened: Date /// Counts stamped at last close, `nil` until a close has stamped them. /// /// Registry-cached on purpose: the welcome window renders these instead of scanning /// (§ Per-board app state, "no directory scan at welcome time" — which would be slow on a big /// board and would hang on an unavailable one). Staleness until the next open is accepted, and a /// board that has never been closed simply shows no counts. public var laneCount: Int? public var cardCount: Int? public var windowFrame: WindowFrame? /// Whether this board's window is open **right now** — the restoration set, as a live marker /// rather than an at-quit write (02-architecture.md § Launch and window lifecycle, settled). /// /// Set when the board's window opens, cleared on *user-initiated* close; quit's teardown /// deliberately leaves it standing, because the boards open at quit are by definition the ones /// to restore. **Crash recovery falls out for free**: after a crash the flags describe what was /// open at crash time, so the next launch restores exactly that — no separate recovery logic, no /// once-at-quit stamp to race teardown or miss when the app dies. /// /// **Optional because every key here must be** (see Evolving this struct above): a registry file /// written before this key existed decodes with `nil`, which reads as "not open" and costs the /// user nothing. A non-optional `Bool` would have quarantined every existing file on upgrade and /// emptied everyone's recents. public var isOpenNow: Bool? /// Whether committing also pushes (07-sync-collab.md). Off by default: pushing is a decision, /// not a side effect. public var pushOnCommit: Bool /// Whether this board's once-per-board iCloud/network-volume warning has been shown /// (07-sync-collab.md). Once-per-*board*, which is why it lives on the record rather than in /// `UserDefaults`. public var remoteLocationWarned: Bool public init( id: UUID = UUID(), bookmark: Data, displayName: String, lastKnownPath: String, lastOpened: Date, laneCount: Int? = nil, cardCount: Int? = nil, windowFrame: WindowFrame? = nil, isOpenNow: Bool? = nil, pushOnCommit: Bool = false, remoteLocationWarned: Bool = false ) { self.id = id self.bookmark = bookmark self.displayName = displayName self.lastKnownPath = lastKnownPath self.lastOpened = lastOpened self.laneCount = laneCount self.cardCount = cardCount self.windowFrame = windowFrame self.isOpenNow = isOpenNow self.pushOnCommit = pushOnCommit self.remoteLocationWarned = remoteLocationWarned } } /// A recents row: the record, plus whether its board can be reached right now. /// /// The classification is a **bookmark resolution and nothing else** — no `fileExists`, no listing, /// no counting. That is what makes `BoardRegistry.recents()` safe to call while a network volume is /// offline (§ Per-board app state, and § Graceful orphaning for what `unavailable` means to the UI: /// the row shows with Forget rather than disappearing). public enum RecentBoard: Sendable, Equatable { /// Resolved. `at` is where the board lives *now*, which is not necessarily `lastKnownPath` — a /// bookmark follows renames and moves on the same volume. case available(BoardRecord, at: URL) /// The bookmark no longer resolves: deleted, or moved across a volume boundary a bookmark /// cannot follow. Orphaned — "its settings are conveniences and die with it". case unavailable(BoardRecord) public var record: BoardRecord { switch self { case let .available(record, _): record case let .unavailable(record): record } } /// Where the board is now, or `nil` for an orphan. public var url: URL? { switch self { case let .available(_, url): url case .unavailable: nil } } } // MARK: - BoardRegistry /// The persistent side of per-board app state: one record per known board, in Application Support /// (02-architecture.md § Per-board app state). /// /// ### Three rules do most of the work /// /// 1. **Identity, never paths.** An opened folder finds its record by resolving each record's /// bookmark and comparing file identity. A renamed board keeps its settings; a board reopened /// through a symlink or a moved parent does not acquire a second record. /// 2. **Nothing it does touches a board folder.** Not a byte, not an xattr. The one file it writes /// is its own, and it lives somewhere else entirely. /// 3. **It never takes the app down.** A missing file is an empty registry; a corrupt one is moved /// aside and the registry starts empty; a save that fails is logged. Every method here is /// non-throwing on purpose — "its settings are conveniences", and losing a window frame must /// never cost the user a board. /// /// ### Deliberately not here /// /// App-wide state that is not board-scoped — quick-style recents, the SSH host-key table, the /// last-used card-window size — lives *beside* this file, not in it (§ Per-board app state, "App-wide /// state has the same home"). Secrets live in the Keychain and nowhere near here. @MainActor public final class BoardRegistry { /// The JSON file this registry is. Public because a diagnostic ("Reveal registry in Finder") and /// every test want to name it, and because injecting it is how a test stays out of the real /// Application Support directory. public let storageURL: URL private var records: [BoardRecord] private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-registry") /// `~/Library/Application Support//board-registry.json`, inside the sandbox /// container. The bundle-id subfolder is Apple's convention and keeps the app's files together /// as the app-wide neighbours arrive. public static var defaultStorageURL: URL { let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) .appendingPathComponent("Library/Application Support", isDirectory: true) let bundleIdentifier = Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban" return support .appendingPathComponent(bundleIdentifier, isDirectory: true) .appendingPathComponent("board-registry.json", isDirectory: false) } /// Loads the registry, tolerating everything a file on disk can be. /// /// Missing file: an empty registry — the first-launch case, and not an error. **Corrupt file: /// renamed aside with a timestamp and the registry starts empty.** Renamed rather than deleted /// because the file may be the only trace of a user's board list, and a support request can read /// it even when this app cannot; empty rather than fatal because a truncated convenience file /// must not stand between the user and their boards. public init(storageURL: URL) { self.storageURL = storageURL self.records = [] self.records = loadFromDisk() } // MARK: - Opening and closing /// Records that a board was opened, and answers with the id of the record it belongs to. /// /// Matching is the interesting half: every existing record's bookmark is resolved and its file /// identity compared with the opened folder's. A hit is *the* record no matter what path either /// side is spelled with — the done-when criterion for a renamed board keeping its settings. /// Records whose bookmarks no longer resolve are skipped rather than repaired: an orphan is a /// recents row with Forget, not a candidate for a board that is demonstrably somewhere else. /// /// A match is updated in place with a **fresh bookmark** (subsuming the stale-refresh case), the /// caller's `displayName`, the path it was opened at, and `lastOpened` = now. No match creates a /// record. Either way the file is saved before returning. /// /// **`isOpenNow` is deliberately untouched here.** Recording an open and *being* open are two /// different facts: this method is called before a window exists (and, later, by flows that /// record a board without showing one), so the flag is set by `setOpenNow(id:)` once the window /// has actually opened. Folding it in would flag boards that never made it onto screen and hand /// the next launch a restoration set describing failures. @discardableResult public func recordOpen(of rootURL: URL, displayName: String) -> UUID { let bookmark = Self.makeBookmark(for: rootURL)?.data ?? Data() if bookmark.isEmpty { // Both the security-scoped and the plain attempt failed — vanishingly unlikely for a // folder the app has just opened. The record is still created: a row the user can see // and Forget beats a board that silently never joins recents. Self.logger.error("no bookmark could be created for the opened board; its record is born orphaned") } if let index = indexOfRecord(matching: rootURL) { records[index].bookmark = bookmark records[index].displayName = displayName records[index].lastKnownPath = rootURL.path records[index].lastOpened = Self.stamp() save() return records[index].id } let record = BoardRecord( bookmark: bookmark, displayName: displayName, lastKnownPath: rootURL.path, lastOpened: Self.stamp() ) records.append(record) save() return record.id } /// Stamps the counts the welcome window will render for this board until it is opened again /// (§ Per-board app state, "Recents counts are registry-cached ... stamped at last close"). /// /// Called from the close-flush sequence (02-architecture.md § Launch and window lifecycle), /// where the store's last snapshot is still in hand — which is the whole reason the counts are /// free here and expensive anywhere else. public func recordClose(id: UUID, laneCount: Int, cardCount: Int) { update(id) { record in record.laneCount = laneCount record.cardCount = cardCount } } // MARK: - The open-now marker /// Marks this board as open — called when its window has actually opened, not when the open was /// merely attempted (02-architecture.md § Launch and window lifecycle). public func setOpenNow(id: UUID) { update(id) { $0.isOpenNow = true } } /// Clears the marker — **user-initiated close only**. /// /// Quit's teardown must never call this, and that omission is the entire restoration mechanism: /// "quit's teardown closes deliberately leave it standing (the boards were open at quit by /// definition; teardown distinguishes user-close from quit-close, and that distinction is the /// whole mechanism)". A crash calls nothing at all, which is why crash recovery needs no code of /// its own — the flags already describe what was open when the app died. public func clearOpenNow(id: UUID) { update(id) { $0.isOpenNow = false } } /// The boards to reopen at launch: every record still flagged open, **oldest `lastOpened` /// first**. /// /// Ascending, unlike `recents()`, because these are reopened in order and the result should be /// the stacking the user left behind — the most recently opened board ends up frontmost because /// it opens last. Classification is `recents()`' own bookmark resolution, reused rather than /// re-implemented: a flagged board on an unmounted volume is `unavailable` here for exactly the /// reason it is unavailable there, and the launch flow renders it as a failed restoration /// (§ "A restored board that fails surfaces on welcome, row-level") instead of hanging on a /// mount. /// /// The preference gates only whether this is *consulted*; the flags are maintained regardless. public func restorables() -> [RecentBoard] { recents() .filter { $0.record.isOpenNow == true } // Ascending, with the same id tie-break `recents()` uses inverted, so two boards opened // in the same millisecond still come back in one stable order rather than whichever // `sorted(by:)` felt like. .sorted { lhs, rhs in lhs.record.lastOpened == rhs.record.lastOpened ? lhs.record.id.uuidString < rhs.record.id.uuidString : lhs.record.lastOpened < rhs.record.lastOpened } } // MARK: - Per-board settings public func updateWindowFrame(id: UUID, frame: WindowFrame) { update(id) { $0.windowFrame = frame } } public func setPushOnCommit(id: UUID, _ value: Bool) { update(id) { $0.pushOnCommit = value } } /// Marks this board's once-per-board remote-location warning as shown (07-sync-collab.md). /// One-way: there is no un-warn, because the warning is about a location the board is already at. public func setRemoteLocationWarned(id: UUID) { update(id) { $0.remoteLocationWarned = true } } // MARK: - Reading /// Every known board, most recently opened first, each classified by whether its bookmark /// resolves — the recents list, which *is* this registry sorted by last-opened. /// /// **No directory is scanned and nothing is counted here.** Counts come from the records; the /// only filesystem work is bookmark resolution, done with `.withoutUI` and `.withoutMounting` /// so an offline volume classifies as unavailable instead of blocking the welcome window on a /// mount. /// /// Two quiet side effects, both of them the "stale is fine — refresh silently" rule: a bookmark /// that resolves *stale* is replaced with a fresh one, and if any was, the file is saved. A /// stale bookmark still works, but only for a while — refreshing it here is what keeps a board /// that moves around from eventually orphaning itself. `lastKnownPath` is deliberately **not** /// refreshed: it is the display fallback for a record that *cannot* be resolved, so the resolved /// URL, not the record, is what an available row shows. /// /// The sort is total — `lastOpened` descending, then id — because `sorted(by:)` is not stable /// and two rows that tie should still come back in the same order every call. public func recents() -> [RecentBoard] { var resolvedURLs: [UUID: URL] = [:] var refreshedAny = false for index in records.indices { guard let resolution = Self.resolve(records[index].bookmark) else { continue } resolvedURLs[records[index].id] = resolution.url guard resolution.isStale else { continue } if let refreshed = Self.withScopedAccess(to: resolution.url, { Self.makeBookmark(for: $0) }) { records[index].bookmark = refreshed.data refreshedAny = true } } if refreshedAny { save() } return records .sorted { lhs, rhs in lhs.lastOpened == rhs.lastOpened ? lhs.id.uuidString > rhs.id.uuidString : lhs.lastOpened > rhs.lastOpened } .map { record in if let url = resolvedURLs[record.id] { .available(record, at: url) } else { .unavailable(record) } } } public func record(id: UUID) -> BoardRecord? { records.first { $0.id == id } } /// Drops a record — the Forget action on an orphaned recents row, and the only way a record /// leaves. Nothing else prunes: a board that is merely unavailable today may be a remounted /// volume tomorrow, so forgetting is always the user's call. public func forget(id: UUID) { guard let index = indexOfRecord(id) else { return } records.remove(at: index) save() } /// Drops every record — File ▸ Open Recent ▸ Clear Menu (11-command-nexus.md). /// /// **Finder's Clear Menu clears the menu; this registry *is* the menu**, so the equivalence is /// exact: there is no separate recents list that could be emptied while the records stayed, and /// a record whose board never appears anywhere is a setting nothing can reach. It is therefore /// `forget(id:)` applied to every row, and the one test worth writing says exactly that. /// /// One save rather than one per record: the file is rewritten wholesale anyway, and forgetting /// twenty boards should not be twenty writes. public func forgetAll() { guard !records.isEmpty else { return } records.removeAll() save() } // MARK: - Matching /// The index of the record whose bookmark resolves to the same file as `url`, if any. private func indexOfRecord(matching url: URL) -> Int? { guard let target = FileIdentity(of: url) else { return nil } return records.firstIndex { record in guard let resolution = Self.resolve(record.bookmark) else { return false } // Scope is started around the identity read and stopped immediately. Resolving a // security-scoped bookmark grants nothing by itself, and in the sandbox an unscoped // `resourceValues` call on some *other* board's folder is exactly the read that gets // refused — which would silently turn "the same board" into "a new one" and duplicate // the record. The pairing is balanced, so an outer scope this app holds elsewhere is // unaffected. return Self.withScopedAccess(to: resolution.url) { FileIdentity(of: $0) == target } } } private func indexOfRecord(_ id: UUID) -> Int? { records.firstIndex { $0.id == id } } /// Mutates a record and saves. An unknown id is a no-op: a window that outlived its record — /// the user pressed Forget while the board was open — must not crash on its way out. private func update(_ id: UUID, _ mutate: (inout BoardRecord) -> Void) { guard let index = indexOfRecord(id) else { Self.logger.debug("update: no record for this id — ignored") return } mutate(&records[index]) save() } // MARK: - Bookmarks /// A bookmark and which flavor the system actually gave us. struct Bookmark { let data: Data let isSecurityScoped: Bool } /// Creates a bookmark for `url`, security-scoped if the system allows it. /// /// **The security-scoped path is the real one.** In production every board URL arrives through /// NSOpenPanel, a Finder drag, or a previously resolved bookmark, so the app already holds /// access and `.withSecurityScope` succeeds — which is what lets the board reopen after a /// relaunch at all (02-architecture.md § Platform, "security-scoped bookmarks for reopening /// boards across launches"; the `com.apple.security.files.bookmarks.app-scope` entitlement is /// what backs it). /// /// **The plain fallback exists for the cases where that is not true**: a unit test pointing at a /// temp directory the panel never blessed, and a non-sandboxed debug build where the option is /// meaningless. A plain bookmark still tracks renames and moves — the identity story is intact — /// it simply grants no access on resolution, which outside the sandbox is nothing to grant. /// Returning `nil` from both attempts is left to the caller, which records an orphan rather than /// dropping the board. static func makeBookmark(for url: URL) -> Bookmark? { if let data = try? url.bookmarkData(options: [.withSecurityScope]) { return Bookmark(data: data, isSecurityScoped: true) } if let data = try? url.bookmarkData(options: []) { Self.logger.debug("security-scoped bookmark unavailable; fell back to a plain bookmark") return Bookmark(data: data, isSecurityScoped: false) } return nil } struct Resolution { let url: URL let isStale: Bool } /// Resolves a bookmark, mirroring `makeBookmark(for:)`'s two flavors in the same order. /// /// `.withoutUI` and `.withoutMounting`: resolution runs during a recents listing, and a recents /// listing must never put up a dialog or block for seconds mounting a server. A board on an /// unmounted volume is *unavailable right now*, which is precisely what the row should say. /// /// Resolution alone does **not** start security-scoped access — the caller does that around the /// use, balanced. That is why `recents()` can classify every record without ever holding a scope /// open. static func resolve(_ bookmark: Data) -> Resolution? { guard !bookmark.isEmpty else { return nil } var isStale = false if let url = try? URL( resolvingBookmarkData: bookmark, options: [.withSecurityScope, .withoutUI, .withoutMounting], relativeTo: nil, bookmarkDataIsStale: &isStale ) { return Resolution(url: url, isStale: isStale) } isStale = false if let url = try? URL( resolvingBookmarkData: bookmark, options: [.withoutUI, .withoutMounting], relativeTo: nil, bookmarkDataIsStale: &isStale ) { return Resolution(url: url, isStale: isStale) } return nil } /// Runs `body` with security-scoped access held, if this URL has any to hold. /// /// `startAccessingSecurityScopedResource()` returns `false` for a URL that is not /// security-scoped (a plain bookmark's, or anything already inside the container), and then /// there is nothing to stop — so the pairing stays balanced either way. static func withScopedAccess(to url: URL, _ body: (URL) -> T) -> T { let started = url.startAccessingSecurityScopedResource() defer { if started { url.stopAccessingSecurityScopedResource() } } return body(url) } // MARK: - Persistence /// Now, at the resolution the file records — so the registry in memory and the registry on disk /// are *identical*, not merely close. /// /// Stamping through the same formatter the encoder uses makes the round trip exact by /// construction: what `string(from:)` produces, `date(from:)` maps back to the value that will /// be decoded, and that value formats to the same string again. Without this a freshly stamped /// record and its reloaded self would differ in the microseconds no format carries — an equality /// that fails only sometimes, which is the worst kind. private static func stamp(_ date: Date = Date()) -> Date { let formatter = boardRegistryTimestampFormatter() return formatter.date(from: formatter.string(from: date)) ?? date } /// Writes the whole file, atomically, and never throws. /// /// Whole-file because it is a handful of small records and a partial writer would be a /// consistency problem in exchange for nothing. Atomic because a crash mid-write must leave the /// previous file, not half of this one — the same temp-then-rename discipline `BoardWriter` uses /// on the user's own files. A failure is logged and swallowed: the caller is a window close or a /// settings toggle, and neither has anything useful to do about a full disk. private func save() { let encoder = JSONEncoder() // `.sortedKeys` for a byte-stable file (the same records always produce the same bytes) and // `.prettyPrinted` because the one time anybody reads this file by hand is when something // has gone wrong. encoder.outputFormatting = [.sortedKeys, .prettyPrinted] encoder.dateEncodingStrategy = .custom { date, encoder in var container = encoder.singleValueContainer() try container.encode(boardRegistryTimestampFormatter().string(from: date)) } // Sorted by id, not by recency: the array's order carries no meaning (`recents()` sorts), // so pinning it keeps the file from churning every time a board is opened. let ordered = records.sorted { $0.id.uuidString < $1.id.uuidString } do { try FileManager.default.createDirectory( at: storageURL.deletingLastPathComponent(), withIntermediateDirectories: true ) try encoder.encode(ordered).write(to: storageURL, options: .atomic) } catch { Self.logger.error("could not save the board registry: \(error.localizedDescription, privacy: .public)") } } private func loadFromDisk() -> [BoardRecord] { guard FileManager.default.fileExists(atPath: storageURL.path) else { return [] } guard let data = try? Data(contentsOf: storageURL) else { quarantine("unreadable") return [] } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let text = try container.decode(String.self) guard let date = boardRegistryTimestampFormatter().date(from: text) else { throw DecodingError.dataCorruptedError( in: container, debugDescription: "not an ISO 8601 timestamp: \(text)" ) } return date } do { return try decoder.decode([BoardRecord].self, from: data) } catch { quarantine(error.localizedDescription) return [] } } /// Renames the unreadable file aside so the next save starts clean. /// /// Renamed, never deleted: this file may be the only record of which boards a user had, and a /// bug that corrupts it should leave evidence to diagnose rather than a hole. The timestamp /// keeps repeat corruptions from overwriting each other; the id suffix covers two in the same /// millisecond. private func quarantine(_ reason: String) { Self.logger.error("board registry is unreadable (\(reason, privacy: .public)); quarantining it") // The timestamp's colons are legal in a path but read as `/` in Finder, so they go. let stamp = boardRegistryTimestampFormatter() .string(from: Date()) .replacingOccurrences(of: ":", with: "-") let folder = storageURL.deletingLastPathComponent() let base = storageURL.deletingPathExtension().lastPathComponent let fileExtension = storageURL.pathExtension func destination(_ name: String) -> URL { let file = folder.appendingPathComponent(name, isDirectory: false) return fileExtension.isEmpty ? file : file.appendingPathExtension(fileExtension) } var target = destination("\(base)-corrupt-\(stamp)") if FileManager.default.fileExists(atPath: target.path) { target = destination("\(base)-corrupt-\(stamp)-\(UUID().uuidString.prefix(8))") } do { try FileManager.default.moveItem(at: storageURL, to: target) } catch { // Nothing further to do: the next `save()` overwrites the file atomically anyway, so a // failed quarantine costs the evidence, not the registry. Self.logger.error("could not quarantine it: \(error.localizedDescription, privacy: .public)") } } }