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) + } }