diff --git a/CHANGELOG.md b/CHANGELOG.md index 4653fe287..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 @@ -315,6 +317,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Restored the cloud-copy public documentation regression contract after a temporary repair path removed it, so CI continues to fail when the new Rust or TypeScript approval surfaces lose beginner-readable documentation. - Align release artifact verification with the pinned `windows-2022` build matrix name, and make the container-capacity regression fixture satisfy the same runtime-health probe required in production. - Require standalone-clone cleanup to bind a real in-root Git directory, complete audit evidence, and an external safe journal before an approved Trash move. +- Add a native Apple Photos duplicate workflow that preserves iCloud-only originals, groups exact + local content, requires an explicit keeper and fresh approval, revalidates PhotoKit identifiers, + and delegates deletion confirmation and Recently Deleted behavior to Photos. ### Security diff --git a/docs/architecture/adr/0023-apple-photos-photokit-boundary.md b/docs/architecture/adr/0023-apple-photos-photokit-boundary.md new file mode 100644 index 000000000..e813d05f8 --- /dev/null +++ b/docs/architecture/adr/0023-apple-photos-photokit-boundary.md @@ -0,0 +1,59 @@ +# ADR 0023: Use PhotoKit rather than Photos library package traversal + +Status: Accepted + +## Context + +Apple Photos libraries can include locally materialized originals and iCloud-only assets. Treating +the `.photoslibrary` package as an ordinary directory bypasses Photos authorization, relationship, +change-notification, and system deletion semantics. A file path is therefore not deletion authority. + +## Decision + +DiskSage uses a macOS-native PhotoKit boundary and never descends into or mutates a managed Photos +library package. Read/write authorization is requested only after the customer selects **Connect +Photos**. Inventory is bounded to 10,000 image assets and 512 MiB per locally available original; +resource requests disable network access, so iCloud-only originals remain unmaterialized and block +all destructive planning. + +Exact groups require SHA-256 content identity. Width, height, pixel count, encoded bytes, resource +type, and UTI remain separate measured evidence; no composite score or arbitrary weight chooses a +keeper. Near-duplicate deletion remains unavailable until measured equivalence evidence exists. +The customer must select one keeper per exact group, enter the fresh exact approval phrase and a +rationale, and then accept Photos' own deletion confirmation. Immediately before the change, +DiskSage re-fetches local identifiers and re-reads local content without network access. The change +uses `PHPhotoLibrary.performChanges`/`PHAssetChangeRequest.deleteAssets`; an immutable, create-new +receipt records only successful completion. Non-macOS builds fail closed. + +## Consequences + +- iCloud-only assets are preserved without implicit download. +- Changes made by Photos, another device, or another app invalidate the reviewed evidence at the + identifier, metadata, or content recheck. +- Deleted assets follow Photos' Recently Deleted and system-confirmation behavior; DiskSage never + directly unlinks a managed original. +- Limited Photos access inventories only the assets Apple exposes, and the UI states the next action. + +## Rejected alternatives + +- Direct `.photoslibrary` traversal or deletion: bypasses PhotoKit authority and can corrupt the library. +- Filesystem Trash for managed originals: bypasses Photos' change transaction and confirmation. +- Automatically downloading iCloud originals: creates disk pressure and expands mutation scope. +- Weighted “best photo” scoring: no calibrated model supports an arbitrary cross-metric weight. + +## References + +Apple. (n.d.). *Delivering an enhanced privacy experience in your Photos app*. Apple Developer. +https://developer.apple.com/documentation/photokit/delivering-an-enhanced-privacy-experience-in-your-photos-app + +Apple. (n.d.). *Observing changes in the photo library*. Apple Developer. +https://developer.apple.com/documentation/photokit/observing-changes-in-the-photo-library + +Apple. (n.d.). *PHAssetChangeRequest*. Apple Developer. +https://developer.apple.com/documentation/photos/phassetchangerequest + +Apple. (n.d.). *PHPhotoLibrary*. Apple Developer. +https://developer.apple.com/documentation/photos/phphotolibrary + +Apple. (n.d.). *Requesting changes to the photo library*. Apple Developer. +https://developer.apple.com/documentation/photokit/requesting-changes-to-the-photo-library 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 16006a1c6..7239f5739 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -28,6 +28,8 @@ new numbered record rather than rewriting history. | [0020](0020-podman-native-storage-repair.md) | Use machine-scoped native Podman storage repair without force | Accepted | | [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/doctoring/perceptual-photo-evidence.md b/docs/doctoring/perceptual-photo-evidence.md index 02006fff2..cd0a7aa3a 100644 --- a/docs/doctoring/perceptual-photo-evidence.md +++ b/docs/doctoring/perceptual-photo-evidence.md @@ -17,6 +17,10 @@ placeholders are rejected before decoding so an audit cannot hydrate them. Execu the same report, checks active use and exact filesystem identity, then uses DiskSage's existing atomic OS-Trash boundary and append-only journal. There is no permanent-delete mode. +Apple Photos libraries use the separate PhotoKit boundary documented in ADR 0023. PhotoKit asset +identifiers and resource reads replace package paths; network access is disabled during evidence +collection, so an iCloud-only original is neither downloaded nor admitted to a deletion plan. + ## References pHash. (2010). *pHash design*. https://www.phash.org/docs/design.html diff --git a/docs/evidence/photos-library-review.png b/docs/evidence/photos-library-review.png new file mode 100644 index 000000000..73bdabb2c Binary files /dev/null and b/docs/evidence/photos-library-review.png differ diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b40987bb6..a714aa879 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1228,6 +1228,20 @@ runner's private workspace temp root instead of weakening the shared production cannot form a deletion cluster with an external file. The 44 external Pictures images currently have unique exact-content digests; perceptual comparison and measured quality-survivor selection remain an open product Gap and no non-identical photo was deleted. +- The managed Apple Photos gap now has a separate macOS-native PhotoKit path. It requests read/write + access only from the customer's connect action, inventories local identifiers and measured + resource evidence without allowing network download, groups only exact SHA-256 content matches, + and requires one explicit keeper per group. iCloud-only originals block deletion planning and + remain unmaterialized. Execution re-fetches every identifier, metadata fingerprint, and local + 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/Cargo.lock b/src-tauri/Cargo.lock index 71f635f33..2586bdf32 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -992,6 +992,7 @@ dependencies = [ "base64 0.23.1", "blake3", "calamine", + "cc", "csv", "embed_plist", "fs4", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8a426e1bb..25dea51ba 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -170,6 +170,7 @@ required-features = ["archive-cli"] [build-dependencies] tauri-build = { version = "2", features = [] } +cc = "1" [dependencies] tauri = { version = "2", features = [] } diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index c186c9548..9540a89a2 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -6,5 +6,7 @@ DiskSage는 클라우드 보관 계획을 만들기 전에 Downloads 파일의 메타데이터와 크기를 읽습니다. NSFileProviderDomainUsageDescription DiskSage는 선택한 iCloud, OneDrive, Google Drive 대상의 접근 및 동기화 상태를 검증합니다. + NSPhotoLibraryUsageDescription + DiskSage는 사진 앱에서 정확히 같은 사본을 찾고, 사용자가 선택한 사본만 사진 앱의 확인을 거쳐 삭제합니다. diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 9bf51da16..25db48012 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -38,5 +38,15 @@ fn generate_cloud_plan_implementation() { fn main() { generate_cloud_plan_implementation(); + #[cfg(target_os = "macos")] + { + println!("cargo:rerun-if-changed=native/photos_bridge.m"); + cc::Build::new() + .file("native/photos_bridge.m") + .flag("-fobjc-arc") + .compile("disksage_photos_bridge"); + println!("cargo:rustc-link-lib=framework=Photos"); + println!("cargo:rustc-link-lib=framework=Foundation"); + } tauri_build::build() } diff --git a/src-tauri/native/photos_bridge.m b/src-tauri/native/photos_bridge.m new file mode 100644 index 000000000..68dbbc074 --- /dev/null +++ b/src-tauri/native/photos_bridge.m @@ -0,0 +1,281 @@ +#import +#import +#import + +static const NSUInteger DSMaxChunkBytes = 8 * 1024 * 1024; +static const int64_t DSAuthorizationTimeoutNanos = 5LL * 60LL * NSEC_PER_SEC; +static NSArray *DSPageIdentifiers = nil; +static NSString *DSPageInventoryIdentity = nil; + +static char *DSJSON(id value) { + NSData *data = [NSJSONSerialization dataWithJSONObject:value options:0 error:nil]; + NSString *text = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"{\"error\":\"photos-json-failed\"}"; + return strdup(text.UTF8String); +} + +static NSString *DSStatus(PHAuthorizationStatus status) { + switch (status) { + case PHAuthorizationStatusAuthorized: return @"authorized"; + case PHAuthorizationStatusLimited: return @"limited"; + case PHAuthorizationStatusDenied: return @"denied"; + case PHAuthorizationStatusRestricted: return @"restricted"; + case PHAuthorizationStatusNotDetermined: return @"not-determined"; + } + return @"unknown"; +} + +static NSString *DSHex(const unsigned char *bytes, NSUInteger length) { + NSMutableString *hex = [NSMutableString stringWithCapacity:length * 2]; + for (NSUInteger index = 0; index < length; index++) [hex appendFormat:@"%02x", bytes[index]]; + return hex; +} + +static NSString *DSInventoryIdentity(PHFetchResult *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, + asset.creationDate.timeIntervalSince1970 * 1000, + asset.modificationDate.timeIntervalSince1970 * 1000, + resource.originalFilename ?: @"", resource.uniformTypeIdentifier ?: @"", (long)resource.type]; + NSData *data = [text dataUsingEncoding:NSUTF8StringEncoding]; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(data.bytes, (CC_LONG)data.length, digest); + return DSHex(digest, sizeof(digest)); +} + +int64_t ds_photos_select_still_resource_index(const int64_t *types, size_t count) { + int64_t photoIndex = -1; + int64_t fullSizeIndex = -1; + NSUInteger photoCount = 0; + NSUInteger fullSizeCount = 0; + for (size_t index = 0; index < count; index++) { + if (types[index] == PHAssetResourceTypePhoto) { + photoIndex = (int64_t)index; + photoCount++; + } else if (types[index] == PHAssetResourceTypeFullSizePhoto) { + fullSizeIndex = (int64_t)index; + fullSizeCount++; + } + } + if (photoCount == 1) return photoIndex; + if (photoCount > 1) return -1; + return fullSizeCount == 1 ? fullSizeIndex : -1; +} + +static PHAssetResource *DSStillPhotoResource(NSArray *resources) { + int64_t *types = calloc(resources.count, sizeof(int64_t)); + if (!types && resources.count > 0) return nil; + for (NSUInteger index = 0; index < resources.count; index++) types[index] = resources[index].type; + int64_t selected = ds_photos_select_still_resource_index(types, resources.count); + free(types); + return selected >= 0 ? resources[(NSUInteger)selected] : nil; +} + +static NSDictionary *DSReadResource(PHAsset *asset, uint64_t maxBytes) { + NSArray *resources = [PHAssetResource assetResourcesForAsset:asset]; + PHAssetResource *resource = DSStillPhotoResource(resources); + if (!resource) return @{ @"state": @"unavailable", @"blocker": @"compound-photo-still-resource-ambiguous" }; + PHAssetResourceRequestOptions *options = [PHAssetResourceRequestOptions new]; + options.networkAccessAllowed = NO; + dispatch_semaphore_t done = dispatch_semaphore_create(0); + __block CC_SHA256_CTX context; + CC_SHA256_Init(&context); + __block uint64_t byteCount = 0; + __block BOOL exceeded = NO; + __block NSError *completionError = nil; + [[PHAssetResourceManager defaultManager] + requestDataForAssetResource:resource + options:options + dataReceivedHandler:^(NSData *data) { + if (data.length > DSMaxChunkBytes || byteCount > maxBytes || data.length > maxBytes - MIN(byteCount, maxBytes)) { + exceeded = YES; + return; + } + byteCount += data.length; + CC_SHA256_Update(&context, data.bytes, (CC_LONG)data.length); + } + completionHandler:^(NSError *error) { + completionError = error; + dispatch_semaphore_signal(done); + }]; + dispatch_semaphore_wait(done, DISPATCH_TIME_FOREVER); + if (completionError) { + return @{ @"state": @"icloud-only-or-unavailable", @"blocker": @"download-original-in-photos" }; + } + if (exceeded) return @{ @"state": @"unavailable", @"blocker": @"local-content-exceeds-review-limit" }; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256_Final(digest, &context); + return @{ @"state": @"local-current", @"content_sha256": DSHex(digest, sizeof(digest)), + @"encoded_bytes": @(byteCount), @"original_filename": resource.originalFilename ?: @"", + @"uniform_type_identifier": resource.uniformTypeIdentifier ?: @"", + @"resource_type": @((NSInteger)resource.type), + @"metadata_fingerprint": DSMetadataFingerprint(asset, resource) }; +} + +static NSDictionary *DSAssetEvidence(PHAsset *asset, uint64_t maxBytes) { + NSMutableDictionary *result = [@{ + @"local_identifier": asset.localIdentifier, + @"width_pixels": @(asset.pixelWidth), @"height_pixels": @(asset.pixelHeight), + @"pixel_count": @((uint64_t)asset.pixelWidth * (uint64_t)asset.pixelHeight), + @"creation_ms": asset.creationDate ? @(llround(asset.creationDate.timeIntervalSince1970 * 1000)) : [NSNull null], + @"modification_ms": asset.modificationDate ? @(llround(asset.modificationDate.timeIntervalSince1970 * 1000)) : [NSNull null] + } mutableCopy]; + [result addEntriesFromDictionary:DSReadResource(asset, maxBytes)]; + return result; +} + +static NSDictionary *DSInventory(NSUInteger maxAssets, uint64_t maxBytes, NSArray *identifiers) { + PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatusForAccessLevel:PHAccessLevelReadWrite]; + if (status != PHAuthorizationStatusAuthorized && status != PHAuthorizationStatusLimited) { + return @{ @"authorization": DSStatus(status), @"evidence_complete": @NO, @"inventory_truncated": @NO, + @"next_action": status == PHAuthorizationStatusNotDetermined ? @"connect-photos" : @"allow-photos-in-system-settings", + @"assets": @[], @"exact_groups": @[], @"unavailable_count": @0 }; + } + PHFetchResult *fetch; + if (identifiers) { + fetch = [PHAsset fetchAssetsWithLocalIdentifiers:identifiers options:nil]; + } else { + PHFetchOptions *options = [PHFetchOptions new]; + options.predicate = [NSPredicate predicateWithFormat:@"mediaType == %d", PHAssetMediaTypeImage]; + fetch = [PHAsset fetchAssetsWithOptions:options]; + } + NSMutableArray *assets = [NSMutableArray arrayWithCapacity:fetch.count]; + NSMutableDictionary *byDigest = [NSMutableDictionary dictionary]; + __block NSUInteger unavailable = 0; + NSUInteger reviewCount = MIN(fetch.count, maxAssets); + for (NSUInteger index = 0; index < reviewCount; index++) { + PHAsset *asset = [fetch objectAtIndex:index]; + (void)index; + NSDictionary *evidence = DSAssetEvidence(asset, maxBytes); + [assets addObject:evidence]; + NSString *digest = evidence[@"content_sha256"]; + if (digest) { + if (!byDigest[digest]) byDigest[digest] = [NSMutableArray array]; + [byDigest[digest] addObject:evidence]; + } else unavailable++; + } + NSMutableArray *groups = [NSMutableArray array]; + for (NSString *digest in [[byDigest allKeys] sortedArrayUsingSelector:@selector(compare:)]) { + NSArray *members = byDigest[digest]; + if (members.count > 1) [groups addObject:@{ @"content_sha256": digest, @"members": members, + @"keeper_required": @YES, @"automatic_delete_allowed": @NO }]; + } + NSDictionary *fingerprintSource = @{ @"assets": assets, @"unavailable_count": @(unavailable) }; + NSData *canonical = [NSJSONSerialization dataWithJSONObject:fingerprintSource options:NSJSONWritingSortedKeys error:nil]; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; CC_SHA256(canonical.bytes, (CC_LONG)canonical.length, digest); + BOOL truncated = fetch.count > reviewCount; + BOOL unsupportedCompound = [assets indexOfObjectPassingTest:^BOOL(NSDictionary *evidence, NSUInteger index, BOOL *stop) { + (void)index; (void)stop; + return [evidence[@"blocker"] isEqualToString:@"compound-photo-still-resource-ambiguous"]; + }] != NSNotFound; + return @{ @"authorization": DSStatus(status), @"observed_at_ms": @((uint64_t)(NSDate.date.timeIntervalSince1970 * 1000)), + @"inventory_fingerprint": DSHex(digest, sizeof(digest)), @"evidence_complete": @(!truncated && unavailable == 0), + @"inventory_truncated": @(truncated), + @"next_action": truncated ? @"reduce-photos-library-review-scope" : (unsupportedCompound ? @"exclude-unsupported-compound-assets-and-review-again" : (unavailable ? @"download-originals-in-photos" : (groups.count ? @"choose-one-photo-to-keep-per-group" : @"no-exact-duplicates-found"))), + @"assets": assets, @"exact_groups": groups, @"unavailable_count": @(unavailable), + @"near_duplicate_evidence": @"unavailable-without-measured-content-equivalence" }; +} + +char *ds_photos_authorization_status(void) { + return DSJSON(@{ @"authorization": DSStatus([PHPhotoLibrary authorizationStatusForAccessLevel:PHAccessLevelReadWrite]) }); +} + +char *ds_photos_request_authorization(void) { + dispatch_semaphore_t done = dispatch_semaphore_create(0); + __block PHAuthorizationStatus result = PHAuthorizationStatusNotDetermined; + [PHPhotoLibrary requestAuthorizationForAccessLevel:PHAccessLevelReadWrite handler:^(PHAuthorizationStatus status) { + result = status; dispatch_semaphore_signal(done); + }]; + if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, DSAuthorizationTimeoutNanos)) != 0) + return DSJSON(@{ @"authorization": @"timed-out" }); + return DSJSON(@{ @"authorization": DSStatus(result) }); +} + +char *ds_photos_inventory(uint32_t maxAssets, uint64_t maxBytes) { + 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 }); + PHFetchOptions *options = [PHFetchOptions new]; + options.predicate = [NSPredicate predicateWithFormat:@"mediaType == %d", PHAssetMediaTypeImage]; + PHFetchResult *fetch = [PHAsset fetchAssetsWithOptions:options]; + if (offset == 0 || !DSPageIdentifiers || !DSPageInventoryIdentity) { + NSMutableArray *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 *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"; + 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]; + NSArray *identifiers = request[@"delete_identifiers"]; + NSDictionary *expected = request[@"expected_metadata_fingerprints"]; + NSDictionary *expectedContent = request[@"expected_content_sha256"]; + if (![identifiers isKindOfClass:NSArray.class] || !identifiers.count || ![expected isKindOfClass:NSDictionary.class] || + ![expectedContent isKindOfClass:NSDictionary.class] || expected.count != expectedContent.count) + return DSJSON(@{ @"error": @"photos-delete-request-invalid" }); + NSArray *reviewedIdentifiers = expected.allKeys; + NSDictionary *fresh = DSInventory(reviewedIdentifiers.count, [request[@"max_resource_bytes"] unsignedLongLongValue], reviewedIdentifiers); + NSArray *freshAssets = fresh[@"assets"]; + if (freshAssets.count != reviewedIdentifiers.count) return DSJSON(@{ @"error": @"photos-library-changed-review-again" }); + for (NSDictionary *asset in freshAssets) { + NSString *identifier = asset[@"local_identifier"]; + if (![expectedContent[identifier] isEqual:asset[@"content_sha256"]] || + ![expected[identifier] isEqual:asset[@"metadata_fingerprint"]]) + return DSJSON(@{ @"error": @"photos-library-changed-review-again" }); + } + PHFetchResult *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:identifiers options:nil]; + NSError *error = nil; + BOOL success = [[PHPhotoLibrary sharedPhotoLibrary] performChangesAndWait:^{ [PHAssetChangeRequest deleteAssets:fetch]; } error:&error]; + if (!success) return DSJSON(@{ @"error": @"photos-system-delete-not-completed", @"system_message": error.localizedDescription ?: @"" }); + return DSJSON(@{ @"deleted_identifiers": identifiers, @"system_confirmation_completed": @YES }); +} diff --git a/src-tauri/src/container_orphan_reclaim.rs b/src-tauri/src/container_orphan_reclaim.rs index 7debfeb15..c92893da1 100644 --- a/src-tauri/src/container_orphan_reclaim.rs +++ b/src-tauri/src/container_orphan_reclaim.rs @@ -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, executed_at_ms, before_available_bytes, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 16e2604eb..7b974b82c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -80,6 +80,8 @@ pub mod orphan; pub mod photo_duplicate; pub mod photo_duplicate_quarantine; pub mod photo_similarity_audit; +/// PhotoKit-only duplicate review and system-confirmed deletion for Apple Photos libraries. +pub mod photos_library; /// Privacy-safe desktop projection of read-only Podman reclaim evidence. pub mod podman_desktop; /// Distinct IPC registration for the privacy-safe Podman evidence contract. @@ -149,6 +151,12 @@ pub fn run() { photo_duplicate_quarantine::audit_exact_photo_duplicates, photo_duplicate_quarantine::plan_exact_photo_duplicate_quarantine, photo_duplicate_quarantine::execute_exact_photo_duplicate_quarantine, + photos_library::photos_authorization_status, + photos_library::request_photos_authorization, + 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, commands::disk_inventory, commands::ontology_coherence, diff --git a/src-tauri/src/photos_library.rs b/src-tauri/src/photos_library.rs new file mode 100644 index 000000000..7dfc3d74a --- /dev/null +++ b/src-tauri/src/photos_library.rs @@ -0,0 +1,880 @@ +//! Apple Photos duplicate review through PhotoKit; managed library files are never traversed. + +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::OpenOptions; +use std::io::Write; +use tauri::Manager; + +const SCHEMA_VERSION: u32 = 1; +const MAX_INVENTORY_ASSETS: u32 = 10_000; +const MAX_RESOURCE_BYTES: u64 = 512 * 1024 * 1024; +const MAX_PLAN_AGE_MS: u64 = 5 * 60 * 1_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhotosAuthorization { + pub authorization: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhotosAssetEvidence { + pub local_identifier: String, + pub width_pixels: u64, + pub height_pixels: u64, + pub pixel_count: u64, + pub creation_ms: Option, + pub modification_ms: Option, + pub state: String, + pub blocker: Option, + pub content_sha256: Option, + pub encoded_bytes: Option, + pub original_filename: Option, + pub uniform_type_identifier: Option, + pub resource_type: Option, + pub metadata_fingerprint: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhotosExactGroup { + pub content_sha256: String, + pub members: Vec, + pub keeper_required: bool, + pub automatic_delete_allowed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhotosDuplicateInventory { + pub authorization: String, + pub observed_at_ms: Option, + pub inventory_fingerprint: Option, + pub evidence_complete: bool, + pub inventory_truncated: bool, + pub next_action: String, + pub assets: Vec, + pub exact_groups: Vec, + pub unavailable_count: u64, + pub near_duplicate_evidence: Option, + #[serde(default)] + pub inventory_total_count: Option, + #[serde(default)] + pub inventory_page_identity: 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 inventory_identity: String, + pub offset: u64, + pub next_offset: Option, + pub native_completion_observed: bool, + pub page_duration_ms: u64, + pub assets: Vec, + pub unavailable_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhotosInventoryCheckpoint { + pub next_offset: u64, + pub total_count: u64, + pub inventory_identity: String, +} + +/// 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), + inventory_page_identity: Some(page.inventory_identity.clone()), + }); + if inventory.authorization != page.authorization + || inventory.inventory_total_count != Some(page.total_count) + || inventory.inventory_page_identity.as_deref() != Some(&page.inventory_identity) + || 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)] +#[serde(deny_unknown_fields)] +pub struct PhotosKeeperSelection { + pub content_sha256: String, + pub keeper_local_identifier: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhotosDeletionPlan { + pub schema_version: u32, + pub inventory_fingerprint: String, + pub observed_at_ms: u64, + pub plan_fingerprint: String, + pub delete_identifiers: Vec, + pub expected_metadata_fingerprints: BTreeMap, + pub expected_content_sha256: BTreeMap, + pub logical_candidate_bytes: u64, + pub exact_approval_phrase: String, + pub max_resource_bytes: u64, + pub permanent_delete_requested: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhotosDeletionReceipt { + pub schema_version: u32, + pub receipt_id: String, + pub plan_fingerprint: String, + pub executed_at_ms: u64, + pub rationale: String, + pub deleted_count: usize, + pub system_confirmation_completed: bool, + pub permanent_delete_requested: bool, + pub next_action: String, +} + +#[derive(Serialize)] +struct PhotosDeletionReceiptRecord<'a> { + phase: &'static str, + receipt: &'a PhotosDeletionReceipt, +} + +fn append_receipt_record( + file: &mut std::fs::File, + phase: &'static str, + receipt: &PhotosDeletionReceipt, +) -> Result<(), String> { + let mut bytes = serde_json::to_vec(&PhotosDeletionReceiptRecord { phase, receipt }) + .map_err(|_| "photos-receipt-serialization-failed".to_string())?; + bytes.push(b'\n'); + file.write_all(&bytes) + .map_err(|_| "photos-receipt-write-failed".to_string())?; + file.sync_all() + .map_err(|_| "photos-receipt-sync-failed".to_string()) +} + +fn prepare_receipt_file_with( + mut file: std::fs::File, + path: &std::path::Path, + append: impl FnOnce(&mut std::fs::File) -> Result<(), String>, +) -> Result { + if let Err(error) = append(&mut file) { + // Keep the create-new handle alive until its pathname is unlinked. Closing first would + // let a concurrent retry create the deterministic path and have this attempt remove it. + let _ = std::fs::remove_file(path); + drop(file); + return Err(error); + } + Ok(file) +} + +fn hash_json(value: &T) -> Result { + let bytes = serde_json::to_vec(value).map_err(|_| "photos-evidence-serialization-failed")?; + Ok(blake3::hash(&bytes).to_hex().to_string()) +} + +pub fn plan_deletion( + inventory: &PhotosDuplicateInventory, + selections: &[PhotosKeeperSelection], +) -> Result { + if inventory.authorization != "authorized" && inventory.authorization != "limited" { + return Err("photos-access-not-authorized".into()); + } + if !inventory.evidence_complete + || inventory.inventory_truncated + || inventory.unavailable_count != 0 + { + return Err("photos-inventory-incomplete-review-again".into()); + } + let inventory_fingerprint = inventory + .inventory_fingerprint + .clone() + .ok_or("photos-inventory-fingerprint-missing")?; + let observed_at_ms = inventory + .observed_at_ms + .ok_or("photos-inventory-observation-missing")?; + let selected = selections + .iter() + .map(|item| { + ( + item.content_sha256.as_str(), + item.keeper_local_identifier.as_str(), + ) + }) + .collect::>(); + if selected.len() != selections.len() || selected.len() != inventory.exact_groups.len() { + return Err("photos-one-keeper-required-per-group".into()); + } + let mut delete_identifiers = Vec::new(); + let mut expected_metadata_fingerprints = BTreeMap::new(); + let mut expected_content_sha256 = BTreeMap::new(); + let mut logical_candidate_bytes = 0u64; + for group in &inventory.exact_groups { + if !group.keeper_required || group.automatic_delete_allowed || group.members.len() < 2 { + return Err("photos-duplicate-group-invalid".into()); + } + let keeper = selected + .get(group.content_sha256.as_str()) + .ok_or("photos-one-keeper-required-per-group")?; + if !group + .members + .iter() + .any(|member| member.local_identifier == *keeper) + { + return Err("photos-keeper-not-in-group".into()); + } + for member in &group.members { + let fingerprint = member + .metadata_fingerprint + .clone() + .ok_or("photos-member-evidence-incomplete")?; + let content_sha256 = member + .content_sha256 + .clone() + .ok_or("photos-member-evidence-incomplete")?; + if content_sha256 != group.content_sha256 { + return Err("photos-duplicate-content-evidence-invalid".into()); + } + if expected_metadata_fingerprints + .insert(member.local_identifier.clone(), fingerprint) + .is_some() + || expected_content_sha256 + .insert(member.local_identifier.clone(), content_sha256) + .is_some() + { + return Err("photos-member-repeated-across-groups".into()); + } + if member.local_identifier == *keeper { + continue; + } + logical_candidate_bytes = logical_candidate_bytes + .checked_add( + member + .encoded_bytes + .ok_or("photos-member-evidence-incomplete")?, + ) + .ok_or("photos-logical-bytes-overflow")?; + delete_identifiers.push(member.local_identifier.clone()); + } + } + if delete_identifiers.is_empty() { + return Err("photos-no-duplicate-selected".into()); + } + delete_identifiers.sort(); + let exact_approval_phrase = format!( + "DELETE {} PHOTOS FROM PHOTOS {}", + delete_identifiers.len(), + &inventory_fingerprint[..inventory_fingerprint.len().min(12)] + ); + let mut plan = PhotosDeletionPlan { + schema_version: SCHEMA_VERSION, + inventory_fingerprint, + observed_at_ms, + plan_fingerprint: String::new(), + exact_approval_phrase, + delete_identifiers, + expected_metadata_fingerprints, + expected_content_sha256, + logical_candidate_bytes, + max_resource_bytes: MAX_RESOURCE_BYTES, + permanent_delete_requested: false, + }; + plan.plan_fingerprint = hash_json(&plan)?; + Ok(plan) +} + +fn validate_execution( + inventory: &PhotosDuplicateInventory, + plan: &PhotosDeletionPlan, + approval_phrase: &str, + rationale: &str, + executed_at_ms: u64, +) -> Result<(), String> { + if plan.schema_version != SCHEMA_VERSION + || plan.permanent_delete_requested + || inventory.inventory_fingerprint.as_deref() != Some(&plan.inventory_fingerprint) + || plan.plan_fingerprint.is_empty() + { + return Err("photos-plan-invalid".into()); + } + let mut unsigned = plan.clone(); + unsigned.plan_fingerprint.clear(); + if hash_json(&unsigned)? != plan.plan_fingerprint { + return Err("photos-plan-fingerprint-invalid".into()); + } + if approval_phrase != plan.exact_approval_phrase { + return Err("photos-exact-approval-required".into()); + } + let rationale = rationale.trim(); + if rationale.is_empty() || rationale.chars().count() > 1_000 { + return Err("photos-rationale-required".into()); + } + if executed_at_ms < plan.observed_at_ms + || executed_at_ms - plan.observed_at_ms > MAX_PLAN_AGE_MS + { + return Err("photos-review-expired-review-again".into()); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +mod native { + use super::*; + use std::ffi::{CStr, CString}; + use std::os::raw::c_char; + + extern "C" { + 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; + } + + fn take_json Deserialize<'de>>(pointer: *mut c_char) -> Result { + if pointer.is_null() { + return Err("photos-native-response-missing".into()); + } + let bytes = unsafe { CStr::from_ptr(pointer).to_bytes().to_vec() }; + unsafe { libc::free(pointer.cast()) }; + serde_json::from_slice(&bytes).map_err(|_| "photos-native-response-invalid".into()) + } + + pub fn status() -> Result { + take_json(unsafe { ds_photos_authorization_status() }) + } + + pub fn authorize() -> Result { + take_json(unsafe { ds_photos_request_authorization() }) + } + + pub fn inventory() -> Result { + 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>, + system_confirmation_completed: Option, + error: Option, + } + + pub fn delete(plan: &PhotosDeletionPlan) -> Result<(), String> { + let json = + CString::new(serde_json::to_vec(plan).map_err(|_| "photos-delete-request-invalid")?) + .map_err(|_| "photos-delete-request-invalid")?; + let response: NativeDeleteResult = take_json(unsafe { ds_photos_delete(json.as_ptr()) })?; + if let Some(error) = response.error { + return Err(error); + } + let deleted = response + .deleted_identifiers + .ok_or("photos-delete-result-incomplete")?; + if response.system_confirmation_completed != Some(true) + || deleted.iter().cloned().collect::>() + != plan.delete_identifiers.iter().cloned().collect() + { + return Err("photos-delete-result-incomplete".into()); + } + Ok(()) + } + + #[cfg(test)] + pub fn select_still_resource(types: &[i64]) -> i64 { + unsafe { ds_photos_select_still_resource_index(types.as_ptr(), types.len()) } + } +} + +#[cfg(not(target_os = "macos"))] +mod native { + use super::*; + pub fn status() -> Result { + Err("photos-library-macos-only".into()) + } + pub fn authorize() -> Result { + Err("photos-library-macos-only".into()) + } + 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()) + } +} + +#[tauri::command] +pub async fn photos_authorization_status() -> Result { + tauri::async_runtime::spawn_blocking(native::status) + .await + .map_err(|_| "photos-operation-interrupted".to_string())? +} + +/// Request read/write permission only when invoked by the customer's Photos connection action. +#[tauri::command] +pub async fn request_photos_authorization() -> Result { + tauri::async_runtime::spawn_blocking(native::authorize) + .await + .map_err(|_| "photos-operation-interrupted".to_string())? +} + +#[tauri::command] +pub async fn inspect_photos_duplicates() -> Result { + tauri::async_runtime::spawn_blocking(native::inventory) + .await + .map_err(|_| "photos-operation-interrupted".to_string())? +} + +#[tauri::command] +pub async fn inspect_photos_duplicates_page( + checkpoint: Option, +) -> Result { + let offset = checkpoint.as_ref().map_or(0, |value| value.next_offset); + let page = tauri::async_runtime::spawn_blocking(move || native::inventory_page(offset)) + .await + .map_err(|_| "photos-operation-interrupted".to_string())??; + if !matches!(page.authorization.as_str(), "authorized" | "limited") { + return Err("photos-authorization-required".into()); + } + if checkpoint.as_ref().is_some_and(|value| { + value.total_count != page.total_count + || value.inventory_identity != page.inventory_identity + || value.next_offset != page.offset + }) { + return Err("photos-page-checkpoint-mismatch".into()); + } + Ok(page) +} + +#[tauri::command] +pub fn finalize_photos_duplicate_inventory( + pages: Vec, +) -> Result { + let mut inventory = None; + for page in pages { + inventory = Some(merge_inventory_page(inventory, page)?); + } + let inventory = inventory.ok_or("photos-page-checkpoint-empty")?; + if inventory.inventory_truncated { + return Err("photos-page-checkpoint-incomplete".into()); + } + Ok(inventory) +} + +#[tauri::command] +pub fn plan_photos_duplicate_deletion( + inventory: PhotosDuplicateInventory, + selections: Vec, +) -> Result { + plan_deletion(&inventory, &selections) +} + +#[tauri::command] +pub async fn execute_photos_duplicate_deletion( + app: tauri::AppHandle, + inventory: PhotosDuplicateInventory, + plan: PhotosDeletionPlan, + approval_phrase: String, + rationale: String, + executed_at_ms: u64, +) -> Result { + validate_execution( + &inventory, + &plan, + &approval_phrase, + &rationale, + executed_at_ms, + )?; + let mut receipt = PhotosDeletionReceipt { + schema_version: SCHEMA_VERSION, + receipt_id: String::new(), + plan_fingerprint: plan.plan_fingerprint.clone(), + executed_at_ms, + rationale: rationale.trim().to_string(), + deleted_count: plan.delete_identifiers.len(), + system_confirmation_completed: true, + permanent_delete_requested: false, + next_action: "open-recently-deleted-to-restore-or-review-space".into(), + }; + receipt.receipt_id = hash_json(&receipt)?; + let directory = app + .path() + .app_data_dir() + .map_err(|_| "photos-receipt-directory-unavailable")? + .join("photos-receipts"); + std::fs::create_dir_all(&directory).map_err(|_| "photos-receipt-directory-unavailable")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)) + .map_err(|_| "photos-receipt-directory-unavailable")?; + } + let path = directory.join(format!("{}.json", receipt.receipt_id)); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options + .open(&path) + .map_err(|_| "photos-receipt-create-failed")?; + let mut prepared_receipt = receipt.clone(); + prepared_receipt.system_confirmation_completed = false; + let mut file = prepare_receipt_file_with(file, &path, |file| { + append_receipt_record(file, "prepared", &prepared_receipt) + })?; + let native_plan = plan.clone(); + let native_result = + match tauri::async_runtime::spawn_blocking(move || native::delete(&native_plan)).await { + Ok(result) => result, + Err(_) => Err("photos-operation-interrupted".to_string()), + }; + if let Err(error) = native_result { + drop(file); + let _ = std::fs::remove_file(path); + return Err(error); + } + append_receipt_record(&mut file, "completed", &receipt)?; + Ok(receipt) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn member(id: &str, bytes: u64) -> PhotosAssetEvidence { + PhotosAssetEvidence { + local_identifier: id.into(), + width_pixels: 4000, + height_pixels: 3000, + pixel_count: 12_000_000, + creation_ms: Some(1), + modification_ms: Some(2), + state: "local-current".into(), + blocker: None, + content_sha256: Some("a".repeat(64)), + encoded_bytes: Some(bytes), + original_filename: Some(format!("{id}.heic")), + uniform_type_identifier: Some("public.heic".into()), + resource_type: Some(1), + metadata_fingerprint: Some(format!("fingerprint-{id}")), + } + } + + fn inventory() -> PhotosDuplicateInventory { + let members = vec![member("keep", 10), member("remove", 8)]; + PhotosDuplicateInventory { + authorization: "authorized".into(), + observed_at_ms: Some(100), + inventory_fingerprint: Some("inventory".into()), + evidence_complete: true, + inventory_truncated: false, + next_action: "choose-one-photo-to-keep-per-group".into(), + assets: members.clone(), + exact_groups: vec![PhotosExactGroup { + content_sha256: "a".repeat(64), + members, + keeper_required: true, + automatic_delete_allowed: false, + }], + unavailable_count: 0, + near_duplicate_evidence: Some( + "unavailable-without-measured-content-equivalence".into(), + ), + inventory_total_count: Some(2), + inventory_page_identity: Some("stable-library".into()), + } + } + + fn page(offset: u64, total: u64, asset: PhotosAssetEvidence) -> PhotosInventoryPage { + PhotosInventoryPage { + authorization: "authorized".into(), + observed_at_ms: 100 + offset, + total_count: total, + inventory_identity: "stable-library".into(), + 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 equal_count_library_replacement_invalidates_checkpoint_identity() { + let first = merge_inventory_page(None, page(0, 2, member("keep", 10))).unwrap(); + let mut replaced = page(1, 2, member("replacement", 8)); + replaced.inventory_identity = "changed-library".into(); + assert_eq!( + merge_inventory_page(Some(first), replaced).unwrap_err(), + "photos-page-checkpoint-mismatch" + ); + } + + #[test] + fn plan_requires_explicit_keeper_and_preserves_it() { + let inventory = inventory(); + assert_eq!( + plan_deletion(&inventory, &[]).unwrap_err(), + "photos-one-keeper-required-per-group" + ); + let plan = plan_deletion( + &inventory, + &[PhotosKeeperSelection { + content_sha256: "a".repeat(64), + keeper_local_identifier: "keep".into(), + }], + ) + .unwrap(); + assert_eq!(plan.delete_identifiers, ["remove"]); + assert_eq!(plan.logical_candidate_bytes, 8); + assert!(!plan.permanent_delete_requested); + } + + #[test] + fn icloud_only_asset_blocks_destructive_planning() { + let mut inventory = inventory(); + inventory.unavailable_count = 1; + let mut cloud = member("cloud", 20); + cloud.state = "icloud-only-or-unavailable".into(); + cloud.blocker = Some("download-original-in-photos".into()); + cloud.content_sha256 = None; + cloud.encoded_bytes = None; + cloud.metadata_fingerprint = None; + inventory.assets.push(cloud); + inventory.evidence_complete = false; + assert_eq!( + plan_deletion(&inventory, &[]).unwrap_err(), + "photos-inventory-incomplete-review-again" + ); + } + + #[test] + fn execution_rejects_stale_or_inexact_approval() { + let inventory = inventory(); + let plan = plan_deletion( + &inventory, + &[PhotosKeeperSelection { + content_sha256: "a".repeat(64), + keeper_local_identifier: "keep".into(), + }], + ) + .unwrap(); + assert_eq!( + validate_execution(&inventory, &plan, "wrong", "duplicate", 100).unwrap_err(), + "photos-exact-approval-required" + ); + assert_eq!( + validate_execution( + &inventory, + &plan, + &plan.exact_approval_phrase, + "duplicate", + MAX_PLAN_AGE_MS + 101 + ) + .unwrap_err(), + "photos-review-expired-review-again" + ); + } + + #[test] + fn durable_receipt_records_prepared_then_completed_outcome() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("receipt.jsonl"); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + let mut receipt = PhotosDeletionReceipt { + schema_version: SCHEMA_VERSION, + receipt_id: "receipt".into(), + plan_fingerprint: "plan".into(), + executed_at_ms: 1, + rationale: "reviewed duplicate".into(), + deleted_count: 1, + system_confirmation_completed: false, + permanent_delete_requested: false, + next_action: "open-recently-deleted-to-restore-or-review-space".into(), + }; + append_receipt_record(&mut file, "prepared", &receipt).unwrap(); + receipt.system_confirmation_completed = true; + append_receipt_record(&mut file, "completed", &receipt).unwrap(); + let records = std::fs::read_to_string(path).unwrap(); + let records = records + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(records.len(), 2); + assert_eq!(records[0]["phase"], "prepared"); + assert_eq!( + records[0]["receipt"]["system_confirmation_completed"], + false + ); + assert_eq!(records[1]["phase"], "completed"); + assert_eq!(records[1]["receipt"]["system_confirmation_completed"], true); + } + + #[test] + fn failed_receipt_preparation_removes_retry_blocking_path() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("receipt.jsonl"); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + let error = prepare_receipt_file_with(file, &path, |file| { + file.write_all(b"partial").unwrap(); + assert!(OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .is_err()); + Err("photos-receipt-sync-failed".into()) + }) + .unwrap_err(); + assert_eq!(error, "photos-receipt-sync-failed"); + assert!(!path.exists()); + OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .unwrap(); + } + + #[test] + fn native_boundary_keeps_local_reads_offline_without_an_arbitrary_timeout() { + let source = include_str!("../native/photos_bridge.m"); + assert!(source.contains("networkAccessAllowed = NO")); + assert!(source.contains("dispatch_semaphore_wait(done, DISPATCH_TIME_FOREVER)")); + assert!(!source.contains("cancelDataRequest:requestID")); + assert!(source.contains("DSAuthorizationTimeoutNanos")); + } + + #[cfg(target_os = "macos")] + #[test] + fn native_still_selection_executes_live_photo_and_ambiguous_cases() { + assert_eq!(native::select_still_resource(&[1, 9]), 0); + assert_eq!(native::select_still_resource(&[9, 5]), 1); + assert_eq!(native::select_still_resource(&[1, 1, 9]), -1); + assert_eq!(native::select_still_resource(&[9]), -1); + } +} diff --git a/src/lib/Duplicates.svelte b/src/lib/Duplicates.svelte index d3b3e6491..b48275526 100644 --- a/src/lib/Duplicates.svelte +++ b/src/lib/Duplicates.svelte @@ -5,6 +5,7 @@ import { verdictBadge } from "./verdictBadge"; import { confirm } from "@tauri-apps/plugin-dialog"; import ExactPhotoReview from "./ExactPhotoReview.svelte"; + import PhotosLibraryReview from "./PhotosLibraryReview.svelte"; let { scannedRoot }: { scannedRoot: string | null } = $props(); @@ -137,6 +138,7 @@ {#if groups.length > 0 && scannedRoot} {/if} + {#if results.length > 0}

{results.filter((r) => r.ok).length}/{results.length}개 휴지통으로 이동 — 복원 가능합니다.

diff --git a/src/lib/PhotosLibraryReview.svelte b/src/lib/PhotosLibraryReview.svelte new file mode 100644 index 000000000..0743064e3 --- /dev/null +++ b/src/lib/PhotosLibraryReview.svelte @@ -0,0 +1,175 @@ + + +
+
+

Apple Photos

사진 앱의 정확한 사본 정리

+ {#if authorization !== "authorized" && authorization !== "limited"} + + {:else} + {#if inspecting} + + {:else} + + {/if} + {/if} +
+

사진 앱이 관리하는 원본만 안전하게 확인합니다. 이 Mac에 없는 원본은 다운로드하거나 삭제하지 않습니다.

+
{status}
+ {#if error}{/if} + + {#if inventory?.unavailable_count} + {#if inventory.next_action === "exclude-unsupported-compound-assets-and-review-again"} + + {:else} + + {/if} + {/if} + {#if inventory?.inventory_truncated} +

사진 확인을 잠시 멈췄습니다. 이 화면에서 사진 사본 확인을 다시 눌러 이어서 확인하세요.

+ {/if} + {#each inventory?.exact_groups ?? [] as group, index (group.content_sha256)} +
+ 사본 그룹 {index + 1} · {group.members.length}개 +

내용 해시가 정확히 같습니다. 해상도·픽셀 수·파일 크기는 각각 표시하며 합산 점수는 사용하지 않습니다.

+ {#each group.members as member (member.local_identifier)} + + {/each} +
+ {/each} + {#if inventory?.exact_groups.length} + + {/if} + {#if plan} +
+

{plan.delete_identifiers.length}개 · 논리 {fmtBytes(plan.logical_candidate_bytes)} · 사진 앱의 확인 전에는 삭제되지 않습니다.

+ {plan.exact_approval_phrase} + + + +
+ {/if} + {#if receipt}

완료 기록 {receipt.receipt_id.slice(0, 12)} · 사진 앱의 최근 삭제된 항목에서 복원할 수 있습니다.

{/if} +
+ + diff --git a/src/lib/api.ts b/src/lib/api.ts index 1d9ed6171..f57c1495f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -218,6 +218,56 @@ export const executeExactPhotoDuplicateQuarantine = ( ) => invoke("execute_exact_photo_duplicate_quarantine", { audit, plan, approvalPhrase, rationale, executedAtMs, }); + +export interface PhotosAuthorization { authorization: string } +export interface PhotosAssetEvidence { + local_identifier: string; width_pixels: number; height_pixels: number; pixel_count: number; + creation_ms: number | null; modification_ms: number | null; state: string; blocker: string | null; + content_sha256: string | null; encoded_bytes: number | null; original_filename: string | null; + uniform_type_identifier: string | null; resource_type: number | null; metadata_fingerprint: string | null; +} +export interface PhotosExactGroup { + content_sha256: string; members: PhotosAssetEvidence[]; keeper_required: boolean; + automatic_delete_allowed: boolean; +} +export interface PhotosDuplicateInventory { + authorization: string; observed_at_ms: number | null; inventory_fingerprint: string | null; + evidence_complete: boolean; inventory_truncated: boolean; next_action: string; assets: PhotosAssetEvidence[]; + exact_groups: PhotosExactGroup[]; unavailable_count: number; near_duplicate_evidence: string | null; + inventory_total_count?: number | null; + inventory_page_identity?: string | null; +} +export interface PhotosInventoryCheckpoint { + next_offset: number; total_count: number; inventory_identity: string; +} +export interface PhotosInventoryPage { + authorization: string; observed_at_ms: number; total_count: number; offset: number; + next_offset: number | null; inventory_identity: string; native_completion_observed: boolean; + page_duration_ms: number; assets: PhotosAssetEvidence[]; unavailable_count: number; +} +export interface PhotosKeeperSelection { content_sha256: string; keeper_local_identifier: string } +export interface PhotosDeletionPlan { + plan_fingerprint: string; delete_identifiers: string[]; logical_candidate_bytes: number; + exact_approval_phrase: string; permanent_delete_requested: false; +} +export interface PhotosDeletionReceipt { + receipt_id: string; deleted_count: number; system_confirmation_completed: boolean; + permanent_delete_requested: false; next_action: string; +} +export const photosAuthorizationStatus = () => invoke("photos_authorization_status"); +export const requestPhotosAuthorization = () => invoke("request_photos_authorization"); +export const inspectPhotosDuplicatesPage = (checkpoint: PhotosInventoryCheckpoint | null) => + invoke("inspect_photos_duplicates_page", { checkpoint }); +export const finalizePhotosDuplicateInventory = (pages: PhotosInventoryPage[]) => + invoke("finalize_photos_duplicate_inventory", { pages }); +export const planPhotosDuplicateDeletion = (inventory: PhotosDuplicateInventory, selections: PhotosKeeperSelection[]) => + invoke("plan_photos_duplicate_deletion", { inventory, selections }); +export const executePhotosDuplicateDeletion = ( + inventory: PhotosDuplicateInventory, plan: PhotosDeletionPlan, approvalPhrase: string, + rationale: string, executedAtMs: number, +) => invoke("execute_photos_duplicate_deletion", { + inventory, plan, approvalPhrase, rationale, executedAtMs, +}); export const planOrphanCleanup = () => invoke("plan_orphan_cleanup"); export const cleanOrphanCandidates = ( planFingerprint: string, diff --git a/src/lib/photosLibraryState.test.ts b/src/lib/photosLibraryState.test.ts new file mode 100644 index 000000000..f20de3231 --- /dev/null +++ b/src/lib/photosLibraryState.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + photosApprovalReady, + photosAuthorizationAfterInspectionFailure, + photosSelections, +} from "./photosLibraryState"; +import type { PhotosDuplicateInventory } from "./api"; + +const inventory = { + exact_groups: [{ content_sha256: "digest", members: [], keeper_required: true, automatic_delete_allowed: false }], +} as unknown as PhotosDuplicateInventory; + +describe("Apple Photos review state", () => { + it("requires one explicit keeper and exact fresh approval inputs", () => { + expect(photosSelections(inventory, {})).toBeNull(); + expect(photosSelections(inventory, { digest: "asset-1" })).toEqual([ + { content_sha256: "digest", keeper_local_identifier: "asset-1" }, + ]); + const plan = { exact_approval_phrase: "DELETE 1 PHOTOS FROM PHOTOS" } as never; + expect(photosApprovalReady(plan, "DELETE 1 PHOTOS FROM PHOTOS", "same photo")).toBe(true); + expect(photosApprovalReady(plan, "DELETE", "same photo")).toBe(false); + }); + + it("offers reconnection when access is revoked during inspection", () => { + expect(photosAuthorizationAfterInspectionFailure("authorized", "photos-authorization-required")) + .toBe("unavailable"); + expect(photosAuthorizationAfterInspectionFailure("authorized", "photos-page-checkpoint-mismatch")) + .toBe("authorized"); + }); + +}); diff --git a/src/lib/photosLibraryState.ts b/src/lib/photosLibraryState.ts new file mode 100644 index 000000000..462751079 --- /dev/null +++ b/src/lib/photosLibraryState.ts @@ -0,0 +1,18 @@ +import type { PhotosDeletionPlan, PhotosDuplicateInventory, PhotosKeeperSelection } from "./api"; + +export const photosSelections = ( + inventory: PhotosDuplicateInventory, + keepers: Record, +): PhotosKeeperSelection[] | null => { + const selections = inventory.exact_groups.map((group) => ({ + content_sha256: group.content_sha256, + keeper_local_identifier: keepers[group.content_sha256] ?? "", + })); + return selections.every((selection) => selection.keeper_local_identifier) ? selections : null; +}; + +export const photosApprovalReady = (plan: PhotosDeletionPlan, approval: string, rationale: string) => + approval === plan.exact_approval_phrase && rationale.trim().length > 0; + +export const photosAuthorizationAfterInspectionFailure = (current: string, reason: unknown) => + String(reason).includes("photos-authorization-required") ? "unavailable" : current;