v0.2.0: semaphore timeouts, error logging, dead code removal, parallel exports

Critical:
- Replace DISPATCH_TIME_FOREVER with 120s/30s timeouts in ObjC
- Log failed asset IDs and error messages in cmdExport/backupTree
- Show failed count in export summaries

Cleanup:
- Remove legacy Bridge methods (ExportAlbumPreviews, ExportAlbumOriginals, BackupAll)
- Remove legacy ObjC functions and C stub equivalents
- Remove photos.go delegates (package-level pass-throughs)
- Remove InterpretExportResult (only used by legacy methods)
- Clean up mockBridge fields (rename Fn2 -> Fn)
- Fix rc race condition in main_main.go (atomic.Int32)
- Remove unused variables (_ = grandTotal, _ = sig)

Design:
- Fix resolveAlbumID: ListAlbums first (cheap), then direct ID
- Unify Cloud type: Asset.Cloud string (was bool)
- Extract shared export logic into exportAssets/exportOne
- Add worker pool for parallel exports (3 workers when assets >= 4)
- Fix backupTree progress bar counter and directory prefix

Robustness:
- Add nil checks for stringWithUTF8String: in ObjC
- Log directory creation errors in ensure_directory (ObjC)

Quality:
- Add go vet and -race flag to Makefile test target
- Add ADR for performSelector cloudIdentifier decision
- Add sync comments between Go/ObjC sanitizePathComponent
- Add package-level doc comment
- Add tests: partial failure, skipped album, album-not-found message
This commit is contained in:
Ein Anderssono
2026-06-11 21:12:47 +02:00
parent b460c68641
commit 85eaa3ea37
14 changed files with 274 additions and 651 deletions
+23 -215
View File
@@ -9,6 +9,11 @@ static NSDictionary *make_error_dict(NSString *message) {
return @{@"error": message};
}
static BOOL semaphore_wait_with_timeout(dispatch_semaphore_t sem, int64_t seconds) {
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, seconds * NSEC_PER_SEC);
return dispatch_semaphore_wait(sem, timeout) == 0;
}
static NSDictionary *collection_to_dict(PHCollection *collection) {
NSString *name = collection.localizedTitle ?: @"Untitled";
NSString *identifier = collection.localIdentifier ?: @"";
@@ -80,8 +85,12 @@ static NSData *nsimage_to_jpeg(NSImage *image, CGFloat compression) {
static BOOL ensure_directory(NSString *outputDir) {
NSFileManager *fm = [NSFileManager defaultManager];
NSError *dirErr = nil;
return [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES
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) {
@@ -91,17 +100,6 @@ static PHFetchResult<PHAsset *> *fetch_assets_for_collection(PHAssetCollection *
return [PHAsset fetchAssetsInAssetCollection:album options:opts];
}
static PHFetchResult<PHAsset *> *fetch_assets_for_album(const char *album_id) {
if (!album_id) return nil;
NSString *nsAlbumId = [NSString stringWithUTF8String:album_id];
PHFetchResult<PHAssetCollection *> *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];
@@ -132,6 +130,7 @@ static NSString *unique_path_for_filename(NSString *outputDir, NSString *filenam
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];
@@ -150,146 +149,6 @@ static NSString *sanitized_path_component(NSString *name) {
return safe;
}
static int export_preview_assets(PHFetchResult<PHAsset *> *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<PHAsset *> *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<PHAssetResource *> *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<PHCollection *> *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<PHAsset *> *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) {
@@ -298,7 +157,9 @@ int photos_request_access(void) {
status = s;
dispatch_semaphore_signal(sem);
}];
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
if (!semaphore_wait_with_timeout(sem, 30)) {
return -1;
}
}
return (status == PHAuthorizationStatusAuthorized) ? 0 : -1;
}
@@ -350,6 +211,7 @@ 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];
@@ -396,66 +258,6 @@ char *photos_list_tree_json(void) {
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<PHAsset *> *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<PHAsset *> *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<PHCollection *> *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"));
@@ -463,6 +265,7 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
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"));
@@ -498,7 +301,9 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
dispatch_semaphore_signal(sem);
}];
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
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)});
@@ -531,6 +336,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
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"));
@@ -564,7 +370,9 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
writeErr = error;
dispatch_semaphore_signal(sem);
}];
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
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)});