import Foundation /// The two ways a composed PDF can reach the iPad and come back marked up. public enum SendTransport: String, Codable, Sendable, CaseIterable { case airDrop case oneDrive public var displayName: String { switch self { case .airDrop: return "AirDrop" case .oneDrive: return "OneDrive folder" } } } /// User-configurable transport choice plus the OneDrive folder override, backed by /// UserDefaults the same way `FolderSettings` is. See `FolderSettings` for why a plain /// path (not a security-scoped bookmark) is correct for this unsandboxed app. public enum TransportSettings { public static let transportDefaultsKey = "ai.flowmaster.shotdeck.transport" public static let oneDriveFolderDefaultsKey = "ai.flowmaster.shotdeck.oneDriveFolder" /// Defaults to `.airDrop` when unset or when the stored value cannot be parsed. public static func transport(defaults: UserDefaults = .standard) -> SendTransport { guard let raw = defaults.string(forKey: transportDefaultsKey), let value = SendTransport(rawValue: raw) else { return .airDrop } return value } public static func setTransport(_ value: SendTransport, defaults: UserDefaults = .standard) { defaults.set(value.rawValue, forKey: transportDefaultsKey) } /// Raw stored path (or nil if never set / cleared). Does NOT validate that the /// directory still exists. public static func storedOneDriveFolderPath(defaults: UserDefaults = .standard) -> String? { defaults.string(forKey: oneDriveFolderDefaultsKey) } public static func setOneDriveFolder(_ url: URL, defaults: UserDefaults = .standard) { defaults.set(url.path, forKey: oneDriveFolderDefaultsKey) } public static func resetOneDriveFolder(defaults: UserDefaults = .standard) { defaults.removeObject(forKey: oneDriveFolderDefaultsKey) } /// The outbox/watch folders Redline should actually use right now, for the current /// transport. AirDrop mode delegates to `FolderSettings.resolve()` unchanged. /// OneDrive mode uses the SAME folder for both outbox and watch — see /// `OneDriveLocator.resolveOneDriveFolder`. When no OneDrive folder can be resolved /// at all (no sync root, no override), this falls back to the AirDrop folders so the /// app always has somewhere to write; `send(anchor:)` performs its own live /// existence check before ever composing into a OneDrive send, so that fallback is /// never mistaken for a valid OneDrive destination. public static func effectiveFolders( defaults: UserDefaults = .standard, fileManager: FileManager = .default ) -> (outbox: URL, watch: URL, transport: SendTransport) { let transport = transport(defaults: defaults) switch transport { case .airDrop: let folders = FolderSettings.resolve(defaults: defaults, fileManager: fileManager) return (folders.outbox, folders.watch, transport) case .oneDrive: if let folder = OneDriveLocator.resolveOneDriveFolder( defaults: defaults, home: fileManager.homeDirectoryForCurrentUser, fileManager: fileManager ) { return (folder, folder, transport) } let folders = FolderSettings.resolve(defaults: defaults, fileManager: fileManager) 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 /// `~/Library/CloudStorage` and the Redline folder inside it. No side effects — never /// creates a directory. Fully unit-testable with a fake home tree. public enum OneDriveLocator { /// Every directory directly under `/Library/CloudStorage` whose name starts /// with "OneDrive-", sorted so a name containing "MMD" (case-insensitive) sorts /// first, then alphabetically. Empty when CloudStorage does not exist. public static func syncRoots( home: URL = FileManager.default.homeDirectoryForCurrentUser, fileManager: FileManager = .default ) -> [URL] { let cloudStorage = home.appendingPathComponent("Library/CloudStorage", isDirectory: true) var isDirectory: ObjCBool = false guard fileManager.fileExists(atPath: cloudStorage.path, isDirectory: &isDirectory), isDirectory.boolValue else { return [] } let items = (try? fileManager.contentsOfDirectory( at: cloudStorage, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] )) ?? [] let roots = items.filter { url in guard url.lastPathComponent.hasPrefix("OneDrive-") else { return false } var itemIsDirectory: ObjCBool = false let exists = fileManager.fileExists(atPath: url.path, isDirectory: &itemIsDirectory) return exists && itemIsDirectory.boolValue } return roots.sorted { a, b in let aName = a.lastPathComponent let bName = b.lastPathComponent let aIsMMD = aName.localizedCaseInsensitiveContains("MMD") let bIsMMD = bName.localizedCaseInsensitiveContains("MMD") if aIsMMD != bIsMMD { return aIsMMD } return aName.localizedStandardCompare(bName) == .orderedAscending } } /// First sync root's "Redline" subfolder, or nil when there is no sync root at all. public static func defaultRedlineFolder( home: URL = FileManager.default.homeDirectoryForCurrentUser, fileManager: FileManager = .default ) -> URL? { guard let first = syncRoots(home: home, fileManager: fileManager).first else { return nil } return first.appendingPathComponent("Redline", isDirectory: true) } /// The stored override when it is set AND still exists as a directory; otherwise /// `defaultRedlineFolder`. Never creates anything. public static func resolveOneDriveFolder( defaults: UserDefaults = .standard, home: URL = FileManager.default.homeDirectoryForCurrentUser, fileManager: FileManager = .default ) -> URL? { if let storedPath = TransportSettings.storedOneDriveFolderPath(defaults: defaults) { var isDirectory: ObjCBool = false let exists = fileManager.fileExists(atPath: storedPath, isDirectory: &isDirectory) if exists, isDirectory.boolValue { return URL(fileURLWithPath: storedPath, isDirectory: true) } } 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) } /// Writes a tiny probe file into `folder`, fsyncs it, then removes it — the only /// reliable way to catch a OneDrive Files-On-Demand directory whose provider domain /// is signed out: such a directory can report as existing and POSIX-writable /// (`isWritableDirectory` returns true) while an actual write fails. True only when /// the write, fsync, AND removal of the probe file all succeed; any failure at any /// of those steps means false, so the caller treats the folder as unavailable /// rather than proceeding to compose a real PDF into it. public static func probeWritable( at folder: URL, fileManager: FileManager = .default ) -> Bool { let probeURL = folder.appendingPathComponent(".redline-probe-\(UUID().uuidString)") do { try AtomicFile.write(Data(), to: probeURL) } catch { return false } do { try fileManager.removeItem(at: probeURL) } catch { return false } return true } }