Redline: OneDrive folder transport as alternative to AirDrop (MMDB-2631) #23
@@ -3,6 +3,7 @@ import CoreGraphics
|
||||
import Darwin
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import PDFKit
|
||||
import ShotdeckCore
|
||||
|
||||
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
|
||||
@@ -173,7 +174,7 @@ enum PickerSelfTest {
|
||||
try await executeSendTruth()
|
||||
print("SEND-TRUTH PASS")
|
||||
fflush(stdout)
|
||||
if !startUpdateSelfTestIfRequested() {
|
||||
if !startUpdateSelfTestIfRequested(), !startOneDriveSelfTestIfRequested() {
|
||||
exit(0)
|
||||
}
|
||||
} catch {
|
||||
@@ -315,7 +316,9 @@ enum PickerSelfTest {
|
||||
try await runUpdateSelfTest(outputDirectory: output)
|
||||
print("UPDATE-SELFTEST PASS version=99.0.0")
|
||||
fflush(stdout)
|
||||
if !startOneDriveSelfTestIfRequested() {
|
||||
exit(0)
|
||||
}
|
||||
} catch let error as UpdateSelfTestError {
|
||||
updateFail(error.description)
|
||||
} catch {
|
||||
@@ -473,6 +476,194 @@ enum PickerSelfTest {
|
||||
exit(1)
|
||||
}
|
||||
|
||||
/// Real sync root this Mac has; the phase proves the transport against the actual
|
||||
/// OneDrive file provider, never a fake home tree (that is what
|
||||
/// OneDriveLocatorTests in ShotdeckCoreTests are for).
|
||||
private static let realOneDriveSyncRoot = URL(
|
||||
fileURLWithPath: "/Users/benjaminhippler/Library/CloudStorage/OneDrive-MMDGROUP",
|
||||
isDirectory: true
|
||||
)
|
||||
|
||||
/// Phase 5: proves the OneDrive transport end to end against the real sync root.
|
||||
/// Triggered by `SHOTDECK_ONEDRIVE_SELFTEST` when chained after PICKER/SEND-TRUTH/
|
||||
/// UPDATE-SELFTEST — the exact pattern `startUpdateSelfTestIfRequested` uses for its
|
||||
/// own env var. Returns true when the async phase was scheduled (it calls `exit` itself).
|
||||
@discardableResult
|
||||
private static func startOneDriveSelfTestIfRequested() -> Bool {
|
||||
guard ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil else {
|
||||
return false
|
||||
}
|
||||
runOneDriveSelfTestAndExit()
|
||||
return true
|
||||
}
|
||||
|
||||
/// Entry point for running ONLY this phase, bypassing the on-screen picker chain
|
||||
/// entirely. The harness has no other per-phase selector, so this is the escape
|
||||
/// hatch: `REDLINE_SELFTEST_PHASE=onedrive`.
|
||||
static func runOneDriveOnlyIfRequested() {
|
||||
guard ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" else {
|
||||
return
|
||||
}
|
||||
// Same hop as runIfRequested(): a plain main-queue turn after NSApp starts, so
|
||||
// AppKit/PDFKit calls inside the phase are not racing app launch.
|
||||
DispatchQueue.main.async {
|
||||
MainActor.assumeIsolated {
|
||||
runOneDriveSelfTestAndExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func runOneDriveSelfTestAndExit() {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let folder = try await executeOneDriveSelfTest()
|
||||
print("ONEDRIVE-SELFTEST PASS path=\(folder.path)")
|
||||
fflush(stdout)
|
||||
exit(0)
|
||||
} catch let error as OneDriveSelfTestError {
|
||||
oneDriveFail(error.description)
|
||||
} catch {
|
||||
oneDriveFail(String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a session, sends it through the OneDrive branch of `send(anchor: nil)`
|
||||
/// against a NEW folder under the real OneDrive sync root, confirms the watcher does
|
||||
/// NOT report the freshly-written unmarked PDF as a return, then adds a real PDFKit
|
||||
/// ink annotation in place (what the iPad does) and confirms the watcher now reports
|
||||
/// it as commented. Never deletes anything under OneDrive — the created folder and
|
||||
/// PDF are left in place for Ben to inspect / for the real iPad round trip.
|
||||
private static func executeOneDriveSelfTest() async throws -> URL {
|
||||
let fm = FileManager.default
|
||||
guard fm.fileExists(atPath: realOneDriveSyncRoot.path) else {
|
||||
throw OneDriveSelfTestError.detail(
|
||||
"real OneDrive sync root not found at \(realOneDriveSyncRoot.path)"
|
||||
)
|
||||
}
|
||||
|
||||
// UserDefaults.standard is the ONLY defaults instance send()/TransportSettings
|
||||
// actually read at runtime (there is no defaults-threading through AppModel), so
|
||||
// "isolated" here means snapshot-and-restore around the real keys — the same
|
||||
// pattern runRegionPersistPhase already uses for CaptureRegion.defaultsKey.
|
||||
let defaults = UserDefaults.standard
|
||||
let previousTransport = defaults.string(forKey: TransportSettings.transportDefaultsKey)
|
||||
let previousFolder = defaults.string(forKey: TransportSettings.oneDriveFolderDefaultsKey)
|
||||
defer {
|
||||
if let previousTransport {
|
||||
defaults.set(previousTransport, forKey: TransportSettings.transportDefaultsKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: TransportSettings.transportDefaultsKey)
|
||||
}
|
||||
if let previousFolder {
|
||||
defaults.set(previousFolder, forKey: TransportSettings.oneDriveFolderDefaultsKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: TransportSettings.oneDriveFolderDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
let stamp = DubaiTime.fileStamp(Date())
|
||||
let selftestFolder = realOneDriveSyncRoot
|
||||
.appendingPathComponent("Redline-selftest-\(stamp)", isDirectory: true)
|
||||
try fm.createDirectory(at: selftestFolder, withIntermediateDirectories: true)
|
||||
|
||||
TransportSettings.setTransport(.oneDrive, defaults: defaults)
|
||||
TransportSettings.setOneDriveFolder(selftestFolder, defaults: defaults)
|
||||
|
||||
// Local spool root only — the outbox/watch folder is the real OneDrive folder.
|
||||
let spoolRoot = fm.temporaryDirectory
|
||||
.appendingPathComponent("shotdeck-onedrive-selftest-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? fm.removeItem(at: spoolRoot) }
|
||||
|
||||
let paths = try AppSupportPaths(root: spoolRoot, outbox: selftestFolder, watchFolder: selftestFolder)
|
||||
let ledger = try ReturnLedger(paths: paths)
|
||||
let watcher = ReturnWatcher(paths: paths, ledger: ledger)
|
||||
await watcher.setRecordUncommented(false) // OneDrive mode: today's default is AirDrop's `true`.
|
||||
|
||||
let model = AppModel(
|
||||
paths: paths,
|
||||
spool: try SpoolStore(paths: paths),
|
||||
composer: PDFComposer(),
|
||||
capturer: ScreenCapturer(),
|
||||
hotkeys: HotkeyCenter(),
|
||||
picker: RegionPickerController(),
|
||||
ledger: ledger,
|
||||
watcher: watcher
|
||||
)
|
||||
model.setFolderURLs(outbox: selftestFolder, watch: selftestFolder)
|
||||
model.setTransport(.oneDrive)
|
||||
|
||||
let png = try makeTinyPNGData()
|
||||
_ = try await model.spool.append(
|
||||
pngData: png, pixelWidth: 64, pixelHeight: 48, scale: 1, capturedAt: Date()
|
||||
)
|
||||
model.replaceSession(try await model.spool.currentSession())
|
||||
guard !model.session.isEmpty else {
|
||||
throw OneDriveSelfTestError.detail("seeded session was empty")
|
||||
}
|
||||
|
||||
await model.send(anchor: nil)
|
||||
|
||||
guard let status = model.statusLine, status.hasPrefix("Saved to OneDrive") else {
|
||||
throw OneDriveSelfTestError.detail(
|
||||
"status did not start with 'Saved to OneDrive': \(model.statusLine ?? "nil")"
|
||||
)
|
||||
}
|
||||
guard model.session.isEmpty else {
|
||||
throw OneDriveSelfTestError.detail("session was not archived after the OneDrive send")
|
||||
}
|
||||
|
||||
let written = (try? fm.contentsOfDirectory(at: selftestFolder, includingPropertiesForKeys: nil)) ?? []
|
||||
guard let pdfURL = written.first(where: { $0.pathExtension.lowercased() == "pdf" }) else {
|
||||
throw OneDriveSelfTestError.detail("no PDF found in \(selftestFolder.path)")
|
||||
}
|
||||
|
||||
// Unmarked so far: the watcher must not treat it as a return.
|
||||
let beforeMarkup = try await watcher.scanNow()
|
||||
guard !beforeMarkup.contains(where: { $0.fileURL == pdfURL }) else {
|
||||
throw OneDriveSelfTestError.detail("unmarked PDF was reported as returned by scanNow")
|
||||
}
|
||||
let commentedBefore = try await ledger.commented()
|
||||
guard !commentedBefore.contains(where: { $0.fileURL == pdfURL }) else {
|
||||
throw OneDriveSelfTestError.detail("unmarked PDF was recorded as commented in the ledger")
|
||||
}
|
||||
|
||||
// What the iPad does: mark it up in place with a real ink annotation, then save.
|
||||
guard let document = PDFDocument(url: pdfURL), let page = document.page(at: 0) else {
|
||||
throw OneDriveSelfTestError.detail("could not reopen \(pdfURL.path) to annotate it")
|
||||
}
|
||||
let ink = PDFAnnotation(
|
||||
bounds: CGRect(x: 20, y: 20, width: 60, height: 60),
|
||||
forType: .ink,
|
||||
withProperties: nil
|
||||
)
|
||||
let stroke = NSBezierPath()
|
||||
stroke.move(to: NSPoint(x: 20, y: 20))
|
||||
stroke.line(to: NSPoint(x: 80, y: 80))
|
||||
ink.add(stroke)
|
||||
page.addAnnotation(ink)
|
||||
guard document.write(to: pdfURL) else {
|
||||
throw OneDriveSelfTestError.detail("could not save the annotated PDF back to \(pdfURL.path)")
|
||||
}
|
||||
|
||||
let afterMarkup = try await watcher.scanNow()
|
||||
guard let recorded = afterMarkup.first(where: { $0.fileURL == pdfURL }), recorded.isCommented else {
|
||||
throw OneDriveSelfTestError.detail("annotated PDF was not reported as commented by scanNow")
|
||||
}
|
||||
let commentedAfter = try await ledger.commented()
|
||||
guard commentedAfter.contains(where: { $0.fileURL == pdfURL }) else {
|
||||
throw OneDriveSelfTestError.detail("annotated PDF was not recorded in the ledger as commented")
|
||||
}
|
||||
|
||||
return selftestFolder
|
||||
}
|
||||
|
||||
private static func oneDriveFail(_ detail: String) -> Never {
|
||||
print("ONEDRIVE-SELFTEST FAIL \(detail)")
|
||||
fflush(stdout)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
private static func interpolate(_ step: Int) -> NSPoint {
|
||||
let t = CGFloat(step) / CGFloat(dragSteps)
|
||||
return NSPoint(
|
||||
@@ -538,3 +729,12 @@ private enum UpdateSelfTestError: Error, CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum OneDriveSelfTestError: Error, CustomStringConvertible {
|
||||
case detail(String)
|
||||
var description: String {
|
||||
switch self {
|
||||
case .detail(let s): return s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import ShotdeckCore
|
||||
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
|
||||
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
|
||||
}
|
||||
if ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" {
|
||||
MainActor.assumeIsolated { PickerSelfTest.runOneDriveOnlyIfRequested() }
|
||||
}
|
||||
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
|
||||
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user