112 lines
5.4 KiB
Swift
112 lines
5.4 KiB
Swift
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)
|
|
}
|
|
}
|