diff --git a/apps/desktop/native/photos-helper/.gitignore b/apps/desktop/native/photos-helper/.gitignore new file mode 100644 index 0000000..2d9f16e --- /dev/null +++ b/apps/desktop/native/photos-helper/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/apps/desktop/native/photos-helper/Package.swift b/apps/desktop/native/photos-helper/Package.swift new file mode 100644 index 0000000..ebf6a40 --- /dev/null +++ b/apps/desktop/native/photos-helper/Package.swift @@ -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"]), + ] +) diff --git a/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/Arguments.swift b/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/Arguments.swift new file mode 100644 index 0000000..43c5c8f --- /dev/null +++ b/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/Arguments.swift @@ -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 ] [--limit <1-1000>] [--since ] + photos-helper asset + photos-helper export --dest [--original|--proxy] + photos-helper changes --token + """ + +/// 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 .") + } + 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 .") + } + 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 = [] + private var consumed: Set = [] + private(set) var positionals: [String] = [] + + private static let valueOptions: Set = ["--cursor", "--limit", "--since", "--dest", "--token"] + private static let flagOptions: Set = ["--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.") + } + } +} diff --git a/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/HelperError.swift b/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/HelperError.swift new file mode 100644 index 0000000..6a56ea9 --- /dev/null +++ b/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/HelperError.swift @@ -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 + } + } +} diff --git a/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/Models.swift b/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/Models.swift new file mode 100644 index 0000000..d880086 --- /dev/null +++ b/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/Models.swift @@ -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 + } +} diff --git a/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/Output.swift b/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/Output.swift new file mode 100644 index 0000000..a3f06ed --- /dev/null +++ b/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/Output.swift @@ -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(_ 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) +} diff --git a/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/Paging.swift b/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/Paging.swift new file mode 100644 index 0000000..a8ef97b --- /dev/null +++ b/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/Paging.swift @@ -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":,"id":""}`. +/// 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) +} diff --git a/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/PathGuard.swift b/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/PathGuard.swift new file mode 100644 index 0000000..cea84c6 --- /dev/null +++ b/apps/desktop/native/photos-helper/Sources/PhotosHelperCore/PathGuard.swift @@ -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 +} diff --git a/apps/desktop/native/photos-helper/Sources/photos-helper/Exporter.swift b/apps/desktop/native/photos-helper/Sources/photos-helper/Exporter.swift new file mode 100644 index 0000000..e491c88 --- /dev/null +++ b/apps/desktop/native/photos-helper/Sources/photos-helper/Exporter.swift @@ -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) 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.") +} diff --git a/apps/desktop/native/photos-helper/Sources/photos-helper/Library.swift b/apps/desktop/native/photos-helper/Sources/photos-helper/Library.swift new file mode 100644 index 0000000..b2a6d8f --- /dev/null +++ b/apps/desktop/native/photos-helper/Sources/photos-helper/Library.swift @@ -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() + var deleted = Set() + 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() + 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" + } +} diff --git a/apps/desktop/native/photos-helper/Sources/photos-helper/PhotosHelper.swift b/apps/desktop/native/photos-helper/Sources/photos-helper/PhotosHelper.swift new file mode 100644 index 0000000..53f7029 --- /dev/null +++ b/apps/desktop/native/photos-helper/Sources/photos-helper/PhotosHelper.swift @@ -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.") + } + } +} diff --git a/apps/desktop/native/photos-helper/Tests/PhotosHelperCoreTests/ArgumentsTests.swift b/apps/desktop/native/photos-helper/Tests/PhotosHelperCoreTests/ArgumentsTests.swift new file mode 100644 index 0000000..3bbfba3 --- /dev/null +++ b/apps/desktop/native/photos-helper/Tests/PhotosHelperCoreTests/ArgumentsTests.swift @@ -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"])) + } +} diff --git a/apps/desktop/native/photos-helper/Tests/PhotosHelperCoreTests/OutputTests.swift b/apps/desktop/native/photos-helper/Tests/PhotosHelperCoreTests/OutputTests.swift new file mode 100644 index 0000000..aa393ee --- /dev/null +++ b/apps/desktop/native/photos-helper/Tests/PhotosHelperCoreTests/OutputTests.swift @@ -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) + } +} diff --git a/apps/desktop/native/photos-helper/Tests/PhotosHelperCoreTests/PagingTests.swift b/apps/desktop/native/photos-helper/Tests/PhotosHelperCoreTests/PagingTests.swift new file mode 100644 index 0000000..8248ed2 --- /dev/null +++ b/apps/desktop/native/photos-helper/Tests/PhotosHelperCoreTests/PagingTests.swift @@ -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]) + } +} diff --git a/apps/desktop/native/photos-helper/Tests/PhotosHelperCoreTests/PathGuardTests.swift b/apps/desktop/native/photos-helper/Tests/PhotosHelperCoreTests/PathGuardTests.swift new file mode 100644 index 0000000..6f431c8 --- /dev/null +++ b/apps/desktop/native/photos-helper/Tests/PhotosHelperCoreTests/PathGuardTests.swift @@ -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) + } +} diff --git a/apps/desktop/native/photos-helper/entitlements.mac.plist b/apps/desktop/native/photos-helper/entitlements.mac.plist new file mode 100644 index 0000000..4699854 --- /dev/null +++ b/apps/desktop/native/photos-helper/entitlements.mac.plist @@ -0,0 +1,16 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + com.apple.security.personal-information.photos-library + + + diff --git a/apps/desktop/src/media-ipc.test.ts b/apps/desktop/src/media-ipc.test.ts new file mode 100644 index 0000000..f1d0bf7 --- /dev/null +++ b/apps/desktop/src/media-ipc.test.ts @@ -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 = {}) { + const handlers = new Map(); + 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[0]) => + new Promise((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[0]) => + new Promise((_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", + }); + }); +}); diff --git a/apps/desktop/src/media-ipc.ts b/apps/desktop/src/media-ipc.ts new file mode 100644 index 0000000..bb8e109 --- /dev/null +++ b/apps/desktop/src/media-ipc.ts @@ -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; + /** Asks for Photos access on first use, then indexes and uploads what the server needs. */ + sync: (request: { spaceId: string | null }) => Promise; + cancel: () => Promise; + 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; +} + +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 } | 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." }; +} diff --git a/apps/desktop/src/media-photos-helper.test.ts b/apps/desktop/src/media-photos-helper.test.ts new file mode 100644 index 0000000..513538a --- /dev/null +++ b/apps/desktop/src/media-photos-helper.test.ts @@ -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 = {}) => ({ + 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(() => { + 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" }); + }); +}); diff --git a/apps/desktop/src/media-photos-helper.ts b/apps/desktop/src/media-photos-helper.ts new file mode 100644 index 0000000..f10e402 --- /dev/null +++ b/apps/desktop/src/media-photos-helper.ts @@ -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([ + "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 { + 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; +} + +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([ + "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; + 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 { + 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; 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 { + const result: Record = {}; + 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 { + 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; + list( + request: { cursor: string | null; limit: number; since?: string | null }, + signal?: AbortSignal, + ): Promise; + asset(localIdentifier: string, signal?: AbortSignal): Promise; + exportOriginal( + localIdentifier: string, + destination: string, + options?: { signal?: AbortSignal; onProgress?: (fraction: number) => void }, + ): Promise; + changes(token: string, signal?: AbortSignal): Promise; +} + +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); + }, + }; +} diff --git a/apps/desktop/src/media-photos-sync.test.ts b/apps/desktop/src/media-photos-sync.test.ts new file mode 100644 index 0000000..a1928a8 --- /dev/null +++ b/apps/desktop/src/media-photos-sync.test.ts @@ -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; + } = {}, +) { + 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(); + 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); + }); +}); diff --git a/apps/desktop/src/media-photos-sync.ts b/apps/desktop/src/media-photos-sync.ts new file mode 100644 index 0000000..1c1b8c4 --- /dev/null +++ b/apps/desktop/src/media-photos-sync.ts @@ -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` exposes it. */ +type Procedure = ( + input: S["input"]["_input"], + options?: { signal?: AbortSignal }, +) => Promise; + +/** The slice of the app RPC client the sync needs; `rpc` from a contract client satisfies it. */ +export interface MediaSyncApi { + media: { + sources: { + registerClient: Procedure; + pushItems: Procedure; + }; + assets: { search: Procedure }; + uploads: { + begin: Procedure; + status: Procedure; + complete: Procedure; + }; + }; +} + +/** Sends one raw chunk to `PUT /api/media/uploads/:uploadId/chunks/:index`. */ +export type UploadChunk = ( + uploadId: string, + index: number, + bytes: Uint8Array, + signal: AbortSignal, +) => Promise; + +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; +} + +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; +} + +export interface PhotosSyncStore { + read(): Promise; + write(state: PhotosSyncState): Promise; +} + +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 => + 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 = {}; + 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; +}): 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 = { "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 { + 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 => { + 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 => { + 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 { + 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, +): Promise { + 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( + items: T[], + limit: number, + signal: AbortSignal, + work: (item: T, signal: AbortSignal) => Promise, +): Promise { + 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(); +}