Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c54471ee2e | ||
|
|
79fa9ee1da | ||
|
|
672f8d495f | ||
|
|
9201e74bfc |
@@ -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()
|
||||
}
|
||||
}
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
APP_BUNDLE="${ROOT}/.build/Shotdeck.app"
|
||||
STAGING="${ROOT}/.build/dmg-staging"
|
||||
DMG="${ROOT}/.build/Shotdeck.dmg"
|
||||
MOUNT_POINT="${ROOT}/.build/dmg-mnt"
|
||||
|
||||
SKIP_SIGN=0
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--skip-sign)
|
||||
SKIP_SIGN=1
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: ${arg}" >&2
|
||||
echo "Usage: $0 [--skip-sign]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "==> Building Shotdeck.app"
|
||||
if [[ "${SKIP_SIGN}" -eq 1 ]]; then
|
||||
./scripts/build-app.sh --skip-sign
|
||||
else
|
||||
./scripts/build-app.sh
|
||||
fi
|
||||
|
||||
if [[ ! -d "${APP_BUNDLE}" ]]; then
|
||||
echo "App bundle not found at ${APP_BUNDLE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Staging DMG contents"
|
||||
rm -rf "${STAGING}"
|
||||
mkdir -p "${STAGING}"
|
||||
ditto "${APP_BUNDLE}" "${STAGING}/Shotdeck.app"
|
||||
ln -s /Applications "${STAGING}/Applications"
|
||||
|
||||
echo "==> Creating ${DMG}"
|
||||
mkdir -p "$(dirname "${DMG}")"
|
||||
hdiutil create -volname "Shotdeck" -srcfolder "${STAGING}" -ov -format UDZO "${DMG}"
|
||||
|
||||
MOUNTED=0
|
||||
detach_dmg() {
|
||||
if [[ "${MOUNTED}" -eq 1 ]]; then
|
||||
hdiutil detach "${MOUNT_POINT}" || hdiutil detach "${MOUNT_POINT}" -force || true
|
||||
MOUNTED=0
|
||||
fi
|
||||
}
|
||||
trap detach_dmg EXIT
|
||||
|
||||
if [[ -d "${MOUNT_POINT}" ]] && /sbin/mount | grep -F -q "${MOUNT_POINT}"; then
|
||||
hdiutil detach "${MOUNT_POINT}" || hdiutil detach "${MOUNT_POINT}" -force
|
||||
fi
|
||||
rm -rf "${MOUNT_POINT}"
|
||||
mkdir -p "${MOUNT_POINT}"
|
||||
|
||||
echo "==> Verifying ${DMG}"
|
||||
hdiutil attach "${DMG}" -nobrowse -readonly -mountpoint "${MOUNT_POINT}"
|
||||
MOUNTED=1
|
||||
|
||||
echo "==> Mount contents"
|
||||
ls -la "${MOUNT_POINT}"
|
||||
|
||||
if [[ ! -d "${MOUNT_POINT}/Shotdeck.app" ]]; then
|
||||
echo "Verification failed: Shotdeck.app missing from mounted DMG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -L "${MOUNT_POINT}/Applications" ]]; then
|
||||
echo "Verification failed: Applications symlink missing from mounted DMG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(readlink "${MOUNT_POINT}/Applications")" != "/Applications" ]]; then
|
||||
echo "Verification failed: Applications does not point at /Applications" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> codesign --verify --deep"
|
||||
codesign --verify --deep --verbose=2 "${MOUNT_POINT}/Shotdeck.app"
|
||||
|
||||
echo "==> Detaching ${MOUNT_POINT}"
|
||||
hdiutil detach "${MOUNT_POINT}"
|
||||
MOUNTED=0
|
||||
trap - EXIT
|
||||
|
||||
SHA256="$(shasum -a 256 "${DMG}" | awk '{print $1}')"
|
||||
|
||||
echo
|
||||
echo "DMG path: ${DMG}"
|
||||
echo "SHA256: ${SHA256}"
|
||||
Reference in New Issue
Block a user