merge: W1-desktop Apple Photos helper and sync

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
This commit is contained in:
Claude 2026-10-03 17:57:20 +00:00
commit 51445e9442
No known key found for this signature in database
22 changed files with 4022 additions and 0 deletions

View File

@ -0,0 +1,2 @@
.build/
.swiftpm/

View File

@ -0,0 +1,18 @@
// swift-tools-version:5.9
import PackageDescription
// PhotosHelperCore holds everything that does not touch PhotoKit (argument parsing,
// cursors, JSON envelopes, the export path guard, hashing) so XCTest runs without
// Photos access. The executable target is the thin PhotoKit layer.
let package = Package(
name: "photos-helper",
platforms: [.macOS(.v13)],
products: [
.executable(name: "photos-helper", targets: ["photos-helper"])
],
targets: [
.target(name: "PhotosHelperCore"),
.executableTarget(name: "photos-helper", dependencies: ["PhotosHelperCore"]),
.testTarget(name: "PhotosHelperCoreTests", dependencies: ["PhotosHelperCore"]),
]
)

View File

@ -0,0 +1,142 @@
import Foundation
public enum ExportVariant: String, Equatable {
/// The untouched original resource, downloaded from iCloud when needed.
case original
/// A smaller rendition of the current version (720p video, 1280 px JPEG).
case proxy
}
public enum Command: Equatable {
/// `prompt: false` only reads the current status and never shows the system dialog.
case authorize(prompt: Bool)
case list(cursor: ListCursor?, limit: Int, since: Date?)
case asset(localIdentifier: String)
case export(localIdentifier: String, destination: String, variant: ExportVariant)
case changes(token: String)
}
public let usageText = """
usage: photos-helper authorize [--no-prompt]
photos-helper list [--cursor <opaque>] [--limit <1-1000>] [--since <iso8601>]
photos-helper asset <localIdentifier>
photos-helper export <localIdentifier> --dest <dir> [--original|--proxy]
photos-helper changes --token <opaque>
"""
/// Parses argv without the program name. Unknown flags, repeated flags and missing
/// values are usage errors so a typo never silently changes behaviour.
public func parseArguments(_ arguments: [String]) throws -> Command {
guard let name = arguments.first else { throw HelperError(.usage, usageText) }
var options = try OptionReader(Array(arguments.dropFirst()))
switch name {
case "authorize":
let noPrompt = options.flag("--no-prompt")
try options.finish(positionals: 0)
return .authorize(prompt: !noPrompt)
case "list":
let cursor = try options.value("--cursor").map(ListCursor.decode)
let limit = try options.value("--limit").map(parseLimit) ?? Limits.defaultPage
let since = try options.value("--since").map { value -> Date in
guard let date = parseTimestamp(value) else {
throw HelperError(.usage, "--since must be an ISO 8601 timestamp.")
}
return date
}
try options.finish(positionals: 0)
return .list(cursor: cursor, limit: limit, since: since)
case "asset":
try options.finish(positionals: 1)
let identifier = try validIdentifier(options.positionals[0])
return .asset(localIdentifier: identifier)
case "export":
guard let destination = options.value("--dest") else {
throw HelperError(.usage, "export needs --dest <dir>.")
}
let original = options.flag("--original")
let proxy = options.flag("--proxy")
if original && proxy { throw HelperError(.usage, "Choose --original or --proxy, not both.") }
try options.finish(positionals: 1)
let identifier = try validIdentifier(options.positionals[0])
return .export(
localIdentifier: identifier,
destination: destination,
variant: proxy ? .proxy : .original)
case "changes":
guard let token = options.value("--token"), !token.isEmpty else {
throw HelperError(.usage, "changes needs --token <opaque>.")
}
try options.finish(positionals: 0)
return .changes(token: token)
default:
throw HelperError(.usage, usageText)
}
}
func parseLimit(_ value: String) throws -> Int {
guard let limit = Int(value), (1...Limits.maxPage).contains(limit) else {
throw HelperError(.usage, "--limit must be between 1 and \(Limits.maxPage).")
}
return limit
}
func validIdentifier(_ value: String) throws -> String {
guard !value.isEmpty, value.utf16.count <= Limits.externalId, !value.hasPrefix("-"),
!value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) })
else { throw HelperError(.usage, "Invalid local identifier.") }
return value
}
private struct OptionReader {
private var values: [String: String] = [:]
private var flags: Set<String> = []
private var consumed: Set<String> = []
private(set) var positionals: [String] = []
private static let valueOptions: Set<String> = ["--cursor", "--limit", "--since", "--dest", "--token"]
private static let flagOptions: Set<String> = ["--no-prompt", "--original", "--proxy"]
init(_ arguments: [String]) throws {
var index = 0
while index < arguments.count {
let argument = arguments[index]
if Self.valueOptions.contains(argument) {
guard index + 1 < arguments.count else {
throw HelperError(.usage, "\(argument) needs a value.")
}
guard values[argument] == nil else { throw HelperError(.usage, "\(argument) given twice.") }
values[argument] = arguments[index + 1]
index += 2
} else if Self.flagOptions.contains(argument) {
guard !flags.contains(argument) else { throw HelperError(.usage, "\(argument) given twice.") }
flags.insert(argument)
index += 1
} else if argument.hasPrefix("--") {
throw HelperError(.usage, "Unknown option \(argument).")
} else {
positionals.append(argument)
index += 1
}
}
}
mutating func value(_ name: String) -> String? {
consumed.insert(name)
return values[name]
}
mutating func flag(_ name: String) -> Bool {
consumed.insert(name)
return flags.contains(name)
}
/// Rejects options that belong to another command and surplus positionals.
func finish(positionals expected: Int) throws {
for name in Set(values.keys).union(flags) where !consumed.contains(name) {
throw HelperError(.usage, "\(name) is not valid here.")
}
guard positionals.count == expected else {
throw HelperError(.usage, expected == 0 ? "Unexpected argument." : "Expected \(expected) argument.")
}
}
}

View File

@ -0,0 +1,37 @@
import Foundation
/// A failure the desktop app maps by `code`. Messages never contain library content
/// beyond the identifier the caller passed in.
public struct HelperError: Error, Equatable {
public enum Code: String, Codable, CaseIterable {
case usage
case notAuthorized = "not_authorized"
case notFound = "not_found"
case pathRejected = "path_rejected"
case destinationExists = "destination_exists"
case network
case exportFailed = "export_failed"
case `internal`
}
public let code: Code
public let message: String
public init(_ code: Code, _ message: String) {
self.code = code
self.message = message
}
/// sysexits(3) style codes so a shell user can tell failure classes apart.
public var exitCode: Int32 {
switch code {
case .usage: return 64
case .notFound: return 66
case .pathRejected, .destinationExists: return 73
case .exportFailed: return 74
case .network: return 75
case .notAuthorized: return 77
case .internal: return 70
}
}
}

View File

@ -0,0 +1,249 @@
import Foundation
/// Mirrors `ClientMediaItemSchema` in packages/contracts/src/media.ts. Every key is
/// always present (nullable fields encode as `null`) because the schema has no optional keys.
public struct ClientMediaItem: Encodable, Equatable {
public enum Kind: String, Encodable {
case video
case photo
case livePhoto = "live_photo"
}
public var externalId: String
public var kind: Kind
public var capturedAt: String?
public var durationMs: Int?
public var width: Int?
public var height: Int?
public var fps: Double?
public var gpsLat: Double?
public var gpsLon: Double?
public var deviceModel: String?
public var albumNames: [String]
public var isFavorite: Bool
public var fileName: String?
public init(
externalId: String,
kind: Kind,
capturedAt: Date?,
durationSeconds: Double?,
width: Int?,
height: Int?,
latitude: Double?,
longitude: Double?,
albumNames: [String],
isFavorite: Bool,
fileName: String?
) {
self.externalId = externalId
self.kind = kind
self.capturedAt = capturedAt.map(formatTimestamp)
if let durationSeconds, durationSeconds.isFinite, durationSeconds > 0 {
durationMs = Int((durationSeconds * 1000).rounded())
} else {
durationMs = nil
}
self.width = width.flatMap { $0 > 0 ? $0 : nil }
self.height = height.flatMap { $0 > 0 ? $0 : nil }
// PhotoKit exposes neither frame rate nor camera model without loading the
// file; the server reads both with ffprobe after upload.
fps = nil
deviceModel = nil
let coordinate = roundedCoordinate(latitude: latitude, longitude: longitude)
gpsLat = coordinate?.latitude
gpsLon = coordinate?.longitude
self.albumNames = Array(
albumNames.map { clip($0, maxCharacters: Limits.albumName) }
.filter { !$0.isEmpty }
.prefix(Limits.albumCount))
self.isFavorite = isFavorite
self.fileName = fileName.map { clip($0, maxCharacters: Limits.fileName) }
}
enum CodingKeys: String, CodingKey {
case externalId, kind, capturedAt, durationMs, width, height, fps, gpsLat, gpsLon
case deviceModel, albumNames, isFavorite, fileName
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(externalId, forKey: .externalId)
try container.encode(kind, forKey: .kind)
// `encode` on an Optional writes `null`, unlike the synthesized `encodeIfPresent`.
try container.encode(capturedAt, forKey: .capturedAt)
try container.encode(durationMs, forKey: .durationMs)
try container.encode(width, forKey: .width)
try container.encode(height, forKey: .height)
try container.encode(fps, forKey: .fps)
try container.encode(gpsLat, forKey: .gpsLat)
try container.encode(gpsLon, forKey: .gpsLon)
try container.encode(deviceModel, forKey: .deviceModel)
try container.encode(albumNames, forKey: .albumNames)
try container.encode(isFavorite, forKey: .isFavorite)
try container.encode(fileName, forKey: .fileName)
}
}
/// Bounds from the contract schema, applied here so one odd asset cannot make a whole
/// page fail validation in the app.
public enum Limits {
public static let externalId = 200
public static let albumName = 200
public static let albumCount = 50
public static let fileName = 255
public static let defaultPage = 500
public static let maxPage = 1000
/// Above this many changed assets a full relisting is cheaper than one huge document.
public static let maxChanges = 5000
}
/// Clips to `maxCharacters` UTF-16 units (what zod's `.max()` counts) without splitting
/// a character.
public func clip(_ value: String, maxCharacters: Int) -> String {
if value.utf16.count <= maxCharacters { return value }
var result = ""
var units = 0
for character in value {
units += character.utf16.count
if units > maxCharacters { break }
result.append(character)
}
return result
}
/// Two decimals is about 1 km, matching `roundCoordinate` in @rakazo/media. Precise
/// locations never leave the device.
public func roundedCoordinate(latitude: Double?, longitude: Double?) -> (
latitude: Double, longitude: Double
)? {
guard let latitude, let longitude, latitude.isFinite, longitude.isFinite,
(-90...90).contains(latitude), (-180...180).contains(longitude),
!(latitude == 0 && longitude == 0)
else { return nil }
return ((latitude * 100).rounded() / 100, (longitude * 100).rounded() / 100)
}
/// RFC 3339 in UTC with milliseconds, accepted by zod's `datetime({ offset: true })`.
public func formatTimestamp(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
formatter.timeZone = TimeZone(identifier: "UTC")
return formatter.string(from: date)
}
public func parseTimestamp(_ value: String) -> Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: value) { return date }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: value)
}
public enum AuthorizationState: String, Encodable {
case authorized, limited, denied, restricted, notDetermined
}
public struct AuthorizationOutput: Encodable, Equatable {
public let status: AuthorizationState
public init(status: AuthorizationState) { self.status = status }
}
public struct ListOutput: Encodable {
public let items: [ClientMediaItem]
public let nextCursor: String?
/// Library change token captured before enumeration; `changes --token` resumes from it.
public let changeToken: String?
public init(items: [ClientMediaItem], nextCursor: String?, changeToken: String?) {
self.items = items
self.nextCursor = nextCursor
self.changeToken = changeToken
}
enum CodingKeys: String, CodingKey { case items, nextCursor, changeToken }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(items, forKey: .items)
try container.encode(nextCursor, forKey: .nextCursor)
try container.encode(changeToken, forKey: .changeToken)
}
}
public struct AssetResourceInfo: Encodable, Equatable {
public let type: String
public let fileName: String
public let uti: String
public init(type: String, fileName: String, uti: String) {
self.type = type
self.fileName = fileName
self.uti = uti
}
}
public struct AssetOutput: Encodable {
public let item: ClientMediaItem
public let modifiedAt: String?
public let hidden: Bool
public let resources: [AssetResourceInfo]
public init(item: ClientMediaItem, modifiedAt: Date?, hidden: Bool, resources: [AssetResourceInfo]) {
self.item = item
self.modifiedAt = modifiedAt.map(formatTimestamp)
self.hidden = hidden
self.resources = resources
}
enum CodingKeys: String, CodingKey { case item, modifiedAt, hidden, resources }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(item, forKey: .item)
try container.encode(modifiedAt, forKey: .modifiedAt)
try container.encode(hidden, forKey: .hidden)
try container.encode(resources, forKey: .resources)
}
}
public struct ChangesOutput: Encodable {
/// Token to store for the next call; null only when a full relisting is required.
public let token: String?
public let changed: [ClientMediaItem]
public let deleted: [String]
public let fullResyncRequired: Bool
public init(token: String?, changed: [ClientMediaItem], deleted: [String], fullResyncRequired: Bool) {
self.token = token
self.changed = changed
self.deleted = deleted
self.fullResyncRequired = fullResyncRequired
}
public static func fullResync() -> ChangesOutput {
ChangesOutput(token: nil, changed: [], deleted: [], fullResyncRequired: true)
}
enum CodingKeys: String, CodingKey { case token, changed, deleted, fullResyncRequired }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(token, forKey: .token)
try container.encode(changed, forKey: .changed)
try container.encode(deleted, forKey: .deleted)
try container.encode(fullResyncRequired, forKey: .fullResyncRequired)
}
}
public struct ExportOutput: Encodable, Equatable {
public let path: String
public let fileName: String
public let sizeBytes: Int64
public let sha256: String
public init(path: String, fileName: String, sizeBytes: Int64, sha256: String) {
self.path = path
self.fileName = fileName
self.sizeBytes = sizeBytes
self.sha256 = sha256
}
}

View File

@ -0,0 +1,56 @@
import CryptoKit
import Foundation
/// stdout carries exactly one JSON document per successful command; stderr carries
/// JSON lines (`{"progress":…}` while exporting, `{"error":…}` on failure).
public func encodeJSON<T: Encodable>(_ value: T) throws -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
encoder.nonConformingFloatEncodingStrategy = .throw
let data = try encoder.encode(value)
guard let text = String(data: data, encoding: .utf8) else {
throw HelperError(.internal, "Output was not UTF-8.")
}
return text
}
private struct ErrorEnvelope: Encodable {
struct Body: Encodable {
let code: String
let message: String
}
let error: Body
}
public func errorLine(_ error: HelperError) -> String {
let envelope = ErrorEnvelope(error: .init(code: error.code.rawValue, message: error.message))
return (try? encodeJSON(envelope))
?? #"{"error":{"code":"internal","message":"Could not encode the error."}}"#
}
private struct ProgressLine: Encodable {
let progress: Double
}
/// Progress clamped to 0…1 and rounded to three decimals so the line stays short.
public func progressLine(_ fraction: Double) -> String {
let value = fraction.isFinite ? min(1, max(0, fraction)) : 0
return (try? encodeJSON(ProgressLine(progress: (value * 1000).rounded() / 1000)))
?? #"{"progress":0}"#
}
/// Streams the file through SHA-256 in 1 MiB reads so large videos never load fully.
public func sha256Hex(of url: URL) throws -> (hex: String, sizeBytes: Int64) {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var hasher = SHA256()
var size: Int64 = 0
while true {
let chunk = try handle.read(upToCount: 1 << 20) ?? Data()
if chunk.isEmpty { break }
hasher.update(data: chunk)
size += Int64(chunk.count)
}
let hex = hasher.finalize().map { String(format: "%02x", $0) }.joined()
return (hex, size)
}

View File

@ -0,0 +1,131 @@
import Foundation
/// Listing order: undated assets first, then creation date ascending, ties broken by
/// local identifier. PhotoKit can only sort by date, so ties are ordered here.
public struct SortKey: Comparable, Hashable {
/// Milliseconds since 1970; nil for assets without a creation date.
public let creationMillis: Int64?
public let localIdentifier: String
public init(creationMillis: Int64?, localIdentifier: String) {
self.creationMillis = creationMillis
self.localIdentifier = localIdentifier
}
public init(creationDate: Date?, localIdentifier: String) {
self.init(
creationMillis: creationDate.map { Int64(($0.timeIntervalSince1970 * 1000).rounded(.down)) },
localIdentifier: localIdentifier)
}
public static func < (lhs: SortKey, rhs: SortKey) -> Bool {
if lhs.creationMillis != rhs.creationMillis {
switch (lhs.creationMillis, rhs.creationMillis) {
case (nil, _): return true
case (_, nil): return false
case let (left?, right?): return left < right
}
}
return lhs.localIdentifier < rhs.localIdentifier
}
}
/// Opaque to the app: base64url of `{"v":1,"d":<millis|null>,"id":"<localIdentifier>"}`.
/// The cursor names the last item returned, so listing resumes after it even when
/// assets were added or removed in between.
public struct ListCursor: Equatable {
public let key: SortKey
public init(key: SortKey) { self.key = key }
private struct Payload: Codable {
let v: Int
let d: Int64?
let id: String
enum CodingKeys: String, CodingKey { case v, d, id }
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(v, forKey: .v)
try container.encode(d, forKey: .d)
try container.encode(id, forKey: .id)
}
}
public func encoded() -> String {
let payload = Payload(v: 1, d: key.creationMillis, id: key.localIdentifier)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
// Encoding a struct of plain values cannot fail.
let data = (try? encoder.encode(payload)) ?? Data()
return base64URLEncode(data)
}
public static func decode(_ value: String) throws -> ListCursor {
guard value.count <= 2048, let data = base64URLDecode(value),
let payload = try? JSONDecoder().decode(Payload.self, from: data),
payload.v == 1, !payload.id.isEmpty
else { throw HelperError(.usage, "Invalid cursor.") }
return ListCursor(key: SortKey(creationMillis: payload.d, localIdentifier: payload.id))
}
}
public func base64URLEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
public func base64URLDecode(_ value: String) -> Data? {
guard value.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == "_") })
else { return nil }
var base64 = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
while base64.count % 4 != 0 { base64.append("=") }
return Data(base64Encoded: base64)
}
public struct Page {
/// Positions in the source sequence, in listing order.
public let indices: [Int]
public let hasMore: Bool
}
/// Selects the next page from a sequence sorted by creation date only (ties in any
/// order). Binary search finds the first candidate date; a whole same-date group is
/// collected before sorting so tie order never depends on PhotoKit.
public func nextPage(count: Int, key: (Int) -> SortKey, after cursor: ListCursor?, limit: Int)
-> Page
{
precondition(limit > 0)
var start = 0
if let cursor, let cursorMillis = cursor.key.creationMillis {
var low = 0
var high = count
while low < high {
let mid = (low + high) / 2
let millis = key(mid).creationMillis
if millis == nil || millis! < cursorMillis { low = mid + 1 } else { high = mid }
}
start = low
}
var candidates: [(index: Int, key: SortKey)] = []
var stoppedEarly = false
var index = start
while index < count {
let current = key(index)
if candidates.count >= limit, let last = candidates.last, current.creationMillis != last.key.creationMillis {
// Dates ascend, so everything from here on sorts after the collected group.
stoppedEarly = true
break
}
if cursor == nil || cursor!.key < current {
candidates.append((index, current))
}
index += 1
}
candidates.sort { $0.key < $1.key }
let hasMore = stoppedEarly || candidates.count > limit
return Page(indices: candidates.prefix(limit).map(\.index), hasMore: hasMore)
}

View File

@ -0,0 +1,72 @@
import Foundation
/// Where an export is written: a final path and a sibling temporary path inside the
/// destination directory.
public struct ExportDestination: Equatable {
public let directory: URL
public let finalURL: URL
public let temporaryURL: URL
public let fileName: String
}
/// Resolves the export target. The directory must be absolute, free of `..`, existing
/// and a directory after resolving symlinks (macOS temp dirs live behind /var). The file
/// name comes from Photos, so it is reduced to one safe component and the joined path is
/// checked to stay a direct child of the resolved directory. Existing files are never
/// overwritten.
public func resolveExportDestination(
directory: String,
fileName rawFileName: String,
temporarySuffix: String = UUID().uuidString,
fileManager: FileManager = .default
) throws -> ExportDestination {
guard directory.hasPrefix("/"), !directory.contains("\0") else {
throw HelperError(.pathRejected, "--dest must be an absolute directory path.")
}
let components = directory.split(separator: "/", omittingEmptySubsequences: true)
guard !components.contains(where: { $0 == ".." || $0 == "." }) else {
throw HelperError(.pathRejected, "--dest must not contain . or .. segments.")
}
let resolved = URL(fileURLWithPath: directory, isDirectory: true).resolvingSymlinksInPath()
.standardizedFileURL
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: resolved.path, isDirectory: &isDirectory), isDirectory.boolValue
else { throw HelperError(.pathRejected, "--dest must be an existing directory.") }
let fileName = sanitizeFileName(rawFileName)
let finalURL = resolved.appendingPathComponent(fileName, isDirectory: false).standardizedFileURL
let temporaryURL = resolved.appendingPathComponent(
".\(fileName).\(temporarySuffix).partial", isDirectory: false
).standardizedFileURL
for candidate in [finalURL, temporaryURL]
where candidate.deletingLastPathComponent().path != resolved.path {
throw HelperError(.pathRejected, "Export path left the destination directory.")
}
// lstat semantics: a dangling symlink at the final path also counts as existing.
if (try? fileManager.attributesOfItem(atPath: finalURL.path)) != nil {
throw HelperError(.destinationExists, "The destination file already exists.")
}
return ExportDestination(
directory: resolved, finalURL: finalURL, temporaryURL: temporaryURL, fileName: fileName)
}
/// One path component with no separators, control characters or leading dots, at most
/// 200 UTF-8 bytes with the extension kept.
public func sanitizeFileName(_ raw: String) -> String {
var cleaned = String(
raw.unicodeScalars.map { scalar -> Character in
if scalar == "/" || scalar == "\\" || scalar == ":" { return "_" }
if CharacterSet.controlCharacters.contains(scalar) { return "_" }
return Character(scalar)
})
while cleaned.hasPrefix(".") || cleaned.hasPrefix(" ") { cleaned.removeFirst() }
cleaned = cleaned.trimmingCharacters(in: .whitespaces)
if cleaned.isEmpty { return "asset" }
let maxBytes = 200
if cleaned.utf8.count <= maxBytes { return cleaned }
let ext = (cleaned as NSString).pathExtension
let suffix = ext.isEmpty || ext.utf8.count > 16 ? "" : ".\(ext)"
var stem = suffix.isEmpty ? cleaned : String(cleaned.dropLast(suffix.count))
while stem.utf8.count + suffix.utf8.count > maxBytes { stem.removeLast() }
return stem + suffix
}

View File

@ -0,0 +1,148 @@
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.")
}

View File

@ -0,0 +1,190 @@
import Foundation
import Photos
import PhotosHelperCore
/// Read-only PhotoKit queries. Nothing here writes to the library.
struct Library {
private let library = PHPhotoLibrary.shared()
/// Images and videos only; audio and unknown media types are not part of Media Studio.
/// PhotoKit sorts by date only; the store places missing dates first, which is what
/// `SortKey` assumes, and `nextPage` orders ties itself.
private func fetchOptions(since: Date?) -> PHFetchOptions {
let options = PHFetchOptions()
options.includeHiddenAssets = false
var predicates = [
NSPredicate(
format: "mediaType == %ld || mediaType == %ld",
PHAssetMediaType.image.rawValue, PHAssetMediaType.video.rawValue)
]
if let since {
predicates.append(NSPredicate(format: "modificationDate >= %@", since as NSDate))
}
options.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
return options
}
func list(after cursor: ListCursor?, limit: Int, since: Date?) throws -> ListOutput {
// Captured before enumeration so changes made while listing show up next time.
let changeToken = try encodeToken(library.currentChangeToken)
let result = PHAsset.fetchAssets(with: fetchOptions(since: since))
let page = nextPage(
count: result.count,
key: { index in
let asset = result.object(at: index)
return SortKey(creationDate: asset.creationDate, localIdentifier: asset.localIdentifier)
},
after: cursor,
limit: limit)
let assets = page.indices.map { result.object(at: $0) }
let items = assets.compactMap(makeItem(for:))
let nextCursor = page.hasMore
? assets.last.map {
ListCursor(key: SortKey(creationDate: $0.creationDate, localIdentifier: $0.localIdentifier))
.encoded()
}
: nil
return ListOutput(items: items, nextCursor: nextCursor, changeToken: changeToken)
}
func asset(_ localIdentifier: String) throws -> AssetOutput {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil).firstObject,
let item = makeItem(for: asset)
else { throw HelperError(.notFound, "No photo or video with that identifier.") }
let resources = PHAssetResource.assetResources(for: asset).map {
AssetResourceInfo(
type: resourceTypeName($0.type), fileName: $0.originalFilename, uti: $0.uniformTypeIdentifier)
}
return AssetOutput(
item: item, modifiedAt: asset.modificationDate, hidden: asset.isHidden, resources: resources)
}
/// Persistent change history (macOS 13). An expired token or missing history asks the
/// caller for a full relisting instead of guessing.
func changes(since encodedToken: String) throws -> ChangesOutput {
guard let token = decodeToken(encodedToken) else { return .fullResync() }
let current = try encodeToken(library.currentChangeToken)
var changed = Set<String>()
var deleted = Set<String>()
do {
let result = try library.fetchPersistentChanges(since: token)
var detailsError: Error?
result.enumerateChanges { change, stop in
do {
let details = try change.changeDetails(for: .asset)
changed.formUnion(details.insertedLocalIdentifiers)
changed.formUnion(details.updatedLocalIdentifiers)
deleted.formUnion(details.deletedLocalIdentifiers)
if changed.count + deleted.count > Limits.maxChanges { stop.pointee = true }
} catch {
detailsError = error
stop.pointee = true
}
}
if let detailsError { throw detailsError }
} catch let error as PHPhotosError
where error.code == .persistentChangeTokenExpired
|| error.code == .persistentChangeDetailsUnavailable
{
return .fullResync()
} catch {
throw HelperError(.internal, "Could not read the Photos change history.")
}
if changed.count + deleted.count > Limits.maxChanges { return .fullResync() }
changed.subtract(deleted)
var items: [ClientMediaItem] = []
var found = Set<String>()
if !changed.isEmpty {
PHAsset.fetchAssets(withLocalIdentifiers: Array(changed), options: nil).enumerateObjects {
asset, _, _ in
found.insert(asset.localIdentifier)
if let item = makeItem(for: asset) { items.append(item) }
}
}
// Assets that vanished between the history read and the fetch count as deleted.
deleted.formUnion(changed.subtracting(found))
items.sort { $0.externalId < $1.externalId }
return ChangesOutput(
token: current, changed: items, deleted: deleted.sorted(), fullResyncRequired: false)
}
private func encodeToken(_ token: PHPersistentChangeToken) throws -> String {
guard
let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true)
else { throw HelperError(.internal, "Could not encode the Photos change token.") }
return base64URLEncode(data)
}
private func decodeToken(_ value: String) -> PHPersistentChangeToken? {
guard let data = base64URLDecode(value) else { return nil }
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
}
}
/// Builds the contract item. Returns nil for media types Media Studio does not handle
/// and for identifiers longer than the contract allows.
func makeItem(for asset: PHAsset) -> ClientMediaItem? {
let kind: ClientMediaItem.Kind
switch asset.mediaType {
case .video: kind = .video
case .image: kind = asset.mediaSubtypes.contains(.photoLive) ? .livePhoto : .photo
default: return nil
}
guard asset.localIdentifier.utf16.count <= Limits.externalId else { return nil }
var albums: [String] = []
PHAssetCollection.fetchAssetCollectionsContaining(asset, with: .album, options: nil)
.enumerateObjects { collection, _, stop in
if let title = collection.localizedTitle { albums.append(title) }
if albums.count >= Limits.albumCount { stop.pointee = true }
}
let coordinate = asset.location?.coordinate
return ClientMediaItem(
externalId: asset.localIdentifier,
kind: kind,
capturedAt: asset.creationDate,
durationSeconds: kind == .video ? asset.duration : nil,
width: asset.pixelWidth,
height: asset.pixelHeight,
latitude: coordinate?.latitude,
longitude: coordinate?.longitude,
albumNames: albums,
isFavorite: asset.isFavorite,
fileName: originalResource(for: asset, variant: .original)?.originalFilename)
}
/// The resource holding the untouched original: the video track for videos, the still
/// for photos and Live Photos (the paired motion clip is not exported).
func originalResource(for asset: PHAsset, variant: ExportVariant) -> PHAssetResource? {
let resources = PHAssetResource.assetResources(for: asset)
let preferred: [PHAssetResourceType]
switch (asset.mediaType, variant) {
case (.video, .original): preferred = [.video, .fullSizeVideo]
case (.video, .proxy): preferred = [.fullSizeVideo, .video]
case (_, .original): preferred = [.photo, .fullSizePhoto]
case (_, .proxy): preferred = [.fullSizePhoto, .photo]
}
for type in preferred {
if let match = resources.first(where: { $0.type == type }) { return match }
}
return nil
}
func resourceTypeName(_ type: PHAssetResourceType) -> String {
switch type {
case .photo: return "photo"
case .video: return "video"
case .audio: return "audio"
case .alternatePhoto: return "alternate_photo"
case .fullSizePhoto: return "full_size_photo"
case .fullSizeVideo: return "full_size_video"
case .adjustmentData: return "adjustment_data"
case .adjustmentBasePhoto: return "adjustment_base_photo"
case .pairedVideo: return "paired_video"
case .fullSizePairedVideo: return "full_size_paired_video"
case .adjustmentBasePairedVideo: return "adjustment_base_paired_video"
case .adjustmentBaseVideo: return "adjustment_base_video"
@unknown default: return "other"
}
}

View File

@ -0,0 +1,103 @@
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.")
}
}
}

View File

@ -0,0 +1,73 @@
import XCTest
@testable import PhotosHelperCore
final class ArgumentsTests: XCTestCase {
func testAuthorizePromptsUnlessAskedNotTo() throws {
XCTAssertEqual(try parseArguments(["authorize"]), .authorize(prompt: true))
XCTAssertEqual(try parseArguments(["authorize", "--no-prompt"]), .authorize(prompt: false))
}
func testListDefaultsAndOptions() throws {
XCTAssertEqual(try parseArguments(["list"]), .list(cursor: nil, limit: 500, since: nil))
let cursor = ListCursor(key: SortKey(creationMillis: 1_700_000_000_000, localIdentifier: "A/L0/001"))
let parsed = try parseArguments([
"list", "--cursor", cursor.encoded(), "--limit", "25", "--since", "2024-01-02T03:04:05Z",
])
XCTAssertEqual(
parsed,
.list(cursor: cursor, limit: 25, since: Date(timeIntervalSince1970: 1_704_164_645)))
}
func testListRejectsBadValues() {
for arguments in [
["list", "--limit", "0"],
["list", "--limit", "1001"],
["list", "--limit", "ten"],
["list", "--limit"],
["list", "--since", "yesterday"],
["list", "--cursor", "not a cursor"],
["list", "--limit", "5", "--limit", "6"],
["list", "--dest", "/tmp"],
["list", "extra"],
["list", "--unknown"],
] {
XCTAssertThrowsError(try parseArguments(arguments), "\(arguments)") { error in
XCTAssertEqual((error as? HelperError)?.code, .usage)
}
}
}
func testAssetNeedsExactlyOneIdentifier() throws {
XCTAssertEqual(try parseArguments(["asset", "ABC/L0/001"]), .asset(localIdentifier: "ABC/L0/001"))
XCTAssertThrowsError(try parseArguments(["asset"]))
XCTAssertThrowsError(try parseArguments(["asset", "a", "b"]))
XCTAssertThrowsError(try parseArguments(["asset", String(repeating: "x", count: 201)]))
XCTAssertThrowsError(try parseArguments(["asset", "bad\nid"]))
}
func testExportVariants() throws {
XCTAssertEqual(
try parseArguments(["export", "ID", "--dest", "/tmp/out"]),
.export(localIdentifier: "ID", destination: "/tmp/out", variant: .original))
XCTAssertEqual(
try parseArguments(["export", "--dest", "/tmp/out", "ID", "--proxy"]),
.export(localIdentifier: "ID", destination: "/tmp/out", variant: .proxy))
XCTAssertThrowsError(try parseArguments(["export", "ID"]))
XCTAssertThrowsError(try parseArguments(["export", "ID", "--dest", "/tmp", "--original", "--proxy"]))
}
func testChangesNeedsToken() throws {
XCTAssertEqual(try parseArguments(["changes", "--token", "abc"]), .changes(token: "abc"))
XCTAssertThrowsError(try parseArguments(["changes"]))
XCTAssertThrowsError(try parseArguments(["changes", "--token", ""]))
}
func testUnknownCommandIsUsageError() {
XCTAssertThrowsError(try parseArguments([])) { error in
XCTAssertEqual((error as? HelperError)?.code, .usage)
XCTAssertEqual((error as? HelperError)?.exitCode, 64)
}
XCTAssertThrowsError(try parseArguments(["delete", "ID"]))
}
}

View File

@ -0,0 +1,92 @@
import XCTest
@testable import PhotosHelperCore
final class OutputTests: XCTestCase {
private func object(_ json: String) throws -> [String: Any] {
try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any])
}
func testItemEncodesEveryContractKeyWithExplicitNulls() throws {
let item = ClientMediaItem(
externalId: "ID/L0/001", kind: .photo, capturedAt: nil, durationSeconds: nil, width: 0,
height: nil, latitude: nil, longitude: nil, albumNames: [], isFavorite: false, fileName: nil)
let json = try object(encodeJSON(item))
XCTAssertEqual(
Set(json.keys),
[
"externalId", "kind", "capturedAt", "durationMs", "width", "height", "fps", "gpsLat",
"gpsLon", "deviceModel", "albumNames", "isFavorite", "fileName",
])
for key in ["capturedAt", "durationMs", "width", "height", "fps", "gpsLat", "gpsLon", "deviceModel", "fileName"] {
XCTAssertTrue(json[key] is NSNull, key)
}
}
func testItemNormalizesValues() throws {
let item = ClientMediaItem(
externalId: "V/L0/001", kind: .livePhoto, capturedAt: Date(timeIntervalSince1970: 1_704_164_645.5),
durationSeconds: 12.3456, width: 1920, height: 1080, latitude: 52.520_08, longitude: 13.404_95,
albumNames: ["Trip", "", String(repeating: "a", count: 300)] + Array(repeating: "x", count: 60),
isFavorite: true, fileName: "IMG_0001.HEIC")
let json = try object(encodeJSON(item))
XCTAssertEqual(json["kind"] as? String, "live_photo")
XCTAssertEqual(json["capturedAt"] as? String, "2024-01-02T03:04:05.500Z")
XCTAssertEqual(json["durationMs"] as? Int, 12_346)
XCTAssertEqual(json["gpsLat"] as? Double, 52.52)
XCTAssertEqual(json["gpsLon"] as? Double, 13.4)
let albums = try XCTUnwrap(json["albumNames"] as? [String])
XCTAssertEqual(albums.count, 50)
XCTAssertEqual(albums[1].count, 200)
}
func testInvalidCoordinatesAreDropped() {
XCTAssertNil(roundedCoordinate(latitude: 91, longitude: 0.5))
XCTAssertNil(roundedCoordinate(latitude: .nan, longitude: 1))
XCTAssertNil(roundedCoordinate(latitude: 0, longitude: 0))
XCTAssertNil(roundedCoordinate(latitude: 1, longitude: nil))
}
func testClipCountsUTF16Units() {
XCTAssertEqual(clip("abc", maxCharacters: 5), "abc")
// Each emoji is two UTF-16 units; a half character is never kept.
XCTAssertEqual(clip("😀😀😀", maxCharacters: 5), "😀😀")
}
func testErrorAndProgressLinesAreSingleLineJSON() throws {
let line = errorLine(HelperError(.notFound, "No \"asset\"\nhere."))
XCTAssertFalse(line.contains("\n"))
let error = try XCTUnwrap(try object(line)["error"] as? [String: String])
XCTAssertEqual(error["code"], "not_found")
XCTAssertEqual(error["message"], "No \"asset\"\nhere.")
for (input, expected) in [(0.42424, 0.424), (7, 1), (-1, 0), (.nan, 0)] as [(Double, Double)] {
let progress = progressLine(input)
XCTAssertFalse(progress.contains("\n"))
XCTAssertEqual(try object(progress)["progress"] as? Double ?? -1, expected, accuracy: 1e-9)
}
}
func testListAndChangesKeepNullKeys() throws {
let list = try object(encodeJSON(ListOutput(items: [], nextCursor: nil, changeToken: nil)))
XCTAssertTrue(list["nextCursor"] is NSNull)
XCTAssertTrue(list["changeToken"] is NSNull)
let changes = try object(encodeJSON(ChangesOutput.fullResync()))
XCTAssertTrue(changes["token"] is NSNull)
XCTAssertEqual(changes["fullResyncRequired"] as? Bool, true)
}
func testExitCodesAreDistinctPerFailureClass() {
let codes = HelperError.Code.allCases.map { HelperError($0, "").exitCode }
XCTAssertFalse(codes.contains(0))
XCTAssertEqual(HelperError(.notAuthorized, "").exitCode, 77)
}
func testSHA256StreamsFiles() throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: url) }
try Data("abc".utf8).write(to: url)
let digest = try sha256Hex(of: url)
XCTAssertEqual(digest.hex, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
XCTAssertEqual(digest.sizeBytes, 3)
}
}

View File

@ -0,0 +1,102 @@
import XCTest
@testable import PhotosHelperCore
final class PagingTests: XCTestCase {
func testCursorRoundTripsAndIsURLSafe() throws {
for key in [
SortKey(creationMillis: 1_700_000_000_123, localIdentifier: "9F2A/L0/001"),
SortKey(creationMillis: nil, localIdentifier: "undated+/=?"),
] {
let encoded = ListCursor(key: key).encoded()
XCTAssertFalse(encoded.contains { "+/=".contains($0) }, encoded)
XCTAssertEqual(try ListCursor.decode(encoded).key, key)
}
}
func testCursorRejectsGarbage() {
for value in ["", "!!!", base64URLEncode(Data("{\"v\":2,\"d\":1,\"id\":\"x\"}".utf8)),
base64URLEncode(Data("{\"v\":1,\"d\":1,\"id\":\"\"}".utf8)), base64URLEncode(Data("[]".utf8))]
{
XCTAssertThrowsError(try ListCursor.decode(value), value)
}
}
func testSortKeyPutsUndatedFirstAndBreaksTiesByIdentifier() {
let keys = [
SortKey(creationMillis: 2, localIdentifier: "a"),
SortKey(creationMillis: 1, localIdentifier: "b"),
SortKey(creationMillis: nil, localIdentifier: "z"),
SortKey(creationMillis: 1, localIdentifier: "a"),
SortKey(creationMillis: nil, localIdentifier: "c"),
]
XCTAssertEqual(
keys.sorted().map { key -> String in
let date = key.creationMillis.map { millis in String(millis) } ?? "-"
return date + key.localIdentifier
},
["-c", "-z", "1a", "1b", "2a"])
}
/// Simulates PhotoKit: sorted by date, ties in arbitrary (here reversed) order.
private func photoKitOrder(_ keys: [SortKey]) -> [SortKey] {
keys.sorted { lhs, rhs in
if lhs.creationMillis == rhs.creationMillis { return lhs.localIdentifier > rhs.localIdentifier }
return lhs < rhs
}
}
private func listAll(_ source: [SortKey], limit: Int) -> [SortKey] {
var cursor: ListCursor?
var listed: [SortKey] = []
var pages = 0
while true {
let page = nextPage(count: source.count, key: { source[$0] }, after: cursor, limit: limit)
XCTAssertLessThanOrEqual(page.indices.count, limit)
listed += page.indices.map { source[$0] }
pages += 1
guard page.hasMore, let last = listed.last else { break }
// The cursor survives a round trip through its encoded form, like the app stores it.
cursor = try? ListCursor.decode(ListCursor(key: last).encoded())
if pages > source.count + 1 { XCTFail("paging did not terminate"); break }
}
return listed
}
func testPagesCoverEverythingOnceInStableOrder() {
var keys: [SortKey] = []
for index in 0..<57 {
// Many equal timestamps (bursts) and a few undated assets.
let millis: Int64? = index % 11 == 0 ? nil : Int64(index / 4)
keys.append(SortKey(creationMillis: millis, localIdentifier: String(format: "id-%03d", index)))
}
let source = photoKitOrder(keys)
for limit in [1, 3, 4, 10, 500] {
XCTAssertEqual(listAll(source, limit: limit), keys.sorted(), "limit \(limit)")
}
}
func testEmptyLibraryHasNoMorePages() {
let page = nextPage(count: 0, key: { _ in fatalError() }, after: nil, limit: 10)
XCTAssertEqual(page.indices, [])
XCTAssertFalse(page.hasMore)
}
func testCursorAfterLastItemReturnsNothing() {
let source = [SortKey(creationMillis: 1, localIdentifier: "a")]
let page = nextPage(
count: 1, key: { source[$0] }, after: ListCursor(key: source[0]), limit: 10)
XCTAssertEqual(page.indices, [])
XCTAssertFalse(page.hasMore)
}
func testDeletedCursorItemStillResumesAfterIt() {
let source = [
SortKey(creationMillis: 1, localIdentifier: "a"),
SortKey(creationMillis: 3, localIdentifier: "c"),
]
let gone = ListCursor(key: SortKey(creationMillis: 2, localIdentifier: "b"))
let page = nextPage(count: 2, key: { source[$0] }, after: gone, limit: 10)
XCTAssertEqual(page.indices, [1])
}
}

View File

@ -0,0 +1,83 @@
import XCTest
@testable import PhotosHelperCore
final class PathGuardTests: XCTestCase {
private var directory: URL!
override func setUpWithError() throws {
directory = FileManager.default.temporaryDirectory.appendingPathComponent(
"photos-helper-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: directory)
}
private func resolved() -> URL { directory.resolvingSymlinksInPath().standardizedFileURL }
func testWritesDirectChildrenOfTheResolvedDirectory() throws {
let target = try resolveExportDestination(
directory: directory.path, fileName: "IMG_0001.MOV", temporarySuffix: "t")
XCTAssertEqual(target.finalURL.deletingLastPathComponent().path, resolved().path)
XCTAssertEqual(target.finalURL.lastPathComponent, "IMG_0001.MOV")
XCTAssertEqual(target.temporaryURL.lastPathComponent, ".IMG_0001.MOV.t.partial")
}
func testRejectsRelativeAndTraversingDirectories() {
for path in ["relative/dir", "\(directory.path)/../x", "\(directory.path)/./", "/a/../b", ""] {
XCTAssertThrowsError(
try resolveExportDestination(directory: path, fileName: "a.jpg"), path
) { error in
XCTAssertEqual((error as? HelperError)?.code, .pathRejected)
}
}
}
func testRejectsMissingDirectoryAndFiles() throws {
XCTAssertThrowsError(
try resolveExportDestination(directory: directory.path + "/missing", fileName: "a.jpg"))
let file = directory.appendingPathComponent("file")
try Data().write(to: file)
XCTAssertThrowsError(try resolveExportDestination(directory: file.path, fileName: "a.jpg"))
}
func testHostileFileNamesStayInside() throws {
for name in ["../../etc/passwd", "/etc/passwd", "..", ".hidden", "a/b\\c:d", "\u{0}x", " "] {
let target = try resolveExportDestination(directory: directory.path, fileName: name)
XCTAssertEqual(target.finalURL.deletingLastPathComponent().path, resolved().path, name)
XCTAssertFalse(target.fileName.hasPrefix("."), name)
XCTAssertFalse(target.fileName.contains("/"), name)
}
XCTAssertEqual(sanitizeFileName("../../etc/passwd"), "_.._etc_passwd")
XCTAssertEqual(sanitizeFileName(""), "asset")
}
func testLongNamesKeepTheirExtension() {
let name = sanitizeFileName(String(repeating: "é", count: 300) + ".mov")
XCTAssertLessThanOrEqual(name.utf8.count, 200)
XCTAssertTrue(name.hasSuffix(".mov"))
}
func testNeverOverwritesAnExistingFileOrSymlink() throws {
try Data().write(to: directory.appendingPathComponent("taken.jpg"))
XCTAssertThrowsError(try resolveExportDestination(directory: directory.path, fileName: "taken.jpg")) {
error in XCTAssertEqual((error as? HelperError)?.code, .destinationExists)
}
try FileManager.default.createSymbolicLink(
atPath: directory.appendingPathComponent("link.jpg").path, withDestinationPath: "/nonexistent")
XCTAssertThrowsError(try resolveExportDestination(directory: directory.path, fileName: "link.jpg"))
}
func testSymlinkedDirectoryResolvesToItsTarget() throws {
let link = directory.appendingPathComponent("link", isDirectory: true)
let real = directory.appendingPathComponent("real", isDirectory: true)
try FileManager.default.createDirectory(at: real, withIntermediateDirectories: false)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real)
let target = try resolveExportDestination(directory: link.path, fileName: "a.jpg")
XCTAssertEqual(
target.finalURL.deletingLastPathComponent().path,
real.resolvingSymlinksInPath().standardizedFileURL.path)
}
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- electron-builder's default hardened-runtime entitlements for Electron. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Hardened runtime blocks Photos without this, for the app and the bundled photos-helper. -->
<key>com.apple.security.personal-information.photos-library</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,161 @@
import { describe, expect, it, vi } from "vitest";
import type { MediaIpcEvent, MediaIpcOptions } from "./media-ipc.js";
import {
MEDIA_PHOTOS_CHANNELS,
outcomeForError,
parseSyncRequest,
registerMediaIpc,
} from "./media-ipc.js";
import type { PhotosHelper } from "./media-photos-helper.js";
import { PhotosHelperError } from "./media-photos-helper.js";
import type { PhotosSyncResult } from "./media-photos-sync.js";
import { PhotosSyncError } from "./media-photos-sync.js";
type Listener = (event: MediaIpcEvent, ...args: unknown[]) => unknown;
const result: PhotosSyncResult = {
indexed: 2,
created: 2,
updated: 0,
unchanged: 0,
uploaded: 0,
fullListing: true,
};
function setup(overrides: Partial<MediaIpcOptions> = {}) {
const handlers = new Map<string, Listener>();
const helper = {
authorize: vi.fn(async () => "authorized" as const),
} as unknown as PhotosHelper;
const options: MediaIpcOptions = {
ipcMain: { handle: (channel, listener) => handlers.set(channel, listener) },
platform: "darwin",
helper,
isTrusted: () => true,
runSync: vi.fn(async () => result),
...overrides,
};
const registration = registerMediaIpc(options);
const sent: [string, unknown][] = [];
const event: MediaIpcEvent = {
sender: { send: (channel, payload) => sent.push([channel, payload]), isDestroyed: () => false },
};
const call = (channel: string, ...args: unknown[]) => handlers.get(channel)!(event, ...args);
return { handlers, helper, options, registration, call, sent };
}
describe("media IPC on platforms without Apple Photos", () => {
it.each(["linux", "win32"])(
"reports %s as unavailable and rejects sync and cancel",
async (platform) => {
const { call, options, handlers } = setup({ platform });
expect([...handlers.keys()].sort()).toEqual(
[
MEDIA_PHOTOS_CHANNELS.cancel,
MEDIA_PHOTOS_CHANNELS.status,
MEDIA_PHOTOS_CHANNELS.sync,
].sort(),
);
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toEqual({ available: false });
expect(() => call(MEDIA_PHOTOS_CHANNELS.sync, { spaceId: null })).toThrow(/macOS/);
expect(() => call(MEDIA_PHOTOS_CHANNELS.cancel)).toThrow(/macOS/);
expect(options.runSync).not.toHaveBeenCalled();
expect(options.helper!.authorize).not.toHaveBeenCalled();
},
);
it("treats a macOS build without the helper as unavailable", async () => {
const { call } = setup({ helper: null });
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toEqual({ available: false });
expect(() => call(MEDIA_PHOTOS_CHANNELS.sync, {})).toThrow(/macOS/);
});
it("treats a helper that cannot start as unavailable", async () => {
const { call, helper } = setup();
vi.mocked(helper.authorize).mockRejectedValueOnce(new PhotosHelperError("unavailable", "x"));
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toEqual({ available: false });
});
});
describe("media IPC on macOS", () => {
it("reads status without prompting", async () => {
const { call, helper } = setup();
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toEqual({
available: true,
authorization: "authorized",
syncing: false,
});
expect(helper.authorize).toHaveBeenCalledWith(false);
});
it("runs one sync at a time, forwards progress and resolves with the outcome", async () => {
let finish: (value: PhotosSyncResult) => void = () => {};
const runSync = vi.fn(
({ onProgress }: Parameters<MediaIpcOptions["runSync"]>[0]) =>
new Promise<PhotosSyncResult>((resolve) => {
onProgress({ phase: "indexing", indexed: 1, uploadsDone: 0, uploadsTotal: 0 });
finish = resolve;
}),
);
const { call, sent } = setup({ runSync });
const first = call(MEDIA_PHOTOS_CHANNELS.sync, { spaceId: "space_1" });
const second = call(MEDIA_PHOTOS_CHANNELS.sync, { spaceId: "space_1" });
expect(second).toBe(first);
expect(runSync).toHaveBeenCalledTimes(1);
expect(runSync.mock.calls[0]?.[0].spaceId).toBe("space_1");
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toMatchObject({ syncing: true });
expect(sent).toEqual([
[
MEDIA_PHOTOS_CHANNELS.progress,
{ phase: "indexing", indexed: 1, uploadsDone: 0, uploadsTotal: 0 },
],
]);
finish(result);
await expect(first).resolves.toEqual({ ok: true, result });
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toMatchObject({ syncing: false });
});
it("cancels the running sync", async () => {
const runSync = vi.fn(
({ signal }: Parameters<MediaIpcOptions["runSync"]>[0]) =>
new Promise<PhotosSyncResult>((_resolve, reject) => {
signal.addEventListener("abort", () =>
reject(new PhotosSyncError("cancelled", "Sync cancelled.")),
);
}),
);
const { call } = setup({ runSync });
const running = call(MEDIA_PHOTOS_CHANNELS.sync, {});
await call(MEDIA_PHOTOS_CHANNELS.cancel);
await expect(running).resolves.toEqual({
ok: false,
code: "cancelled",
message: "Sync cancelled.",
});
});
it("rejects untrusted senders and malformed requests", async () => {
const { call } = setup({ isTrusted: () => false });
expect(() => call(MEDIA_PHOTOS_CHANNELS.sync, {})).toThrow("Not allowed.");
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).rejects.toThrow("Not allowed.");
expect(() => parseSyncRequest(null)).toThrow();
expect(() => parseSyncRequest({ spaceId: "../x" })).toThrow();
expect(() => parseSyncRequest({ spaceId: 4 })).toThrow();
expect(parseSyncRequest({})).toEqual({ spaceId: null });
});
it("never forwards unexpected error text to the renderer", () => {
expect(outcomeForError(new Error("/Users/example/secret path"))).toEqual({
ok: false,
code: "failed",
message: "Apple Photos sync failed.",
});
expect(
outcomeForError(
new PhotosHelperError("not_authorized", "Photos access has not been granted."),
),
).toMatchObject({
code: "not_authorized",
});
});
});

View File

@ -0,0 +1,135 @@
import type { PhotosAuthorization, PhotosHelper } from "./media-photos-helper.js";
import { PhotosHelperError } from "./media-photos-helper.js";
import type { PhotosSyncProgress, PhotosSyncResult } from "./media-photos-sync.js";
import { PhotosSyncError } from "./media-photos-sync.js";
/*
* IPC for the Apple Photos source. The renderer only expresses intent (sync, cancel)
* and renders state; the main process owns the helper, the session and the upload.
*/
export const MEDIA_PHOTOS_CHANNELS = {
status: "desktop.media.photos.status",
sync: "desktop.media.photos.sync",
cancel: "desktop.media.photos.cancel",
progress: "desktop.media.photos.progress",
} as const;
export type PhotosSourceStatus =
| { available: false }
| { available: true; authorization: PhotosAuthorization; syncing: boolean };
export type PhotosSyncOutcome =
| { ok: true; result: PhotosSyncResult }
| { ok: false; code: string; message: string };
/** Renderer-facing bridge, exposed as `rakazoDesktop.media.photos`. */
export interface RakazoDesktopMediaPhotos {
status: () => Promise<PhotosSourceStatus>;
/** Asks for Photos access on first use, then indexes and uploads what the server needs. */
sync: (request: { spaceId: string | null }) => Promise<PhotosSyncOutcome>;
cancel: () => Promise<void>;
onProgress: (listener: (progress: PhotosSyncProgress) => void) => () => void;
}
export interface MediaIpcEvent {
sender: { send(channel: string, payload: unknown): void; isDestroyed(): boolean };
}
export interface MediaIpcMain {
handle(channel: string, listener: (event: MediaIpcEvent, ...args: unknown[]) => unknown): void;
}
export interface MediaIpcOptions {
ipcMain: MediaIpcMain;
platform: string;
/** Null when the helper binary is missing (unbuilt dev checkout). */
helper: PhotosHelper | null;
/** Same gate as the other app-window handlers: main window, main frame. */
isTrusted: (event: MediaIpcEvent) => boolean;
runSync: (request: {
spaceId: string | null;
signal: AbortSignal;
onProgress: (progress: PhotosSyncProgress) => void;
}) => Promise<PhotosSyncResult>;
}
const UNAVAILABLE = "Apple Photos is only available in the macOS app.";
const SPACE_ID = /^[A-Za-z0-9_-]{1,200}$/;
export function parseSyncRequest(value: unknown): { spaceId: string | null } {
if (typeof value !== "object" || value === null) throw new Error("Invalid sync request.");
const spaceId = (value as { spaceId?: unknown }).spaceId ?? null;
if (spaceId !== null && (typeof spaceId !== "string" || !SPACE_ID.test(spaceId))) {
throw new Error("Invalid sync request.");
}
return { spaceId };
}
/** Registers the handlers; `cancel()` stops a running sync, e.g. before quit. */
export function registerMediaIpc(options: MediaIpcOptions): { cancel: () => void } {
const available = options.platform === "darwin" && options.helper !== null;
let running: { controller: AbortController; done: Promise<PhotosSyncOutcome> } | null = null;
const requireTrusted = (event: MediaIpcEvent) => {
if (!options.isTrusted(event)) throw new Error("Not allowed.");
};
options.ipcMain.handle(MEDIA_PHOTOS_CHANNELS.status, async (event) => {
requireTrusted(event);
if (!available) return { available: false } satisfies PhotosSourceStatus;
try {
const authorization = await options.helper!.authorize(false);
return { available: true, authorization, syncing: running !== null };
} catch (error) {
if (error instanceof PhotosHelperError && error.code === "unavailable") {
return { available: false } satisfies PhotosSourceStatus;
}
throw error;
}
});
options.ipcMain.handle(MEDIA_PHOTOS_CHANNELS.sync, (event, request: unknown) => {
requireTrusted(event);
if (!available) throw new Error(UNAVAILABLE);
const { spaceId } = parseSyncRequest(request);
// A second click joins the running sync instead of starting a parallel one.
if (running) return running.done;
const controller = new AbortController();
const done = options
.runSync({
spaceId,
signal: controller.signal,
onProgress: (progress) => {
if (!event.sender.isDestroyed()) {
event.sender.send(MEDIA_PHOTOS_CHANNELS.progress, progress);
}
},
})
.then(
(result): PhotosSyncOutcome => ({ ok: true, result }),
(error: unknown): PhotosSyncOutcome => outcomeForError(error),
)
.finally(() => {
if (running?.controller === controller) running = null;
});
running = { controller, done };
return done;
});
options.ipcMain.handle(MEDIA_PHOTOS_CHANNELS.cancel, (event) => {
requireTrusted(event);
if (!available) throw new Error(UNAVAILABLE);
running?.controller.abort();
});
return { cancel: () => running?.controller.abort() };
}
/** Only our own fixed messages reach the renderer; anything else is generic. */
export function outcomeForError(error: unknown): PhotosSyncOutcome {
if (error instanceof PhotosSyncError || error instanceof PhotosHelperError) {
return { ok: false, code: error.code, message: error.message };
}
return { ok: false, code: "failed", message: "Apple Photos sync failed." };
}

View File

@ -0,0 +1,437 @@
import { EventEmitter } from "node:events";
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HelperProcess, SpawnHelper } from "./media-photos-helper.js";
import {
changesArgs,
createPhotosHelper,
exportArgs,
helperEnv,
listArgs,
PhotosHelperError,
parseAssetOutput,
parseAuthorizeOutput,
parseChangesOutput,
parseExportOutput,
parseHelperError,
parseListOutput,
parseProgressLine,
photosHelperCandidates,
resolvePhotosHelperBinary,
runPhotosHelper,
} from "./media-photos-helper.js";
const item = (overrides: Record<string, unknown> = {}) => ({
externalId: "9F2A61B0-0000-4000-8000-000000000001/L0/001",
kind: "video",
capturedAt: "2024-01-02T03:04:05.500Z",
durationMs: 12_346,
width: 1920,
height: 1080,
fps: null,
gpsLat: 52.52,
gpsLon: 13.4,
deviceModel: null,
albumNames: ["Trip"],
isFavorite: true,
fileName: "IMG_0001.MOV",
...overrides,
});
const listJson = (items: unknown[], nextCursor: unknown = null, changeToken: unknown = "tok") =>
JSON.stringify({ items, nextCursor, changeToken });
function expectCode(run: () => unknown, code: string) {
try {
run();
} catch (error) {
expect(error).toBeInstanceOf(PhotosHelperError);
expect((error as PhotosHelperError).code).toBe(code);
return;
}
throw new Error("expected a PhotosHelperError");
}
describe("photos helper output parsing", () => {
it("accepts a list page that matches the shared item contract", () => {
const page = parseListOutput(
listJson([item(), item({ kind: "photo", durationMs: null })], "c1"),
2,
);
expect(page.nextCursor).toBe("c1");
expect(page.changeToken).toBe("tok");
expect(page.items).toHaveLength(2);
expect(page.items[0]).toEqual(item());
});
it("accepts the last page of an empty library", () => {
expect(parseListOutput(listJson([], null, null), 500)).toEqual({
items: [],
nextCursor: null,
changeToken: null,
});
});
it.each([
["not JSON", "items: []"],
["an array document", "[]"],
["truncated JSON", '{"items":[{"externalId":'],
["missing items", JSON.stringify({ nextCursor: null, changeToken: null })],
["an item missing a key", listJson([{ ...item(), width: undefined }])],
["an unknown kind", listJson([item({ kind: "audio" })])],
["out-of-range GPS", listJson([item({ gpsLat: 91 })])],
["a non-UTC-parsable date", listJson([item({ capturedAt: "yesterday" })])],
["a negative duration", listJson([item({ durationMs: -1 })])],
["an empty external id", listJson([item({ externalId: "" })])],
["an empty cursor", listJson([item()], "")],
["a numeric cursor", listJson([item()], 3)],
["too many items", listJson([item(), item(), item()])],
])("rejects %s", (_name, text) => {
expectCode(() => parseListOutput(text, 2), "invalid_output");
});
it("parses authorization states and rejects unknown ones", () => {
expect(parseAuthorizeOutput('{"status":"limited"}')).toBe("limited");
expect(parseAuthorizeOutput('{"status":"notDetermined"}')).toBe("notDetermined");
expectCode(() => parseAuthorizeOutput('{"status":"maybe"}'), "invalid_output");
expectCode(() => parseAuthorizeOutput(""), "invalid_output");
});
it("parses asset details", () => {
const detail = parseAssetOutput(
JSON.stringify({
item: item(),
modifiedAt: "2024-02-01T00:00:00.000Z",
hidden: false,
resources: [{ type: "video", fileName: "IMG_0001.MOV", uti: "com.apple.quicktime-movie" }],
}),
);
expect(detail.resources[0]?.type).toBe("video");
expectCode(
() => parseAssetOutput(JSON.stringify({ item: item(), modifiedAt: null, resources: [] })),
"invalid_output",
);
});
it("parses changes and requires a token unless a full relisting is needed", () => {
expect(
parseChangesOutput(
JSON.stringify({
token: "t2",
changed: [item()],
deleted: ["x/L0/001"],
fullResyncRequired: false,
}),
),
).toMatchObject({ token: "t2", deleted: ["x/L0/001"], fullResyncRequired: false });
expect(
parseChangesOutput(
JSON.stringify({ token: null, changed: [], deleted: [], fullResyncRequired: true }),
).fullResyncRequired,
).toBe(true);
expectCode(
() =>
parseChangesOutput(
JSON.stringify({ token: null, changed: [], deleted: [], fullResyncRequired: false }),
),
"invalid_output",
);
expectCode(
() =>
parseChangesOutput(
JSON.stringify({ token: "t", changed: [], deleted: [7], fullResyncRequired: false }),
),
"invalid_output",
);
});
it("maps helper error lines to codes", () => {
const stderr =
'{"progress":0.5}\n{"error":{"code":"not_authorized","message":"Photos access has not been granted."}}\n';
const error = parseHelperError(stderr, 77);
expect(error.code).toBe("not_authorized");
expect(error.message).toBe("Photos access has not been granted.");
expect(parseHelperError('{"error":{"code":"internal","message":"x"}}', 70).code).toBe("failed");
expect(parseHelperError("Segmentation fault", 139).message).toContain("139");
expect(parseHelperError('{"error":"oops"}', 1).code).toBe("failed");
});
it("reads progress lines only", () => {
expect(parseProgressLine('{"progress":0.42}')).toBe(0.42);
expect(parseProgressLine('{"progress":2}')).toBeNull();
expect(parseProgressLine('{"error":{"code":"usage"}}')).toBeNull();
expect(parseProgressLine('{"progress":')).toBeNull();
});
});
describe("photos helper export result", () => {
let dir: string;
let other: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "rakazo-photos-export-"));
other = await mkdtemp(path.join(tmpdir(), "rakazo-photos-other-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
await rm(other, { recursive: true, force: true });
});
const result = (file: string, fileName = path.basename(file)) =>
JSON.stringify({ path: file, fileName, sizeBytes: 3, sha256: "a".repeat(64) });
it("accepts a file directly inside the export folder", async () => {
const file = path.join(dir, "IMG_0001.MOV");
await writeFile(file, "abc");
await expect(parseExportOutput(result(file), dir)).resolves.toMatchObject({
fileName: "IMG_0001.MOV",
sizeBytes: 3,
});
});
it("rejects files outside the export folder, including through symlinks", async () => {
const outside = path.join(other, "secret.txt");
await writeFile(outside, "abc");
await expect(parseExportOutput(result(outside), dir)).rejects.toMatchObject({
code: "path_rejected",
});
const link = path.join(dir, "link.MOV");
await symlink(outside, link);
await expect(parseExportOutput(result(link), dir)).rejects.toMatchObject({
code: "path_rejected",
});
const nested = path.join(dir, "..", path.basename(other), "secret.txt");
await expect(parseExportOutput(result(nested), dir)).rejects.toMatchObject({
code: "path_rejected",
});
});
it("rejects malformed results", async () => {
const file = path.join(dir, "a.jpg");
await writeFile(file, "abc");
for (const text of [
JSON.stringify({ path: file, fileName: "a.jpg", sizeBytes: 3, sha256: "XYZ" }),
JSON.stringify({ path: "a.jpg", fileName: "a.jpg", sizeBytes: 3, sha256: "a".repeat(64) }),
JSON.stringify({ path: file, fileName: "a.jpg", sizeBytes: -1, sha256: "a".repeat(64) }),
JSON.stringify({
path: path.join(dir, "missing.jpg"),
fileName: "missing.jpg",
sizeBytes: 3,
sha256: "a".repeat(64),
}),
]) {
await expect(parseExportOutput(text, dir)).rejects.toMatchObject({ code: "invalid_output" });
}
await expect(parseExportOutput(result(file, "b.jpg"), dir)).rejects.toMatchObject({
code: "path_rejected",
});
});
});
describe("photos helper argv", () => {
it("builds fixed argv arrays", () => {
expect(listArgs({ cursor: null, limit: 500 })).toEqual(["list", "--limit", "500"]);
expect(listArgs({ cursor: "abc_-", limit: 10, since: "2024-01-01T00:00:00Z" })).toEqual([
"list",
"--limit",
"10",
"--cursor",
"abc_-",
"--since",
"2024-01-01T00:00:00Z",
]);
expect(exportArgs("ID/L0/001", "/tmp/rakazo-photos-1")).toEqual([
"export",
"ID/L0/001",
"--dest",
"/tmp/rakazo-photos-1",
"--original",
]);
expect(changesArgs("tok")).toEqual(["changes", "--token", "tok"]);
});
it("keeps shell syntax as inert data and rejects option-like values", () => {
// No shell runs, so metacharacters stay one literal argument.
expect(exportArgs("$(rm -rf ~);`x`", "/tmp/a b")[1]).toBe("$(rm -rf ~);`x`");
expectCode(() => exportArgs("--dest", "/tmp"), "usage");
expectCode(() => listArgs({ cursor: "--limit", limit: 1 }), "usage");
expectCode(() => changesArgs("a\nb"), "usage");
expectCode(() => listArgs({ cursor: null, limit: 501 }), "usage");
expectCode(() => exportArgs("ID", "relative/dir"), "path_rejected");
expectCode(() => exportArgs("ID", "/tmp/../etc"), "path_rejected");
});
it("passes only the environment PhotoKit needs", () => {
expect(
helperEnv({ HOME: "/Users/example", TMPDIR: "/tmp", OPENAI_API_KEY: "secret", PATH: "/bin" }),
).toEqual({ HOME: "/Users/example", TMPDIR: "/tmp" });
});
});
describe("photos helper binary", () => {
it("is only available on macOS", () => {
const options = {
packaged: true,
resourcesPath: "/App/Resources",
appPath: "/app",
exists: () => true,
};
expect(resolvePhotosHelperBinary({ ...options, platform: "linux" })).toBeNull();
expect(resolvePhotosHelperBinary({ ...options, platform: "win32" })).toBeNull();
expect(resolvePhotosHelperBinary({ ...options, platform: "darwin" })).toBe(
"/App/Resources/bin/photos-helper",
);
});
it("falls back to the development build and reports a missing build", () => {
const options = {
platform: "darwin",
packaged: false,
resourcesPath: "/r",
appPath: "/repo/apps/desktop",
};
expect(photosHelperCandidates(options)).toEqual([
"/repo/apps/desktop/native/photos-helper/.build/apple/Products/Release/photos-helper",
"/repo/apps/desktop/native/photos-helper/.build/release/photos-helper",
]);
expect(
resolvePhotosHelperBinary({
...options,
exists: (file) => file.endsWith(".build/release/photos-helper"),
}),
).toBe("/repo/apps/desktop/native/photos-helper/.build/release/photos-helper");
expect(resolvePhotosHelperBinary({ ...options, exists: () => false })).toBeNull();
});
});
class FakeProcess extends EventEmitter implements HelperProcess {
stdout = new PassThrough();
stderr = new PassThrough();
kill = vi.fn(() => {
queueMicrotask(() => this.emit("close", null));
return true;
});
exit(code: number, stdout = "", stderr = "") {
if (stdout) this.stdout.write(stdout);
if (stderr) this.stderr.write(stderr);
setImmediate(() => this.emit("close", code));
}
}
function fakeSpawn() {
const processes: FakeProcess[] = [];
const spawn = vi.fn<SpawnHelper>(() => {
const child = new FakeProcess();
processes.push(child);
return child;
});
return { spawn, processes };
}
describe("photos helper process", () => {
it("spawns the binary with argv and no shell", async () => {
const { spawn, processes } = fakeSpawn();
const helper = createPhotosHelper("/App/Resources/bin/photos-helper", spawn);
const pending = helper.list({ cursor: null, limit: 2 });
processes[0]!.exit(0, listJson([item()]));
await expect(pending).resolves.toMatchObject({ items: [item()] });
expect(spawn).toHaveBeenCalledWith(
"/App/Resources/bin/photos-helper",
["list", "--limit", "2"],
expect.objectContaining({ shell: false, stdio: ["ignore", "pipe", "pipe"] }),
);
});
it("maps a failing exit to the helper's error code", async () => {
const { spawn, processes } = fakeSpawn();
const pending = createPhotosHelper("/bin/photos-helper", spawn).asset("ID/L0/001");
processes[0]!.exit(
66,
"",
'{"error":{"code":"not_found","message":"No photo or video with that identifier."}}\n',
);
await expect(pending).rejects.toMatchObject({ code: "not_found" });
});
it("reports export progress and validates the result", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "rakazo-photos-run-"));
try {
const file = path.join(dir, "IMG_0001.MOV");
await writeFile(file, "abc");
const { spawn, processes } = fakeSpawn();
const progress: number[] = [];
const pending = createPhotosHelper("/bin/photos-helper", spawn).exportOriginal("ID", dir, {
onProgress: (value) => progress.push(value),
});
const child = processes[0]!;
child.stderr.write('{"progress":0}\n{"progress":0.5');
child.stderr.write('}\nnoise\n{"progress":1}\n');
child.exit(
0,
JSON.stringify({
path: file,
fileName: "IMG_0001.MOV",
sizeBytes: 3,
sha256: "b".repeat(64),
}),
);
await expect(pending).resolves.toMatchObject({ sizeBytes: 3 });
expect(progress).toEqual([0, 0.5, 1]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("kills the helper on abort and on timeout", async () => {
const { spawn, processes } = fakeSpawn();
const controller = new AbortController();
const aborted = runPhotosHelper("/bin/h", ["list"], {
spawn,
timeoutMs: 60_000,
signal: controller.signal,
});
controller.abort();
await expect(aborted).rejects.toMatchObject({ code: "cancelled" });
expect(processes[0]!.kill).toHaveBeenCalledWith("SIGTERM");
const timedOut = runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 5 });
await expect(timedOut).rejects.toMatchObject({ code: "timeout" });
expect(processes[1]!.kill).toHaveBeenCalled();
});
it("fails a silent export through the idle limit", async () => {
const { spawn, processes } = fakeSpawn();
const pending = runPhotosHelper("/bin/h", ["export"], {
spawn,
timeoutMs: 60_000,
idleTimeoutMs: 5,
});
await expect(pending).rejects.toMatchObject({ code: "timeout" });
expect(processes[0]!.kill).toHaveBeenCalled();
});
it("does not start when already aborted and reports spawn failures", async () => {
const { spawn, processes } = fakeSpawn();
const controller = new AbortController();
controller.abort();
await expect(
runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 1000, signal: controller.signal }),
).rejects.toMatchObject({ code: "cancelled" });
expect(spawn).not.toHaveBeenCalled();
const pending = runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 1000 });
processes[0]!.emit("error", new Error("ENOENT"));
await expect(pending).rejects.toMatchObject({ code: "unavailable" });
});
it("rejects oversized output", async () => {
const { spawn, processes } = fakeSpawn();
const pending = runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 1000 });
processes[0]!.stdout.write(Buffer.alloc(33 * 1024 * 1024));
await expect(pending).rejects.toMatchObject({ code: "invalid_output" });
});
});

View File

@ -0,0 +1,517 @@
import { spawn as nodeSpawn } from "node:child_process";
import { realpath } from "node:fs/promises";
import path from "node:path";
import type { Readable } from "node:stream";
import type { ClientMediaItem } from "@rakazo/contracts";
import { ClientMediaItemSchema } from "@rakazo/contracts";
/*
* Client for the macOS `photos-helper` CLI (apps/desktop/native/photos-helper). The
* helper runs with a fixed argv and no shell; every byte it prints is validated before
* the app uses it, so a malformed or unexpected answer fails instead of being guessed at.
*/
export type PhotosAuthorization =
| "authorized"
| "limited"
| "denied"
| "restricted"
| "notDetermined";
export interface PhotosListPage {
items: ClientMediaItem[];
nextCursor: string | null;
/** Library change token from before this page was read. */
changeToken: string | null;
}
export interface PhotosAssetDetail {
item: ClientMediaItem;
modifiedAt: string | null;
hidden: boolean;
resources: { type: string; fileName: string; uti: string }[];
}
export interface PhotosChanges {
token: string | null;
changed: ClientMediaItem[];
deleted: string[];
fullResyncRequired: boolean;
}
export interface PhotosExport {
path: string;
fileName: string;
sizeBytes: number;
sha256: string;
}
export type PhotosHelperErrorCode =
| "unavailable"
| "usage"
| "not_authorized"
| "not_found"
| "path_rejected"
| "destination_exists"
| "network"
| "export_failed"
| "invalid_output"
| "timeout"
| "cancelled"
| "failed";
const HELPER_CODES = new Set<PhotosHelperErrorCode>([
"usage",
"not_authorized",
"not_found",
"path_rejected",
"destination_exists",
"network",
"export_failed",
]);
export class PhotosHelperError extends Error {
constructor(
readonly code: PhotosHelperErrorCode,
message: string,
) {
super(message);
this.name = "PhotosHelperError";
}
}
export const PHOTOS_HELPER_NAME = "photos-helper";
/** Matches the contract's batch limit for `media.sources.pushItems`. */
export const PHOTOS_MAX_PAGE = 500;
/**
* Packaged apps ship the universal helper in Resources/bin; development uses the
* SwiftPM output (universal `--arch` build first, then the host-arch build).
*/
export function photosHelperCandidates(options: {
packaged: boolean;
resourcesPath: string;
appPath: string;
}): string[] {
if (options.packaged) return [path.join(options.resourcesPath, "bin", PHOTOS_HELPER_NAME)];
const build = path.join(options.appPath, "native", "photos-helper", ".build");
return [
path.join(build, "apple", "Products", "Release", PHOTOS_HELPER_NAME),
path.join(build, "release", PHOTOS_HELPER_NAME),
];
}
/** Null off macOS or when the helper was not built; the Apple Photos source is then hidden. */
export function resolvePhotosHelperBinary(options: {
platform: string;
packaged: boolean;
resourcesPath: string;
appPath: string;
exists: (file: string) => boolean;
}): string | null {
if (options.platform !== "darwin") return null;
return photosHelperCandidates(options).find((candidate) => options.exists(candidate)) ?? null;
}
// ---------------------------------------------------------------------------
// argv
// ---------------------------------------------------------------------------
export function authorizeArgs(prompt: boolean): string[] {
return prompt ? ["authorize"] : ["authorize", "--no-prompt"];
}
export function listArgs(request: {
cursor: string | null;
limit: number;
since?: string | null;
}): string[] {
if (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > PHOTOS_MAX_PAGE) {
throw new PhotosHelperError("usage", "List limit is out of range.");
}
const args = ["list", "--limit", String(request.limit)];
if (request.cursor !== null) args.push("--cursor", opaqueArgument(request.cursor));
if (request.since) args.push("--since", opaqueArgument(request.since));
return args;
}
export function assetArgs(localIdentifier: string): string[] {
return ["asset", identifierArgument(localIdentifier)];
}
export function exportArgs(localIdentifier: string, destination: string): string[] {
if (!path.isAbsolute(destination) || destination.split(/[\\/]/).includes("..")) {
throw new PhotosHelperError("path_rejected", "Export directory must be absolute.");
}
return ["export", identifierArgument(localIdentifier), "--dest", destination, "--original"];
}
export function changesArgs(token: string): string[] {
return ["changes", "--token", opaqueArgument(token)];
}
/** Identifiers come from the helper itself; a leading dash would read as an option. */
function identifierArgument(value: string): string {
if (value === "" || value.length > 200 || value.startsWith("-") || hasControl(value)) {
throw new PhotosHelperError("usage", "Invalid Photos identifier.");
}
return value;
}
function opaqueArgument(value: string): string {
if (value === "" || value.length > 4096 || value.startsWith("-") || hasControl(value)) {
throw new PhotosHelperError("usage", "Invalid helper argument.");
}
return value;
}
function hasControl(value: string): boolean {
for (const char of value) {
const code = char.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Output parsing
// ---------------------------------------------------------------------------
function invalid(what: string): never {
throw new PhotosHelperError("invalid_output", `Photos helper returned invalid ${what}.`);
}
function parseDocument(text: string): Record<string, unknown> {
let value: unknown;
try {
value = JSON.parse(text);
} catch {
invalid("JSON");
}
if (typeof value !== "object" || value === null || Array.isArray(value)) invalid("JSON");
return value as Record<string, unknown>;
}
function nullableString(value: unknown, what: string, max = 4096): string | null {
if (value === null) return null;
if (typeof value !== "string" || value === "" || value.length > max) invalid(what);
return value;
}
/** Each item must satisfy the shared contract schema exactly as the server will parse it. */
function parseItems(value: unknown, what: string): ClientMediaItem[] {
if (!Array.isArray(value) || value.length > 10_000) invalid(what);
return value.map((entry) => {
if (typeof entry !== "object" || entry === null) invalid(what);
const parsed = ClientMediaItemSchema.safeParse(entry);
if (!parsed.success) invalid(what);
return parsed.data;
});
}
const AUTHORIZATION = new Set<PhotosAuthorization>([
"authorized",
"limited",
"denied",
"restricted",
"notDetermined",
]);
export function parseAuthorizeOutput(text: string): PhotosAuthorization {
const status = parseDocument(text).status;
if (typeof status !== "string" || !AUTHORIZATION.has(status as PhotosAuthorization)) {
invalid("authorization status");
}
return status as PhotosAuthorization;
}
export function parseListOutput(text: string, limit: number): PhotosListPage {
const document = parseDocument(text);
const items = parseItems(document.items, "list items");
if (items.length > limit) invalid("list page size");
const nextCursor = nullableString(document.nextCursor, "list cursor");
const changeToken = nullableString(document.changeToken, "change token");
return { items, nextCursor, changeToken };
}
export function parseAssetOutput(text: string): PhotosAssetDetail {
const document = parseDocument(text);
const [item] = parseItems([document.item], "asset");
const modifiedAt = nullableString(document.modifiedAt, "asset date", 64);
if (modifiedAt !== null && Number.isNaN(Date.parse(modifiedAt))) invalid("asset date");
if (typeof document.hidden !== "boolean") invalid("asset");
if (!Array.isArray(document.resources) || document.resources.length > 50) invalid("resources");
const resources = document.resources.map((resource) => {
if (typeof resource !== "object" || resource === null) invalid("resources");
const { type, fileName, uti } = resource as Record<string, unknown>;
if (typeof type !== "string" || typeof fileName !== "string" || typeof uti !== "string") {
invalid("resources");
}
return { type, fileName, uti };
});
return { item: item!, modifiedAt, hidden: document.hidden, resources };
}
export function parseChangesOutput(text: string): PhotosChanges {
const document = parseDocument(text);
const token = nullableString(document.token, "change token");
if (typeof document.fullResyncRequired !== "boolean") invalid("changes");
const changed = parseItems(document.changed, "changed items");
if (!Array.isArray(document.deleted) || document.deleted.length > 10_000) invalid("changes");
const deleted = document.deleted.map((id) => {
if (typeof id !== "string" || id === "" || id.length > 200) invalid("deleted ids");
return id;
});
if (!document.fullResyncRequired && token === null) invalid("change token");
return { token, changed, deleted, fullResyncRequired: document.fullResyncRequired };
}
/**
* Besides the shape, the file must be a direct child of the directory the app chose,
* after resolving symlinks, so a confused helper cannot point the upload elsewhere.
*/
export async function parseExportOutput(text: string, destination: string): Promise<PhotosExport> {
const document = parseDocument(text);
const { path: file, fileName, sizeBytes, sha256 } = document;
if (
typeof file !== "string" ||
!path.isAbsolute(file) ||
typeof fileName !== "string" ||
fileName === "" ||
typeof sizeBytes !== "number" ||
!Number.isSafeInteger(sizeBytes) ||
sizeBytes < 0 ||
typeof sha256 !== "string" ||
!/^[0-9a-f]{64}$/.test(sha256)
) {
invalid("export result");
}
let real: string;
let realDestination: string;
try {
[real, realDestination] = await Promise.all([realpath(file), realpath(destination)]);
} catch {
invalid("export path");
}
if (path.dirname(real) !== realDestination || path.basename(real) !== fileName) {
throw new PhotosHelperError("path_rejected", "Photos helper wrote outside the export folder.");
}
return { path: real, fileName, sizeBytes, sha256 };
}
/** The last `{"error":{code,message}}` line on stderr; unknown codes become `failed`. */
export function parseHelperError(stderr: string, exitCode: number | null): PhotosHelperError {
const lines = stderr.split("\n").reverse();
for (const line of lines) {
if (!line.startsWith("{")) continue;
try {
const error = (JSON.parse(line) as { error?: unknown }).error as
| { code?: unknown; message?: unknown }
| undefined;
if (typeof error?.code !== "string") continue;
const code = HELPER_CODES.has(error.code as PhotosHelperErrorCode)
? (error.code as PhotosHelperErrorCode)
: "failed";
const message =
typeof error.message === "string" && error.message.length <= 500
? error.message
: "Photos helper failed.";
return new PhotosHelperError(code, message);
} catch {}
}
return new PhotosHelperError("failed", `Photos helper exited with status ${exitCode ?? "?"}.`);
}
/** `{"progress":0.42}` lines on stderr; anything else is ignored. */
export function parseProgressLine(line: string): number | null {
if (!line.startsWith('{"progress"')) return null;
try {
const value = (JSON.parse(line) as { progress?: unknown }).progress;
return typeof value === "number" && value >= 0 && value <= 1 ? value : null;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Process
// ---------------------------------------------------------------------------
/** The subset of ChildProcess the runner needs, so tests can supply a fake. */
export interface HelperProcess {
stdout: Readable | null;
stderr: Readable | null;
kill(signal?: NodeJS.Signals): boolean;
on(event: "error", listener: (error: Error) => void): unknown;
on(event: "close", listener: (code: number | null) => void): unknown;
}
export type SpawnHelper = (
binary: string,
args: string[],
options: { shell: false; env: Record<string, string>; stdio: ["ignore", "pipe", "pipe"] },
) => HelperProcess;
export interface RunOptions {
signal?: AbortSignal;
/** Hard limit for the whole run. */
timeoutMs: number;
/** Fails when stderr progress stops for this long (exports only). */
idleTimeoutMs?: number;
onProgress?: (fraction: number) => void;
}
const MAX_STDOUT_BYTES = 32 * 1024 * 1024;
const MAX_STDERR_BYTES = 1024 * 1024;
const ENV_ALLOWLIST = ["HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "LC_ALL"];
/** PhotoKit needs the user's home; nothing else from the app environment is passed on. */
export function helperEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const result: Record<string, string> = {};
for (const name of ENV_ALLOWLIST) {
const value = env[name];
if (value) result[name] = value;
}
return result;
}
export const defaultSpawnHelper: SpawnHelper = (binary, args, options) =>
nodeSpawn(binary, args, options);
export function runPhotosHelper(
binary: string,
args: string[],
options: RunOptions & { spawn?: SpawnHelper; env?: NodeJS.ProcessEnv },
): Promise<string> {
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(new PhotosHelperError("cancelled", "Cancelled."));
return;
}
const child = (options.spawn ?? defaultSpawnHelper)(binary, args, {
shell: false,
env: helperEnv(options.env ?? process.env),
stdio: ["ignore", "pipe", "pipe"],
});
const stdout: Buffer[] = [];
let stdoutBytes = 0;
let stderr = "";
let pendingLine = "";
let settled = false;
let idleTimer: NodeJS.Timeout | undefined;
const finish = (error: PhotosHelperError | null, output = "") => {
if (settled) return;
settled = true;
clearTimeout(timer);
clearTimeout(idleTimer);
options.signal?.removeEventListener("abort", abort);
if (error) reject(error);
else resolve(output);
};
const stop = (error: PhotosHelperError) => {
child.kill("SIGTERM");
finish(error);
};
const abort = () => stop(new PhotosHelperError("cancelled", "Cancelled."));
const timer = setTimeout(
() => stop(new PhotosHelperError("timeout", "Photos helper timed out.")),
options.timeoutMs,
);
timer.unref?.();
const armIdle = () => {
if (options.idleTimeoutMs === undefined) return;
clearTimeout(idleTimer);
idleTimer = setTimeout(
() => stop(new PhotosHelperError("timeout", "Photos export stalled.")),
options.idleTimeoutMs,
);
idleTimer.unref?.();
};
armIdle();
options.signal?.addEventListener("abort", abort, { once: true });
child.stdout?.on("data", (chunk: Buffer) => {
stdoutBytes += chunk.byteLength;
if (stdoutBytes > MAX_STDOUT_BYTES) {
stop(new PhotosHelperError("invalid_output", "Photos helper output is too large."));
return;
}
stdout.push(chunk);
});
child.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString("utf8");
if (stderr.length < MAX_STDERR_BYTES) stderr += text;
const lines = (pendingLine + text).split("\n");
pendingLine = lines.pop() ?? "";
for (const line of lines) {
const progress = parseProgressLine(line.trim());
if (progress !== null) {
armIdle();
options.onProgress?.(progress);
}
}
});
child.on("error", () => {
finish(new PhotosHelperError("unavailable", "Photos helper could not be started."));
});
child.on("close", (code) => {
if (code === 0) finish(null, Buffer.concat(stdout).toString("utf8"));
else finish(parseHelperError(stderr + pendingLine, code));
});
});
}
const QUERY_TIMEOUT_MS = 2 * 60_000;
/** The system permission dialog waits on the person. */
const AUTHORIZE_TIMEOUT_MS = 10 * 60_000;
/** Large iCloud videos take long; a stalled download fails sooner through the idle limit. */
const EXPORT_TIMEOUT_MS = 2 * 60 * 60_000;
const EXPORT_IDLE_TIMEOUT_MS = 5 * 60_000;
export interface PhotosHelper {
authorize(prompt: boolean, signal?: AbortSignal): Promise<PhotosAuthorization>;
list(
request: { cursor: string | null; limit: number; since?: string | null },
signal?: AbortSignal,
): Promise<PhotosListPage>;
asset(localIdentifier: string, signal?: AbortSignal): Promise<PhotosAssetDetail>;
exportOriginal(
localIdentifier: string,
destination: string,
options?: { signal?: AbortSignal; onProgress?: (fraction: number) => void },
): Promise<PhotosExport>;
changes(token: string, signal?: AbortSignal): Promise<PhotosChanges>;
}
export function createPhotosHelper(binary: string, spawn?: SpawnHelper): PhotosHelper {
const run = (args: string[], options: RunOptions) =>
runPhotosHelper(binary, args, { ...options, spawn });
return {
async authorize(prompt, signal) {
const timeoutMs = prompt ? AUTHORIZE_TIMEOUT_MS : QUERY_TIMEOUT_MS;
return parseAuthorizeOutput(await run(authorizeArgs(prompt), { signal, timeoutMs }));
},
async list(request, signal) {
const output = await run(listArgs(request), { signal, timeoutMs: QUERY_TIMEOUT_MS });
return parseListOutput(output, request.limit);
},
async asset(localIdentifier, signal) {
const output = await run(assetArgs(localIdentifier), { signal, timeoutMs: QUERY_TIMEOUT_MS });
return parseAssetOutput(output);
},
async exportOriginal(localIdentifier, destination, options = {}) {
const output = await run(exportArgs(localIdentifier, destination), {
signal: options.signal,
onProgress: options.onProgress,
timeoutMs: EXPORT_TIMEOUT_MS,
idleTimeoutMs: EXPORT_IDLE_TIMEOUT_MS,
});
return parseExportOutput(output, destination);
},
async changes(token, signal) {
const output = await run(changesArgs(token), { signal, timeoutMs: QUERY_TIMEOUT_MS });
return parseChangesOutput(output);
},
};
}

View File

@ -0,0 +1,658 @@
import { createHash } from "node:crypto";
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { ClientMediaItem, MediaAsset, MediaUpload } from "@rakazo/contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PhotosHelper } from "./media-photos-helper.js";
import type { MediaSyncApi, PhotosSyncState, PhotosSyncStore } from "./media-photos-sync.js";
import {
createChunkUploader,
createPhotosSyncFileStore,
parsePhotosSyncState,
runPool,
syncApplePhotos,
} from "./media-photos-sync.js";
const sha = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex");
function libraryItem(index: number): ClientMediaItem {
return {
externalId: `ASSET-${String(index).padStart(5, "0")}/L0/001`,
kind: index % 3 === 0 ? "video" : "photo",
capturedAt: new Date(Date.UTC(2024, 0, 1) + index * 60_000).toISOString(),
durationMs: index % 3 === 0 ? 5000 : null,
width: 1920,
height: 1080,
fps: null,
gpsLat: null,
gpsLon: null,
deviceModel: null,
albumNames: [],
isFavorite: false,
fileName: `IMG_${index}.JPG`,
};
}
/** Pages a synthetic library the way the Swift helper does: opaque cursor = next index. */
function fakeHelper(options: { size: number; contents?: (id: string) => string }) {
const library = Array.from({ length: options.size }, (_, index) => libraryItem(index));
const exports: string[] = [];
let active = 0;
const stats = { maxActiveExports: 0 };
const helper: PhotosHelper & { library: ClientMediaItem[] } = {
library,
authorize: vi.fn(async () => "authorized" as const),
list: vi.fn(async ({ cursor, limit }) => {
const start = cursor === null ? 0 : Number(cursor);
const items = library.slice(start, start + limit);
const next = start + limit < library.length ? String(start + limit) : null;
return { items, nextCursor: next, changeToken: `token-${library.length}` };
}),
asset: vi.fn(),
changes: vi.fn(),
exportOriginal: vi.fn(async (id: string, destination: string, opts) => {
active += 1;
stats.maxActiveExports = Math.max(stats.maxActiveExports, active);
try {
await new Promise((resolve) => setTimeout(resolve, 5));
opts?.signal?.throwIfAborted();
const contents = options.contents?.(id) ?? `original bytes of ${id}`;
const file = path.join(destination, "IMG.MOV");
await writeFile(file, contents);
exports.push(destination);
return {
path: file,
fileName: "IMG.MOV",
sizeBytes: Buffer.byteLength(contents),
sha256: sha(contents),
};
} finally {
active -= 1;
}
}),
};
return { helper, exports, stats };
}
function memoryStore(initial?: PhotosSyncState) {
let state: PhotosSyncState = initial ?? { version: 1, deviceId: "device-1", targets: {} };
const writes: PhotosSyncState[] = [];
const store: PhotosSyncStore = {
read: async () => JSON.parse(JSON.stringify(state)) as PhotosSyncState,
write: async (next) => {
state = JSON.parse(JSON.stringify(next)) as PhotosSyncState;
writes.push(state);
},
};
return { store, writes, current: () => state };
}
function asset(id: string, externalId: string | undefined, ingestState: MediaAsset["ingestState"]) {
return {
id,
sourceId: "source-1",
kind: "video",
storage: "remote",
capturedAt: null,
durationMs: null,
width: null,
height: null,
fps: null,
gpsLat: null,
gpsLon: null,
deviceModel: null,
albumNames: [],
isFavorite: false,
ingestState,
hasProxy: false,
hasThumbnail: false,
qualityScore: null,
createdAt: "2024-01-01T00:00:00.000Z",
...(externalId === undefined ? {} : { externalId }),
} as MediaAsset;
}
function fakeApi(
options: {
assets?: MediaAsset[];
chunkBytes?: number;
uploads?: Record<string, MediaUpload>;
} = {},
) {
const uploads = new Map(Object.entries(options.uploads ?? {}));
const pushes: { items: number; syncToken: string | null; complete: boolean }[] = [];
const chunkBytes = options.chunkBytes ?? 4;
const api = {
media: {
sources: {
registerClient: vi.fn(async (input: { deviceId: string }) => ({
id: "source-1",
kind: "apple_photos" as const,
label: "Apple Photos",
status: "idle" as const,
assetCount: 0,
lastSyncedAt: null,
error: null,
createdAt: "2024-01-01T00:00:00.000Z",
deviceId: input.deviceId,
})),
pushItems: vi.fn(
async (input: { items: unknown[]; syncToken: string | null; complete: boolean }) => {
expect(input.items.length).toBeGreaterThan(0);
expect(input.items.length).toBeLessThanOrEqual(500);
pushes.push({
items: input.items.length,
syncToken: input.syncToken,
complete: input.complete,
});
return { created: input.items.length, updated: 0, unchanged: 0 };
},
),
},
assets: {
search: vi.fn(async (input: { cursor?: string | null }) => {
const all = options.assets ?? [];
// Two pages to exercise pagination.
const half = Math.ceil(all.length / 2);
return input.cursor === "p2"
? { items: all.slice(half), nextCursor: null }
: { items: all.slice(0, half), nextCursor: all.length > 1 ? "p2" : null };
}),
},
uploads: {
begin: vi.fn(async (input: { assetId: string; sizeBytes: number }) => {
const upload: MediaUpload = {
id: `upload-${input.assetId}`,
assetId: input.assetId,
sizeBytes: input.sizeBytes,
chunkBytes,
receivedChunks: [],
status: "open",
};
uploads.set(upload.id, upload);
return upload;
}),
status: vi.fn(async (input: { uploadId: string }) => {
const upload = uploads.get(input.uploadId);
if (!upload) throw Object.assign(new Error("Not found"), { code: "NOT_FOUND" });
return upload;
}),
complete: vi.fn(async (input: { uploadId: string }) => {
const upload = uploads.get(input.uploadId)!;
return { ...upload, status: "complete" as const };
}),
},
},
};
return { api: api as unknown as MediaSyncApi & typeof api, pushes };
}
let tempRoot: string;
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(tmpdir(), "rakazo-photos-sync-"));
});
afterEach(async () => {
await rm(tempRoot, { recursive: true, force: true });
});
const baseOptions = () => ({
target: "https://rakazo.example.com#space_1",
label: "Apple Photos",
tempRoot,
signal: new AbortController().signal,
});
describe("Apple Photos metadata sync", () => {
it("registers once, pushes batches of at most 500 and completes the listing", async () => {
const { helper } = fakeHelper({ size: 1203 });
const { api, pushes } = fakeApi();
const { store, current } = memoryStore();
const chunks = vi.fn();
const result = await syncApplePhotos({
...baseOptions(),
helper,
api,
store,
uploadChunk: chunks,
});
expect(api.media.sources.registerClient).toHaveBeenCalledWith(
{ kind: "apple_photos", label: "Apple Photos", deviceId: "device-1" },
expect.anything(),
);
expect(pushes).toEqual([
{ items: 500, syncToken: null, complete: false },
{ items: 500, syncToken: null, complete: false },
{ items: 203, syncToken: "token-1203", complete: true },
]);
expect(result).toMatchObject({ indexed: 1203, created: 1203, uploaded: 0, fullListing: true });
expect(current().targets["https://rakazo.example.com#space_1"]).toEqual({
sourceId: "source-1",
listing: null,
changeToken: "token-1203",
uploads: {},
});
// The next run reads changes only and does not register again.
vi.mocked(helper.changes).mockResolvedValueOnce({
token: "token-next",
changed: [libraryItem(1), libraryItem(2)],
deleted: [],
fullResyncRequired: false,
});
const second = await syncApplePhotos({
...baseOptions(),
helper,
api,
store,
uploadChunk: chunks,
});
expect(api.media.sources.registerClient).toHaveBeenCalledTimes(1);
expect(helper.changes).toHaveBeenCalledWith("token-1203", expect.anything());
expect(pushes.at(-1)).toEqual({ items: 2, syncToken: "token-next", complete: false });
expect(second.fullListing).toBe(false);
expect(current().targets["https://rakazo.example.com#space_1"]?.changeToken).toBe("token-next");
});
it("persists the cursor after every batch and resumes an interrupted listing", async () => {
const { helper } = fakeHelper({ size: 1100 });
const { api, pushes } = fakeApi();
const { store, writes, current } = memoryStore();
vi.mocked(api.media.sources.pushItems).mockImplementationOnce(async (input) => {
pushes.push({
items: input.items.length,
syncToken: input.syncToken,
complete: input.complete,
});
return { created: input.items.length, updated: 0, unchanged: 0 };
});
vi.mocked(api.media.sources.pushItems).mockRejectedValueOnce(new Error("network down"));
await expect(
syncApplePhotos({ ...baseOptions(), helper, api, store, uploadChunk: vi.fn() }),
).rejects.toThrow("network down");
const interrupted = current().targets["https://rakazo.example.com#space_1"]!;
expect(interrupted.listing).toMatchObject({ cursor: "500", token: "token-1100" });
expect(interrupted.listing?.lastItem?.externalId).toBe(libraryItem(499).externalId);
expect(writes.length).toBeGreaterThan(1);
await syncApplePhotos({ ...baseOptions(), helper, api, store, uploadChunk: vi.fn() });
expect(vi.mocked(helper.list).mock.calls.at(-2)?.[0]).toMatchObject({ cursor: "500" });
expect(pushes.slice(1)).toEqual([
{ items: 500, syncToken: null, complete: false },
{ items: 100, syncToken: "token-1100", complete: true },
]);
expect(current().targets["https://rakazo.example.com#space_1"]).toMatchObject({
listing: null,
changeToken: "token-1100",
});
});
it("closes a resumed listing whose last page came back empty", async () => {
const { helper } = fakeHelper({ size: 0 });
const { api, pushes } = fakeApi();
const last = libraryItem(7);
const { store } = memoryStore({
version: 1,
deviceId: "device-1",
targets: {
"https://rakazo.example.com#space_1": {
sourceId: "source-1",
listing: { cursor: "500", token: "token-a", lastItem: last },
changeToken: null,
uploads: {},
},
},
});
await syncApplePhotos({ ...baseOptions(), helper, api, store, uploadChunk: vi.fn() });
expect(pushes).toEqual([{ items: 1, syncToken: "token-a", complete: true }]);
});
it("relists everything when items were deleted or the token expired", async () => {
const { helper } = fakeHelper({ size: 3 });
const { api, pushes } = fakeApi();
const { store } = memoryStore({
version: 1,
deviceId: "device-1",
targets: {
"https://rakazo.example.com#space_1": {
sourceId: "source-1",
listing: null,
changeToken: "old",
uploads: {},
},
},
});
vi.mocked(helper.changes).mockResolvedValueOnce({
token: "new",
changed: [],
deleted: ["gone/L0/001"],
fullResyncRequired: false,
});
const result = await syncApplePhotos({
...baseOptions(),
helper,
api,
store,
uploadChunk: vi.fn(),
});
expect(result.fullListing).toBe(true);
expect(pushes).toEqual([{ items: 3, syncToken: "token-3", complete: true }]);
});
it("registers again when the server no longer knows the source", async () => {
const { helper } = fakeHelper({ size: 2 });
const { api } = fakeApi();
const { store } = memoryStore({
version: 1,
deviceId: "device-1",
targets: {
"https://rakazo.example.com#space_1": {
sourceId: "deleted-source",
listing: { cursor: null, token: null, lastItem: null },
changeToken: null,
uploads: {},
},
},
});
vi.mocked(api.media.sources.pushItems).mockRejectedValueOnce(
Object.assign(new Error("Not found"), { code: "NOT_FOUND" }),
);
await syncApplePhotos({ ...baseOptions(), helper, api, store, uploadChunk: vi.fn() });
expect(api.media.sources.registerClient).toHaveBeenCalledTimes(1);
expect(vi.mocked(api.media.sources.pushItems).mock.calls.at(-1)?.[0].sourceId).toBe("source-1");
});
it("stops before anything is sent when Photos access is denied", async () => {
const { helper } = fakeHelper({ size: 2 });
vi.mocked(helper.authorize).mockResolvedValueOnce("denied");
const { api } = fakeApi();
await expect(
syncApplePhotos({
...baseOptions(),
helper,
api,
store: memoryStore().store,
uploadChunk: vi.fn(),
}),
).rejects.toMatchObject({ code: "not_authorized" });
expect(api.media.sources.registerClient).not.toHaveBeenCalled();
});
});
describe("Apple Photos original upload", () => {
const awaitingAssets = [
asset("a1", "ASSET-00001/L0/001", "awaiting_upload"),
asset("a2", "ASSET-00002/L0/001", "proxied"),
asset("a3", "ASSET-00003/L0/001", "awaiting_upload"),
asset("a4", "ASSET-00004/L0/001", "awaiting_upload"),
];
it("uploads every awaiting original in chunks with two at a time and cleans up", async () => {
const { helper, exports, stats } = fakeHelper({ size: 1 });
const { api } = fakeApi({ assets: awaitingAssets, chunkBytes: 8 });
const sent = new Map<string, Buffer[]>();
const uploadChunk = vi.fn(async (uploadId: string, index: number, bytes: Uint8Array) => {
const parts = sent.get(uploadId) ?? [];
parts[index] = Buffer.from(bytes);
sent.set(uploadId, parts);
});
const progress = vi.fn();
const result = await syncApplePhotos({
...baseOptions(),
helper,
api,
store: memoryStore().store,
uploadChunk,
onProgress: progress,
});
expect(result.uploaded).toBe(3);
expect(
vi
.mocked(helper.exportOriginal)
.mock.calls.map((call) => call[0])
.sort(),
).toEqual(["ASSET-00001/L0/001", "ASSET-00003/L0/001", "ASSET-00004/L0/001"]);
expect(stats.maxActiveExports).toBe(2);
expect(Buffer.concat(sent.get("upload-a1")!).toString()).toBe(
"original bytes of ASSET-00001/L0/001",
);
expect(sent.get("upload-a1")).toHaveLength(Math.ceil(36 / 8));
expect(api.media.uploads.begin).toHaveBeenCalledWith(
expect.objectContaining({
assetId: "a1",
sizeBytes: 36,
sha256: sha("original bytes of ASSET-00001/L0/001"),
}),
expect.anything(),
);
expect(api.media.uploads.complete).toHaveBeenCalledTimes(3);
expect(progress).toHaveBeenLastCalledWith({
phase: "uploading",
indexed: 1,
uploadsDone: 3,
uploadsTotal: 3,
});
for (const dir of exports) await expect(readdir(dir)).rejects.toThrow();
await expect(readdir(tempRoot)).resolves.toEqual([]);
});
it("resumes an open upload and sends only the missing chunks", async () => {
const { helper } = fakeHelper({ size: 1 });
const contents = "original bytes of ASSET-00001/L0/001";
const { api } = fakeApi({
assets: [awaitingAssets[0]!],
uploads: {
"upload-old": {
id: "upload-old",
assetId: "a1",
sizeBytes: Buffer.byteLength(contents),
chunkBytes: 10,
receivedChunks: [0, 2],
status: "open",
},
},
});
const { store, current } = memoryStore({
version: 1,
deviceId: "device-1",
targets: {
"https://rakazo.example.com#space_1": {
sourceId: "source-1",
listing: null,
changeToken: "tok",
uploads: { a1: "upload-old" },
},
},
});
vi.mocked(helper.changes).mockResolvedValueOnce({
token: "tok",
changed: [],
deleted: [],
fullResyncRequired: false,
});
const uploadChunk = vi.fn(async () => {});
await syncApplePhotos({ ...baseOptions(), helper, api, store, uploadChunk });
expect(api.media.uploads.begin).not.toHaveBeenCalled();
expect(
uploadChunk.mock.calls.map((call) => [
call[0],
call[1],
Buffer.from(call[2] as Uint8Array).toString(),
]),
).toEqual([
["upload-old", 1, contents.slice(10, 20)],
["upload-old", 3, contents.slice(30)],
]);
expect(current().targets["https://rakazo.example.com#space_1"]?.uploads).toEqual({});
});
it("aborts on a checksum mismatch without starting an upload", async () => {
const { helper } = fakeHelper({ size: 1 });
vi.mocked(helper.exportOriginal).mockImplementationOnce(async (_id, destination) => {
const file = path.join(destination, "IMG.MOV");
await writeFile(file, "tampered");
return { path: file, fileName: "IMG.MOV", sizeBytes: 8, sha256: sha("original") };
});
const { api } = fakeApi({ assets: [awaitingAssets[0]!] });
const uploadChunk = vi.fn();
await expect(
syncApplePhotos({ ...baseOptions(), helper, api, store: memoryStore().store, uploadChunk }),
).rejects.toMatchObject({ code: "integrity" });
expect(api.media.uploads.begin).not.toHaveBeenCalled();
expect(uploadChunk).not.toHaveBeenCalled();
await expect(readdir(tempRoot)).resolves.toEqual([]);
});
it("cancels a running upload", async () => {
const { helper } = fakeHelper({ size: 1 });
const { api } = fakeApi({ assets: [awaitingAssets[0]!], chunkBytes: 4 });
const controller = new AbortController();
const uploadChunk = vi.fn(
async (_id: string, index: number, _bytes: Uint8Array, signal: AbortSignal) => {
if (index === 1) controller.abort();
signal.throwIfAborted();
},
);
await expect(
syncApplePhotos({
...baseOptions(),
signal: controller.signal,
helper,
api,
store: memoryStore().store,
uploadChunk,
}),
).rejects.toMatchObject({ code: "cancelled" });
expect(uploadChunk).toHaveBeenCalledTimes(2);
expect(api.media.uploads.complete).not.toHaveBeenCalled();
await expect(readdir(tempRoot)).resolves.toEqual([]);
});
it("refuses to guess which Photos item an asset is", async () => {
const { helper } = fakeHelper({ size: 1 });
const { api } = fakeApi({ assets: [asset("a1", undefined, "awaiting_upload")] });
await expect(
syncApplePhotos({
...baseOptions(),
helper,
api,
store: memoryStore().store,
uploadChunk: vi.fn(),
}),
).rejects.toMatchObject({ code: "unsupported_server" });
expect(helper.exportOriginal).not.toHaveBeenCalled();
});
});
describe("Apple Photos sync state file", () => {
it("keeps a stable device id across launches", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "rakazo-photos-state-"));
try {
const store = createPhotosSyncFileStore(dir, () => "device-a");
const first = await store.read();
expect(first.deviceId).toBe("device-a");
first.targets.x = { sourceId: "s", listing: null, changeToken: "t", uploads: {} };
await store.write(first);
const reopened = await createPhotosSyncFileStore(dir, () => "device-b").read();
expect(reopened).toEqual(first);
expect(await readFile(path.join(dir, "media-photos-sync.json"), "utf8")).toContain(
"device-a",
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("drops unreadable state and malformed targets", () => {
expect(parsePhotosSyncState("{")).toBeNull();
expect(
parsePhotosSyncState(JSON.stringify({ version: 2, deviceId: "d", targets: {} })),
).toBeNull();
expect(
parsePhotosSyncState(
JSON.stringify({
version: 1,
deviceId: "d",
targets: {
good: { sourceId: "s", listing: null, changeToken: null, uploads: {} },
bad: { sourceId: 4, listing: null, changeToken: null, uploads: {} },
badListing: { sourceId: "s", listing: { cursor: 3 }, changeToken: null, uploads: {} },
},
}),
)?.targets,
).toEqual({ good: { sourceId: "s", listing: null, changeToken: null, uploads: {} } });
});
});
describe("chunk uploader", () => {
it("PUTs raw bytes with the session and space", async () => {
const fetch = vi.fn(async () => new Response(null, { status: 204 }));
const upload = createChunkUploader({
origin: "https://rakazo.example.com",
spaceId: "space_1",
fetch,
});
const signal = new AbortController().signal;
await upload("up/1", 3, new Uint8Array([1, 2, 3]), signal);
expect(fetch).toHaveBeenCalledWith(
"https://rakazo.example.com/api/media/uploads/up%2F1/chunks/3",
expect.objectContaining({
method: "PUT",
credentials: "include",
redirect: "error",
signal,
headers: { "content-type": "application/octet-stream", "x-rakazo-space-id": "space_1" },
}),
);
});
it("rejects failed chunks and non-origin targets", async () => {
const fetch = vi.fn(async () => new Response(null, { status: 409 }));
const upload = createChunkUploader({
origin: "https://rakazo.example.com",
spaceId: null,
fetch,
});
await expect(
upload("u", 0, new Uint8Array(), new AbortController().signal),
).rejects.toMatchObject({
code: "upload_failed",
});
expect(() =>
createChunkUploader({ origin: "https://rakazo.example.com/rpc", spaceId: null, fetch }),
).toThrow();
expect(() => createChunkUploader({ origin: "file:///etc", spaceId: null, fetch })).toThrow();
});
});
describe("runPool", () => {
it("bounds concurrency and reports the first failure", async () => {
let active = 0;
let max = 0;
const seen: number[] = [];
await expect(
runPool([1, 2, 3, 4, 5], 2, new AbortController().signal, async (item, signal) => {
active += 1;
max = Math.max(max, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
if (item === 2) throw new Error("boom");
signal.throwIfAborted();
seen.push(item);
}),
).rejects.toThrow("boom");
expect(max).toBe(2);
expect(seen).not.toContain(4);
});
});

View File

@ -0,0 +1,600 @@
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdtemp, open, rm, stat } from "node:fs/promises";
import path from "node:path";
import type { ClientMediaItem, MediaAsset, MediaUpload, mediaRpcShapes } from "@rakazo/contracts";
import type { PhotosHelper } from "./media-photos-helper.js";
import { PHOTOS_MAX_PAGE } from "./media-photos-helper.js";
import { readPrivateFile, writePrivateFile } from "./setup-store.js";
/*
* Apple Photos is a client-pushed source: the API may run on another machine, so the
* app never sends a local path. It pushes metadata in batches and uploads the originals
* the server asks for (`awaiting_upload`) in resumable raw-byte chunks.
*/
type Shapes = typeof mediaRpcShapes;
type Shape = { input: { _input: unknown }; output: { _output: unknown } };
/** One oRPC procedure as `ContractRouterClient<AppContract>` exposes it. */
type Procedure<S extends Shape> = (
input: S["input"]["_input"],
options?: { signal?: AbortSignal },
) => Promise<S["output"]["_output"]>;
/** The slice of the app RPC client the sync needs; `rpc` from a contract client satisfies it. */
export interface MediaSyncApi {
media: {
sources: {
registerClient: Procedure<Shapes["sources"]["registerClient"]>;
pushItems: Procedure<Shapes["sources"]["pushItems"]>;
};
assets: { search: Procedure<Shapes["assets"]["search"]> };
uploads: {
begin: Procedure<Shapes["uploads"]["begin"]>;
status: Procedure<Shapes["uploads"]["status"]>;
complete: Procedure<Shapes["uploads"]["complete"]>;
};
};
}
/** Sends one raw chunk to `PUT /api/media/uploads/:uploadId/chunks/:index`. */
export type UploadChunk = (
uploadId: string,
index: number,
bytes: Uint8Array<ArrayBuffer>,
signal: AbortSignal,
) => Promise<void>;
export type PhotosSyncErrorCode =
| "not_authorized"
| "cancelled"
| "integrity"
| "upload_failed"
| "unsupported_server";
export class PhotosSyncError extends Error {
constructor(
readonly code: PhotosSyncErrorCode,
message: string,
) {
super(message);
this.name = "PhotosSyncError";
}
}
// ---------------------------------------------------------------------------
// Persistent state
// ---------------------------------------------------------------------------
export interface PhotosSyncTargetState {
sourceId: string;
/** A full listing in progress: where to resume, the token from its start and the last pushed item. */
listing: { cursor: string | null; token: string | null; lastItem: ClientMediaItem | null } | null;
/** Set after a complete listing; later syncs only read changes since it. */
changeToken: string | null;
/** Open uploads by asset id so an interrupted upload resumes instead of restarting. */
uploads: Record<string, string>;
}
export interface PhotosSyncState {
version: 1;
/** Stable per installation; the server keys the Apple Photos source by it. */
deviceId: string;
/** Keyed by server origin and space, since each has its own source. */
targets: Record<string, PhotosSyncTargetState>;
}
export interface PhotosSyncStore {
read(): Promise<PhotosSyncState>;
write(state: PhotosSyncState): Promise<void>;
}
export const PHOTOS_SYNC_FILE_NAME = "media-photos-sync.json";
const MAX_STATE_BYTES = 4 * 1024 * 1024;
/** Kept next to the desktop setup file, private to the user and written atomically. */
export function createPhotosSyncFileStore(
userDataDir: string,
newDeviceId: () => string = randomUUID,
): PhotosSyncStore {
const file = path.join(userDataDir, PHOTOS_SYNC_FILE_NAME);
return {
async read() {
const raw = await readPrivateFile(file, MAX_STATE_BYTES);
return (raw === null ? null : parsePhotosSyncState(raw)) ?? emptyState(newDeviceId());
},
write: (state) => writePrivateFile(file, `${JSON.stringify(state)}\n`),
};
}
function emptyState(deviceId: string): PhotosSyncState {
return { version: 1, deviceId, targets: {} };
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isNullableString = (value: unknown) => value === null || typeof value === "string";
/** Unreadable state starts over: the server deduplicates by device and external id. */
export function parsePhotosSyncState(raw: string): PhotosSyncState | null {
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
return null;
}
if (
!isRecord(value) ||
value.version !== 1 ||
typeof value.deviceId !== "string" ||
value.deviceId === "" ||
value.deviceId.length > 200 ||
!isRecord(value.targets)
) {
return null;
}
const targets: Record<string, PhotosSyncTargetState> = {};
for (const [key, target] of Object.entries(value.targets)) {
if (
!isRecord(target) ||
typeof target.sourceId !== "string" ||
!isNullableString(target.changeToken) ||
!isRecord(target.uploads) ||
!Object.values(target.uploads).every((id) => typeof id === "string")
) {
continue;
}
const listing = target.listing;
const validListing =
listing === null ||
(isRecord(listing) &&
isNullableString(listing.cursor) &&
isNullableString(listing.token) &&
(listing.lastItem === null || isRecord(listing.lastItem)));
if (!validListing) continue;
targets[key] = target as unknown as PhotosSyncTargetState;
}
return { version: 1, deviceId: value.deviceId, targets };
}
// ---------------------------------------------------------------------------
// Chunk upload over HTTP
// ---------------------------------------------------------------------------
/**
* Chunk PUTs ride the same signed-in session as the app window: the injected `fetch`
* must carry that session's cookies (Electron `session.fetch` with credentials).
*/
export function createChunkUploader(options: {
origin: string;
spaceId: string | null;
fetch: (url: string, init: RequestInit) => Promise<Response>;
}): UploadChunk {
const origin = new URL(options.origin);
if (!["http:", "https:"].includes(origin.protocol) || origin.origin !== options.origin) {
throw new Error("Uploads need the server origin.");
}
return async (uploadId, index, bytes, signal) => {
if (!Number.isSafeInteger(index) || index < 0) throw new Error("Invalid chunk index.");
const headers: Record<string, string> = { "content-type": "application/octet-stream" };
if (options.spaceId) headers["x-rakazo-space-id"] = options.spaceId;
const response = await options.fetch(
`${origin.origin}/api/media/uploads/${encodeURIComponent(uploadId)}/chunks/${index}`,
{
method: "PUT",
headers,
body: bytes,
credentials: "include",
redirect: "error",
signal,
},
);
if (!response.ok) {
throw new PhotosSyncError(
"upload_failed",
`The server rejected an upload (${response.status}).`,
);
}
};
}
// ---------------------------------------------------------------------------
// Sync
// ---------------------------------------------------------------------------
export interface PhotosSyncProgress {
phase: "authorizing" | "indexing" | "uploading";
/** Items pushed in this run. */
indexed: number;
uploadsDone: number;
uploadsTotal: number;
}
export interface PhotosSyncResult {
indexed: number;
created: number;
updated: number;
unchanged: number;
uploaded: number;
/** True when this run listed the whole library instead of reading changes. */
fullListing: boolean;
}
export interface PhotosSyncOptions {
helper: PhotosHelper;
api: MediaSyncApi;
uploadChunk: UploadChunk;
store: PhotosSyncStore;
/** Server origin plus space, e.g. `https://rakazo.example.com#space_1`. */
target: string;
/** Source label shown in the library, e.g. the Mac's name or "Apple Photos". */
label: string;
/** Exports land in a fresh folder below this and are deleted after upload. */
tempRoot: string;
signal: AbortSignal;
onProgress?: (progress: PhotosSyncProgress) => void;
/** At most 500, the contract's batch limit. */
batchSize?: number;
concurrency?: number;
}
const MAX_SYNC_TOKEN_LENGTH = 2000;
export async function syncApplePhotos(options: PhotosSyncOptions): Promise<PhotosSyncResult> {
const { helper, api, signal } = options;
const batchSize = Math.min(Math.max(1, options.batchSize ?? PHOTOS_MAX_PAGE), PHOTOS_MAX_PAGE);
const result: PhotosSyncResult = {
indexed: 0,
created: 0,
updated: 0,
unchanged: 0,
uploaded: 0,
fullListing: false,
};
const progress = { uploadsDone: 0, uploadsTotal: 0 };
const report = (phase: PhotosSyncProgress["phase"]) =>
options.onProgress?.({ phase, indexed: result.indexed, ...progress });
const state = await options.store.read();
// Writes are serialized so concurrent upload workers never interleave partial state.
let saving = Promise.resolve();
const save = () => {
const snapshot = JSON.parse(JSON.stringify(state)) as PhotosSyncState;
saving = saving.then(() => options.store.write(snapshot));
return saving;
};
try {
report("authorizing");
const authorization = await helper.authorize(true, signal);
if (authorization !== "authorized" && authorization !== "limited") {
throw new PhotosSyncError("not_authorized", "Photos access was not granted.");
}
const ensureTarget = async (): Promise<PhotosSyncTargetState> => {
const existing = state.targets[options.target];
if (existing) return existing;
const source = await api.media.sources.registerClient(
{ kind: "apple_photos", label: options.label, deviceId: state.deviceId },
{ signal },
);
const created: PhotosSyncTargetState = {
sourceId: source.id,
listing: null,
changeToken: null,
uploads: {},
};
state.targets[options.target] = created;
await save();
return created;
};
const push = async (
target: PhotosSyncTargetState,
items: ClientMediaItem[],
syncToken: string | null,
complete: boolean,
) => {
const counts = await api.media.sources.pushItems(
{
sourceId: target.sourceId,
items,
syncToken:
syncToken !== null && syncToken.length <= MAX_SYNC_TOKEN_LENGTH ? syncToken : null,
complete,
},
{ signal },
);
result.indexed += items.length;
result.created += counts.created;
result.updated += counts.updated;
result.unchanged += counts.unchanged;
report("indexing");
};
/** Full listing, resumable page by page; the last push says `complete`. */
const listAll = async (target: PhotosSyncTargetState) => {
result.fullListing = true;
target.listing ??= { cursor: null, token: null, lastItem: null };
const listing = target.listing;
while (true) {
const page = await helper.list({ cursor: listing.cursor, limit: batchSize }, signal);
if (listing.cursor === null) listing.token = page.changeToken;
const done = page.nextCursor === null;
// An empty final page (deletions while resuming) still has to close the listing,
// and pushItems needs at least one item: resend the last one, which is a no-op.
const items =
page.items.length > 0 || !done || listing.lastItem === null
? page.items
: [listing.lastItem];
if (items.length > 0) await push(target, items, done ? listing.token : null, done);
if (done) {
target.changeToken = listing.token;
target.listing = null;
await save();
return;
}
listing.cursor = page.nextCursor;
listing.lastItem = items.at(-1) ?? listing.lastItem;
await save();
}
};
const syncMetadata = async () => {
const target = await ensureTarget();
if (target.listing !== null || target.changeToken === null) {
await listAll(target);
return;
}
const changes = await helper.changes(target.changeToken, signal);
// pushItems cannot express deletions; a complete listing lets the server reconcile them.
if (changes.fullResyncRequired || changes.deleted.length > 0 || changes.token === null) {
target.listing = null;
await listAll(target);
return;
}
for (let start = 0; start < changes.changed.length; start += batchSize) {
const batch = changes.changed.slice(start, start + batchSize);
const last = start + batchSize >= changes.changed.length;
await push(target, batch, last ? changes.token : null, false);
}
target.changeToken = changes.token;
await save();
};
report("indexing");
try {
await syncMetadata();
} catch (error) {
// The source was removed on the server: register again and list from scratch.
if (!isNotFound(error) || signal.aborted) throw error;
delete state.targets[options.target];
await save();
await syncMetadata();
}
const target = state.targets[options.target]!;
const awaiting = await awaitingUploads(api, target.sourceId, signal);
progress.uploadsTotal = awaiting.length;
// Forget uploads for assets the server no longer waits for.
const waiting = new Set(awaiting.map((asset) => asset.id));
for (const assetId of Object.keys(target.uploads)) {
if (!waiting.has(assetId)) delete target.uploads[assetId];
}
await save();
report("uploading");
await runPool(awaiting, options.concurrency ?? 2, signal, async (asset, workerSignal) => {
await uploadAsset({ ...options, signal: workerSignal }, target, asset, save);
result.uploaded += 1;
progress.uploadsDone += 1;
report("uploading");
});
return result;
} catch (error) {
if (signal.aborted) throw new PhotosSyncError("cancelled", "Sync cancelled.");
throw error;
} finally {
await saving.catch(() => undefined);
}
}
/**
* Binds the sync to the server and space the request names. `connect` builds the RPC
* client and chunk uploader on the signed-in session for that space.
*/
export function createPhotosSyncRunner(deps: {
helper: PhotosHelper;
store: PhotosSyncStore;
tempRoot: string;
label: string;
connect: (
spaceId: string | null,
) => Promise<{ origin: string; api: MediaSyncApi; uploadChunk: UploadChunk }>;
}) {
return async (request: {
spaceId: string | null;
signal: AbortSignal;
onProgress: (progress: PhotosSyncProgress) => void;
}): Promise<PhotosSyncResult> => {
const { origin, api, uploadChunk } = await deps.connect(request.spaceId);
return syncApplePhotos({
helper: deps.helper,
store: deps.store,
tempRoot: deps.tempRoot,
label: deps.label,
api,
uploadChunk,
target: `${origin}#${request.spaceId ?? "default"}`,
signal: request.signal,
onProgress: request.onProgress,
});
};
}
function isNotFound(error: unknown): boolean {
return isRecord(error) && error.code === "NOT_FOUND";
}
interface AwaitingAsset {
id: string;
externalId: string;
}
/**
* The contract has no "awaiting upload" query, so this pages through the source's
* assets and filters on `ingestState`. Mapping an asset back to a Photos item needs its
* `externalId`, which `MediaAssetSchema` does not expose yet; without it uploads fail
* loudly instead of guessing.
*/
export async function awaitingUploads(
api: MediaSyncApi,
sourceId: string,
signal: AbortSignal,
): Promise<AwaitingAsset[]> {
const awaiting: AwaitingAsset[] = [];
let cursor: string | null = null;
do {
const page: { items: MediaAsset[]; nextCursor: string | null } = await api.media.assets.search(
{ sourceId, cursor, limit: PHOTOS_MAX_PAGE },
{ signal },
);
for (const asset of page.items) {
if (asset.ingestState !== "awaiting_upload" || asset.sourceId !== sourceId) continue;
const externalId = (asset as MediaAsset & { externalId?: unknown }).externalId;
if (typeof externalId !== "string" || externalId === "") {
throw new PhotosSyncError(
"unsupported_server",
"The server does not say which Photos item an asset is.",
);
}
awaiting.push({ id: asset.id, externalId });
}
cursor = page.nextCursor;
} while (cursor !== null);
return awaiting;
}
async function uploadAsset(
options: PhotosSyncOptions,
target: PhotosSyncTargetState,
asset: AwaitingAsset,
save: () => Promise<void>,
): Promise<void> {
const { api, signal } = options;
const directory = await mkdtemp(path.join(options.tempRoot, "rakazo-photos-"));
try {
const exported = await options.helper.exportOriginal(asset.externalId, directory, { signal });
// The helper's hash is checked against the bytes on disk before anything is sent.
const local = await hashFile(exported.path, signal);
if (local.sha256 !== exported.sha256 || local.sizeBytes !== exported.sizeBytes) {
throw new PhotosSyncError("integrity", "The exported file does not match its checksum.");
}
let upload: MediaUpload | null = null;
const previous = target.uploads[asset.id];
if (previous) {
const status = await api.media.uploads
.status({ uploadId: previous }, { signal })
.catch((error: unknown) => {
if (isNotFound(error)) return null;
throw error;
});
if (status?.status === "complete") {
delete target.uploads[asset.id];
await save();
return;
}
if (status?.status === "open" && status.sizeBytes === local.sizeBytes) upload = status;
}
if (upload === null) {
upload = await api.media.uploads.begin(
{
assetId: asset.id,
sizeBytes: local.sizeBytes,
sha256: local.sha256,
fileName: exported.fileName.slice(0, 255),
},
{ signal },
);
target.uploads[asset.id] = upload.id;
await save();
}
const received = new Set(upload.receivedChunks);
const chunkCount = Math.ceil(local.sizeBytes / upload.chunkBytes);
const file = await open(exported.path, "r");
try {
for (let index = 0; index < chunkCount; index += 1) {
if (received.has(index)) continue;
signal.throwIfAborted();
const offset = index * upload.chunkBytes;
const length = Math.min(upload.chunkBytes, local.sizeBytes - offset);
const bytes = Buffer.alloc(length);
const { bytesRead } = await file.read(bytes, 0, length, offset);
if (bytesRead !== length) {
throw new PhotosSyncError("integrity", "The exported file changed during upload.");
}
await options.uploadChunk(upload.id, index, bytes, signal);
}
} finally {
await file.close();
}
const completed = await api.media.uploads.complete({ uploadId: upload.id }, { signal });
if (completed.status !== "complete") {
throw new PhotosSyncError("upload_failed", "The server did not accept the upload.");
}
delete target.uploads[asset.id];
await save();
} finally {
await rm(directory, { recursive: true, force: true });
}
}
export async function hashFile(
file: string,
signal?: AbortSignal,
): Promise<{ sha256: string; sizeBytes: number }> {
const hash = createHash("sha256");
for await (const chunk of createReadStream(file, { signal })) hash.update(chunk as Buffer);
return { sha256: hash.digest("hex"), sizeBytes: (await stat(file)).size };
}
/**
* Runs `work` with at most `limit` items in flight. The first failure aborts the
* others and is the error reported.
*/
export async function runPool<T>(
items: T[],
limit: number,
signal: AbortSignal,
work: (item: T, signal: AbortSignal) => Promise<void>,
): Promise<void> {
const controller = new AbortController();
const forward = () => controller.abort(signal.reason);
signal.addEventListener("abort", forward, { once: true });
if (signal.aborted) forward();
let next = 0;
let failed = false;
let failure: unknown;
const worker = async () => {
while (!failed && !controller.signal.aborted && next < items.length) {
const item = items[next++]!;
try {
await work(item, controller.signal);
} catch (error) {
if (!failed) {
failed = true;
failure = error;
controller.abort(error);
}
}
}
};
try {
await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker));
} finally {
signal.removeEventListener("abort", forward);
}
if (failed) throw failure;
signal.throwIfAborted();
}