Files
shotdeck/Sources/Shotdeck/Sharing.swift
T

115 lines
3.6 KiB
Swift

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).
///
/// `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
}
// 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,
onFinished: onFinished
)
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, 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] = []
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,
onFinished: @escaping @MainActor @Sendable (Bool) async -> Void
) {
self.service = service
self.window = window
self.view = view
self.onFinished = onFinished
}
static func keepAlive(_ session: AirDropSession) {
live.append(session)
}
private func drop() {
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],
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]) {
report(true)
}
func sharingService(
_ sharingService: NSSharingService,
didFailToShareItems items: [Any],
error: any Error
) {
report(false)
}
}