Redline v0.2.0 — complete app (integration branch → main) #22
@@ -0,0 +1,71 @@
|
|||||||
|
import AppKit
|
||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
import ShotdeckCore
|
||||||
|
|
||||||
|
extension AppModel: SendCapable {
|
||||||
|
public func send(anchor: NSView?) async {
|
||||||
|
guard !session.isEmpty, !isSending else { return }
|
||||||
|
setSending(true)
|
||||||
|
defer { setSending(false) }
|
||||||
|
|
||||||
|
let workingSession = session
|
||||||
|
let composer = self.composer
|
||||||
|
// Live outbox (FolderSettings), not `paths.outbox` — Settings changes take effect.
|
||||||
|
let outboxDir = outboxURL
|
||||||
|
let sourceDir = paths.sessionDirectory(workingSession.id)
|
||||||
|
let fileName = PDFComposer.fileName(for: workingSession)
|
||||||
|
let finalURL = outboxDir.appendingPathComponent(fileName)
|
||||||
|
// Same directory as the final target so the rename below is same-volume (atomic).
|
||||||
|
let tempURL = outboxDir.appendingPathComponent(".shotdeck-\(UUID().uuidString).pdf")
|
||||||
|
let title = "Shotdeck – \(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
|
||||||
|
)
|
||||||
|
// 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 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)."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} 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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import AppKit
|
||||||
|
import ShotdeckCore
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum Sharing {
|
||||||
|
/// Presents the AirDrop picker for `fileURL`, anchored to `view`.
|
||||||
|
/// 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 {
|
||||||
|
guard let service = NSSharingService(named: .sendViaAirDrop),
|
||||||
|
service.canPerform(withItems: [fileURL]) else {
|
||||||
|
throw ShotdeckError.airDropUnavailable
|
||||||
|
}
|
||||||
|
// Presenting from a detached NSView (no window) yields a sheet that never appears.
|
||||||
|
guard let window = view.window, window.isVisible else {
|
||||||
|
throw ShotdeckError.airDropUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
NSApp.activate()
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
|
||||||
|
service.subject = fileURL.lastPathComponent
|
||||||
|
let session = AirDropSession(service: service, window: window, view: view)
|
||||||
|
AirDropSession.keepAlive(session)
|
||||||
|
service.delegate = session
|
||||||
|
service.perform(withItems: [fileURL])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
@MainActor
|
||||||
|
private final class AirDropSession: NSObject, NSSharingServiceDelegate {
|
||||||
|
static var live: [AirDropSession] = []
|
||||||
|
|
||||||
|
let service: NSSharingService
|
||||||
|
let window: NSWindow
|
||||||
|
let view: NSView
|
||||||
|
|
||||||
|
init(service: NSSharingService, window: NSWindow, view: NSView) {
|
||||||
|
self.service = service
|
||||||
|
self.window = window
|
||||||
|
self.view = view
|
||||||
|
}
|
||||||
|
|
||||||
|
static func keepAlive(_ session: AirDropSession) {
|
||||||
|
live.append(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drop() {
|
||||||
|
Self.live.removeAll { $0 === self }
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharingService(
|
||||||
|
_ sharingService: NSSharingService,
|
||||||
|
sourceWindowForShareItems items: [Any],
|
||||||
|
sharingContentScope: UnsafeMutablePointer<NSSharingService.SharingContentScope>
|
||||||
|
) -> NSWindow? {
|
||||||
|
sharingContentScope.pointee = .item
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharingService(
|
||||||
|
_ sharingService: NSSharingService,
|
||||||
|
sourceFrameOnScreenForShareItem item: Any
|
||||||
|
) -> NSRect {
|
||||||
|
let inWindow = view.convert(view.bounds, to: nil)
|
||||||
|
return window.convertToScreen(inWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) {
|
||||||
|
drop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharingService(
|
||||||
|
_ sharingService: NSSharingService,
|
||||||
|
didFailToShareItems items: [Any],
|
||||||
|
error: any Error
|
||||||
|
) {
|
||||||
|
drop()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user