From 20de467e8739596e6b590896ed121e604f91774a Mon Sep 17 00:00:00 2001 From: kua-agent Date: Sat, 5 Sep 2026 09:27:32 +0400 Subject: [PATCH] fix(core): transport-aware launch paths + writable-folder check (adversarial review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp --- .../Support/AppSupportPaths.swift | 23 +++++++++---- .../ShotdeckCore/Support/FolderSettings.swift | 10 +----- .../Support/TransportSettings.swift | 34 +++++++++++++++++++ 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/Sources/ShotdeckCore/Support/AppSupportPaths.swift b/Sources/ShotdeckCore/Support/AppSupportPaths.swift index 7f1dccf..8e584b4 100644 --- a/Sources/ShotdeckCore/Support/AppSupportPaths.swift +++ b/Sources/ShotdeckCore/Support/AppSupportPaths.swift @@ -11,12 +11,6 @@ public struct AppSupportPaths: Sendable { /// Production paths. public static func standard() throws -> AppSupportPaths { let fileManager = FileManager.default - let appSupportParent = try fileManager.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) let desktop = try fileManager.url( for: .desktopDirectory, in: .userDomainMask, @@ -29,10 +23,25 @@ public struct AppSupportPaths: Sendable { appropriateFor: nil, create: true ) - let root = appSupportParent.appendingPathComponent("Shotdeck", isDirectory: true) + let root = try standardRoot(fileManager: fileManager) 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. public init(root: URL, outbox: URL, watchFolder: URL) throws { self.root = root diff --git a/Sources/ShotdeckCore/Support/FolderSettings.swift b/Sources/ShotdeckCore/Support/FolderSettings.swift index 618b1ae..136bd8e 100644 --- a/Sources/ShotdeckCore/Support/FolderSettings.swift +++ b/Sources/ShotdeckCore/Support/FolderSettings.swift @@ -70,15 +70,7 @@ public enum FolderSettings { defaults: UserDefaults = .standard, fileManager: FileManager = .default ) throws -> AppSupportPaths { - let resolvedRoot: URL - 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 resolvedRoot = try root ?? AppSupportPaths.standardRoot(fileManager: fileManager) let folders = resolve(defaults: defaults, fileManager: fileManager) return try AppSupportPaths(root: resolvedRoot, outbox: folders.outbox, watchFolder: folders.watch) } diff --git a/Sources/ShotdeckCore/Support/TransportSettings.swift b/Sources/ShotdeckCore/Support/TransportSettings.swift index aca2648..9ced9dd 100644 --- a/Sources/ShotdeckCore/Support/TransportSettings.swift +++ b/Sources/ShotdeckCore/Support/TransportSettings.swift @@ -75,6 +75,25 @@ public enum TransportSettings { 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 @@ -142,4 +161,19 @@ public enum OneDriveLocator { } 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) + } }