Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ All notable changes to DiskSage are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and released versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Unreleased entries describe integrated source changes only; they are not release evidence until the repository's review, CI, security, packaging, provenance, and release-acceptance gates pass on the exact tagged commit.

## [Unreleased]
- Make Apple Photos duplicate inventory resumable and cancellable between native-completed assets,
with visible progress and no arbitrary whole-library timeout.
- Connect exact decoded-pixel duplicate groups to the existing reversible photo quarantine engine;
forged or stale audits, ambiguous keepers without an explicit selection, and changed roots fail closed.
The review screen shows measured evidence and blockers, requires direct typed approval, and reports
Expand Down
38 changes: 38 additions & 0 deletions docs/architecture/adr/0024-photokit-checkpointed-inventory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# ADR-0024: Checkpoint PhotoKit inventory at native completion boundaries

- Status: Accepted
- Date: 2026-08-30

## Context

Reading locally available originals can take materially different time per asset. A single large
PhotoKit request kept the customer waiting without progress or cancellation, while an arbitrary
wall-clock timeout discarded completed evidence.

## Decision

DiskSage requests one `PHAsset` per native page. A page is accepted only after PhotoKit's resource
completion handler, and records its measured duration rather than using it as a guessed cutoff.
Rust rejects gaps, repeated offsets, missing completion evidence, and inconsistent totals. The UI
checkpoints after every page, yields for rendering, and stops between pages when requested. A
checkpoint is resumable; it never authorizes deletion. Network access remains disabled and no
PhotoKit change request is made during inventory.

## Consequences

Large libraries take as long as their locally available originals require, but the customer sees
progress, may stop safely, and can resume without repeating accepted pages. Destructive planning
still requires a complete inventory and the existing fresh re-fetch and exact approval contract.

## Rejected alternatives

An arbitrary whole-library timeout was rejected because elapsed wall time is not evidence that
PhotoKit failed. Large fixed pages were rejected because they cannot yield promptly between assets.

## References

Apple. (n.d.). *PHAssetResourceManager*. Apple Developer Documentation.
https://developer.apple.com/documentation/photokit/phassetresourcemanager

Apple. (n.d.). *Fetching and caching assets and thumbnails*. Apple Developer Documentation.
https://developer.apple.com/documentation/photokit/fetching-and-caching-assets-and-thumbnails
1 change: 1 addition & 0 deletions docs/architecture/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ new numbered record rather than rewriting history.
| [0021](0021-perceptual-photo-candidates.md) | Require measured evidence and a selected survivor for perceptual photo candidates | Accepted |
| [0022](0022-photo-duplicate-evidence-without-composite-scoring.md) | Separate photo-duplicate and keeper evidence without composite scoring | Accepted |
| [0023](0023-apple-photos-photokit-boundary.md) | Use PhotoKit rather than Photos library package traversal | Accepted |
| [0024](0024-photokit-checkpointed-inventory.md) | Checkpoint PhotoKit inventory at native completion boundaries | Accepted |

New records must state context, decision, consequences, rejected alternatives, and the evidence or
standard that led to the decision. A record never grants cloud-write or source-eviction authority;
Expand Down
6 changes: 6 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -1236,6 +1236,12 @@ runner's private workspace temp root instead of weakening the shared production
content digest before invoking Photos' own deletion transaction and confirmation; a create-new
receipt follows success. Near-duplicate managed assets remain unavailable rather than receiving
an uncalibrated score, so that Gap is explicit and non-destructive.
- A 5,000-item read-only audit did not return customer-visible progress before an external
60-second observation was stopped. The native bridge now returns exactly one asset only after
PhotoKit's completion callback; Rust persists gap-free checkpoint evidence, and the UI renders
progress and accepts cancellation between completed assets. Resuming uses the accepted
checkpoint instead of restarting. No elapsed-time threshold is interpreted as failure and no
photo mutation is introduced by this audit path.

- A fresh Naruon audit proved exactly one removable worktree: PR #1429 was merged, its detached
head was retained by current `origin/develop`, the checkout was clean and inactive, and no open
Expand Down
71 changes: 64 additions & 7 deletions src-tauri/native/photos_bridge.m
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
#import <Photos/Photos.h>

static const NSUInteger DSMaxChunkBytes = 8 * 1024 * 1024;
static const int64_t DSResourceTimeoutNanos = 30LL * NSEC_PER_SEC;
static const int64_t DSAuthorizationTimeoutNanos = 5LL * 60LL * NSEC_PER_SEC;
static NSArray<NSString *> *DSPageIdentifiers = nil;
static NSString *DSPageInventoryIdentity = nil;

static char *DSJSON(id value) {
NSData *data = [NSJSONSerialization dataWithJSONObject:value options:0 error:nil];
Expand All @@ -29,6 +30,18 @@
return hex;
}

static NSString *DSInventoryIdentity(PHFetchResult<PHAsset *> *fetch) {
CC_SHA256_CTX context; CC_SHA256_Init(&context);
for (PHAsset *asset in fetch) {
NSData *identifier = [asset.localIdentifier dataUsingEncoding:NSUTF8StringEncoding];
uint64_t length = CFSwapInt64HostToLittle((uint64_t)identifier.length);
CC_SHA256_Update(&context, &length, sizeof(length));
CC_SHA256_Update(&context, identifier.bytes, (CC_LONG)identifier.length);
}
unsigned char digest[CC_SHA256_DIGEST_LENGTH]; CC_SHA256_Final(digest, &context);
return DSHex(digest, sizeof(digest));
}

static NSString *DSMetadataFingerprint(PHAsset *asset, PHAssetResource *resource) {
NSString *text = [NSString stringWithFormat:@"%@\n%ld\n%ld\n%.0f\n%.0f\n%@\n%@\n%ld",
asset.localIdentifier, (long)asset.pixelWidth, (long)asset.pixelHeight,
Expand Down Expand Up @@ -81,7 +94,7 @@ int64_t ds_photos_select_still_resource_index(const int64_t *types, size_t count
__block uint64_t byteCount = 0;
__block BOOL exceeded = NO;
__block NSError *completionError = nil;
PHAssetResourceDataRequestID requestID = [[PHAssetResourceManager defaultManager]
[[PHAssetResourceManager defaultManager]
requestDataForAssetResource:resource
options:options
dataReceivedHandler:^(NSData *data) {
Expand All @@ -96,11 +109,7 @@ int64_t ds_photos_select_still_resource_index(const int64_t *types, size_t count
completionError = error;
dispatch_semaphore_signal(done);
}];
if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, DSResourceTimeoutNanos)) != 0) {
[[PHAssetResourceManager defaultManager] cancelDataRequest:requestID];
dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, 5LL * NSEC_PER_SEC));
return @{ @"state": @"unavailable", @"blocker": @"local-content-read-timed-out" };
}
dispatch_semaphore_wait(done, DISPATCH_TIME_FOREVER);
if (completionError) {
return @{ @"state": @"icloud-only-or-unavailable", @"blocker": @"download-original-in-photos" };
}
Expand Down Expand Up @@ -197,6 +206,54 @@ int64_t ds_photos_select_still_resource_index(const int64_t *types, size_t count
return DSJSON(DSInventory(MIN((NSUInteger)maxAssets, 10000), MIN(maxBytes, 536870912ULL), nil));
}

// One PhotoKit asset is the native pagination unit. Each call returns only after PhotoKit's
// resource completion handler (or its explicit unavailable result), allowing the UI to checkpoint,
// repaint, or cancel between assets without guessing a wall-clock page timeout.
char *ds_photos_inventory_page(uint64_t offset, uint64_t maxBytes) {
PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatusForAccessLevel:PHAccessLevelReadWrite];
if (status != PHAuthorizationStatusAuthorized && status != PHAuthorizationStatusLimited)
return DSJSON(@{ @"authorization": DSStatus(status), @"observed_at_ms": @((uint64_t)(NSDate.date.timeIntervalSince1970 * 1000)),
@"total_count": @0, @"offset": @(offset), @"next_offset": [NSNull null],
@"inventory_identity": @"",
@"native_completion_observed": @YES, @"page_duration_ms": @0,
@"assets": @[], @"unavailable_count": @0 });
Comment thread
seonghobae marked this conversation as resolved.
PHFetchOptions *options = [PHFetchOptions new];
options.predicate = [NSPredicate predicateWithFormat:@"mediaType == %d", PHAssetMediaTypeImage];
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithOptions:options];
if (offset == 0 || !DSPageIdentifiers || !DSPageInventoryIdentity) {
NSMutableArray<NSString *> *identifiers = [NSMutableArray arrayWithCapacity:fetch.count];
for (PHAsset *asset in fetch) [identifiers addObject:asset.localIdentifier];
DSPageIdentifiers = [identifiers copy];
DSPageInventoryIdentity = DSInventoryIdentity(fetch);
}
NSString *inventoryIdentity = DSPageInventoryIdentity;
uint64_t snapshotTotal = DSPageIdentifiers.count;
uint64_t total = fetch.count;
BOOL libraryChanged = total != snapshotTotal;
uint64_t started = (uint64_t)(NSDate.date.timeIntervalSince1970 * 1000);
NSArray *assets = @[];
uint64_t unavailable = 0;
if (total == snapshotTotal && offset < snapshotTotal) {
NSString *identifier = DSPageIdentifiers[(NSUInteger)offset];
PHFetchResult<PHAsset *> *selected = [PHAsset fetchAssetsWithLocalIdentifiers:@[identifier] options:nil];
if (selected.count == 1) {
NSDictionary *asset = DSAssetEvidence(selected.firstObject, MIN(maxBytes, 536870912ULL));
assets = @[asset];
unavailable = asset[@"content_sha256"] ? 0 : 1;
} else libraryChanged = YES;
}
if (libraryChanged || (offset + assets.count >= snapshotTotal && ![DSInventoryIdentity(fetch) isEqualToString:inventoryIdentity]))
inventoryIdentity = @"photos-library-changed";
Comment thread
seonghobae marked this conversation as resolved.
uint64_t completed = (uint64_t)(NSDate.date.timeIntervalSince1970 * 1000);
NSNumber *next = offset + assets.count < snapshotTotal ? @(offset + assets.count) : nil;
return DSJSON(@{ @"authorization": DSStatus(status), @"observed_at_ms": @(completed),
@"total_count": @(snapshotTotal), @"offset": @(offset),
@"inventory_identity": inventoryIdentity,
@"next_offset": next ?: [NSNull null], @"native_completion_observed": @YES,
@"page_duration_ms": @(completed - started), @"assets": assets,
@"unavailable_count": @(unavailable) });
}

char *ds_photos_delete(const char *requestJSON) {
NSData *data = [[NSData alloc] initWithBytes:requestJSON length:strlen(requestJSON)];
NSDictionary *request = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/container_orphan_reclaim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1381,6 +1381,9 @@ pub fn execute_container_orphan_prune(
stdout: output.stdout,
stderr: output.stderr,
output_truncated: false,
// A non-zero exact-delete status means the runtime refused the mutation (for example,
// because a previously stopped container restarted). Keep the attempted command and
// status in the receipt, but never report the reclaim as executed.
executed: output.status_code == 0,
Comment thread
seonghobae marked this conversation as resolved.
executed_at_ms,
before_available_bytes,
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@ pub fn run() {
photo_duplicate_quarantine::execute_exact_photo_duplicate_quarantine,
photos_library::photos_authorization_status,
photos_library::request_photos_authorization,
photos_library::inspect_photos_duplicates,
photos_library::inspect_photos_duplicates_page,
photos_library::finalize_photos_duplicate_inventory,
photos_library::plan_photos_duplicate_deletion,
photos_library::execute_photos_duplicate_deletion,
commands::get_ontology,
Expand Down
Loading