isWritableDirectory (permissions bits) is not enough: a OneDrive Files-On-Demand directory whose provider domain is signed out can report as existing and POSIX-writable while an actual write fails. probeWritable writes a small ".redline-probe-<uuid>" file into the folder via AtomicFile.write (open+write+fsync+rename+directory-fsync), then removes it; any failure at write, fsync, or removal means false. Three unit tests: an ordinary writable directory (true, and no probe file left behind), a chmod 500 directory (false; permissions restored in teardown), and a plain file path (false). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
205 lines
9.5 KiB
Swift
205 lines
9.5 KiB
Swift
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 `<home>/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
|
|
}
|
|
}
|