Two leak paths: (a) AtomicFile.write renames the temp file onto the probe path and THEN fsyncs the containing directory — if that last fsync throws, the probe file already exists on disk but the old code returned false without ever attempting removal; (b) if the explicit removeItem call itself threw, there was no retry, so a transient File Provider removal failure left the file behind permanently. Fix: an unconditional `defer` now checks fileExists and retries removeItem regardless of which branch returned early. The function only reports true when the explicit write, fsync (inside AtomicFile.write), AND removal all succeeded AND the file is confirmed gone afterward. New test: a FileManager subclass whose removeItem(at:) throws on its first call (AtomicFile.write itself never touches this injected FileManager — it uses raw Darwin/POSIX calls, not FileManager, so this only intercepts the explicit removal + the defer's retry) asserts the function returns false AND no probe file remains — verified this actually needs the defer by temporarily removing it and confirming the same test then fails with a leftover ".redline-probe-<uuid>" file (see this branch's history for the discarded revert). The directory-fsync failure path has no injectable seam (raw fsync(2) on an already-open fd, not parameterized by any FileManager or other substitutable dependency, and not reproducible via chmod or other standard test techniques) — documented in the test rather than simulated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
222 lines
10 KiB
Swift
222 lines
10 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)")
|
|
// Belt-and-suspenders cleanup, unconditional: AtomicFile.write renames the temp
|
|
// file onto probeURL and THEN fsyncs the containing directory — if that last
|
|
// fsync throws, the probe file already exists on disk but the catch below
|
|
// returns false before ever reaching the explicit removeItem call. And if the
|
|
// explicit removeItem itself throws, this is the only retry it gets. Either
|
|
// way, never leave the probe file behind just because we're about to return.
|
|
defer {
|
|
if fileManager.fileExists(atPath: probeURL.path) {
|
|
try? fileManager.removeItem(at: probeURL)
|
|
}
|
|
}
|
|
|
|
do {
|
|
try AtomicFile.write(Data(), to: probeURL)
|
|
} catch {
|
|
return false
|
|
}
|
|
|
|
do {
|
|
try fileManager.removeItem(at: probeURL)
|
|
} catch {
|
|
return false
|
|
}
|
|
|
|
// Only true when the explicit removal above actually succeeded AND the file is
|
|
// confirmed gone — never trust a removeItem call that returned without throwing
|
|
// as proof of anything on a File Provider domain.
|
|
return !fileManager.fileExists(atPath: probeURL.path)
|
|
}
|
|
}
|