#import #import #import #import "photokit_bridge.h" static volatile int photos_cancelled = 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]; } // Must stay in sync with sanitizePathComponent in cmd/photoscli/main.go 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; } 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 *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]; 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 = [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}); } 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]; if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments")); if (!ensure_directory(nsOutputDir)) { return json_from_object(make_error_dict(@"failed to create output directory")); } 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); }]; if (!semaphore_wait_with_timeout(sem, 120)) { return json_from_object(@{@"error": @"timeout waiting for image", @"cloud": asset_cloud_status_string(asset)}); } 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]) { NSString *msg = writeErr ? writeErr.localizedDescription : @"unknown write error"; return json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", msg], @"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]; if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments")); if (!ensure_directory(nsOutputDir)) { return json_from_object(make_error_dict(@"failed to create output directory")); } 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); }]; if (!semaphore_wait_with_timeout(sem, 120)) { return json_from_object(@{@"error": @"timeout waiting for resource", @"cloud": asset_cloud_status_string(asset)}); } if (writeErr) { return json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", writeErr.localizedDescription], @"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; }