fix(core): transport-aware launch paths + writable-folder check (adversarial review)

BLOCKER fix, Core half: adds TransportSettings.resolvedAppSupportPaths(),
the transport-aware equivalent of the AirDrop-only
FolderSettings.resolvedAppSupportPaths() — launch code must use this one so
the ReturnWatcher it feeds is never seeded with a stale AirDrop folder while
OneDrive is the persisted transport. Both now share a single
AppSupportPaths.standardRoot() helper for the ~/Library/Application
Support/Shotdeck root, instead of computing it three separate times.

MAJOR fix, Core half: adds OneDriveLocator.isWritableDirectory(at:) — exists
+ isDirectory is not enough; an existing-but-unwritable folder (permissions
revoked) must be treated as unavailable, not silently attempted and surfaced
as a generic PDF-composition failure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
This commit is contained in:
2026-09-05 09:27:32 +04:00
co-authored by Claude Fable 5.1
parent 209921f084
commit 20de467e87
3 changed files with 51 additions and 16 deletions
@@ -11,12 +11,6 @@ public struct AppSupportPaths: Sendable {
/// Production paths. /// Production paths.
public static func standard() throws -> AppSupportPaths { public static func standard() throws -> AppSupportPaths {
let fileManager = FileManager.default let fileManager = FileManager.default
let appSupportParent = try fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let desktop = try fileManager.url( let desktop = try fileManager.url(
for: .desktopDirectory, for: .desktopDirectory,
in: .userDomainMask, in: .userDomainMask,
@@ -29,10 +23,25 @@ public struct AppSupportPaths: Sendable {
appropriateFor: nil, appropriateFor: nil,
create: true create: true
) )
let root = appSupportParent.appendingPathComponent("Shotdeck", isDirectory: true) let root = try standardRoot(fileManager: fileManager)
return try AppSupportPaths(root: root, outbox: desktop, watchFolder: downloads) return try AppSupportPaths(root: root, outbox: desktop, watchFolder: downloads)
} }
/// The standard `~/Library/Application Support/Shotdeck` root. Shared by
/// `standard()`, `FolderSettings.resolvedAppSupportPaths()`, and
/// `TransportSettings.resolvedAppSupportPaths()` so all three agree on where the
/// root lives the folder-resolution logic (AirDrop-only vs transport-aware)
/// differs between those, the root computation never should.
public static func standardRoot(fileManager: FileManager = .default) throws -> URL {
let appSupportParent = try fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
return appSupportParent.appendingPathComponent("Shotdeck", isDirectory: true)
}
/// Test paths rooted anywhere. Every directory is created if missing. /// Test paths rooted anywhere. Every directory is created if missing.
public init(root: URL, outbox: URL, watchFolder: URL) throws { public init(root: URL, outbox: URL, watchFolder: URL) throws {
self.root = root self.root = root
@@ -70,15 +70,7 @@ public enum FolderSettings {
defaults: UserDefaults = .standard, defaults: UserDefaults = .standard,
fileManager: FileManager = .default fileManager: FileManager = .default
) throws -> AppSupportPaths { ) throws -> AppSupportPaths {
let resolvedRoot: URL let resolvedRoot = try root ?? AppSupportPaths.standardRoot(fileManager: fileManager)
if let root {
resolvedRoot = root
} else {
let appSupportParent = try fileManager.url(
for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
resolvedRoot = appSupportParent.appendingPathComponent("Shotdeck", isDirectory: true)
}
let folders = resolve(defaults: defaults, fileManager: fileManager) let folders = resolve(defaults: defaults, fileManager: fileManager)
return try AppSupportPaths(root: resolvedRoot, outbox: folders.outbox, watchFolder: folders.watch) return try AppSupportPaths(root: resolvedRoot, outbox: folders.outbox, watchFolder: folders.watch)
} }
@@ -75,6 +75,25 @@ public enum TransportSettings {
return (folders.outbox, folders.watch, transport) return (folders.outbox, folders.watch, transport)
} }
} }
/// Builds an `AppSupportPaths` using `root` (defaults to the standard
/// `~/Library/Application Support/Shotdeck` when nil) plus whatever
/// `effectiveFolders()` returns for outbox/watch. Unlike
/// `FolderSettings.resolvedAppSupportPaths()` (AirDrop-only), this is
/// transport-aware it is the ONLY function launch code should use to build its
/// paths, so the watcher it feeds is never seeded with a stale AirDrop folder while
/// OneDrive is the persisted transport. `root` is exposed purely so tests (and the
/// ONEDRIVE-SELFTEST relaunch simulation) can point it at a temporary directory
/// instead of the user's real Application Support folder.
public static func resolvedAppSupportPaths(
root: URL? = nil,
defaults: UserDefaults = .standard,
fileManager: FileManager = .default
) throws -> AppSupportPaths {
let resolvedRoot = try root ?? AppSupportPaths.standardRoot(fileManager: fileManager)
let folders = effectiveFolders(defaults: defaults, fileManager: fileManager)
return try AppSupportPaths(root: resolvedRoot, outbox: folders.outbox, watchFolder: folders.watch)
}
} }
/// Pure path logic for locating a OneDrive sync root under /// Pure path logic for locating a OneDrive sync root under
@@ -142,4 +161,19 @@ public enum OneDriveLocator {
} }
return defaultRedlineFolder(home: home, fileManager: fileManager) return defaultRedlineFolder(home: home, fileManager: fileManager)
} }
/// True when `url` exists as a directory AND is writable by the current process.
/// The live check `send(anchor:)` performs before ever composing into a OneDrive
/// destination a directory that exists but has had its permissions revoked (e.g.
/// `chmod 500`) must be treated as unavailable, not silently attempted and
/// surfaced as a generic PDF-composition failure.
public static func isWritableDirectory(
at url: URL,
fileManager: FileManager = .default
) -> Bool {
var isDirectory: ObjCBool = false
let exists = fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory)
guard exists, isDirectory.boolValue else { return false }
return fileManager.isWritableFile(atPath: url.path)
}
} }