fix: archive only after AirDrop completes; send-time PDF names; Reveal last PDF #21

Merged
kua-agent merged 2 commits from fix/send-truth-20260902 into feat/shotdeck-20260830 2026-09-02 05:18:11 +00:00
7 changed files with 331 additions and 55 deletions
+38
View File
@@ -36,6 +36,8 @@ public final class AppModel {
public private(set) var outboxURL: URL public private(set) var outboxURL: URL
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`. /// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
public private(set) var watchFolderURL: URL 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). /// Currently bound capture combo (the last one Carbon accepted, or the preferred load).
private(set) var captureHotkey: HotkeyPreference private(set) var captureHotkey: HotkeyPreference
var hotkeyDisplayString: String { captureHotkey.displayString } var hotkeyDisplayString: String { captureHotkey.displayString }
@@ -116,6 +118,42 @@ public final class AppModel {
watchFolderURL = watch watchFolderURL = watch
setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent) 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 { public var iconState: MenuIconState {
if !screenRecordingGranted { return .recordingMissing } if !screenRecordingGranted { return .recordingMissing }
+7
View File
@@ -95,6 +95,13 @@ struct MenuBarView: View {
} }
.disabled(model.session.isEmpty || model.isSending) .disabled(model.session.isEmpty || model.isSending)
Button {
model.revealLastPDF()
} label: {
actionLabel("Reveal last PDF")
}
.disabled(!model.canRevealLastPDF)
Button { Button {
Task { await model.captureNow() } Task { await model.captureNow() }
} label: { } label: {
+145 -5
View File
@@ -1,6 +1,8 @@
import AppKit import AppKit
import CoreGraphics
import Darwin import Darwin
import Foundation import Foundation
import ImageIO
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`.
@@ -113,10 +115,10 @@ enum PickerSelfTest {
fflush(stdout) fflush(stdout)
runRegionPersistPhase() runRegionPersistPhase()
if !startUpdateSelfTestIfRequested() { // Hop off this MainActor job so the SEND-TRUTH Task can run; do not
exit(0) // exit(0) here runSendTruthPhase prints its own PASS/FAIL, then
} // chains to UPDATE-SELFTEST (or exits if that phase is not requested).
// UPDATE-SELFTEST hops to a later main-actor turn and exits itself. runSendTruthPhase()
} }
/// Phase 2: writes a known region under `CaptureRegion.defaultsKey`, reloads it through /// Phase 2: writes a known region under `CaptureRegion.defaultsKey`, reloads it through
@@ -160,7 +162,145 @@ enum PickerSelfTest {
fflush(stdout) 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`. /// `checkNow`, then `installStaged` into the env dir never `/Applications`.
/// Returns true when the async phase was scheduled (it calls `exit` itself). /// Returns true when the async phase was scheduled (it calls `exit` itself).
@discardableResult @discardableResult
+91 -42
View File
@@ -3,12 +3,59 @@ import Darwin
import Foundation import Foundation
import ShotdeckCore 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 { extension AppModel: SendCapable {
public func send(anchor: NSView?) async { public func send(anchor: NSView?) async {
guard !session.isEmpty, !isSending else { return } guard !session.isEmpty, !isSending else { return }
setSending(true) 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 workingSession = session
let composer = self.composer let composer = self.composer
// Live outbox (FolderSettings), not `paths.outbox` Settings changes take effect. // 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 tempURL = outboxDir.appendingPathComponent(".shotdeck-\(UUID().uuidString).pdf")
let title = "Redline \(DubaiTime.stamp(workingSession.createdAt))" let title = "Redline \(DubaiTime.stamp(workingSession.createdAt))"
do { // D-13: build off the main actor. Only Sendable values cross into the
// D-13: build off the main actor. Only Sendable values cross into the // detached task never `anchor` (NSView is not Sendable).
// detached task never `anchor` (NSView is not Sendable). try await Task.detached(priority: .userInitiated) {
try await Task.detached(priority: .userInitiated) { _ = try composer.compose(
_ = try composer.compose( session: workingSession,
session: workingSession, imageURL: { capture in sourceDir.appendingPathComponent(capture.fileName) },
imageURL: { capture in sourceDir.appendingPathComponent(capture.fileName) }, title: title,
title: title, to: tempURL
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) _ = try await spool.archiveCurrent(pdfFileName: fileName)
replaceSession(try await spool.currentSession()) replaceSession(try await spool.currentSession())
let pageWord = pageCount == 1 ? "page" : "pages"
let pageWord = workingSession.captures.count == 1 ? "page" : "pages" setStatus("Sent — \(pageCount) \(pageWord).")
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)."
)
}
} catch { } catch {
// Never unlink the published PDF, and never unlink `tempURL` either: setStatus((error as? ShotdeckError)?.errorDescription ?? "Could not archive the session.")
// a rename failure would leave the complete document at the temp name.
setStatus((error as? ShotdeckError)?.errorDescription ?? "The PDF could not be built.")
} }
} }
/// `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)."
)
}
} }
+37 -6
View File
@@ -7,7 +7,14 @@ enum Sharing {
/// Throws `ShotdeckError.airDropUnavailable` when the service cannot be created, /// Throws `ShotdeckError.airDropUnavailable` when the service cannot be created,
/// `canPerform` is false, or `view` is not in a visible window (a detached view /// `canPerform` is false, or `view` is not in a visible window (a detached view
/// never produces an on-screen sheet). /// 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), guard let service = NSSharingService(named: .sendViaAirDrop),
service.canPerform(withItems: [fileURL]) else { service.canPerform(withItems: [fileURL]) else {
throw ShotdeckError.airDropUnavailable throw ShotdeckError.airDropUnavailable
@@ -21,7 +28,12 @@ enum Sharing {
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
service.subject = fileURL.lastPathComponent 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) AirDropSession.keepAlive(session)
service.delegate = session service.delegate = session
service.perform(withItems: [fileURL]) service.perform(withItems: [fileURL])
@@ -29,7 +41,9 @@ enum Sharing {
} }
/// Retains the sharing service for the life of the picker and supplies the real /// 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 @MainActor
private final class AirDropSession: NSObject, NSSharingServiceDelegate { private final class AirDropSession: NSObject, NSSharingServiceDelegate {
static var live: [AirDropSession] = [] static var live: [AirDropSession] = []
@@ -37,11 +51,19 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
let service: NSSharingService let service: NSSharingService
let window: NSWindow let window: NSWindow
let view: NSView 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.service = service
self.window = window self.window = window
self.view = view self.view = view
self.onFinished = onFinished
} }
static func keepAlive(_ session: AirDropSession) { static func keepAlive(_ session: AirDropSession) {
@@ -52,6 +74,15 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
Self.live.removeAll { $0 === self } 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( func sharingService(
_ sharingService: NSSharingService, _ sharingService: NSSharingService,
sourceWindowForShareItems items: [Any], sourceWindowForShareItems items: [Any],
@@ -70,7 +101,7 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
} }
func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) { func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) {
drop() report(true)
} }
func sharingService( func sharingService(
@@ -78,6 +109,6 @@ private final class AirDropSession: NSObject, NSSharingServiceDelegate {
didFailToShareItems items: [Any], didFailToShareItems items: [Any],
error: any Error error: any Error
) { ) {
drop() report(false)
} }
} }
+2 -2
View File
@@ -70,8 +70,8 @@ public struct PDFComposer: Sendable {
} }
} }
public static func fileName(for session: CaptureSession) -> String { public static func fileName(for _: CaptureSession) -> String {
"Redline-\(DubaiTime.fileStamp(session.createdAt)).pdf" "Redline-\(DubaiTime.fileStamp(Date())).pdf"
} }
private static func writePDF( private static func writePDF(
@@ -203,6 +203,17 @@ private func pixelWindow(
return (pixelX0, pixelY0, pixelX1, pixelY1) 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") @Test("Three-page basic compose")
func threePageBasicCompose() throws { func threePageBasicCompose() throws {
let directory = try makeScratchDirectory() let directory = try makeScratchDirectory()