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? 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//.` /// 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 album↔asset 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" } } }