Compare commits
1 Commits
V1.0.2
...
vk/ad15-ep
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5dd042a85 |
120
.github/workflows/release.yml
vendored
120
.github/workflows/release.yml
vendored
@@ -54,102 +54,9 @@ jobs:
|
||||
path: bin/*-darwin-*
|
||||
retention-days: 1
|
||||
|
||||
native:
|
||||
name: Build Native Release
|
||||
runs-on: macos-latest
|
||||
outputs:
|
||||
packaging_mode: ${{ steps.mode.outputs.packaging_mode }}
|
||||
prerelease: ${{ steps.mode.outputs.prerelease }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4
|
||||
|
||||
- name: Derive native release version
|
||||
run: |
|
||||
echo "ATLAS_VERSION=${GITHUB_REF_NAME#V}" >> "$GITHUB_ENV"
|
||||
echo "ATLAS_BUILD_NUMBER=${GITHUB_RUN_NUMBER}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Select native packaging mode
|
||||
id: mode
|
||||
env:
|
||||
ATLAS_RELEASE_APP_CERT_P12_BASE64: ${{ secrets.ATLAS_RELEASE_APP_CERT_P12_BASE64 }}
|
||||
ATLAS_RELEASE_APP_CERT_P12_PASSWORD: ${{ secrets.ATLAS_RELEASE_APP_CERT_P12_PASSWORD }}
|
||||
ATLAS_RELEASE_INSTALLER_CERT_P12_BASE64: ${{ secrets.ATLAS_RELEASE_INSTALLER_CERT_P12_BASE64 }}
|
||||
ATLAS_RELEASE_INSTALLER_CERT_P12_PASSWORD: ${{ secrets.ATLAS_RELEASE_INSTALLER_CERT_P12_PASSWORD }}
|
||||
ATLAS_NOTARY_KEY_ID: ${{ secrets.ATLAS_NOTARY_KEY_ID }}
|
||||
ATLAS_NOTARY_ISSUER_ID: ${{ secrets.ATLAS_NOTARY_ISSUER_ID }}
|
||||
ATLAS_NOTARY_API_KEY_BASE64: ${{ secrets.ATLAS_NOTARY_API_KEY_BASE64 }}
|
||||
run: |
|
||||
required_vars=(
|
||||
ATLAS_RELEASE_APP_CERT_P12_BASE64
|
||||
ATLAS_RELEASE_APP_CERT_P12_PASSWORD
|
||||
ATLAS_RELEASE_INSTALLER_CERT_P12_BASE64
|
||||
ATLAS_RELEASE_INSTALLER_CERT_P12_PASSWORD
|
||||
ATLAS_NOTARY_KEY_ID
|
||||
ATLAS_NOTARY_API_KEY_BASE64
|
||||
)
|
||||
|
||||
missing_vars=()
|
||||
for name in "${required_vars[@]}"; do
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
missing_vars+=("$name")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#missing_vars[@]} -eq 0 ]]; then
|
||||
echo "packaging_mode=developer-id" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
echo "ATLAS_RELEASE_SIGNING_MODE=developer-id" >> "$GITHUB_ENV"
|
||||
echo "Using Developer ID release packaging"
|
||||
else
|
||||
echo "packaging_mode=development" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
echo "ATLAS_RELEASE_SIGNING_MODE=development" >> "$GITHUB_ENV"
|
||||
printf 'Falling back to development packaging; missing secrets: %s\n' "${missing_vars[*]}"
|
||||
fi
|
||||
|
||||
- name: Configure release signing
|
||||
if: steps.mode.outputs.packaging_mode == 'developer-id'
|
||||
env:
|
||||
ATLAS_RELEASE_APP_CERT_P12_BASE64: ${{ secrets.ATLAS_RELEASE_APP_CERT_P12_BASE64 }}
|
||||
ATLAS_RELEASE_APP_CERT_P12_PASSWORD: ${{ secrets.ATLAS_RELEASE_APP_CERT_P12_PASSWORD }}
|
||||
ATLAS_RELEASE_INSTALLER_CERT_P12_BASE64: ${{ secrets.ATLAS_RELEASE_INSTALLER_CERT_P12_BASE64 }}
|
||||
ATLAS_RELEASE_INSTALLER_CERT_P12_PASSWORD: ${{ secrets.ATLAS_RELEASE_INSTALLER_CERT_P12_PASSWORD }}
|
||||
ATLAS_NOTARY_KEY_ID: ${{ secrets.ATLAS_NOTARY_KEY_ID }}
|
||||
ATLAS_NOTARY_ISSUER_ID: ${{ secrets.ATLAS_NOTARY_ISSUER_ID }}
|
||||
ATLAS_NOTARY_API_KEY_BASE64: ${{ secrets.ATLAS_NOTARY_API_KEY_BASE64 }}
|
||||
run: ./scripts/atlas/setup-release-signing-ci.sh
|
||||
|
||||
- name: Provision local development signing identity
|
||||
if: steps.mode.outputs.packaging_mode == 'development'
|
||||
run: ./scripts/atlas/ensure-local-signing-identity.sh
|
||||
|
||||
- name: Validate signing prerequisites
|
||||
if: steps.mode.outputs.packaging_mode == 'developer-id'
|
||||
run: ./scripts/atlas/signing-preflight.sh
|
||||
|
||||
- name: Build and package Atlas native app
|
||||
run: ./scripts/atlas/package-native.sh
|
||||
|
||||
- name: Verify DMG can install to the user Applications folder
|
||||
run: KEEP_INSTALLED_APP=1 ./scripts/atlas/verify-dmg-install.sh
|
||||
|
||||
- name: Upload native release artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: atlas-native-release
|
||||
path: |
|
||||
dist/native/Atlas-for-Mac.zip
|
||||
dist/native/Atlas-for-Mac.dmg
|
||||
dist/native/Atlas-for-Mac.pkg
|
||||
dist/native/Atlas-for-Mac.sha256
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
name: Publish Release
|
||||
needs:
|
||||
- build
|
||||
- native
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -163,24 +70,6 @@ jobs:
|
||||
pattern: binaries-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download native release artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: atlas-native-release
|
||||
path: bin
|
||||
|
||||
- name: Generate release body
|
||||
run: |
|
||||
if [[ "${{ needs.native.outputs.packaging_mode }}" == "development" ]]; then
|
||||
{
|
||||
echo "Native macOS assets in this tag were packaged in development mode because Developer ID release-signing credentials were not configured for this run."
|
||||
echo
|
||||
echo "These \`.zip\`, \`.dmg\`, and \`.pkg\` files are intended for internal testing or developer use. macOS Gatekeeper may require \`Open Anyway\` or a right-click \`Open\` flow before launch."
|
||||
} > RELEASE_BODY.md
|
||||
else
|
||||
echo "Native macOS assets in this tag were packaged in CI using Developer ID signing and notarization, then uploaded alongside the existing command-line release artifacts." > RELEASE_BODY.md
|
||||
fi
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R bin/
|
||||
|
||||
@@ -202,10 +91,6 @@ jobs:
|
||||
bin/analyze-darwin-*
|
||||
bin/status-darwin-*
|
||||
bin/binaries-darwin-*.tar.gz
|
||||
bin/Atlas-for-Mac.zip
|
||||
bin/Atlas-for-Mac.dmg
|
||||
bin/Atlas-for-Mac.pkg
|
||||
bin/Atlas-for-Mac.sha256
|
||||
bin/SHA256SUMS
|
||||
|
||||
- name: Create Release
|
||||
@@ -214,7 +99,6 @@ jobs:
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
files: bin/*
|
||||
body_path: RELEASE_BODY.md
|
||||
generate_release_notes: false
|
||||
draft: false
|
||||
prerelease: ${{ needs.native.outputs.prerelease == 'true' }}
|
||||
prerelease: false
|
||||
|
||||
@@ -37,7 +37,6 @@ final class AtlasAppModel: ObservableObject {
|
||||
@Published private(set) var updateCheckNotice: String?
|
||||
@Published private(set) var updateCheckError: String?
|
||||
|
||||
private let repository: AtlasWorkspaceRepository
|
||||
private let workspaceController: AtlasWorkspaceController
|
||||
private let updateChecker = AtlasUpdateChecker()
|
||||
private let notificationPermissionRequester: @Sendable () async -> Bool
|
||||
@@ -54,7 +53,6 @@ final class AtlasAppModel: ObservableObject {
|
||||
notificationPermissionRequester: (@Sendable () async -> Bool)? = nil
|
||||
) {
|
||||
let state = repository.loadState()
|
||||
self.repository = repository
|
||||
self.snapshot = state.snapshot
|
||||
self.currentPlan = state.currentPlan
|
||||
self.settings = state.settings
|
||||
@@ -100,11 +98,11 @@ final class AtlasAppModel: ObservableObject {
|
||||
}
|
||||
|
||||
var appVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.2"
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
||||
}
|
||||
|
||||
var appBuild: String {
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "3"
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
||||
}
|
||||
|
||||
func checkForUpdate() async {
|
||||
@@ -484,12 +482,6 @@ final class AtlasAppModel: ObservableObject {
|
||||
}
|
||||
await refreshPlanPreview()
|
||||
} catch {
|
||||
let persistedState = repository.loadState()
|
||||
withAnimation(.snappy(duration: 0.24)) {
|
||||
snapshot = persistedState.snapshot
|
||||
currentPlan = persistedState.currentPlan
|
||||
settings = persistedState.settings
|
||||
}
|
||||
latestScanSummary = error.localizedDescription
|
||||
}
|
||||
|
||||
|
||||
@@ -189,62 +189,6 @@ final class AtlasAppModelTests: XCTestCase {
|
||||
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 {
|
||||
let repository = makeRepository()
|
||||
let permissionInspector = AtlasPermissionInspector(
|
||||
@@ -365,12 +309,11 @@ final class AtlasAppModelTests: XCTestCase {
|
||||
XCTAssertEqual(AtlasRoute.overview.title, "Overview")
|
||||
}
|
||||
|
||||
private func makeRepository(nowProvider: @escaping @Sendable () -> Date = { Date() }) -> AtlasWorkspaceRepository {
|
||||
private func makeRepository() -> AtlasWorkspaceRepository {
|
||||
AtlasWorkspaceRepository(
|
||||
stateFileURL: FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
.appendingPathComponent("workspace-state.json"),
|
||||
nowProvider: nowProvider
|
||||
.appendingPathComponent("workspace-state.json")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -472,11 +415,3 @@ private actor ExecuteRejectingRestoreDelegatingWorker: AtlasWorkerServing {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class TestClock: @unchecked Sendable {
|
||||
var now: Date
|
||||
|
||||
init(now: Date) {
|
||||
self.now = now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
18248DDA2E6242D30B2FF84B /* AtlasFeaturesSettings in Frameworks */ = {isa = PBXBuildFile; productRef = FE51513F5C3746B2C3DA5E9A /* AtlasFeaturesSettings */; };
|
||||
18361B20FDB815F8F80A8D89 /* AtlasCoreAdapters in Frameworks */ = {isa = PBXBuildFile; productRef = A110B5FE410BD691B10F4338 /* AtlasCoreAdapters */; };
|
||||
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 */; };
|
||||
5E17D3D1A8B2B6844C11E4A0 /* AppShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6F7E5AF1DB77BD9455C253 /* AppShellView.swift */; };
|
||||
69A95E0759F67A6749B13268 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7514A7238A2A0C3B19F6D967 /* Assets.xcassets */; };
|
||||
@@ -51,13 +50,6 @@
|
||||
remoteGlobalIDString = 6554EF197FBC626F52F4BA4B;
|
||||
remoteInfo = AtlasWorkerXPC;
|
||||
};
|
||||
4D8C0366067B16B0EDEFF06F /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 43D55555CA7BCC7C87E44A39 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 98260B956C6EC40DBBEEC103;
|
||||
remoteInfo = AtlasApp;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -76,14 +68,12 @@
|
||||
/* Begin PBXFileReference section */
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
@@ -125,14 +115,6 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
239A9C84704A886CD4AF1BE3 /* AtlasAppTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1E3F033599CA5CB41CC01A2D /* AtlasAppModelTests.swift */,
|
||||
);
|
||||
path = AtlasAppTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
33096698F9C248F87E324810 /* Packages */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -153,7 +135,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3FA23CAAED1482D5CD7DBA21 /* Sources */,
|
||||
6E9CA333F7EF9B53949FFE51 /* Tests */,
|
||||
);
|
||||
path = AtlasApp;
|
||||
sourceTree = "<group>";
|
||||
@@ -166,14 +147,6 @@
|
||||
path = AtlasWorkerXPC;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6E9CA333F7EF9B53949FFE51 /* Tests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
239A9C84704A886CD4AF1BE3 /* AtlasAppTests */,
|
||||
);
|
||||
path = Tests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
70A20106B8807AFCC4851B2C = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -204,7 +177,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9AB1D202267B7A0E93C4D7A4 /* AtlasApp.app */,
|
||||
7FF49AAA8C253DBEE96EC8D3 /* AtlasAppTests.xctest */,
|
||||
7B60D354F907D973C9D78524 /* AtlasAppUITests.xctest */,
|
||||
97A2723BA50C375AFC3C9321 /* AtlasWorkerXPC.xpc */,
|
||||
);
|
||||
@@ -308,24 +280,6 @@
|
||||
productReference = 9AB1D202267B7A0E93C4D7A4 /* AtlasApp.app */;
|
||||
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 */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E425825D3882044CA19B8446 /* Build configuration list for PBXNativeTarget "AtlasAppUITests" */;
|
||||
@@ -376,7 +330,6 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
98260B956C6EC40DBBEEC103 /* AtlasApp */,
|
||||
BC1D0FBB35276C2AE56A0268 /* AtlasAppTests */,
|
||||
DC24C4DDD452116007066447 /* AtlasAppUITests */,
|
||||
6554EF197FBC626F52F4BA4B /* AtlasWorkerXPC */,
|
||||
);
|
||||
@@ -424,22 +377,9 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E0B1CC6AEF9B151A45B8A49E /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3F61098A3E68EB5B385D676C /* AtlasAppModelTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
1402ADF5C02FD65B5B68E38C /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 98260B956C6EC40DBBEEC103 /* AtlasApp */;
|
||||
targetProxy = 4D8C0366067B16B0EDEFF06F /* PBXContainerItemProxy */;
|
||||
};
|
||||
4561468E37A46EEB82269AB0 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 6554EF197FBC626F52F4BA4B /* AtlasWorkerXPC */;
|
||||
@@ -458,7 +398,7 @@
|
||||
buildSettings = {
|
||||
AD_HOC_CODE_SIGNING_ALLOWED = YES;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)";
|
||||
INFOPLIST_KEY_CFBundleVersion = "$(CURRENT_PROJECT_VERSION)";
|
||||
@@ -467,7 +407,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0.2;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app.worker;
|
||||
PRODUCT_NAME = AtlasWorkerXPC;
|
||||
SDKROOT = macosx;
|
||||
@@ -535,7 +475,7 @@
|
||||
buildSettings = {
|
||||
AD_HOC_CODE_SIGNING_ALLOWED = YES;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)";
|
||||
INFOPLIST_KEY_CFBundleVersion = "$(CURRENT_PROJECT_VERSION)";
|
||||
@@ -544,7 +484,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0.2;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app.worker;
|
||||
PRODUCT_NAME = AtlasWorkerXPC;
|
||||
SDKROOT = macosx;
|
||||
@@ -557,7 +497,7 @@
|
||||
AD_HOC_CODE_SIGNING_ALLOWED = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Atlas for Mac";
|
||||
INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)";
|
||||
@@ -569,9 +509,8 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0.2;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app;
|
||||
PRODUCT_MODULE_NAME = AtlasApp;
|
||||
PRODUCT_NAME = "Atlas for Mac";
|
||||
SDKROOT = macosx;
|
||||
};
|
||||
@@ -640,32 +579,13 @@
|
||||
};
|
||||
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 */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
AD_HOC_CODE_SIGNING_ALLOWED = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Atlas for Mac";
|
||||
INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)";
|
||||
@@ -677,33 +597,13 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0.2;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app;
|
||||
PRODUCT_MODULE_NAME = AtlasApp;
|
||||
PRODUCT_NAME = "Atlas for Mac";
|
||||
SDKROOT = macosx;
|
||||
};
|
||||
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 */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -772,15 +672,6 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
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" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@@ -53,17 +53,6 @@
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BC1D0FBB35276C2AE56A0268"
|
||||
BuildableName = "AtlasAppTests.xctest"
|
||||
BlueprintName = "AtlasAppTests"
|
||||
ReferencedContainer = "container:Atlas.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -6,24 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.2] - 2026-03-14
|
||||
|
||||
### Added
|
||||
|
||||
- Tag-driven GitHub Releases now publish Atlas native `.zip`, `.dmg`, and `.pkg` assets in addition to the legacy command-line binaries.
|
||||
- Added `scripts/atlas/prepare-release.sh` to align app version, build number, and changelog scaffolding before tagging a release.
|
||||
|
||||
### Changed
|
||||
|
||||
- Release automation now falls back to a development-signed prerelease path when `Developer ID` signing credentials are unavailable, instead of blocking native packaging entirely.
|
||||
- README installation guidance now distinguishes signed public releases from development prereleases and recommends local stable signing for developer packaging.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `package-native.sh` and `signing-preflight.sh` now support `notarytool` profiles stored in a non-default keychain, which unblocks CI-based notarization.
|
||||
|
||||
## [1.0.1] - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
||||
- Native macOS app with 7 MVP modules: Overview, Smart Clean, Apps, History, Recovery, Permissions, Settings
|
||||
@@ -40,13 +22,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- Go-based TUI tools inherited from upstream Mole: disk analyzer (`analyze`) and system monitor (`status`)
|
||||
- CI/CD: GitHub Actions for formatting, linting, testing, CodeQL scanning, and release packaging
|
||||
|
||||
### Fixed
|
||||
|
||||
- Recovery restore requests now preflight every selected item before Atlas mutates local recovery state, preventing partial in-memory restore success when a later item fails.
|
||||
- Helper-backed restore destination conflicts now surface restore-specific errors instead of falling back to a generic execution-unavailable message.
|
||||
- Expired recovery items are pruned from persisted state and rejected with explicit restore-expired messaging.
|
||||
- Revalidated the current native release candidate with package tests, app tests, DMG install verification, launch smoke, and native UI automation.
|
||||
|
||||
### Attribution
|
||||
|
||||
- Built in part on the open-source [Mole](https://github.com/tw93/mole) project (MIT) by tw93 and contributors
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# 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,7 +40,6 @@
|
||||
|
||||
- XPC transport
|
||||
- JSON-backed workspace state persistence
|
||||
- Recovery-state normalization that prunes expired recovery entries on load/save
|
||||
- Logging and audit events
|
||||
- Best-effort permission inspection
|
||||
- Helper executable client
|
||||
@@ -55,7 +54,6 @@
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -72,8 +70,6 @@
|
||||
- Distribution target: `Developer ID + Hardened Runtime + Notarization`
|
||||
- Initial release target: direct distribution, not Mac App Store
|
||||
- Native packaging currently uses `xcodegen + xcodebuild`, embeds the helper into `Contents/Helpers/`, and emits `.zip`, `.dmg`, and `.pkg` distribution artifacts.
|
||||
- Tagged GitHub Releases reuse the same native packaging scripts in CI and publish `.zip`, `.dmg`, `.pkg`, and checksum assets.
|
||||
- When release signing credentials are configured, CI signs and notarizes those assets; otherwise it falls back to a local development signing identity and marks the GitHub Release as a prerelease.
|
||||
- Local internal packaging now prefers a stable non-ad-hoc app signature when a usable identity is available, so macOS TCC decisions can survive rebuilds more reliably during development.
|
||||
- If Apple release certificates are unavailable, Atlas can fall back to a repo-managed local signing keychain for stable app-bundle identity; public release artifacts still require `Developer ID`.
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
|
||||
- Destructive helper actions use a structured executable boundary with path validation
|
||||
- Native MVP packaging uses `xcodegen + xcodebuild`, then embeds the helper into the app bundle
|
||||
- Tagged GitHub Releases should publish the native `.zip`, `.dmg`, and `.pkg` assets from CI using the same packaging scripts as local release builds
|
||||
- If CI lacks `Developer ID` release credentials, tagged native assets may still be published as development-signed prereleases instead of blocking the packaging path entirely
|
||||
- Signing and notarization remain optional release-time steps driven by credentials
|
||||
- Internal packaging should prefer a stable local app-signing identity over ad hoc signing whenever possible so macOS permission state does not drift across rebuilds
|
||||
|
||||
|
||||
@@ -31,11 +31,6 @@
|
||||
- Use sheets for permission and destructive confirmation flows.
|
||||
- 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
|
||||
|
||||
- User-visible format recommendation: `ATLAS-<DOMAIN>-<NUMBER>`
|
||||
|
||||
@@ -119,8 +119,6 @@ Do not expand into:
|
||||
|
||||
- Added one new real Smart Clean execute target class for `~/Library/pnpm/store/*`.
|
||||
- Added stronger worker-side truthfulness so Atlas only records recovery/history side effects when a real file move happened.
|
||||
- Hardened recovery restore semantics so batched restore requests preflight all selected items before mutating Atlas state.
|
||||
- Mapped helper-backed restore destination conflicts back to the restore-specific rejection path instead of the generic execution-unavailable path.
|
||||
- Split History recovery messaging between:
|
||||
- file-backed restore entries with `restoreMappings`
|
||||
- Atlas-only recovery entries with no supported on-disk restore path
|
||||
@@ -128,17 +126,17 @@ Do not expand into:
|
||||
|
||||
### Current blocker
|
||||
|
||||
- Local UI automation is no longer a hard blocker on this Mac.
|
||||
- `./scripts/atlas/full-acceptance.sh` passed on **2026-03-13** for the latest recovery-fix candidate build.
|
||||
- Interactive bilingual UI automation on this machine is **blocked** by macOS Accessibility trust for the current terminal process.
|
||||
- `./scripts/atlas/ui-automation-preflight.sh` reported `Accessibility trusted for current process: false` on **2026-03-12**.
|
||||
|
||||
The remaining gap is narrower:
|
||||
This means the packaged-build install and fresh-state launch checks below are complete, but a full click-through clean-machine bilingual UI walkthrough still requires either:
|
||||
|
||||
- a dedicated clean-machine bilingual manual walkthrough is still outstanding
|
||||
- current packaged-build evidence is still machine-local, not evidence from a second physical clean Mac
|
||||
- Accessibility trust to be granted on this Mac, or
|
||||
- a separate clean machine for the final interactive pass.
|
||||
|
||||
## Packaged-Build Evidence
|
||||
|
||||
### Latest artifacts built on 2026-03-13
|
||||
### Latest artifacts built on 2026-03-12
|
||||
|
||||
- App: `dist/native/Atlas for Mac.app`
|
||||
- DMG: `dist/native/Atlas-for-Mac.dmg`
|
||||
@@ -149,19 +147,16 @@ The remaining gap is narrower:
|
||||
### Checksum record
|
||||
|
||||
```text
|
||||
2d5988ac5f03d06b5f8ec2c94869752ad5c67de0f7eeb7b59aeb9e463f6cdee9 Atlas-for-Mac.zip
|
||||
2b5d1a1b6636edcf180b5639bcf62734d25e05adcb405419f7a234ade3870c1e Atlas-for-Mac.dmg
|
||||
e89f50a77d9148d18ca78d8a0fd005394fc5848e6ae6a5231700e1b180135495 Atlas-for-Mac.pkg
|
||||
b85425649c5d781f234cdf1690ce01f330e3216d963cbf7d8f720a2e66611ffa Atlas-for-Mac.zip
|
||||
2d5f480110d13f83c38e2296fafaa72617fc122d694d78c2c32c3a260f0ae110 Atlas-for-Mac.dmg
|
||||
d71c45b0312ceeb045e390d851e246fe7f59e90961f2a482cfb21ee4f65d56ec Atlas-for-Mac.pkg
|
||||
```
|
||||
|
||||
### Verified commands
|
||||
|
||||
- `./scripts/atlas/full-acceptance.sh` — **pass** on 2026-03-13
|
||||
- `./scripts/atlas/package-native.sh` — **pass** on 2026-03-13
|
||||
- `./scripts/atlas/verify-bundle-contents.sh` — **pass** on 2026-03-13
|
||||
- `KEEP_INSTALLED_APP=1 ./scripts/atlas/verify-dmg-install.sh` — **pass** on 2026-03-13
|
||||
- `./scripts/atlas/verify-app-launch.sh` — **pass** on 2026-03-13
|
||||
- `./scripts/atlas/run-ui-automation.sh` — **pass** on 2026-03-13 for the latest recovery-fix candidate build
|
||||
- `./scripts/atlas/package-native.sh` — **pass** on 2026-03-12
|
||||
- `KEEP_INSTALLED_APP=1 ./scripts/atlas/verify-dmg-install.sh` — **pass** on 2026-03-12
|
||||
- `STATE_DIR="$PWD/.build/atlas-hardening-fresh-state-2026-03-12" ./scripts/atlas/verify-app-launch.sh` — **pass** on 2026-03-12
|
||||
|
||||
### Fresh-state file evidence
|
||||
|
||||
@@ -175,19 +170,19 @@ This is a machine-local fresh-state packaged-build verification, not a claim of
|
||||
|
||||
### 1. Clean-machine bilingual QA
|
||||
|
||||
**Status:** `Partially complete / manual clean-machine pass still pending`
|
||||
**Status:** `Partially complete / locally blocked`
|
||||
|
||||
Completed evidence:
|
||||
|
||||
- Packaged install path verified to `~/Applications/Atlas for Mac.app`
|
||||
- Fresh-state packaged launch verified with a brand-new workspace-state directory
|
||||
- Default first-launch language persisted as `zh-Hans`
|
||||
- Language-switch persistence covered by app-model test evidence and local UI automation
|
||||
- Language-switch persistence covered by app-model test evidence
|
||||
- Smart Clean, Apps, and Recovery trust paths covered by package and app tests listed below
|
||||
|
||||
Remaining blocker:
|
||||
|
||||
- A dedicated clean-machine bilingual manual walkthrough is still not recorded for this candidate build
|
||||
- Interactive packaged-app UI walkthrough for first launch + bilingual control verification is blocked on local Accessibility trust
|
||||
|
||||
### 2. Fresh-state verification with latest packaged build
|
||||
|
||||
@@ -236,7 +231,6 @@ Behavior tightened so that:
|
||||
|
||||
- `swift test --package-path Packages --filter MoleSmartCleanAdapterTests` — **pass** on 2026-03-12
|
||||
- `swift test --package-path Packages --filter AtlasInfrastructureTests` — **pass** on 2026-03-12
|
||||
- `swift test --package-path Packages` — **pass** on 2026-03-13 after the recovery restore atomicity and helper-conflict fix
|
||||
|
||||
Key tests:
|
||||
|
||||
@@ -250,7 +244,6 @@ Key tests:
|
||||
### App-model coverage
|
||||
|
||||
- `swift test --package-path Apps --filter AtlasAppModelTests` — **pass** on 2026-03-12
|
||||
- `swift test --package-path Apps` — **pass** on 2026-03-13 after the recovery restore atomicity and helper-conflict fix
|
||||
|
||||
Key tests:
|
||||
|
||||
@@ -258,7 +251,6 @@ Key tests:
|
||||
- `testPreferredXPCWorkerPathFailsClosedWhenScanIsRejected`
|
||||
- `testExecuteCurrentPlanExposesExplicitExecutionIssueWhenWorkerRejectsExecution`
|
||||
- `testExecuteCurrentPlanOnlyRecordsRecoveryForRealSideEffects`
|
||||
- `testRestoreExpiredRecoveryItemReloadsPersistedState`
|
||||
- `testRestoreRecoveryItemReturnsFindingToWorkspace`
|
||||
|
||||
## QA Matrix
|
||||
@@ -268,7 +260,7 @@ Key tests:
|
||||
| First launch | packaged app launch smoke with new state dir | Pass |
|
||||
| Install path | DMG install validation to `~/Applications` | Pass |
|
||||
| Default language | fresh packaged state file persisted `zh-Hans` | Pass |
|
||||
| Language switching | app-model persistence test plus local UI automation pass on 2026-03-13; clean-machine manual pass still pending | Partial |
|
||||
| Language switching | app-model persistence test; UI click-through still blocked locally | Partial |
|
||||
| Smart Clean execute | package tests + real file-backed contract tests | Pass |
|
||||
| Apps | app-model and infrastructure uninstall/recovery tests | Pass |
|
||||
| History / Recovery | file-backed vs Atlas-only summary/copy split + restore tests | Pass |
|
||||
@@ -289,8 +281,6 @@ Do not say:
|
||||
|
||||
## Files Changed for Hardening
|
||||
|
||||
- `Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift`
|
||||
- `Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift`
|
||||
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift`
|
||||
- `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift`
|
||||
- `Packages/AtlasCoreAdapters/Sources/AtlasCoreAdapters/MoleSmartCleanAdapter.swift`
|
||||
|
||||
@@ -23,7 +23,7 @@ Freeze Atlas recovery semantics against the behavior that is actually shipped to
|
||||
|
||||
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
|
||||
- the recovery item still exists in Atlas history
|
||||
- its retention window is still open
|
||||
- the recovery item contains at least one `restoreMappings` entry
|
||||
- the trashed source still exists on disk
|
||||
@@ -46,12 +46,10 @@ State-only restore means:
|
||||
|
||||
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 original destination already exists
|
||||
- the target falls outside the supported direct/helper allowlist
|
||||
- a required helper capability is unavailable (`helperUnavailable`)
|
||||
- another restore precondition fails after validation (`executionUnavailable`)
|
||||
- a required helper capability is unavailable
|
||||
|
||||
### 5. History and completion wording
|
||||
|
||||
@@ -113,18 +111,12 @@ Avoid saying:
|
||||
### 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`
|
||||
|
||||
@@ -134,8 +126,6 @@ Avoid saying:
|
||||
- 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
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
## 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
|
||||
@@ -59,8 +58,6 @@
|
||||
- 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
|
||||
|
||||
@@ -71,7 +68,7 @@
|
||||
### 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.
|
||||
- The contract distinguishes physical restore from Atlas-only state rehydration and documents the exact failure conditions.
|
||||
|
||||
## Remaining Limits
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ Turn Atlas for Mac from an installable local build into a publicly distributable
|
||||
- `ATLAS_CODESIGN_KEYCHAIN`
|
||||
- `ATLAS_INSTALLER_SIGN_IDENTITY`
|
||||
- `ATLAS_NOTARY_PROFILE`
|
||||
- `ATLAS_NOTARY_KEYCHAIN` (optional; required when the notary profile lives in a non-default keychain such as CI)
|
||||
|
||||
## Stable Local Signing
|
||||
|
||||
@@ -44,28 +43,6 @@ Run:
|
||||
|
||||
If preflight passes, the current machine is ready for signed packaging.
|
||||
|
||||
## Version Prep
|
||||
|
||||
Before pushing a release tag, align the app version, build number, and changelog skeleton:
|
||||
|
||||
```bash
|
||||
./scripts/atlas/prepare-release.sh 1.0.2
|
||||
```
|
||||
|
||||
Optional arguments:
|
||||
|
||||
```bash
|
||||
./scripts/atlas/prepare-release.sh 1.0.2 3 2026-03-14
|
||||
```
|
||||
|
||||
This updates:
|
||||
|
||||
- `project.yml`
|
||||
- `Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift`
|
||||
- `CHANGELOG.md`
|
||||
|
||||
The script increments `CURRENT_PROJECT_VERSION` automatically when you omit the build number. Review the new changelog section before creating the `V1.0.2` tag.
|
||||
|
||||
## Signed Packaging
|
||||
|
||||
Run:
|
||||
@@ -79,12 +56,6 @@ ATLAS_NOTARY_PROFILE="<profile-name>" \
|
||||
|
||||
This signs the app bundle, emits `.zip`, `.dmg`, and `.pkg`, submits artifacts for notarization, and staples results when credentials are available.
|
||||
|
||||
If the notary profile is stored in a non-default keychain, also set:
|
||||
|
||||
```bash
|
||||
ATLAS_NOTARY_KEYCHAIN="/path/to/release.keychain-db"
|
||||
```
|
||||
|
||||
## Install Verification
|
||||
|
||||
After packaging, validate the DMG installation path with:
|
||||
@@ -93,52 +64,7 @@ After packaging, validate the DMG installation path with:
|
||||
KEEP_INSTALLED_APP=1 ./scripts/atlas/verify-dmg-install.sh
|
||||
```
|
||||
|
||||
## GitHub Tag Release Automation
|
||||
|
||||
Tagged pushes matching `V*` now reuse the same packaging flow in CI and attach native release assets to the GitHub Release created by `.github/workflows/release.yml`.
|
||||
|
||||
Required GitHub Actions secrets:
|
||||
|
||||
- `ATLAS_RELEASE_APP_CERT_P12_BASE64`
|
||||
- `ATLAS_RELEASE_APP_CERT_P12_PASSWORD`
|
||||
- `ATLAS_RELEASE_INSTALLER_CERT_P12_BASE64`
|
||||
- `ATLAS_RELEASE_INSTALLER_CERT_P12_PASSWORD`
|
||||
- `ATLAS_NOTARY_KEY_ID`
|
||||
- `ATLAS_NOTARY_ISSUER_ID` for Team API keys; omit only if you intentionally use an Individual API key
|
||||
- `ATLAS_NOTARY_API_KEY_BASE64`
|
||||
|
||||
If those secrets are present, the workflow bootstraps a temporary keychain with `./scripts/atlas/setup-release-signing-ci.sh`, stores a `notarytool` profile there, derives `ATLAS_VERSION` from the pushed tag name, then runs `./scripts/atlas/package-native.sh`.
|
||||
|
||||
If those secrets are missing, the workflow automatically falls back to:
|
||||
|
||||
- `./scripts/atlas/ensure-local-signing-identity.sh`
|
||||
- local development signing for the app bundle
|
||||
- unsigned installer packaging if no installer identity exists
|
||||
- no notarization
|
||||
- GitHub Release marked as `prerelease`
|
||||
|
||||
Release flow:
|
||||
|
||||
```bash
|
||||
git tag -a V1.0.2 -m "Release V1.0.2"
|
||||
git push origin V1.0.2
|
||||
```
|
||||
|
||||
That tag creates one GitHub Release containing:
|
||||
|
||||
- legacy Go binaries and Homebrew tarballs from the existing release pipeline
|
||||
- `Atlas-for-Mac.zip`
|
||||
- `Atlas-for-Mac.dmg`
|
||||
- `Atlas-for-Mac.pkg`
|
||||
- native and aggregate SHA-256 checksum files
|
||||
|
||||
Packaging mode by credential state:
|
||||
|
||||
- `Developer ID secrets present` -> signed and notarized native assets, normal GitHub Release
|
||||
- `Developer ID secrets missing` -> development-signed native assets, GitHub `prerelease`
|
||||
|
||||
## Current Repo State
|
||||
|
||||
- Internal packaging can now use a stable local app-signing identity instead of ad hoc signing.
|
||||
- Signed/notarized release artifacts remain blocked only by missing Apple release credentials on this machine.
|
||||
- Tagged GitHub Releases can still publish development-mode native assets without those credentials.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Protocol Version
|
||||
|
||||
- Current implementation version: `0.3.1`
|
||||
- Current implementation version: `0.3.0`
|
||||
|
||||
## UI ↔ Worker Commands
|
||||
|
||||
@@ -56,8 +56,6 @@
|
||||
- `permissionRequired`
|
||||
- `helperUnavailable`
|
||||
- `executionUnavailable`
|
||||
- `restoreExpired`
|
||||
- `restoreConflict`
|
||||
- `invalidSelection`
|
||||
|
||||
## Event Payloads
|
||||
@@ -148,7 +146,6 @@
|
||||
- `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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -157,4 +154,3 @@
|
||||
|
||||
- `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` rejects expired recovery items with `restoreExpired` and rejects destination collisions with `restoreConflict`.
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
- Progress must not move backwards.
|
||||
- Destructive tasks must be audited.
|
||||
- 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.
|
||||
|
||||
## Current MVP Notes
|
||||
@@ -66,5 +65,4 @@
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# 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,10 +161,6 @@ public enum AtlasWorkspaceControllerError: LocalizedError, Sendable {
|
||||
return AtlasL10n.string("application.error.executionUnavailable", reason)
|
||||
case .helperUnavailable:
|
||||
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:
|
||||
return AtlasL10n.string("application.error.workerRejected", code.rawValue, reason)
|
||||
}
|
||||
|
||||
@@ -209,52 +209,6 @@ final class AtlasApplicationTests: XCTestCase {
|
||||
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 {
|
||||
|
||||
@@ -92,8 +92,6 @@
|
||||
"application.error.workerRejected" = "Worker rejected request (%@): %@";
|
||||
"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.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.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.";
|
||||
|
||||
@@ -92,8 +92,6 @@
|
||||
"application.error.workerRejected" = "后台服务拒绝了请求(%@):%@";
|
||||
"application.error.executionUnavailable" = "Atlas 当前无法通过真实工作链路执行这项操作:%@";
|
||||
"application.error.helperUnavailable" = "Atlas 当前无法完成这项操作,因为特权辅助组件不可用:%@";
|
||||
"application.error.restoreExpired" = "这个项目已经超出恢复保留窗口,Atlas 不能再恢复它:%@";
|
||||
"application.error.restoreConflict" = "Atlas 无法恢复这个项目,因为它的原始目标位置已经存在内容:%@";
|
||||
"xpc.error.encodingFailed" = "无法编码后台请求:%@";
|
||||
"xpc.error.decodingFailed" = "无法解析后台响应:%@";
|
||||
"xpc.error.invalidResponse" = "后台工作组件返回了无效响应。请完全退出并重新打开 Atlas;若仍失败,请重新安装当前版本。";
|
||||
|
||||
@@ -536,7 +536,7 @@ public struct HistoryFeatureView: View {
|
||||
HistoryRecoveryDetailView(
|
||||
item: item,
|
||||
isRestoring: restoringItemID == item.id,
|
||||
canRestore: restoringItemID == nil && !item.isExpired,
|
||||
canRestore: restoringItemID == nil,
|
||||
onRestore: { onRestoreItem(item.id) }
|
||||
)
|
||||
} else {
|
||||
@@ -1115,13 +1115,6 @@ private extension RecoveryItem {
|
||||
!(restoreMappings ?? []).isEmpty
|
||||
}
|
||||
|
||||
var isExpired: Bool {
|
||||
guard let expiresAt else {
|
||||
return false
|
||||
}
|
||||
return expiresAt <= Date()
|
||||
}
|
||||
|
||||
var isExpiringSoon: Bool {
|
||||
guard let expiresAt else {
|
||||
return false
|
||||
|
||||
@@ -297,14 +297,9 @@ public enum AtlasSmartCleanExecutionSupport {
|
||||
|
||||
public struct AtlasWorkspaceRepository: Sendable {
|
||||
private let stateFileURL: URL
|
||||
private let nowProvider: @Sendable () -> Date
|
||||
|
||||
public init(
|
||||
stateFileURL: URL? = nil,
|
||||
nowProvider: @escaping @Sendable () -> Date = { Date() }
|
||||
) {
|
||||
public init(stateFileURL: URL? = nil) {
|
||||
self.stateFileURL = stateFileURL ?? Self.defaultStateFileURL
|
||||
self.nowProvider = nowProvider
|
||||
}
|
||||
|
||||
public func loadState() -> AtlasWorkspaceState {
|
||||
@@ -313,12 +308,7 @@ 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 normalized = normalizedState(decoded)
|
||||
if normalized != decoded {
|
||||
_ = try? saveState(normalized)
|
||||
}
|
||||
return normalized
|
||||
return try decoder.decode(AtlasWorkspaceState.self, from: data)
|
||||
} catch let repositoryError as AtlasWorkspaceRepositoryError {
|
||||
reportFailure(repositoryError, operation: "load existing workspace state from \(stateFileURL.path)")
|
||||
} catch {
|
||||
@@ -342,7 +332,6 @@ public struct AtlasWorkspaceRepository: Sendable {
|
||||
public func saveState(_ state: AtlasWorkspaceState) throws -> AtlasWorkspaceState {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let normalizedState = normalizedState(state)
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
@@ -355,7 +344,7 @@ public struct AtlasWorkspaceRepository: Sendable {
|
||||
|
||||
let data: Data
|
||||
do {
|
||||
data = try encoder.encode(normalizedState)
|
||||
data = try encoder.encode(state)
|
||||
} catch {
|
||||
throw AtlasWorkspaceRepositoryError.encodeFailed(error.localizedDescription)
|
||||
}
|
||||
@@ -366,7 +355,7 @@ public struct AtlasWorkspaceRepository: Sendable {
|
||||
throw AtlasWorkspaceRepositoryError.writeFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
return normalizedState
|
||||
return state
|
||||
}
|
||||
|
||||
public func loadScaffoldSnapshot() -> AtlasWorkspaceSnapshot {
|
||||
@@ -388,15 +377,6 @@ 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 {
|
||||
if let explicit = ProcessInfo.processInfo.environment["ATLAS_STATE_FILE"], !explicit.isEmpty {
|
||||
return URL(fileURLWithPath: explicit)
|
||||
@@ -423,7 +403,6 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
||||
private let smartCleanScanProvider: (any AtlasSmartCleanScanProviding)?
|
||||
private let appsInventoryProvider: (any AtlasAppInventoryProviding)?
|
||||
private let helperExecutor: (any AtlasPrivilegedActionExecuting)?
|
||||
private let nowProvider: @Sendable () -> Date
|
||||
private let allowProviderFailureFallback: Bool
|
||||
private let allowStateOnlyCleanExecution: Bool
|
||||
private var state: AtlasWorkspaceState
|
||||
@@ -436,7 +415,6 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
||||
appsInventoryProvider: (any AtlasAppInventoryProviding)? = nil,
|
||||
helperExecutor: (any AtlasPrivilegedActionExecuting)? = nil,
|
||||
auditStore: AtlasAuditStore = AtlasAuditStore(),
|
||||
nowProvider: @escaping @Sendable () -> Date = { Date() },
|
||||
allowProviderFailureFallback: Bool = ProcessInfo.processInfo.environment["ATLAS_ALLOW_PROVIDER_FAILURE_FALLBACK"] == "1",
|
||||
allowStateOnlyCleanExecution: Bool = ProcessInfo.processInfo.environment["ATLAS_ALLOW_STATE_ONLY_CLEAN_EXECUTION"] == "1"
|
||||
) {
|
||||
@@ -447,7 +425,6 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
||||
self.smartCleanScanProvider = smartCleanScanProvider
|
||||
self.appsInventoryProvider = appsInventoryProvider
|
||||
self.helperExecutor = helperExecutor
|
||||
self.nowProvider = nowProvider
|
||||
self.allowProviderFailureFallback = allowProviderFailureFallback
|
||||
self.allowStateOnlyCleanExecution = allowStateOnlyCleanExecution
|
||||
self.state = repository.loadState()
|
||||
@@ -456,11 +433,6 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
||||
|
||||
public func submit(_ request: AtlasRequestEnvelope) async throws -> AtlasWorkerCommandResult {
|
||||
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 {
|
||||
case .healthSnapshot:
|
||||
return try await healthSnapshot(using: request)
|
||||
@@ -723,20 +695,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
||||
}
|
||||
|
||||
private func restoreItems(using request: AtlasRequestEnvelope, taskID: UUID, itemIDs: [UUID]) async -> AtlasWorkerCommandResult {
|
||||
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) }
|
||||
let itemsToRestore = state.snapshot.recoveryItems.filter { itemIDs.contains($0.id) }
|
||||
|
||||
guard !itemsToRestore.isEmpty else {
|
||||
return rejectedResult(
|
||||
@@ -746,22 +705,6 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
try validateRestoreItems(itemsToRestore)
|
||||
} catch let failure as RecoveryRestoreFailure {
|
||||
return rejectedResult(
|
||||
for: request,
|
||||
code: failure.code,
|
||||
reason: failure.localizedDescription
|
||||
)
|
||||
} catch {
|
||||
return rejectedResult(
|
||||
for: request,
|
||||
code: .executionUnavailable,
|
||||
reason: error.localizedDescription
|
||||
)
|
||||
}
|
||||
|
||||
var physicalRestoreCount = 0
|
||||
var atlasOnlyRestoreCount = 0
|
||||
|
||||
@@ -770,12 +713,6 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
||||
do {
|
||||
try await restoreRecoveryMappings(restoreMappings)
|
||||
physicalRestoreCount += 1
|
||||
} catch let failure as RecoveryRestoreFailure {
|
||||
return rejectedResult(
|
||||
for: request,
|
||||
code: failure.code,
|
||||
reason: failure.localizedDescription
|
||||
)
|
||||
} catch {
|
||||
return rejectedResult(
|
||||
for: request,
|
||||
@@ -786,9 +723,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
||||
} else {
|
||||
atlasOnlyRestoreCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
for item in itemsToRestore {
|
||||
switch item.payload {
|
||||
case let .finding(finding):
|
||||
if !state.snapshot.findings.contains(where: { $0.id == finding.id }) {
|
||||
@@ -803,7 +738,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
||||
}
|
||||
}
|
||||
|
||||
state.snapshot.recoveryItems.removeAll { requestedItemIDs.contains($0.id) }
|
||||
state.snapshot.recoveryItems.removeAll { itemIDs.contains($0.id) }
|
||||
recalculateReclaimableSpace()
|
||||
state.currentPlan = makePreviewPlan(findingIDs: state.snapshot.findings.map(\.id))
|
||||
|
||||
@@ -950,40 +885,6 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
||||
)
|
||||
}
|
||||
|
||||
private func validateRestoreItems(_ items: [RecoveryItem]) throws {
|
||||
for item in items {
|
||||
guard let restoreMappings = item.restoreMappings, !restoreMappings.isEmpty else {
|
||||
continue
|
||||
}
|
||||
try validateRestoreMappings(restoreMappings)
|
||||
}
|
||||
}
|
||||
|
||||
private func validateRestoreMappings(_ restoreMappings: [RecoveryPathMapping]) throws {
|
||||
for mapping in restoreMappings {
|
||||
try validateRestoreTarget(mapping)
|
||||
}
|
||||
}
|
||||
|
||||
private func expiredRecoveryItemIDs(asOf now: Date? = nil) -> Set<UUID> {
|
||||
let cutoff = now ?? nowProvider()
|
||||
return Set(state.snapshot.recoveryItems.compactMap { item in
|
||||
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 {
|
||||
do {
|
||||
_ = try repository.saveState(state)
|
||||
@@ -1098,64 +999,32 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
|
||||
}
|
||||
}
|
||||
|
||||
private func validateRestoreTarget(_ mapping: RecoveryPathMapping) throws {
|
||||
private func restoreRecoveryTarget(_ mapping: RecoveryPathMapping) async throws {
|
||||
let sourceURL = URL(fileURLWithPath: mapping.trashedPath).resolvingSymlinksInPath()
|
||||
let destinationURL = URL(fileURLWithPath: mapping.originalPath).resolvingSymlinksInPath()
|
||||
guard FileManager.default.fileExists(atPath: sourceURL.path) else {
|
||||
throw RecoveryRestoreFailure.executionUnavailable("Recovery source is no longer available on disk: \(sourceURL.path)")
|
||||
throw AtlasWorkspaceRepositoryError.writeFailed("Recovery source is no longer available on disk: \(sourceURL.path)")
|
||||
}
|
||||
if shouldUseHelperForSmartCleanTarget(destinationURL) {
|
||||
guard helperExecutor != nil else {
|
||||
throw RecoveryRestoreFailure.helperUnavailable("Bundled helper unavailable for recovery target: \(destinationURL.path)")
|
||||
}
|
||||
} else if !isDirectlyTrashableSmartCleanTarget(destinationURL) {
|
||||
throw RecoveryRestoreFailure.executionUnavailable("Recovery target is outside the supported execution allowlist: \(destinationURL.path)")
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||
throw RecoveryRestoreFailure.restoreConflict("Recovery target already exists: \(destinationURL.path)")
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreRecoveryTarget(_ mapping: RecoveryPathMapping) async throws {
|
||||
try validateRestoreTarget(mapping)
|
||||
let sourceURL = URL(fileURLWithPath: mapping.trashedPath).resolvingSymlinksInPath()
|
||||
let destinationURL = URL(fileURLWithPath: mapping.originalPath).resolvingSymlinksInPath()
|
||||
if shouldUseHelperForSmartCleanTarget(destinationURL) {
|
||||
guard let helperExecutor else {
|
||||
throw RecoveryRestoreFailure.helperUnavailable("Bundled helper unavailable for recovery target: \(destinationURL.path)")
|
||||
throw AtlasWorkspaceRepositoryError.writeFailed("Bundled helper unavailable for recovery target: \(destinationURL.path)")
|
||||
}
|
||||
do {
|
||||
let result = try await helperExecutor.perform(
|
||||
AtlasHelperAction(kind: .restoreItem, targetPath: sourceURL.path, destinationPath: destinationURL.path)
|
||||
)
|
||||
guard result.success else {
|
||||
throw recoveryRestoreFailure(fromHelperMessage: result.message)
|
||||
}
|
||||
} catch let failure as RecoveryRestoreFailure {
|
||||
throw failure
|
||||
} catch let clientError as AtlasHelperClientError {
|
||||
switch clientError {
|
||||
case .helperUnavailable:
|
||||
throw RecoveryRestoreFailure.helperUnavailable(clientError.localizedDescription)
|
||||
case .encodingFailed, .decodingFailed, .invocationFailed:
|
||||
throw RecoveryRestoreFailure.executionUnavailable(clientError.localizedDescription)
|
||||
}
|
||||
} catch {
|
||||
throw RecoveryRestoreFailure.executionUnavailable(error.localizedDescription)
|
||||
let result = try await helperExecutor.perform(AtlasHelperAction(kind: .restoreItem, targetPath: sourceURL.path, destinationPath: destinationURL.path))
|
||||
guard result.success else {
|
||||
throw AtlasWorkspaceRepositoryError.writeFailed(result.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard isDirectlyTrashableSmartCleanTarget(destinationURL) else {
|
||||
throw AtlasWorkspaceRepositoryError.writeFailed("Recovery target is outside the supported execution allowlist: \(destinationURL.path)")
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||
throw AtlasWorkspaceRepositoryError.writeFailed("Recovery target already exists: \(destinationURL.path)")
|
||||
}
|
||||
try FileManager.default.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try FileManager.default.moveItem(at: sourceURL, to: destinationURL)
|
||||
}
|
||||
|
||||
private func recoveryRestoreFailure(fromHelperMessage message: String) -> RecoveryRestoreFailure {
|
||||
if message.hasPrefix("Restore destination already exists:") {
|
||||
return .restoreConflict(message)
|
||||
}
|
||||
return .executionUnavailable(message)
|
||||
}
|
||||
|
||||
private func shouldUseHelperForSmartCleanTarget(_ targetURL: URL) -> Bool {
|
||||
AtlasSmartCleanExecutionSupport.requiresHelper(for: targetURL)
|
||||
}
|
||||
@@ -1415,41 +1284,6 @@ 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 Foundation
|
||||
|
||||
|
||||
@@ -27,55 +27,6 @@ final class AtlasInfrastructureTests: XCTestCase {
|
||||
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 {
|
||||
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
@@ -758,67 +709,6 @@ 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
|
||||
@@ -914,198 +804,6 @@ final class AtlasInfrastructureTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
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 testRestoreItemsKeepsStateUnchangedWhenLaterHelperRestoreFails() async throws {
|
||||
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
|
||||
let fileManager = FileManager.default
|
||||
let home = fileManager.homeDirectoryForCurrentUser
|
||||
|
||||
let directRoot = home.appendingPathComponent("Library/Caches/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true)
|
||||
try fileManager.createDirectory(at: directRoot, withIntermediateDirectories: true)
|
||||
let directTargetURL = directRoot.appendingPathComponent("restored.cache")
|
||||
try Data("restored".utf8).write(to: directTargetURL)
|
||||
var directTrashedURL: NSURL?
|
||||
try fileManager.trashItem(at: directTargetURL, resultingItemURL: &directTrashedURL)
|
||||
let directTrashedPath = try XCTUnwrap((directTrashedURL as URL?)?.path)
|
||||
|
||||
let appRoot = home.appendingPathComponent("Applications/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true)
|
||||
let appBundleURL = appRoot.appendingPathComponent("Atlas Restore Conflict.app", isDirectory: true)
|
||||
try fileManager.createDirectory(at: appBundleURL.appendingPathComponent("Contents/MacOS"), withIntermediateDirectories: true)
|
||||
try Data("#!/bin/sh\nexit 0\n".utf8).write(to: appBundleURL.appendingPathComponent("Contents/MacOS/AtlasRestoreConflict"))
|
||||
var appTrashedURL: NSURL?
|
||||
try fileManager.trashItem(at: appBundleURL, resultingItemURL: &appTrashedURL)
|
||||
let appTrashedPath = try XCTUnwrap((appTrashedURL as URL?)?.path)
|
||||
|
||||
addTeardownBlock {
|
||||
try? FileManager.default.removeItem(at: directRoot)
|
||||
try? FileManager.default.removeItem(at: appRoot)
|
||||
if let directTrashedURL {
|
||||
try? FileManager.default.removeItem(at: directTrashedURL as URL)
|
||||
}
|
||||
if let appTrashedURL {
|
||||
try? FileManager.default.removeItem(at: appTrashedURL as URL)
|
||||
}
|
||||
}
|
||||
|
||||
let directFinding = Finding(
|
||||
id: UUID(),
|
||||
title: "Direct restore fixture",
|
||||
detail: directTargetURL.path,
|
||||
bytes: 11,
|
||||
risk: .safe,
|
||||
category: "Developer tools",
|
||||
targetPaths: [directTargetURL.path]
|
||||
)
|
||||
let helperApp = AppFootprint(
|
||||
id: UUID(),
|
||||
name: "Atlas Restore Conflict",
|
||||
bundleIdentifier: "com.atlas.restore-conflict",
|
||||
bundlePath: appBundleURL.path,
|
||||
bytes: 17,
|
||||
leftoverItems: 1
|
||||
)
|
||||
let directRecoveryItem = RecoveryItem(
|
||||
id: UUID(),
|
||||
title: directFinding.title,
|
||||
detail: directFinding.detail,
|
||||
originalPath: directTargetURL.path,
|
||||
bytes: directFinding.bytes,
|
||||
deletedAt: Date(),
|
||||
expiresAt: Date().addingTimeInterval(3600),
|
||||
payload: .finding(directFinding),
|
||||
restoreMappings: [RecoveryPathMapping(originalPath: directTargetURL.path, trashedPath: directTrashedPath)]
|
||||
)
|
||||
let helperRecoveryItem = RecoveryItem(
|
||||
id: UUID(),
|
||||
title: helperApp.name,
|
||||
detail: helperApp.bundlePath,
|
||||
originalPath: helperApp.bundlePath,
|
||||
bytes: helperApp.bytes,
|
||||
deletedAt: Date(),
|
||||
expiresAt: Date().addingTimeInterval(3600),
|
||||
payload: .app(helperApp),
|
||||
restoreMappings: [RecoveryPathMapping(originalPath: helperApp.bundlePath, trashedPath: appTrashedPath)]
|
||||
)
|
||||
let state = AtlasWorkspaceState(
|
||||
snapshot: AtlasWorkspaceSnapshot(
|
||||
reclaimableSpaceBytes: 0,
|
||||
findings: [],
|
||||
apps: [],
|
||||
taskRuns: [],
|
||||
recoveryItems: [directRecoveryItem, helperRecoveryItem],
|
||||
permissions: [],
|
||||
healthSnapshot: nil
|
||||
),
|
||||
currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0),
|
||||
settings: AtlasScaffoldWorkspace.state().settings
|
||||
)
|
||||
_ = try repository.saveState(state)
|
||||
|
||||
let worker = AtlasScaffoldWorkerService(
|
||||
repository: repository,
|
||||
helperExecutor: RestoreConflictPrivilegedHelperExecutor(),
|
||||
allowStateOnlyCleanExecution: false
|
||||
)
|
||||
|
||||
let restore = try await worker.submit(
|
||||
AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [directRecoveryItem.id, helperRecoveryItem.id]))
|
||||
)
|
||||
|
||||
guard case let .rejected(code, reason) = restore.response.response else {
|
||||
return XCTFail("Expected rejected restore response")
|
||||
}
|
||||
XCTAssertEqual(code, .restoreConflict)
|
||||
XCTAssertTrue(reason.contains(helperApp.bundlePath))
|
||||
|
||||
XCTAssertTrue(fileManager.fileExists(atPath: directTargetURL.path))
|
||||
XCTAssertFalse(fileManager.fileExists(atPath: directTrashedPath))
|
||||
XCTAssertFalse(fileManager.fileExists(atPath: helperApp.bundlePath))
|
||||
XCTAssertTrue(fileManager.fileExists(atPath: appTrashedPath))
|
||||
|
||||
XCTAssertFalse(restore.snapshot.findings.contains(where: { $0.id == directFinding.id }))
|
||||
XCTAssertFalse(restore.snapshot.apps.contains(where: { $0.id == helperApp.id }))
|
||||
XCTAssertEqual(restore.snapshot.recoveryItems.map(\.id), [directRecoveryItem.id, helperRecoveryItem.id])
|
||||
|
||||
let persisted = repository.loadState()
|
||||
XCTAssertFalse(persisted.snapshot.findings.contains(where: { $0.id == directFinding.id }))
|
||||
XCTAssertFalse(persisted.snapshot.apps.contains(where: { $0.id == helperApp.id }))
|
||||
XCTAssertEqual(persisted.snapshot.recoveryItems.map(\.id), [directRecoveryItem.id, helperRecoveryItem.id])
|
||||
}
|
||||
|
||||
func testExecuteAppUninstallRemovesAppAndCreatesRecoveryEntry() async throws {
|
||||
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
|
||||
let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: true)
|
||||
@@ -1268,21 +966,3 @@ private actor StubPrivilegedHelperExecutor: AtlasPrivilegedActionExecuting {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor RestoreConflictPrivilegedHelperExecutor: AtlasPrivilegedActionExecuting {
|
||||
func perform(_ action: AtlasHelperAction) async throws -> AtlasHelperActionResult {
|
||||
AtlasHelperActionResult(
|
||||
action: action,
|
||||
success: false,
|
||||
message: "Restore destination already exists: \(action.destinationPath ?? "<missing>")"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class TestClock: @unchecked Sendable {
|
||||
var now: Date
|
||||
|
||||
init(now: Date) {
|
||||
self.now = now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import AtlasDomain
|
||||
import Foundation
|
||||
|
||||
public enum AtlasProtocolVersion {
|
||||
public static let current = "0.3.1"
|
||||
public static let current = "0.3.0"
|
||||
}
|
||||
|
||||
public enum AtlasCommand: Codable, Hashable, Sendable {
|
||||
@@ -46,8 +46,6 @@ public enum AtlasProtocolErrorCode: String, Codable, CaseIterable, Hashable, Sen
|
||||
case permissionRequired
|
||||
case helperUnavailable
|
||||
case executionUnavailable
|
||||
case restoreExpired
|
||||
case restoreConflict
|
||||
case invalidSelection
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,6 @@ Download the latest release from the [Releases](https://github.com/CSZHK/CleanMy
|
||||
- **`.zip`** — Extract and move Atlas.app to your Applications folder.
|
||||
- **`.pkg`** — Run the installer package for guided installation.
|
||||
|
||||
Prefer the latest non-prerelease release if you want the normal public install path. GitHub prereleases may contain development-signed builds intended for testing; those builds can require `Open Anyway` or a right-click `Open` flow before launch.
|
||||
|
||||
### Requirements
|
||||
|
||||
- macOS 14.0 (Sonoma) or later
|
||||
@@ -53,7 +51,7 @@ xcodegen generate
|
||||
open Atlas.xcodeproj
|
||||
```
|
||||
|
||||
> **Note**: Atlas release assets can be either `Developer ID signed + notarized` or `development prerelease` builds, depending on the release. If you install a prerelease or a local build, macOS may require `Open Anyway` or a right-click `Open` flow before launch.
|
||||
> **Note**: The app is currently unsigned. On first launch, you may need to right-click and select "Open" to bypass Gatekeeper, or go to System Settings > Privacy & Security to allow it.
|
||||
|
||||
## MVP Modules
|
||||
|
||||
@@ -117,12 +115,9 @@ open Atlas.xcodeproj
|
||||
### Package `.zip`, `.dmg`, and `.pkg` artifacts
|
||||
|
||||
```bash
|
||||
./scripts/atlas/ensure-local-signing-identity.sh
|
||||
./scripts/atlas/package-native.sh
|
||||
```
|
||||
|
||||
The local signing step is recommended on machines that do not have Apple release certificates. It gives local and prerelease builds a stable development signature instead of falling back to ad hoc packaging.
|
||||
|
||||
### Run focused tests
|
||||
|
||||
```bash
|
||||
|
||||
@@ -30,8 +30,6 @@ Atlas for Mac 是一个独立的开源项目,与 Apple、Mole 上游作者或
|
||||
- **`.zip`** - 解压后将 Atlas.app 移动到 Applications 文件夹。
|
||||
- **`.pkg`** - 运行安装包,按向导完成安装。
|
||||
|
||||
如果你想要正常的公开安装路径,优先下载最新的非 `prerelease` 版本。GitHub 上的 `prerelease` 版本可能包含用于测试的开发签名构建,这类构建在首次启动时可能需要 `仍要打开` 或右键 `打开`。
|
||||
|
||||
### 系统要求
|
||||
|
||||
- macOS 14.0(Sonoma)或更高版本
|
||||
@@ -53,7 +51,7 @@ xcodegen generate
|
||||
open Atlas.xcodeproj
|
||||
```
|
||||
|
||||
> **说明**:Atlas 的发布包可能是 `Developer ID 签名 + 公证` 的正式构建,也可能是开发预发布构建,具体取决于该次发布。如果你安装的是预发布版本或本地构建版本,macOS 在首次启动时可能要求你使用 `仍要打开` 或右键 `打开`。
|
||||
> **说明**:应用当前尚未签名。首次启动时,你可能需要右键点击应用并选择“打开”以绕过 Gatekeeper,或前往“系统设置 > 隐私与安全性”手动允许启动。
|
||||
|
||||
## MVP 模块
|
||||
|
||||
@@ -117,12 +115,9 @@ open Atlas.xcodeproj
|
||||
### 打包 `.zip`、`.dmg` 和 `.pkg` 产物
|
||||
|
||||
```bash
|
||||
./scripts/atlas/ensure-local-signing-identity.sh
|
||||
./scripts/atlas/package-native.sh
|
||||
```
|
||||
|
||||
对于没有 Apple 发布证书的机器,建议先执行本地签名初始化。这样本地包和预发布开发包会带有稳定的开发签名,而不是退回到 ad hoc 打包。
|
||||
|
||||
### 运行聚焦测试
|
||||
|
||||
```bash
|
||||
|
||||
25
project.yml
25
project.yml
@@ -21,7 +21,6 @@ schemes:
|
||||
config: Debug
|
||||
gatherCoverageData: false
|
||||
targets:
|
||||
- name: AtlasAppTests
|
||||
- name: AtlasAppUITests
|
||||
run:
|
||||
config: Debug
|
||||
@@ -38,8 +37,8 @@ targets:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.atlasformac.app.worker
|
||||
PRODUCT_NAME: AtlasWorkerXPC
|
||||
MARKETING_VERSION: "1.0.2"
|
||||
CURRENT_PROJECT_VERSION: 3
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
CURRENT_PROJECT_VERSION: 1
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_KEY_CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
INFOPLIST_KEY_CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
@@ -63,9 +62,8 @@ targets:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.atlasformac.app
|
||||
PRODUCT_NAME: Atlas for Mac
|
||||
PRODUCT_MODULE_NAME: AtlasApp
|
||||
MARKETING_VERSION: "1.0.2"
|
||||
CURRENT_PROJECT_VERSION: 3
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
CURRENT_PROJECT_VERSION: 1
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_KEY_CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
INFOPLIST_KEY_CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
@@ -101,21 +99,6 @@ targets:
|
||||
product: AtlasFeaturesSmartClean
|
||||
- package: Packages
|
||||
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:
|
||||
type: bundle.ui-testing
|
||||
platform: macOS
|
||||
|
||||
@@ -21,7 +21,6 @@ APP_SIGNING_KEYCHAIN="$(atlas_resolve_app_signing_keychain "$APP_SIGN_IDENTITY")
|
||||
APP_SIGNING_MODE="$(atlas_signing_mode_for_identity "$APP_SIGN_IDENTITY")"
|
||||
INSTALLER_SIGN_IDENTITY="$(atlas_resolve_installer_signing_identity)"
|
||||
NOTARY_PROFILE="${ATLAS_NOTARY_PROFILE:-}"
|
||||
NOTARY_KEYCHAIN="${ATLAS_NOTARY_KEYCHAIN:-}"
|
||||
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
@@ -123,15 +122,10 @@ echo "Installer package: $PKG_PATH"
|
||||
echo "Checksums: $SHA_PATH"
|
||||
|
||||
if [[ -n "$NOTARY_PROFILE" && "$APP_SIGNING_MODE" == "developer-id" && -n "$INSTALLER_SIGN_IDENTITY" ]]; then
|
||||
notarytool_args=(--keychain-profile "$NOTARY_PROFILE")
|
||||
if [[ -n "$NOTARY_KEYCHAIN" ]]; then
|
||||
notarytool_args+=(--keychain "$NOTARY_KEYCHAIN")
|
||||
fi
|
||||
|
||||
xcrun notarytool submit "$PKG_PATH" "${notarytool_args[@]}" --wait
|
||||
xcrun notarytool submit "$PKG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait
|
||||
xcrun stapler staple "$PKG_PATH"
|
||||
xcrun notarytool submit "$DMG_PATH" "${notarytool_args[@]}" --wait
|
||||
xcrun notarytool submit "$ZIP_PATH" "${notarytool_args[@]}" --wait
|
||||
xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait
|
||||
xcrun notarytool submit "$ZIP_PATH" --keychain-profile "$NOTARY_PROFILE" --wait
|
||||
xcrun stapler staple "$PACKAGED_APP_PATH"
|
||||
/usr/bin/ditto -c -k --sequesterRsrc --keepParent "$PACKAGED_APP_PATH" "$ZIP_PATH"
|
||||
(
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
PROJECT_FILE="$ROOT_DIR/project.yml"
|
||||
APP_MODEL_FILE="$ROOT_DIR/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift"
|
||||
CHANGELOG_FILE="$ROOT_DIR/CHANGELOG.md"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/atlas/prepare-release.sh <version> [build-number] [release-date]
|
||||
|
||||
Examples:
|
||||
./scripts/atlas/prepare-release.sh 1.0.2
|
||||
./scripts/atlas/prepare-release.sh 1.0.2 3
|
||||
./scripts/atlas/prepare-release.sh 1.0.2 3 2026-03-14
|
||||
|
||||
Behavior:
|
||||
- updates MARKETING_VERSION and CURRENT_PROJECT_VERSION in project.yml
|
||||
- updates AtlasApp fallback version/build strings
|
||||
- inserts a changelog section for the requested version if it does not already exist
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ $# -lt 1 || $# -gt 3 ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$1"
|
||||
BUILD_NUMBER="${2:-}"
|
||||
RELEASE_DATE="${3:-$(date +%F)}"
|
||||
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "Invalid version: $VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$BUILD_NUMBER" && ! "$BUILD_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
echo "Build number must be numeric: $BUILD_NUMBER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "$RELEASE_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
||||
echo "Release date must use YYYY-MM-DD: $RELEASE_DATE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$PROJECT_FILE" || ! -f "$APP_MODEL_FILE" || ! -f "$CHANGELOG_FILE" ]]; then
|
||||
echo "Expected release files are missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$BUILD_NUMBER" ]]; then
|
||||
current_build="$(
|
||||
sed -n 's/.*CURRENT_PROJECT_VERSION: \([0-9][0-9]*\).*/\1/p' "$PROJECT_FILE" | head -1
|
||||
)"
|
||||
if [[ -z "$current_build" ]]; then
|
||||
echo "Could not determine current build number from project.yml" >&2
|
||||
exit 1
|
||||
fi
|
||||
BUILD_NUMBER="$((current_build + 1))"
|
||||
fi
|
||||
|
||||
current_version="$(
|
||||
sed -n 's/.*MARKETING_VERSION: "\(.*\)"/\1/p' "$PROJECT_FILE" | head -1
|
||||
)"
|
||||
|
||||
perl -0pi -e 's/MARKETING_VERSION: "[^"]+"/MARKETING_VERSION: "'"$VERSION"'"/g' "$PROJECT_FILE"
|
||||
perl -0pi -e 's/CURRENT_PROJECT_VERSION: \d+/CURRENT_PROJECT_VERSION: '"$BUILD_NUMBER"'/g' "$PROJECT_FILE"
|
||||
perl -0pi -e 's/(CFBundleShortVersionString"\] as\? String \?\? )"[^"]+"/${1}"'"$VERSION"'"/' "$APP_MODEL_FILE"
|
||||
perl -0pi -e 's/(CFBundleVersion"\] as\? String \?\? )"[^"]+"/${1}"'"$BUILD_NUMBER"'"/' "$APP_MODEL_FILE"
|
||||
|
||||
if ! grep -Fq "## [$VERSION] - $RELEASE_DATE" "$CHANGELOG_FILE"; then
|
||||
tmpfile="$(mktemp "${TMPDIR:-/tmp}/atlas-changelog.XXXXXX")"
|
||||
awk -v version="$VERSION" -v date="$RELEASE_DATE" '
|
||||
BEGIN { inserted = 0 }
|
||||
{
|
||||
print
|
||||
if (!inserted && $0 == "## [Unreleased]") {
|
||||
print ""
|
||||
print "## [" version "] - " date
|
||||
print ""
|
||||
print "### Added"
|
||||
print ""
|
||||
print "### Changed"
|
||||
print ""
|
||||
print "### Fixed"
|
||||
print ""
|
||||
inserted = 1
|
||||
}
|
||||
}
|
||||
' "$CHANGELOG_FILE" > "$tmpfile"
|
||||
mv "$tmpfile" "$CHANGELOG_FILE"
|
||||
fi
|
||||
|
||||
printf 'Prepared Atlas release files\n'
|
||||
printf 'Previous version: %s\n' "${current_version:-UNKNOWN}"
|
||||
printf 'New version: %s\n' "$VERSION"
|
||||
printf 'Build number: %s\n' "$BUILD_NUMBER"
|
||||
printf 'Release date: %s\n' "$RELEASE_DATE"
|
||||
printf 'Updated: %s\n' "$PROJECT_FILE"
|
||||
printf 'Updated: %s\n' "$APP_MODEL_FILE"
|
||||
printf 'Updated: %s\n' "$CHANGELOG_FILE"
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
require_env() {
|
||||
local name="$1"
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "Missing required environment variable: $name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
write_env() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
|
||||
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||
printf '%s=%s\n' "$name" "$value" >> "$GITHUB_ENV"
|
||||
else
|
||||
printf 'export %s=%q\n' "$name" "$value"
|
||||
fi
|
||||
}
|
||||
|
||||
append_keychain_search_list() {
|
||||
local keychain_path="$1"
|
||||
local current_keychains=()
|
||||
local line=""
|
||||
|
||||
while IFS= read -r line; do
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
line="${line%\"}"
|
||||
line="${line#\"}"
|
||||
[[ -n "$line" ]] && current_keychains+=("$line")
|
||||
done < <(security list-keychains -d user 2> /dev/null || true)
|
||||
|
||||
if printf '%s\n' "${current_keychains[@]}" | grep -Fx "$keychain_path" > /dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
security list-keychains -d user -s "$keychain_path" "${current_keychains[@]}" > /dev/null
|
||||
}
|
||||
|
||||
decode_base64_to_file() {
|
||||
local encoded="$1"
|
||||
local destination="$2"
|
||||
|
||||
printf '%s' "$encoded" | base64 --decode > "$destination"
|
||||
}
|
||||
|
||||
detect_identity() {
|
||||
local policy="$1"
|
||||
local prefix="$2"
|
||||
local keychain_path="$3"
|
||||
|
||||
security find-identity -v -p "$policy" "$keychain_path" 2> /dev/null |
|
||||
sed -n "s/.*\"\\($prefix.*\\)\"/\\1/p" |
|
||||
head -1
|
||||
}
|
||||
|
||||
require_env ATLAS_RELEASE_APP_CERT_P12_BASE64
|
||||
require_env ATLAS_RELEASE_APP_CERT_P12_PASSWORD
|
||||
require_env ATLAS_RELEASE_INSTALLER_CERT_P12_BASE64
|
||||
require_env ATLAS_RELEASE_INSTALLER_CERT_P12_PASSWORD
|
||||
require_env ATLAS_NOTARY_KEY_ID
|
||||
require_env ATLAS_NOTARY_API_KEY_BASE64
|
||||
|
||||
KEYCHAIN_PATH="${ATLAS_RELEASE_KEYCHAIN_PATH:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}/atlas-release.keychain-db}"
|
||||
KEYCHAIN_PASSWORD="${ATLAS_RELEASE_KEYCHAIN_PASSWORD:-$(uuidgen | tr '[:upper:]' '[:lower:]')}"
|
||||
NOTARY_PROFILE="${ATLAS_NOTARY_PROFILE:-atlas-release}"
|
||||
|
||||
tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/atlas-release-signing.XXXXXX")"
|
||||
cleanup() {
|
||||
rm -rf "$tmpdir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
APP_CERT_PATH="$tmpdir/application-cert.p12"
|
||||
INSTALLER_CERT_PATH="$tmpdir/installer-cert.p12"
|
||||
NOTARY_KEY_PATH="$tmpdir/AuthKey.p8"
|
||||
|
||||
decode_base64_to_file "$ATLAS_RELEASE_APP_CERT_P12_BASE64" "$APP_CERT_PATH"
|
||||
decode_base64_to_file "$ATLAS_RELEASE_INSTALLER_CERT_P12_BASE64" "$INSTALLER_CERT_PATH"
|
||||
decode_base64_to_file "$ATLAS_NOTARY_API_KEY_BASE64" "$NOTARY_KEY_PATH"
|
||||
|
||||
if [[ -f "$KEYCHAIN_PATH" ]]; then
|
||||
rm -f "$KEYCHAIN_PATH"
|
||||
fi
|
||||
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" > /dev/null
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
append_keychain_search_list "$KEYCHAIN_PATH"
|
||||
|
||||
security import "$APP_CERT_PATH" \
|
||||
-k "$KEYCHAIN_PATH" \
|
||||
-P "$ATLAS_RELEASE_APP_CERT_P12_PASSWORD" \
|
||||
-f pkcs12 \
|
||||
-A \
|
||||
-T /usr/bin/codesign \
|
||||
-T /usr/bin/security \
|
||||
-T /usr/bin/productbuild > /dev/null
|
||||
|
||||
security import "$INSTALLER_CERT_PATH" \
|
||||
-k "$KEYCHAIN_PATH" \
|
||||
-P "$ATLAS_RELEASE_INSTALLER_CERT_P12_PASSWORD" \
|
||||
-f pkcs12 \
|
||||
-A \
|
||||
-T /usr/bin/codesign \
|
||||
-T /usr/bin/security \
|
||||
-T /usr/bin/productbuild > /dev/null
|
||||
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" > /dev/null
|
||||
|
||||
APP_IDENTITY="${ATLAS_CODESIGN_IDENTITY:-$(detect_identity codesigning 'Developer ID Application:' "$KEYCHAIN_PATH")}"
|
||||
INSTALLER_IDENTITY="${ATLAS_INSTALLER_SIGN_IDENTITY:-$(detect_identity basic 'Developer ID Installer:' "$KEYCHAIN_PATH")}"
|
||||
|
||||
if [[ -z "$APP_IDENTITY" ]]; then
|
||||
echo "Developer ID Application identity was not imported successfully." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$INSTALLER_IDENTITY" ]]; then
|
||||
echo "Developer ID Installer identity was not imported successfully." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
notarytool_args=(
|
||||
store-credentials
|
||||
"$NOTARY_PROFILE"
|
||||
--key "$NOTARY_KEY_PATH"
|
||||
--key-id "$ATLAS_NOTARY_KEY_ID"
|
||||
--keychain "$KEYCHAIN_PATH"
|
||||
--validate
|
||||
)
|
||||
|
||||
if [[ -n "${ATLAS_NOTARY_ISSUER_ID:-}" ]]; then
|
||||
notarytool_args+=(--issuer "$ATLAS_NOTARY_ISSUER_ID")
|
||||
fi
|
||||
|
||||
xcrun notarytool "${notarytool_args[@]}" > /dev/null
|
||||
|
||||
write_env ATLAS_CODESIGN_KEYCHAIN "$KEYCHAIN_PATH"
|
||||
write_env ATLAS_CODESIGN_IDENTITY "$APP_IDENTITY"
|
||||
write_env ATLAS_INSTALLER_SIGN_IDENTITY "$INSTALLER_IDENTITY"
|
||||
write_env ATLAS_NOTARY_PROFILE "$NOTARY_PROFILE"
|
||||
write_env ATLAS_NOTARY_KEYCHAIN "$KEYCHAIN_PATH"
|
||||
|
||||
printf 'Configured Atlas release signing\n'
|
||||
printf 'App identity: %s\n' "$APP_IDENTITY"
|
||||
printf 'Installer identity: %s\n' "$INSTALLER_IDENTITY"
|
||||
printf 'Notary profile: %s\n' "$NOTARY_PROFILE"
|
||||
printf 'Keychain: %s\n' "$KEYCHAIN_PATH"
|
||||
@@ -7,7 +7,6 @@ source "$ROOT_DIR/scripts/atlas/signing-common.sh"
|
||||
APP_IDENTITY_OVERRIDE="${ATLAS_CODESIGN_IDENTITY:-}"
|
||||
INSTALLER_IDENTITY_OVERRIDE="${ATLAS_INSTALLER_SIGN_IDENTITY:-}"
|
||||
NOTARY_PROFILE_OVERRIDE="${ATLAS_NOTARY_PROFILE:-}"
|
||||
NOTARY_KEYCHAIN_OVERRIDE="${ATLAS_NOTARY_KEYCHAIN:-}"
|
||||
|
||||
codesign_output="$(security find-identity -v -p codesigning 2> /dev/null || true)"
|
||||
basic_output="$(security find-identity -v -p basic 2> /dev/null || true)"
|
||||
@@ -27,9 +26,6 @@ printf '======================\n'
|
||||
printf 'Developer ID Application: %s\n' "${app_identity:-MISSING}"
|
||||
printf 'Developer ID Installer: %s\n' "${installer_identity:-MISSING}"
|
||||
printf 'Notary profile: %s\n' "${NOTARY_PROFILE_OVERRIDE:-MISSING}"
|
||||
if [[ -n "$NOTARY_KEYCHAIN_OVERRIDE" ]]; then
|
||||
printf 'Notary keychain: %s\n' "$NOTARY_KEYCHAIN_OVERRIDE"
|
||||
fi
|
||||
if [[ -n "$local_identity" ]]; then
|
||||
printf 'Stable local app identity: %s\n' "$local_identity"
|
||||
else
|
||||
@@ -51,12 +47,7 @@ if [[ -z "$NOTARY_PROFILE_OVERRIDE" ]]; then
|
||||
fi
|
||||
|
||||
if [[ -n "$NOTARY_PROFILE_OVERRIDE" ]]; then
|
||||
notarytool_args=(history --keychain-profile "$NOTARY_PROFILE_OVERRIDE")
|
||||
if [[ -n "$NOTARY_KEYCHAIN_OVERRIDE" ]]; then
|
||||
notarytool_args+=(--keychain "$NOTARY_KEYCHAIN_OVERRIDE")
|
||||
fi
|
||||
|
||||
if xcrun notarytool "${notarytool_args[@]}" > /dev/null 2>&1; then
|
||||
if xcrun notarytool history --keychain-profile "$NOTARY_PROFILE_OVERRIDE" > /dev/null 2>&1; then
|
||||
echo '✓ notarytool profile is usable'
|
||||
else
|
||||
echo '✗ notarytool profile could not be validated'
|
||||
|
||||
Reference in New Issue
Block a user