1 Commits

Author SHA1 Message Date
zhukang
a5dd042a85 test/docs: freeze recovery credibility contract 2026-03-13 01:06:50 +08:00
32 changed files with 65 additions and 1454 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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 = (

View File

@@ -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">

View File

@@ -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

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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

View File

@@ -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>`

View File

@@ -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`

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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`

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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.";

View File

@@ -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若仍失败请重新安装当前版本。";

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -30,8 +30,6 @@ Atlas for Mac 是一个独立的开源项目,与 Apple、Mole 上游作者或
- **`.zip`** - 解压后将 Atlas.app 移动到 Applications 文件夹。
- **`.pkg`** - 运行安装包,按向导完成安装。
如果你想要正常的公开安装路径,优先下载最新的非 `prerelease` 版本。GitHub 上的 `prerelease` 版本可能包含用于测试的开发签名构建,这类构建在首次启动时可能需要 `仍要打开` 或右键 `打开`
### 系统要求
- macOS 14.0Sonoma或更高版本
@@ -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

View File

@@ -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

View File

@@ -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"
(

View File

@@ -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"

View 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"

View File

@@ -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'