fix: enforce recovery retention and fail-closed restore semantics
- prune expired recovery items on load/save and reject expired restores at worker boundary - add restoreExpired and restoreConflict protocol/application error mapping - disable expired restore actions in History and reload persisted state after restore failures - add recovery expiry/conflict coverage plus sync protocol, architecture, state-machine, and recovery contract docs - wire AtlasAppTests into the shared Xcode scheme and add app-layer regression coverage for expired restore reload behavior Refs: ATL-221 ATL-222 ATL-223 ATL-224 ATL-225, vibe-kanban SID-9
This commit is contained in:
@@ -37,6 +37,7 @@ final class AtlasAppModel: ObservableObject {
|
|||||||
@Published private(set) var updateCheckNotice: String?
|
@Published private(set) var updateCheckNotice: String?
|
||||||
@Published private(set) var updateCheckError: String?
|
@Published private(set) var updateCheckError: String?
|
||||||
|
|
||||||
|
private let repository: AtlasWorkspaceRepository
|
||||||
private let workspaceController: AtlasWorkspaceController
|
private let workspaceController: AtlasWorkspaceController
|
||||||
private let updateChecker = AtlasUpdateChecker()
|
private let updateChecker = AtlasUpdateChecker()
|
||||||
private let notificationPermissionRequester: @Sendable () async -> Bool
|
private let notificationPermissionRequester: @Sendable () async -> Bool
|
||||||
@@ -53,6 +54,7 @@ final class AtlasAppModel: ObservableObject {
|
|||||||
notificationPermissionRequester: (@Sendable () async -> Bool)? = nil
|
notificationPermissionRequester: (@Sendable () async -> Bool)? = nil
|
||||||
) {
|
) {
|
||||||
let state = repository.loadState()
|
let state = repository.loadState()
|
||||||
|
self.repository = repository
|
||||||
self.snapshot = state.snapshot
|
self.snapshot = state.snapshot
|
||||||
self.currentPlan = state.currentPlan
|
self.currentPlan = state.currentPlan
|
||||||
self.settings = state.settings
|
self.settings = state.settings
|
||||||
@@ -482,6 +484,12 @@ final class AtlasAppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
await refreshPlanPreview()
|
await refreshPlanPreview()
|
||||||
} catch {
|
} catch {
|
||||||
|
let persistedState = repository.loadState()
|
||||||
|
withAnimation(.snappy(duration: 0.24)) {
|
||||||
|
snapshot = persistedState.snapshot
|
||||||
|
currentPlan = persistedState.currentPlan
|
||||||
|
settings = persistedState.settings
|
||||||
|
}
|
||||||
latestScanSummary = error.localizedDescription
|
latestScanSummary = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,62 @@ final class AtlasAppModelTests: XCTestCase {
|
|||||||
XCTAssertNil(model.smartCleanExecutionIssue)
|
XCTAssertNil(model.smartCleanExecutionIssue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testRestoreExpiredRecoveryItemReloadsPersistedState() async throws {
|
||||||
|
let baseDate = Date(timeIntervalSince1970: 1_710_000_000)
|
||||||
|
let clock = TestClock(now: baseDate)
|
||||||
|
let repository = makeRepository(nowProvider: { clock.now })
|
||||||
|
let finding = Finding(
|
||||||
|
id: UUID(),
|
||||||
|
title: "Expiring fixture",
|
||||||
|
detail: "Expires soon",
|
||||||
|
bytes: 5,
|
||||||
|
risk: .safe,
|
||||||
|
category: "Developer tools"
|
||||||
|
)
|
||||||
|
let recoveryItem = RecoveryItem(
|
||||||
|
id: UUID(),
|
||||||
|
title: finding.title,
|
||||||
|
detail: finding.detail,
|
||||||
|
originalPath: "~/Library/Caches/AtlasOnly",
|
||||||
|
bytes: 5,
|
||||||
|
deletedAt: baseDate,
|
||||||
|
expiresAt: baseDate.addingTimeInterval(10),
|
||||||
|
payload: .finding(finding),
|
||||||
|
restoreMappings: nil
|
||||||
|
)
|
||||||
|
let state = AtlasWorkspaceState(
|
||||||
|
snapshot: AtlasWorkspaceSnapshot(
|
||||||
|
reclaimableSpaceBytes: 0,
|
||||||
|
findings: [],
|
||||||
|
apps: [],
|
||||||
|
taskRuns: [],
|
||||||
|
recoveryItems: [recoveryItem],
|
||||||
|
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,
|
||||||
|
nowProvider: { clock.now },
|
||||||
|
allowStateOnlyCleanExecution: true
|
||||||
|
)
|
||||||
|
let model = AtlasAppModel(repository: repository, workerService: worker)
|
||||||
|
XCTAssertTrue(model.snapshot.recoveryItems.contains(where: { $0.id == recoveryItem.id }))
|
||||||
|
|
||||||
|
clock.now = baseDate.addingTimeInterval(60)
|
||||||
|
await model.restoreRecoveryItem(recoveryItem.id)
|
||||||
|
|
||||||
|
XCTAssertFalse(model.snapshot.recoveryItems.contains(where: { $0.id == recoveryItem.id }))
|
||||||
|
XCTAssertEqual(
|
||||||
|
model.latestScanSummary,
|
||||||
|
AtlasL10n.string("application.error.restoreExpired", "One or more selected recovery items have expired and can no longer be restored.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func testSettingsUpdatePersistsThroughWorker() async throws {
|
func testSettingsUpdatePersistsThroughWorker() async throws {
|
||||||
let repository = makeRepository()
|
let repository = makeRepository()
|
||||||
let permissionInspector = AtlasPermissionInspector(
|
let permissionInspector = AtlasPermissionInspector(
|
||||||
@@ -309,11 +365,12 @@ final class AtlasAppModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(AtlasRoute.overview.title, "Overview")
|
XCTAssertEqual(AtlasRoute.overview.title, "Overview")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeRepository() -> AtlasWorkspaceRepository {
|
private func makeRepository(nowProvider: @escaping @Sendable () -> Date = { Date() }) -> AtlasWorkspaceRepository {
|
||||||
AtlasWorkspaceRepository(
|
AtlasWorkspaceRepository(
|
||||||
stateFileURL: FileManager.default.temporaryDirectory
|
stateFileURL: FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
.appendingPathComponent("workspace-state.json")
|
.appendingPathComponent("workspace-state.json"),
|
||||||
|
nowProvider: nowProvider
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,3 +472,11 @@ private actor ExecuteRejectingRestoreDelegatingWorker: AtlasWorkerServing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class TestClock: @unchecked Sendable {
|
||||||
|
var now: Date
|
||||||
|
|
||||||
|
init(now: Date) {
|
||||||
|
self.now = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
18248DDA2E6242D30B2FF84B /* AtlasFeaturesSettings in Frameworks */ = {isa = PBXBuildFile; productRef = FE51513F5C3746B2C3DA5E9A /* AtlasFeaturesSettings */; };
|
18248DDA2E6242D30B2FF84B /* AtlasFeaturesSettings in Frameworks */ = {isa = PBXBuildFile; productRef = FE51513F5C3746B2C3DA5E9A /* AtlasFeaturesSettings */; };
|
||||||
18361B20FDB815F8F80A8D89 /* AtlasCoreAdapters in Frameworks */ = {isa = PBXBuildFile; productRef = A110B5FE410BD691B10F4338 /* AtlasCoreAdapters */; };
|
18361B20FDB815F8F80A8D89 /* AtlasCoreAdapters in Frameworks */ = {isa = PBXBuildFile; productRef = A110B5FE410BD691B10F4338 /* AtlasCoreAdapters */; };
|
||||||
1FD68E8A5DFA42F86C474290 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10ECA6F0B2C093A4FDBA60A5 /* main.swift */; };
|
1FD68E8A5DFA42F86C474290 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10ECA6F0B2C093A4FDBA60A5 /* main.swift */; };
|
||||||
|
3F61098A3E68EB5B385D676C /* AtlasAppModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3F033599CA5CB41CC01A2D /* AtlasAppModelTests.swift */; };
|
||||||
568260A734C660E1C1E29EEF /* AtlasApplication in Frameworks */ = {isa = PBXBuildFile; productRef = 07F92560DDAA3271466226A0 /* AtlasApplication */; };
|
568260A734C660E1C1E29EEF /* AtlasApplication in Frameworks */ = {isa = PBXBuildFile; productRef = 07F92560DDAA3271466226A0 /* AtlasApplication */; };
|
||||||
5E17D3D1A8B2B6844C11E4A0 /* AppShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6F7E5AF1DB77BD9455C253 /* AppShellView.swift */; };
|
5E17D3D1A8B2B6844C11E4A0 /* AppShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6F7E5AF1DB77BD9455C253 /* AppShellView.swift */; };
|
||||||
69A95E0759F67A6749B13268 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7514A7238A2A0C3B19F6D967 /* Assets.xcassets */; };
|
69A95E0759F67A6749B13268 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7514A7238A2A0C3B19F6D967 /* Assets.xcassets */; };
|
||||||
@@ -50,6 +51,13 @@
|
|||||||
remoteGlobalIDString = 6554EF197FBC626F52F4BA4B;
|
remoteGlobalIDString = 6554EF197FBC626F52F4BA4B;
|
||||||
remoteInfo = AtlasWorkerXPC;
|
remoteInfo = AtlasWorkerXPC;
|
||||||
};
|
};
|
||||||
|
4D8C0366067B16B0EDEFF06F /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 43D55555CA7BCC7C87E44A39 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 98260B956C6EC40DBBEEC103;
|
||||||
|
remoteInfo = AtlasApp;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -68,12 +76,14 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
10ECA6F0B2C093A4FDBA60A5 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
10ECA6F0B2C093A4FDBA60A5 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
||||||
13B5E85855AB6534C486F6AB /* AtlasAppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtlasAppUITests.swift; sourceTree = "<group>"; };
|
13B5E85855AB6534C486F6AB /* AtlasAppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtlasAppUITests.swift; sourceTree = "<group>"; };
|
||||||
|
1E3F033599CA5CB41CC01A2D /* AtlasAppModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtlasAppModelTests.swift; sourceTree = "<group>"; };
|
||||||
31527C6248CC4F0D354F6593 /* TaskCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCenterView.swift; sourceTree = "<group>"; };
|
31527C6248CC4F0D354F6593 /* TaskCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCenterView.swift; sourceTree = "<group>"; };
|
||||||
67FF6A7D6D44C9F4789DA0FF /* Packages */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Packages; path = Packages; sourceTree = SOURCE_ROOT; };
|
67FF6A7D6D44C9F4789DA0FF /* Packages */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Packages; path = Packages; sourceTree = SOURCE_ROOT; };
|
||||||
6A363C421B6DA2EFE09AE3D7 /* ReadmeAssetExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadmeAssetExporter.swift; sourceTree = "<group>"; };
|
6A363C421B6DA2EFE09AE3D7 /* ReadmeAssetExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadmeAssetExporter.swift; sourceTree = "<group>"; };
|
||||||
6D6F7E5AF1DB77BD9455C253 /* AppShellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShellView.swift; sourceTree = "<group>"; };
|
6D6F7E5AF1DB77BD9455C253 /* AppShellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShellView.swift; sourceTree = "<group>"; };
|
||||||
7514A7238A2A0C3B19F6D967 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
7514A7238A2A0C3B19F6D967 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
7B60D354F907D973C9D78524 /* AtlasAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AtlasAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
7B60D354F907D973C9D78524 /* AtlasAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AtlasAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
7FF49AAA8C253DBEE96EC8D3 /* AtlasAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AtlasAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
97A2723BA50C375AFC3C9321 /* AtlasWorkerXPC.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = AtlasWorkerXPC.xpc; sourceTree = BUILT_PRODUCTS_DIR; };
|
97A2723BA50C375AFC3C9321 /* AtlasWorkerXPC.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = AtlasWorkerXPC.xpc; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
9AB1D202267B7A0E93C4D7A4 /* AtlasApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AtlasApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
9AB1D202267B7A0E93C4D7A4 /* AtlasApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AtlasApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
A57454A431BBD25C9D9F7ACA /* AtlasAppCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtlasAppCommands.swift; sourceTree = "<group>"; };
|
A57454A431BBD25C9D9F7ACA /* AtlasAppCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtlasAppCommands.swift; sourceTree = "<group>"; };
|
||||||
@@ -115,6 +125,14 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
239A9C84704A886CD4AF1BE3 /* AtlasAppTests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1E3F033599CA5CB41CC01A2D /* AtlasAppModelTests.swift */,
|
||||||
|
);
|
||||||
|
path = AtlasAppTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
33096698F9C248F87E324810 /* Packages */ = {
|
33096698F9C248F87E324810 /* Packages */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -135,6 +153,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
3FA23CAAED1482D5CD7DBA21 /* Sources */,
|
3FA23CAAED1482D5CD7DBA21 /* Sources */,
|
||||||
|
6E9CA333F7EF9B53949FFE51 /* Tests */,
|
||||||
);
|
);
|
||||||
path = AtlasApp;
|
path = AtlasApp;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -147,6 +166,14 @@
|
|||||||
path = AtlasWorkerXPC;
|
path = AtlasWorkerXPC;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
6E9CA333F7EF9B53949FFE51 /* Tests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
239A9C84704A886CD4AF1BE3 /* AtlasAppTests */,
|
||||||
|
);
|
||||||
|
path = Tests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
70A20106B8807AFCC4851B2C = {
|
70A20106B8807AFCC4851B2C = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -177,6 +204,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
9AB1D202267B7A0E93C4D7A4 /* AtlasApp.app */,
|
9AB1D202267B7A0E93C4D7A4 /* AtlasApp.app */,
|
||||||
|
7FF49AAA8C253DBEE96EC8D3 /* AtlasAppTests.xctest */,
|
||||||
7B60D354F907D973C9D78524 /* AtlasAppUITests.xctest */,
|
7B60D354F907D973C9D78524 /* AtlasAppUITests.xctest */,
|
||||||
97A2723BA50C375AFC3C9321 /* AtlasWorkerXPC.xpc */,
|
97A2723BA50C375AFC3C9321 /* AtlasWorkerXPC.xpc */,
|
||||||
);
|
);
|
||||||
@@ -280,6 +308,24 @@
|
|||||||
productReference = 9AB1D202267B7A0E93C4D7A4 /* AtlasApp.app */;
|
productReference = 9AB1D202267B7A0E93C4D7A4 /* AtlasApp.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
BC1D0FBB35276C2AE56A0268 /* AtlasAppTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = AFB6375FA8AB26AE68C66925 /* Build configuration list for PBXNativeTarget "AtlasAppTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
E0B1CC6AEF9B151A45B8A49E /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
1402ADF5C02FD65B5B68E38C /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = AtlasAppTests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = AtlasAppTests;
|
||||||
|
productReference = 7FF49AAA8C253DBEE96EC8D3 /* AtlasAppTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
DC24C4DDD452116007066447 /* AtlasAppUITests */ = {
|
DC24C4DDD452116007066447 /* AtlasAppUITests */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = E425825D3882044CA19B8446 /* Build configuration list for PBXNativeTarget "AtlasAppUITests" */;
|
buildConfigurationList = E425825D3882044CA19B8446 /* Build configuration list for PBXNativeTarget "AtlasAppUITests" */;
|
||||||
@@ -330,6 +376,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
98260B956C6EC40DBBEEC103 /* AtlasApp */,
|
98260B956C6EC40DBBEEC103 /* AtlasApp */,
|
||||||
|
BC1D0FBB35276C2AE56A0268 /* AtlasAppTests */,
|
||||||
DC24C4DDD452116007066447 /* AtlasAppUITests */,
|
DC24C4DDD452116007066447 /* AtlasAppUITests */,
|
||||||
6554EF197FBC626F52F4BA4B /* AtlasWorkerXPC */,
|
6554EF197FBC626F52F4BA4B /* AtlasWorkerXPC */,
|
||||||
);
|
);
|
||||||
@@ -377,9 +424,22 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
E0B1CC6AEF9B151A45B8A49E /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
3F61098A3E68EB5B385D676C /* AtlasAppModelTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
|
1402ADF5C02FD65B5B68E38C /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 98260B956C6EC40DBBEEC103 /* AtlasApp */;
|
||||||
|
targetProxy = 4D8C0366067B16B0EDEFF06F /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
4561468E37A46EEB82269AB0 /* PBXTargetDependency */ = {
|
4561468E37A46EEB82269AB0 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = 6554EF197FBC626F52F4BA4B /* AtlasWorkerXPC */;
|
target = 6554EF197FBC626F52F4BA4B /* AtlasWorkerXPC */;
|
||||||
@@ -511,6 +571,7 @@
|
|||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app;
|
||||||
|
PRODUCT_MODULE_NAME = AtlasApp;
|
||||||
PRODUCT_NAME = "Atlas for Mac";
|
PRODUCT_NAME = "Atlas for Mac";
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
};
|
};
|
||||||
@@ -579,6 +640,25 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
A91320B2152453D347819B2E /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
AD_HOC_CODE_SIGNING_ALLOWED = YES;
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
"@loader_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app.tests;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Atlas for Mac.app/Contents/MacOS/Atlas for Mac";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
CBD20A4D8B026FF2EDBFF1DC /* Release */ = {
|
CBD20A4D8B026FF2EDBFF1DC /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -599,11 +679,31 @@
|
|||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app;
|
||||||
|
PRODUCT_MODULE_NAME = AtlasApp;
|
||||||
PRODUCT_NAME = "Atlas for Mac";
|
PRODUCT_NAME = "Atlas for Mac";
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
D2CF1D133D0E5FFAEFA90E2F /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
AD_HOC_CODE_SIGNING_ALLOWED = YES;
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
"@loader_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app.tests;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Atlas for Mac.app/Contents/MacOS/Atlas for Mac";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
E15130E42E1B6078B50A4F28 /* Release */ = {
|
E15130E42E1B6078B50A4F28 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -672,6 +772,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Debug;
|
defaultConfigurationName = Debug;
|
||||||
};
|
};
|
||||||
|
AFB6375FA8AB26AE68C66925 /* Build configuration list for PBXNativeTarget "AtlasAppTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
A91320B2152453D347819B2E /* Debug */,
|
||||||
|
D2CF1D133D0E5FFAEFA90E2F /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
E425825D3882044CA19B8446 /* Build configuration list for PBXNativeTarget "AtlasAppUITests" */ = {
|
E425825D3882044CA19B8446 /* Build configuration list for PBXNativeTarget "AtlasAppUITests" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
|||||||
@@ -53,6 +53,17 @@
|
|||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</MacroExpansion>
|
</MacroExpansion>
|
||||||
<Testables>
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BC1D0FBB35276C2AE56A0268"
|
||||||
|
BuildableName = "AtlasAppTests.xctest"
|
||||||
|
BlueprintName = "AtlasAppTests"
|
||||||
|
ReferencedContainer = "container:Atlas.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
<TestableReference
|
<TestableReference
|
||||||
skipped = "NO"
|
skipped = "NO"
|
||||||
parallelizable = "NO">
|
parallelizable = "NO">
|
||||||
|
|||||||
32
Docs/ADR/ADR-007-Recovery-Retention-Enforcement.md
Normal file
32
Docs/ADR/ADR-007-Recovery-Retention-Enforcement.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# ADR-007: Recovery Retention Enforcement
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Atlas already documents a retention-window recovery model, including `RecoveryItem.expiresAt`, the `expired` task-state concept, and `restore_expired` in the error-code registry. The shipped worker, however, still restores items solely by presence in `snapshot.recoveryItems`. That means an expired entry can remain visible and restorable if it has not yet been pruned from persisted state.
|
||||||
|
|
||||||
|
This creates a trust gap in a release-sensitive area: History and Recovery can claim that items are available only while the retention window remains open, while the implementation still allows restore after expiry.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Atlas must treat expiry as an enforced worker and persistence boundary, not only as UI copy.
|
||||||
|
- `AtlasWorkspaceRepository` must prune expired `RecoveryItem`s on load and save so stale entries do not remain in active recovery state across launches.
|
||||||
|
- `AtlasScaffoldWorkerService.restoreItems` must recheck expiry at request time and fail closed before any restore side effect.
|
||||||
|
- Restore rejections must use stable restore-specific protocol codes for expiry and restore conflicts.
|
||||||
|
- Presentation may add defensive restore disabling for expired entries, but worker enforcement remains authoritative.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Recovery behavior now matches the documented retention contract.
|
||||||
|
- Expired entries stop appearing as active recovery inventory after repository normalization.
|
||||||
|
- Restore batches remain fail closed: if any selected item is expired, the batch is rejected before mutation.
|
||||||
|
- Protocol consumers must handle the additional restore-specific rejection codes.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- Narrow docs to match current behavior: rejected because it preserves an avoidable trust gap.
|
||||||
|
- Enforce expiry only in the restore command: rejected because stale entries would still persist in active recovery state.
|
||||||
|
- Fix only in UI: rejected because restore is ultimately a worker-boundary guarantee.
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
|
|
||||||
- XPC transport
|
- XPC transport
|
||||||
- JSON-backed workspace state persistence
|
- JSON-backed workspace state persistence
|
||||||
|
- Recovery-state normalization that prunes expired recovery entries on load/save
|
||||||
- Logging and audit events
|
- Logging and audit events
|
||||||
- Best-effort permission inspection
|
- Best-effort permission inspection
|
||||||
- Helper executable client
|
- Helper executable client
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
- Allowlisted helper actions for bundle trashing, restoration, and launch-service removal
|
- Allowlisted helper actions for bundle trashing, restoration, and launch-service removal
|
||||||
- Release-facing execution must fail closed when real worker/adapter/helper capability is unavailable; scaffold fallback is development-only by opt-in
|
- Release-facing execution must fail closed when real worker/adapter/helper capability is unavailable; scaffold fallback is development-only by opt-in
|
||||||
- Smart Clean now supports a real Trash-based execution path for a safe structured subset of user-owned targets, plus physical restoration when recovery mappings are present
|
- Smart Clean now supports a real Trash-based execution path for a safe structured subset of user-owned targets, plus physical restoration when recovery mappings are present
|
||||||
|
- Restore requests recheck expiry and destination conflicts before side effects, so expired or conflicting recovery items fail closed
|
||||||
|
|
||||||
## Process Boundaries
|
## Process Boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,11 @@
|
|||||||
- Use sheets for permission and destructive confirmation flows.
|
- Use sheets for permission and destructive confirmation flows.
|
||||||
- Use result pages for partial success, cancellation, and recovery outcomes.
|
- Use result pages for partial success, cancellation, and recovery outcomes.
|
||||||
|
|
||||||
|
## Recovery Semantics
|
||||||
|
|
||||||
|
- `restore_expired` — the recovery retention window has closed; the item must no longer be restorable and should disappear from active recovery state on the next refresh.
|
||||||
|
- `restore_conflict` — the original destination already exists; the restore request must fail closed without moving the trashed source.
|
||||||
|
|
||||||
## Format
|
## Format
|
||||||
|
|
||||||
- User-visible format recommendation: `ATLAS-<DOMAIN>-<NUMBER>`
|
- User-visible format recommendation: `ATLAS-<DOMAIN>-<NUMBER>`
|
||||||
|
|||||||
145
Docs/Execution/Recovery-Contract-2026-03-13.md
Normal file
145
Docs/Execution/Recovery-Contract-2026-03-13.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Recovery Contract — 2026-03-13
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Freeze Atlas recovery semantics against the behavior that is actually shipped today.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `ATL-221` physical restore for file-backed recoverable actions where safe
|
||||||
|
- `ATL-222` restore validation on real file-backed test cases
|
||||||
|
- `ATL-223` README, in-app, and release-facing recovery claim audit
|
||||||
|
- `ATL-224` recovery contract and acceptance evidence freeze
|
||||||
|
|
||||||
|
## Canonical Contract
|
||||||
|
|
||||||
|
### 1. What a recovery item means
|
||||||
|
|
||||||
|
- Every recoverable destructive flow must produce a structured `RecoveryItem`.
|
||||||
|
- A `RecoveryItem` may carry `restoreMappings` that pair the original path with the actual path returned from Trash.
|
||||||
|
- `restoreMappings` are the only shipped proof that Atlas can claim an on-disk return path for that item.
|
||||||
|
|
||||||
|
### 2. When Atlas can claim physical restore
|
||||||
|
|
||||||
|
Atlas can claim physical on-disk restore only when all of the following are true:
|
||||||
|
|
||||||
|
- the recovery item still exists in active Atlas recovery state
|
||||||
|
- its retention window is still open
|
||||||
|
- the recovery item contains at least one `restoreMappings` entry
|
||||||
|
- the trashed source still exists on disk
|
||||||
|
- the original destination path does not already exist
|
||||||
|
- the required execution capability is available:
|
||||||
|
- direct move for supported user-trashable targets such as `~/Library/Caches/*` and `~/Library/pnpm/store/*`
|
||||||
|
- helper-backed restore for protected targets such as app bundles under `/Applications` or `~/Applications`
|
||||||
|
|
||||||
|
### 3. When Atlas restores state only
|
||||||
|
|
||||||
|
If a recovery item has no `restoreMappings`, Atlas may still restore Atlas workspace state by rehydrating the saved `Finding` or `AppFootprint` payload.
|
||||||
|
|
||||||
|
State-only restore means:
|
||||||
|
|
||||||
|
- the item reappears in Atlas UI state
|
||||||
|
- the action remains auditable in History
|
||||||
|
- Atlas does not claim the underlying file or bundle returned on disk
|
||||||
|
|
||||||
|
### 4. Failure behavior
|
||||||
|
|
||||||
|
Restore remains fail-closed. Atlas rejects the restore request instead of claiming success when:
|
||||||
|
|
||||||
|
- the recovery item has expired and is no longer active in recovery state (`restoreExpired`)
|
||||||
|
- the trash source no longer exists
|
||||||
|
- the original destination already exists (`restoreConflict`)
|
||||||
|
- the target falls outside the supported direct/helper allowlist
|
||||||
|
- a required helper capability is unavailable (`helperUnavailable`)
|
||||||
|
- another restore precondition fails after validation (`executionUnavailable`)
|
||||||
|
|
||||||
|
### 5. History and completion wording
|
||||||
|
|
||||||
|
- Disk-backed restores must use the disk-specific completion summary.
|
||||||
|
- State-only restores must use the Atlas-only completion summary.
|
||||||
|
- Mixed restore batches must report both clauses instead of collapsing everything into a physical-restore claim.
|
||||||
|
|
||||||
|
## Accepted Physical Restore Surface
|
||||||
|
|
||||||
|
### Direct restore paths
|
||||||
|
|
||||||
|
The currently proven direct restore surface is the same safe structured subset used by Smart Clean execution:
|
||||||
|
|
||||||
|
- `~/Library/Caches/*`
|
||||||
|
- `~/Library/pnpm/store/*`
|
||||||
|
- other targets explicitly allowed by `AtlasSmartCleanExecutionSupport.isDirectlyTrashable`
|
||||||
|
|
||||||
|
### Helper-backed restore paths
|
||||||
|
|
||||||
|
The currently proven helper-backed restore surface includes app bundles that require the privileged helper path:
|
||||||
|
|
||||||
|
- `/Applications/*.app`
|
||||||
|
- `~/Applications/*.app`
|
||||||
|
|
||||||
|
## Claim Audit
|
||||||
|
|
||||||
|
The following surfaces now match the frozen contract and must stay aligned:
|
||||||
|
|
||||||
|
- `README.md`
|
||||||
|
- `README.zh-CN.md`
|
||||||
|
- `Docs/Protocol.md`
|
||||||
|
- `Docs/Execution/Smart-Clean-Execution-Coverage-2026-03-09.md`
|
||||||
|
- `Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings`
|
||||||
|
- `Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings`
|
||||||
|
|
||||||
|
No additional README or in-app narrowing is required in this slice because the shipped wording already distinguishes:
|
||||||
|
|
||||||
|
- supported on-disk restore
|
||||||
|
- Atlas-only state restoration
|
||||||
|
- fail-closed behavior for unsupported or unprovable actions
|
||||||
|
|
||||||
|
## Release-Note-Safe Wording
|
||||||
|
|
||||||
|
Future release notes must stay within these statements unless the restore surface expands and new evidence is added:
|
||||||
|
|
||||||
|
- `Recoverable items can be restored when a supported recovery path is available.`
|
||||||
|
- `Some recoverable items restore on disk, while older or unstructured records restore Atlas state only.`
|
||||||
|
- `Atlas only claims physical return when it recorded a supported restore path for that item.`
|
||||||
|
- `Unsupported or unavailable restore paths fail closed instead of being reported as restored.`
|
||||||
|
|
||||||
|
Avoid saying:
|
||||||
|
|
||||||
|
- `All recoverable items restore physically.`
|
||||||
|
- `History always returns deleted files to disk.`
|
||||||
|
- `Restore succeeded` when Atlas only rehydrated workspace state.
|
||||||
|
|
||||||
|
## Acceptance Evidence
|
||||||
|
|
||||||
|
### Automated proof points
|
||||||
|
|
||||||
|
- `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift`
|
||||||
|
- `testRepositorySaveStatePrunesExpiredRecoveryItems`
|
||||||
|
- `testRestoreRecoveryItemPhysicallyRestoresRealTargets`
|
||||||
|
- `testExecuteAppUninstallRestorePhysicallyRestoresAppBundle`
|
||||||
|
- `testRestoreItemsStateOnlySummaryDoesNotClaimOnDiskRestore`
|
||||||
|
- `testRestoreItemsMixedSummaryIncludesDiskAndStateOnlyClauses`
|
||||||
|
- `testRestoreItemsRejectsExpiredRecoveryItemsAndPrunesThem`
|
||||||
|
- `testRestoreItemsRejectsWhenDestinationAlreadyExists`
|
||||||
|
- `testScanExecuteRescanRemovesExecutedTargetFromRealResults`
|
||||||
|
- `testScanExecuteRescanRemovesExecutedPnpmStoreTargetFromRealResults`
|
||||||
|
- `Packages/AtlasApplication/Tests/AtlasApplicationTests/AtlasApplicationTests.swift`
|
||||||
|
- `testRestoreItemsMapsRestoreExpiredToLocalizedError`
|
||||||
|
- `testRestoreItemsMapsRestoreConflictToLocalizedError`
|
||||||
|
- `Docs/Execution/Smart-Clean-Manual-Verification-2026-03-09.md`
|
||||||
|
- `Docs/Execution/Smart-Clean-QA-Checklist-2026-03-09.md`
|
||||||
|
|
||||||
|
### What the evidence proves
|
||||||
|
|
||||||
|
- direct-trash file-backed recovery physically returns a real file to its original path
|
||||||
|
- helper-backed app uninstall recovery physically returns a real app bundle to its original path
|
||||||
|
- Atlas-only recovery records do not overclaim on-disk restore
|
||||||
|
- mixed restore batches preserve truthful summaries when disk-backed and Atlas-only items are restored together
|
||||||
|
- expired recovery items are pruned from active recovery state and fail closed if a restore arrives after expiry
|
||||||
|
- destination collisions return a stable restore-specific rejection instead of claiming completion
|
||||||
|
- supported file-backed targets still satisfy `scan -> execute -> rescan` credibility checks
|
||||||
|
|
||||||
|
## Remaining Limits
|
||||||
|
|
||||||
|
- Physical restore is intentionally partial, not universal.
|
||||||
|
- Older or unstructured recovery entries remain Atlas-state-only unless they carry `restoreMappings`.
|
||||||
|
- Broader restore scope must not ship without new allowlist review, automated coverage, and matching copy updates.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Recovery Credibility Gate Review
|
||||||
|
|
||||||
|
## Gate
|
||||||
|
|
||||||
|
- `Recovery Credibility`
|
||||||
|
|
||||||
|
## Review Date
|
||||||
|
|
||||||
|
- `2026-03-13`
|
||||||
|
|
||||||
|
## Scope Reviewed
|
||||||
|
|
||||||
|
- `ATL-221` implement physical restore for file-backed recoverable actions where safe
|
||||||
|
- `ATL-222` validate shipped restore behavior on real file-backed test cases
|
||||||
|
- `ATL-223` narrow README, in-app, and release-note recovery claims if needed
|
||||||
|
- `ATL-224` freeze recovery contract and acceptance evidence
|
||||||
|
- `ATL-225` recovery credibility gate review
|
||||||
|
|
||||||
|
## Readiness Checklist
|
||||||
|
|
||||||
|
- [x] Required P0 tasks complete
|
||||||
|
- [x] Docs updated
|
||||||
|
- [x] Risks reviewed
|
||||||
|
- [x] Open questions below threshold
|
||||||
|
- [x] Next-stage inputs available
|
||||||
|
|
||||||
|
## Evidence Reviewed
|
||||||
|
|
||||||
|
- `Docs/Protocol.md`
|
||||||
|
- `Docs/Execution/Smart-Clean-Execution-Coverage-2026-03-09.md`
|
||||||
|
- `Docs/Execution/Smart-Clean-QA-Checklist-2026-03-09.md`
|
||||||
|
- `Docs/Execution/Smart-Clean-Manual-Verification-2026-03-09.md`
|
||||||
|
- `Docs/Execution/Recovery-Contract-2026-03-13.md`
|
||||||
|
- `README.md`
|
||||||
|
- `README.zh-CN.md`
|
||||||
|
- `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift`
|
||||||
|
- `Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings`
|
||||||
|
- `Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings`
|
||||||
|
|
||||||
|
## Automated Validation Summary
|
||||||
|
|
||||||
|
- `swift test --package-path Packages --filter AtlasInfrastructureTests` — pass
|
||||||
|
- `swift test --package-path Packages --filter AtlasApplicationTests` — pass
|
||||||
|
- `swift test --package-path Packages` — pass
|
||||||
|
|
||||||
|
## Gate Assessment
|
||||||
|
|
||||||
|
### ATL-221 Physical Restore Surface
|
||||||
|
|
||||||
|
- File-backed recovery items now restore physically when Atlas recorded `restoreMappings` from a real Trash move.
|
||||||
|
- Supported direct-trash targets restore back to their original on-disk path.
|
||||||
|
- Protected app-bundle targets restore through the helper-backed path instead of claiming an unproven direct move.
|
||||||
|
- Restore remains fail-closed when the source, destination, or capability contract is not satisfied.
|
||||||
|
|
||||||
|
### ATL-222 Shipped Restore Evidence
|
||||||
|
|
||||||
|
- Automated tests now cover both proven physical restore classes:
|
||||||
|
- direct-trash file-backed Smart Clean targets
|
||||||
|
- helper-backed app uninstall targets
|
||||||
|
- State-only recovery remains explicitly covered so Atlas does not regress into overclaiming physical restore.
|
||||||
|
- Mixed restore summaries are covered so a batch containing both kinds of items stays truthful.
|
||||||
|
- Expired recovery items are now covered as a fail-closed path and are pruned from active recovery state.
|
||||||
|
- Restore destination conflicts now return a stable restore-specific rejection instead of being reported as generic success.
|
||||||
|
|
||||||
|
### ATL-223 Claim Audit
|
||||||
|
|
||||||
|
- README and localized in-app strings already reflect the narrowed recovery promise.
|
||||||
|
- No new copy narrowing was required in this slice.
|
||||||
|
- This gate freezes a release-note-safe wording set in `Docs/Execution/Recovery-Contract-2026-03-13.md` so future release notes cannot overstate restore behavior.
|
||||||
|
|
||||||
|
### ATL-224 Contract Freeze
|
||||||
|
|
||||||
|
- The recovery contract is now explicit, evidence-backed, and tied to shipped protocol fields and worker behavior.
|
||||||
|
- The contract distinguishes physical restore from Atlas-only state rehydration and documents the exact failure conditions, including expiry and destination conflicts.
|
||||||
|
|
||||||
|
## Remaining Limits
|
||||||
|
|
||||||
|
- Physical restore is still partial and depends on supported `restoreMappings`.
|
||||||
|
- Older or unstructured recovery items still restore Atlas state only.
|
||||||
|
- Broader restore coverage, including additional protected or system-managed targets, must not be described as shipped until new allowlist and QA evidence exist.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- `Pass with Conditions`
|
||||||
|
|
||||||
|
## Conditions
|
||||||
|
|
||||||
|
- Release-facing copy must continue to use the frozen wording in `Docs/Execution/Recovery-Contract-2026-03-13.md`.
|
||||||
|
- Any future restore-surface expansion must add automated proof for the new target class before copy is widened.
|
||||||
|
- Candidate-build QA should still rerun the manual restore checklist on packaged artifacts before external distribution.
|
||||||
|
|
||||||
|
## Follow-up Actions
|
||||||
|
|
||||||
|
- Reuse the frozen recovery contract in future release notes and internal beta notices.
|
||||||
|
- Add new restore targets only after allowlist review, helper-path review, and contract tests land together.
|
||||||
|
- Re-run packaged-app manual restore verification when signed distribution work resumes.
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## Protocol Version
|
## Protocol Version
|
||||||
|
|
||||||
- Current implementation version: `0.3.0`
|
- Current implementation version: `0.3.1`
|
||||||
|
|
||||||
## UI ↔ Worker Commands
|
## UI ↔ Worker Commands
|
||||||
|
|
||||||
@@ -56,6 +56,8 @@
|
|||||||
- `permissionRequired`
|
- `permissionRequired`
|
||||||
- `helperUnavailable`
|
- `helperUnavailable`
|
||||||
- `executionUnavailable`
|
- `executionUnavailable`
|
||||||
|
- `restoreExpired`
|
||||||
|
- `restoreConflict`
|
||||||
- `invalidSelection`
|
- `invalidSelection`
|
||||||
|
|
||||||
## Event Payloads
|
## Event Payloads
|
||||||
@@ -146,6 +148,7 @@
|
|||||||
- `scan.start` is backed by `bin/clean.sh --dry-run` through `MoleSmartCleanAdapter` when the upstream workflow succeeds. If it cannot complete, the worker now rejects the request instead of silently fabricating scan results.
|
- `scan.start` is backed by `bin/clean.sh --dry-run` through `MoleSmartCleanAdapter` when the upstream workflow succeeds. If it cannot complete, the worker now rejects the request instead of silently fabricating scan results.
|
||||||
- `apps.list` is backed by `MacAppsInventoryAdapter`, which scans local app bundles and derives leftover counts.
|
- `apps.list` is backed by `MacAppsInventoryAdapter`, which scans local app bundles and derives leftover counts.
|
||||||
- The worker persists a local JSON-backed workspace state containing the latest snapshot, current Smart Clean plan, and settings, including the persisted app-language preference.
|
- The worker persists a local JSON-backed workspace state containing the latest snapshot, current Smart Clean plan, and settings, including the persisted app-language preference.
|
||||||
|
- The repository and worker normalize recovery state by pruning expired `RecoveryItem`s and rejecting restore requests that arrive after the retention window has closed.
|
||||||
- Atlas localizes user-facing shell copy through a package-scoped resource bundle and uses the persisted language to keep summaries and settings text aligned.
|
- Atlas localizes user-facing shell copy through a package-scoped resource bundle and uses the persisted language to keep summaries and settings text aligned.
|
||||||
- App uninstall can invoke the packaged or development helper executable through structured JSON actions.
|
- App uninstall can invoke the packaged or development helper executable through structured JSON actions.
|
||||||
- Structured Smart Clean findings can now carry executable target paths, and a safe subset of those targets can be moved to Trash and physically restored later.
|
- Structured Smart Clean findings can now carry executable target paths, and a safe subset of those targets can be moved to Trash and physically restored later.
|
||||||
@@ -154,3 +157,4 @@
|
|||||||
|
|
||||||
- `executePlan` is fail-closed for unsupported targets, but now supports a real Trash-based execution path for a safe structured subset of Smart Clean items.
|
- `executePlan` is fail-closed for unsupported targets, but now supports a real Trash-based execution path for a safe structured subset of Smart Clean items.
|
||||||
- `recovery.restore` can physically restore items when `restoreMappings` are present; otherwise it falls back to model rehydration only.
|
- `recovery.restore` can physically restore items when `restoreMappings` are present; otherwise it falls back to model rehydration only.
|
||||||
|
- `recovery.restore` rejects expired recovery items with `restoreExpired` and rejects destination collisions with `restoreConflict`.
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ This directory contains the working product, design, engineering, and compliance
|
|||||||
- `Execution/Execution-Chain-Audit-2026-03-09.md` — end-to-end review of real vs scaffold execution paths and release-facing trust gaps
|
- `Execution/Execution-Chain-Audit-2026-03-09.md` — end-to-end review of real vs scaffold execution paths and release-facing trust gaps
|
||||||
- `Execution/Implementation-Plan-ATL-201-202-205-2026-03-12.md` — implementation plan for internal-beta hardening tasks ATL-201, ATL-202, and ATL-205
|
- `Execution/Implementation-Plan-ATL-201-202-205-2026-03-12.md` — implementation plan for internal-beta hardening tasks ATL-201, ATL-202, and ATL-205
|
||||||
- `Execution/Execution-Credibility-Gate-Review-2026-03-12.md` — gate review for ATL-211, ATL-212, and ATL-215 Smart Clean execution credibility work
|
- `Execution/Execution-Credibility-Gate-Review-2026-03-12.md` — gate review for ATL-211, ATL-212, and ATL-215 Smart Clean execution credibility work
|
||||||
|
- `Execution/Recovery-Contract-2026-03-13.md` — frozen recovery semantics, claim boundaries, and acceptance evidence for ATL-221 through ATL-224
|
||||||
|
- `Execution/Recovery-Credibility-Gate-Review-2026-03-13.md` — gate review for ATL-221 through ATL-225 recovery credibility work
|
||||||
- `Execution/Smart-Clean-Execution-Coverage-2026-03-09.md` — user-facing summary of what Smart Clean can execute for real today
|
- `Execution/Smart-Clean-Execution-Coverage-2026-03-09.md` — user-facing summary of what Smart Clean can execute for real today
|
||||||
- `Execution/Smart-Clean-QA-Checklist-2026-03-09.md` — QA checklist for scan, execute, rescan, and physical restore validation
|
- `Execution/Smart-Clean-QA-Checklist-2026-03-09.md` — QA checklist for scan, execute, rescan, and physical restore validation
|
||||||
- `Execution/Smart-Clean-Manual-Verification-2026-03-09.md` — local-machine fixture workflow for validating real Smart Clean execution and restore
|
- `Execution/Smart-Clean-Manual-Verification-2026-03-09.md` — local-machine fixture workflow for validating real Smart Clean execution and restore
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
- Progress must not move backwards.
|
- Progress must not move backwards.
|
||||||
- Destructive tasks must be audited.
|
- Destructive tasks must be audited.
|
||||||
- Recoverable tasks must leave structured recovery entries until restored or expired.
|
- Recoverable tasks must leave structured recovery entries until restored or expired.
|
||||||
|
- Expired recovery entries must no longer remain actionable in active recovery state.
|
||||||
- Repeated write requests must honor idempotency rules when those flows become externally reentrant.
|
- Repeated write requests must honor idempotency rules when those flows become externally reentrant.
|
||||||
|
|
||||||
## Current MVP Notes
|
## Current MVP Notes
|
||||||
@@ -65,4 +66,5 @@
|
|||||||
- `execute_clean` must not report completion in release-facing flows unless real cleanup side effects have been applied. Fresh preview plans now carry structured execution targets, and unsupported or unstructured targets should fail closed.
|
- `execute_clean` must not report completion in release-facing flows unless real cleanup side effects have been applied. Fresh preview plans now carry structured execution targets, and unsupported or unstructured targets should fail closed.
|
||||||
- `execute_uninstall` removes an app from the current workspace view and creates a recovery entry.
|
- `execute_uninstall` removes an app from the current workspace view and creates a recovery entry.
|
||||||
- `restore` can physically restore items when structured recovery mappings are present, and can still rehydrate a `Finding` or an `AppFootprint` into Atlas state from the recovery payload.
|
- `restore` can physically restore items when structured recovery mappings are present, and can still rehydrate a `Finding` or an `AppFootprint` into Atlas state from the recovery payload.
|
||||||
|
- `restore` must reject expired recovery items before side effects and must fail closed when the original destination already exists.
|
||||||
- User-visible task summaries and settings-driven text should reflect the persisted app-language preference when generated.
|
- User-visible task summaries and settings-driven text should reflect the persisted app-language preference when generated.
|
||||||
|
|||||||
172
Docs/plans/2026-03-13-recovery-credibility.md
Normal file
172
Docs/plans/2026-03-13-recovery-credibility.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# Recovery Credibility Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Freeze Atlas recovery semantics against shipped behavior by adding missing restore coverage and publishing explicit acceptance evidence and a gate review for ATL-221 through ATL-225.
|
||||||
|
|
||||||
|
**Architecture:** The worker already supports restore mappings for file-backed recovery items and Atlas-only rehydration for older/state-only records. This slice should avoid widening restore scope; instead it should prove the current contract with focused automated tests, then freeze that contract in execution docs and a recovery gate review.
|
||||||
|
|
||||||
|
**Tech Stack:** Swift Package Manager, XCTest, Markdown docs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add helper-backed app restore coverage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift`
|
||||||
|
- Check: `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add a test that:
|
||||||
|
- creates a fake installed app under `~/Applications/AtlasExecutionTests/...`
|
||||||
|
- injects a stub `AtlasPrivilegedActionExecuting`
|
||||||
|
- executes app uninstall
|
||||||
|
- restores the resulting recovery item
|
||||||
|
- asserts the app bundle returns to its original path and the restore summary uses the disk-backed wording
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `swift test --package-path Packages --filter AtlasInfrastructureTests/testExecuteAppUninstallRestorePhysicallyRestoresAppBundle`
|
||||||
|
Expected: FAIL until the stub/helper-backed path is wired correctly in the test.
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Implement only the test support needed:
|
||||||
|
- a stub helper executor that handles `.trashItems` and `.restoreItem`
|
||||||
|
- deterministic assertions for returned `restoreMappings`
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `swift test --package-path Packages --filter AtlasInfrastructureTests/testExecuteAppUninstallRestorePhysicallyRestoresAppBundle`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift
|
||||||
|
git commit -m "test: cover helper-backed app restore"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Add mixed recovery summary coverage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift`
|
||||||
|
- Check: `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift:1086`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add a test that restores:
|
||||||
|
- one recovery item with `restoreMappings`
|
||||||
|
- one recovery item without `restoreMappings`
|
||||||
|
|
||||||
|
Assert the task summary contains both:
|
||||||
|
- disk restore wording
|
||||||
|
- Atlas-only restore wording
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `swift test --package-path Packages --filter AtlasInfrastructureTests/testRestoreItemsMixedSummaryIncludesDiskAndStateOnlyClauses`
|
||||||
|
Expected: FAIL if the combined contract is not proven yet.
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
If needed, adjust only test fixtures or summary generation so mixed restores preserve both clauses without overstating physical restore.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `swift test --package-path Packages --filter AtlasInfrastructureTests/testRestoreItemsMixedSummaryIncludesDiskAndStateOnlyClauses`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift
|
||||||
|
git commit -m "test: cover mixed recovery summaries"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Freeze recovery contract and evidence
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Docs/Execution/Recovery-Contract-2026-03-13.md`
|
||||||
|
- Create: `Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md`
|
||||||
|
- Modify: `Docs/README.md`
|
||||||
|
- Check: `Docs/Protocol.md`
|
||||||
|
- Check: `README.md`
|
||||||
|
- Check: `README.zh-CN.md`
|
||||||
|
- Check: `Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings`
|
||||||
|
- Check: `Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings`
|
||||||
|
|
||||||
|
**Step 1: Write the contract doc**
|
||||||
|
|
||||||
|
Document exactly what Atlas promises today:
|
||||||
|
- file-backed recovery physically restores only when `restoreMappings` exist
|
||||||
|
- Atlas-only recovery rehydrates workspace state without claiming on-disk return
|
||||||
|
- helper-backed restore is required for protected paths like app bundles
|
||||||
|
- restore fails closed when the trash source is gone, the destination already exists, or helper capability is unavailable
|
||||||
|
|
||||||
|
**Step 2: Write the evidence section**
|
||||||
|
|
||||||
|
Reference automated proof points:
|
||||||
|
- direct-trash cache restore test
|
||||||
|
- helper-backed app uninstall restore test
|
||||||
|
- mixed summary/state-only tests
|
||||||
|
- existing `scan -> execute -> rescan` coverage for supported targets
|
||||||
|
|
||||||
|
**Step 3: Write the gate review**
|
||||||
|
|
||||||
|
Mirror the existing execution gate format and record:
|
||||||
|
- scope reviewed (`ATL-221` to `ATL-225`)
|
||||||
|
- evidence reviewed
|
||||||
|
- automated validation summary
|
||||||
|
- remaining limits
|
||||||
|
- decision and follow-up conditions
|
||||||
|
|
||||||
|
**Step 4: Update docs index**
|
||||||
|
|
||||||
|
Add the new recovery contract and gate review docs to `Docs/README.md`.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Docs/Execution/Recovery-Contract-2026-03-13.md Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md Docs/README.md
|
||||||
|
git commit -m "docs: freeze recovery contract and gate evidence"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Run focused validation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Check: `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift`
|
||||||
|
- Check: `Docs/Execution/Recovery-Contract-2026-03-13.md`
|
||||||
|
- Check: `Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md`
|
||||||
|
|
||||||
|
**Step 1: Run targeted infrastructure tests**
|
||||||
|
|
||||||
|
Run: `swift test --package-path Packages --filter AtlasInfrastructureTests`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 2: Run broader package tests**
|
||||||
|
|
||||||
|
Run: `swift test --package-path Packages`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 3: Sanity-check docs claims**
|
||||||
|
|
||||||
|
Verify every new doc line matches one of:
|
||||||
|
- protocol contract
|
||||||
|
- localized UI copy
|
||||||
|
- automated test evidence
|
||||||
|
|
||||||
|
**Step 4: Summarize remaining limits**
|
||||||
|
|
||||||
|
Call out that:
|
||||||
|
- physical restore is still partial by design
|
||||||
|
- unsupported or older recovery items remain Atlas-state-only
|
||||||
|
- broader restore scope should not expand without new allowlist and QA evidence
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Docs/README.md Docs/Execution/Recovery-Contract-2026-03-13.md Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift
|
||||||
|
git commit -m "chore: validate recovery credibility slice"
|
||||||
|
```
|
||||||
62
Docs/plans/2026-03-13-recovery-retention-enforcement.md
Normal file
62
Docs/plans/2026-03-13-recovery-retention-enforcement.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Recovery Retention Enforcement Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Align shipped recovery behavior with the existing Atlas retention contract so expired recovery items are no longer restorable, no longer linger as active recovery entries, and return stable restore-specific protocol errors.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The current worker restores any `RecoveryItem` still present in state, even when `expiresAt` is already in the past. The app also keeps the restore action available as long as the item remains selected. This breaks the retention-window claim already present in the protocol, task-state, and recovery docs.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
### Option A: Narrow docs to match current code
|
||||||
|
|
||||||
|
- Remove the retention-window restore claim from docs and gate reviews.
|
||||||
|
- Keep restore behavior unchanged.
|
||||||
|
|
||||||
|
Why not:
|
||||||
|
|
||||||
|
- It weakens an existing product promise instead of fixing the trust gap.
|
||||||
|
- It leaves expired recovery items actionable in UI and worker flows.
|
||||||
|
|
||||||
|
### Option B: Enforce expiry only inside `restoreItems`
|
||||||
|
|
||||||
|
- Reject restore requests when any selected `RecoveryItem.expiresAt` is in the past.
|
||||||
|
- Leave repository state unchanged.
|
||||||
|
|
||||||
|
Why not:
|
||||||
|
|
||||||
|
- Expired entries would still linger in active recovery state across launches.
|
||||||
|
- The app could still display stale recovery items until the user attempts restore.
|
||||||
|
|
||||||
|
### Option C: Enforce expiry centrally and prune expired recovery items
|
||||||
|
|
||||||
|
- Normalize persisted workspace state so expired recovery items are removed on load/save.
|
||||||
|
- Recheck expiry in the worker restore path to fail closed for items that expire while the app is open.
|
||||||
|
- Return stable restore-specific error codes for expiry and restore conflicts.
|
||||||
|
- Disable restore UI when the selected entry is already expired.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Choose Option C.
|
||||||
|
|
||||||
|
## Implementation Outline
|
||||||
|
|
||||||
|
1. Extend `AtlasProtocolErrorCode` with restore-specific cases used by this flow.
|
||||||
|
2. Normalize workspace state in `AtlasWorkspaceRepository` by pruning expired `RecoveryItem`s.
|
||||||
|
3. Recheck expiry in `AtlasScaffoldWorkerService.restoreItems` before side effects.
|
||||||
|
4. Map restore conflicts such as an already-existing destination to a stable restore-specific rejection.
|
||||||
|
5. Disable restore actions for expired entries in History UI.
|
||||||
|
6. Add tests for:
|
||||||
|
- expired recovery rejection
|
||||||
|
- repository pruning of expired recovery items
|
||||||
|
- restore conflict rejection
|
||||||
|
- controller localization for restore-specific rejections
|
||||||
|
7. Update protocol, architecture, task-state, recovery contract, and gate review docs to match the shipped behavior.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `swift test --package-path Packages --filter AtlasInfrastructureTests`
|
||||||
|
- `swift test --package-path Packages --filter AtlasApplicationTests`
|
||||||
|
- `swift test --package-path Packages`
|
||||||
@@ -161,6 +161,10 @@ public enum AtlasWorkspaceControllerError: LocalizedError, Sendable {
|
|||||||
return AtlasL10n.string("application.error.executionUnavailable", reason)
|
return AtlasL10n.string("application.error.executionUnavailable", reason)
|
||||||
case .helperUnavailable:
|
case .helperUnavailable:
|
||||||
return AtlasL10n.string("application.error.helperUnavailable", reason)
|
return AtlasL10n.string("application.error.helperUnavailable", reason)
|
||||||
|
case .restoreExpired:
|
||||||
|
return AtlasL10n.string("application.error.restoreExpired", reason)
|
||||||
|
case .restoreConflict:
|
||||||
|
return AtlasL10n.string("application.error.restoreConflict", reason)
|
||||||
default:
|
default:
|
||||||
return AtlasL10n.string("application.error.workerRejected", code.rawValue, reason)
|
return AtlasL10n.string("application.error.workerRejected", code.rawValue, reason)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,52 @@ final class AtlasApplicationTests: XCTestCase {
|
|||||||
XCTAssertEqual(error.localizedDescription, AtlasL10n.string("application.error.helperUnavailable", "Privileged helper missing"))
|
XCTAssertEqual(error.localizedDescription, AtlasL10n.string("application.error.helperUnavailable", "Privileged helper missing"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testRestoreItemsMapsRestoreExpiredToLocalizedError() async throws {
|
||||||
|
let itemID = UUID()
|
||||||
|
let request = AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [itemID]))
|
||||||
|
let result = AtlasWorkerCommandResult(
|
||||||
|
request: request,
|
||||||
|
response: AtlasResponseEnvelope(
|
||||||
|
requestID: request.id,
|
||||||
|
response: .rejected(code: .restoreExpired, reason: "Recovery retention expired")
|
||||||
|
),
|
||||||
|
events: [],
|
||||||
|
snapshot: AtlasScaffoldWorkspace.snapshot(),
|
||||||
|
previewPlan: nil
|
||||||
|
)
|
||||||
|
let controller = AtlasWorkspaceController(worker: FakeWorker(result: result))
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await controller.restoreItems(itemIDs: [itemID])
|
||||||
|
XCTFail("Expected restoreItems to throw")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(error.localizedDescription, AtlasL10n.string("application.error.restoreExpired", "Recovery retention expired"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRestoreItemsMapsRestoreConflictToLocalizedError() async throws {
|
||||||
|
let itemID = UUID()
|
||||||
|
let request = AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [itemID]))
|
||||||
|
let result = AtlasWorkerCommandResult(
|
||||||
|
request: request,
|
||||||
|
response: AtlasResponseEnvelope(
|
||||||
|
requestID: request.id,
|
||||||
|
response: .rejected(code: .restoreConflict, reason: "Original path already exists")
|
||||||
|
),
|
||||||
|
events: [],
|
||||||
|
snapshot: AtlasScaffoldWorkspace.snapshot(),
|
||||||
|
previewPlan: nil
|
||||||
|
)
|
||||||
|
let controller = AtlasWorkspaceController(worker: FakeWorker(result: result))
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await controller.restoreItems(itemIDs: [itemID])
|
||||||
|
XCTFail("Expected restoreItems to throw")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(error.localizedDescription, AtlasL10n.string("application.error.restoreConflict", "Original path already exists"))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private actor FakeWorker: AtlasWorkerServing {
|
private actor FakeWorker: AtlasWorkerServing {
|
||||||
|
|||||||
@@ -92,6 +92,8 @@
|
|||||||
"application.error.workerRejected" = "Worker rejected request (%@): %@";
|
"application.error.workerRejected" = "Worker rejected request (%@): %@";
|
||||||
"application.error.executionUnavailable" = "Atlas could not run this action with the real worker path: %@";
|
"application.error.executionUnavailable" = "Atlas could not run this action with the real worker path: %@";
|
||||||
"application.error.helperUnavailable" = "Atlas could not complete this action because the privileged helper is unavailable: %@";
|
"application.error.helperUnavailable" = "Atlas could not complete this action because the privileged helper is unavailable: %@";
|
||||||
|
"application.error.restoreExpired" = "Atlas can no longer restore this item because its recovery retention window has expired: %@";
|
||||||
|
"application.error.restoreConflict" = "Atlas could not restore this item because its original destination already exists: %@";
|
||||||
"xpc.error.encodingFailed" = "Could not encode the background worker request: %@";
|
"xpc.error.encodingFailed" = "Could not encode the background worker request: %@";
|
||||||
"xpc.error.decodingFailed" = "Could not decode the background worker response: %@";
|
"xpc.error.decodingFailed" = "Could not decode the background worker response: %@";
|
||||||
"xpc.error.invalidResponse" = "The background worker returned an invalid response. Fully quit and reopen Atlas; if it still fails, reinstall the current build.";
|
"xpc.error.invalidResponse" = "The background worker returned an invalid response. Fully quit and reopen Atlas; if it still fails, reinstall the current build.";
|
||||||
|
|||||||
@@ -92,6 +92,8 @@
|
|||||||
"application.error.workerRejected" = "后台服务拒绝了请求(%@):%@";
|
"application.error.workerRejected" = "后台服务拒绝了请求(%@):%@";
|
||||||
"application.error.executionUnavailable" = "Atlas 当前无法通过真实工作链路执行这项操作:%@";
|
"application.error.executionUnavailable" = "Atlas 当前无法通过真实工作链路执行这项操作:%@";
|
||||||
"application.error.helperUnavailable" = "Atlas 当前无法完成这项操作,因为特权辅助组件不可用:%@";
|
"application.error.helperUnavailable" = "Atlas 当前无法完成这项操作,因为特权辅助组件不可用:%@";
|
||||||
|
"application.error.restoreExpired" = "这个项目已经超出恢复保留窗口,Atlas 不能再恢复它:%@";
|
||||||
|
"application.error.restoreConflict" = "Atlas 无法恢复这个项目,因为它的原始目标位置已经存在内容:%@";
|
||||||
"xpc.error.encodingFailed" = "无法编码后台请求:%@";
|
"xpc.error.encodingFailed" = "无法编码后台请求:%@";
|
||||||
"xpc.error.decodingFailed" = "无法解析后台响应:%@";
|
"xpc.error.decodingFailed" = "无法解析后台响应:%@";
|
||||||
"xpc.error.invalidResponse" = "后台工作组件返回了无效响应。请完全退出并重新打开 Atlas;若仍失败,请重新安装当前版本。";
|
"xpc.error.invalidResponse" = "后台工作组件返回了无效响应。请完全退出并重新打开 Atlas;若仍失败,请重新安装当前版本。";
|
||||||
|
|||||||
@@ -536,7 +536,7 @@ public struct HistoryFeatureView: View {
|
|||||||
HistoryRecoveryDetailView(
|
HistoryRecoveryDetailView(
|
||||||
item: item,
|
item: item,
|
||||||
isRestoring: restoringItemID == item.id,
|
isRestoring: restoringItemID == item.id,
|
||||||
canRestore: restoringItemID == nil,
|
canRestore: restoringItemID == nil && !item.isExpired,
|
||||||
onRestore: { onRestoreItem(item.id) }
|
onRestore: { onRestoreItem(item.id) }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -1115,6 +1115,13 @@ private extension RecoveryItem {
|
|||||||
!(restoreMappings ?? []).isEmpty
|
!(restoreMappings ?? []).isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isExpired: Bool {
|
||||||
|
guard let expiresAt else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return expiresAt <= Date()
|
||||||
|
}
|
||||||
|
|
||||||
var isExpiringSoon: Bool {
|
var isExpiringSoon: Bool {
|
||||||
guard let expiresAt else {
|
guard let expiresAt else {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -297,9 +297,14 @@ public enum AtlasSmartCleanExecutionSupport {
|
|||||||
|
|
||||||
public struct AtlasWorkspaceRepository: Sendable {
|
public struct AtlasWorkspaceRepository: Sendable {
|
||||||
private let stateFileURL: URL
|
private let stateFileURL: URL
|
||||||
|
private let nowProvider: @Sendable () -> Date
|
||||||
|
|
||||||
public init(stateFileURL: URL? = nil) {
|
public init(
|
||||||
|
stateFileURL: URL? = nil,
|
||||||
|
nowProvider: @escaping @Sendable () -> Date = { Date() }
|
||||||
|
) {
|
||||||
self.stateFileURL = stateFileURL ?? Self.defaultStateFileURL
|
self.stateFileURL = stateFileURL ?? Self.defaultStateFileURL
|
||||||
|
self.nowProvider = nowProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
public func loadState() -> AtlasWorkspaceState {
|
public func loadState() -> AtlasWorkspaceState {
|
||||||
@@ -308,7 +313,12 @@ public struct AtlasWorkspaceRepository: Sendable {
|
|||||||
if FileManager.default.fileExists(atPath: stateFileURL.path) {
|
if FileManager.default.fileExists(atPath: stateFileURL.path) {
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: stateFileURL)
|
let data = try Data(contentsOf: stateFileURL)
|
||||||
return try decoder.decode(AtlasWorkspaceState.self, from: data)
|
let decoded = try decoder.decode(AtlasWorkspaceState.self, from: data)
|
||||||
|
let normalized = normalizedState(decoded)
|
||||||
|
if normalized != decoded {
|
||||||
|
_ = try? saveState(normalized)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
} catch let repositoryError as AtlasWorkspaceRepositoryError {
|
} catch let repositoryError as AtlasWorkspaceRepositoryError {
|
||||||
reportFailure(repositoryError, operation: "load existing workspace state from \(stateFileURL.path)")
|
reportFailure(repositoryError, operation: "load existing workspace state from \(stateFileURL.path)")
|
||||||
} catch {
|
} catch {
|
||||||
@@ -332,6 +342,7 @@ public struct AtlasWorkspaceRepository: Sendable {
|
|||||||
public func saveState(_ state: AtlasWorkspaceState) throws -> AtlasWorkspaceState {
|
public func saveState(_ state: AtlasWorkspaceState) throws -> AtlasWorkspaceState {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let normalizedState = normalizedState(state)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
@@ -344,7 +355,7 @@ public struct AtlasWorkspaceRepository: Sendable {
|
|||||||
|
|
||||||
let data: Data
|
let data: Data
|
||||||
do {
|
do {
|
||||||
data = try encoder.encode(state)
|
data = try encoder.encode(normalizedState)
|
||||||
} catch {
|
} catch {
|
||||||
throw AtlasWorkspaceRepositoryError.encodeFailed(error.localizedDescription)
|
throw AtlasWorkspaceRepositoryError.encodeFailed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
@@ -355,7 +366,7 @@ public struct AtlasWorkspaceRepository: Sendable {
|
|||||||
throw AtlasWorkspaceRepositoryError.writeFailed(error.localizedDescription)
|
throw AtlasWorkspaceRepositoryError.writeFailed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
return state
|
return normalizedState
|
||||||
}
|
}
|
||||||
|
|
||||||
public func loadScaffoldSnapshot() -> AtlasWorkspaceSnapshot {
|
public func loadScaffoldSnapshot() -> AtlasWorkspaceSnapshot {
|
||||||
@@ -377,6 +388,15 @@ public struct AtlasWorkspaceRepository: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func normalizedState(_ state: AtlasWorkspaceState) -> AtlasWorkspaceState {
|
||||||
|
var normalized = state
|
||||||
|
let now = nowProvider()
|
||||||
|
normalized.snapshot.recoveryItems.removeAll { item in
|
||||||
|
item.isExpired(asOf: now)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
private static var defaultStateFileURL: URL {
|
private static var defaultStateFileURL: URL {
|
||||||
if let explicit = ProcessInfo.processInfo.environment["ATLAS_STATE_FILE"], !explicit.isEmpty {
|
if let explicit = ProcessInfo.processInfo.environment["ATLAS_STATE_FILE"], !explicit.isEmpty {
|
||||||
return URL(fileURLWithPath: explicit)
|
return URL(fileURLWithPath: explicit)
|
||||||
@@ -403,6 +423,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
|||||||
private let smartCleanScanProvider: (any AtlasSmartCleanScanProviding)?
|
private let smartCleanScanProvider: (any AtlasSmartCleanScanProviding)?
|
||||||
private let appsInventoryProvider: (any AtlasAppInventoryProviding)?
|
private let appsInventoryProvider: (any AtlasAppInventoryProviding)?
|
||||||
private let helperExecutor: (any AtlasPrivilegedActionExecuting)?
|
private let helperExecutor: (any AtlasPrivilegedActionExecuting)?
|
||||||
|
private let nowProvider: @Sendable () -> Date
|
||||||
private let allowProviderFailureFallback: Bool
|
private let allowProviderFailureFallback: Bool
|
||||||
private let allowStateOnlyCleanExecution: Bool
|
private let allowStateOnlyCleanExecution: Bool
|
||||||
private var state: AtlasWorkspaceState
|
private var state: AtlasWorkspaceState
|
||||||
@@ -415,6 +436,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
|||||||
appsInventoryProvider: (any AtlasAppInventoryProviding)? = nil,
|
appsInventoryProvider: (any AtlasAppInventoryProviding)? = nil,
|
||||||
helperExecutor: (any AtlasPrivilegedActionExecuting)? = nil,
|
helperExecutor: (any AtlasPrivilegedActionExecuting)? = nil,
|
||||||
auditStore: AtlasAuditStore = AtlasAuditStore(),
|
auditStore: AtlasAuditStore = AtlasAuditStore(),
|
||||||
|
nowProvider: @escaping @Sendable () -> Date = { Date() },
|
||||||
allowProviderFailureFallback: Bool = ProcessInfo.processInfo.environment["ATLAS_ALLOW_PROVIDER_FAILURE_FALLBACK"] == "1",
|
allowProviderFailureFallback: Bool = ProcessInfo.processInfo.environment["ATLAS_ALLOW_PROVIDER_FAILURE_FALLBACK"] == "1",
|
||||||
allowStateOnlyCleanExecution: Bool = ProcessInfo.processInfo.environment["ATLAS_ALLOW_STATE_ONLY_CLEAN_EXECUTION"] == "1"
|
allowStateOnlyCleanExecution: Bool = ProcessInfo.processInfo.environment["ATLAS_ALLOW_STATE_ONLY_CLEAN_EXECUTION"] == "1"
|
||||||
) {
|
) {
|
||||||
@@ -425,6 +447,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
|||||||
self.smartCleanScanProvider = smartCleanScanProvider
|
self.smartCleanScanProvider = smartCleanScanProvider
|
||||||
self.appsInventoryProvider = appsInventoryProvider
|
self.appsInventoryProvider = appsInventoryProvider
|
||||||
self.helperExecutor = helperExecutor
|
self.helperExecutor = helperExecutor
|
||||||
|
self.nowProvider = nowProvider
|
||||||
self.allowProviderFailureFallback = allowProviderFailureFallback
|
self.allowProviderFailureFallback = allowProviderFailureFallback
|
||||||
self.allowStateOnlyCleanExecution = allowStateOnlyCleanExecution
|
self.allowStateOnlyCleanExecution = allowStateOnlyCleanExecution
|
||||||
self.state = repository.loadState()
|
self.state = repository.loadState()
|
||||||
@@ -433,6 +456,11 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
|||||||
|
|
||||||
public func submit(_ request: AtlasRequestEnvelope) async throws -> AtlasWorkerCommandResult {
|
public func submit(_ request: AtlasRequestEnvelope) async throws -> AtlasWorkerCommandResult {
|
||||||
AtlasL10n.setCurrentLanguage(state.settings.language)
|
AtlasL10n.setCurrentLanguage(state.settings.language)
|
||||||
|
if case .restoreItems = request.command {
|
||||||
|
// Restore needs selected-item expiry reporting before the general prune.
|
||||||
|
} else {
|
||||||
|
await pruneExpiredRecoveryItemsIfNeeded(context: "process request \(request.id.uuidString)")
|
||||||
|
}
|
||||||
switch request.command {
|
switch request.command {
|
||||||
case .healthSnapshot:
|
case .healthSnapshot:
|
||||||
return try await healthSnapshot(using: request)
|
return try await healthSnapshot(using: request)
|
||||||
@@ -695,7 +723,20 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func restoreItems(using request: AtlasRequestEnvelope, taskID: UUID, itemIDs: [UUID]) async -> AtlasWorkerCommandResult {
|
private func restoreItems(using request: AtlasRequestEnvelope, taskID: UUID, itemIDs: [UUID]) async -> AtlasWorkerCommandResult {
|
||||||
let itemsToRestore = state.snapshot.recoveryItems.filter { itemIDs.contains($0.id) }
|
let requestedItemIDs = Set(itemIDs)
|
||||||
|
let expiredSelectionIDs = requestedItemIDs.intersection(expiredRecoveryItemIDs())
|
||||||
|
|
||||||
|
if !expiredSelectionIDs.isEmpty {
|
||||||
|
await pruneExpiredRecoveryItemsIfNeeded(context: "prune expired recovery items before rejected restore")
|
||||||
|
return rejectedResult(
|
||||||
|
for: request,
|
||||||
|
code: .restoreExpired,
|
||||||
|
reason: "One or more selected recovery items have expired and can no longer be restored."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await pruneExpiredRecoveryItemsIfNeeded(context: "refresh recovery retention before restore")
|
||||||
|
let itemsToRestore = state.snapshot.recoveryItems.filter { requestedItemIDs.contains($0.id) }
|
||||||
|
|
||||||
guard !itemsToRestore.isEmpty else {
|
guard !itemsToRestore.isEmpty else {
|
||||||
return rejectedResult(
|
return rejectedResult(
|
||||||
@@ -713,6 +754,12 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
|||||||
do {
|
do {
|
||||||
try await restoreRecoveryMappings(restoreMappings)
|
try await restoreRecoveryMappings(restoreMappings)
|
||||||
physicalRestoreCount += 1
|
physicalRestoreCount += 1
|
||||||
|
} catch let failure as RecoveryRestoreFailure {
|
||||||
|
return rejectedResult(
|
||||||
|
for: request,
|
||||||
|
code: failure.code,
|
||||||
|
reason: failure.localizedDescription
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
return rejectedResult(
|
return rejectedResult(
|
||||||
for: request,
|
for: request,
|
||||||
@@ -885,6 +932,25 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func expiredRecoveryItemIDs(asOf now: Date? = nil) -> Set<UUID> {
|
||||||
|
let cutoff = now ?? nowProvider()
|
||||||
|
return Set(state.snapshot.recoveryItems.compactMap { item in
|
||||||
|
item.isExpired(asOf: cutoff) ? item.id : nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pruneExpiredRecoveryItemsIfNeeded(context: String, now: Date? = nil) async {
|
||||||
|
let cutoff = now ?? nowProvider()
|
||||||
|
let expiredIDs = expiredRecoveryItemIDs(asOf: cutoff)
|
||||||
|
guard !expiredIDs.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.snapshot.recoveryItems.removeAll { expiredIDs.contains($0.id) }
|
||||||
|
await persistState(context: context)
|
||||||
|
await auditStore.append("Pruned \(expiredIDs.count) expired recovery item(s)")
|
||||||
|
}
|
||||||
|
|
||||||
private func persistState(context: String) async {
|
private func persistState(context: String) async {
|
||||||
do {
|
do {
|
||||||
_ = try repository.saveState(state)
|
_ = try repository.saveState(state)
|
||||||
@@ -1003,23 +1069,23 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
|||||||
let sourceURL = URL(fileURLWithPath: mapping.trashedPath).resolvingSymlinksInPath()
|
let sourceURL = URL(fileURLWithPath: mapping.trashedPath).resolvingSymlinksInPath()
|
||||||
let destinationURL = URL(fileURLWithPath: mapping.originalPath).resolvingSymlinksInPath()
|
let destinationURL = URL(fileURLWithPath: mapping.originalPath).resolvingSymlinksInPath()
|
||||||
guard FileManager.default.fileExists(atPath: sourceURL.path) else {
|
guard FileManager.default.fileExists(atPath: sourceURL.path) else {
|
||||||
throw AtlasWorkspaceRepositoryError.writeFailed("Recovery source is no longer available on disk: \(sourceURL.path)")
|
throw RecoveryRestoreFailure.executionUnavailable("Recovery source is no longer available on disk: \(sourceURL.path)")
|
||||||
}
|
}
|
||||||
if shouldUseHelperForSmartCleanTarget(destinationURL) {
|
if shouldUseHelperForSmartCleanTarget(destinationURL) {
|
||||||
guard let helperExecutor else {
|
guard let helperExecutor else {
|
||||||
throw AtlasWorkspaceRepositoryError.writeFailed("Bundled helper unavailable for recovery target: \(destinationURL.path)")
|
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))
|
let result = try await helperExecutor.perform(AtlasHelperAction(kind: .restoreItem, targetPath: sourceURL.path, destinationPath: destinationURL.path))
|
||||||
guard result.success else {
|
guard result.success else {
|
||||||
throw AtlasWorkspaceRepositoryError.writeFailed(result.message)
|
throw RecoveryRestoreFailure.executionUnavailable(result.message)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard isDirectlyTrashableSmartCleanTarget(destinationURL) else {
|
guard isDirectlyTrashableSmartCleanTarget(destinationURL) else {
|
||||||
throw AtlasWorkspaceRepositoryError.writeFailed("Recovery target is outside the supported execution allowlist: \(destinationURL.path)")
|
throw RecoveryRestoreFailure.executionUnavailable("Recovery target is outside the supported execution allowlist: \(destinationURL.path)")
|
||||||
}
|
}
|
||||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||||
throw AtlasWorkspaceRepositoryError.writeFailed("Recovery target already exists: \(destinationURL.path)")
|
throw RecoveryRestoreFailure.restoreConflict("Recovery target already exists: \(destinationURL.path)")
|
||||||
}
|
}
|
||||||
try FileManager.default.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
try FileManager.default.moveItem(at: sourceURL, to: destinationURL)
|
try FileManager.default.moveItem(at: sourceURL, to: destinationURL)
|
||||||
@@ -1284,6 +1350,41 @@ private struct SmartCleanExecutionFailure: LocalizedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum RecoveryRestoreFailure: LocalizedError {
|
||||||
|
case helperUnavailable(String)
|
||||||
|
case restoreConflict(String)
|
||||||
|
case executionUnavailable(String)
|
||||||
|
|
||||||
|
var code: AtlasProtocolErrorCode {
|
||||||
|
switch self {
|
||||||
|
case .helperUnavailable:
|
||||||
|
return .helperUnavailable
|
||||||
|
case .restoreConflict:
|
||||||
|
return .restoreConflict
|
||||||
|
case .executionUnavailable:
|
||||||
|
return .executionUnavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case let .helperUnavailable(reason),
|
||||||
|
let .restoreConflict(reason),
|
||||||
|
let .executionUnavailable(reason):
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension RecoveryItem {
|
||||||
|
func isExpired(asOf date: Date) -> Bool {
|
||||||
|
guard let expiresAt else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return expiresAt <= date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
import AtlasProtocol
|
import AtlasProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,55 @@ final class AtlasInfrastructureTests: XCTestCase {
|
|||||||
XCTAssertThrowsError(try repository.saveState(AtlasScaffoldWorkspace.state()))
|
XCTAssertThrowsError(try repository.saveState(AtlasScaffoldWorkspace.state()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testRepositorySaveStatePrunesExpiredRecoveryItems() throws {
|
||||||
|
let baseDate = Date(timeIntervalSince1970: 1_710_000_000)
|
||||||
|
let clock = TestClock(now: baseDate)
|
||||||
|
let repository = AtlasWorkspaceRepository(
|
||||||
|
stateFileURL: temporaryStateFileURL(),
|
||||||
|
nowProvider: { clock.now }
|
||||||
|
)
|
||||||
|
let activeItem = RecoveryItem(
|
||||||
|
id: UUID(),
|
||||||
|
title: "Active recovery",
|
||||||
|
detail: "Still valid",
|
||||||
|
originalPath: "~/Library/Caches/Active",
|
||||||
|
bytes: 5,
|
||||||
|
deletedAt: baseDate.addingTimeInterval(-120),
|
||||||
|
expiresAt: baseDate.addingTimeInterval(3600),
|
||||||
|
payload: nil,
|
||||||
|
restoreMappings: nil
|
||||||
|
)
|
||||||
|
let expiredItem = RecoveryItem(
|
||||||
|
id: UUID(),
|
||||||
|
title: "Expired recovery",
|
||||||
|
detail: "Expired",
|
||||||
|
originalPath: "~/Library/Caches/Expired",
|
||||||
|
bytes: 7,
|
||||||
|
deletedAt: baseDate.addingTimeInterval(-7200),
|
||||||
|
expiresAt: baseDate.addingTimeInterval(-1),
|
||||||
|
payload: nil,
|
||||||
|
restoreMappings: nil
|
||||||
|
)
|
||||||
|
let state = AtlasWorkspaceState(
|
||||||
|
snapshot: AtlasWorkspaceSnapshot(
|
||||||
|
reclaimableSpaceBytes: 0,
|
||||||
|
findings: [],
|
||||||
|
apps: [],
|
||||||
|
taskRuns: [],
|
||||||
|
recoveryItems: [activeItem, expiredItem],
|
||||||
|
permissions: [],
|
||||||
|
healthSnapshot: nil
|
||||||
|
),
|
||||||
|
currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0),
|
||||||
|
settings: AtlasScaffoldWorkspace.state().settings
|
||||||
|
)
|
||||||
|
|
||||||
|
let saved = try repository.saveState(state)
|
||||||
|
|
||||||
|
XCTAssertEqual(saved.snapshot.recoveryItems.map(\.id), [activeItem.id])
|
||||||
|
XCTAssertEqual(repository.loadState().snapshot.recoveryItems.map(\.id), [activeItem.id])
|
||||||
|
}
|
||||||
|
|
||||||
func testExecutePlanMovesSupportedFindingsIntoRecoveryWhileKeepingInspectionOnlyItems() async throws {
|
func testExecutePlanMovesSupportedFindingsIntoRecoveryWhileKeepingInspectionOnlyItems() async throws {
|
||||||
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
|
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||||
@@ -709,6 +758,237 @@ final class AtlasInfrastructureTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testRestoreItemsRejectsExpiredRecoveryItemsAndPrunesThem() async throws {
|
||||||
|
let baseDate = Date(timeIntervalSince1970: 1_710_000_000)
|
||||||
|
let clock = TestClock(now: baseDate)
|
||||||
|
let repository = AtlasWorkspaceRepository(
|
||||||
|
stateFileURL: temporaryStateFileURL(),
|
||||||
|
nowProvider: { clock.now }
|
||||||
|
)
|
||||||
|
let finding = Finding(
|
||||||
|
id: UUID(),
|
||||||
|
title: "Atlas-only fixture",
|
||||||
|
detail: "Expires soon",
|
||||||
|
bytes: 5,
|
||||||
|
risk: .safe,
|
||||||
|
category: "Developer tools"
|
||||||
|
)
|
||||||
|
let recoveryItem = RecoveryItem(
|
||||||
|
id: UUID(),
|
||||||
|
title: finding.title,
|
||||||
|
detail: finding.detail,
|
||||||
|
originalPath: "~/Library/Caches/AtlasOnly",
|
||||||
|
bytes: 5,
|
||||||
|
deletedAt: baseDate,
|
||||||
|
expiresAt: baseDate.addingTimeInterval(10),
|
||||||
|
payload: .finding(finding),
|
||||||
|
restoreMappings: nil
|
||||||
|
)
|
||||||
|
let state = AtlasWorkspaceState(
|
||||||
|
snapshot: AtlasWorkspaceSnapshot(
|
||||||
|
reclaimableSpaceBytes: 0,
|
||||||
|
findings: [],
|
||||||
|
apps: [],
|
||||||
|
taskRuns: [],
|
||||||
|
recoveryItems: [recoveryItem],
|
||||||
|
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,
|
||||||
|
nowProvider: { clock.now },
|
||||||
|
allowStateOnlyCleanExecution: false
|
||||||
|
)
|
||||||
|
clock.now = baseDate.addingTimeInterval(60)
|
||||||
|
|
||||||
|
let restore = try await worker.submit(
|
||||||
|
AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [recoveryItem.id]))
|
||||||
|
)
|
||||||
|
|
||||||
|
guard case let .rejected(code, reason) = restore.response.response else {
|
||||||
|
return XCTFail("Expected rejected restore response")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(code, .restoreExpired)
|
||||||
|
XCTAssertTrue(reason.contains("expired"))
|
||||||
|
XCTAssertFalse(restore.snapshot.recoveryItems.contains(where: { $0.id == recoveryItem.id }))
|
||||||
|
XCTAssertFalse(repository.loadState().snapshot.recoveryItems.contains(where: { $0.id == recoveryItem.id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRestoreItemsMixedSummaryIncludesDiskAndStateOnlyClauses() async throws {
|
||||||
|
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
|
||||||
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
let targetDirectory = home.appendingPathComponent("Library/Caches/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: targetDirectory, withIntermediateDirectories: true)
|
||||||
|
let targetFile = targetDirectory.appendingPathComponent("sample.cache")
|
||||||
|
try Data("cache".utf8).write(to: targetFile)
|
||||||
|
|
||||||
|
var trashedURL: NSURL?
|
||||||
|
try FileManager.default.trashItem(at: targetFile, resultingItemURL: &trashedURL)
|
||||||
|
let trashedPath = try XCTUnwrap((trashedURL as URL?)?.path)
|
||||||
|
|
||||||
|
addTeardownBlock {
|
||||||
|
try? FileManager.default.removeItem(at: targetDirectory)
|
||||||
|
if let trashedURL {
|
||||||
|
try? FileManager.default.removeItem(at: trashedURL as URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileBackedFinding = Finding(
|
||||||
|
id: UUID(),
|
||||||
|
title: "Disk-backed fixture",
|
||||||
|
detail: targetFile.path,
|
||||||
|
bytes: 5,
|
||||||
|
risk: .safe,
|
||||||
|
category: "Developer tools",
|
||||||
|
targetPaths: [targetFile.path]
|
||||||
|
)
|
||||||
|
let stateOnlyFinding = Finding(
|
||||||
|
id: UUID(),
|
||||||
|
title: "Atlas-only fixture",
|
||||||
|
detail: "State-only recovery item",
|
||||||
|
bytes: 7,
|
||||||
|
risk: .safe,
|
||||||
|
category: "Developer tools"
|
||||||
|
)
|
||||||
|
let state = AtlasWorkspaceState(
|
||||||
|
snapshot: AtlasWorkspaceSnapshot(
|
||||||
|
reclaimableSpaceBytes: 0,
|
||||||
|
findings: [],
|
||||||
|
apps: [],
|
||||||
|
taskRuns: [],
|
||||||
|
recoveryItems: [
|
||||||
|
RecoveryItem(
|
||||||
|
id: UUID(),
|
||||||
|
title: fileBackedFinding.title,
|
||||||
|
detail: fileBackedFinding.detail,
|
||||||
|
originalPath: targetFile.path,
|
||||||
|
bytes: fileBackedFinding.bytes,
|
||||||
|
deletedAt: Date(),
|
||||||
|
expiresAt: Date().addingTimeInterval(3600),
|
||||||
|
payload: .finding(fileBackedFinding),
|
||||||
|
restoreMappings: [RecoveryPathMapping(originalPath: targetFile.path, trashedPath: trashedPath)]
|
||||||
|
),
|
||||||
|
RecoveryItem(
|
||||||
|
id: UUID(),
|
||||||
|
title: stateOnlyFinding.title,
|
||||||
|
detail: stateOnlyFinding.detail,
|
||||||
|
originalPath: "~/Library/Caches/AtlasOnly",
|
||||||
|
bytes: stateOnlyFinding.bytes,
|
||||||
|
deletedAt: Date(),
|
||||||
|
expiresAt: Date().addingTimeInterval(3600),
|
||||||
|
payload: .finding(stateOnlyFinding),
|
||||||
|
restoreMappings: nil
|
||||||
|
),
|
||||||
|
],
|
||||||
|
permissions: [],
|
||||||
|
healthSnapshot: nil
|
||||||
|
),
|
||||||
|
currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0),
|
||||||
|
settings: AtlasScaffoldWorkspace.state().settings
|
||||||
|
)
|
||||||
|
_ = try repository.saveState(state)
|
||||||
|
|
||||||
|
let restoreItemIDs = state.snapshot.recoveryItems.map(\.id)
|
||||||
|
let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: false)
|
||||||
|
let restore = try await worker.submit(AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: restoreItemIDs)))
|
||||||
|
|
||||||
|
if case let .accepted(task) = restore.response.response {
|
||||||
|
XCTAssertEqual(task.kind, .restore)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected accepted restore response")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: targetFile.path))
|
||||||
|
XCTAssertTrue(restore.snapshot.findings.contains(where: { $0.id == fileBackedFinding.id }))
|
||||||
|
XCTAssertTrue(restore.snapshot.findings.contains(where: { $0.id == stateOnlyFinding.id }))
|
||||||
|
XCTAssertEqual(
|
||||||
|
restore.snapshot.taskRuns.first?.summary,
|
||||||
|
[
|
||||||
|
AtlasL10n.string("infrastructure.restore.summary.disk.one", language: state.settings.language),
|
||||||
|
AtlasL10n.string("infrastructure.restore.summary.state.one", language: state.settings.language),
|
||||||
|
].joined(separator: " ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRestoreItemsRejectsWhenDestinationAlreadyExists() async throws {
|
||||||
|
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let home = fileManager.homeDirectoryForCurrentUser
|
||||||
|
let sourceDirectory = home.appendingPathComponent("Library/Caches/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true)
|
||||||
|
let destinationDirectory = home.appendingPathComponent("Library/Caches/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: sourceDirectory, withIntermediateDirectories: true)
|
||||||
|
try fileManager.createDirectory(at: destinationDirectory, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let trashedCandidate = sourceDirectory.appendingPathComponent("trashed.cache")
|
||||||
|
try Data("trashed".utf8).write(to: trashedCandidate)
|
||||||
|
var trashedURL: NSURL?
|
||||||
|
try fileManager.trashItem(at: trashedCandidate, resultingItemURL: &trashedURL)
|
||||||
|
let trashedPath = try XCTUnwrap((trashedURL as URL?)?.path)
|
||||||
|
|
||||||
|
let destinationURL = destinationDirectory.appendingPathComponent("trashed.cache")
|
||||||
|
try Data("existing".utf8).write(to: destinationURL)
|
||||||
|
|
||||||
|
addTeardownBlock {
|
||||||
|
try? FileManager.default.removeItem(at: sourceDirectory)
|
||||||
|
try? FileManager.default.removeItem(at: destinationDirectory)
|
||||||
|
if let trashedURL {
|
||||||
|
try? FileManager.default.removeItem(at: trashedURL as URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let finding = Finding(
|
||||||
|
id: UUID(),
|
||||||
|
title: "Conflicting restore",
|
||||||
|
detail: destinationURL.path,
|
||||||
|
bytes: 7,
|
||||||
|
risk: .safe,
|
||||||
|
category: "Developer tools",
|
||||||
|
targetPaths: [destinationURL.path]
|
||||||
|
)
|
||||||
|
let recoveryItem = RecoveryItem(
|
||||||
|
id: UUID(),
|
||||||
|
title: finding.title,
|
||||||
|
detail: finding.detail,
|
||||||
|
originalPath: destinationURL.path,
|
||||||
|
bytes: 7,
|
||||||
|
deletedAt: Date(),
|
||||||
|
expiresAt: Date().addingTimeInterval(3600),
|
||||||
|
payload: .finding(finding),
|
||||||
|
restoreMappings: [RecoveryPathMapping(originalPath: destinationURL.path, trashedPath: trashedPath)]
|
||||||
|
)
|
||||||
|
let state = AtlasWorkspaceState(
|
||||||
|
snapshot: AtlasWorkspaceSnapshot(
|
||||||
|
reclaimableSpaceBytes: 0,
|
||||||
|
findings: [],
|
||||||
|
apps: [],
|
||||||
|
taskRuns: [],
|
||||||
|
recoveryItems: [recoveryItem],
|
||||||
|
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, allowStateOnlyCleanExecution: false)
|
||||||
|
let restore = try await worker.submit(
|
||||||
|
AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [recoveryItem.id]))
|
||||||
|
)
|
||||||
|
|
||||||
|
guard case let .rejected(code, reason) = restore.response.response else {
|
||||||
|
return XCTFail("Expected rejected restore response")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(code, .restoreConflict)
|
||||||
|
XCTAssertTrue(reason.contains(destinationURL.path))
|
||||||
|
XCTAssertTrue(fileManager.fileExists(atPath: destinationURL.path))
|
||||||
|
XCTAssertTrue(fileManager.fileExists(atPath: trashedPath))
|
||||||
|
}
|
||||||
|
|
||||||
func testExecuteAppUninstallRemovesAppAndCreatesRecoveryEntry() async throws {
|
func testExecuteAppUninstallRemovesAppAndCreatesRecoveryEntry() async throws {
|
||||||
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
|
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
|
||||||
let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: true)
|
let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: true)
|
||||||
@@ -724,6 +1004,84 @@ final class AtlasInfrastructureTests: XCTestCase {
|
|||||||
XCTAssertEqual(result.snapshot.taskRuns.first?.kind, .uninstallApp)
|
XCTAssertEqual(result.snapshot.taskRuns.first?.kind, .uninstallApp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testExecuteAppUninstallRestorePhysicallyRestoresAppBundle() async throws {
|
||||||
|
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let appRoot = fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Applications/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true)
|
||||||
|
let appBundleURL = appRoot.appendingPathComponent("Atlas Restore Test.app", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: appBundleURL, withIntermediateDirectories: true)
|
||||||
|
let executableURL = appBundleURL.appendingPathComponent("Contents/MacOS/AtlasRestoreTest")
|
||||||
|
try fileManager.createDirectory(at: executableURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try Data("#!/bin/sh\nexit 0\n".utf8).write(to: executableURL)
|
||||||
|
|
||||||
|
addTeardownBlock {
|
||||||
|
try? FileManager.default.removeItem(at: appRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
let app = AppFootprint(
|
||||||
|
id: UUID(),
|
||||||
|
name: "Atlas Restore Test",
|
||||||
|
bundleIdentifier: "com.atlas.restore-test",
|
||||||
|
bundlePath: appBundleURL.path,
|
||||||
|
bytes: 17,
|
||||||
|
leftoverItems: 1
|
||||||
|
)
|
||||||
|
let state = AtlasWorkspaceState(
|
||||||
|
snapshot: AtlasWorkspaceSnapshot(
|
||||||
|
reclaimableSpaceBytes: app.bytes,
|
||||||
|
findings: [],
|
||||||
|
apps: [app],
|
||||||
|
taskRuns: [],
|
||||||
|
recoveryItems: [],
|
||||||
|
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: StubPrivilegedHelperExecutor(),
|
||||||
|
allowStateOnlyCleanExecution: false
|
||||||
|
)
|
||||||
|
|
||||||
|
let execute = try await worker.submit(
|
||||||
|
AtlasRequestEnvelope(command: .executeAppUninstall(appID: app.id))
|
||||||
|
)
|
||||||
|
|
||||||
|
if case let .accepted(task) = execute.response.response {
|
||||||
|
XCTAssertEqual(task.kind, .uninstallApp)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected accepted uninstall response")
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertFalse(fileManager.fileExists(atPath: appBundleURL.path))
|
||||||
|
XCTAssertFalse(execute.snapshot.apps.contains(where: { $0.id == app.id }))
|
||||||
|
|
||||||
|
let recoveryItem = try XCTUnwrap(execute.snapshot.recoveryItems.first)
|
||||||
|
XCTAssertEqual(recoveryItem.restoreMappings?.first?.originalPath, appBundleURL.path)
|
||||||
|
XCTAssertNotNil(recoveryItem.restoreMappings?.first?.trashedPath)
|
||||||
|
|
||||||
|
let restore = try await worker.submit(
|
||||||
|
AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [recoveryItem.id]))
|
||||||
|
)
|
||||||
|
|
||||||
|
if case let .accepted(task) = restore.response.response {
|
||||||
|
XCTAssertEqual(task.kind, .restore)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected accepted restore response")
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(fileManager.fileExists(atPath: appBundleURL.path))
|
||||||
|
XCTAssertTrue(restore.snapshot.apps.contains(where: { $0.id == app.id }))
|
||||||
|
XCTAssertEqual(
|
||||||
|
restore.snapshot.taskRuns.first?.summary,
|
||||||
|
AtlasL10n.string("infrastructure.restore.summary.disk.one", language: state.settings.language)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func temporaryStateFileURL() -> URL {
|
private func temporaryStateFileURL() -> URL {
|
||||||
FileManager.default.temporaryDirectory
|
FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
@@ -761,3 +1119,43 @@ private struct FileBackedSmartCleanProvider: AtlasSmartCleanScanProviding {
|
|||||||
return AtlasSmartCleanScanResult(findings: [finding], summary: "Found 1 reclaimable item.")
|
return AtlasSmartCleanScanResult(findings: [finding], summary: "Found 1 reclaimable item.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private actor StubPrivilegedHelperExecutor: AtlasPrivilegedActionExecuting {
|
||||||
|
func perform(_ action: AtlasHelperAction) async throws -> AtlasHelperActionResult {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let targetURL = URL(fileURLWithPath: action.targetPath)
|
||||||
|
|
||||||
|
switch action.kind {
|
||||||
|
case .trashItems:
|
||||||
|
var trashedURL: NSURL?
|
||||||
|
try fileManager.trashItem(at: targetURL, resultingItemURL: &trashedURL)
|
||||||
|
return AtlasHelperActionResult(
|
||||||
|
action: action,
|
||||||
|
success: true,
|
||||||
|
message: "Moved item to Trash.",
|
||||||
|
resolvedPath: (trashedURL as URL?)?.path
|
||||||
|
)
|
||||||
|
case .restoreItem:
|
||||||
|
let destinationPath = try XCTUnwrap(action.destinationPath)
|
||||||
|
let destinationURL = URL(fileURLWithPath: destinationPath)
|
||||||
|
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try fileManager.moveItem(at: targetURL, to: destinationURL)
|
||||||
|
return AtlasHelperActionResult(
|
||||||
|
action: action,
|
||||||
|
success: true,
|
||||||
|
message: "Restored item from Trash.",
|
||||||
|
resolvedPath: destinationURL.path
|
||||||
|
)
|
||||||
|
case .removeLaunchService, .repairOwnership:
|
||||||
|
throw NSError(domain: "StubPrivilegedHelperExecutor", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unsupported test helper action: \(action.kind)"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TestClock: @unchecked Sendable {
|
||||||
|
var now: Date
|
||||||
|
|
||||||
|
init(now: Date) {
|
||||||
|
self.now = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import AtlasDomain
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum AtlasProtocolVersion {
|
public enum AtlasProtocolVersion {
|
||||||
public static let current = "0.3.0"
|
public static let current = "0.3.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AtlasCommand: Codable, Hashable, Sendable {
|
public enum AtlasCommand: Codable, Hashable, Sendable {
|
||||||
@@ -46,6 +46,8 @@ public enum AtlasProtocolErrorCode: String, Codable, CaseIterable, Hashable, Sen
|
|||||||
case permissionRequired
|
case permissionRequired
|
||||||
case helperUnavailable
|
case helperUnavailable
|
||||||
case executionUnavailable
|
case executionUnavailable
|
||||||
|
case restoreExpired
|
||||||
|
case restoreConflict
|
||||||
case invalidSelection
|
case invalidSelection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
project.yml
17
project.yml
@@ -21,6 +21,7 @@ schemes:
|
|||||||
config: Debug
|
config: Debug
|
||||||
gatherCoverageData: false
|
gatherCoverageData: false
|
||||||
targets:
|
targets:
|
||||||
|
- name: AtlasAppTests
|
||||||
- name: AtlasAppUITests
|
- name: AtlasAppUITests
|
||||||
run:
|
run:
|
||||||
config: Debug
|
config: Debug
|
||||||
@@ -62,6 +63,7 @@ targets:
|
|||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.atlasformac.app
|
PRODUCT_BUNDLE_IDENTIFIER: com.atlasformac.app
|
||||||
PRODUCT_NAME: Atlas for Mac
|
PRODUCT_NAME: Atlas for Mac
|
||||||
|
PRODUCT_MODULE_NAME: AtlasApp
|
||||||
MARKETING_VERSION: "1.0.0"
|
MARKETING_VERSION: "1.0.0"
|
||||||
CURRENT_PROJECT_VERSION: 1
|
CURRENT_PROJECT_VERSION: 1
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
@@ -99,6 +101,21 @@ targets:
|
|||||||
product: AtlasFeaturesSmartClean
|
product: AtlasFeaturesSmartClean
|
||||||
- package: Packages
|
- package: Packages
|
||||||
product: AtlasInfrastructure
|
product: AtlasInfrastructure
|
||||||
|
AtlasAppTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: macOS
|
||||||
|
deploymentTarget: "14.0"
|
||||||
|
sources:
|
||||||
|
- path: Apps/AtlasApp/Tests/AtlasAppTests
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.atlasformac.app.tests
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Atlas for Mac.app/Contents/MacOS/Atlas for Mac"
|
||||||
|
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||||
|
AD_HOC_CODE_SIGNING_ALLOWED: YES
|
||||||
|
dependencies:
|
||||||
|
- target: AtlasApp
|
||||||
AtlasAppUITests:
|
AtlasAppUITests:
|
||||||
type: bundle.ui-testing
|
type: bundle.ui-testing
|
||||||
platform: macOS
|
platform: macOS
|
||||||
|
|||||||
Reference in New Issue
Block a user