555 lines
22 KiB
Swift
555 lines
22 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,
|
|
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 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: nil,
|
|
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 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"
|
|
}
|
|
}
|
|
}
|