ralph-loop[epic-a-to-d-mainline]: iteration 2
This commit is contained in:
@@ -391,29 +391,7 @@ final class AtlasAppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func refreshApps() async {
|
func refreshApps() async {
|
||||||
guard !isAppActionRunning else {
|
await reloadAppsInventory(navigateToApps: true, resetPreview: true)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func previewAppUninstall(appID: UUID) async {
|
func previewAppUninstall(appID: UUID) async {
|
||||||
@@ -473,6 +451,8 @@ final class AtlasAppModel: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let restoredItem = snapshot.recoveryItems.first(where: { $0.id == itemID })
|
||||||
|
let shouldRefreshAppsAfterRestore = restoredItem?.isAppPayload == true
|
||||||
restoringRecoveryItemID = itemID
|
restoringRecoveryItemID = itemID
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -481,8 +461,21 @@ final class AtlasAppModel: ObservableObject {
|
|||||||
snapshot = output.snapshot
|
snapshot = output.snapshot
|
||||||
latestScanSummary = output.summary
|
latestScanSummary = output.summary
|
||||||
smartCleanExecutionIssue = nil
|
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 {
|
} catch {
|
||||||
let persistedState = repository.loadState()
|
let persistedState = repository.loadState()
|
||||||
withAnimation(.snappy(duration: 0.24)) {
|
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<Element>(
|
private func filter<Element>(
|
||||||
_ elements: [Element],
|
_ elements: [Element],
|
||||||
route: AtlasRoute,
|
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 {
|
private extension AtlasAppModel {
|
||||||
func resolvedTargetPaths(for item: ActionItem) -> [String] {
|
func resolvedTargetPaths(for item: ActionItem) -> [String] {
|
||||||
if let targetPaths = item.targetPaths, !targetPaths.isEmpty {
|
if let targetPaths = item.targetPaths, !targetPaths.isEmpty {
|
||||||
|
|||||||
@@ -284,6 +284,72 @@ final class AtlasAppModelTests: XCTestCase {
|
|||||||
XCTAssertNil(model.smartCleanExecutionIssue)
|
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 {
|
func testRestoreExpiredRecoveryItemReloadsPersistedState() async throws {
|
||||||
let baseDate = Date(timeIntervalSince1970: 1_710_000_000)
|
let baseDate = Date(timeIntervalSince1970: 1_710_000_000)
|
||||||
let clock = TestClock(now: baseDate)
|
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 {
|
private struct FailingSmartCleanProvider: AtlasSmartCleanScanProviding {
|
||||||
func collectSmartCleanScan() async throws -> AtlasSmartCleanScanResult {
|
func collectSmartCleanScan() async throws -> AtlasSmartCleanScanResult {
|
||||||
throw NSError(domain: "AtlasAppModelTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "Fixture scan failed."])
|
throw NSError(domain: "AtlasAppModelTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "Fixture scan failed."])
|
||||||
|
|||||||
@@ -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 enum AtlasScaffoldWorkspace {
|
||||||
public static func state(language: AtlasLanguage = AtlasL10n.currentLanguage) -> AtlasWorkspaceState {
|
public static func state(language: AtlasLanguage = AtlasL10n.currentLanguage) -> AtlasWorkspaceState {
|
||||||
let snapshot = AtlasWorkspaceSnapshot(
|
let snapshot = AtlasWorkspaceSnapshot(
|
||||||
|
|||||||
@@ -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 struct AtlasAppRecoveryPayload: Codable, Hashable, Sendable {
|
||||||
|
public var schemaVersion: Int
|
||||||
public var app: AppFootprint
|
public var app: AppFootprint
|
||||||
public var uninstallEvidence: AtlasAppUninstallEvidence
|
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.app = app
|
||||||
self.uninstallEvidence = uninstallEvidence
|
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 {
|
public enum RecoveryPayload: Codable, Hashable, Sendable {
|
||||||
|
|||||||
@@ -494,6 +494,16 @@
|
|||||||
"history.detail.recovery.deleted" = "Deleted";
|
"history.detail.recovery.deleted" = "Deleted";
|
||||||
"history.detail.recovery.window" = "Retention window";
|
"history.detail.recovery.window" = "Retention window";
|
||||||
"history.detail.recovery.window.open" = "Still recoverable";
|
"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.title" = "Review-Only Leftover Evidence";
|
||||||
"history.detail.recovery.reviewOnly.subtitle.one" = "1 related leftover item was recorded during uninstall.";
|
"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.";
|
"history.detail.recovery.reviewOnly.subtitle.other" = "%d related leftover items were recorded during uninstall.";
|
||||||
|
|||||||
@@ -494,6 +494,16 @@
|
|||||||
"history.detail.recovery.deleted" = "删除时间";
|
"history.detail.recovery.deleted" = "删除时间";
|
||||||
"history.detail.recovery.window" = "保留窗口";
|
"history.detail.recovery.window" = "保留窗口";
|
||||||
"history.detail.recovery.window.open" = "仍可恢复";
|
"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.title" = "仅供复核的残留证据";
|
||||||
"history.detail.recovery.reviewOnly.subtitle.one" = "这次卸载还记录了 1 个相关残留项目。";
|
"history.detail.recovery.reviewOnly.subtitle.one" = "这次卸载还记录了 1 个相关残留项目。";
|
||||||
"history.detail.recovery.reviewOnly.subtitle.other" = "这次卸载还记录了 %d 个相关残留项目。";
|
"history.detail.recovery.reviewOnly.subtitle.other" = "这次卸载还记录了 %d 个相关残留项目。";
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ final class AtlasDomainTests: XCTestCase {
|
|||||||
return XCTFail("Expected app payload")
|
return XCTFail("Expected app payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(appPayload.schemaVersion, AtlasRecoveryPayloadSchemaVersion.current)
|
||||||
XCTAssertEqual(appPayload.app.name, "Legacy App")
|
XCTAssertEqual(appPayload.app.name, "Legacy App")
|
||||||
XCTAssertEqual(appPayload.app.leftoverItems, 2)
|
XCTAssertEqual(appPayload.app.leftoverItems, 2)
|
||||||
XCTAssertEqual(appPayload.uninstallEvidence.reviewOnlyGroupCount, 0)
|
XCTAssertEqual(appPayload.uninstallEvidence.reviewOnlyGroupCount, 0)
|
||||||
|
|||||||
@@ -981,6 +981,54 @@ private struct HistoryRecoveryDetailView: View {
|
|||||||
.strokeBorder(AtlasColor.border, lineWidth: 1)
|
.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 {
|
if let payload = appRecoveryPayload, payload.uninstallEvidence.reviewOnlyItemCount > 0 {
|
||||||
AtlasInfoCard(
|
AtlasInfoCard(
|
||||||
title: AtlasL10n.string("history.detail.recovery.reviewOnly.title"),
|
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.fileBacked")
|
||||||
: AtlasL10n.string("history.restore.hint.stateOnly")
|
: 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 {
|
private extension TaskRun {
|
||||||
|
|||||||
@@ -189,10 +189,14 @@ public enum AtlasSmartCleanExecutionSupport {
|
|||||||
home + "/Library/Logs",
|
home + "/Library/Logs",
|
||||||
home + "/Library/Suggestions",
|
home + "/Library/Suggestions",
|
||||||
home + "/Library/Messages/Caches",
|
home + "/Library/Messages/Caches",
|
||||||
|
home + "/Library/Developer/CoreSimulator/Caches",
|
||||||
|
home + "/Library/Developer/CoreSimulator/tmp",
|
||||||
home + "/Library/Developer/Xcode/DerivedData",
|
home + "/Library/Developer/Xcode/DerivedData",
|
||||||
home + "/Library/pnpm/store",
|
home + "/Library/pnpm/store",
|
||||||
home + "/.npm",
|
home + "/.npm",
|
||||||
home + "/.npm_cache",
|
home + "/.npm_cache",
|
||||||
|
home + "/.gradle/caches",
|
||||||
|
home + "/.ivy2/cache",
|
||||||
home + "/.oh-my-zsh/cache",
|
home + "/.oh-my-zsh/cache",
|
||||||
home + "/.cache",
|
home + "/.cache",
|
||||||
home + "/.pytest_cache",
|
home + "/.pytest_cache",
|
||||||
@@ -220,6 +224,7 @@ public enum AtlasSmartCleanExecutionSupport {
|
|||||||
home + "/.android/build-cache",
|
home + "/.android/build-cache",
|
||||||
home + "/.android/cache",
|
home + "/.android/cache",
|
||||||
home + "/.cache/swift-package-manager",
|
home + "/.cache/swift-package-manager",
|
||||||
|
home + "/.swiftpm/cache",
|
||||||
home + "/.expo/expo-go",
|
home + "/.expo/expo-go",
|
||||||
home + "/.expo/android-apk-cache",
|
home + "/.expo/android-apk-cache",
|
||||||
home + "/.expo/ios-simulator-app-cache",
|
home + "/.expo/ios-simulator-app-cache",
|
||||||
@@ -319,9 +324,10 @@ 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)
|
||||||
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)
|
let normalized = normalizedState(decoded)
|
||||||
if normalized != decoded {
|
if decodedResult.usedLegacyShape || normalized != decoded {
|
||||||
_ = try? saveState(normalized)
|
_ = try? saveState(normalized)
|
||||||
}
|
}
|
||||||
return normalized
|
return normalized
|
||||||
@@ -361,7 +367,12 @@ public struct AtlasWorkspaceRepository: Sendable {
|
|||||||
|
|
||||||
let data: Data
|
let data: Data
|
||||||
do {
|
do {
|
||||||
data = try encoder.encode(normalizedState)
|
data = try encoder.encode(
|
||||||
|
AtlasPersistedWorkspaceState(
|
||||||
|
savedAt: nowProvider(),
|
||||||
|
state: normalizedState
|
||||||
|
)
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
throw AtlasWorkspaceRepositoryError.encodeFailed(error.localizedDescription)
|
throw AtlasWorkspaceRepositoryError.encodeFailed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
@@ -403,6 +414,14 @@ public struct AtlasWorkspaceRepository: Sendable {
|
|||||||
return normalized
|
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 {
|
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)
|
||||||
|
|||||||
@@ -18,6 +18,35 @@ final class AtlasInfrastructureTests: XCTestCase {
|
|||||||
XCTAssertEqual(loaded.snapshot.apps.count, state.snapshot.apps.count)
|
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() {
|
func testRepositorySaveStateThrowsForInvalidParentURL() {
|
||||||
let repository = AtlasWorkspaceRepository(
|
let repository = AtlasWorkspaceRepository(
|
||||||
@@ -439,6 +468,40 @@ final class AtlasInfrastructureTests: XCTestCase {
|
|||||||
XCTAssertTrue(AtlasSmartCleanExecutionSupport.isFindingExecutionSupported(finding))
|
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() {
|
func testContainerCacheTargetIsSupportedExecutionTarget() {
|
||||||
let targetURL = FileManager.default.homeDirectoryForCurrentUser
|
let targetURL = FileManager.default.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/Containers/com.example.preview/Data/Library/Caches/cache.db")
|
.appendingPathComponent("Library/Containers/com.example.preview/Data/Library/Caches/cache.db")
|
||||||
@@ -661,6 +724,39 @@ final class AtlasInfrastructureTests: XCTestCase {
|
|||||||
XCTAssertEqual(secondScan.snapshot.reclaimableSpaceBytes, 0)
|
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 {
|
func testScanExecuteRescanRemovesExecutedContainerCacheTargetFromRealResults() async throws {
|
||||||
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
|
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
|||||||
@@ -3,8 +3,59 @@ import AtlasDomain
|
|||||||
import AtlasProtocol
|
import AtlasProtocol
|
||||||
import Foundation
|
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 enum AtlasTestingFixtures {
|
||||||
public static let workspace = AtlasScaffoldWorkspace.snapshot()
|
public static let workspace = AtlasScaffoldWorkspace.snapshot()
|
||||||
public static let request = AtlasRequestEnvelope(command: .inspectPermissions)
|
public static let request = AtlasRequestEnvelope(command: .inspectPermissions)
|
||||||
public static let firstFinding = AtlasScaffoldFixtures.findings.first
|
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",
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
50
scripts/atlas/apps-evidence-acceptance.sh
Executable file
50
scripts/atlas/apps-evidence-acceptance.sh
Executable file
@@ -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
|
||||||
178
scripts/atlas/apps-manual-fixtures.sh
Executable file
178
scripts/atlas/apps-manual-fixtures.sh
Executable file
@@ -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" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>${bundle_id}</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>${app_name}</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>fixture</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
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" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>${bundle_id}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/bin/true</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
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
|
||||||
@@ -34,35 +34,40 @@ run_ui_acceptance() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "[1/10] Shared package tests"
|
echo "[1/11] Shared package tests"
|
||||||
swift test --package-path Packages
|
swift test --package-path Packages
|
||||||
|
|
||||||
echo "[2/10] App package tests"
|
echo "[2/11] App package tests"
|
||||||
swift test --package-path Apps
|
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 build --package-path XPC
|
||||||
swift test --package-path Helpers
|
swift test --package-path Helpers
|
||||||
swift build --package-path Testing
|
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
|
./scripts/atlas/package-native.sh
|
||||||
|
|
||||||
echo "[5/10] Bundle structure verification"
|
echo "[6/11] Bundle structure verification"
|
||||||
./scripts/atlas/verify-bundle-contents.sh
|
./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
|
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
|
./scripts/atlas/verify-app-launch.sh
|
||||||
|
|
||||||
echo "[8/10] Native UI automation"
|
echo "[9/11] Native UI automation"
|
||||||
run_ui_acceptance
|
run_ui_acceptance
|
||||||
|
|
||||||
echo "[9/10] Signing preflight"
|
echo "[10/11] Signing preflight"
|
||||||
./scripts/atlas/signing-preflight.sh || true
|
./scripts/atlas/signing-preflight.sh || true
|
||||||
|
|
||||||
echo "[10/10] Acceptance summary"
|
echo "[11/11] Acceptance summary"
|
||||||
echo "Artifacts available in dist/native"
|
echo "Artifacts available in dist/native"
|
||||||
ls -lah dist/native
|
ls -lah dist/native
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ set -euo pipefail
|
|||||||
CACHE_ROOT="$HOME/Library/Caches/AtlasExecutionFixturesCache"
|
CACHE_ROOT="$HOME/Library/Caches/AtlasExecutionFixturesCache"
|
||||||
LOG_ROOT="$HOME/Library/Logs/AtlasExecutionFixturesLogs"
|
LOG_ROOT="$HOME/Library/Logs/AtlasExecutionFixturesLogs"
|
||||||
DERIVED_ROOT="$HOME/Library/Developer/Xcode/DerivedData/AtlasExecutionFixturesDerivedData"
|
DERIVED_ROOT="$HOME/Library/Developer/Xcode/DerivedData/AtlasExecutionFixturesDerivedData"
|
||||||
|
CORESIM_ROOT="$HOME/Library/Developer/CoreSimulator/Caches/AtlasExecutionFixturesCoreSimulator"
|
||||||
PYCACHE_ROOT="$HOME/Library/Caches/AtlasExecutionFixturesPycache"
|
PYCACHE_ROOT="$HOME/Library/Caches/AtlasExecutionFixturesPycache"
|
||||||
PNPM_ROOT="$HOME/Library/pnpm/store/v3/files/AtlasExecutionFixturesPnpm"
|
PNPM_ROOT="$HOME/Library/pnpm/store/v3/files/AtlasExecutionFixturesPnpm"
|
||||||
|
GRADLE_ROOT="$HOME/.gradle/caches/AtlasExecutionFixturesGradle"
|
||||||
|
IVY_ROOT="$HOME/.ivy2/cache/AtlasExecutionFixturesIvy"
|
||||||
|
|
||||||
create_blob() {
|
create_blob() {
|
||||||
local path="$1"
|
local path="$1"
|
||||||
@@ -20,7 +23,7 @@ create_blob() {
|
|||||||
|
|
||||||
print_status() {
|
print_status() {
|
||||||
local existing=false
|
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
|
if [[ -e "$path" ]]; then
|
||||||
existing=true
|
existing=true
|
||||||
du -sh "$path"
|
du -sh "$path"
|
||||||
@@ -39,9 +42,12 @@ create_fixtures() {
|
|||||||
create_blob "$CACHE_ROOT/cache-b.bin" 12
|
create_blob "$CACHE_ROOT/cache-b.bin" 12
|
||||||
create_blob "$LOG_ROOT/app.log" 8
|
create_blob "$LOG_ROOT/app.log" 8
|
||||||
create_blob "$DERIVED_ROOT/Build/Logs/build-products.bin" 16
|
create_blob "$DERIVED_ROOT/Build/Logs/build-products.bin" 16
|
||||||
|
create_blob "$CORESIM_ROOT/device-cache.db" 6
|
||||||
mkdir -p "$PYCACHE_ROOT/project/__pycache__"
|
mkdir -p "$PYCACHE_ROOT/project/__pycache__"
|
||||||
create_blob "$PYCACHE_ROOT/project/__pycache__/sample.cpython-312.pyc" 4
|
create_blob "$PYCACHE_ROOT/project/__pycache__/sample.cpython-312.pyc" 4
|
||||||
create_blob "$PNPM_ROOT/package.tgz" 10
|
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:"
|
echo "Created Smart Clean manual fixtures:"
|
||||||
print_status
|
print_status
|
||||||
@@ -50,7 +56,7 @@ create_fixtures() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cleanup_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."
|
echo "Removed Smart Clean manual fixtures."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user