Redline: OneDrive folder transport as alternative to AirDrop (MMDB-2631) #23
@@ -35,6 +35,11 @@ public final class AppModel {
|
||||
/// Live transport choice; WP-onedrive reads this to pick the send path and to drive
|
||||
/// the Settings "Send via" picker and the menu's "Send…" label.
|
||||
public private(set) var transport: SendTransport
|
||||
/// Ground truth for the Settings OneDrive row: nil means "no OneDrive folder found".
|
||||
/// Views read this instead of calling `OneDriveLocator.resolveOneDriveFolder()`
|
||||
/// directly, so state (and testing with a fake home) flows through the model like
|
||||
/// everything else — never a View reaching past the model for real UserDefaults/home.
|
||||
public private(set) var resolvedOneDriveFolder: URL?
|
||||
/// Live outbox; WP-4b reads this (not `paths.outbox`) so Settings folder changes take effect.
|
||||
public private(set) var outboxURL: URL
|
||||
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
|
||||
@@ -93,6 +98,7 @@ public final class AppModel {
|
||||
self.outboxDisplayName = folders.outbox.lastPathComponent
|
||||
self.watchFolderDisplayName = folders.watch.lastPathComponent
|
||||
self.transport = folders.transport
|
||||
self.resolvedOneDriveFolder = OneDriveLocator.resolveOneDriveFolder()
|
||||
self.captureHotkey = HotkeyPreference.load()
|
||||
self.updateChecker = UpdateChecker()
|
||||
self.updateChecker.onChecked = { [weak self] in
|
||||
@@ -125,6 +131,7 @@ public final class AppModel {
|
||||
setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent)
|
||||
}
|
||||
func setTransport(_ value: SendTransport) { transport = value }
|
||||
func setResolvedOneDriveFolder(_ value: URL?) { resolvedOneDriveFolder = value }
|
||||
func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url }
|
||||
|
||||
/// True when a last-composed PDF path is known this run, or the newest
|
||||
@@ -200,6 +207,7 @@ public final class AppModel {
|
||||
if transport == .oneDrive {
|
||||
try? FileManager.default.createDirectory(at: outboxURL, withIntermediateDirectories: true)
|
||||
}
|
||||
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
|
||||
await watcher.setRecordUncommented(transport == .airDrop)
|
||||
|
||||
do {
|
||||
|
||||
@@ -61,6 +61,28 @@ enum PanelSnapshot {
|
||||
try renderMenuBar(model: model, to: directory, name: "03-empty-session")
|
||||
|
||||
// 04 — three real PNGs in the temp spool so SessionStrip thumbnails decode.
|
||||
try await addSampleCaptures(to: model)
|
||||
try renderMenuBar(model: model, to: directory, name: "04-captures-present")
|
||||
|
||||
// 05 — two inspected PDFs in the temp ledger, one marked / one not.
|
||||
try await seedReturns(model: model)
|
||||
try renderMenuBar(model: model, to: directory, name: "05-returns-present")
|
||||
|
||||
// 06 — SettingsView against the same isolated model.
|
||||
try render(
|
||||
SettingsView().environment(model),
|
||||
to: directory.appendingPathComponent("panel-06-settings.png")
|
||||
)
|
||||
|
||||
// 07/08/09 — OneDrive-mode Settings + menu bar, on a SEPARATE isolated model so
|
||||
// this transport switch never bleeds into the AirDrop-mode panels above.
|
||||
try await captureOneDrivePanels(to: directory)
|
||||
}
|
||||
|
||||
/// Three real PNGs appended to the given model's temp spool so SessionStrip
|
||||
/// thumbnails decode. Shared by panel 04 (AirDrop) and panel 09 (OneDrive).
|
||||
@MainActor
|
||||
private static func addSampleCaptures(to model: AppModel) async throws {
|
||||
let swatches: [(CGFloat, CGFloat, CGFloat)] = [
|
||||
(0.85, 0.22, 0.18),
|
||||
(0.18, 0.62, 0.32),
|
||||
@@ -77,17 +99,62 @@ enum PanelSnapshot {
|
||||
)
|
||||
}
|
||||
model.replaceSession(try await model.spool.currentSession())
|
||||
try renderMenuBar(model: model, to: directory, name: "04-captures-present")
|
||||
}
|
||||
|
||||
// 05 — two inspected PDFs in the temp ledger, one marked / one not.
|
||||
try await seedReturns(model: model)
|
||||
try renderMenuBar(model: model, to: directory, name: "05-returns-present")
|
||||
/// Panels 07-09: OneDrive transport, on its own isolated model/temp root so
|
||||
/// switching transport here never touches the AirDrop-mode model above, the real
|
||||
/// home directory, or UserDefaults.standard. The "resolved" and "not found" states
|
||||
/// are produced by calling the real OneDriveLocator functions against fake home
|
||||
/// trees built under this snapshot's own temp root — never a hand-typed path.
|
||||
@MainActor
|
||||
private static func captureOneDrivePanels(to directory: URL) async throws {
|
||||
let (model, root) = try makeIsolatedModel()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
model.setTransport(.oneDrive)
|
||||
|
||||
// 06 — SettingsView against the same isolated model.
|
||||
// 07 — a resolved OneDrive folder, shaped like the real default
|
||||
// (…/Library/CloudStorage/OneDrive-MMDGROUP/Redline): a fake home tree with a
|
||||
// real OneDrive-MMDGROUP directory under it, resolved via the same pure
|
||||
// OneDriveLocator function production code uses — never a hand-typed path.
|
||||
let fakeHomeWithOneDrive = root.appendingPathComponent("fake-home-with-onedrive", isDirectory: true)
|
||||
let syncRoot = fakeHomeWithOneDrive
|
||||
.appendingPathComponent("Library/CloudStorage/OneDrive-MMDGROUP", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: syncRoot, withIntermediateDirectories: true)
|
||||
guard let resolvedFolder = OneDriveLocator.defaultRedlineFolder(
|
||||
home: fakeHomeWithOneDrive, fileManager: .default
|
||||
) else {
|
||||
throw SnapshotError.oneDriveFixtureFailed("fake OneDrive-MMDGROUP root did not resolve")
|
||||
}
|
||||
model.setResolvedOneDriveFolder(resolvedFolder)
|
||||
try render(
|
||||
SettingsView().environment(model),
|
||||
to: directory.appendingPathComponent("panel-06-settings.png")
|
||||
to: directory.appendingPathComponent("panel-07-settings-onedrive.png")
|
||||
)
|
||||
|
||||
// 08 — no OneDrive folder found: a fake home with NO Library/CloudStorage at
|
||||
// all, and a throwaway UserDefaults suite (never .standard, never touched
|
||||
// before) so the stored-override check also legitimately finds nothing.
|
||||
let fakeHomeWithoutOneDrive = root.appendingPathComponent("fake-home-without-onedrive", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: fakeHomeWithoutOneDrive, withIntermediateDirectories: true)
|
||||
let isolatedDefaults = try makeIsolatedDefaultsSuite()
|
||||
defer { isolatedDefaults.defaults.removePersistentDomain(forName: isolatedDefaults.suiteName) }
|
||||
let missingFolder = OneDriveLocator.resolveOneDriveFolder(
|
||||
defaults: isolatedDefaults.defaults, home: fakeHomeWithoutOneDrive, fileManager: .default
|
||||
)
|
||||
guard missingFolder == nil else {
|
||||
throw SnapshotError.oneDriveFixtureFailed("fake home without OneDrive unexpectedly resolved")
|
||||
}
|
||||
model.setResolvedOneDriveFolder(nil)
|
||||
try render(
|
||||
SettingsView().environment(model),
|
||||
to: directory.appendingPathComponent("panel-08-settings-onedrive-missing.png")
|
||||
)
|
||||
|
||||
// 09 — menu bar panel, 3 captures present, OneDrive mode ("Send to OneDrive").
|
||||
model.snapshotSetScreenRecordingGranted(true)
|
||||
model.replaceRegion(sampleRegion())
|
||||
try await addSampleCaptures(to: model)
|
||||
try renderMenuBar(model: model, to: directory, name: "09-captures-present-onedrive")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -177,6 +244,23 @@ enum PanelSnapshot {
|
||||
return (model, root)
|
||||
}
|
||||
|
||||
/// A throwaway UserDefaults suite — never `.standard` — for the panel-08 fixture,
|
||||
/// the same isolation pattern ShotdeckCoreTests uses for TransportSettings/
|
||||
/// OneDriveLocator tests.
|
||||
private struct IsolatedDefaultsSuite {
|
||||
let suiteName: String
|
||||
let defaults: UserDefaults
|
||||
}
|
||||
|
||||
private static func makeIsolatedDefaultsSuite() throws -> IsolatedDefaultsSuite {
|
||||
let suiteName = "shotdeck-panel-snapshot-\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
throw SnapshotError.oneDriveFixtureFailed("could not create isolated UserDefaults suite")
|
||||
}
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return IsolatedDefaultsSuite(suiteName: suiteName, defaults: defaults)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func seedReturns(model: AppModel) async throws {
|
||||
let watch = model.paths.watchFolder
|
||||
@@ -283,6 +367,7 @@ private enum SnapshotError: Error, CustomStringConvertible {
|
||||
case encodeFailed(String)
|
||||
case pngGenerationFailed
|
||||
case pdfWriteFailed(String)
|
||||
case oneDriveFixtureFailed(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
@@ -290,6 +375,7 @@ private enum SnapshotError: Error, CustomStringConvertible {
|
||||
case .encodeFailed(let name): return "PNG encode failed for \(name)"
|
||||
case .pngGenerationFailed: return "CoreGraphics PNG generation failed"
|
||||
case .pdfWriteFailed(let name): return "could not write \(name)"
|
||||
case .oneDriveFixtureFailed(let detail): return "OneDrive snapshot fixture failed: \(detail)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,12 @@ extension AppModel: SendCapable {
|
||||
let path = OneDriveLocator.resolveOneDriveFolder()?.path
|
||||
?? TransportSettings.storedOneDriveFolderPath()
|
||||
?? "no OneDrive folder found"
|
||||
setResolvedOneDriveFolder(nil)
|
||||
setStatus(ShotdeckError.oneDriveFolderUnavailable(path: path).errorDescription)
|
||||
setSending(false)
|
||||
return
|
||||
}
|
||||
setResolvedOneDriveFolder(folder)
|
||||
if outboxURL != folder || watchFolderURL != folder {
|
||||
setFolderURLs(outbox: folder, watch: folder)
|
||||
try? await watcher.updateWatchFolder(folder)
|
||||
|
||||
@@ -77,7 +77,7 @@ struct SettingsView: View {
|
||||
} else {
|
||||
GridRow(alignment: .center) {
|
||||
fieldLabel("OneDrive folder")
|
||||
if let folder = resolvedOneDriveFolder {
|
||||
if let folder = model.resolvedOneDriveFolder {
|
||||
folderValue(path: folder.path) {
|
||||
model.chooseOneDriveFolder()
|
||||
}
|
||||
@@ -122,13 +122,6 @@ struct SettingsView: View {
|
||||
Binding(get: { model.transport }, set: { model.chooseTransport($0) })
|
||||
}
|
||||
|
||||
/// Ground truth from OneDriveLocator, not `model.outboxURL` — the model may be
|
||||
/// showing an AirDrop-folder fallback when no real OneDrive folder resolves, and
|
||||
/// the Settings row must say so plainly rather than repeat that fallback path.
|
||||
private var resolvedOneDriveFolder: URL? {
|
||||
OneDriveLocator.resolveOneDriveFolder()
|
||||
}
|
||||
|
||||
private func armHotkeyRecorder() {
|
||||
guard !isRecordingHotkey else { return }
|
||||
isRecordingHotkey = true
|
||||
@@ -267,6 +260,7 @@ extension AppModel: SettingsWindowPresenting {
|
||||
)
|
||||
}
|
||||
setFolderURLs(outbox: folders.outbox, watch: folders.watch)
|
||||
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
|
||||
Task {
|
||||
await watcher.setRecordUncommented(value == .airDrop)
|
||||
do {
|
||||
@@ -280,10 +274,11 @@ extension AppModel: SettingsWindowPresenting {
|
||||
}
|
||||
|
||||
func chooseOneDriveFolder() {
|
||||
let start = OneDriveLocator.resolveOneDriveFolder() ?? FileManager.default.homeDirectoryForCurrentUser
|
||||
let start = resolvedOneDriveFolder ?? FileManager.default.homeDirectoryForCurrentUser
|
||||
guard let url = chooseDirectory(startingAt: start) else { return }
|
||||
TransportSettings.setOneDriveFolder(url)
|
||||
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
|
||||
guard transport == .oneDrive else { return }
|
||||
setFolderURLs(outbox: url, watch: url)
|
||||
Task {
|
||||
|
||||
Reference in New Issue
Block a user