import Foundation /// User-configurable overrides for the outbox (PDF send destination) and watch folder /// (AirDrop return), backed by UserDefaults. Shotdeck is NOT App-Sandboxed (no /// `com.apple.security.app-sandbox` entitlement in this build — it is a plain, unsandboxed /// SwiftPM executable). Security-scoped bookmarks exist to let a SANDBOXED app retain access /// to a user-picked file/folder outside its container across relaunches; an unsandboxed /// process already has the invoking user's own filesystem permissions on every launch, so a /// plain absolute path stored in UserDefaults is sufficient — including for a mounted network /// share, which resolves by path the same as any local folder for as long as it is mounted. public enum FolderSettings { public static let outboxDefaultsKey = "ai.flowmaster.shotdeck.outbox" public static let watchFolderDefaultsKey = "ai.flowmaster.shotdeck.watchFolder" /// Raw stored path (or nil if never set / cleared). For display in Settings — does NOT /// validate that the path still exists. public static func storedOutboxPath(defaults: UserDefaults = .standard) -> String? { defaults.string(forKey: outboxDefaultsKey) } public static func storedWatchFolderPath(defaults: UserDefaults = .standard) -> String? { defaults.string(forKey: watchFolderDefaultsKey) } /// Persists a user-chosen folder as its absolute POSIX path. public static func setOutbox(_ url: URL, defaults: UserDefaults = .standard) { defaults.set(url.path, forKey: outboxDefaultsKey) } public static func setWatchFolder(_ url: URL, defaults: UserDefaults = .standard) { defaults.set(url.path, forKey: watchFolderDefaultsKey) } /// Removes the override; resolve() falls back to the default again. public static func resetOutbox(defaults: UserDefaults = .standard) { defaults.removeObject(forKey: outboxDefaultsKey) } public static func resetWatchFolder(defaults: UserDefaults = .standard) { defaults.removeObject(forKey: watchFolderDefaultsKey) } /// Resolves both folders. A stored override wins only if it is set AND the directory it /// names still exists; otherwise falls back to the default (Desktop / Downloads). Never /// throws — a missing/bad override is logged via `Log.ui` and silently replaced. public static func resolve( defaults: UserDefaults = .standard, fileManager: FileManager = .default ) -> (outbox: URL, watch: URL) { let outbox = resolveOne( storedPath: storedOutboxPath(defaults: defaults), fallback: defaultOutbox(fileManager: fileManager), label: "outbox", fileManager: fileManager) let watch = resolveOne( storedPath: storedWatchFolderPath(defaults: defaults), fallback: defaultWatchFolder(fileManager: fileManager), label: "watch", fileManager: fileManager) return (outbox, watch) } /// Builds an `AppSupportPaths` using `root` (defaults to the standard /// `~/Library/Application Support/Shotdeck`, computed the same way /// `AppSupportPaths.standard()` does, when `root` is nil) plus whatever `resolve()` /// returns for outbox/watch. This is the ONLY place that combines FolderSettings with /// AppSupportPaths — call this everywhere in the app instead of `AppSupportPaths.standard()`. /// `root` is exposed purely so tests 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: 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 folders = resolve(defaults: defaults, fileManager: fileManager) return try AppSupportPaths(root: resolvedRoot, outbox: folders.outbox, watchFolder: folders.watch) } private static func defaultOutbox(fileManager: FileManager) -> URL { fileManager.urls(for: .desktopDirectory, in: .userDomainMask).first ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Desktop", isDirectory: true) } private static func defaultWatchFolder(fileManager: FileManager) -> URL { fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Downloads", isDirectory: true) } private static func resolveOne( storedPath: String?, fallback: URL, label: String, fileManager: FileManager ) -> URL { guard let storedPath else { return fallback } var isDirectory: ObjCBool = false let exists = fileManager.fileExists(atPath: storedPath, isDirectory: &isDirectory) guard exists, isDirectory.boolValue else { Log.ui.warning("Configured \(label, privacy: .public) folder \(storedPath, privacy: .public) no longer exists; falling back to the default.") return fallback } return URL(fileURLWithPath: storedPath, isDirectory: true) } }