#import #import #import #import "photokit_bridge.h" static volatile int photos_cancelled = 0; static NSDictionary *make_error_dict(NSString *message) { return @{@"error": message}; } 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; return [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES attributes:nil error:&dirErr]; } 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 PHFetchResult *fetch_assets_for_album(const char *album_id) { if (!album_id) return nil; NSString *nsAlbumId = [NSString stringWithUTF8String:album_id]; PHFetchResult *albums = [PHAssetCollection fetchAssetCollectionsWithLocalIdentifiers:@[nsAlbumId] options:nil]; if (albums.count == 0) return nil; return fetch_assets_for_collection(albums.firstObject); } 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 int export_preview_assets(PHFetchResult *assets, NSString *outputDir, int target_size) { 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; __block int exported = 0; __block int failed = 0; for (NSUInteger i = 0; i < assets.count; i++) { if (photos_cancelled) break; PHAsset *asset = assets[i]; dispatch_semaphore_t sem = dispatch_semaphore_create(0); __block NSData *imageData = nil; [im requestImageForAsset:asset targetSize:targetCGSize contentMode:PHImageContentModeAspectFit options:imgOpts resultHandler:^(NSImage *result, NSDictionary *info) { if (info[PHImageErrorKey]) { dispatch_semaphore_signal(sem); return; } if (result) { imageData = nsimage_to_jpeg(result, 0.85); } dispatch_semaphore_signal(sem); }]; dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); if (!imageData) { failed++; continue; } NSString *safe = sanitized_asset_identifier(asset.localIdentifier); NSString *filename = [NSString stringWithFormat:@"%04lu_%@.jpg", (unsigned long)i, safe]; NSString *filepath = [outputDir stringByAppendingPathComponent:filename]; NSError *writeErr = nil; if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) { failed++; continue; } exported++; } return (failed > 0 && exported == 0) ? -4 : exported; } static int export_original_assets(PHFetchResult *assets, NSString *outputDir) { PHAssetResourceManager *rm = [PHAssetResourceManager defaultManager]; PHAssetResourceRequestOptions *opts = [[PHAssetResourceRequestOptions alloc] init]; opts.networkAccessAllowed = YES; __block int exported = 0; __block int failed = 0; for (NSUInteger i = 0; i < assets.count; i++) { if (photos_cancelled) break; PHAsset *asset = assets[i]; NSArray *resources = [PHAssetResource assetResourcesForAsset:asset]; if (resources.count == 0) { failed++; continue; } 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(outputDir, filename, i); NSURL *fileURL = [NSURL fileURLWithPath:filepath]; 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); }]; dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); if (writeErr) { failed++; continue; } exported++; } return (failed > 0 && exported == 0) ? -4 : exported; } static int backup_collection(PHCollection *collection, NSString *outputDir, int target_size, BOOL originals) { NSString *path = [outputDir stringByAppendingPathComponent:sanitized_path_component(collection.localizedTitle)]; if ([collection isKindOfClass:[PHCollectionList class]]) { PHCollectionList *list = (PHCollectionList *)collection; PHFetchResult *children = [PHCollectionList fetchCollectionsInCollectionList:list options:nil]; __block int total = 0; __block BOOL failedAny = NO; for (NSUInteger i = 0; i < children.count; i++) { if (photos_cancelled) break; int rc = backup_collection(children[i], path, target_size, originals); if (rc >= 0) { total += rc; } else if (rc == -4) { failedAny = YES; } else { return rc; } } if (failedAny && total == 0) { return -4; } return total; } if ([collection isKindOfClass:[PHAssetCollection class]]) { if (!ensure_directory(path)) { return -2; } PHFetchResult *assets = fetch_assets_for_collection((PHAssetCollection *)collection); return originals ? export_original_assets(assets, path) : export_preview_assets(assets, path, target_size); } return 0; } 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); }]; dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); } 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 *asset_cloud_status_string(PHAsset *asset) { @try { id cloudId = [asset performSelector:@selector(cloudIdentifier)]; if (cloudId && ![cloudId isEqual:[NSNull null]]) { return @"cloud"; } } @catch (NSException *exception) { } PHAssetResource *resource = [PHAssetResource assetResourcesForAsset:asset].firstObject; if (resource) { @try { id locallyAvailable = [resource performSelector:@selector(isLocallyAvailable)]; if (locallyAvailable && [locallyAvailable boolValue]) { return @"local"; } return @"cloud"; } @catch (NSException *exception) { } } return @"local"; } 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]; 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 = [PHAssetResource assetResourcesForAsset:asset]; if (resources.count > 0) { filename = resources.firstObject.originalFilename; } NSString *cloudStatus = asset_cloud_status_string(asset); [list addObject:@{ @"id": lid ?: @"", @"filename": filename ?: @"", @"cloud": cloudStatus }]; } return json_from_object(@{@"assets": list, @"total": @(assets.count)}); } 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}); } int photos_export_album_previews(const char *album_id, const char *output_dir, int target_size) { if (!album_id || !output_dir) return -1; if (target_size <= 0) target_size = 1024; NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; if (!ensure_directory(nsOutputDir)) { return -2; } PHFetchResult *assets = fetch_assets_for_album(album_id); if (!assets) return -3; return export_preview_assets(assets, nsOutputDir, target_size); } int photos_export_album_originals(const char *album_id, const char *output_dir) { if (!album_id || !output_dir) return -1; NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; if (!ensure_directory(nsOutputDir)) { return -2; } PHFetchResult *assets = fetch_assets_for_album(album_id); if (!assets) return -3; return export_original_assets(assets, nsOutputDir); } int photos_backup_all(const char *output_dir, int target_size, int originals) { if (!output_dir) return -1; if (!originals && target_size <= 0) target_size = 1024; NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; if (!ensure_directory(nsOutputDir)) { return -2; } PHFetchResult *collections = [PHCollectionList fetchTopLevelUserCollectionsWithOptions:nil]; __block int total = 0; __block BOOL failedAny = NO; for (NSUInteger i = 0; i < collections.count; i++) { if (photos_cancelled) break; int rc = backup_collection(collections[i], nsOutputDir, target_size, originals != 0); if (rc >= 0) { total += rc; } else if (rc == -4) { failedAny = YES; } else { return rc; } } if (failedAny && total == 0) { return -4; } return total; } char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int index) { if (!asset_id || !output_dir) return json_from_object(make_error_dict(@"asset_id and output_dir required")); if (target_size <= 0) target_size = 1024; NSString *nsAssetId = [NSString stringWithUTF8String:asset_id]; NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; PHFetchResult *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil]; if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found")); PHAsset *asset = fetch.firstObject; 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; 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) { imageData = nsimage_to_jpeg(result, 0.85); } dispatch_semaphore_signal(sem); }]; dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); if (!imageData) { return json_from_object(@{@"error": @"failed to render image", @"cloud": asset_cloud_status_string(asset)}); } NSString *safe = sanitized_asset_identifier(asset.localIdentifier); NSString *filename = [NSString stringWithFormat:@"%04lu_%@.jpg", (unsigned long)index, safe]; NSString *filepath = [nsOutputDir stringByAppendingPathComponent:filename]; NSError *writeErr = nil; if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) { return json_from_object(@{@"error": @"write failed", @"cloud": asset_cloud_status_string(asset)}); } NSNumber *fileSize = nil; NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:filepath error:nil]; if (attrs) { fileSize = attrs[NSFileSize]; } return json_from_object(@{ @"filename": filename, @"size": fileSize ?: @0, @"cloud": asset_cloud_status_string(asset) }); } char *photos_export_original_json(const char *asset_id, const char *output_dir, int index) { if (!asset_id || !output_dir) return json_from_object(make_error_dict(@"asset_id and output_dir required")); NSString *nsAssetId = [NSString stringWithUTF8String:asset_id]; NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; PHFetchResult *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil]; if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found")); PHAsset *asset = fetch.firstObject; NSArray *resources = [PHAssetResource assetResourcesForAsset:asset]; if (resources.count == 0) { return json_from_object(@{@"error": @"no resources", @"cloud": asset_cloud_status_string(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); NSURL *fileURL = [NSURL fileURLWithPath:filepath]; PHAssetResourceManager *rm = [PHAssetResourceManager defaultManager]; PHAssetResourceRequestOptions *opts = [[PHAssetResourceRequestOptions alloc] init]; opts.networkAccessAllowed = YES; 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); }]; dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); if (writeErr) { return json_from_object(@{@"error": @"write failed", @"cloud": asset_cloud_status_string(asset)}); } NSString *writtenFilename = [filepath lastPathComponent]; NSNumber *fileSize = nil; NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:filepath error:nil]; if (attrs) { fileSize = attrs[NSFileSize]; } return json_from_object(@{ @"filename": writtenFilename, @"size": fileSize ?: @0, @"cloud": asset_cloud_status_string(asset) }); } void photos_free_string(char *value) { if (value) free(value); } void photos_request_cancel(void) { photos_cancelled = 1; }