Files
photocli/bridge/photokit_bridge.m
T
Ein Anderssono b2d4c6188d fix: make Ctrl+C cancel ObjC semaphore waits within ~1s
semaphore_wait_with_timeout now polls photos_cancelled every second
instead of blocking for the full timeout duration
2026-06-11 21:24:03 +02:00

409 lines
16 KiB
Objective-C

#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
#import <Photos/Photos.h>
#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<PHCollection *> *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<PHAsset *> *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<PHAssetCollection *> *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<PHAssetCollection *> *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<PHAsset *> *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<PHAssetResource *> *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<PHCollection *> *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"));
PHFetchResult<PHAsset *> *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]) {
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];
if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments"));
PHFetchResult<PHAsset *> *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<PHAssetResource *> *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": @"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;
}