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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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<UUID> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 ?? "<missing>")"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class TestClock: @unchecked Sendable {
|
||||
var now: Date
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user