macsync/@packages/iphoto/Sources/IPhotoSync/Reader.swift
Natalie 2568866c70 feat(@applications): implement mac-sync identity and photo workflows
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-17 20:27:05 -07:00

557 lines
23 KiB
Swift

import CoreLocation
import Foundation
import GRDB
import Photos
// MARK: - Models
/// Metadata for a single photo asset from the Photos library.
public struct PhotoAsset: Sendable {
public let localIdentifier: String
public let rawKind: Int // 0=image/photo, 1=video, 2=audio
public let rawSubtype: Int // bitmask: 4=screenshot, 8=live, 32=depth, 64k=slow-mo
public let width: Int
public let height: Int
public let fileSize: Int?
public let creationDate: Date?
public let modificationDate: Date?
public let latitude: Double?
public let longitude: Double?
public let isFavorite: Bool
public let isHidden: Bool
public let duration: TimeInterval
public let burstIdentifier: String?
public let originalFilename: String?
public var mediaTypeString: String {
switch rawKind {
case 1: return "video"
case 2: return "audio"
default: return rawSubtype & 8 != 0 ? "live_photo" : "image"
}
}
public var isScreenshot: Bool { rawSubtype & 4 != 0 }
public var isBurst: Bool { burstIdentifier != nil }
// Selfie detection deferred to server-side EXIF analysis.
public var isSelfie: Bool { false }
}
/// Metadata for a Photos library album/collection.
public struct PhotoAlbum: Sendable {
public let localIdentifier: String
public let title: String
public let albumTypeString: String
public let startDate: Date?
public let endDate: Date?
public let assetLocalIdentifiers: [String]
}
// MARK: - Reader
/// Reads metadata and binary data from the macOS Photos library.
///
/// ## Metadata reading strategy: direct SQLite via /bin/cp + GRDB
///
/// PHKit's `PHAsset.fetchAssets` requires a working XPC connection to `photolibraryd`.
/// Ad-hoc signed processes (no Apple Developer cert) cannot authenticate to the
/// `photolibraryd` XPC endpoint on macOS 14+, causing "Unable to send to server;
/// failed after 8 attempts" and an empty result from `fetchAssets`.
///
/// The workaround: copy `Photos.sqlite` + WAL/SHM files to `/tmp/` via `/bin/cp`
/// (bypasses the libswiftDarwin open() interpose), then open the copy with GRDB
/// read-only. The schema uses CoreData naming: `ZASSET` (assets), `ZGENERICALBUM`
/// (albums), `ZADDITIONALASSETATTRIBUTES` (filename + size), etc.
///
/// CoreData timestamps are seconds since January 1, 2001 (reference date).
///
/// ## Binary data: /bin/cat and /bin/cp subprocess workaround
///
/// libswiftDarwin DYLD-interposes `open(2)` across the entire Swift process. When
/// `photolibraryd` is active, `Darwin.open()`, `Data(contentsOf:)`, and `FileManager`
/// reads inside the Photos originals directory hang indefinitely. Spawning `/bin/cat`
/// (images) or `/bin/cp` (videos) as a native C subprocess bypasses this interpose.
/// `Darwin.access()` is not intercepted and is used for existence checks.
public final class PhotosLibraryReader: NSObject, @unchecked Sendable, PHPhotoLibraryChangeObserver {
public static let shared = PhotosLibraryReader()
private var authorizationStatus: PHAuthorizationStatus = .notDetermined
private var libraryReadyContinuation: CheckedContinuation<Void, Never>?
private let readyLock = NSLock()
private var libraryRegistered = false
private override init() {
super.init()
}
// MARK: - PHPhotoLibraryChangeObserver (unused but satisfies protocol)
public func photoLibraryDidChange(_ changeInstance: PHChange) {
readyLock.lock()
defer { readyLock.unlock() }
guard let cont = libraryReadyContinuation else { return }
libraryReadyContinuation = nil
cont.resume()
}
// MARK: - Authorization
/// Register with PHKit for TCC (so Photos access shows in System Settings),
/// then verify the originals directory is directly readable.
///
/// We do NOT use `PHAsset.fetchAssets` see type-level documentation.
///
/// - Returns: `true` when the originals directory is directly readable.
public func requestAuthorization() async -> Bool {
let status = await withCheckedContinuation { continuation in
PHPhotoLibrary.requestAuthorization(for: .readWrite) { s in
continuation.resume(returning: s)
}
}
authorizationStatus = status
NSLog("PhotosReader: PHKit auth status=\(status.rawValue)")
if status == .denied || status == .restricted {
NSLog("PhotosReader: PHKit denied — file reads will use Full Disk Access path")
}
let accessible = isAccessible
NSLog("PhotosReader: direct library access=\(accessible)")
return accessible
}
/// Whether the originals directory is directly readable right now.
public var isAccessible: Bool {
Darwin.access(photosLibraryURL.appendingPathComponent("originals").path, F_OK) == 0
}
/// Diagnostic log of file access state. Fire-and-forget.
public func checkAccessibility() {
let path = photosLibraryURL.appendingPathComponent("originals").path
let ok = Darwin.access(path, F_OK) == 0
NSLog("PhotosReader: originals directory access(2)=\(ok)")
}
// MARK: - Metadata Fetch (direct SQLite)
/// Fetch photo assets by copying Photos.sqlite to temp and querying with GRDB.
public func fetchPhotos(since: Date? = nil) -> [PhotoAsset] {
guard let tempDB = copyPhotosDB() else {
NSLog("PhotosReader: could not copy Photos.sqlite — originals inaccessible or DB missing")
return []
}
defer { cleanupTempDB(tempDB) }
do {
var config = Configuration()
config.readonly = true
let queue = try DatabaseQueue(path: tempDB, configuration: config)
let assets: [PhotoAsset] = try queue.read { db in
var sql = """
SELECT
a.ZUUID,
a.ZKIND,
a.ZKINDSUBTYPE,
a.ZWIDTH,
a.ZHEIGHT,
a.ZDURATION,
a.ZDATECREATED,
a.ZMODIFICATIONDATE,
a.ZLATITUDE,
a.ZLONGITUDE,
a.ZFAVORITE,
a.ZHIDDEN,
a.ZAVALANCHEUUID,
attrs.ZORIGINALFILENAME,
attrs.ZORIGINALFILESIZE
FROM ZASSET a
LEFT JOIN ZADDITIONALASSETATTRIBUTES attrs ON attrs.ZASSET = a.Z_PK
WHERE a.ZTRASHEDSTATE = 0
"""
var args: [DatabaseValueConvertible] = []
if let since {
// CoreData reference date is Jan 1, 2001
let coredataSince = since.timeIntervalSinceReferenceDate
sql += " AND a.ZMODIFICATIONDATE > ?"
args.append(coredataSince)
}
sql += " ORDER BY a.ZDATECREATED DESC"
return try Row.fetchAll(db, sql: sql, arguments: StatementArguments(args)).map { row in
let uuid: String = row["ZUUID"] ?? UUID().uuidString
let kind: Int = row["ZKIND"] ?? 0
let subtype: Int = row["ZKINDSUBTYPE"] ?? 0
let width: Int = row["ZWIDTH"] ?? 0
let height: Int = row["ZHEIGHT"] ?? 0
let duration: Double = row["ZDURATION"] ?? 0
let createdTs: Double? = row["ZDATECREATED"]
let modifiedTs: Double? = row["ZMODIFICATIONDATE"]
let lat: Double? = row["ZLATITUDE"]
let lon: Double? = row["ZLONGITUDE"]
let favorite: Int = row["ZFAVORITE"] ?? 0
let hidden: Int = row["ZHIDDEN"] ?? 0
let avalanche: String? = row["ZAVALANCHEUUID"]
let filename: String? = row["ZORIGINALFILENAME"]
let fileSize: Int? = row["ZORIGINALFILESIZE"]
// -180.0 is Photos' sentinel for "no location"
let hasLocation = (lat != nil && lon != nil && lat! > -179.9 && lon! > -179.9)
return PhotoAsset(
localIdentifier: uuid + "/L0/001",
rawKind: kind,
rawSubtype: subtype,
width: width,
height: height,
fileSize: fileSize,
creationDate: createdTs.map { Date(timeIntervalSinceReferenceDate: $0) },
modificationDate: modifiedTs.map { Date(timeIntervalSinceReferenceDate: $0) },
latitude: hasLocation ? lat : nil,
longitude: hasLocation ? lon : nil,
isFavorite: favorite != 0,
isHidden: hidden != 0,
duration: duration,
burstIdentifier: avalanche,
originalFilename: filename
)
}
}
NSLog("PhotosReader: fetchPhotos count=\(assets.count) (SQLite)")
return assets
} catch {
NSLog("PhotosReader: fetchPhotos SQLite error: \(error)")
return []
}
}
/// Fetch user and smart albums from Photos.sqlite.
public func fetchAlbums() -> [PhotoAlbum] {
guard let tempDB = copyPhotosDB() else { return [] }
defer { cleanupTempDB(tempDB) }
do {
var config = Configuration()
config.readonly = true
let queue = try DatabaseQueue(path: tempDB, configuration: config)
return try queue.read { db in
// Get albums with non-zero asset counts
let albumRows = try Row.fetchAll(db, sql: """
SELECT
a.ZUUID,
a.ZTITLE,
a.ZTRASHEDSTATE,
a.ZCACHEDPHOTOSCOUNT,
a.ZCACHEDCOUNT,
a.ZSTARTDATE,
a.ZENDDATE,
a.ZCUSTOMKEYASSET,
a.ZPARENTFOLDER
FROM ZGENERICALBUM a
WHERE a.ZTRASHEDSTATE = 0
AND a.ZTITLE IS NOT NULL
ORDER BY a.ZTITLE ASC
""")
// Discover junction table once CoreData generates Z_XXAssets per model version
guard let (jTable, albumCol, assetCol) = try discoverAlbumJunctionTable(db) else {
NSLog("PhotosReader: fetchAlbums — no junction table found, skipping album assets")
return []
}
var albums: [PhotoAlbum] = []
for row in albumRows {
guard let albumUUID: String = row["ZUUID"] else { continue }
let title: String = row["ZTITLE"] ?? "Untitled"
let startTs: Double? = row["ZSTARTDATE"]
let endTs: Double? = row["ZENDDATE"]
let assetRows = try Row.fetchAll(db, sql: """
SELECT ZUUID FROM ZASSET
WHERE ZASSET.Z_PK IN (
SELECT \(assetCol) FROM \(jTable)
WHERE \(albumCol) = (
SELECT Z_PK FROM ZGENERICALBUM WHERE ZUUID = ?
)
) AND ZTRASHEDSTATE = 0
""", arguments: [albumUUID])
let assetIds: [String] = assetRows.compactMap { r -> String? in
guard let uuid: String = r["ZUUID"] else { return nil }
return uuid + "/L0/001"
}
guard !assetIds.isEmpty else { continue }
albums.append(PhotoAlbum(
localIdentifier: albumUUID + "/L0/001",
title: title,
albumTypeString: "user",
startDate: startTs.map { Date(timeIntervalSinceReferenceDate: $0) },
endDate: endTs.map { Date(timeIntervalSinceReferenceDate: $0) },
assetLocalIdentifiers: assetIds
))
}
NSLog("PhotosReader: fetchAlbums count=\(albums.count) (SQLite)")
return albums
}
} catch {
NSLog("PhotosReader: fetchAlbums SQLite error: \(error)")
return []
}
}
// MARK: - File Location
/// Locate the original file for a given asset local identifier.
///
/// Photos stores originals at: `originals/<HEX-PREFIX>/<UUID>.<ext>`
/// Returns `nil` when the original has not been downloaded from iCloud.
public func findOriginalFile(uuid: String) -> URL? {
guard let first = uuid.first else { return nil }
let hexPrefix = String(first).uppercased()
let dirPath = photosLibraryURL
.appendingPathComponent("originals/\(hexPrefix)", isDirectory: true)
.path
let extensions = ["heic", "jpeg", "jpg", "heif", "mov", "mp4",
"png", "m4v", "tiff", "tif", "gif",
"dng", "raw", "cr2", "arw", "bmp"]
for ext in extensions {
let path = "\(dirPath)/\(uuid).\(ext)"
if Darwin.access(path, F_OK) == 0 {
return URL(fileURLWithPath: path)
}
}
return nil
}
// MARK: - Binary Data
/// Read image data for an asset via `/bin/cat` subprocess.
public func requestImageData(
localIdentifier: String,
completion: @escaping @Sendable (Data?, String?) -> Void
) {
let uuid = uuidFromLocalIdentifier(localIdentifier)
guard !uuid.isEmpty else { completion(nil, nil); return }
DispatchQueue.global(qos: .userInitiated).async {
guard let fileURL = self.findOriginalFile(uuid: uuid) else {
NSLog("PhotosReader: original not locally available for \(uuid) — iCloud-only or deleted")
completion(nil, nil)
return
}
let mimeType = Self.mimeType(fromExtension: fileURL.pathExtension)
NSLog("PhotosReader: /bin/cat reading \(fileURL.lastPathComponent)")
let task = Process()
task.executableURL = URL(fileURLWithPath: "/bin/cat")
task.arguments = [fileURL.path]
let outPipe = Pipe()
task.standardOutput = outPipe
task.standardError = Pipe()
do { try task.run() } catch {
NSLog("PhotosReader: /bin/cat launch failed for \(uuid): \(error)")
completion(nil, nil)
return
}
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
task.waitUntilExit()
guard task.terminationStatus == 0, !data.isEmpty else {
NSLog("PhotosReader: /bin/cat exit=\(task.terminationStatus) for \(uuid)")
completion(nil, nil)
return
}
NSLog("PhotosReader: read \(data.count) bytes for \(uuid)")
completion(data, mimeType)
}
}
/// Copy a video asset to a temp location via `/bin/cp` subprocess.
public func requestVideoData(
localIdentifier: String,
completion: @escaping @Sendable (URL?, String?) -> Void
) {
let uuid = uuidFromLocalIdentifier(localIdentifier)
guard !uuid.isEmpty else { completion(nil, nil); return }
DispatchQueue.global(qos: .userInitiated).async {
guard let fileURL = self.findOriginalFile(uuid: uuid) else {
NSLog("PhotosReader: video not locally available for \(uuid)")
completion(nil, nil)
return
}
let mimeType = Self.mimeType(fromExtension: fileURL.pathExtension)
let tempURL = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("\(uuid).\(fileURL.pathExtension)")
if let size = try? FileManager.default.attributesOfItem(atPath: tempURL.path)[.size] as? Int,
size > 0 {
completion(tempURL, mimeType)
return
}
let task = Process()
task.executableURL = URL(fileURLWithPath: "/bin/cp")
task.arguments = [fileURL.path, tempURL.path]
task.standardError = Pipe()
do {
try task.run()
task.waitUntilExit()
} catch {
NSLog("PhotosReader: /bin/cp failed for \(uuid): \(error)")
completion(nil, nil)
return
}
guard task.terminationStatus == 0 else {
NSLog("PhotosReader: /bin/cp exit=\(task.terminationStatus) for \(uuid)")
completion(nil, nil)
return
}
NSLog("PhotosReader: video staged to temp for \(uuid)")
completion(tempURL, mimeType)
}
}
/// Remove any temp copy created by `requestVideoData`.
public func cleanupVideoTemp(localIdentifier: String) {
let uuid = uuidFromLocalIdentifier(localIdentifier)
guard !uuid.isEmpty else { return }
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory())
for ext in ["mov", "mp4", "m4v"] {
try? FileManager.default.removeItem(at: tmpDir.appendingPathComponent("\(uuid).\(ext)"))
}
}
// MARK: - Auxiliary Metadata
/// File size (bytes) for an asset's original file, or `nil` if unavailable locally.
public func getAssetFileSize(localIdentifier: String) -> Int? {
let uuid = uuidFromLocalIdentifier(localIdentifier)
guard !uuid.isEmpty, let url = findOriginalFile(uuid: uuid) else { return nil }
return (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? nil
}
/// Original filename for an asset (derived from file on disk, not DB).
public func getOriginalFilename(localIdentifier: String) -> String? {
let uuid = uuidFromLocalIdentifier(localIdentifier)
guard !uuid.isEmpty else { return nil }
return findOriginalFile(uuid: uuid)?.lastPathComponent
}
// MARK: - SQLite helpers
/// Discover the CoreData-generated albumasset junction table name and its column names.
/// CoreData generates names like `Z_30ASSETS` where the number varies by model version.
/// Returns `(tableName, albumColumn, assetColumn)` or `nil` if not found.
private func discoverAlbumJunctionTable(_ db: Database) throws -> (String, String, String)? {
// Dynamic discovery may miss tables only in the WAL, not yet checkpointed
var candidates = (try? String.fetchAll(db, sql: """
SELECT name FROM sqlite_master
WHERE type = 'table' AND name LIKE 'Z_%ASSETS'
ORDER BY name
""")) ?? []
// Fallback: probe known junction table names directly
// (sqlite_master may not show WAL-only tables in read-only mode)
if candidates.isEmpty {
for name in ["Z_30ASSETS", "Z_28ASSETS", "Z_29ASSETS", "Z_31ASSETS", "Z_32ASSETS"] {
if let rows = try? Row.fetchAll(db, sql: "PRAGMA table_info(\(name))"), !rows.isEmpty {
candidates = [name]
break
}
}
}
for tableName in candidates {
guard let rows = try? Row.fetchAll(db, sql: "PRAGMA table_info(\(tableName))"),
!rows.isEmpty else { continue }
let colNames = rows.compactMap { r -> String? in r["name"] }
guard let albumCol = colNames.first(where: { $0.hasSuffix("ALBUMS") }),
let assetCol = colNames.first(where: { $0.hasSuffix("ASSETS") && !$0.hasPrefix("Z_FOK") })
else { continue }
return (tableName, albumCol, assetCol)
}
return nil
}
/// Copy Photos.sqlite + WAL/SHM to a temp path so GRDB can open it without the
/// libswiftDarwin open() interpose blocking. Returns the temp DB path, or nil on failure.
private func copyPhotosDB() -> String? {
let dbPath = photosLibraryURL
.appendingPathComponent("database/Photos.sqlite").path
guard Darwin.access(dbPath, F_OK) == 0 else {
NSLog("PhotosReader: Photos.sqlite not accessible at \(dbPath)")
return nil
}
let tempDB = NSTemporaryDirectory() + "mac-sync-photos-\(ProcessInfo.processInfo.processIdentifier).sqlite"
func cpSubprocess(_ src: String, _ dst: String) -> Bool {
let t = Process()
t.executableURL = URL(fileURLWithPath: "/bin/cp")
t.arguments = [src, dst]
t.standardError = Pipe()
guard (try? t.run()) != nil else { return false }
t.waitUntilExit()
return t.terminationStatus == 0
}
guard cpSubprocess(dbPath, tempDB) else {
NSLog("PhotosReader: failed to copy Photos.sqlite")
return nil
}
// WAL mode: copy -wal and -shm so GRDB sees committed transactions
let walSrc = dbPath + "-wal"
let shmSrc = dbPath + "-shm"
if Darwin.access(walSrc, F_OK) == 0 { _ = cpSubprocess(walSrc, tempDB + "-wal") }
if Darwin.access(shmSrc, F_OK) == 0 { _ = cpSubprocess(shmSrc, tempDB + "-shm") }
return tempDB
}
private func cleanupTempDB(_ path: String) {
try? FileManager.default.removeItem(atPath: path)
try? FileManager.default.removeItem(atPath: path + "-wal")
try? FileManager.default.removeItem(atPath: path + "-shm")
}
// MARK: - Private helpers
private func uuidFromLocalIdentifier(_ localIdentifier: String) -> String {
localIdentifier.components(separatedBy: "/").first ?? ""
}
private var photosLibraryURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Pictures/Photos Library.photoslibrary", isDirectory: true)
}
public static func mimeType(fromExtension ext: String) -> String {
switch ext.lowercased() {
case "jpg", "jpeg": return "image/jpeg"
case "png": return "image/png"
case "heic", "heif": return "image/heic"
case "gif": return "image/gif"
case "tiff", "tif": return "image/tiff"
case "mov": return "video/quicktime"
case "mp4", "m4v": return "video/mp4"
case "avi": return "video/avi"
case "dng": return "image/x-adobe-dng"
default: return "application/octet-stream"
}
}
}