diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift index 410d55a..47897ad 100644 --- a/Sources/Shotdeck/AppModel.swift +++ b/Sources/Shotdeck/AppModel.swift @@ -36,6 +36,8 @@ public final class AppModel { public private(set) var outboxURL: URL /// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`. public private(set) var watchFolderURL: URL + /// Absolute URL of the PDF composed this run, if any. Used by "Reveal last PDF". + public private(set) var lastComposedPDFURL: URL? /// Currently bound capture combo (the last one Carbon accepted, or the preferred load). private(set) var captureHotkey: HotkeyPreference var hotkeyDisplayString: String { captureHotkey.displayString } @@ -116,6 +118,42 @@ public final class AppModel { watchFolderURL = watch setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent) } + func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url } + + /// True when a last-composed PDF path is known this run, or the newest + /// `Redline-*.pdf` in the outbox exists on disk. + var canRevealLastPDF: Bool { revealablePDFURL() != nil } + + public func revealLastPDF() { + guard let url = revealablePDFURL() else { return } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + func revealablePDFURL() -> URL? { + if let last = lastComposedPDFURL, FileManager.default.fileExists(atPath: last.path) { + return last + } + return newestOutboxRedlinePDF() + } + + func newestOutboxRedlinePDF() -> URL? { + let fm = FileManager.default + let items = (try? fm.contentsOfDirectory( + at: outboxURL, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + )) ?? [] + let matches = items.filter { + $0.lastPathComponent.hasPrefix("Redline-") && $0.pathExtension.lowercased() == "pdf" + } + return matches.max { a, b in + let da = (try? a.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate) ?? .distantPast + let db = (try? b.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate) ?? .distantPast + return da < db + } + } public var iconState: MenuIconState { if !screenRecordingGranted { return .recordingMissing } diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift index 1dbca7c..5b239cb 100644 --- a/Sources/Shotdeck/MenuBarView.swift +++ b/Sources/Shotdeck/MenuBarView.swift @@ -95,6 +95,13 @@ struct MenuBarView: View { } .disabled(model.session.isEmpty || model.isSending) + Button { + model.revealLastPDF() + } label: { + actionLabel("Reveal last PDF") + } + .disabled(!model.canRevealLastPDF) + Button { Task { await model.captureNow() } } label: { diff --git a/Sources/Shotdeck/PickerSelfTest.swift b/Sources/Shotdeck/PickerSelfTest.swift index 2f14ffe..b30c703 100644 --- a/Sources/Shotdeck/PickerSelfTest.swift +++ b/Sources/Shotdeck/PickerSelfTest.swift @@ -1,6 +1,8 @@ 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`. @@ -113,10 +115,10 @@ enum PickerSelfTest { fflush(stdout) runRegionPersistPhase() - if !startUpdateSelfTestIfRequested() { - exit(0) - } - // UPDATE-SELFTEST hops to a later main-actor turn and exits itself. + // 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 @@ -160,7 +162,145 @@ enum PickerSelfTest { fflush(stdout) } - /// Phase 3: builds a fake 99.0.0 bundle, serves a local appcast, stages via + /// 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() { + 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) + } + + /// 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 diff --git a/Sources/Shotdeck/SendController.swift b/Sources/Shotdeck/SendController.swift index 59b7a87..d4d818a 100644 --- a/Sources/Shotdeck/SendController.swift +++ b/Sources/Shotdeck/SendController.swift @@ -3,12 +3,59 @@ import Darwin import Foundation import ShotdeckCore +/// Result of composing a send PDF. Kept so the self-test can drive the share +/// outcome without presenting a real AirDrop sheet. +struct ComposedSend: Sendable { + let fileName: String + let fileURL: URL + let pageCount: Int +} + extension AppModel: SendCapable { public func send(anchor: NSView?) async { guard !session.isEmpty, !isSending else { return } setSending(true) - defer { setSending(false) } + let pending: ComposedSend + do { + pending = try await composePDFForSend() + } catch { + // Never unlink the published PDF, and never unlink the temp file either: + // a rename failure would leave the complete document at the temp name. + setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.") + setSending(false) + return + } + + guard let anchor else { + handleDidFailToShareItems(fileName: pending.fileName) + setSending(false) + return + } + + do { + try Sharing.airDrop(fileURL: pending.fileURL, from: anchor) { [weak self] success in + guard let self else { return } + if success { + await self.handleDidShareItems( + fileName: pending.fileName, + pageCount: pending.pageCount + ) + } else { + self.handleDidFailToShareItems(fileName: pending.fileName) + } + self.setSending(false) + } + } catch { + // canPerform false, no service, or no visible window: same as cancel. + handleDidFailToShareItems(fileName: pending.fileName) + setSending(false) + } + } + + /// Writes the PDF to the outbox and records its path. Does not archive the session + /// and does not present AirDrop — that happens only after the share completes. + func composePDFForSend() async throws -> ComposedSend { let workingSession = session let composer = self.composer // Live outbox (FolderSettings), not `paths.outbox` — Settings changes take effect. @@ -20,52 +67,54 @@ extension AppModel: SendCapable { let tempURL = outboxDir.appendingPathComponent(".shotdeck-\(UUID().uuidString).pdf") let title = "Redline – \(DubaiTime.stamp(workingSession.createdAt))" - do { - // D-13: build off the main actor. Only Sendable values cross into the - // detached task — never `anchor` (NSView is not Sendable). - try await Task.detached(priority: .userInitiated) { - _ = try composer.compose( - session: workingSession, - imageURL: { capture in sourceDir.appendingPathComponent(capture.fileName) }, - title: title, - to: tempURL + // D-13: build off the main actor. Only Sendable values cross into the + // detached task — never `anchor` (NSView is not Sendable). + try await Task.detached(priority: .userInitiated) { + _ = try composer.compose( + session: workingSession, + imageURL: { capture in sourceDir.appendingPathComponent(capture.fileName) }, + title: title, + to: tempURL + ) + // POSIX rename onto `finalURL` replaces any same-name file in one + // directory operation; there is never a window where the PDF is gone. + if Darwin.rename(tempURL.path, finalURL.path) != 0 { + throw ShotdeckError.pdfCompositionFailed( + reason: "could not publish the PDF: \(String(cString: strerror(errno)))" ) - // POSIX rename onto `finalURL` replaces any same-name file in one - // directory operation; there is never a window where the PDF is gone. - if Darwin.rename(tempURL.path, finalURL.path) != 0 { - throw ShotdeckError.pdfCompositionFailed( - reason: "could not publish the PDF: \(String(cString: strerror(errno)))" - ) - } - try AtomicFile.fsyncDirectory(at: outboxDir) - }.value - - // File exists on disk now — archive only after that (D-13). A later AirDrop - // failure never deletes this file. - guard FileManager.default.fileExists(atPath: finalURL.path) else { - throw ShotdeckError.pdfCompositionFailed(reason: "the PDF was not written to disk") } + try AtomicFile.fsyncDirectory(at: outboxDir) + }.value + + guard FileManager.default.fileExists(atPath: finalURL.path) else { + throw ShotdeckError.pdfCompositionFailed(reason: "the PDF was not written to disk") + } + rememberLastComposedPDF(finalURL) + return ComposedSend( + fileName: fileName, + fileURL: finalURL, + pageCount: workingSession.captures.count + ) + } + + /// `NSSharingServiceDelegate.sharingService(_:didShareItems:)` seam. + func handleDidShareItems(fileName: String, pageCount: Int) async { + guard !session.isEmpty else { return } + do { _ = try await spool.archiveCurrent(pdfFileName: fileName) replaceSession(try await spool.currentSession()) - - let pageWord = workingSession.captures.count == 1 ? "page" : "pages" - setStatus("Sent — \(workingSession.captures.count) \(pageWord).") - - guard let anchor else { - setStatus("PDF saved to \(outboxDisplayName). Open the panel to AirDrop it.") - return - } - do { - try Sharing.airDrop(fileURL: finalURL, from: anchor) - } catch { - setStatus( - "AirDrop is not available right now — the PDF is on your \(outboxDisplayName)." - ) - } + let pageWord = pageCount == 1 ? "page" : "pages" + setStatus("Sent — \(pageCount) \(pageWord).") } catch { - // Never unlink the published PDF, and never unlink `tempURL` either: - // a rename failure would leave the complete document at the temp name. - setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.") + setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not archive the session.") } } + + /// `NSSharingServiceDelegate.sharingService(_:didFailToShareItems:error:)` seam, + /// also used when `canPerform` is false or the user cancels. Does not archive. + func handleDidFailToShareItems(fileName: String) { + setStatus( + "AirDrop didn't complete — nothing was sent. Your captures are still here; the PDF is on your \(outboxDisplayName) as \(fileName)." + ) + } } diff --git a/Sources/Shotdeck/Sharing.swift b/Sources/Shotdeck/Sharing.swift index 5b9741a..7d26be0 100644 --- a/Sources/Shotdeck/Sharing.swift +++ b/Sources/Shotdeck/Sharing.swift @@ -7,7 +7,14 @@ enum Sharing { /// Throws `ShotdeckError.airDropUnavailable` when the service cannot be created, /// `canPerform` is false, or `view` is not in a visible window (a detached view /// never produces an on-screen sheet). - static func airDrop(fileURL: URL, from view: NSView) throws { + /// + /// `onFinished` is invoked on the main actor when the sheet completes: `true` for + /// `didShareItems`, `false` for `didFailToShareItems` (including user cancel). + static func airDrop( + fileURL: URL, + from view: NSView, + onFinished: @escaping @MainActor @Sendable (Bool) async -> Void + ) throws { guard let service = NSSharingService(named: .sendViaAirDrop), service.canPerform(withItems: [fileURL]) else { throw ShotdeckError.airDropUnavailable @@ -21,7 +28,12 @@ enum Sharing { window.makeKeyAndOrderFront(nil) service.subject = fileURL.lastPathComponent - let session = AirDropSession(service: service, window: window, view: view) + let session = AirDropSession( + service: service, + window: window, + view: view, + onFinished: onFinished + ) AirDropSession.keepAlive(session) service.delegate = session service.perform(withItems: [fileURL]) @@ -29,7 +41,9 @@ enum Sharing { } /// Retains the sharing service for the life of the picker and supplies the real -/// on-screen window as the sheet parent. `NSSharingService.delegate` is weak. +/// on-screen window as the sheet parent. `NSSharingService.delegate` is weak, so +/// `live` is the strong reference that keeps this object alive until the sheet +/// reports success or failure (including cancel). @MainActor private final class AirDropSession: NSObject, NSSharingServiceDelegate { static var live: [AirDropSession] = [] @@ -37,11 +51,19 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate { let service: NSSharingService let window: NSWindow let view: NSView + let onFinished: @MainActor @Sendable (Bool) async -> Void + private var reported = false - init(service: NSSharingService, window: NSWindow, view: NSView) { + init( + service: NSSharingService, + window: NSWindow, + view: NSView, + onFinished: @escaping @MainActor @Sendable (Bool) async -> Void + ) { self.service = service self.window = window self.view = view + self.onFinished = onFinished } static func keepAlive(_ session: AirDropSession) { @@ -52,6 +74,15 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate { Self.live.removeAll { $0 === self } } + private func report(_ success: Bool) { + guard !reported else { return } + reported = true + Task { @MainActor in + await self.onFinished(success) + self.drop() + } + } + func sharingService( _ sharingService: NSSharingService, sourceWindowForShareItems items: [Any], @@ -70,7 +101,7 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate { } func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) { - drop() + report(true) } func sharingService( @@ -78,6 +109,6 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate { didFailToShareItems items: [Any], error: any Error ) { - drop() + report(false) } } diff --git a/Sources/ShotdeckCore/PDF/PDFComposer.swift b/Sources/ShotdeckCore/PDF/PDFComposer.swift index 0a9729c..5fbfbc8 100644 --- a/Sources/ShotdeckCore/PDF/PDFComposer.swift +++ b/Sources/ShotdeckCore/PDF/PDFComposer.swift @@ -70,8 +70,8 @@ public struct PDFComposer: Sendable { } } - public static func fileName(for session: CaptureSession) -> String { - "Redline-\(DubaiTime.fileStamp(session.createdAt)).pdf" + public static func fileName(for _: CaptureSession) -> String { + "Redline-\(DubaiTime.fileStamp(Date())).pdf" } private static func writePDF( diff --git a/Tests/ShotdeckCoreTests/PDFComposerTests.swift b/Tests/ShotdeckCoreTests/PDFComposerTests.swift index ef7d3d7..0b3987c 100644 --- a/Tests/ShotdeckCoreTests/PDFComposerTests.swift +++ b/Tests/ShotdeckCoreTests/PDFComposerTests.swift @@ -203,6 +203,17 @@ private func pixelWindow( return (pixelX0, pixelY0, pixelX1, pixelY1) } +@Test("fileName uses compose time, not session.createdAt, and matches Redline-yyyyMMdd-HHmmss.pdf") +func fileNameUsesComposeTimeNotSessionCreatedAt() { + let old = Date(timeIntervalSince1970: 1_600_000_000) // 2020-09-13 + let session = makeSession(captures: [], createdAt: old) + let name = PDFComposer.fileName(for: session) + #expect(name.wholeMatch(of: /^Redline-\d{8}-\d{6}\.pdf$/) != nil) + #expect(!name.contains(DubaiTime.fileStamp(old))) + let today = String(DubaiTime.fileStamp(Date()).prefix(8)) + #expect(name.contains(today)) +} + @Test("Three-page basic compose") func threePageBasicCompose() throws { let directory = try makeScratchDirectory()