From 86e6ea1d8051dc8c3f9a90aa55d68b3a01d5a425 Mon Sep 17 00:00:00 2001 From: zhukang <274546966@qq.com> Date: Fri, 13 Mar 2026 16:40:31 +0800 Subject: [PATCH] fix(recovery): preflight restore items before mutating state Add validation pass that checks all selected recovery items before any restore operations begin. This prevents partial in-memory restore success when a later item fails. Map helper-backed restore destination conflicts to restore-specific rejection paths instead of falling back to generic execution-unavailable messages. Bump version to 1.0.1 and update CHANGELOG with release notes. --- .../Sources/AtlasApp/AtlasAppModel.swift | 4 +- Atlas.xcodeproj/project.pbxproj | 16 +-- CHANGELOG.md | 9 ++ ...Internal-Beta-Hardening-Week-2026-03-16.md | 42 +++--- .../AtlasInfrastructure.swift | 85 ++++++++++-- .../AtlasInfrastructureTests.swift | 127 ++++++++++++++++++ project.yml | 8 +- 7 files changed, 251 insertions(+), 40 deletions(-) diff --git a/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift b/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift index 9931ed0..c5bb3fb 100644 --- a/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift +++ b/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift @@ -100,11 +100,11 @@ final class AtlasAppModel: ObservableObject { } var appVersion: String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.1" } var appBuild: String { - Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "2" } func checkForUpdate() async { diff --git a/Atlas.xcodeproj/project.pbxproj b/Atlas.xcodeproj/project.pbxproj index 9ba71b7..f77247a 100644 --- a/Atlas.xcodeproj/project.pbxproj +++ b/Atlas.xcodeproj/project.pbxproj @@ -458,7 +458,7 @@ buildSettings = { AD_HOC_CODE_SIGNING_ALLOWED = YES; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)"; INFOPLIST_KEY_CFBundleVersion = "$(CURRENT_PROJECT_VERSION)"; @@ -467,7 +467,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app.worker; PRODUCT_NAME = AtlasWorkerXPC; SDKROOT = macosx; @@ -535,7 +535,7 @@ buildSettings = { AD_HOC_CODE_SIGNING_ALLOWED = YES; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)"; INFOPLIST_KEY_CFBundleVersion = "$(CURRENT_PROJECT_VERSION)"; @@ -544,7 +544,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app.worker; PRODUCT_NAME = AtlasWorkerXPC; SDKROOT = macosx; @@ -557,7 +557,7 @@ AD_HOC_CODE_SIGNING_ALLOWED = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Atlas for Mac"; INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)"; @@ -569,7 +569,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app; PRODUCT_MODULE_NAME = AtlasApp; PRODUCT_NAME = "Atlas for Mac"; @@ -665,7 +665,7 @@ AD_HOC_CODE_SIGNING_ALLOWED = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Atlas for Mac"; INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)"; @@ -677,7 +677,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app; PRODUCT_MODULE_NAME = AtlasApp; PRODUCT_NAME = "Atlas for Mac"; diff --git a/CHANGELOG.md b/CHANGELOG.md index 34ceb78..3cc34c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [1.0.1] - 2026-03-13 + ### Added - Native macOS app with 7 MVP modules: Overview, Smart Clean, Apps, History, Recovery, Permissions, Settings @@ -22,6 +24,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Go-based TUI tools inherited from upstream Mole: disk analyzer (`analyze`) and system monitor (`status`) - CI/CD: GitHub Actions for formatting, linting, testing, CodeQL scanning, and release packaging +### Fixed + +- Recovery restore requests now preflight every selected item before Atlas mutates local recovery state, preventing partial in-memory restore success when a later item fails. +- Helper-backed restore destination conflicts now surface restore-specific errors instead of falling back to a generic execution-unavailable message. +- Expired recovery items are pruned from persisted state and rejected with explicit restore-expired messaging. +- Revalidated the current native release candidate with package tests, app tests, DMG install verification, launch smoke, and native UI automation. + ### Attribution - Built in part on the open-source [Mole](https://github.com/tw93/mole) project (MIT) by tw93 and contributors diff --git a/Docs/Execution/Internal-Beta-Hardening-Week-2026-03-16.md b/Docs/Execution/Internal-Beta-Hardening-Week-2026-03-16.md index 5dceda2..3af99fb 100644 --- a/Docs/Execution/Internal-Beta-Hardening-Week-2026-03-16.md +++ b/Docs/Execution/Internal-Beta-Hardening-Week-2026-03-16.md @@ -119,6 +119,8 @@ Do not expand into: - Added one new real Smart Clean execute target class for `~/Library/pnpm/store/*`. - Added stronger worker-side truthfulness so Atlas only records recovery/history side effects when a real file move happened. +- Hardened recovery restore semantics so batched restore requests preflight all selected items before mutating Atlas state. +- Mapped helper-backed restore destination conflicts back to the restore-specific rejection path instead of the generic execution-unavailable path. - Split History recovery messaging between: - file-backed restore entries with `restoreMappings` - Atlas-only recovery entries with no supported on-disk restore path @@ -126,17 +128,17 @@ Do not expand into: ### Current blocker -- Interactive bilingual UI automation on this machine is **blocked** by macOS Accessibility trust for the current terminal process. -- `./scripts/atlas/ui-automation-preflight.sh` reported `Accessibility trusted for current process: false` on **2026-03-12**. +- Local UI automation is no longer a hard blocker on this Mac. +- `./scripts/atlas/full-acceptance.sh` passed on **2026-03-13** for the latest recovery-fix candidate build. -This means the packaged-build install and fresh-state launch checks below are complete, but a full click-through clean-machine bilingual UI walkthrough still requires either: +The remaining gap is narrower: -- Accessibility trust to be granted on this Mac, or -- a separate clean machine for the final interactive pass. +- a dedicated clean-machine bilingual manual walkthrough is still outstanding +- current packaged-build evidence is still machine-local, not evidence from a second physical clean Mac ## Packaged-Build Evidence -### Latest artifacts built on 2026-03-12 +### Latest artifacts built on 2026-03-13 - App: `dist/native/Atlas for Mac.app` - DMG: `dist/native/Atlas-for-Mac.dmg` @@ -147,16 +149,19 @@ This means the packaged-build install and fresh-state launch checks below are co ### Checksum record ```text -b85425649c5d781f234cdf1690ce01f330e3216d963cbf7d8f720a2e66611ffa Atlas-for-Mac.zip -2d5f480110d13f83c38e2296fafaa72617fc122d694d78c2c32c3a260f0ae110 Atlas-for-Mac.dmg -d71c45b0312ceeb045e390d851e246fe7f59e90961f2a482cfb21ee4f65d56ec Atlas-for-Mac.pkg +2d5988ac5f03d06b5f8ec2c94869752ad5c67de0f7eeb7b59aeb9e463f6cdee9 Atlas-for-Mac.zip +2b5d1a1b6636edcf180b5639bcf62734d25e05adcb405419f7a234ade3870c1e Atlas-for-Mac.dmg +e89f50a77d9148d18ca78d8a0fd005394fc5848e6ae6a5231700e1b180135495 Atlas-for-Mac.pkg ``` ### Verified commands -- `./scripts/atlas/package-native.sh` — **pass** on 2026-03-12 -- `KEEP_INSTALLED_APP=1 ./scripts/atlas/verify-dmg-install.sh` — **pass** on 2026-03-12 -- `STATE_DIR="$PWD/.build/atlas-hardening-fresh-state-2026-03-12" ./scripts/atlas/verify-app-launch.sh` — **pass** on 2026-03-12 +- `./scripts/atlas/full-acceptance.sh` — **pass** on 2026-03-13 +- `./scripts/atlas/package-native.sh` — **pass** on 2026-03-13 +- `./scripts/atlas/verify-bundle-contents.sh` — **pass** on 2026-03-13 +- `KEEP_INSTALLED_APP=1 ./scripts/atlas/verify-dmg-install.sh` — **pass** on 2026-03-13 +- `./scripts/atlas/verify-app-launch.sh` — **pass** on 2026-03-13 +- `./scripts/atlas/run-ui-automation.sh` — **pass** on 2026-03-13 for the latest recovery-fix candidate build ### Fresh-state file evidence @@ -170,19 +175,19 @@ This is a machine-local fresh-state packaged-build verification, not a claim of ### 1. Clean-machine bilingual QA -**Status:** `Partially complete / locally blocked` +**Status:** `Partially complete / manual clean-machine pass still pending` Completed evidence: - Packaged install path verified to `~/Applications/Atlas for Mac.app` - Fresh-state packaged launch verified with a brand-new workspace-state directory - Default first-launch language persisted as `zh-Hans` -- Language-switch persistence covered by app-model test evidence +- Language-switch persistence covered by app-model test evidence and local UI automation - Smart Clean, Apps, and Recovery trust paths covered by package and app tests listed below Remaining blocker: -- Interactive packaged-app UI walkthrough for first launch + bilingual control verification is blocked on local Accessibility trust +- A dedicated clean-machine bilingual manual walkthrough is still not recorded for this candidate build ### 2. Fresh-state verification with latest packaged build @@ -231,6 +236,7 @@ Behavior tightened so that: - `swift test --package-path Packages --filter MoleSmartCleanAdapterTests` — **pass** on 2026-03-12 - `swift test --package-path Packages --filter AtlasInfrastructureTests` — **pass** on 2026-03-12 +- `swift test --package-path Packages` — **pass** on 2026-03-13 after the recovery restore atomicity and helper-conflict fix Key tests: @@ -244,6 +250,7 @@ Key tests: ### App-model coverage - `swift test --package-path Apps --filter AtlasAppModelTests` — **pass** on 2026-03-12 +- `swift test --package-path Apps` — **pass** on 2026-03-13 after the recovery restore atomicity and helper-conflict fix Key tests: @@ -251,6 +258,7 @@ Key tests: - `testPreferredXPCWorkerPathFailsClosedWhenScanIsRejected` - `testExecuteCurrentPlanExposesExplicitExecutionIssueWhenWorkerRejectsExecution` - `testExecuteCurrentPlanOnlyRecordsRecoveryForRealSideEffects` +- `testRestoreExpiredRecoveryItemReloadsPersistedState` - `testRestoreRecoveryItemReturnsFindingToWorkspace` ## QA Matrix @@ -260,7 +268,7 @@ Key tests: | First launch | packaged app launch smoke with new state dir | Pass | | Install path | DMG install validation to `~/Applications` | Pass | | Default language | fresh packaged state file persisted `zh-Hans` | Pass | -| Language switching | app-model persistence test; UI click-through still blocked locally | Partial | +| Language switching | app-model persistence test plus local UI automation pass on 2026-03-13; clean-machine manual pass still pending | Partial | | Smart Clean execute | package tests + real file-backed contract tests | Pass | | Apps | app-model and infrastructure uninstall/recovery tests | Pass | | History / Recovery | file-backed vs Atlas-only summary/copy split + restore tests | Pass | @@ -281,6 +289,8 @@ Do not say: ## Files Changed for Hardening +- `Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift` +- `Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift` - `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift` - `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift` - `Packages/AtlasCoreAdapters/Sources/AtlasCoreAdapters/MoleSmartCleanAdapter.swift` diff --git a/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift b/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift index d2d72b7..aa48ca3 100644 --- a/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift +++ b/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift @@ -746,6 +746,22 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { ) } + do { + try validateRestoreItems(itemsToRestore) + } catch let failure as RecoveryRestoreFailure { + return rejectedResult( + for: request, + code: failure.code, + reason: failure.localizedDescription + ) + } catch { + return rejectedResult( + for: request, + code: .executionUnavailable, + reason: error.localizedDescription + ) + } + var physicalRestoreCount = 0 var atlasOnlyRestoreCount = 0 @@ -770,7 +786,9 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { } else { atlasOnlyRestoreCount += 1 } + } + for item in itemsToRestore { switch item.payload { case let .finding(finding): if !state.snapshot.findings.contains(where: { $0.id == finding.id }) { @@ -785,7 +803,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { } } - state.snapshot.recoveryItems.removeAll { itemIDs.contains($0.id) } + state.snapshot.recoveryItems.removeAll { requestedItemIDs.contains($0.id) } recalculateReclaimableSpace() state.currentPlan = makePreviewPlan(findingIDs: state.snapshot.findings.map(\.id)) @@ -932,6 +950,21 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { ) } + private func validateRestoreItems(_ items: [RecoveryItem]) throws { + for item in items { + guard let restoreMappings = item.restoreMappings, !restoreMappings.isEmpty else { + continue + } + try validateRestoreMappings(restoreMappings) + } + } + + private func validateRestoreMappings(_ restoreMappings: [RecoveryPathMapping]) throws { + for mapping in restoreMappings { + try validateRestoreTarget(mapping) + } + } + private func expiredRecoveryItemIDs(asOf now: Date? = nil) -> Set { let cutoff = now ?? nowProvider() return Set(state.snapshot.recoveryItems.compactMap { item in @@ -1065,32 +1098,64 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { } } - private func restoreRecoveryTarget(_ mapping: RecoveryPathMapping) async throws { + private func validateRestoreTarget(_ mapping: RecoveryPathMapping) throws { let sourceURL = URL(fileURLWithPath: mapping.trashedPath).resolvingSymlinksInPath() let destinationURL = URL(fileURLWithPath: mapping.originalPath).resolvingSymlinksInPath() guard FileManager.default.fileExists(atPath: sourceURL.path) else { throw RecoveryRestoreFailure.executionUnavailable("Recovery source is no longer available on disk: \(sourceURL.path)") } if shouldUseHelperForSmartCleanTarget(destinationURL) { - guard let helperExecutor else { + guard helperExecutor != nil else { throw RecoveryRestoreFailure.helperUnavailable("Bundled helper unavailable for recovery target: \(destinationURL.path)") } - let result = try await helperExecutor.perform(AtlasHelperAction(kind: .restoreItem, targetPath: sourceURL.path, destinationPath: destinationURL.path)) - guard result.success else { - throw RecoveryRestoreFailure.executionUnavailable(result.message) - } - return - } - guard isDirectlyTrashableSmartCleanTarget(destinationURL) else { + } else if !isDirectlyTrashableSmartCleanTarget(destinationURL) { throw RecoveryRestoreFailure.executionUnavailable("Recovery target is outside the supported execution allowlist: \(destinationURL.path)") } if FileManager.default.fileExists(atPath: destinationURL.path) { throw RecoveryRestoreFailure.restoreConflict("Recovery target already exists: \(destinationURL.path)") } + } + + private func restoreRecoveryTarget(_ mapping: RecoveryPathMapping) async throws { + try validateRestoreTarget(mapping) + let sourceURL = URL(fileURLWithPath: mapping.trashedPath).resolvingSymlinksInPath() + let destinationURL = URL(fileURLWithPath: mapping.originalPath).resolvingSymlinksInPath() + if shouldUseHelperForSmartCleanTarget(destinationURL) { + guard let helperExecutor else { + throw RecoveryRestoreFailure.helperUnavailable("Bundled helper unavailable for recovery target: \(destinationURL.path)") + } + do { + let result = try await helperExecutor.perform( + AtlasHelperAction(kind: .restoreItem, targetPath: sourceURL.path, destinationPath: destinationURL.path) + ) + guard result.success else { + throw recoveryRestoreFailure(fromHelperMessage: result.message) + } + } catch let failure as RecoveryRestoreFailure { + throw failure + } catch let clientError as AtlasHelperClientError { + switch clientError { + case .helperUnavailable: + throw RecoveryRestoreFailure.helperUnavailable(clientError.localizedDescription) + case .encodingFailed, .decodingFailed, .invocationFailed: + throw RecoveryRestoreFailure.executionUnavailable(clientError.localizedDescription) + } + } catch { + throw RecoveryRestoreFailure.executionUnavailable(error.localizedDescription) + } + return + } try FileManager.default.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.moveItem(at: sourceURL, to: destinationURL) } + private func recoveryRestoreFailure(fromHelperMessage message: String) -> RecoveryRestoreFailure { + if message.hasPrefix("Restore destination already exists:") { + return .restoreConflict(message) + } + return .executionUnavailable(message) + } + private func shouldUseHelperForSmartCleanTarget(_ targetURL: URL) -> Bool { AtlasSmartCleanExecutionSupport.requiresHelper(for: targetURL) } diff --git a/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift b/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift index 0405545..524f3de 100644 --- a/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift +++ b/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift @@ -989,6 +989,123 @@ final class AtlasInfrastructureTests: XCTestCase { XCTAssertTrue(fileManager.fileExists(atPath: trashedPath)) } + func testRestoreItemsKeepsStateUnchangedWhenLaterHelperRestoreFails() async throws { + let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) + let fileManager = FileManager.default + let home = fileManager.homeDirectoryForCurrentUser + + let directRoot = home.appendingPathComponent("Library/Caches/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: directRoot, withIntermediateDirectories: true) + let directTargetURL = directRoot.appendingPathComponent("restored.cache") + try Data("restored".utf8).write(to: directTargetURL) + var directTrashedURL: NSURL? + try fileManager.trashItem(at: directTargetURL, resultingItemURL: &directTrashedURL) + let directTrashedPath = try XCTUnwrap((directTrashedURL as URL?)?.path) + + let appRoot = home.appendingPathComponent("Applications/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true) + let appBundleURL = appRoot.appendingPathComponent("Atlas Restore Conflict.app", isDirectory: true) + try fileManager.createDirectory(at: appBundleURL.appendingPathComponent("Contents/MacOS"), withIntermediateDirectories: true) + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: appBundleURL.appendingPathComponent("Contents/MacOS/AtlasRestoreConflict")) + var appTrashedURL: NSURL? + try fileManager.trashItem(at: appBundleURL, resultingItemURL: &appTrashedURL) + let appTrashedPath = try XCTUnwrap((appTrashedURL as URL?)?.path) + + addTeardownBlock { + try? FileManager.default.removeItem(at: directRoot) + try? FileManager.default.removeItem(at: appRoot) + if let directTrashedURL { + try? FileManager.default.removeItem(at: directTrashedURL as URL) + } + if let appTrashedURL { + try? FileManager.default.removeItem(at: appTrashedURL as URL) + } + } + + let directFinding = Finding( + id: UUID(), + title: "Direct restore fixture", + detail: directTargetURL.path, + bytes: 11, + risk: .safe, + category: "Developer tools", + targetPaths: [directTargetURL.path] + ) + let helperApp = AppFootprint( + id: UUID(), + name: "Atlas Restore Conflict", + bundleIdentifier: "com.atlas.restore-conflict", + bundlePath: appBundleURL.path, + bytes: 17, + leftoverItems: 1 + ) + let directRecoveryItem = RecoveryItem( + id: UUID(), + title: directFinding.title, + detail: directFinding.detail, + originalPath: directTargetURL.path, + bytes: directFinding.bytes, + deletedAt: Date(), + expiresAt: Date().addingTimeInterval(3600), + payload: .finding(directFinding), + restoreMappings: [RecoveryPathMapping(originalPath: directTargetURL.path, trashedPath: directTrashedPath)] + ) + let helperRecoveryItem = RecoveryItem( + id: UUID(), + title: helperApp.name, + detail: helperApp.bundlePath, + originalPath: helperApp.bundlePath, + bytes: helperApp.bytes, + deletedAt: Date(), + expiresAt: Date().addingTimeInterval(3600), + payload: .app(helperApp), + restoreMappings: [RecoveryPathMapping(originalPath: helperApp.bundlePath, trashedPath: appTrashedPath)] + ) + let state = AtlasWorkspaceState( + snapshot: AtlasWorkspaceSnapshot( + reclaimableSpaceBytes: 0, + findings: [], + apps: [], + taskRuns: [], + recoveryItems: [directRecoveryItem, helperRecoveryItem], + permissions: [], + healthSnapshot: nil + ), + currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0), + settings: AtlasScaffoldWorkspace.state().settings + ) + _ = try repository.saveState(state) + + let worker = AtlasScaffoldWorkerService( + repository: repository, + helperExecutor: RestoreConflictPrivilegedHelperExecutor(), + allowStateOnlyCleanExecution: false + ) + + let restore = try await worker.submit( + AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [directRecoveryItem.id, helperRecoveryItem.id])) + ) + + guard case let .rejected(code, reason) = restore.response.response else { + return XCTFail("Expected rejected restore response") + } + XCTAssertEqual(code, .restoreConflict) + XCTAssertTrue(reason.contains(helperApp.bundlePath)) + + XCTAssertTrue(fileManager.fileExists(atPath: directTargetURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: directTrashedPath)) + XCTAssertFalse(fileManager.fileExists(atPath: helperApp.bundlePath)) + XCTAssertTrue(fileManager.fileExists(atPath: appTrashedPath)) + + XCTAssertFalse(restore.snapshot.findings.contains(where: { $0.id == directFinding.id })) + XCTAssertFalse(restore.snapshot.apps.contains(where: { $0.id == helperApp.id })) + XCTAssertEqual(restore.snapshot.recoveryItems.map(\.id), [directRecoveryItem.id, helperRecoveryItem.id]) + + let persisted = repository.loadState() + XCTAssertFalse(persisted.snapshot.findings.contains(where: { $0.id == directFinding.id })) + XCTAssertFalse(persisted.snapshot.apps.contains(where: { $0.id == helperApp.id })) + XCTAssertEqual(persisted.snapshot.recoveryItems.map(\.id), [directRecoveryItem.id, helperRecoveryItem.id]) + } + func testExecuteAppUninstallRemovesAppAndCreatesRecoveryEntry() async throws { let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: true) @@ -1152,6 +1269,16 @@ private actor StubPrivilegedHelperExecutor: AtlasPrivilegedActionExecuting { } } +private actor RestoreConflictPrivilegedHelperExecutor: AtlasPrivilegedActionExecuting { + func perform(_ action: AtlasHelperAction) async throws -> AtlasHelperActionResult { + AtlasHelperActionResult( + action: action, + success: false, + message: "Restore destination already exists: \(action.destinationPath ?? "")" + ) + } +} + private final class TestClock: @unchecked Sendable { var now: Date diff --git a/project.yml b/project.yml index 00b7b5d..c670e6a 100644 --- a/project.yml +++ b/project.yml @@ -38,8 +38,8 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.atlasformac.app.worker PRODUCT_NAME: AtlasWorkerXPC - MARKETING_VERSION: "1.0.0" - CURRENT_PROJECT_VERSION: 1 + MARKETING_VERSION: "1.0.1" + CURRENT_PROJECT_VERSION: 2 GENERATE_INFOPLIST_FILE: YES INFOPLIST_KEY_CFBundleShortVersionString: $(MARKETING_VERSION) INFOPLIST_KEY_CFBundleVersion: $(CURRENT_PROJECT_VERSION) @@ -64,8 +64,8 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: com.atlasformac.app PRODUCT_NAME: Atlas for Mac PRODUCT_MODULE_NAME: AtlasApp - MARKETING_VERSION: "1.0.0" - CURRENT_PROJECT_VERSION: 1 + MARKETING_VERSION: "1.0.1" + CURRENT_PROJECT_VERSION: 2 GENERATE_INFOPLIST_FILE: YES INFOPLIST_KEY_CFBundleShortVersionString: $(MARKETING_VERSION) INFOPLIST_KEY_CFBundleVersion: $(CURRENT_PROJECT_VERSION)