#import #import #import #import #import #import #import #import "photokit_bridge.h" static volatile int photos_cancelled = 0; #define PROGRESS_SLOT_COUNT 16 static export_progress_t progress_slots[PROGRESS_SLOT_COUNT]; static void reset_slot(int slot_index) { if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) { progress_slots[slot_index].active = 0; progress_slots[slot_index].progress = 0; progress_slots[slot_index].bytes_done = 0; progress_slots[slot_index].bytes_total = 0; } } static NSDictionary *make_error_dict(NSString *message) { return @{@"error": message}; } static BOOL semaphore_wait_with_timeout(dispatch_semaphore_t sem, int64_t seconds) { int64_t deadline = (int64_t)[NSDate timeIntervalSinceReferenceDate] + seconds; while (1) { if (photos_cancelled) return NO; int64_t remaining = deadline - (int64_t)[NSDate timeIntervalSinceReferenceDate]; if (remaining <= 0) return NO; int64_t waitSecs = remaining < 1 ? remaining : 1; dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, waitSecs * NSEC_PER_SEC); if (dispatch_semaphore_wait(sem, timeout) == 0) return YES; } } static NSDictionary *collection_to_dict(PHCollection *collection) { NSString *name = collection.localizedTitle ?: @"Untitled"; NSString *identifier = collection.localIdentifier ?: @""; if ([collection isKindOfClass:[PHCollectionList class]]) { PHCollectionList *list = (PHCollectionList *)collection; PHFetchResult *children = [PHCollectionList fetchCollectionsInCollectionList:list options:nil]; NSMutableArray *childList = [NSMutableArray arrayWithCapacity:children.count]; for (NSUInteger i = 0; i < children.count; i++) { NSDictionary *child = collection_to_dict(children[i]); if (child) { [childList addObject:child]; } } return @{ @"id": identifier, @"name": name, @"kind": @"folder", @"children": childList, }; } if ([collection isKindOfClass:[PHAssetCollection class]]) { return @{ @"id": identifier, @"name": name, @"kind": @"album", @"children": @[], }; } return nil; } static char *json_from_object(id obj) { if (!obj) return NULL; NSData *data = [NSJSONSerialization dataWithJSONObject:obj options:0 error:nil]; if (!data) return NULL; const char *utf8 = [data bytes]; size_t len = [data length]; char *copy = (char *)malloc(len + 1); if (!copy) return NULL; memcpy(copy, utf8, len); copy[len] = '\0'; return copy; } static NSData *nsimage_to_jpeg(NSImage *image, CGFloat compression) { NSData *result = nil; NSBitmapImageRep *rep = nil; for (NSImageRep *r in image.representations) { if ([r isKindOfClass:[NSBitmapImageRep class]]) { rep = (NSBitmapImageRep *)r; break; } } if (!rep) { rep = [[NSBitmapImageRep alloc] initWithData:[image TIFFRepresentation]]; } if (rep) { NSDictionary *props = @{NSImageCompressionMethod: @(NSTIFFCompressionJPEG), NSImageCompressionFactor: @(compression)}; result = [rep representationUsingType:NSBitmapImageFileTypeJPEG properties:props]; } return result; } static BOOL ensure_directory(NSString *outputDir) { NSFileManager *fm = [NSFileManager defaultManager]; NSError *dirErr = nil; BOOL ok = [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES attributes:nil error:&dirErr]; if (!ok && dirErr) { NSLog(@"ensure_directory failed: %@", dirErr); } return ok; } static PHFetchResult *fetch_assets_for_collection(PHAssetCollection *album) { PHFetchOptions *opts = [[PHFetchOptions alloc] init]; opts.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:YES]]; return [PHAsset fetchAssetsInAssetCollection:album options:opts]; } static NSString *sanitized_asset_identifier(NSString *assetID) { NSMutableString *safe = [assetID mutableCopy]; NSRange fullRange = NSMakeRange(0, safe.length); [safe replaceOccurrencesOfString:@"/" withString:@"_" options:0 range:fullRange]; [safe replaceOccurrencesOfString:@"\\" withString:@"_" options:0 range:NSMakeRange(0, safe.length)]; return safe; } static NSString *unique_path_for_filename(NSString *outputDir, NSString *filename, NSUInteger index) { NSFileManager *fm = [NSFileManager defaultManager]; NSString *candidate = [outputDir stringByAppendingPathComponent:filename]; if (![fm fileExistsAtPath:candidate]) { return candidate; } NSString *base = [filename stringByDeletingPathExtension]; NSString *ext = [filename pathExtension]; NSString *prefixed = ext.length > 0 ? [NSString stringWithFormat:@"%04lu_%@.%@", (unsigned long)index, base, ext] : [NSString stringWithFormat:@"%04lu_%@", (unsigned long)index, base]; return [outputDir stringByAppendingPathComponent:prefixed]; } static NSString *sanitized_path_component(NSString *name) { NSString *source = name ?: @"Untitled"; NSMutableString *safe = [[source stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] mutableCopy]; if (safe.length == 0) { return @"Untitled"; } NSRange fullRange = NSMakeRange(0, safe.length); [safe replaceOccurrencesOfString:@"/" withString:@"_" options:0 range:fullRange]; [safe replaceOccurrencesOfString:@"\\" withString:@"_" options:0 range:NSMakeRange(0, safe.length)]; return safe; } static BOOL resource_is_locally_available(PHAssetResource *res) { @try { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" SEL sel = @selector(isLocallyAvailable); if ([res respondsToSelector:sel]) { return ((BOOL (*)(id, SEL))objc_msgSend)(res, sel); } #pragma clang diagnostic pop } @catch (NSException *e) {} return NO; } static NSArray *safe_asset_resources(PHAsset *asset) { @try { @autoreleasepool { NSArray *resources = [PHAssetResource assetResourcesForAsset:asset]; return resources ?: @[]; } } @catch (NSException *e) { NSLog(@"photoscli: exception getting resources for asset %@: %@", asset.localIdentifier, e.reason); return @[]; } } static NSString *asset_cloud_status_string_safe(PHAsset *asset) { @try { @autoreleasepool { if (@available(macOS 11, *)) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" SEL sel = @selector(isCloudShared); if ([asset respondsToSelector:sel]) { BOOL isShared = ((BOOL (*)(id, SEL))objc_msgSend)(asset, sel); if (isShared) return @"cloud"; } #pragma clang diagnostic pop } } } @catch (NSException *exception) { NSLog(@"photoscli: exception checking isCloudShared for %@: %@", asset.localIdentifier, exception.reason); } PHCloudIdentifier *cloudId = nil; @try { @autoreleasepool { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" cloudId = [asset performSelector:@selector(cloudIdentifier)]; #pragma clang diagnostic pop } } @catch (NSException *exception) { cloudId = nil; } if (cloudId && ![cloudId isEqual:[NSNull null]]) { return @"cloud"; } BOOL isInCloud = NO; @try { @autoreleasepool { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" SEL sel = @selector(isInCloud); if ([asset respondsToSelector:sel]) { isInCloud = ((BOOL (*)(id, SEL))objc_msgSend)(asset, sel); } #pragma clang diagnostic pop } } @catch (NSException *exception) { isInCloud = NO; } if (isInCloud) { return @"cloud"; } return @"local"; } int photos_request_access(void) { __block PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus]; if (status == PHAuthorizationStatusNotDetermined) { dispatch_semaphore_t sem = dispatch_semaphore_create(0); [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus s) { status = s; dispatch_semaphore_signal(sem); }]; if (!semaphore_wait_with_timeout(sem, 30)) { return -1; } } return (status == PHAuthorizationStatusAuthorized) ? 0 : -1; } char *photos_list_albums_json(void) { PHFetchResult *albums = [PHAssetCollection fetchAssetCollectionsWithType:PHAssetCollectionTypeAlbum subtype:PHAssetCollectionSubtypeAny options:nil]; NSMutableArray *list = [NSMutableArray arrayWithCapacity:albums.count]; for (NSUInteger i = 0; i < albums.count; i++) { PHAssetCollection *album = albums[i]; NSString *title = album.localizedTitle; NSString *lid = album.localIdentifier; [list addObject:@{ @"id": lid ?: @"", @"title": title ?: @"" }]; } return json_from_object(@{@"albums": list}); } static NSString *resource_type_string(NSInteger type) { switch (type) { case 1: return @"photo"; case 2: return @"video"; case 3: return @"audio"; case 4: return @"alternatePhoto"; case 5: return @"fullSizePhoto"; case 6: return @"fullSizeVideo"; case 7: return @"adjustmentData"; case 8: return @"adjustmentBasePhoto"; case 9: return @"pairedVideo"; case 10: return @"fullSizePairedVideo"; case 11: return @"adjustmentBasePairedVideo"; case 12: return @"adjustmentBaseVideo"; default: return [NSString stringWithFormat:@"other(%ld)", (long)type]; } } static NSString *media_type_string(PHAssetMediaType type) { switch (type) { case PHAssetMediaTypeImage: return @"image"; case PHAssetMediaTypeVideo: return @"video"; case PHAssetMediaTypeAudio: return @"audio"; default: return @"unknown"; } } static NSString *source_type_string(PHAssetSourceType type) { NSMutableArray *parts = [NSMutableArray array]; if (type & PHAssetSourceTypeUserLibrary) [parts addObject:@"userLibrary"]; if (type & PHAssetSourceTypeCloudShared) [parts addObject:@"cloudShared"]; if (type & PHAssetSourceTypeiTunesSynced) [parts addObject:@"iTunesSynced"]; return parts.count > 0 ? [parts componentsJoinedByString:@","] : @"unknown"; } static NSString *playback_style_string(PHAssetPlaybackStyle style) { switch (style) { case PHAssetPlaybackStyleImage: return @"image"; case PHAssetPlaybackStyleImageAnimated: return @"imageAnimated"; case PHAssetPlaybackStyleLivePhoto: return @"livePhoto"; case PHAssetPlaybackStyleVideo: return @"video"; case PHAssetPlaybackStyleVideoLooping: return @"videoLooping"; default: return @"unknown"; } } static NSArray *media_subtype_strings(PHAssetMediaSubtype subtypes) { NSMutableArray *parts = [NSMutableArray array]; if (subtypes & PHAssetMediaSubtypePhotoPanorama) [parts addObject:@"photoPanorama"]; if (subtypes & PHAssetMediaSubtypePhotoHDR) [parts addObject:@"photoHDR"]; if (subtypes & PHAssetMediaSubtypePhotoScreenshot) [parts addObject:@"photoScreenshot"]; if (subtypes & PHAssetMediaSubtypePhotoLive) [parts addObject:@"photoLive"]; if (subtypes & PHAssetMediaSubtypePhotoDepthEffect) [parts addObject:@"photoDepthEffect"]; if (subtypes & PHAssetMediaSubtypeVideoStreamed) [parts addObject:@"videoStreamed"]; if (subtypes & PHAssetMediaSubtypeVideoHighFrameRate) [parts addObject:@"videoHighFrameRate"]; if (subtypes & PHAssetMediaSubtypeVideoTimelapse) [parts addObject:@"videoTimelapse"]; return parts; } static NSArray *burst_selection_type_strings(PHAssetBurstSelectionType types) { NSMutableArray *parts = [NSMutableArray array]; if (types & PHAssetBurstSelectionTypeAutoPick) [parts addObject:@"autoPick"]; if (types & PHAssetBurstSelectionTypeUserPick) [parts addObject:@"userPick"]; return parts; } static NSNumber *resource_file_size(PHAssetResource *res) { @try { id value = [res valueForKey:@"fileSize"]; if ([value respondsToSelector:@selector(longLongValue)]) { return @([value longLongValue]); } } @catch (NSException *e) {} return nil; } static NSDictionary *location_dict(CLLocation *location) { if (!location) return nil; NSMutableDictionary *dict = [NSMutableDictionary dictionary]; dict[@"latitude"] = @(location.coordinate.latitude); dict[@"longitude"] = @(location.coordinate.longitude); dict[@"altitude"] = @(location.altitude); dict[@"horizontalAccuracy"] = @(location.horizontalAccuracy); return dict; } static NSDictionary *editing_input_info(PHAsset *asset) { PHContentEditingInputRequestOptions *opts = [[PHContentEditingInputRequestOptions alloc] init]; opts.networkAccessAllowed = NO; dispatch_semaphore_t sem = dispatch_semaphore_create(0); __block NSMutableDictionary *dict = [NSMutableDictionary dictionary]; [asset requestContentEditingInputWithOptions:opts completionHandler:^(PHContentEditingInput *input, NSDictionary *info) { if (input.adjustmentData) { dict[@"formatIdentifier"] = input.adjustmentData.formatIdentifier ?: @""; dict[@"formatVersion"] = input.adjustmentData.formatVersion ?: @""; } NSURL *url = input.fullSizeImageURL; if (url.lastPathComponent.length > 0) { dict[@"baseFilename"] = url.lastPathComponent; } dispatch_semaphore_signal(sem); }]; if (!semaphore_wait_with_timeout(sem, 5)) { return nil; } return dict.count > 0 ? dict : nil; } static NSString *iso8601_string(NSDate *date) { if (!date) return nil; static NSDateFormatter *fmt = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ fmt = [[NSDateFormatter alloc] init]; fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; fmt.dateFormat = @"yyyy-MM-dd'T'HH:mm:ssZ"; }); return [fmt stringFromDate:date]; } #import char *photos_list_assets_json(const char *album_id) { if (!album_id) return json_from_object(make_error_dict(@"album_id is required")); NSString *nsAlbumId = [NSString stringWithUTF8String:album_id]; if (!nsAlbumId) return json_from_object(make_error_dict(@"invalid UTF-8 in album_id")); PHFetchResult *albums = [PHAssetCollection fetchAssetCollectionsWithLocalIdentifiers:@[nsAlbumId] options:nil]; if (albums.count == 0) return json_from_object(make_error_dict(@"album not found")); PHAssetCollection *album = albums.firstObject; PHFetchOptions *opts = [[PHFetchOptions alloc] init]; opts.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:YES]]; PHFetchResult *assets = [PHAsset fetchAssetsInAssetCollection:album options:opts]; NSMutableArray *list = [NSMutableArray arrayWithCapacity:assets.count]; for (NSUInteger i = 0; i < assets.count; i++) { PHAsset *asset = assets[i]; NSString *lid = asset.localIdentifier; NSString *filename = nil; NSArray *resources = safe_asset_resources(asset); if (resources.count > 0) { filename = resources.firstObject.originalFilename; } NSString *cloudStatus = asset_cloud_status_string_safe(asset); NSString *mediaTypeStr = media_type_string(asset.mediaType); NSString *creationDateStr = iso8601_string(asset.creationDate); NSString *modificationDateStr = iso8601_string(asset.modificationDate); NSMutableArray *resourcesList = [NSMutableArray arrayWithCapacity:resources.count]; for (PHAssetResource *res in resources) { NSString *resTypeStr = resource_type_string(res.type); NSString *uti = res.uniformTypeIdentifier ?: @""; BOOL isLocal = resource_is_locally_available(res); NSMutableDictionary *resourceDict = [NSMutableDictionary dictionaryWithDictionary:@{ @"type": resTypeStr, @"filename": res.originalFilename ?: @"", @"uti": uti, @"local": @(isLocal) }]; NSNumber *size = resource_file_size(res); if (size) resourceDict[@"size"] = size; [resourcesList addObject:resourceDict]; } NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithDictionary:@{ @"id": lid ?: @"", @"filename": filename ?: @"", @"cloud": cloudStatus, @"mediaType": mediaTypeStr, @"mediaSubtypes": media_subtype_strings(asset.mediaSubtypes), @"sourceType": source_type_string(asset.sourceType), @"playbackStyle": playback_style_string(asset.playbackStyle), @"pixelWidth": @(asset.pixelWidth), @"pixelHeight": @(asset.pixelHeight), @"duration": @(asset.duration), @"isFavorite": @(asset.isFavorite), @"isHidden": @(asset.isHidden), @"representsBurst": @(asset.representsBurst), @"burstSelectionTypes": burst_selection_type_strings(asset.burstSelectionTypes) }]; if (creationDateStr) { dict[@"creationDate"] = creationDateStr; } if (modificationDateStr) { dict[@"modificationDate"] = modificationDateStr; } NSDictionary *loc = location_dict(asset.location); if (loc) { dict[@"location"] = loc; } if (asset.burstIdentifier.length > 0) { dict[@"burstIdentifier"] = asset.burstIdentifier; } if (@available(macOS 12, *)) { dict[@"hasAdjustments"] = @(asset.hasAdjustments); } NSDictionary *adjustmentInfo = editing_input_info(asset); if (adjustmentInfo) { dict[@"adjustmentInfo"] = adjustmentInfo; } if (resourcesList.count > 0) { dict[@"resources"] = resourcesList; } [list addObject:dict]; } return json_from_object(@{@"assets": list, @"total": @(assets.count)}); } char *photos_reverse_geocode_json(double latitude, double longitude) { if (@available(macOS 26.0, *)) { CLLocation *location = [[CLLocation alloc] initWithLatitude:latitude longitude:longitude]; MKReverseGeocodingRequest *request = [[MKReverseGeocodingRequest alloc] initWithLocation:location]; dispatch_semaphore_t sem = dispatch_semaphore_create(0); __block MKMapItem *item = nil; __block NSError *mapErr = nil; [request getMapItemsWithCompletionHandler:^(NSArray *mapItems, NSError *error) { mapErr = error; item = mapItems.firstObject; dispatch_semaphore_signal(sem); }]; if (!semaphore_wait_with_timeout(sem, 10)) { [request cancel]; return json_from_object(@{@"error": @"timeout waiting for reverse geocode"}); } if (mapErr || !item) { NSString *msg = mapErr.localizedDescription ?: @"reverse geocode failed"; return json_from_object(@{@"error": msg}); } NSMutableDictionary *dict = [NSMutableDictionary dictionary]; if (item.name) dict[@"name"] = item.name; if (item.address.fullAddress) dict[@"formattedAddress"] = item.address.fullAddress; if (item.address.shortAddress) dict[@"thoroughfare"] = item.address.shortAddress; return json_from_object(@{@"placemark": dict}); } return json_from_object(@{@"error": @"reverse geocoding requires macOS 26 or newer"}); } char *photos_list_tree_json(void) { PHFetchResult *collections = [PHCollectionList fetchTopLevelUserCollectionsWithOptions:nil]; NSMutableArray *list = [NSMutableArray arrayWithCapacity:collections.count]; for (NSUInteger i = 0; i < collections.count; i++) { NSDictionary *node = collection_to_dict(collections[i]); if (node) { [list addObject:node]; } } return json_from_object(@{@"collections": list}); } #define RETURN_PREVIEW(x) do { reset_slot(slot_index); return (x); } while(0) char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int quality, int index, int slot_index) { if (!asset_id || !output_dir) RETURN_PREVIEW(json_from_object(make_error_dict(@"asset_id and output_dir required"))); if (target_size <= 0) target_size = 1024; if (quality <= 0 || quality > 100) quality = 85; NSString *nsAssetId = [NSString stringWithUTF8String:asset_id]; NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; if (!nsAssetId || !nsOutputDir) RETURN_PREVIEW(json_from_object(make_error_dict(@"invalid UTF-8 in arguments"))); if (!ensure_directory(nsOutputDir)) { RETURN_PREVIEW(json_from_object(make_error_dict(@"failed to create output directory"))); } PHFetchResult *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil]; if (fetch.count == 0) RETURN_PREVIEW(json_from_object(make_error_dict(@"asset not found"))); PHAsset *asset = fetch.firstObject; if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) { progress_slots[slot_index].active = 1; progress_slots[slot_index].progress = 0; progress_slots[slot_index].bytes_done = 0; progress_slots[slot_index].bytes_total = 0; } PHImageManager *im = [PHImageManager defaultManager]; CGFloat scale = (CGFloat)target_size; CGSize targetCGSize = CGSizeMake(scale, scale); PHImageRequestOptions *imgOpts = [[PHImageRequestOptions alloc] init]; imgOpts.deliveryMode = PHImageRequestOptionsDeliveryModeHighQualityFormat; imgOpts.resizeMode = PHImageRequestOptionsResizeModeExact; imgOpts.networkAccessAllowed = YES; imgOpts.synchronous = YES; imgOpts.progressHandler = ^(double progress, NSError * _Nullable error, BOOL * _Nonnull stop, NSDictionary * _Nullable info) { if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) { progress_slots[slot_index].progress = progress; } }; dispatch_semaphore_t sem = dispatch_semaphore_create(0); __block NSData *imageData = nil; __block NSDictionary *errorInfo = nil; [im requestImageForAsset:asset targetSize:targetCGSize contentMode:PHImageContentModeAspectFit options:imgOpts resultHandler:^(NSImage *result, NSDictionary *info) { if (info[PHImageErrorKey]) { errorInfo = info; dispatch_semaphore_signal(sem); return; } if (result) { CGFloat compression = (CGFloat)quality / 100.0; imageData = nsimage_to_jpeg(result, compression); } dispatch_semaphore_signal(sem); }]; if (!semaphore_wait_with_timeout(sem, 120)) { RETURN_PREVIEW(json_from_object(@{@"error": @"timeout waiting for image", @"cloud": asset_cloud_status_string_safe(asset)})); } if (!imageData) { RETURN_PREVIEW(json_from_object(@{@"error": @"failed to render image", @"cloud": asset_cloud_status_string_safe(asset)})); } NSString *safe = sanitized_asset_identifier(asset.localIdentifier); NSString *filename = [NSString stringWithFormat:@"%04lu_%@.jpg", (unsigned long)index, safe]; NSString *filepath = [nsOutputDir stringByAppendingPathComponent:filename]; NSFileManager *fm = [NSFileManager defaultManager]; NSDictionary *existingAttrs = [fm attributesOfItemAtPath:filepath error:nil]; if (existingAttrs) { NSNumber *existingSize = existingAttrs[NSFileSize]; if (existingSize && existingSize.unsignedLongValue > 0) { RETURN_PREVIEW(json_from_object(@{ @"filename": filename, @"size": existingSize, @"cloud": asset_cloud_status_string_safe(asset), @"skipped": @YES })); } } NSError *writeErr = nil; if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) { NSString *msg = writeErr ? writeErr.localizedDescription : @"unknown write error"; RETURN_PREVIEW(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", msg], @"cloud": asset_cloud_status_string_safe(asset)})); } NSNumber *fileSize = nil; NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:filepath error:nil]; if (attrs) { fileSize = attrs[NSFileSize]; } RETURN_PREVIEW(json_from_object(@{ @"filename": filename, @"size": fileSize ?: @0, @"cloud": asset_cloud_status_string_safe(asset) })); } #undef RETURN_PREVIEW #define RETURN_ORIGINAL(x) do { reset_slot(slot_index); return (x); } while(0) char *photos_export_original_json(const char *asset_id, const char *output_dir, int index, int slot_index) { if (!asset_id || !output_dir) RETURN_ORIGINAL(json_from_object(make_error_dict(@"asset_id and output_dir required"))); NSString *nsAssetId = [NSString stringWithUTF8String:asset_id]; NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; if (!nsAssetId || !nsOutputDir) RETURN_ORIGINAL(json_from_object(make_error_dict(@"invalid UTF-8 in arguments"))); if (!ensure_directory(nsOutputDir)) { RETURN_ORIGINAL(json_from_object(make_error_dict(@"failed to create output directory"))); } PHFetchResult *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil]; if (fetch.count == 0) RETURN_ORIGINAL(json_from_object(make_error_dict(@"asset not found"))); PHAsset *asset = fetch.firstObject; if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) { progress_slots[slot_index].active = 1; progress_slots[slot_index].progress = 0; progress_slots[slot_index].bytes_done = 0; progress_slots[slot_index].bytes_total = 0; } NSArray *resources = safe_asset_resources(asset); if (resources.count == 0) { RETURN_ORIGINAL(json_from_object(@{@"error": @"no resources", @"cloud": asset_cloud_status_string_safe(asset)})); } PHAssetResource *resource = resources.firstObject; NSString *filename = resource.originalFilename; if (!filename || filename.length == 0) { filename = sanitized_asset_identifier(asset.localIdentifier); } NSString *filepath = unique_path_for_filename(nsOutputDir, filename, (NSUInteger)index); NSFileManager *fm = [NSFileManager defaultManager]; { NSString *checkPath = [nsOutputDir stringByAppendingPathComponent:filename]; NSDictionary *existingAttrs = [fm attributesOfItemAtPath:checkPath error:nil]; if (existingAttrs) { NSNumber *existingSize = existingAttrs[NSFileSize]; if (existingSize && existingSize.unsignedLongValue > 0) { RETURN_ORIGINAL(json_from_object(@{ @"filename": filename, @"size": existingSize, @"cloud": asset_cloud_status_string_safe(asset), @"skipped": @YES })); } } } NSURL *fileURL = [NSURL fileURLWithPath:filepath]; if ([fm fileExistsAtPath:filepath]) { [fm removeItemAtPath:filepath error:nil]; } PHAssetResourceManager *rm = [PHAssetResourceManager defaultManager]; PHAssetResourceRequestOptions *opts = [[PHAssetResourceRequestOptions alloc] init]; opts.networkAccessAllowed = YES; opts.progressHandler = ^(double progress) { if (slot_index >= 0 && slot_index < PROGRESS_SLOT_COUNT) { progress_slots[slot_index].progress = progress; } }; dispatch_semaphore_t sem = dispatch_semaphore_create(0); __block NSError *writeErr = nil; [rm writeDataForAssetResource:resource toFile:fileURL options:opts completionHandler:^(NSError * _Nullable error) { writeErr = error; dispatch_semaphore_signal(sem); }]; if (!semaphore_wait_with_timeout(sem, 120)) { RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for resource", @"cloud": asset_cloud_status_string_safe(asset)})); } if (writeErr) { PHImageRequestOptions *imgOpts = [[PHImageRequestOptions alloc] init]; imgOpts.networkAccessAllowed = YES; imgOpts.synchronous = NO; dispatch_semaphore_t sem2 = dispatch_semaphore_create(0); __block NSData *fallbackData = nil; __block NSError *fallbackErr = nil; __block NSString *fallbackUTI = nil; [[PHImageManager defaultManager] requestImageDataAndOrientationForAsset:asset options:imgOpts resultHandler:^(NSData *imageData, NSString *dataUTI, CGImagePropertyOrientation orientation, NSDictionary *info) { if (info[PHImageErrorKey]) { fallbackErr = info[PHImageErrorKey]; } else if (imageData) { fallbackData = imageData; fallbackUTI = dataUTI; } else { fallbackErr = [NSError errorWithDomain:@"photoscli" code:-1 userInfo:@{NSLocalizedDescriptionKey: @"no data"}]; } dispatch_semaphore_signal(sem2); }]; if (!semaphore_wait_with_timeout(sem2, 120)) { RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for image data", @"cloud": asset_cloud_status_string_safe(asset)})); } if (fallbackErr || !fallbackData) { NSString *detail = writeErr.localizedDescription; if (fallbackErr) { detail = [NSString stringWithFormat:@"%@; fallback: %@", detail, fallbackErr.localizedDescription]; } RETURN_ORIGINAL(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", detail], @"cloud": asset_cloud_status_string_safe(asset)})); } NSString *ext = nil; if (fallbackUTI) { UTType *uti = [UTType typeWithIdentifier:fallbackUTI]; if (uti && uti.preferredFilenameExtension) { ext = uti.preferredFilenameExtension; } } if (ext.length == 0) { ext = @"dng"; } NSString *baseFilename = [filename stringByDeletingPathExtension]; if (baseFilename.length == 0) { baseFilename = sanitized_asset_identifier(asset.localIdentifier); } NSString *fallbackFilename = [NSString stringWithFormat:@"%@.%@", baseFilename, ext]; NSString *fallbackPath = [nsOutputDir stringByAppendingPathComponent:fallbackFilename]; NSError *writeFallbackErr = nil; if (![fallbackData writeToFile:fallbackPath options:NSDataWritingAtomic error:&writeFallbackErr]) { RETURN_ORIGINAL(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", writeFallbackErr.localizedDescription], @"cloud": asset_cloud_status_string_safe(asset)})); } NSNumber *fileSize = nil; NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:fallbackPath error:nil]; if (attrs) { fileSize = attrs[NSFileSize]; } RETURN_ORIGINAL(json_from_object(@{ @"filename": fallbackFilename, @"size": fileSize ?: @0, @"cloud": asset_cloud_status_string_safe(asset) })); } NSString *writtenFilename = [filepath lastPathComponent]; NSNumber *fileSize = nil; NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:filepath error:nil]; if (attrs) { fileSize = attrs[NSFileSize]; } RETURN_ORIGINAL(json_from_object(@{ @"filename": writtenFilename, @"size": fileSize ?: @0, @"cloud": asset_cloud_status_string_safe(asset) })); } #undef RETURN_ORIGINAL void photos_free_string(char *value) { if (value) free(value); } void photos_request_cancel(void) { photos_cancelled = 1; } int photos_request_is_cancelled(void) { return photos_cancelled; } export_progress_t *photos_get_progress_slots(void) { return progress_slots; } int photos_get_progress_slot_count(void) { return PROGRESS_SLOT_COUNT; } void photos_reset_progress_slots(void) { memset(progress_slots, 0, sizeof(progress_slots)); }