Redline: OneDrive folder transport as alternative to AirDrop (MMDB-2631) #23
@@ -3,6 +3,7 @@ import CoreGraphics
|
|||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import ImageIO
|
import ImageIO
|
||||||
|
import PDFKit
|
||||||
import ShotdeckCore
|
import ShotdeckCore
|
||||||
|
|
||||||
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
|
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
|
||||||
@@ -173,7 +174,7 @@ enum PickerSelfTest {
|
|||||||
try await executeSendTruth()
|
try await executeSendTruth()
|
||||||
print("SEND-TRUTH PASS")
|
print("SEND-TRUTH PASS")
|
||||||
fflush(stdout)
|
fflush(stdout)
|
||||||
if !startUpdateSelfTestIfRequested() {
|
if !startUpdateSelfTestIfRequested(), !startOneDriveSelfTestIfRequested() {
|
||||||
exit(0)
|
exit(0)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -315,7 +316,9 @@ enum PickerSelfTest {
|
|||||||
try await runUpdateSelfTest(outputDirectory: output)
|
try await runUpdateSelfTest(outputDirectory: output)
|
||||||
print("UPDATE-SELFTEST PASS version=99.0.0")
|
print("UPDATE-SELFTEST PASS version=99.0.0")
|
||||||
fflush(stdout)
|
fflush(stdout)
|
||||||
|
if !startOneDriveSelfTestIfRequested() {
|
||||||
exit(0)
|
exit(0)
|
||||||
|
}
|
||||||
} catch let error as UpdateSelfTestError {
|
} catch let error as UpdateSelfTestError {
|
||||||
updateFail(error.description)
|
updateFail(error.description)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -473,6 +476,194 @@ enum PickerSelfTest {
|
|||||||
exit(1)
|
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 {
|
private static func interpolate(_ step: Int) -> NSPoint {
|
||||||
let t = CGFloat(step) / CGFloat(dragSteps)
|
let t = CGFloat(step) / CGFloat(dragSteps)
|
||||||
return NSPoint(
|
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 {
|
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
|
||||||
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
|
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
|
||||||
}
|
}
|
||||||
|
if ProcessInfo.processInfo.environment["REDLINE_SELFTEST_PHASE"] == "onedrive" {
|
||||||
|
MainActor.assumeIsolated { PickerSelfTest.runOneDriveOnlyIfRequested() }
|
||||||
|
}
|
||||||
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
|
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
|
||||||
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
|
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user