From 0f1e57ad887cc7445ee10691fd2d2b90dfd2ec73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 09:26:19 +0900 Subject: [PATCH 1/7] feat: checkpoint PhotoKit duplicate inventory --- CHANGELOG.md | 2 + .../0024-photokit-checkpointed-inventory.md | 38 ++++ docs/architecture/adr/README.md | 1 + docs/product-technical-gap-baseline.md | 6 + src-tauri/native/photos_bridge.m | 31 ++++ src-tauri/src/lib.rs | 1 + src-tauri/src/photos_library.rs | 165 ++++++++++++++++++ src/lib/PhotosLibraryReview.svelte | 28 ++- src/lib/api.ts | 3 + src/lib/photosLibraryState.test.ts | 8 +- src/lib/photosLibraryState.ts | 3 + 11 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 docs/architecture/adr/0024-photokit-checkpointed-inventory.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 26130bb39..07434f328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/architecture/adr/0024-photokit-checkpointed-inventory.md b/docs/architecture/adr/0024-photokit-checkpointed-inventory.md new file mode 100644 index 000000000..58a8897a5 --- /dev/null +++ b/docs/architecture/adr/0024-photokit-checkpointed-inventory.md @@ -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 diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 72fa8c017..7239f5739 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -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; diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 827d8fb1d..a714aa879 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -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 diff --git a/src-tauri/native/photos_bridge.m b/src-tauri/native/photos_bridge.m index 6bef3bf73..b566a19b2 100644 --- a/src-tauri/native/photos_bridge.m +++ b/src-tauri/native/photos_bridge.m @@ -197,6 +197,37 @@ 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], + @"native_completion_observed": @YES, @"page_duration_ms": @0, + @"assets": @[], @"unavailable_count": @0 }); + PHFetchOptions *options = [PHFetchOptions new]; + options.predicate = [NSPredicate predicateWithFormat:@"mediaType == %d", PHAssetMediaTypeImage]; + PHFetchResult *fetch = [PHAsset fetchAssetsWithOptions:options]; + uint64_t total = fetch.count; + uint64_t started = (uint64_t)(NSDate.date.timeIntervalSince1970 * 1000); + NSArray *assets = @[]; + uint64_t unavailable = 0; + if (offset < total) { + NSDictionary *asset = DSAssetEvidence([fetch objectAtIndex:(NSUInteger)offset], MIN(maxBytes, 536870912ULL)); + assets = @[asset]; + unavailable = asset[@"content_sha256"] ? 0 : 1; + } + uint64_t completed = (uint64_t)(NSDate.date.timeIntervalSince1970 * 1000); + NSNumber *next = offset + assets.count < total ? @(offset + assets.count) : nil; + return DSJSON(@{ @"authorization": DSStatus(status), @"observed_at_ms": @(completed), + @"total_count": @(total), @"offset": @(offset), + @"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]; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 39a2ed774..7a2265dbe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -154,6 +154,7 @@ pub fn run() { photos_library::photos_authorization_status, photos_library::request_photos_authorization, photos_library::inspect_photos_duplicates, + photos_library::inspect_photos_duplicates_page, photos_library::plan_photos_duplicate_deletion, photos_library::execute_photos_duplicate_deletion, commands::get_ontology, diff --git a/src-tauri/src/photos_library.rs b/src-tauri/src/photos_library.rs index 6bea1530d..d757fb0e5 100644 --- a/src-tauri/src/photos_library.rs +++ b/src-tauri/src/photos_library.rs @@ -58,6 +58,107 @@ pub struct PhotosDuplicateInventory { pub exact_groups: Vec, pub unavailable_count: u64, pub near_duplicate_evidence: Option, + #[serde(default)] + pub inventory_total_count: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhotosInventoryPage { + pub authorization: String, + pub observed_at_ms: u64, + pub total_count: u64, + pub offset: u64, + pub next_offset: Option, + pub native_completion_observed: bool, + pub page_duration_ms: u64, + pub assets: Vec, + pub unavailable_count: u64, +} + +/// Merge one native-completed PhotoKit page into a resumable read-only checkpoint. +pub fn merge_inventory_page( + previous: Option, + page: PhotosInventoryPage, +) -> Result { + if !page.native_completion_observed || page.assets.len() > 1 { + return Err("photos-page-native-completion-required".into()); + } + let mut inventory = previous.unwrap_or(PhotosDuplicateInventory { + authorization: page.authorization.clone(), + observed_at_ms: Some(page.observed_at_ms), + inventory_fingerprint: None, + evidence_complete: false, + inventory_truncated: true, + next_action: "사진 확인을 계속하세요.".into(), + assets: Vec::new(), + exact_groups: Vec::new(), + unavailable_count: 0, + near_duplicate_evidence: Some("unavailable-without-measured-content-equivalence".into()), + inventory_total_count: Some(page.total_count), + }); + if inventory.authorization != page.authorization + || inventory.inventory_total_count != Some(page.total_count) + || inventory.assets.len() as u64 != page.offset + || page + .next_offset + .is_some_and(|next| next != page.offset + page.assets.len() as u64) + || page.total_count < page.offset + page.assets.len() as u64 + { + return Err("photos-page-checkpoint-mismatch".into()); + } + inventory.assets.extend(page.assets); + inventory.unavailable_count = inventory + .unavailable_count + .checked_add(page.unavailable_count) + .ok_or("photos-page-count-overflow")?; + inventory.observed_at_ms = Some(page.observed_at_ms); + let complete = page.next_offset.is_none() && inventory.assets.len() as u64 == page.total_count; + inventory.inventory_truncated = !complete; + inventory.evidence_complete = complete + && inventory.unavailable_count == 0 + && matches!(inventory.authorization.as_str(), "authorized" | "limited"); + inventory.next_action = if complete { + if inventory.unavailable_count > 0 { + "사진 앱에서 이 Mac에 없는 원본을 다운로드한 뒤 다시 확인하세요.".into() + } else { + "검사가 끝났습니다. 정확한 사본 그룹을 검토하세요.".into() + } + } else { + format!( + "{}개 중 {}개를 확인했습니다. 계속하려면 사진 확인을 누르세요.", + page.total_count, + inventory.assets.len() + ) + }; + let mut groups = BTreeMap::>::new(); + for asset in &inventory.assets { + if let Some(digest) = &asset.content_sha256 { + groups + .entry(digest.clone()) + .or_default() + .push(asset.clone()); + } + } + inventory.exact_groups = groups + .into_iter() + .filter(|(_, members)| members.len() > 1) + .map(|(content_sha256, members)| PhotosExactGroup { + content_sha256, + members, + keeper_required: true, + automatic_delete_allowed: false, + }) + .collect(); + inventory.inventory_fingerprint = if complete { + Some(hash_json(&( + inventory.assets.clone(), + inventory.unavailable_count, + ))?) + } else { + None + }; + Ok(inventory) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -291,6 +392,7 @@ mod native { fn ds_photos_authorization_status() -> *mut c_char; fn ds_photos_request_authorization() -> *mut c_char; fn ds_photos_inventory(max_assets: u32, max_bytes: u64) -> *mut c_char; + fn ds_photos_inventory_page(offset: u64, max_bytes: u64) -> *mut c_char; fn ds_photos_delete(request_json: *const c_char) -> *mut c_char; #[cfg(test)] fn ds_photos_select_still_resource_index(types: *const i64, count: usize) -> i64; @@ -317,6 +419,10 @@ mod native { take_json(unsafe { ds_photos_inventory(MAX_INVENTORY_ASSETS, MAX_RESOURCE_BYTES) }) } + pub fn inventory_page(offset: u64) -> Result { + take_json(unsafe { ds_photos_inventory_page(offset, MAX_RESOURCE_BYTES) }) + } + #[derive(Deserialize)] struct NativeDeleteResult { deleted_identifiers: Option>, @@ -362,6 +468,9 @@ mod native { pub fn inventory() -> Result { Err("photos-library-macos-only".into()) } + pub fn inventory_page(_: u64) -> Result { + Err("photos-library-macos-only".into()) + } pub fn delete(_: &PhotosDeletionPlan) -> Result<(), String> { Err("photos-library-macos-only".into()) } @@ -389,6 +498,19 @@ pub async fn inspect_photos_duplicates() -> Result, +) -> Result { + let offset = previous + .as_ref() + .map_or(0, |value| value.assets.len() as u64); + let page = tauri::async_runtime::spawn_blocking(move || native::inventory_page(offset)) + .await + .map_err(|_| "photos-operation-interrupted".to_string())??; + merge_inventory_page(previous, page) +} + #[tauri::command] pub fn plan_photos_duplicate_deletion( inventory: PhotosDuplicateInventory, @@ -511,9 +633,52 @@ mod tests { near_duplicate_evidence: Some( "unavailable-without-measured-content-equivalence".into(), ), + inventory_total_count: Some(2), + } + } + + fn page(offset: u64, total: u64, asset: PhotosAssetEvidence) -> PhotosInventoryPage { + PhotosInventoryPage { + authorization: "authorized".into(), + observed_at_ms: 100 + offset, + total_count: total, + offset, + next_offset: (offset + 1 < total).then_some(offset + 1), + native_completion_observed: true, + page_duration_ms: 7, + assets: vec![asset], + unavailable_count: 0, } } + #[test] + fn native_completed_pages_resume_and_only_finish_at_exact_total() { + let first = merge_inventory_page(None, page(0, 2, member("keep", 10))).unwrap(); + assert!(first.inventory_truncated); + assert!(!first.evidence_complete); + assert!(first.inventory_fingerprint.is_none()); + let complete = merge_inventory_page(Some(first), page(1, 2, member("remove", 8))).unwrap(); + assert!(!complete.inventory_truncated); + assert!(complete.evidence_complete); + assert!(complete.inventory_fingerprint.is_some()); + assert_eq!(complete.exact_groups.len(), 1); + } + + #[test] + fn page_gap_or_missing_native_completion_is_rejected() { + let first = merge_inventory_page(None, page(0, 2, member("keep", 10))).unwrap(); + assert_eq!( + merge_inventory_page(Some(first.clone()), page(0, 2, member("remove", 8))).unwrap_err(), + "photos-page-checkpoint-mismatch" + ); + let mut incomplete = page(1, 2, member("remove", 8)); + incomplete.native_completion_observed = false; + assert_eq!( + merge_inventory_page(Some(first), incomplete).unwrap_err(), + "photos-page-native-completion-required" + ); + } + #[test] fn plan_requires_explicit_keeper_and_preserves_it() { let inventory = inventory(); diff --git a/src/lib/PhotosLibraryReview.svelte b/src/lib/PhotosLibraryReview.svelte index 6ef04be4e..79bd9d398 100644 --- a/src/lib/PhotosLibraryReview.svelte +++ b/src/lib/PhotosLibraryReview.svelte @@ -1,7 +1,7 @@