import AppKit import CoreGraphics import Darwin import Foundation import ImageIO 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 exits when it finishes. 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. private static func runSendTruthPhase() { Task { @MainActor in do { try await executeSendTruth() print("SEND-TRUTH PASS") fflush(stdout) 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() 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) } 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) } }