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
View File
@@ -10,11 +10,8 @@ type Bridge interface {
ListAlbums() ([]Album, error)
ListAssets(albumID string) ([]Asset, int, error)
ListTree() ([]CollectionNode, error)
ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error)
ExportAlbumOriginals(albumID, outputDir string) (int, error)
ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error)
ExportOriginal(assetID, outputDir string, index int) (ExportResult, error)
BackupAll(outputDir string, targetSize int, originals bool) (int, error)
Cancel()
}
@@ -62,23 +59,3 @@ func ParseExportResultJSON(jsonStr string) (ExportResult, error) {
}
return resp.ExportResult, nil
}
func InterpretExportResult(rc int) (int, error) {
switch rc {
case -1:
return 0, fmt.Errorf("invalid arguments or directory creation failed")
case -2:
return 0, fmt.Errorf("could not create output directory")
case -3:
return 0, fmt.Errorf("album not found")
case -4:
return 0, fmt.Errorf("all exports failed")
case -5:
return 0, fmt.Errorf("cancelled")
default:
if rc < 0 {
return 0, fmt.Errorf("unknown error (code %d)", rc)
}
return rc, nil
}
}
-33
View File
@@ -54,39 +54,6 @@ func (*CgoBridge) ListTree() ([]CollectionNode, error) {
return ParseTreeJSON(C.GoString(cs))
}
func (*CgoBridge) ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error) {
cid := C.CString(albumID)
defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
rc := C.photos_export_album_previews(cid, cdir, C.int(targetSize))
return InterpretExportResult(int(rc))
}
func (*CgoBridge) ExportAlbumOriginals(albumID, outputDir string) (int, error) {
cid := C.CString(albumID)
defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
rc := C.photos_export_album_originals(cid, cdir)
return InterpretExportResult(int(rc))
}
func (*CgoBridge) BackupAll(outputDir string, targetSize int, originals bool) (int, error) {
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
coriginals := 0
if originals {
coriginals = 1
}
rc := C.photos_backup_all(cdir, C.int(targetSize), C.int(coriginals))
return InterpretExportResult(int(rc))
}
func (*CgoBridge) Cancel() {
C.photos_request_cancel()
}
-37
View File
@@ -12,9 +12,6 @@ void photos_test_set_access(int rc);
void photos_test_set_albums(const char *json);
void photos_test_set_assets(const char *json);
void photos_test_set_tree(const char *json);
void photos_test_set_export_rc(int rc);
void photos_test_set_export_originals_rc(int rc);
void photos_test_set_backup_all_rc(int rc);
void photos_test_set_albums_null(void);
void photos_test_set_assets_null(void);
void photos_test_set_tree_null(void);
@@ -34,9 +31,6 @@ func SetTestAccessRC(rc int) { C.photos_test_set_access(C.int(rc)) }
func SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) }
func SetTestAssetsJSON(json string) { C.photos_test_set_assets(C.CString(json)) }
func SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) }
func SetTestExportRC(rc int) { C.photos_test_set_export_rc(C.int(rc)) }
func SetTestExportOriginalsRC(rc int) { C.photos_test_set_export_originals_rc(C.int(rc)) }
func SetTestBackupAllRC(rc int) { C.photos_test_set_backup_all_rc(C.int(rc)) }
func SetTestAlbumsNull() { C.photos_test_set_albums_null() }
func SetTestAssetsNull() { C.photos_test_set_assets_null() }
func SetTestTreeNull() { C.photos_test_set_tree_null() }
@@ -80,37 +74,6 @@ func (*CgoBridge) ListTree() ([]CollectionNode, error) {
return ParseTreeJSON(C.GoString(cs))
}
func (*CgoBridge) ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error) {
cid := C.CString(albumID)
defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
rc := C.photos_export_album_previews(cid, cdir, C.int(targetSize))
return InterpretExportResult(int(rc))
}
func (*CgoBridge) ExportAlbumOriginals(albumID, outputDir string) (int, error) {
cid := C.CString(albumID)
defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
rc := C.photos_export_album_originals(cid, cdir)
return InterpretExportResult(int(rc))
}
func (*CgoBridge) BackupAll(outputDir string, targetSize int, originals bool) (int, error) {
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
coriginals := 0
if originals {
coriginals = 1
}
rc := C.photos_backup_all(cdir, C.int(targetSize), C.int(coriginals))
return InterpretExportResult(int(rc))
}
func (*CgoBridge) Cancel() {
C.photos_request_cancel()
}
+3 -31
View File
@@ -1,36 +1,8 @@
// Package photos provides a Go bridge to Apple's PhotoKit framework via CGo,
// enabling programmatic access to photos, albums, and export operations.
package photos
import "fmt"
var errAccessDenied = fmt.Errorf("photos access denied: grant Full Disk Access or Photos permission in System Settings > Privacy & Security")
var errBridgeNil = fmt.Errorf("bridge returned nil")
func RequestAccess() error { return DefaultBridge.RequestAccess() }
func ListAlbums() ([]Album, error) { return DefaultBridge.ListAlbums() }
func ListAssets(albumID string) ([]Asset, int, error) { return DefaultBridge.ListAssets(albumID) }
func ListTree() ([]CollectionNode, error) { return DefaultBridge.ListTree() }
func ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error) {
return DefaultBridge.ExportAlbumPreviews(albumID, outputDir, targetSize)
}
func ExportAlbumOriginals(albumID, outputDir string) (int, error) {
return DefaultBridge.ExportAlbumOriginals(albumID, outputDir)
}
func BackupAll(outputDir string, targetSize int, originals bool) (int, error) {
return DefaultBridge.BackupAll(outputDir, targetSize, originals)
}
func Cancel() { DefaultBridge.Cancel() }
func ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
return DefaultBridge.ExportPreview(assetID, outputDir, targetSize, index)
}
func ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) {
return DefaultBridge.ExportOriginal(assetID, outputDir, index)
}
var errBridgeNil = fmt.Errorf("bridge returned nil")
-164
View File
@@ -209,45 +209,6 @@ func TestParseTreeJSON(t *testing.T) {
}
}
func TestInterpretExportResult(t *testing.T) {
tests := []struct {
name string
rc int
wantN int
wantErr bool
errMsg string
}{
{name: "success", rc: 5, wantN: 5},
{name: "zero exports", rc: 0, wantN: 0},
{name: "single export", rc: 1, wantN: 1},
{name: "invalid args", rc: -1, wantErr: true, errMsg: "invalid arguments or directory creation failed"},
{name: "mkdir failed", rc: -2, wantErr: true, errMsg: "could not create output directory"},
{name: "album not found", rc: -3, wantErr: true, errMsg: "album not found"},
{name: "all exports failed", rc: -4, wantErr: true, errMsg: "all exports failed"},
{name: "cancelled", rc: -5, wantErr: true, errMsg: "cancelled"},
{name: "unknown error", rc: -99, wantErr: true, errMsg: "unknown error (code -99)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
n, err := InterpretExportResult(tt.rc)
if (err != nil) != tt.wantErr {
t.Errorf("InterpretExportResult() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
if err.Error() != tt.errMsg {
t.Errorf("InterpretExportResult() error = %q, want %q", err.Error(), tt.errMsg)
}
return
}
if n != tt.wantN {
t.Errorf("InterpretExportResult() = %d, want %d", n, tt.wantN)
}
})
}
}
func TestAlbumsResponseUnmarshal(t *testing.T) {
raw := `{"albums":[{"id":"x","title":"Y"}]}`
var resp AlbumsResponse
@@ -299,68 +260,6 @@ func TestCgoBridgeImplementsBridge(t *testing.T) {
var _ Bridge = &CgoBridge{}
}
func TestPackageLevelDelegatesToDefaultBridge(t *testing.T) {
SetTestAccessRC(0)
SetTestAlbumsJSON(`{"albums":[{"id":"p1","title":"PAlbum"}]}`)
SetTestAssetsJSON(`{"assets":[{"id":"pa1","filename":"IMG_1001.JPG"}],"total":1}`)
SetTestTreeJSON(`{"collections":[{"id":"f1","name":"Trips","kind":"folder","children":[{"id":"a1","name":"Venice","kind":"album"}]}]}`)
SetTestExportRC(3)
SetTestExportOriginalsRC(2)
SetTestBackupAllRC(5)
if err := RequestAccess(); err != nil {
t.Fatal(err)
}
albums, err := ListAlbums()
if err != nil {
t.Fatal(err)
}
if len(albums) != 1 || albums[0].ID != "p1" {
t.Errorf("got %+v", albums)
}
assets, _, err := ListAssets("p1")
if err != nil {
t.Fatal(err)
}
if len(assets) != 1 || assets[0].ID != "pa1" || assets[0].Filename != "IMG_1001.JPG" {
t.Errorf("got %+v", assets)
}
tree, err := ListTree()
if err != nil {
t.Fatal(err)
}
if len(tree) != 1 || tree[0].ID != "f1" || tree[0].Name != "Trips" || len(tree[0].Children) != 1 || tree[0].Children[0].ID != "a1" || tree[0].Children[0].Name != "Venice" {
t.Errorf("got %+v", tree)
}
n, err := ExportAlbumPreviews("p1", "/tmp/x", 1024)
if err != nil {
t.Fatal(err)
}
if n != 3 {
t.Errorf("got %d", n)
}
n, err = ExportAlbumOriginals("p1", "/tmp/x")
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("got %d", n)
}
n, err = BackupAll("/tmp/x", 1024, false)
if err != nil {
t.Fatal(err)
}
if n != 5 {
t.Errorf("got %d", n)
}
}
func TestCgoBridgeListAlbumsViaStub(t *testing.T) {
SetTestAlbumsJSON(`{"albums":[{"id":"abc","title":"TestAlbum"}]}`)
bridge := &CgoBridge{}
@@ -425,69 +324,6 @@ func TestCgoBridgeListTreeNil(t *testing.T) {
}
}
func TestCgoBridgeExportViaStub(t *testing.T) {
SetTestExportRC(7)
bridge := &CgoBridge{}
n, err := bridge.ExportAlbumPreviews("id", "/tmp/out", 512)
if err != nil {
t.Fatal(err)
}
if n != 7 {
t.Errorf("got %d", n)
}
}
func TestCgoBridgeExportErrorViaStub(t *testing.T) {
SetTestExportRC(-3)
bridge := &CgoBridge{}
_, err := bridge.ExportAlbumPreviews("id", "/tmp/out", 512)
if err == nil || err.Error() != "album not found" {
t.Errorf("got %v", err)
}
}
func TestCgoBridgeExportOriginalsViaStub(t *testing.T) {
SetTestExportOriginalsRC(6)
bridge := &CgoBridge{}
n, err := bridge.ExportAlbumOriginals("id", "/tmp/out")
if err != nil {
t.Fatal(err)
}
if n != 6 {
t.Errorf("got %d", n)
}
}
func TestCgoBridgeExportOriginalsErrorViaStub(t *testing.T) {
SetTestExportOriginalsRC(-4)
bridge := &CgoBridge{}
_, err := bridge.ExportAlbumOriginals("id", "/tmp/out")
if err == nil || err.Error() != "all exports failed" {
t.Errorf("got %v", err)
}
}
func TestCgoBridgeBackupAllViaStub(t *testing.T) {
SetTestBackupAllRC(8)
bridge := &CgoBridge{}
n, err := bridge.BackupAll("/tmp/out", 1024, false)
if err != nil {
t.Fatal(err)
}
if n != 8 {
t.Errorf("got %d", n)
}
}
func TestCgoBridgeBackupAllErrorViaStub(t *testing.T) {
SetTestBackupAllRC(-2)
bridge := &CgoBridge{}
_, err := bridge.BackupAll("/tmp/out", 1024, true)
if err == nil || err.Error() != "could not create output directory" {
t.Errorf("got %v", err)
}
}
func TestCgoBridgeRequestAccessGranted(t *testing.T) {
SetTestAccessRC(0)
bridge := &CgoBridge{}
+1 -1
View File
@@ -8,7 +8,7 @@ type Album struct {
type Asset struct {
ID string `json:"id"`
Filename string `json:"filename"`
Cloud bool `json:"cloud"`
Cloud string `json:"cloud"`
}
type ExportResult struct {