Files
duplicati/Tools/MacOSPhotosNative/DuplicatiPhotos.m
T

568 lines
23 KiB
Objective-C

// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
#import "DuplicatiPhotos.h"
#import <CoreFoundation/CoreFoundation.h>
#import <Foundation/Foundation.h>
#import <Photos/Photos.h>
#include <math.h>
#include <pthread.h>
#include <stdlib.h>
#include <string.h>
// Static variable to control logging, initialized from environment variable
static int g_loggingEnabled = -1;
static pthread_mutex_t g_loggingMutex = PTHREAD_MUTEX_INITIALIZER;
static void DuplicatiPhotosInitializeLogging(void) {
if (g_loggingEnabled == -1) {
g_loggingEnabled = getenv("DEBUG_PHOTOKIT") ? 1 : 0;
}
}
#define DLog(...) do { \
if (g_loggingEnabled == -1) DuplicatiPhotosInitializeLogging(); \
if (g_loggingEnabled) { \
pthread_mutex_lock(&g_loggingMutex); \
NSLog(__VA_ARGS__); \
pthread_mutex_unlock(&g_loggingMutex); \
} \
} while(0)
static char *DuplicatiPhotosCopyCString(NSString *value) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosCopyCString called with value: %@", value ?: @"(null)");
if (!value) {
return NULL;
}
const char *utf8 = [value cStringUsingEncoding:NSUTF8StringEncoding];
if (!utf8) {
return NULL;
}
size_t length = strlen(utf8);
char *copy = malloc(length + 1);
if (!copy) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosCopyCString failed to allocate memory");
return NULL;
}
memcpy(copy, utf8, length);
copy[length] = '\0';
DLog(@"DuplicatiPhotos: DuplicatiPhotosCopyCString returning copy of length %zu", length);
return copy;
}
static char *DuplicatiPhotosCopyError(const char *message) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosCopyError called with message: %s", message ?: "(null)");
if (!message) {
return NULL;
}
size_t length = strlen(message);
char *copy = malloc(length + 1);
if (!copy) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosCopyError failed to allocate memory");
return NULL;
}
memcpy(copy, message, length);
copy[length] = '\0';
DLog(@"DuplicatiPhotos: DuplicatiPhotosCopyError returning copy");
return copy;
}
static char *DuplicatiPhotosCopyErrorFromString(NSString *value) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosCopyErrorFromString called with value: %@", value ?: @"(null)");
if (!value) {
return NULL;
}
return DuplicatiPhotosCopyCString(value);
}
static PHAssetResource *DuplicatiPhotosSelectResource(PHAsset *asset) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosSelectResource called for asset: %@", asset.localIdentifier);
NSArray<PHAssetResource *> *resources = [PHAssetResource assetResourcesForAsset:asset];
DLog(@"DuplicatiPhotos: Found %lu resources for asset", (unsigned long)resources.count);
if (resources.count == 0) {
return nil;
}
PHAssetResource *selected = resources.firstObject;
for (PHAssetResource *candidate in resources) {
PHAssetResourceType type = candidate.type;
DLog(@"DuplicatiPhotos: Evaluating resource type: %ld", (long)type);
if (type == PHAssetResourceTypeFullSizePhoto ||
type == PHAssetResourceTypePhoto ||
type == PHAssetResourceTypeFullSizePairedVideo ||
type == PHAssetResourceTypeVideo ||
type == PHAssetResourceTypeAudio) {
selected = candidate;
DLog(@"DuplicatiPhotos: Selected resource type: %ld", (long)type);
break;
}
}
DLog(@"DuplicatiPhotos: Returning selected resource: %@", selected.originalFilename);
return selected;
}
int DuplicatiPhotosEnumerateAssets(DuplicatiPhotosAssetMetadata **assetsOut, size_t *countOut, char **errorMessageOut) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosEnumerateAssets called");
if (!assetsOut || !countOut) {
DLog(@"DuplicatiPhotos: Invalid arguments to DuplicatiPhotosEnumerateAssets");
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyError("invalid arguments");
}
return -1;
}
*assetsOut = NULL;
*countOut = 0;
if (errorMessageOut) {
*errorMessageOut = NULL;
}
@autoreleasepool {
PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus];
DLog(@"DuplicatiPhotos: Authorization status: %ld", (long)status);
if (status == PHAuthorizationStatusDenied || status == PHAuthorizationStatusRestricted) {
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyError("Photos access denied or restricted");
}
return -1;
} else if (status == PHAuthorizationStatusNotDetermined) {
DLog(@"DuplicatiPhotos: Requesting authorization");
// Request authorization synchronously (blocking)
__block PHAuthorizationStatus newStatus = PHAuthorizationStatusNotDetermined;
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
[PHPhotoLibrary requestAuthorizationForAccessLevel:PHAccessLevelReadWrite handler:^(PHAuthorizationStatus authStatus) {
newStatus = authStatus;
dispatch_semaphore_signal(semaphore);
}];
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
DLog(@"DuplicatiPhotos: Authorization result: %ld", (long)newStatus);
if (newStatus != PHAuthorizationStatusAuthorized) {
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyError("Photos access not granted, go to System Settings to allow access");
}
return -1;
}
}
PHFetchOptions *options = [[PHFetchOptions alloc] init];
PHFetchResult<PHAsset *> *assets = [PHAsset fetchAssetsWithOptions:options];
DLog(@"DuplicatiPhotos: Fetched %lu assets", (unsigned long)assets.count);
if (assets.count == 0) {
return 0;
}
NSMutableArray<NSDictionary *> *results = [NSMutableArray arrayWithCapacity:assets.count];
for (NSUInteger idx = 0; idx < assets.count; idx++) {
PHAsset *asset = [assets objectAtIndex:idx];
PHAssetResource *resource = DuplicatiPhotosSelectResource(asset);
if (!resource) {
DLog(@"DuplicatiPhotos: Asset %lu has no valid resource", (unsigned long)idx);
continue;
}
NSString *identifier = asset.localIdentifier ?: @"";
NSString *filename = resource.originalFilename ?: @"";
NSString *uti = resource.uniformTypeIdentifier;
NSNumber *sizeValue = [resource valueForKey:@"fileSize"];
NSNumber *creation = asset.creationDate ? @([asset.creationDate timeIntervalSince1970]) : nil;
NSNumber *modification = asset.modificationDate ? @([asset.modificationDate timeIntervalSince1970]) : nil;
NSDictionary *entry = @{ @"identifier": identifier,
@"filename": filename,
@"uti": uti ?: (id)[NSNull null],
@"size": sizeValue ?: (id)[NSNull null],
@"mediaType": @(asset.mediaType),
@"pixelWidth": @(asset.pixelWidth),
@"pixelHeight": @(asset.pixelHeight),
@"creation": creation ?: (id)[NSNull null],
@"modification": modification ?: (id)[NSNull null] };
[results addObject:entry];
}
DLog(@"DuplicatiPhotos: After filtering, %lu assets with resources", (unsigned long)results.count);
if (results.count == 0) {
return 0;
}
DLog(@"DuplicatiPhotos: Allocating buffer for %lu assets, size per asset: %zu", (unsigned long)results.count, sizeof(DuplicatiPhotosAssetMetadata));
DuplicatiPhotosAssetMetadata *buffer = calloc(results.count, sizeof(DuplicatiPhotosAssetMetadata));
if (!buffer) {
DLog(@"DuplicatiPhotos: Failed to allocate buffer");
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyError("out of memory");
}
return -1;
}
DLog(@"DuplicatiPhotos: Buffer allocated successfully at %p", buffer);
for (NSUInteger idx = 0; idx < results.count; idx++) {
DLog(@"DuplicatiPhotos: Processing asset %lu of %lu", (unsigned long)idx, (unsigned long)results.count);
NSDictionary *entry = results[idx];
DLog(@"DuplicatiPhotos: Copying identifier for asset %lu", (unsigned long)idx);
buffer[idx].identifier = DuplicatiPhotosCopyCString(entry[@"identifier"]);
DLog(@"DuplicatiPhotos: Copying filename for asset %lu", (unsigned long)idx);
buffer[idx].filename = DuplicatiPhotosCopyCString(entry[@"filename"]);
DLog(@"DuplicatiPhotos: Processing UTI for asset %lu", (unsigned long)idx);
id utiValue = entry[@"uti"];
buffer[idx].uti = (utiValue && utiValue != [NSNull null]) ? DuplicatiPhotosCopyCString(utiValue) : NULL;
DLog(@"DuplicatiPhotos: Processing size for asset %lu", (unsigned long)idx);
id sizeValue = entry[@"size"];
buffer[idx].size = (sizeValue && sizeValue != [NSNull null]) ? [sizeValue longLongValue] : -1;
DLog(@"DuplicatiPhotos: Processing mediaType for asset %lu", (unsigned long)idx);
buffer[idx].mediaType = [entry[@"mediaType"] intValue];
DLog(@"DuplicatiPhotos: Processing dimensions for asset %lu", (unsigned long)idx);
buffer[idx].pixelWidth = [entry[@"pixelWidth"] intValue];
buffer[idx].pixelHeight = [entry[@"pixelHeight"] intValue];
DLog(@"DuplicatiPhotos: Processing creation date for asset %lu", (unsigned long)idx);
id creation = entry[@"creation"];
buffer[idx].creationDateSeconds = (creation && creation != [NSNull null]) ? [creation doubleValue] : NAN;
DLog(@"DuplicatiPhotos: Processing modification date for asset %lu", (unsigned long)idx);
id modification = entry[@"modification"];
buffer[idx].modificationDateSeconds = (modification && modification != [NSNull null]) ? [modification doubleValue] : NAN;
DLog(@"DuplicatiPhotos: Completed processing asset %lu", (unsigned long)idx);
}
DLog(@"DuplicatiPhotos: All assets processed, setting output parameters");
*assetsOut = buffer;
*countOut = results.count;
DLog(@"DuplicatiPhotos: Returning success with %lu assets", (unsigned long)results.count);
return 0;
}
}
void DuplicatiPhotosFreeAssets(DuplicatiPhotosAssetMetadata *assets, size_t count) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosFreeAssets called with count: %zu", count);
if (!assets) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosFreeAssets called with NULL assets");
return;
}
for (size_t idx = 0; idx < count; idx++) {
if (assets[idx].identifier) {
free(assets[idx].identifier);
}
if (assets[idx].filename) {
free(assets[idx].filename);
}
if (assets[idx].uti) {
free(assets[idx].uti);
}
}
free(assets);
}
@interface DuplicatiPhotosReader : NSObject
@property (nonatomic, strong) NSMutableData *buffer;
@property (nonatomic, strong) NSCondition *condition;
@property (nonatomic, assign) BOOL completed;
@property (nonatomic, assign) BOOL cancelled;
@property (nonatomic, strong) NSError *error;
@property (nonatomic, assign) PHAssetResourceDataRequestID requestId;
@property (nonatomic, strong) PHAssetResource *resource;
@end
@implementation DuplicatiPhotosReader
- (instancetype)initWithResource:(PHAssetResource *)resource {
DLog(@"DuplicatiPhotos: DuplicatiPhotosReader initWithResource called for: %@", resource.originalFilename);
self = [super init];
if (self) {
_resource = resource;
_buffer = [NSMutableData data];
_condition = [[NSCondition alloc] init];
_completed = NO;
_cancelled = NO;
_requestId = PHInvalidAssetResourceDataRequestID;
PHAssetResourceRequestOptions *options = [[PHAssetResourceRequestOptions alloc] init];
options.networkAccessAllowed = YES;
DLog(@"DuplicatiPhotos: Starting data request for resource: %@", resource.originalFilename);
__weak typeof(self) weakSelf = self;
PHAssetResourceManager *manager = [PHAssetResourceManager defaultManager];
_requestId = [manager requestDataForAssetResource:resource
options:options
dataReceivedHandler:^(NSData * _Nonnull data) {
@autoreleasepool {
DLog(@"DuplicatiPhotos: Data received handler called on thread %@", [NSThread currentThread]);
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf || !data || strongSelf.cancelled) {
DLog(@"DuplicatiPhotos: Data received handler skipped (cancelled or invalid)");
return;
}
DLog(@"DuplicatiPhotos: Received data chunk of size: %lu", (unsigned long)data.length);
[strongSelf.condition lock];
DLog(@"DuplicatiPhotos: Locked condition, current buffer size: %lu, appending data", (unsigned long)strongSelf.buffer.length);
[strongSelf.buffer appendData:data];
DLog(@"DuplicatiPhotos: Data appended, new buffer size: %lu", (unsigned long)strongSelf.buffer.length);
[strongSelf.condition signal];
DLog(@"DuplicatiPhotos: Signaled condition, unlocking");
[strongSelf.condition unlock];
DLog(@"DuplicatiPhotos: Data received handler completed");
}
}
completionHandler:^(NSError * _Nullable error) {
@autoreleasepool {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf)
return;
if (error) {
DLog(@"DuplicatiPhotos: Completion handler called with error: %@", error.localizedDescription);
} else {
DLog(@"DuplicatiPhotos: Completion handler called successfully");
}
[strongSelf.condition lock];
strongSelf.completed = YES;
strongSelf.error = error;
[strongSelf.condition broadcast];
[strongSelf.condition unlock];
}
}];
}
return self;
}
- (void)dealloc {
DLog(@"DuplicatiPhotos: DuplicatiPhotosReader dealloc called");
[self close];
}
- (void)close {
DLog(@"DuplicatiPhotos: DuplicatiPhotosReader close called");
if (self.cancelled) {
DLog(@"DuplicatiPhotos: Already cancelled, skipping close");
return;
}
self.cancelled = YES;
if (self.requestId != PHInvalidAssetResourceDataRequestID)
{
PHAssetResourceManager *manager = [PHAssetResourceManager defaultManager];
[manager cancelDataRequest:self.requestId];
}
[self.condition lock];
self.completed = YES;
[self.condition broadcast];
[self.condition unlock];
}
- (ssize_t)readInto:(uint8_t *)destination length:(size_t)length error:(char **)errorMessageOut {
DLog(@"DuplicatiPhotos: readInto called with length: %zu", length);
if (!destination) {
DLog(@"DuplicatiPhotos: Invalid destination buffer");
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyError("invalid buffer");
}
return -1;
}
if (length == 0) {
DLog(@"DuplicatiPhotos: Zero length read requested");
return 0;
}
DLog(@"DuplicatiPhotos: Locking condition for read on thread %@", [NSThread currentThread]);
[self.condition lock];
DLog(@"DuplicatiPhotos: Condition locked, buffer length: %lu, completed: %d", (unsigned long)self.buffer.length, self.completed);
while (self.buffer.length == 0 && !self.completed) {
DLog(@"DuplicatiPhotos: Waiting for data...");
[self.condition wait];
DLog(@"DuplicatiPhotos: Woke up from wait, buffer length: %lu, completed: %d", (unsigned long)self.buffer.length, self.completed);
}
if (self.buffer.length == 0) {
DLog(@"DuplicatiPhotos: Buffer is empty after wait");
NSError *error = self.error;
[self.condition unlock];
if (error) {
DLog(@"DuplicatiPhotos: Returning error: %@", error.localizedDescription);
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyErrorFromString(error.localizedDescription ?: @"read error");
}
return -1;
}
DLog(@"DuplicatiPhotos: No error, returning EOF");
return 0;
}
size_t toCopy = MIN((size_t)self.buffer.length, length);
DLog(@"DuplicatiPhotos: Copying %zu bytes to destination from buffer of size %lu", toCopy, (unsigned long)self.buffer.length);
memcpy(destination, self.buffer.bytes, toCopy);
DLog(@"DuplicatiPhotos: memcpy completed, removing copied bytes from buffer");
[self.buffer replaceBytesInRange:NSMakeRange(0, toCopy) withBytes:NULL length:0];
DLog(@"DuplicatiPhotos: Buffer updated, new size: %lu, unlocking", (unsigned long)self.buffer.length);
[self.condition unlock];
DLog(@"DuplicatiPhotos: Returning %zd bytes", (ssize_t)toCopy);
return (ssize_t)toCopy;
}
@end
int DuplicatiPhotosOpenAsset(const char *identifier, void **handleOut, char **errorMessageOut) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosOpenAsset called with identifier: %s", identifier ?: "(null)");
if (!identifier || !handleOut) {
DLog(@"DuplicatiPhotos: Invalid arguments to DuplicatiPhotosOpenAsset");
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyError("invalid arguments");
}
return -1;
}
*handleOut = NULL;
if (errorMessageOut) {
*errorMessageOut = NULL;
}
@autoreleasepool {
NSString *identifierString = [NSString stringWithUTF8String:identifier];
if (!identifierString) {
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyError("invalid identifier");
}
return -1;
}
PHFetchResult<PHAsset *> *fetchResult = [PHAsset fetchAssetsWithLocalIdentifiers:@[identifierString] options:nil];
if (fetchResult.count == 0) {
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyError("asset not found");
}
return -1;
}
PHAsset *asset = [fetchResult objectAtIndex:0];
PHAssetResource *resource = DuplicatiPhotosSelectResource(asset);
if (!resource) {
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyError("asset resource unavailable");
}
return -1;
}
DuplicatiPhotosReader *reader = [[DuplicatiPhotosReader alloc] initWithResource:resource];
if (!reader) {
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyError("unable to create reader");
}
return -1;
}
*handleOut = (void *)CFBridgingRetain(reader);
return 0;
}
}
ssize_t DuplicatiPhotosReadAsset(void *handle, uint8_t *buffer, size_t bufferLength, char **errorMessageOut) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosReadAsset called with bufferLength: %zu", bufferLength);
if (!handle) {
DLog(@"DuplicatiPhotos: Invalid handle to DuplicatiPhotosReadAsset");
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyError("invalid handle");
}
return -1;
}
DuplicatiPhotosReader *reader = (__bridge DuplicatiPhotosReader *)handle;
ssize_t result = [reader readInto:buffer length:bufferLength error:errorMessageOut];
DLog(@"DuplicatiPhotos: DuplicatiPhotosReadAsset returning: %zd", result);
return result;
}
int DuplicatiPhotosGetAssetSize(void *handle, int64_t *sizeOut, char **errorMessageOut) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosGetAssetSize called");
if (!handle || !sizeOut) {
DLog(@"DuplicatiPhotos: Invalid arguments to DuplicatiPhotosGetAssetSize");
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyError("invalid arguments");
}
return -1;
}
if (errorMessageOut) {
*errorMessageOut = NULL;
}
@autoreleasepool {
DuplicatiPhotosReader *reader = (__bridge DuplicatiPhotosReader *)handle;
PHAssetResource *resource = reader.resource;
if (!resource) {
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyError("resource not available");
}
return -1;
}
NSNumber *sizeValue = [resource valueForKey:@"fileSize"];
if (!sizeValue) {
if (errorMessageOut) {
*errorMessageOut = DuplicatiPhotosCopyError("size not available");
}
return -1;
}
*sizeOut = [sizeValue longLongValue];
DLog(@"DuplicatiPhotos: DuplicatiPhotosGetAssetSize returning size: %lld", *sizeOut);
return 0;
}
}
void DuplicatiPhotosCloseAsset(void *handle) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosCloseAsset called");
if (!handle) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosCloseAsset called with NULL handle");
return;
}
DuplicatiPhotosReader *reader = (__bridge_transfer DuplicatiPhotosReader *)handle;
[reader close];
}
void DuplicatiPhotosFreeString(char *value) {
DLog(@"DuplicatiPhotos: DuplicatiPhotosFreeString called");
if (value) {
free(value);
}
}