From 78ecca3a1524d4d796f0e8c0845a268777e36694 Mon Sep 17 00:00:00 2001 From: zhukang <274546966@qq.com> Date: Mon, 23 Mar 2026 17:35:05 +0800 Subject: [PATCH] ralph-loop[epic-a-to-d-mainline]: iteration 2 --- .../Sources/AtlasApp/AtlasAppModel.swift | 84 ++++++--- .../AtlasAppTests/AtlasAppModelTests.swift | 80 ++++++++ .../AtlasApplication/AtlasApplication.swift | 44 +++++ .../Sources/AtlasDomain/AtlasDomain.swift | 33 +++- .../Resources/en.lproj/Localizable.strings | 10 + .../zh-Hans.lproj/Localizable.strings | 10 + .../AtlasDomainTests/AtlasDomainTests.swift | 1 + .../HistoryFeatureView.swift | 74 ++++++++ .../AtlasInfrastructure.swift | 25 ++- .../AtlasInfrastructureTests.swift | 96 ++++++++++ .../AtlasTestingSupport.swift | 51 +++++ scripts/atlas/apps-evidence-acceptance.sh | 50 +++++ scripts/atlas/apps-manual-fixtures.sh | 178 ++++++++++++++++++ scripts/atlas/full-acceptance.sh | 25 ++- scripts/atlas/smart-clean-manual-fixtures.sh | 10 +- 15 files changed, 731 insertions(+), 40 deletions(-) create mode 100755 scripts/atlas/apps-evidence-acceptance.sh create mode 100755 scripts/atlas/apps-manual-fixtures.sh diff --git a/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift b/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift index 5ad7594..79e5d0c 100644 --- a/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift +++ b/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift @@ -391,29 +391,7 @@ final class AtlasAppModel: ObservableObject { } func refreshApps() async { - guard !isAppActionRunning else { - return - } - - selection = .apps - isAppActionRunning = true - activePreviewAppID = nil - activeUninstallAppID = nil - currentAppPreview = nil - currentPreviewedAppID = nil - latestAppsSummary = AtlasL10n.string("model.apps.refreshing") - - do { - let output = try await workspaceController.listApps() - withAnimation(.snappy(duration: 0.24)) { - snapshot = output.snapshot - latestAppsSummary = output.summary - } - } catch { - latestAppsSummary = error.localizedDescription - } - - isAppActionRunning = false + await reloadAppsInventory(navigateToApps: true, resetPreview: true) } func previewAppUninstall(appID: UUID) async { @@ -473,6 +451,8 @@ final class AtlasAppModel: ObservableObject { return } + let restoredItem = snapshot.recoveryItems.first(where: { $0.id == itemID }) + let shouldRefreshAppsAfterRestore = restoredItem?.isAppPayload == true restoringRecoveryItemID = itemID do { @@ -481,8 +461,21 @@ final class AtlasAppModel: ObservableObject { snapshot = output.snapshot latestScanSummary = output.summary smartCleanExecutionIssue = nil + if shouldRefreshAppsAfterRestore { + currentAppPreview = nil + currentPreviewedAppID = nil + latestAppsSummary = output.summary + } + } + if shouldRefreshAppsAfterRestore { + await reloadAppsInventory( + navigateToApps: false, + resetPreview: true, + loadingSummary: output.summary + ) + } else { + await refreshPlanPreview() } - await refreshPlanPreview() } catch { let persistedState = repository.loadState() withAnimation(.snappy(duration: 0.24)) { @@ -605,6 +598,40 @@ final class AtlasAppModel: ObservableObject { } } + private func reloadAppsInventory( + navigateToApps: Bool, + resetPreview: Bool, + loadingSummary: String? = nil + ) async { + guard !isAppActionRunning else { + return + } + + if navigateToApps { + selection = .apps + } + isAppActionRunning = true + activePreviewAppID = nil + activeUninstallAppID = nil + if resetPreview { + currentAppPreview = nil + currentPreviewedAppID = nil + } + latestAppsSummary = loadingSummary ?? AtlasL10n.string("model.apps.refreshing") + + do { + let output = try await workspaceController.listApps() + withAnimation(.snappy(duration: 0.24)) { + snapshot = output.snapshot + latestAppsSummary = output.summary + } + } catch { + latestAppsSummary = error.localizedDescription + } + + isAppActionRunning = false + } + private func filter( _ elements: [Element], route: AtlasRoute, @@ -627,6 +654,15 @@ final class AtlasAppModel: ObservableObject { } } +private extension RecoveryItem { + var isAppPayload: Bool { + if case .app = payload { + return true + } + return false + } +} + private extension AtlasAppModel { func resolvedTargetPaths(for item: ActionItem) -> [String] { if let targetPaths = item.targetPaths, !targetPaths.isEmpty { diff --git a/Apps/AtlasApp/Tests/AtlasAppTests/AtlasAppModelTests.swift b/Apps/AtlasApp/Tests/AtlasAppTests/AtlasAppModelTests.swift index 995173f..2176cfe 100644 --- a/Apps/AtlasApp/Tests/AtlasAppTests/AtlasAppModelTests.swift +++ b/Apps/AtlasApp/Tests/AtlasAppTests/AtlasAppModelTests.swift @@ -284,6 +284,72 @@ final class AtlasAppModelTests: XCTestCase { XCTAssertNil(model.smartCleanExecutionIssue) } + func testRestoreAppRecoveryItemClearsPreviewAndRefreshesInventoryWithoutLeavingHistory() async throws { + let repository = makeRepository() + let app = AppFootprint( + id: UUID(), + name: "Recovered App", + bundleIdentifier: "com.example.recovered", + bundlePath: "/Applications/Recovered App.app", + bytes: 2_048, + leftoverItems: 9 + ) + let recoveryItem = RecoveryItem( + id: UUID(), + title: app.name, + detail: "Restorable app payload", + originalPath: app.bundlePath, + bytes: app.bytes, + deletedAt: Date(), + expiresAt: Date().addingTimeInterval(3600), + payload: .app( + AtlasAppRecoveryPayload( + app: app, + uninstallEvidence: AtlasAppUninstallEvidence( + bundlePath: app.bundlePath, + bundleBytes: app.bytes, + reviewOnlyGroups: [] + ) + ) + ), + restoreMappings: nil + ) + let state = AtlasWorkspaceState( + snapshot: AtlasWorkspaceSnapshot( + reclaimableSpaceBytes: 0, + findings: [], + apps: [app], + 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, + appsInventoryProvider: RestoredInventoryProvider() + ) + let model = AtlasAppModel(repository: repository, workerService: worker) + + await model.previewAppUninstall(appID: app.id) + XCTAssertNotNil(model.currentAppPreview) + XCTAssertEqual(model.currentPreviewedAppID, app.id) + + model.navigate(to: .history) + await model.restoreRecoveryItem(recoveryItem.id) + + XCTAssertEqual(model.selection, .history) + XCTAssertNil(model.currentAppPreview) + XCTAssertNil(model.currentPreviewedAppID) + XCTAssertEqual(model.snapshot.apps.first?.leftoverItems, 1) + XCTAssertEqual(model.latestAppsSummary, AtlasL10n.string("application.apps.loaded.one")) + XCTAssertFalse(model.snapshot.recoveryItems.contains(where: { $0.id == recoveryItem.id })) + } + func testRestoreExpiredRecoveryItemReloadsPersistedState() async throws { let baseDate = Date(timeIntervalSince1970: 1_710_000_000) let clock = TestClock(now: baseDate) @@ -496,6 +562,20 @@ private struct FakeInventoryProvider: AtlasAppInventoryProviding { } } +private struct RestoredInventoryProvider: AtlasAppInventoryProviding { + func collectInstalledApps() async throws -> [AppFootprint] { + [ + AppFootprint( + name: "Recovered App", + bundleIdentifier: "com.example.recovered", + bundlePath: "/Applications/Recovered App.app", + bytes: 2_048, + leftoverItems: 1 + ) + ] + } +} + private struct FailingSmartCleanProvider: AtlasSmartCleanScanProviding { func collectSmartCleanScan() async throws -> AtlasSmartCleanScanResult { throw NSError(domain: "AtlasAppModelTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "Fixture scan failed."]) diff --git a/Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift b/Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift index 42f7559..02728a5 100644 --- a/Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift +++ b/Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift @@ -42,6 +42,50 @@ public struct AtlasWorkspaceState: Codable, Hashable, Sendable { } } +public enum AtlasWorkspaceStateSchemaVersion { + public static let current = 1 +} + +public struct AtlasPersistedWorkspaceState: Codable, Hashable, Sendable { + public var schemaVersion: Int + public var savedAt: Date + public var snapshot: AtlasWorkspaceSnapshot + public var currentPlan: ActionPlan + public var settings: AtlasSettings + + public init( + schemaVersion: Int = AtlasWorkspaceStateSchemaVersion.current, + savedAt: Date = Date(), + snapshot: AtlasWorkspaceSnapshot, + currentPlan: ActionPlan, + settings: AtlasSettings + ) { + self.schemaVersion = schemaVersion + self.savedAt = savedAt + self.snapshot = snapshot + self.currentPlan = currentPlan + self.settings = settings + } + + public init( + schemaVersion: Int = AtlasWorkspaceStateSchemaVersion.current, + savedAt: Date = Date(), + state: AtlasWorkspaceState + ) { + self.init( + schemaVersion: schemaVersion, + savedAt: savedAt, + snapshot: state.snapshot, + currentPlan: state.currentPlan, + settings: state.settings + ) + } + + public var workspaceState: AtlasWorkspaceState { + AtlasWorkspaceState(snapshot: snapshot, currentPlan: currentPlan, settings: settings) + } +} + public enum AtlasScaffoldWorkspace { public static func state(language: AtlasLanguage = AtlasL10n.currentLanguage) -> AtlasWorkspaceState { let snapshot = AtlasWorkspaceSnapshot( diff --git a/Packages/AtlasDomain/Sources/AtlasDomain/AtlasDomain.swift b/Packages/AtlasDomain/Sources/AtlasDomain/AtlasDomain.swift index c78b4ae..a49ea51 100644 --- a/Packages/AtlasDomain/Sources/AtlasDomain/AtlasDomain.swift +++ b/Packages/AtlasDomain/Sources/AtlasDomain/AtlasDomain.swift @@ -355,14 +355,45 @@ public struct AtlasAppUninstallEvidence: Codable, Hashable, Sendable { } } +public enum AtlasRecoveryPayloadSchemaVersion { + public static let current = 1 +} + public struct AtlasAppRecoveryPayload: Codable, Hashable, Sendable { + public var schemaVersion: Int public var app: AppFootprint public var uninstallEvidence: AtlasAppUninstallEvidence - public init(app: AppFootprint, uninstallEvidence: AtlasAppUninstallEvidence) { + public init( + schemaVersion: Int = AtlasRecoveryPayloadSchemaVersion.current, + app: AppFootprint, + uninstallEvidence: AtlasAppUninstallEvidence + ) { + self.schemaVersion = schemaVersion self.app = app self.uninstallEvidence = uninstallEvidence } + + private enum CodingKeys: String, CodingKey { + case schemaVersion + case app + case uninstallEvidence + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) + ?? AtlasRecoveryPayloadSchemaVersion.current + self.app = try container.decode(AppFootprint.self, forKey: .app) + self.uninstallEvidence = try container.decode(AtlasAppUninstallEvidence.self, forKey: .uninstallEvidence) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(schemaVersion, forKey: .schemaVersion) + try container.encode(app, forKey: .app) + try container.encode(uninstallEvidence, forKey: .uninstallEvidence) + } } public enum RecoveryPayload: Codable, Hashable, Sendable { diff --git a/Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings b/Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings index 2229211..b5446ac 100644 --- a/Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings +++ b/Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings @@ -494,6 +494,16 @@ "history.detail.recovery.deleted" = "Deleted"; "history.detail.recovery.window" = "Retention window"; "history.detail.recovery.window.open" = "Still recoverable"; +"history.detail.recovery.evidence.title" = "Recorded Recovery Evidence"; +"history.detail.recovery.evidence.subtitle" = "Atlas keeps the app payload, review-only groups, and restore path records together for this recovery item."; +"history.detail.recovery.evidence.payload" = "Recorded payload"; +"history.detail.recovery.evidence.schema" = "Payload schema"; +"history.detail.recovery.evidence.reviewGroups" = "Review-only groups"; +"history.detail.recovery.evidence.reviewGroups.detail.one" = "%@ · 1 item"; +"history.detail.recovery.evidence.reviewGroups.detail.other" = "%1$@ · %2$d items"; +"history.detail.recovery.evidence.restorePaths" = "Restore path records"; +"history.detail.recovery.evidence.restorePaths.detail.one" = "1 original-path to Trash-path record was saved for this item."; +"history.detail.recovery.evidence.restorePaths.detail.other" = "%d original-path to Trash-path records were saved for this item."; "history.detail.recovery.reviewOnly.title" = "Review-Only Leftover Evidence"; "history.detail.recovery.reviewOnly.subtitle.one" = "1 related leftover item was recorded during uninstall."; "history.detail.recovery.reviewOnly.subtitle.other" = "%d related leftover items were recorded during uninstall."; diff --git a/Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings b/Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings index 7f594e5..4d17843 100644 --- a/Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings +++ b/Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings @@ -494,6 +494,16 @@ "history.detail.recovery.deleted" = "删除时间"; "history.detail.recovery.window" = "保留窗口"; "history.detail.recovery.window.open" = "仍可恢复"; +"history.detail.recovery.evidence.title" = "恢复证据记录"; +"history.detail.recovery.evidence.subtitle" = "Atlas 会把这条恢复项的应用载荷、仅供复核分组和恢复路径记录一起保留下来。"; +"history.detail.recovery.evidence.payload" = "已记录载荷"; +"history.detail.recovery.evidence.schema" = "载荷版本"; +"history.detail.recovery.evidence.reviewGroups" = "仅供复核分组"; +"history.detail.recovery.evidence.reviewGroups.detail.one" = "%@ · 1 项"; +"history.detail.recovery.evidence.reviewGroups.detail.other" = "%1$@ · %2$d 项"; +"history.detail.recovery.evidence.restorePaths" = "恢复路径记录"; +"history.detail.recovery.evidence.restorePaths.detail.one" = "这条恢复项记录了 1 组原路径与废纸篓路径映射。"; +"history.detail.recovery.evidence.restorePaths.detail.other" = "这条恢复项记录了 %d 组原路径与废纸篓路径映射。"; "history.detail.recovery.reviewOnly.title" = "仅供复核的残留证据"; "history.detail.recovery.reviewOnly.subtitle.one" = "这次卸载还记录了 1 个相关残留项目。"; "history.detail.recovery.reviewOnly.subtitle.other" = "这次卸载还记录了 %d 个相关残留项目。"; diff --git a/Packages/AtlasDomain/Tests/AtlasDomainTests/AtlasDomainTests.swift b/Packages/AtlasDomain/Tests/AtlasDomainTests/AtlasDomainTests.swift index 24365b0..3cb689f 100644 --- a/Packages/AtlasDomain/Tests/AtlasDomainTests/AtlasDomainTests.swift +++ b/Packages/AtlasDomain/Tests/AtlasDomainTests/AtlasDomainTests.swift @@ -65,6 +65,7 @@ final class AtlasDomainTests: XCTestCase { return XCTFail("Expected app payload") } + XCTAssertEqual(appPayload.schemaVersion, AtlasRecoveryPayloadSchemaVersion.current) XCTAssertEqual(appPayload.app.name, "Legacy App") XCTAssertEqual(appPayload.app.leftoverItems, 2) XCTAssertEqual(appPayload.uninstallEvidence.reviewOnlyGroupCount, 0) diff --git a/Packages/AtlasFeaturesHistory/Sources/AtlasFeaturesHistory/HistoryFeatureView.swift b/Packages/AtlasFeaturesHistory/Sources/AtlasFeaturesHistory/HistoryFeatureView.swift index 317b85a..0d56eda 100644 --- a/Packages/AtlasFeaturesHistory/Sources/AtlasFeaturesHistory/HistoryFeatureView.swift +++ b/Packages/AtlasFeaturesHistory/Sources/AtlasFeaturesHistory/HistoryFeatureView.swift @@ -981,6 +981,54 @@ private struct HistoryRecoveryDetailView: View { .strokeBorder(AtlasColor.border, lineWidth: 1) ) + if appRecoveryPayload != nil || item.hasPhysicalRestorePath { + AtlasInfoCard( + title: AtlasL10n.string("history.detail.recovery.evidence.title"), + subtitle: AtlasL10n.string("history.detail.recovery.evidence.subtitle"), + tone: item.hasPhysicalRestorePath ? .success : .neutral + ) { + VStack(alignment: .leading, spacing: AtlasSpacing.md) { + if let payload = appRecoveryPayload { + AtlasKeyValueRow( + title: AtlasL10n.string("history.detail.recovery.evidence.payload"), + value: payload.app.name, + detail: payload.app.bundleIdentifier + ) + AtlasKeyValueRow( + title: AtlasL10n.string("history.detail.recovery.evidence.schema"), + value: "\(payload.schemaVersion)", + detail: item.hasPhysicalRestorePath + ? AtlasL10n.string("history.detail.recovery.callout.available.fileBacked.title") + : AtlasL10n.string("history.detail.recovery.callout.available.stateOnly.title") + ) + + if payload.uninstallEvidence.reviewOnlyGroupCount > 0 { + AtlasKeyValueRow( + title: AtlasL10n.string("history.detail.recovery.evidence.reviewGroups"), + value: "\(payload.uninstallEvidence.reviewOnlyGroupCount)", + detail: appReviewOnlyGroupSummary(for: payload) + ) + } + } + + if let restoreMappings = item.restoreMappings, !restoreMappings.isEmpty { + AtlasMachineTextBlock( + title: AtlasL10n.string("history.detail.recovery.evidence.restorePaths"), + value: restoreMappings + .map { "\($0.originalPath)\n-> \($0.trashedPath)" } + .joined(separator: "\n\n"), + detail: AtlasL10n.string( + restoreMappings.count == 1 + ? "history.detail.recovery.evidence.restorePaths.detail.one" + : "history.detail.recovery.evidence.restorePaths.detail.other", + restoreMappings.count + ) + ) + } + } + } + } + if let payload = appRecoveryPayload, payload.uninstallEvidence.reviewOnlyItemCount > 0 { AtlasInfoCard( title: AtlasL10n.string("history.detail.recovery.reviewOnly.title"), @@ -1128,6 +1176,32 @@ private struct HistoryRecoveryDetailView: View { ? AtlasL10n.string("history.restore.hint.fileBacked") : AtlasL10n.string("history.restore.hint.stateOnly") } + + private func appReviewOnlyGroupSummary(for payload: AtlasAppRecoveryPayload) -> String { + payload.uninstallEvidence.reviewOnlyGroups.map { group in + let categoryLabel: String + switch group.category { + case .supportFiles: + categoryLabel = AtlasL10n.string("infrastructure.plan.uninstall.review.supportFiles") + case .caches: + categoryLabel = AtlasL10n.string("infrastructure.plan.uninstall.review.caches") + case .preferences: + categoryLabel = AtlasL10n.string("infrastructure.plan.uninstall.review.preferences") + case .logs: + categoryLabel = AtlasL10n.string("infrastructure.plan.uninstall.review.logs") + case .launchItems: + categoryLabel = AtlasL10n.string("infrastructure.plan.uninstall.review.launchItems") + } + return AtlasL10n.string( + group.items.count == 1 + ? "history.detail.recovery.evidence.reviewGroups.detail.one" + : "history.detail.recovery.evidence.reviewGroups.detail.other", + categoryLabel, + group.items.count + ) + } + .joined(separator: " • ") + } } private extension TaskRun { diff --git a/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift b/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift index 94ab328..a3b6660 100644 --- a/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift +++ b/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift @@ -189,10 +189,14 @@ public enum AtlasSmartCleanExecutionSupport { home + "/Library/Logs", home + "/Library/Suggestions", home + "/Library/Messages/Caches", + home + "/Library/Developer/CoreSimulator/Caches", + home + "/Library/Developer/CoreSimulator/tmp", home + "/Library/Developer/Xcode/DerivedData", home + "/Library/pnpm/store", home + "/.npm", home + "/.npm_cache", + home + "/.gradle/caches", + home + "/.ivy2/cache", home + "/.oh-my-zsh/cache", home + "/.cache", home + "/.pytest_cache", @@ -220,6 +224,7 @@ public enum AtlasSmartCleanExecutionSupport { home + "/.android/build-cache", home + "/.android/cache", home + "/.cache/swift-package-manager", + home + "/.swiftpm/cache", home + "/.expo/expo-go", home + "/.expo/android-apk-cache", home + "/.expo/ios-simulator-app-cache", @@ -319,9 +324,10 @@ public struct AtlasWorkspaceRepository: Sendable { if FileManager.default.fileExists(atPath: stateFileURL.path) { do { let data = try Data(contentsOf: stateFileURL) - let decoded = try decoder.decode(AtlasWorkspaceState.self, from: data) + let decodedResult = try decodePersistedState(from: data, using: decoder) + let decoded = decodedResult.state let normalized = normalizedState(decoded) - if normalized != decoded { + if decodedResult.usedLegacyShape || normalized != decoded { _ = try? saveState(normalized) } return normalized @@ -361,7 +367,12 @@ public struct AtlasWorkspaceRepository: Sendable { let data: Data do { - data = try encoder.encode(normalizedState) + data = try encoder.encode( + AtlasPersistedWorkspaceState( + savedAt: nowProvider(), + state: normalizedState + ) + ) } catch { throw AtlasWorkspaceRepositoryError.encodeFailed(error.localizedDescription) } @@ -403,6 +414,14 @@ public struct AtlasWorkspaceRepository: Sendable { return normalized } + private func decodePersistedState(from data: Data, using decoder: JSONDecoder) throws -> (state: AtlasWorkspaceState, usedLegacyShape: Bool) { + if let persisted = try? decoder.decode(AtlasPersistedWorkspaceState.self, from: data) { + return (persisted.workspaceState, false) + } + + return (try decoder.decode(AtlasWorkspaceState.self, from: data), true) + } + private static var defaultStateFileURL: URL { if let explicit = ProcessInfo.processInfo.environment["ATLAS_STATE_FILE"], !explicit.isEmpty { return URL(fileURLWithPath: explicit) diff --git a/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift b/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift index 16bb0b3..6221049 100644 --- a/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift +++ b/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift @@ -18,6 +18,35 @@ final class AtlasInfrastructureTests: XCTestCase { XCTAssertEqual(loaded.snapshot.apps.count, state.snapshot.apps.count) } + func testRepositoryPersistsVersionedWorkspaceEnvelope() throws { + let fileURL = temporaryStateFileURL() + let repository = AtlasWorkspaceRepository(stateFileURL: fileURL) + + XCTAssertNoThrow(try repository.saveState(AtlasScaffoldWorkspace.state())) + + let data = try Data(contentsOf: fileURL) + let persisted = try JSONDecoder().decode(AtlasPersistedWorkspaceState.self, from: data) + + XCTAssertEqual(persisted.schemaVersion, AtlasWorkspaceStateSchemaVersion.current) + XCTAssertFalse(persisted.snapshot.apps.isEmpty) + } + + func testRepositoryLoadsLegacyWorkspaceStateAndRewritesEnvelope() throws { + let fileURL = temporaryStateFileURL() + let legacyState = AtlasScaffoldWorkspace.state() + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(legacyState).write(to: fileURL) + + let repository = AtlasWorkspaceRepository(stateFileURL: fileURL) + let loaded = repository.loadState() + + XCTAssertEqual(loaded.snapshot.apps.count, legacyState.snapshot.apps.count) + + let migratedData = try Data(contentsOf: fileURL) + let persisted = try JSONDecoder().decode(AtlasPersistedWorkspaceState.self, from: migratedData) + XCTAssertEqual(persisted.schemaVersion, AtlasWorkspaceStateSchemaVersion.current) + XCTAssertEqual(persisted.snapshot.apps.count, legacyState.snapshot.apps.count) + } func testRepositorySaveStateThrowsForInvalidParentURL() { let repository = AtlasWorkspaceRepository( @@ -439,6 +468,40 @@ final class AtlasInfrastructureTests: XCTestCase { XCTAssertTrue(AtlasSmartCleanExecutionSupport.isFindingExecutionSupported(finding)) } + func testGradleCacheTargetIsSupportedExecutionTarget() { + let targetURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gradle/caches/modules-2/files-2.1/atlas-fixture.bin") + let finding = Finding( + id: UUID(), + title: "Gradle cache", + detail: targetURL.path, + bytes: 1, + risk: .safe, + category: "Developer tools", + targetPaths: [targetURL.path] + ) + + XCTAssertTrue(AtlasSmartCleanExecutionSupport.isSupportedExecutionTarget(targetURL)) + XCTAssertTrue(AtlasSmartCleanExecutionSupport.isFindingExecutionSupported(finding)) + } + + func testCoreSimulatorCacheTargetIsSupportedExecutionTarget() { + let targetURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Developer/CoreSimulator/Caches/atlas-fixture/device-cache.db") + let finding = Finding( + id: UUID(), + title: "CoreSimulator cache", + detail: targetURL.path, + bytes: 1, + risk: .safe, + category: "Developer tools", + targetPaths: [targetURL.path] + ) + + XCTAssertTrue(AtlasSmartCleanExecutionSupport.isSupportedExecutionTarget(targetURL)) + XCTAssertTrue(AtlasSmartCleanExecutionSupport.isFindingExecutionSupported(finding)) + } + func testContainerCacheTargetIsSupportedExecutionTarget() { let targetURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Containers/com.example.preview/Data/Library/Caches/cache.db") @@ -661,6 +724,39 @@ final class AtlasInfrastructureTests: XCTestCase { XCTAssertEqual(secondScan.snapshot.reclaimableSpaceBytes, 0) } + func testScanExecuteRescanRemovesExecutedGradleCacheTargetFromRealResults() async throws { + let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) + let home = FileManager.default.homeDirectoryForCurrentUser + let targetDirectory = home.appendingPathComponent(".gradle/caches/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: targetDirectory, withIntermediateDirectories: true) + let targetFile = targetDirectory.appendingPathComponent("modules.bin") + try Data("gradle-cache".utf8).write(to: targetFile) + + let provider = FileBackedSmartCleanProvider(targetFileURL: targetFile, title: "Gradle cache") + let worker = AtlasScaffoldWorkerService( + repository: repository, + smartCleanScanProvider: provider, + allowProviderFailureFallback: false, + allowStateOnlyCleanExecution: false + ) + + let firstScan = try await worker.submit(AtlasRequestEnvelope(command: .startScan(taskID: UUID()))) + XCTAssertEqual(firstScan.snapshot.findings.count, 1) + let planID = try XCTUnwrap(firstScan.previewPlan?.id) + + let execute = try await worker.submit(AtlasRequestEnvelope(command: .executePlan(planID: planID))) + if case let .accepted(task) = execute.response.response { + XCTAssertEqual(task.kind, .executePlan) + } else { + XCTFail("Expected accepted execute-plan response") + } + XCTAssertFalse(FileManager.default.fileExists(atPath: targetFile.path)) + + let secondScan = try await worker.submit(AtlasRequestEnvelope(command: .startScan(taskID: UUID()))) + XCTAssertEqual(secondScan.snapshot.findings.count, 0) + XCTAssertEqual(secondScan.snapshot.reclaimableSpaceBytes, 0) + } + func testScanExecuteRescanRemovesExecutedContainerCacheTargetFromRealResults() async throws { let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) let home = FileManager.default.homeDirectoryForCurrentUser diff --git a/Testing/AtlasTestingSupport/Sources/AtlasTestingSupport/AtlasTestingSupport.swift b/Testing/AtlasTestingSupport/Sources/AtlasTestingSupport/AtlasTestingSupport.swift index d1bd13e..10707a6 100644 --- a/Testing/AtlasTestingSupport/Sources/AtlasTestingSupport/AtlasTestingSupport.swift +++ b/Testing/AtlasTestingSupport/Sources/AtlasTestingSupport/AtlasTestingSupport.swift @@ -3,8 +3,59 @@ import AtlasDomain import AtlasProtocol import Foundation +public struct AtlasFixtureAppDescriptor: Hashable, Sendable { + public let scenario: String + public let appName: String + public let bundleIdentifier: String + public let hasLaunchAgent: Bool + public let expectedReviewOnlyCategories: [String] + + public init( + scenario: String, + appName: String, + bundleIdentifier: String, + hasLaunchAgent: Bool, + expectedReviewOnlyCategories: [String] + ) { + self.scenario = scenario + self.appName = appName + self.bundleIdentifier = bundleIdentifier + self.hasLaunchAgent = hasLaunchAgent + self.expectedReviewOnlyCategories = expectedReviewOnlyCategories + } +} + public enum AtlasTestingFixtures { public static let workspace = AtlasScaffoldWorkspace.snapshot() public static let request = AtlasRequestEnvelope(command: .inspectPermissions) public static let firstFinding = AtlasScaffoldFixtures.findings.first + public static let appEvidenceFixtures: [AtlasFixtureAppDescriptor] = [ + AtlasFixtureAppDescriptor( + scenario: "mainstream-gui", + appName: "Atlas Fixture Browser", + bundleIdentifier: "com.example.atlas.fixture.browser", + hasLaunchAgent: false, + expectedReviewOnlyCategories: ["support files", "caches", "preferences"] + ), + AtlasFixtureAppDescriptor( + scenario: "developer-heavy", + appName: "Atlas Fixture Dev", + bundleIdentifier: "com.example.atlas.fixture.dev", + hasLaunchAgent: true, + expectedReviewOnlyCategories: ["support files", "caches", "logs", "launch items"] + ), + AtlasFixtureAppDescriptor( + scenario: "sparse-leftovers", + appName: "Atlas Fixture Sparse", + bundleIdentifier: "com.example.atlas.fixture.sparse", + hasLaunchAgent: false, + expectedReviewOnlyCategories: ["saved state"] + ), + ] + + public static let smartCleanSafeRoots: [String] = [ + "~/Library/Developer/CoreSimulator/Caches", + "~/.gradle/caches", + "~/.ivy2/cache", + ] } diff --git a/scripts/atlas/apps-evidence-acceptance.sh b/scripts/atlas/apps-evidence-acceptance.sh new file mode 100755 index 0000000..fbf6e28 --- /dev/null +++ b/scripts/atlas/apps-evidence-acceptance.sh @@ -0,0 +1,50 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FIXTURE_SCRIPT="$SCRIPT_DIR/apps-manual-fixtures.sh" + +print_guide() { + cat <<'EOF' +Apps Evidence Acceptance Guide + +1. Run Atlas and open the Apps screen. +2. Verify these fixture apps appear: + - Atlas Fixture Browser + - Atlas Fixture Dev + - Atlas Fixture Sparse +3. For each fixture app, build the uninstall plan and confirm: + - preview categories match the expected review-only evidence + - recoverable bundle removal is separated from review-only evidence + - observed paths are listed for review-only groups +4. Execute uninstall for Atlas Fixture Dev and confirm: + - completion summary mentions real removal and review-only categories + - History shows the uninstall with review-only evidence still informational +5. Restore the Atlas Fixture Dev recovery item and confirm: + - the app reappears in Apps after the restore-driven inventory refresh + - stale uninstall preview is cleared + - History shows restore-path evidence when supported +6. Re-run Apps refresh and verify leftover counts remain consistent with current disk state. +7. Clean up fixtures when done. +EOF +} + +case "${1:-guide}" in + setup) + "$FIXTURE_SCRIPT" create + print_guide + ;; + status) + "$FIXTURE_SCRIPT" status + ;; + cleanup) + "$FIXTURE_SCRIPT" cleanup + ;; + guide) + print_guide + ;; + *) + echo "Usage: $0 [setup|status|cleanup|guide]" >&2 + exit 1 + ;; +esac diff --git a/scripts/atlas/apps-manual-fixtures.sh b/scripts/atlas/apps-manual-fixtures.sh new file mode 100755 index 0000000..4b1ac5c --- /dev/null +++ b/scripts/atlas/apps-manual-fixtures.sh @@ -0,0 +1,178 @@ +#!/bin/bash +set -euo pipefail + +APPS_ROOT="$HOME/Applications" +SUPPORT_ROOT="$HOME/Library/Application Support" +CACHE_ROOT="$HOME/Library/Caches" +PREFERENCES_ROOT="$HOME/Library/Preferences" +LOG_ROOT="$HOME/Library/Logs" +STATE_ROOT="$HOME/Library/Saved Application State" +LAUNCH_AGENTS_ROOT="$HOME/Library/LaunchAgents" + +FIXTURES=( + "Atlas Fixture Browser|com.example.atlas.fixture.browser|support,caches,preferences" + "Atlas Fixture Dev|com.example.atlas.fixture.dev|support,caches,logs,launch" + "Atlas Fixture Sparse|com.example.atlas.fixture.sparse|saved-state" +) + +create_blob() { + local path="$1" + local size_kb="$2" + mkdir -p "$(dirname "$path")" + if command -v mkfile > /dev/null 2>&1; then + mkfile "${size_kb}k" "$path" + else + dd if=/dev/zero of="$path" bs=1024 count="$size_kb" status=none + fi +} + +write_info_plist() { + local plist_path="$1" + local bundle_id="$2" + local app_name="$3" + cat > "$plist_path" < + + + + CFBundleIdentifier + ${bundle_id} + CFBundleName + ${app_name} + CFBundlePackageType + APPL + CFBundleExecutable + fixture + + +EOF +} + +create_app_bundle() { + local app_name="$1" + local bundle_id="$2" + local bundle_path="$APPS_ROOT/${app_name}.app" + local contents_path="$bundle_path/Contents" + local executable_path="$contents_path/MacOS/fixture" + local plist_path="$contents_path/Info.plist" + + mkdir -p "$(dirname "$executable_path")" + printf '#!/bin/sh\nexit 0\n' > "$executable_path" + chmod +x "$executable_path" + write_info_plist "$plist_path" "$bundle_id" "$app_name" + create_blob "$bundle_path/Contents/Resources/fixture.dat" 128 +} + +create_leftovers() { + local app_name="$1" + local bundle_id="$2" + local categories="$3" + + IFS=',' read -r -a parts <<< "$categories" + for category in "${parts[@]}"; do + case "$category" in + support) + create_blob "$SUPPORT_ROOT/$bundle_id/settings.json" 32 + ;; + caches) + create_blob "$CACHE_ROOT/$bundle_id/cache.bin" 48 + ;; + preferences) + create_blob "$PREFERENCES_ROOT/$bundle_id.plist" 4 + ;; + logs) + create_blob "$LOG_ROOT/$bundle_id/runtime.log" 24 + ;; + launch) + mkdir -p "$LAUNCH_AGENTS_ROOT" + cat > "$LAUNCH_AGENTS_ROOT/$bundle_id.plist" < + + + + Label + ${bundle_id} + ProgramArguments + + /usr/bin/true + + + +EOF + ;; + saved-state) + create_blob "$STATE_ROOT/$bundle_id.savedState/data.data" 8 + ;; + esac + done +} + +cleanup_fixture() { + local app_name="$1" + local bundle_id="$2" + + rm -rf \ + "$APPS_ROOT/${app_name}.app" \ + "$SUPPORT_ROOT/$bundle_id" \ + "$CACHE_ROOT/$bundle_id" \ + "$PREFERENCES_ROOT/$bundle_id.plist" \ + "$LOG_ROOT/$bundle_id" \ + "$STATE_ROOT/$bundle_id.savedState" \ + "$LAUNCH_AGENTS_ROOT/$bundle_id.plist" +} + +print_status() { + local found=false + for fixture in "${FIXTURES[@]}"; do + IFS='|' read -r app_name bundle_id categories <<< "$fixture" + local bundle_path="$APPS_ROOT/${app_name}.app" + if [[ -d "$bundle_path" ]]; then + found=true + echo "Fixture: $app_name ($bundle_id)" + du -sh "$bundle_path" "$SUPPORT_ROOT/$bundle_id" "$CACHE_ROOT/$bundle_id" \ + "$PREFERENCES_ROOT/$bundle_id.plist" "$LOG_ROOT/$bundle_id" \ + "$STATE_ROOT/$bundle_id.savedState" "$LAUNCH_AGENTS_ROOT/$bundle_id.plist" 2> /dev/null || true + echo "Expected review-only categories: $categories" + echo "" + fi + done + if [[ "$found" == false ]]; then + echo "No Apps manual fixtures found." + fi +} + +create_fixtures() { + for fixture in "${FIXTURES[@]}"; do + IFS='|' read -r app_name bundle_id categories <<< "$fixture" + cleanup_fixture "$app_name" "$bundle_id" + create_app_bundle "$app_name" "$bundle_id" + create_leftovers "$app_name" "$bundle_id" "$categories" + done + + echo "Created Apps manual fixtures:" + print_status +} + +cleanup_fixtures() { + for fixture in "${FIXTURES[@]}"; do + IFS='|' read -r app_name bundle_id _ <<< "$fixture" + cleanup_fixture "$app_name" "$bundle_id" + done + echo "Removed Apps manual fixtures." +} + +case "${1:-create}" in + create) + create_fixtures + ;; + status) + print_status + ;; + cleanup) + cleanup_fixtures + ;; + *) + echo "Usage: $0 [create|status|cleanup]" >&2 + exit 1 + ;; +esac diff --git a/scripts/atlas/full-acceptance.sh b/scripts/atlas/full-acceptance.sh index dfd9ea4..4e62965 100755 --- a/scripts/atlas/full-acceptance.sh +++ b/scripts/atlas/full-acceptance.sh @@ -34,35 +34,40 @@ run_ui_acceptance() { return 1 } -echo "[1/10] Shared package tests" +echo "[1/11] Shared package tests" swift test --package-path Packages -echo "[2/10] App package tests" +echo "[2/11] App package tests" swift test --package-path Apps -echo "[3/10] Worker and helper builds" +echo "[3/11] Worker and helper builds" swift build --package-path XPC swift test --package-path Helpers swift build --package-path Testing -echo "[4/10] Native packaging" +echo "[4/11] Fixture automation scripts" +bash -n ./scripts/atlas/smart-clean-manual-fixtures.sh +bash -n ./scripts/atlas/apps-manual-fixtures.sh +bash -n ./scripts/atlas/apps-evidence-acceptance.sh + +echo "[5/11] Native packaging" ./scripts/atlas/package-native.sh -echo "[5/10] Bundle structure verification" +echo "[6/11] Bundle structure verification" ./scripts/atlas/verify-bundle-contents.sh -echo "[6/10] DMG install verification" +echo "[7/11] DMG install verification" KEEP_INSTALLED_APP=1 ./scripts/atlas/verify-dmg-install.sh -echo "[7/10] Installed app launch smoke" +echo "[8/11] Installed app launch smoke" ./scripts/atlas/verify-app-launch.sh -echo "[8/10] Native UI automation" +echo "[9/11] Native UI automation" run_ui_acceptance -echo "[9/10] Signing preflight" +echo "[10/11] Signing preflight" ./scripts/atlas/signing-preflight.sh || true -echo "[10/10] Acceptance summary" +echo "[11/11] Acceptance summary" echo "Artifacts available in dist/native" ls -lah dist/native diff --git a/scripts/atlas/smart-clean-manual-fixtures.sh b/scripts/atlas/smart-clean-manual-fixtures.sh index d61d820..a835f80 100755 --- a/scripts/atlas/smart-clean-manual-fixtures.sh +++ b/scripts/atlas/smart-clean-manual-fixtures.sh @@ -4,8 +4,11 @@ set -euo pipefail CACHE_ROOT="$HOME/Library/Caches/AtlasExecutionFixturesCache" LOG_ROOT="$HOME/Library/Logs/AtlasExecutionFixturesLogs" DERIVED_ROOT="$HOME/Library/Developer/Xcode/DerivedData/AtlasExecutionFixturesDerivedData" +CORESIM_ROOT="$HOME/Library/Developer/CoreSimulator/Caches/AtlasExecutionFixturesCoreSimulator" PYCACHE_ROOT="$HOME/Library/Caches/AtlasExecutionFixturesPycache" PNPM_ROOT="$HOME/Library/pnpm/store/v3/files/AtlasExecutionFixturesPnpm" +GRADLE_ROOT="$HOME/.gradle/caches/AtlasExecutionFixturesGradle" +IVY_ROOT="$HOME/.ivy2/cache/AtlasExecutionFixturesIvy" create_blob() { local path="$1" @@ -20,7 +23,7 @@ create_blob() { print_status() { local existing=false - for path in "$CACHE_ROOT" "$LOG_ROOT" "$DERIVED_ROOT" "$PYCACHE_ROOT" "$PNPM_ROOT"; do + for path in "$CACHE_ROOT" "$LOG_ROOT" "$DERIVED_ROOT" "$CORESIM_ROOT" "$PYCACHE_ROOT" "$PNPM_ROOT" "$GRADLE_ROOT" "$IVY_ROOT"; do if [[ -e "$path" ]]; then existing=true du -sh "$path" @@ -39,9 +42,12 @@ create_fixtures() { create_blob "$CACHE_ROOT/cache-b.bin" 12 create_blob "$LOG_ROOT/app.log" 8 create_blob "$DERIVED_ROOT/Build/Logs/build-products.bin" 16 + create_blob "$CORESIM_ROOT/device-cache.db" 6 mkdir -p "$PYCACHE_ROOT/project/__pycache__" create_blob "$PYCACHE_ROOT/project/__pycache__/sample.cpython-312.pyc" 4 create_blob "$PNPM_ROOT/package.tgz" 10 + create_blob "$GRADLE_ROOT/modules.bin" 10 + create_blob "$IVY_ROOT/artifact.bin" 6 echo "Created Smart Clean manual fixtures:" print_status @@ -50,7 +56,7 @@ create_fixtures() { } cleanup_fixtures() { - rm -rf "$CACHE_ROOT" "$LOG_ROOT" "$DERIVED_ROOT" "$PYCACHE_ROOT" "$PNPM_ROOT" + rm -rf "$CACHE_ROOT" "$LOG_ROOT" "$DERIVED_ROOT" "$CORESIM_ROOT" "$PYCACHE_ROOT" "$PNPM_ROOT" "$GRADLE_ROOT" "$IVY_ROOT" echo "Removed Smart Clean manual fixtures." }