Adds a third rapid-toggle assertion alongside the existing folder-reconcile check: chooseTransport(.airDrop) immediately followed by chooseTransport(.oneDrive), with NO sleep, then an immediate send(anchor: nil) — proving send() correctly awaits the pending reconcile Task (SendController.swift/AppModel.swift in this series) rather than racing ahead with a stale recordUncommented flag. Asserts the freshly-sent, still-unmarked PDF is never itself reported as an already-returned document. Also updates composePDFForSend's call site for its new transport: parameter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
882 lines
38 KiB
Swift
882 lines
38 KiB
Swift
import AppKit
|
|
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`.
|
|
/// Posts synthetic mouse events through `NSApp.postEvent` only — never CGEventPost/taps.
|
|
@MainActor
|
|
enum PickerSelfTest {
|
|
private static let pointA = NSPoint(x: 200, y: 300)
|
|
private static let pointB = NSPoint(x: 600, y: 600)
|
|
private static let dragSteps = 6
|
|
|
|
/// Called from `main.swift` before `ShotdeckApp.main()`. Returns immediately when the
|
|
/// env var is unset; otherwise waits for launch, drives the production picker, and `exit`s.
|
|
static func runIfRequested() {
|
|
guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"],
|
|
!raw.isEmpty
|
|
else { return }
|
|
|
|
let output = URL(fileURLWithPath: raw, isDirectory: true)
|
|
// Hop onto a plain main-queue turn after NSApp starts. Nested run loops
|
|
// from a Swift Task do not drain NSApp's event queue.
|
|
DispatchQueue.main.async {
|
|
MainActor.assumeIsolated {
|
|
execute(outputDirectory: output)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func execute(outputDirectory: URL) {
|
|
do {
|
|
try FileManager.default.createDirectory(
|
|
at: outputDirectory,
|
|
withIntermediateDirectories: true
|
|
)
|
|
} catch {
|
|
fail(expected: expectedLabel(), got: "could not create output dir: \(error)")
|
|
}
|
|
|
|
guard let screen = NSScreen.screens.first(where: { $0.frame.contains(pointA) })
|
|
?? NSScreen.screens.first
|
|
else {
|
|
fail(expected: expectedLabel(), got: "no NSScreen")
|
|
}
|
|
|
|
let appKitRect = CGRect(
|
|
x: min(pointA.x, pointB.x),
|
|
y: min(pointA.y, pointB.y),
|
|
width: abs(pointB.x - pointA.x),
|
|
height: abs(pointB.y - pointA.y)
|
|
).intersection(screen.frame)
|
|
let expected = CaptureRegion.fromAppKit(rect: appKitRect, on: screen)
|
|
|
|
let picker = RegionPickerController()
|
|
var result: CaptureRegion??
|
|
picker.pick { region in
|
|
result = .some(region)
|
|
}
|
|
|
|
guard let overlay = overlayWindow(containing: pointA) else {
|
|
fail(expected: format(expected.rect), got: "no overlay window after pick()")
|
|
}
|
|
overlay.makeKey()
|
|
|
|
var eventNumber = 1
|
|
func post(_ type: NSEvent.EventType, at screenPoint: NSPoint, clickCount: Int) {
|
|
let locationInWindow = overlay.convertPoint(fromScreen: screenPoint)
|
|
guard let event = NSEvent.mouseEvent(
|
|
with: type,
|
|
location: locationInWindow,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: overlay.windowNumber,
|
|
context: nil,
|
|
eventNumber: eventNumber,
|
|
clickCount: clickCount,
|
|
pressure: 1
|
|
) else {
|
|
fail(expected: format(expected.rect), got: "NSEvent.mouseEvent(\(type.rawValue)) returned nil")
|
|
}
|
|
eventNumber += 1
|
|
NSApp.postEvent(event, atStart: false)
|
|
// Local monitors run during NSApp.sendEvent (production dispatch),
|
|
// not during nextEvent dequeue. Drive that same path here.
|
|
NSApp.sendEvent(event)
|
|
}
|
|
|
|
post(.leftMouseDown, at: pointA, clickCount: 1)
|
|
for step in 1...(dragSteps / 2) {
|
|
post(.leftMouseDragged, at: interpolate(step), clickCount: 0)
|
|
}
|
|
|
|
writeOverlayBitmap(overlay, to: outputDirectory.appendingPathComponent("overlay-middrag.png"))
|
|
|
|
for step in ((dragSteps / 2) + 1)...dragSteps {
|
|
post(.leftMouseDragged, at: interpolate(step), clickCount: 0)
|
|
}
|
|
post(.leftMouseUp, at: pointB, clickCount: 1)
|
|
|
|
guard let wrapped = result else {
|
|
fail(expected: format(expected.rect), got: "completion never fired")
|
|
}
|
|
guard let got = wrapped else {
|
|
fail(expected: format(expected.rect), got: "nil")
|
|
}
|
|
|
|
if got.rect != expected.rect {
|
|
fail(expected: format(expected.rect), got: format(got.rect))
|
|
}
|
|
|
|
print("PICKER-SELFTEST PASS rect=\(format(got.rect))")
|
|
fflush(stdout)
|
|
|
|
runRegionPersistPhase()
|
|
// Hop off this MainActor job so the SEND-TRUTH Task can run; do not
|
|
// exit(0) here — runSendTruthPhase prints its own PASS/FAIL, then
|
|
// chains to UPDATE-SELFTEST (or exits if that phase is not requested).
|
|
runSendTruthPhase()
|
|
}
|
|
|
|
/// Phase 2: writes a known region under `CaptureRegion.defaultsKey`, reloads it through
|
|
/// `AppModel.loadPersistedRegion()` (the same path init uses), then restores whatever
|
|
/// value was stored before so a real picked region is untouched.
|
|
private static func runRegionPersistPhase() {
|
|
let defaults = UserDefaults.standard
|
|
let previous = defaults.data(forKey: CaptureRegion.defaultsKey)
|
|
|
|
let known = CaptureRegion(
|
|
displayID: CGMainDisplayID(),
|
|
rect: CGRect(x: 10, y: 10, width: 100, height: 100),
|
|
capturedScale: 2.0
|
|
)
|
|
var failure: String?
|
|
if let encoded = try? JSONEncoder().encode(known) {
|
|
defaults.set(encoded, forKey: CaptureRegion.defaultsKey)
|
|
if let loaded = AppModel.loadPersistedRegion() {
|
|
if loaded.rect != known.rect {
|
|
failure = "expected=\(format(known.rect)) got=\(format(loaded.rect))"
|
|
}
|
|
} else {
|
|
failure = "loadPersistedRegion returned nil"
|
|
}
|
|
} else {
|
|
failure = "could not encode CaptureRegion"
|
|
}
|
|
|
|
if let previous {
|
|
defaults.set(previous, forKey: CaptureRegion.defaultsKey)
|
|
} else {
|
|
defaults.removeObject(forKey: CaptureRegion.defaultsKey)
|
|
}
|
|
|
|
if let failure {
|
|
print("REGION-PERSIST FAIL \(failure)")
|
|
fflush(stdout)
|
|
exit(1)
|
|
}
|
|
print("REGION-PERSIST PASS")
|
|
fflush(stdout)
|
|
}
|
|
|
|
/// Phase 3: drive SendController's share-outcome seams with no AirDrop sheet.
|
|
/// Fail path must leave the session open in the temp spool; success path archives
|
|
/// and mints a fresh empty session. Scheduled as a new MainActor job because this
|
|
/// function is called from inside `execute()` — a nested run-loop wait would never
|
|
/// let the Task start. On success, chains to UPDATE-SELFTEST instead of exiting.
|
|
private static func runSendTruthPhase() {
|
|
Task { @MainActor in
|
|
do {
|
|
try await executeSendTruth()
|
|
print("SEND-TRUTH PASS")
|
|
fflush(stdout)
|
|
if !startUpdateSelfTestIfRequested(), !startOneDriveSelfTestIfRequested() {
|
|
exit(0)
|
|
}
|
|
} catch {
|
|
print("SEND-TRUTH FAIL \(error)")
|
|
fflush(stdout)
|
|
exit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func executeSendTruth() async throws {
|
|
let fm = FileManager.default
|
|
let root = fm.temporaryDirectory
|
|
.appendingPathComponent("shotdeck-send-truth-\(UUID().uuidString)", isDirectory: true)
|
|
defer { try? fm.removeItem(at: root) }
|
|
|
|
let paths = try AppSupportPaths(
|
|
root: root,
|
|
outbox: root.appendingPathComponent("outbox", isDirectory: true),
|
|
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
|
|
)
|
|
let ledger = try ReturnLedger(paths: paths)
|
|
let model = AppModel(
|
|
paths: paths,
|
|
spool: try SpoolStore(paths: paths),
|
|
composer: PDFComposer(),
|
|
capturer: ScreenCapturer(),
|
|
hotkeys: HotkeyCenter(),
|
|
picker: RegionPickerController(),
|
|
ledger: ledger,
|
|
watcher: ReturnWatcher(paths: paths, ledger: ledger)
|
|
)
|
|
model.setFolderURLs(outbox: paths.outbox, watch: paths.watchFolder)
|
|
|
|
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())
|
|
let openID = model.session.id
|
|
guard !model.session.isEmpty else {
|
|
sendTruthFail("seeded session was empty")
|
|
}
|
|
|
|
let pending = try await model.composePDFForSend(outbox: model.outboxURL, transport: .airDrop)
|
|
guard fm.fileExists(atPath: pending.fileURL.path) else {
|
|
sendTruthFail("PDF was not written")
|
|
}
|
|
|
|
model.handleDidFailToShareItems(fileName: pending.fileName)
|
|
let still = try await model.spool.currentSession()
|
|
guard still.id == openID, !still.isEmpty, still.state == .open else {
|
|
sendTruthFail("fail path archived or replaced the session")
|
|
}
|
|
let spoolDir = paths.sessionDirectory(openID)
|
|
guard fm.fileExists(atPath: spoolDir.path) else {
|
|
sendTruthFail("fail path: session missing from temp spool")
|
|
}
|
|
guard let status = model.statusLine, status.contains("nothing was sent") else {
|
|
sendTruthFail("fail path status missing 'nothing was sent': \(model.statusLine ?? "nil")")
|
|
}
|
|
|
|
await model.handleDidShareItems(fileName: pending.fileName, pageCount: pending.pageCount)
|
|
let fresh = try await model.spool.currentSession()
|
|
guard fresh.isEmpty, fresh.id != openID, fresh.state == .open else {
|
|
sendTruthFail("success path did not mint a fresh empty session")
|
|
}
|
|
let archived = try await model.spool.archivedSessions()
|
|
guard archived.contains(where: { $0.id == openID && $0.state == .archived }) else {
|
|
sendTruthFail("success path did not archive the session")
|
|
}
|
|
let archiveDir = paths.archiveDirectory(openID)
|
|
guard fm.fileExists(atPath: archiveDir.path) else {
|
|
sendTruthFail("success path: archive dir missing")
|
|
}
|
|
guard !fm.fileExists(atPath: spoolDir.path) else {
|
|
sendTruthFail("success path: session still in spool")
|
|
}
|
|
}
|
|
|
|
private static func makeTinyPNGData() throws -> Data {
|
|
let width = 64
|
|
let height = 48
|
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
guard let context = CGContext(
|
|
data: nil,
|
|
width: width,
|
|
height: height,
|
|
bitsPerComponent: 8,
|
|
bytesPerRow: width * 4,
|
|
space: colorSpace,
|
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
) else {
|
|
sendTruthFail("could not create PNG context")
|
|
}
|
|
context.setFillColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1)
|
|
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
|
guard let image = context.makeImage() else {
|
|
sendTruthFail("could not make CGImage")
|
|
}
|
|
let buffer = NSMutableData()
|
|
guard let destination = CGImageDestinationCreateWithData(
|
|
buffer,
|
|
"public.png" as CFString,
|
|
1,
|
|
nil
|
|
) else {
|
|
sendTruthFail("could not create PNG destination")
|
|
}
|
|
CGImageDestinationAddImage(destination, image, nil)
|
|
guard CGImageDestinationFinalize(destination) else {
|
|
sendTruthFail("could not finalize PNG")
|
|
}
|
|
return buffer as Data
|
|
}
|
|
|
|
private static func sendTruthFail(_ reason: String) -> Never {
|
|
print("SEND-TRUTH FAIL \(reason)")
|
|
fflush(stdout)
|
|
exit(1)
|
|
}
|
|
|
|
/// Phase 4: builds a fake 99.0.0 bundle, serves a local appcast, stages via
|
|
/// `checkNow`, then `installStaged` into the env dir — never `/Applications`.
|
|
/// Returns true when the async phase was scheduled (it calls `exit` itself).
|
|
@discardableResult
|
|
private static func startUpdateSelfTestIfRequested() -> Bool {
|
|
guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"],
|
|
!raw.isEmpty
|
|
else { return false }
|
|
|
|
let output = URL(fileURLWithPath: raw, isDirectory: true)
|
|
Task { @MainActor in
|
|
do {
|
|
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 {
|
|
updateFail(String(describing: error))
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
private static func runUpdateSelfTest(outputDirectory: URL) async throws {
|
|
let fm = FileManager.default
|
|
try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
|
|
|
|
guard let sourceApp = ownAppBundleURL() else {
|
|
throw UpdateSelfTestError.detail("own bundle is not a .app (\(Bundle.main.bundleURL.path))")
|
|
}
|
|
|
|
let payload = outputDirectory.appendingPathComponent("payload", isDirectory: true)
|
|
if fm.fileExists(atPath: payload.path) {
|
|
try fm.removeItem(at: payload)
|
|
}
|
|
try fm.createDirectory(at: payload, withIntermediateDirectories: true)
|
|
let fakeApp = payload.appendingPathComponent("Redline.app")
|
|
try fm.copyItem(at: sourceApp, to: fakeApp)
|
|
|
|
let plistURL = fakeApp.appendingPathComponent("Contents/Info.plist")
|
|
let plistData = try Data(contentsOf: plistURL)
|
|
guard var plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] else {
|
|
throw UpdateSelfTestError.detail("could not parse copied Info.plist")
|
|
}
|
|
plist["CFBundleShortVersionString"] = "99.0.0"
|
|
let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
|
try rewritten.write(to: plistURL)
|
|
|
|
let zipURL = outputDirectory.appendingPathComponent("Redline-99.0.0.zip")
|
|
if fm.fileExists(atPath: zipURL.path) {
|
|
try fm.removeItem(at: zipURL)
|
|
}
|
|
try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path])
|
|
|
|
let zipData = try Data(contentsOf: zipURL)
|
|
let hex = UpdateChecker.sha256Hex(zipData)
|
|
|
|
let appcastURL = outputDirectory.appendingPathComponent("appcast.json")
|
|
let appcast: [String: String] = [
|
|
"version": "99.0.0",
|
|
"zipURL": zipURL.absoluteString,
|
|
"sha256": hex,
|
|
"notes": "UPDATE-SELFTEST",
|
|
]
|
|
let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys])
|
|
try appcastData.write(to: appcastURL)
|
|
|
|
let defaults = UserDefaults.standard
|
|
let previous = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey)
|
|
defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey)
|
|
defer {
|
|
if let previous {
|
|
defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey)
|
|
} else {
|
|
defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey)
|
|
}
|
|
}
|
|
|
|
let (model, isolatedRoot) = try makeIsolatedUpdateModel()
|
|
defer { try? fm.removeItem(at: isolatedRoot) }
|
|
|
|
await model.updateChecker.checkNow()
|
|
|
|
guard model.updateAvailable?.version == "99.0.0" else {
|
|
throw UpdateSelfTestError.detail(
|
|
"updateAvailable=\(model.updateAvailable?.version ?? "nil")"
|
|
)
|
|
}
|
|
guard let staged = model.updateChecker.stagedAppURL else {
|
|
throw UpdateSelfTestError.detail("staged payload missing")
|
|
}
|
|
guard staged.lastPathComponent == "Redline.app" else {
|
|
throw UpdateSelfTestError.detail("staged name \(staged.lastPathComponent)")
|
|
}
|
|
let stagedExe = staged.appendingPathComponent("Contents/MacOS/Shotdeck")
|
|
guard fm.fileExists(atPath: stagedExe.path) else {
|
|
throw UpdateSelfTestError.detail("staged Contents/MacOS/Shotdeck missing")
|
|
}
|
|
|
|
let targetRoot = outputDirectory.appendingPathComponent("target", isDirectory: true)
|
|
if fm.fileExists(atPath: targetRoot.path) {
|
|
try fm.removeItem(at: targetRoot)
|
|
}
|
|
let target = targetRoot.appendingPathComponent("Redline.app")
|
|
model.updateChecker.installStaged(to: target)
|
|
|
|
let installedPlist = target.appendingPathComponent("Contents/Info.plist")
|
|
guard let installed = NSDictionary(contentsOf: installedPlist) as? [String: Any],
|
|
let installedVersion = installed["CFBundleShortVersionString"] as? String
|
|
else {
|
|
throw UpdateSelfTestError.detail("installed Info.plist unreadable")
|
|
}
|
|
guard installedVersion == "99.0.0" else {
|
|
throw UpdateSelfTestError.detail("installed version \(installedVersion)")
|
|
}
|
|
}
|
|
|
|
private static func ownAppBundleURL() -> URL? {
|
|
let bundle = Bundle.main.bundleURL
|
|
if bundle.pathExtension == "app" { return bundle }
|
|
let up3 = bundle
|
|
.deletingLastPathComponent()
|
|
.deletingLastPathComponent()
|
|
.deletingLastPathComponent()
|
|
if up3.pathExtension == "app" { return up3 }
|
|
return nil
|
|
}
|
|
|
|
private static func makeIsolatedUpdateModel() throws -> (AppModel, URL) {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("shotdeck-update-selftest-\(UUID().uuidString)", isDirectory: true)
|
|
let paths = try AppSupportPaths(
|
|
root: root,
|
|
outbox: root.appendingPathComponent("outbox", isDirectory: true),
|
|
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
|
|
)
|
|
let ledger = try ReturnLedger(paths: paths)
|
|
let model = AppModel(
|
|
paths: paths,
|
|
spool: try SpoolStore(paths: paths),
|
|
composer: PDFComposer(),
|
|
capturer: ScreenCapturer(),
|
|
hotkeys: HotkeyCenter(),
|
|
picker: RegionPickerController(),
|
|
ledger: ledger,
|
|
watcher: ReturnWatcher(paths: paths, ledger: ledger)
|
|
)
|
|
return (model, root)
|
|
}
|
|
|
|
private static func runDitto(arguments: [String]) throws {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
|
|
process.arguments = arguments
|
|
let err = Pipe()
|
|
process.standardError = err
|
|
process.standardOutput = Pipe()
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
guard process.terminationStatus == 0 else {
|
|
let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
throw UpdateSelfTestError.detail("ditto failed: \(message)")
|
|
}
|
|
}
|
|
|
|
private static func updateFail(_ detail: String) -> Never {
|
|
print("UPDATE-SELFTEST FAIL \(detail)")
|
|
fflush(stdout)
|
|
exit(1)
|
|
}
|
|
|
|
/// Phase 5: proves the OneDrive transport end to end against a REAL sync root —
|
|
/// resolved at runtime via `OneDriveLocator.syncRoots()`, never a hardcoded path, so
|
|
/// this runs correctly on any Mac/account that has OneDrive signed in (MMD-named
|
|
/// root preferred, same as production). 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() {
|
|
// Never a false PASS: no real OneDrive sync root on this machine/account is a
|
|
// SKIP (still non-zero exit), not silently treated as passing.
|
|
guard let syncRoot = OneDriveLocator.syncRoots().first else {
|
|
print("ONEDRIVE-SELFTEST SKIP no OneDrive sync root")
|
|
fflush(stdout)
|
|
exit(1)
|
|
}
|
|
Task { @MainActor in
|
|
do {
|
|
let folder = try await executeOneDriveSelfTest(syncRoot: syncRoot)
|
|
print("ONEDRIVE-SELFTEST PASS path=\(folder.path)")
|
|
fflush(stdout)
|
|
exit(0)
|
|
} catch let error as OneDriveSelfTestError {
|
|
oneDriveFail(error.description)
|
|
} catch {
|
|
oneDriveFail(String(describing: error))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sub-step 1: builds a session, sends it through the OneDrive branch of
|
|
/// `send(anchor: nil)` against a NEW folder under `syncRoot`, 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.
|
|
///
|
|
/// Sub-step 1b: rapid transport toggling (chooseTransport(.airDrop) immediately
|
|
/// followed by chooseTransport(.oneDrive), no await between them) must still end
|
|
/// with the watcher pointed at the OneDrive folder — proves the generation-guarded
|
|
/// reconcile in chooseTransport/chooseOneDriveFolder (SettingsView.swift) really
|
|
/// does let the last choice win instead of an earlier, superseded call applying its
|
|
/// stale folder after a later one already won.
|
|
///
|
|
/// Sub-step 1c: the OTHER race — a toggle immediately followed by Send, with no
|
|
/// sleep at all before send() runs. Proves send() awaits `pendingReconcileTask`
|
|
/// before snapshotting transport/folder: a freshly-sent, still-unmarked PDF must
|
|
/// never be reported as an already-returned document (which is exactly what would
|
|
/// happen if send() raced ahead while recordUncommented was still `true`, stale
|
|
/// from the .airDrop leg of the toggle).
|
|
///
|
|
/// Sub-step 2: relaunch simulation — the exact BLOCKER scenario this phase exists to
|
|
/// catch. OneDrive is still persisted in defaults from sub-step 1; builds a FRESH
|
|
/// model the same way the real app launches (`AppDelegate.makeLaunchModel()` itself,
|
|
/// not a reimplementation), bootstraps it, then marks a PDF in the folder in place —
|
|
/// the relaunched watcher must report it. A temp app-support root keeps this off the
|
|
/// real ~/Library/Application Support/Shotdeck.
|
|
///
|
|
/// Never deletes anything under OneDrive — the created folder and PDFs are left in
|
|
/// place for Ben to inspect / for the real iPad round trip.
|
|
private static func executeOneDriveSelfTest(syncRoot: URL) async throws -> URL {
|
|
let fm = FileManager.default
|
|
|
|
// 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 = syncRoot.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.
|
|
try addInkMark(to: pdfURL)
|
|
|
|
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")
|
|
}
|
|
|
|
// Sub-step 1b: rapid toggle race — see the doc comment above this function.
|
|
model.chooseTransport(.airDrop)
|
|
model.chooseTransport(.oneDrive) // immediately superseding the call above
|
|
// The generation guard itself is what's under test, not this wait — it just
|
|
// gives the (already-guarded) reconcile Task a moment to settle either way.
|
|
try await Task.sleep(for: .milliseconds(500))
|
|
guard model.transport == .oneDrive else {
|
|
throw OneDriveSelfTestError.detail(
|
|
"rapid toggle: model.transport ended as \(model.transport), expected .oneDrive"
|
|
)
|
|
}
|
|
let racePDFURL = selftestFolder.appendingPathComponent("Redline-race-\(stamp).pdf")
|
|
try writeUnmarkedRedlinePDF(to: racePDFURL)
|
|
try addInkMark(to: racePDFURL)
|
|
let raceFound = try await model.watcher.scanNow()
|
|
guard raceFound.first(where: { $0.fileURL == racePDFURL })?.isCommented == true else {
|
|
throw OneDriveSelfTestError.detail(
|
|
"rapid toggle: watcher did not end up watching \(selftestFolder.path) — an earlier, superseded chooseTransport call won"
|
|
)
|
|
}
|
|
|
|
// Sub-step 1c: toggle-then-immediate-send race — see the doc comment above
|
|
// this function. No sleep here: this IS the exact race window finding #3
|
|
// exists to close, so send() itself must wait out the pending reconcile.
|
|
let racePNG = try makeTinyPNGData()
|
|
_ = try await model.spool.append(
|
|
pngData: racePNG, pixelWidth: 64, pixelHeight: 48, scale: 1, capturedAt: Date()
|
|
)
|
|
model.replaceSession(try await model.spool.currentSession())
|
|
guard !model.session.isEmpty else {
|
|
throw OneDriveSelfTestError.detail("toggle-then-send: re-seeded session was empty")
|
|
}
|
|
|
|
let knownBeforeToggleSend = Set(
|
|
((try? fm.contentsOfDirectory(at: selftestFolder, includingPropertiesForKeys: nil)) ?? [])
|
|
.map(\.lastPathComponent)
|
|
)
|
|
model.chooseTransport(.airDrop)
|
|
model.chooseTransport(.oneDrive) // immediately superseding, no sleep before send()
|
|
await model.send(anchor: nil)
|
|
|
|
guard let toggleSendStatus = model.statusLine, toggleSendStatus.hasPrefix("Saved to OneDrive") else {
|
|
throw OneDriveSelfTestError.detail(
|
|
"toggle-then-send: status was \(model.statusLine ?? "nil"), expected 'Saved to OneDrive'"
|
|
)
|
|
}
|
|
guard model.session.isEmpty else {
|
|
throw OneDriveSelfTestError.detail("toggle-then-send: session was not archived")
|
|
}
|
|
let filesAfterToggleSend = (try? fm.contentsOfDirectory(
|
|
at: selftestFolder, includingPropertiesForKeys: nil
|
|
)) ?? []
|
|
guard let toggleSendPDFURL = filesAfterToggleSend.first(where: {
|
|
$0.pathExtension.lowercased() == "pdf" && !knownBeforeToggleSend.contains($0.lastPathComponent)
|
|
}) else {
|
|
throw OneDriveSelfTestError.detail("toggle-then-send: no new PDF found in \(selftestFolder.path)")
|
|
}
|
|
// The freshly-sent PDF is UNMARKED. If send() had raced ahead of the pending
|
|
// reconcile, recordUncommented could still have been (stale) true, and this
|
|
// scan would wrongly report it as already returned.
|
|
let scanAfterToggleSend = try await model.watcher.scanNow()
|
|
guard !scanAfterToggleSend.contains(where: { $0.fileURL == toggleSendPDFURL }) else {
|
|
throw OneDriveSelfTestError.detail(
|
|
"toggle-then-send: freshly-sent unmarked PDF at \(toggleSendPDFURL.path) was reported as returned — send() raced ahead of the pending reconcile"
|
|
)
|
|
}
|
|
|
|
// Sub-step 2: relaunch simulation — see the doc comment above this function.
|
|
let relaunchAppSupportRoot = fm.temporaryDirectory
|
|
.appendingPathComponent("shotdeck-onedrive-relaunch-\(UUID().uuidString)", isDirectory: true)
|
|
defer { try? fm.removeItem(at: relaunchAppSupportRoot) }
|
|
|
|
let relaunchModel = AppDelegate.makeLaunchModel(appSupportRoot: relaunchAppSupportRoot)
|
|
guard relaunchModel.transport == .oneDrive else {
|
|
throw OneDriveSelfTestError.detail(
|
|
"relaunch: model transport was \(relaunchModel.transport), expected .oneDrive"
|
|
)
|
|
}
|
|
guard relaunchModel.watchFolderURL.path == selftestFolder.path else {
|
|
throw OneDriveSelfTestError.detail(
|
|
"relaunch: model watchFolderURL was \(relaunchModel.watchFolderURL.path), expected \(selftestFolder.path) — this is the exact BLOCKER this phase guards against"
|
|
)
|
|
}
|
|
|
|
await relaunchModel.bootstrap()
|
|
|
|
let relaunchPDFURL = selftestFolder.appendingPathComponent("Redline-relaunch-\(stamp).pdf")
|
|
try writeUnmarkedRedlinePDF(to: relaunchPDFURL)
|
|
try addInkMark(to: relaunchPDFURL)
|
|
|
|
let relaunchFound = try await relaunchModel.watcher.scanNow()
|
|
guard relaunchFound.first(where: { $0.fileURL == relaunchPDFURL })?.isCommented == true else {
|
|
throw OneDriveSelfTestError.detail(
|
|
"relaunch: watcher did not report the marked PDF at \(relaunchPDFURL.path) as returned — it was watching the wrong folder after relaunch"
|
|
)
|
|
}
|
|
await relaunchModel.watcher.stop()
|
|
|
|
return selftestFolder
|
|
}
|
|
|
|
/// Adds a real PDFKit ink annotation to the PDF at `url` in place and saves it —
|
|
/// exactly what the iPad does when marking up a page.
|
|
private static func addInkMark(to url: URL) throws {
|
|
guard let document = PDFDocument(url: url), let page = document.page(at: 0) else {
|
|
throw OneDriveSelfTestError.detail("could not reopen \(url.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: url) else {
|
|
throw OneDriveSelfTestError.detail("could not save the annotated PDF back to \(url.path)")
|
|
}
|
|
}
|
|
|
|
/// Writes a fresh, unmarked, single-page "Redline"-creator PDF straight to `url` —
|
|
/// standing in for a PDF that has just landed in the watch folder, before any
|
|
/// human mark. Used by the rapid-toggle and relaunch sub-steps, which don't need to
|
|
/// exercise send()/composePDFForSend() again (sub-step 1 already does).
|
|
private static func writeUnmarkedRedlinePDF(to url: URL) throws {
|
|
let document = PDFDocument()
|
|
let page = PDFPage()
|
|
page.setBounds(CGRect(x: 0, y: 0, width: 612, height: 792), for: .mediaBox)
|
|
document.insert(page, at: 0)
|
|
document.documentAttributes = [
|
|
PDFDocumentAttribute.creatorAttribute: "Redline",
|
|
PDFDocumentAttribute.subjectAttribute: UUID().uuidString,
|
|
]
|
|
guard document.write(to: url) else {
|
|
throw OneDriveSelfTestError.detail("could not write \(url.path)")
|
|
}
|
|
}
|
|
|
|
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(
|
|
x: pointA.x + (pointB.x - pointA.x) * t,
|
|
y: pointA.y + (pointB.y - pointA.y) * t
|
|
)
|
|
}
|
|
|
|
private static func overlayWindow(containing point: NSPoint) -> RegionPickerWindow? {
|
|
let overlays = NSApp.windows.compactMap { $0 as? RegionPickerWindow }
|
|
return overlays.first(where: { $0.coveringScreen.frame.contains(point) }) ?? overlays.first
|
|
}
|
|
|
|
private static func writeOverlayBitmap(_ overlay: RegionPickerWindow, to url: URL) {
|
|
guard let view = overlay.contentView else {
|
|
fail(expected: expectedLabel(), got: "overlay has no contentView")
|
|
}
|
|
overlay.layoutIfNeeded()
|
|
view.layoutSubtreeIfNeeded()
|
|
view.display()
|
|
let bounds = view.bounds
|
|
guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else {
|
|
fail(expected: expectedLabel(), got: "bitmapImageRepForCachingDisplay failed")
|
|
}
|
|
view.cacheDisplay(in: bounds, to: rep)
|
|
guard let png = rep.representation(using: .png, properties: [:]) else {
|
|
fail(expected: expectedLabel(), got: "PNG encode failed")
|
|
}
|
|
do {
|
|
try png.write(to: url)
|
|
} catch {
|
|
fail(expected: expectedLabel(), got: "could not write \(url.path): \(error)")
|
|
}
|
|
print(url.path)
|
|
fflush(stdout)
|
|
}
|
|
|
|
private static func expectedLabel() -> String {
|
|
format(CGRect(
|
|
x: min(pointA.x, pointB.x),
|
|
y: min(pointA.y, pointB.y),
|
|
width: abs(pointB.x - pointA.x),
|
|
height: abs(pointB.y - pointA.y)
|
|
))
|
|
}
|
|
|
|
private static func format(_ rect: CGRect) -> String {
|
|
"(\(rect.origin.x), \(rect.origin.y), \(rect.width), \(rect.height))"
|
|
}
|
|
|
|
private static func fail(expected: String, got: String) -> Never {
|
|
print("PICKER-SELFTEST FAIL expected=\(expected) got=\(got)")
|
|
fflush(stdout)
|
|
exit(1)
|
|
}
|
|
}
|
|
|
|
private enum UpdateSelfTestError: Error, CustomStringConvertible {
|
|
case detail(String)
|
|
var description: String {
|
|
switch self {
|
|
case .detail(let s): return s
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum OneDriveSelfTestError: Error, CustomStringConvertible {
|
|
case detail(String)
|
|
var description: String {
|
|
switch self {
|
|
case .detail(let s): return s
|
|
}
|
|
}
|
|
}
|