Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9201e74bfc | ||
|
|
9989333c24 |
@@ -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