Wire the open-time writability probe and read-only lock
Closes the gap found at m10: enterUnwritableLock existed with zero call
sites. WritabilityProbe classifies the cause volume-first - a board on a
read-only DMG is also permission-denied by access(2), and "you don't
have permission" would send the user to a Get Info panel that cannot
help - with a pure classify(volumeIsReadOnly:isWritable:) truth table
and a two-syscall probe that rebuilds its URL to defeat NSURL resource
caching. ReadOnlyLockReason.unwritableLocation now carries the cause;
BannerCenter phrases the two ("this board's volume is read-only" vs
"you don't have permission to change this folder").
The probe wires once in BoardStoreRegistry.acquire, immediately after
the store loads - every open path funnels through it, and running
before the loose-file relocation and agent-guide hooks makes the
skipped-with-log guide write true by construction (its isWritableFile
pre-check demotes to second line of defense). The board still opens:
lock, not refusal.
The reconciling re-probe is now symmetric per 02's settled text - a
volume gone read-only mid-session raises the lock at the next probe
(sibling locks settle first, so a root returning read-only lands the
honest lock); the stale "deliberately one-way" comment and its pinning
test are gone. Save as Template's carve-out predicate extracted to a
testable allowsSave (behavior unchanged); Duplicate stays disabled.
11 tests added. 1649 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -165,8 +165,9 @@ public struct InProgressOperation: Identifiable, Sendable {
|
||||
/// and a changing *reason* updates the row rather than replacing it — no view churn, no lost
|
||||
/// animation, and no diffing surprise when a lock's cause changes underneath a standing row.
|
||||
public enum BannerRow: Identifiable, Sendable {
|
||||
/// The board refuses writes. Condition, error tone. Producers: the failed bracketed reload
|
||||
/// (built), the vanished root (this milestone), the open-time writability probe (m4).
|
||||
/// The board refuses writes. Condition, error tone. Producers: the failed bracketed reload, the
|
||||
/// vanished root, and the writability probe — at open and, symmetrically, on every reconciling
|
||||
/// reload thereafter.
|
||||
case readOnlyLock(ReadOnlyLockReason)
|
||||
/// A reload failed and the last good snapshot is still on screen. Condition, error tone.
|
||||
case reloadBreakage(BoardLoadError)
|
||||
@@ -804,14 +805,21 @@ public final class BannerCenter {
|
||||
/// what is on screen is still the last good view — because the lock's whole promise is that
|
||||
/// nothing was lost: reading, selecting, searching and copying out all stay live (02 § "The
|
||||
/// lock's scope").
|
||||
///
|
||||
/// **The unwritable location gets two lines, not one shared one** (02 § Write-failure
|
||||
/// surfacing, settled): "which specific cause, not a shared line … the fixes being different
|
||||
/// acts". Ejecting a DMG or copying the board off it is not the same repair as a `chmod` or a
|
||||
/// Get Info panel, and a line that covered both would name neither.
|
||||
public nonisolated static func headline(for lock: ReadOnlyLockReason) -> String {
|
||||
switch lock {
|
||||
case .bracketedReloadFailed:
|
||||
"This board couldn't be re-read after the last operation — showing the last good view, read-only"
|
||||
case .vanishedRoot:
|
||||
"This board's folder is gone — showing the last good view, read-only"
|
||||
case .unwritableLocation:
|
||||
"This board's location can't be written to — showing the last good view, read-only"
|
||||
case .unwritableLocation(.readOnlyVolume):
|
||||
"This board's volume is read-only — showing the last good view, read-only"
|
||||
case .unwritableLocation(.permissionDenied):
|
||||
"You don't have permission to change this folder — showing the last good view, read-only"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,17 +38,24 @@ public enum ReadOnlyLockReason: Sendable, Equatable {
|
||||
/// normally.
|
||||
case vanishedRoot
|
||||
|
||||
/// The board opened somewhere it cannot be written: a read-only volume (a DMG, a snapshot, a
|
||||
/// The board is somewhere it cannot be written: a read-only volume (a DMG, a snapshot, a
|
||||
/// read-only share) or a permission-denied folder (02-architecture.md § Write-failure
|
||||
/// surfacing, "An unwritable board location enters the read-only lock at open"). Failing
|
||||
/// loudly, specifically, *once* beats letting every gesture fail one at a time.
|
||||
///
|
||||
/// **Clears only on a successful *reconciling* reload whose writability re-probe passes** —
|
||||
/// unlike its two siblings, whose cause a successful reload disproves by itself. A board on a
|
||||
/// read-only DMG reloads perfectly all day long; only the probe (§ "Writability re-probes on
|
||||
/// every reconciling reload" — wake, activation) can tell that the permission or the mount
|
||||
/// actually changed.
|
||||
case unwritableLocation
|
||||
/// **It carries which** (settled): "the probe distinguishes read-only volume from
|
||||
/// permission-denied folder and the lock reason carries it … the fixes being different acts".
|
||||
/// The payload is the *only* thing the two spellings of this lock differ in — same scope, same
|
||||
/// clearing rule — so it is an associated value rather than two cases, and `BannerCenter` turns
|
||||
/// it into the one line the user reads.
|
||||
///
|
||||
/// **Raised and cleared by the probe, not by the reload's success** — unlike its two siblings,
|
||||
/// whose cause a successful reload disproves by itself. A board on a read-only DMG reloads
|
||||
/// perfectly all day long; only the probe (§ "Writability re-probes on every reconciling
|
||||
/// reload" — wake, activation) can tell that the permission or the mount changed, in *either*
|
||||
/// direction: it clears a lock whose cause is gone and raises one whose cause has appeared
|
||||
/// mid-session (§ "the probe is symmetric").
|
||||
case unwritableLocation(UnwritableCause)
|
||||
}
|
||||
|
||||
/// The refusal `BoardStore.performWrite` throws when the board is locked read-only.
|
||||
@@ -760,18 +767,21 @@ public final class BoardStore {
|
||||
reloadFailure = nil
|
||||
looseCardFiles = result.looseCardFiles
|
||||
legacyTombstones = result.legacyTombstones
|
||||
clearLockIfDisproved(by: origin)
|
||||
reconcileLock(after: origin)
|
||||
// The registry write-through, for the same "not board structure" reason the lock
|
||||
// clearing sits out here: whether this board's row needs a new title, icon, or
|
||||
// iconColor is the registry's question to answer (`syncDisplayState`'s own no-op
|
||||
// guard), not a decision this store makes by comparing against its own prior
|
||||
// snapshot.
|
||||
displayStateDelegate?()
|
||||
// Last, and after `clearLockIfDisproved` deliberately: this is the seam the two
|
||||
// deferred app-initiated writes are armed on. A board that was locked read-only
|
||||
// tolerated its loose files and its legacy tombstones for exactly as long as the lock
|
||||
// stood, and the reload that clears the lock is the reload that lets them move — see
|
||||
// `relocateLooseCardFiles()` and `migrateLegacyTombstones()`.
|
||||
// Last, and after `reconcileLock` deliberately: this is the seam the two deferred
|
||||
// app-initiated writes are armed on. A board that was locked read-only tolerated its
|
||||
// loose files and its legacy tombstones for exactly as long as the lock stood, and the
|
||||
// reload that clears the lock is the reload that lets them move — see
|
||||
// `relocateLooseCardFiles()` and `migrateLegacyTombstones()`. The ordering cuts the
|
||||
// other way too now that the probe is symmetric: a reconciling reload that *raises* the
|
||||
// lock raises it before these three run, so none of them writes into a location the
|
||||
// same reload just learned is read-only.
|
||||
//
|
||||
// The migration goes second only because the relocation is the older rule; they touch
|
||||
// disjoint files (loose files beside an `index.md` vs the `deleted:` key inside one) and
|
||||
@@ -890,9 +900,10 @@ public final class BoardStore {
|
||||
startReload(origin)
|
||||
}
|
||||
|
||||
// MARK: - The lock's clearing rules
|
||||
// MARK: - The lock's reconciliation rules
|
||||
|
||||
/// Clears the read-only lock if this successful reload actually disproved its cause.
|
||||
/// Brings the read-only lock into line with what this successful reload — and, on a reconciling
|
||||
/// one, a fresh writability probe — actually proves.
|
||||
///
|
||||
/// **Reason-specific, because the causes are not alike** (02-architecture.md § Write-failure
|
||||
/// surfacing):
|
||||
@@ -902,28 +913,54 @@ public final class BoardStore {
|
||||
/// "the root is gone" — a completed tree walk at the root contradicts both, whatever origin
|
||||
/// asked for it, so any success clears them.
|
||||
/// - `.unwritableLocation` is not. A board on a read-only DMG reloads flawlessly forever;
|
||||
/// loading proves nothing about writing. It clears only when a **reconciling** reload — wake,
|
||||
/// activation, a stream re-creation — re-probes writability and finds it changed ("Writability
|
||||
/// re-probes on every reconciling reload, so a fixed permission or rewritable remount clears
|
||||
/// the lock without ceremony").
|
||||
/// loading proves nothing about writing. Only the probe can speak to it, and the probe runs on
|
||||
/// **reconciling** reloads — wake, activation, a stream re-creation — because those are the
|
||||
/// reloads that admit a blind window ("Writability re-probes on every reconciling reload").
|
||||
///
|
||||
/// `FileManager.isWritableFile(atPath:)` is `access(2)` on the root directory: a real-uid
|
||||
/// permission question asked of the filesystem, which is what makes it answer correctly for
|
||||
/// both halves of the case — a read-only *mount* and a permission-denied *folder*.
|
||||
/// ### The probe is symmetric (settled)
|
||||
///
|
||||
/// Deliberately **one-way**: a reconciling reload that finds the root unwritable does not
|
||||
/// *raise* the lock. Arming it is the open flow's job (`enterUnwritableLock()`), and inferring
|
||||
/// a lock from a probe here would be a policy decision this milestone was not asked to make.
|
||||
private func clearLockIfDisproved(by origin: WatchOrigin) {
|
||||
/// It clears *and* raises. "A rewritable remount or fixed permission clears the lock without
|
||||
/// ceremony, and a volume gone read-only mid-session *raises* it at the next probe — banner up
|
||||
/// front, not every gesture failing one at a time (the lock's own founding rationale)." Between
|
||||
/// probes a write that hits the newly read-only volume fails as an ordinary one-shot; this is
|
||||
/// the line that converts that condition into the standing lock.
|
||||
///
|
||||
/// A raise here is deliberately **not** routed through `enterUnwritableLock(_:)`: that method
|
||||
/// speaks its own sentence, and this runs inside `land`, which posts exactly one announcement
|
||||
/// per reload from the before/after pictures it already holds. Two voices for one lock is the
|
||||
/// bug `announceLockChange` exists to avoid.
|
||||
///
|
||||
/// The sibling locks are settled *before* the probe, so a reconciling reload that clears a
|
||||
/// vanished root on a volume that came back read-only ends with the honest lock rather than no
|
||||
/// lock at all. And a standing `.unwritableLocation` whose cause *changed* — a permission-denied
|
||||
/// folder whose volume was then remounted read-only — re-lands with the new cause, updating the
|
||||
/// row's line rather than replacing the row (`BannerRow.id` is constant per condition).
|
||||
private func reconcileLock(after origin: WatchOrigin) {
|
||||
switch readOnlyLock {
|
||||
case nil:
|
||||
break
|
||||
case .bracketedReloadFailed, .vanishedRoot:
|
||||
readOnlyLock = nil
|
||||
case .unwritableLocation:
|
||||
guard origin == .reconciling, FileManager.default.isWritableFile(atPath: rootURL.path) else { return }
|
||||
case .unwritableLocation, nil:
|
||||
break
|
||||
}
|
||||
|
||||
guard origin == .reconciling else { return }
|
||||
|
||||
switch (readOnlyLock, WritabilityProbe.probe(rootURL)) {
|
||||
case (.unwritableLocation, nil):
|
||||
Self.logger.debug("writability re-probe passed — the unwritable-location lock clears")
|
||||
readOnlyLock = nil
|
||||
case let (.unwritableLocation, .some(cause)):
|
||||
// Still unwritable. The assignment is not a no-op only when the *cause* moved.
|
||||
readOnlyLock = .unwritableLocation(cause)
|
||||
case let (nil, .some(cause)):
|
||||
Self.logger.error("writability re-probe failed (\(cause.rawValue, privacy: .public)) — the read-only lock rises")
|
||||
readOnlyLock = .unwritableLocation(cause)
|
||||
case (nil, nil):
|
||||
break
|
||||
// Cleared above, so unreachable — spelled so a new lock reason is a compile error here
|
||||
// rather than a silent fall-through past the probe.
|
||||
case (.bracketedReloadFailed, _), (.vanishedRoot, _):
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -960,18 +997,37 @@ public final class BoardStore {
|
||||
announceLockChange(from: before)
|
||||
}
|
||||
|
||||
/// Raises the unwritable-location read-only lock — the open flow's call, after probing the
|
||||
/// root's writability (02 § "An unwritable board location enters the read-only lock at open").
|
||||
/// Public now so the vocabulary and its clearing rule ship together; m4's open flow is the
|
||||
/// producer.
|
||||
/// **The open-time writability probe** (02 § "An unwritable board location enters the read-only
|
||||
/// lock at open") — `BoardStoreRegistry.acquire`'s call, and the only place the lock is raised
|
||||
/// outside a reconciling reload.
|
||||
///
|
||||
/// A no-op on a writable board, which is the overwhelming case, and one `access(2)` plus one
|
||||
/// volume resource value when it is not — cheap enough to sit unconditionally on the open path.
|
||||
///
|
||||
/// **The open still succeeds.** Nothing here refuses the board or throws: the lock's read
|
||||
/// affordances stay live as always, because "inspecting an archived board on a DMG is a
|
||||
/// legitimate errand, and viewing-first is the point". All that changes is that every mutating
|
||||
/// entry point now consults a predicate that is already `true` before the window can be acted
|
||||
/// on — the lock is up *before* the user's first gesture, which is the whole of "fail loudly,
|
||||
/// specifically, once".
|
||||
public func probeWritabilityAtOpen() {
|
||||
guard let cause = WritabilityProbe.probe(rootURL) else { return }
|
||||
enterUnwritableLock(cause)
|
||||
}
|
||||
|
||||
/// Raises the unwritable-location read-only lock with the cause the probe found.
|
||||
///
|
||||
/// Public because the probe is not the only conceivable producer and because tests arm it
|
||||
/// directly; `probeWritabilityAtOpen()` is the app's own path to it.
|
||||
///
|
||||
/// Does **not** overwrite a standing lock: a board that is already locked for a vanished root
|
||||
/// or a failed bracketed reload has a cause that outranks "and it is also read-only", and both
|
||||
/// of those clear on a success that would then re-probe anyway.
|
||||
public func enterUnwritableLock() {
|
||||
/// of those clear on a success that would then re-probe anyway (`reconcileLock(after:)` settles
|
||||
/// the siblings first, then probes, precisely so that reload lands on the right answer).
|
||||
public func enterUnwritableLock(_ cause: UnwritableCause) {
|
||||
guard readOnlyLock == nil else { return }
|
||||
Self.logger.error("board location is not writable — entering the read-only lock")
|
||||
readOnlyLock = .unwritableLocation
|
||||
Self.logger.error("board location is not writable (\(cause.rawValue, privacy: .public)) — entering the read-only lock")
|
||||
readOnlyLock = .unwritableLocation(cause)
|
||||
announceLockChange(from: nil)
|
||||
}
|
||||
|
||||
@@ -2756,8 +2812,10 @@ public final class BoardStore {
|
||||
/// locked board returns here having written nothing **and having remembered nothing**, so the
|
||||
/// next attempt is a fresh one. The arming seam is `land(_:generation:origin:)`: every lock
|
||||
/// clears on a successful reload and nowhere else, and this runs at the end of every successful
|
||||
/// reload, after `clearLockIfDisproved` — so the reload that lifts the lock is the reload that
|
||||
/// performs the relocation, with no timer, no queue, and no second state to keep in step.
|
||||
/// reload, after `reconcileLock(after:)` — so the reload that lifts the lock is the reload that
|
||||
/// performs the relocation, with no timer, no queue, and no second state to keep in step. The
|
||||
/// same ordering covers the other direction, now that the probe is symmetric: a reconciling
|
||||
/// reload that *raises* the unwritable-location lock raises it before this runs.
|
||||
///
|
||||
/// ### It cannot hot-loop
|
||||
///
|
||||
@@ -3581,12 +3639,17 @@ public final class BoardStore {
|
||||
///
|
||||
/// Two gates, because two different things can be true. `performWrite` would refuse under the
|
||||
/// read-only lock on its own, but that refusal is a thrown error and this is not a gesture — so
|
||||
/// the lock is checked first, the relocation's own deferral idiom. The writability probe beside
|
||||
/// it covers the case the lock does not: 02-architecture.md's open-time unwritable-root lock is a
|
||||
/// separate card, and until it lands a board on a read-only volume would reach the Writer, fail,
|
||||
/// and post a banner about a file the user never asked for. 02 settles that exact case the other
|
||||
/// way — "the open-time agent-guide write is skipped-with-log, the `CLAUDE.user.md`-taken
|
||||
/// precedent" — so it is skipped with a log.
|
||||
/// the lock is checked first, the relocation's own deferral idiom. That gate is now the one that
|
||||
/// actually fires on an unwritable board: `BoardStoreRegistry.acquire` probes writability
|
||||
/// *before* it calls this method, so 02-architecture.md's "the open-time agent-guide write is
|
||||
/// skipped-with-log, the `CLAUDE.user.md`-taken precedent" is honored by the lock being up
|
||||
/// rather than by this method noticing on its own.
|
||||
///
|
||||
/// The `access(2)` check below stays anyway, as the second line of defense: it is the only gate
|
||||
/// covering the window *between* probes — a volume remounted read-only mid-session raises the
|
||||
/// lock at the next reconciling reload, and a reload landing in that window would otherwise
|
||||
/// reach the Writer, fail, and post a banner about a file the user never asked for. Both skips
|
||||
/// are logged and neither is ever surfaced.
|
||||
///
|
||||
/// ### It cannot hot-loop
|
||||
///
|
||||
|
||||
@@ -146,6 +146,22 @@ public final class BoardStoreRegistry {
|
||||
|
||||
let store = try BoardStore(rootURL: rootURL)
|
||||
|
||||
// **The open-time writability probe** (02-architecture.md § Write-failure surfacing, "An
|
||||
// unwritable board location enters the read-only lock at open"), and this is the seam for
|
||||
// it: every way a board opens — welcome's recents, a Finder open, File ▸ Open, restoration,
|
||||
// a card window arriving first — funnels through `acquire`, so the probe is wired once here
|
||||
// instead of at each caller, and no future open path can forget it.
|
||||
//
|
||||
// **First, immediately after the load.** The lock has to be standing before anything else
|
||||
// in this method can act on the board, and two things below would otherwise write into a
|
||||
// location this line already knows is read-only: the loose-file relocation and the agent
|
||||
// guide. Ordering them after the probe is what makes 02's "the open-time agent-guide write
|
||||
// is skipped-with-log" true by construction rather than by that method's own second gate.
|
||||
//
|
||||
// The board still opens. This is a lock, not a refusal: the window comes up, the snapshot
|
||||
// renders, and reading, selecting, searching and copying out all stay live.
|
||||
store.probeWritabilityAtOpen()
|
||||
|
||||
// Unreachable in practice — the load above just walked this directory — but the alternative
|
||||
// is a force-unwrap on a resource value the filesystem is free to refuse, so it is spelled
|
||||
// out. The loader's own vocabulary says it; no new error path is invented for a case that
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - UnwritableCause
|
||||
|
||||
/// **Why** a board's location refuses writes — the half of `ReadOnlyLockReason.unwritableLocation`
|
||||
/// the user is actually told about (02-architecture.md § Write-failure surfacing, settled: "the
|
||||
/// probe distinguishes read-only volume from permission-denied folder and the lock reason carries
|
||||
/// it … the fixes being different acts").
|
||||
///
|
||||
/// Two cases and no `.unknown`: the probe below is total — it asks two questions of the filesystem
|
||||
/// and every answer maps onto one of these or onto "writable". A third case would be a shrug in the
|
||||
/// one place the design forbids one, since the whole point of naming the cause is that ejecting a
|
||||
/// DMG and running `chmod` are not the same repair.
|
||||
public enum UnwritableCause: String, Sendable, Equatable, CaseIterable {
|
||||
/// The volume the board sits on is mounted read-only — a DMG, an APFS snapshot, a read-only
|
||||
/// share. Nothing on it can be written, by anybody, until it is remounted.
|
||||
case readOnlyVolume
|
||||
|
||||
/// The volume is writable; this *folder* is not — POSIX permissions, an ACL, an owner that is
|
||||
/// not us. A `chmod`, a Get Info panel, or a move somewhere else fixes it.
|
||||
case permissionDenied
|
||||
}
|
||||
|
||||
// MARK: - WritabilityProbe
|
||||
|
||||
/// The board root's writability, asked of the filesystem and classified — the open-time probe of
|
||||
/// 02-architecture.md § Write-failure surfacing ("An unwritable board location enters the read-only
|
||||
/// lock at open"), re-run on every reconciling reload because that rule is **symmetric**: a fixed
|
||||
/// permission or a rewritable remount clears the lock, and a volume gone read-only mid-session
|
||||
/// raises it.
|
||||
///
|
||||
/// ### Two questions, and the order they are asked in matters
|
||||
///
|
||||
/// `FileManager.isWritableFile(atPath:)` is `access(2)` with `W_OK`: a real-uid permission question
|
||||
/// answered by the filesystem itself, which is why it is trustworthy where a stat of the mode bits
|
||||
/// would not be (ACLs, sandbox denials, and read-only mounts all show up in it). But it answers
|
||||
/// `false` for **both** halves of this case — a read-only volume fails `access(2)` exactly like a
|
||||
/// `r-x` folder does — so on its own it can only say "no", never "why".
|
||||
///
|
||||
/// `URLResourceKey.volumeIsReadOnlyKey` is what separates them, and it is therefore asked **first**.
|
||||
/// The precedence is not a tie-break: a board on a mounted DMG *is* on a read-only volume and it is
|
||||
/// also permission-denied by `access(2)`, and telling the user "you don't have permission to change
|
||||
/// this folder" would send them to a Get Info panel that cannot help. The volume's answer is the
|
||||
/// more fundamental fact and it names the repair that works, so it wins whenever it is `true`.
|
||||
///
|
||||
/// A volume that will not answer at all (`nil` — an exotic filesystem, a URL whose volume is gone)
|
||||
/// falls through to `access(2)`, which is the honest degradation: we still know whether the board
|
||||
/// can be written, we just describe it as the folder's doing.
|
||||
///
|
||||
/// ### Why the classifier is separate from the I/O
|
||||
///
|
||||
/// `classify(volumeIsReadOnly:isWritable:)` is a pure function of the two answers, so the truth
|
||||
/// table — including the read-only-volume-that-somehow-passes-`access` row, which `root` can
|
||||
/// produce — is testable without a DMG. `probe(_:)` is only the two syscalls and this call.
|
||||
public enum WritabilityProbe {
|
||||
|
||||
/// The cause, or `nil` when the location accepts writes.
|
||||
///
|
||||
/// Deliberately optional-returning rather than `Bool`-returning: every caller either raises a
|
||||
/// lock carrying the cause or clears one, and a `Bool` would force a second question at each
|
||||
/// site to find out which line to show.
|
||||
public nonisolated static func classify(volumeIsReadOnly: Bool?, isWritable: Bool) -> UnwritableCause? {
|
||||
if volumeIsReadOnly == true { return .readOnlyVolume }
|
||||
return isWritable ? nil : .permissionDenied
|
||||
}
|
||||
|
||||
/// Asks the filesystem about `root` and classifies the answer.
|
||||
///
|
||||
/// **The URL is rebuilt from its path** before the resource value is read. `URL` bridges to
|
||||
/// `NSURL`, which caches resource values it has already been asked for — and this probe's whole
|
||||
/// job is to notice that an answer *changed* since last time. A cached `volumeIsReadOnly` would
|
||||
/// make the symmetric rule silently one-way again, in a way no test on a fresh URL would catch.
|
||||
/// `access(2)` has no such cache, which is why only the resource value needs the ceremony.
|
||||
public nonisolated static func probe(_ root: URL) -> UnwritableCause? {
|
||||
let fresh = URL(fileURLWithPath: root.path, isDirectory: true)
|
||||
let volumeIsReadOnly = (try? fresh.resourceValues(forKeys: [.volumeIsReadOnlyKey]))?.volumeIsReadOnly
|
||||
return classify(
|
||||
volumeIsReadOnly: volumeIsReadOnly,
|
||||
isWritable: FileManager.default.isWritableFile(atPath: root.path)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user