Claude d3c6f262ad
feat(media): Apple Photos helper and client-pushed desktop sync
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
2026-10-03 17:56:14 +00:00

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.")
}
}
}