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:
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user