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
104 lines
3.7 KiB
Swift
104 lines
3.7 KiB
Swift
import Foundation
|
|
import Photos
|
|
import PhotosHelperCore
|
|
|
|
/// Entry point. Exactly one JSON document goes to stdout on success; failures write
|
|
/// `{"error":{code,message}}` to stderr and exit non-zero (see HelperError.exitCode).
|
|
@main
|
|
struct PhotosHelper {
|
|
static func main() async {
|
|
do {
|
|
let command = try parseArguments(Array(CommandLine.arguments.dropFirst()))
|
|
let output = try await run(command)
|
|
FileHandle.standardOutput.write(Data((output + "\n").utf8))
|
|
exit(0)
|
|
} catch let error as HelperError {
|
|
fail(error)
|
|
} catch {
|
|
fail(HelperError(.internal, "Unexpected failure."))
|
|
}
|
|
}
|
|
|
|
static func fail(_ error: HelperError) -> Never {
|
|
FileHandle.standardError.write(Data((errorLine(error) + "\n").utf8))
|
|
exit(error.exitCode)
|
|
}
|
|
|
|
static func run(_ command: Command) async throws -> String {
|
|
switch command {
|
|
case .authorize(let prompt):
|
|
let status = await authorize(prompt: prompt)
|
|
return try encodeJSON(AuthorizationOutput(status: status))
|
|
case .list(let cursor, let limit, let since):
|
|
try requireAccess()
|
|
return try encodeJSON(try Library().list(after: cursor, limit: limit, since: since))
|
|
case .asset(let localIdentifier):
|
|
try requireAccess()
|
|
return try encodeJSON(try Library().asset(localIdentifier))
|
|
case .export(let localIdentifier, let destination, let variant):
|
|
try requireAccess()
|
|
let progress = ProgressReporter()
|
|
// Local copies report no progress, so a heartbeat repeats the last value; the app
|
|
// treats a silent helper as stalled.
|
|
let heartbeat = Task {
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(nanoseconds: 15_000_000_000)
|
|
if !Task.isCancelled { progress.emit(nil) }
|
|
}
|
|
}
|
|
defer { heartbeat.cancel() }
|
|
progress.emit(0)
|
|
let result = try await Exporter().export(
|
|
localIdentifier: localIdentifier, directory: destination, variant: variant,
|
|
progress: { fraction in progress.emit(fraction) })
|
|
return try encodeJSON(result)
|
|
case .changes(let token):
|
|
try requireAccess()
|
|
return try encodeJSON(try Library().changes(since: token))
|
|
}
|
|
}
|
|
|
|
/// `.readWrite` is the lowest PhotoKit level that can read assets: `.addOnly` cannot
|
|
/// enumerate or export anything. The helper never writes to the library.
|
|
static func authorize(prompt: Bool) async -> AuthorizationState {
|
|
var status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
|
if prompt && status == .notDetermined {
|
|
status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
|
|
}
|
|
return state(status)
|
|
}
|
|
|
|
static func state(_ status: PHAuthorizationStatus) -> AuthorizationState {
|
|
switch status {
|
|
case .authorized: return .authorized
|
|
case .limited: return .limited
|
|
case .denied: return .denied
|
|
case .restricted: return .restricted
|
|
case .notDetermined: return .notDetermined
|
|
@unknown default: return .denied
|
|
}
|
|
}
|
|
|
|
/// Serializes progress lines written from PhotoKit callback queues and the heartbeat.
|
|
final class ProgressReporter: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var last = 0.0
|
|
|
|
/// nil repeats the last reported value.
|
|
func emit(_ fraction: Double?) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
if let fraction { last = max(last, fraction) }
|
|
FileHandle.standardError.write(Data((progressLine(last) + "\n").utf8))
|
|
}
|
|
}
|
|
|
|
/// Every command except `authorize` runs only with granted access and never prompts.
|
|
static func requireAccess() throws {
|
|
switch state(PHPhotoLibrary.authorizationStatus(for: .readWrite)) {
|
|
case .authorized, .limited: return
|
|
default: throw HelperError(.notAuthorized, "Photos access has not been granted.")
|
|
}
|
|
}
|
|
}
|