Files
shotdeck/Sources/ShotdeckCore/Support/AppSupportPaths.swift
T
kua-agentandClaude Fable 5.1 20de467e87 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
2026-09-05 09:27:32 +04:00

75 lines
2.7 KiB
Swift

import Foundation
/// The only type in the app that knows the on-disk directory layout.
public struct AppSupportPaths: Sendable {
public let root: URL
public let spool: URL
public let archive: URL
public let outbox: URL
public let watchFolder: URL
/// Production paths.
public static func standard() throws -> AppSupportPaths {
let fileManager = FileManager.default
let desktop = try fileManager.url(
for: .desktopDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let downloads = try fileManager.url(
for: .downloadsDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: 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
self.spool = root.appendingPathComponent("spool", isDirectory: true)
self.archive = root.appendingPathComponent("archive", isDirectory: true)
self.outbox = outbox
self.watchFolder = watchFolder
try Self.createDirectory(self.root)
try Self.createDirectory(self.spool)
try Self.createDirectory(self.archive)
try Self.createDirectory(self.outbox)
try Self.createDirectory(self.watchFolder)
}
public func sessionDirectory(_ id: UUID) -> URL {
spool.appendingPathComponent(id.uuidString, isDirectory: true)
}
public func archiveDirectory(_ id: UUID) -> URL {
archive.appendingPathComponent(id.uuidString, isDirectory: true)
}
private static func createDirectory(_ url: URL) throws {
try FileManager.default.createDirectory(
at: url,
withIntermediateDirectories: true
)
}
}