feat(media): Apple Photos helper and client-pushed desktop sync
Adds the macOS photos-helper Swift CLI (PhotoKit listing with stable cursors, persistent change tokens, iCloud-aware original export with a path guard and SHA-256) plus XCTests for its PhotoKit-free core, and the Electron main-process side: a validated helper client, a resumable metadata push and chunked original upload against the media contract, and IPC handlers that stay unavailable off macOS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
This commit is contained in:
parent
7ccc96ac11
commit
d3c6f262ad
2
apps/desktop/native/photos-helper/.gitignore
vendored
Normal file
2
apps/desktop/native/photos-helper/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
18
apps/desktop/native/photos-helper/Package.swift
Normal file
18
apps/desktop/native/photos-helper/Package.swift
Normal 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"]),
|
||||
]
|
||||
)
|
||||
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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.")
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"]))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
16
apps/desktop/native/photos-helper/entitlements.mac.plist
Normal file
16
apps/desktop/native/photos-helper/entitlements.mac.plist
Normal 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>
|
||||
161
apps/desktop/src/media-ipc.test.ts
Normal file
161
apps/desktop/src/media-ipc.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
135
apps/desktop/src/media-ipc.ts
Normal file
135
apps/desktop/src/media-ipc.ts
Normal 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." };
|
||||
}
|
||||
437
apps/desktop/src/media-photos-helper.test.ts
Normal file
437
apps/desktop/src/media-photos-helper.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
517
apps/desktop/src/media-photos-helper.ts
Normal file
517
apps/desktop/src/media-photos-helper.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
658
apps/desktop/src/media-photos-sync.test.ts
Normal file
658
apps/desktop/src/media-photos-sync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
600
apps/desktop/src/media-photos-sync.ts
Normal file
600
apps/desktop/src/media-photos-sync.ts
Normal 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();
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user