Adds the macOS photos-helper Swift CLI (PhotoKit listing with stable cursors, persistent change tokens, iCloud-aware original export with a path guard and SHA-256) plus XCTests for its PhotoKit-free core, and the Electron main-process side: a validated helper client, a resumable metadata push and chunked original upload against the media contract, and IPC handlers that stay unavailable off macOS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
149 lines
6.2 KiB
Swift
149 lines
6.2 KiB
Swift
import AVFoundation
|
|
import AppKit
|
|
import Foundation
|
|
import Photos
|
|
import PhotosHelperCore
|
|
|
|
/// Writes one asset into the destination directory: first to a hidden temporary file,
|
|
/// then renamed into place after hashing, so a cancelled export never leaves a file
|
|
/// that looks complete.
|
|
struct Exporter {
|
|
func export(
|
|
localIdentifier: String,
|
|
directory: String,
|
|
variant: ExportVariant,
|
|
progress: @escaping (Double) -> Void
|
|
) async throws -> ExportOutput {
|
|
guard
|
|
let asset = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil)
|
|
.firstObject
|
|
else { throw HelperError(.notFound, "No photo or video with that identifier.") }
|
|
guard let resource = originalResource(for: asset, variant: variant) else {
|
|
throw HelperError(.notFound, "The asset has no exportable resource.")
|
|
}
|
|
var fileName = resource.originalFilename
|
|
if variant == .proxy {
|
|
let stem = (fileName as NSString).deletingPathExtension
|
|
fileName = stem + (asset.mediaType == .video ? ".mp4" : ".jpg")
|
|
}
|
|
let destination = try resolveExportDestination(directory: directory, fileName: fileName)
|
|
let fileManager = FileManager.default
|
|
do {
|
|
switch (variant, asset.mediaType) {
|
|
case (.original, _):
|
|
try await writeResource(resource, to: destination.temporaryURL, progress: progress)
|
|
case (.proxy, .video):
|
|
try await writeVideoProxy(asset, to: destination.temporaryURL, progress: progress)
|
|
case (.proxy, _):
|
|
try await writeImageProxy(asset, to: destination.temporaryURL, progress: progress)
|
|
}
|
|
let digest = try sha256Hex(of: destination.temporaryURL)
|
|
// moveItem refuses an existing destination, so nothing is ever overwritten.
|
|
try fileManager.moveItem(at: destination.temporaryURL, to: destination.finalURL)
|
|
progress(1)
|
|
return ExportOutput(
|
|
path: destination.finalURL.path, fileName: destination.fileName,
|
|
sizeBytes: digest.sizeBytes, sha256: digest.hex)
|
|
} catch {
|
|
try? fileManager.removeItem(at: destination.temporaryURL)
|
|
if let helperError = error as? HelperError { throw helperError }
|
|
throw mapExportError(error)
|
|
}
|
|
}
|
|
|
|
/// Original bytes through PHAssetResourceManager; downloads from iCloud when the
|
|
/// library keeps only optimized copies on this Mac.
|
|
private func writeResource(
|
|
_ resource: PHAssetResource, to url: URL, progress: @escaping (Double) -> Void
|
|
) async throws {
|
|
let options = PHAssetResourceRequestOptions()
|
|
options.isNetworkAccessAllowed = true
|
|
options.progressHandler = { value in progress(value) }
|
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
PHAssetResourceManager.default().writeData(for: resource, toFile: url, options: options) {
|
|
error in
|
|
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 720p H.264 MP4 of the current version, without source metadata.
|
|
private func writeVideoProxy(
|
|
_ asset: PHAsset, to url: URL, progress: @escaping (Double) -> Void
|
|
) async throws {
|
|
let options = PHVideoRequestOptions()
|
|
options.isNetworkAccessAllowed = true
|
|
options.deliveryMode = .highQualityFormat
|
|
options.version = .current
|
|
options.progressHandler = { value, _, _, _ in progress(value * 0.5) }
|
|
let session: AVAssetExportSession = try await withCheckedThrowingContinuation { continuation in
|
|
PHImageManager.default().requestExportSession(
|
|
forVideo: asset, options: options, exportPreset: AVAssetExportPreset1280x720
|
|
) { session, info in
|
|
if let session {
|
|
continuation.resume(returning: session)
|
|
} else {
|
|
continuation.resume(
|
|
throwing: (info?[PHImageErrorKey] as? Error)
|
|
?? HelperError(.exportFailed, "Could not prepare the video export."))
|
|
}
|
|
}
|
|
}
|
|
session.outputURL = url
|
|
session.outputFileType = .mp4
|
|
session.shouldOptimizeForNetworkUse = true
|
|
session.metadata = []
|
|
session.metadataItemFilter = .forSharing()
|
|
await session.export()
|
|
guard session.status == .completed else {
|
|
throw session.error ?? HelperError(.exportFailed, "The video export did not finish.")
|
|
}
|
|
}
|
|
|
|
/// JPEG of the current version, at most 1280 px on the long edge, without EXIF/GPS.
|
|
private func writeImageProxy(
|
|
_ asset: PHAsset, to url: URL, progress: @escaping (Double) -> Void
|
|
) async throws {
|
|
let options = PHImageRequestOptions()
|
|
options.isNetworkAccessAllowed = true
|
|
options.deliveryMode = .highQualityFormat
|
|
options.resizeMode = .exact
|
|
options.version = .current
|
|
options.isSynchronous = false
|
|
options.progressHandler = { value, _, _, _ in progress(value) }
|
|
// highQualityFormat calls the handler exactly once.
|
|
let image: NSImage = try await withCheckedThrowingContinuation { continuation in
|
|
PHImageManager.default().requestImage(
|
|
for: asset, targetSize: CGSize(width: 1280, height: 1280), contentMode: .aspectFit,
|
|
options: options
|
|
) { image, info in
|
|
if let image {
|
|
continuation.resume(returning: image)
|
|
} else {
|
|
continuation.resume(
|
|
throwing: (info?[PHImageErrorKey] as? Error)
|
|
?? HelperError(.exportFailed, "Could not render the photo."))
|
|
}
|
|
}
|
|
}
|
|
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil),
|
|
let data = NSBitmapImageRep(cgImage: cgImage).representation(
|
|
using: .jpeg, properties: [.compressionFactor: 0.85])
|
|
else { throw HelperError(.exportFailed, "Could not encode the photo.") }
|
|
try data.write(to: url, options: .withoutOverwriting)
|
|
}
|
|
}
|
|
|
|
/// iCloud and network failures are retryable; everything else is an export failure.
|
|
func mapExportError(_ error: Error) -> HelperError {
|
|
if let photosError = error as? PHPhotosError,
|
|
photosError.code == .networkAccessRequired || photosError.code == .networkError
|
|
{
|
|
return HelperError(.network, "The original could not be downloaded from iCloud.")
|
|
}
|
|
if (error as NSError).domain == NSURLErrorDomain {
|
|
return HelperError(.network, "The original could not be downloaded from iCloud.")
|
|
}
|
|
return HelperError(.exportFailed, "The export failed.")
|
|
}
|