19 Commits
V1.0.2 ... main

Author SHA1 Message Date
zhukang
8116eee6c1 feat(landing): glassmorphism visual upgrade with dark/light theme toggle
Some checks failed
Atlas Acceptance / acceptance (push) Has been cancelled
Atlas Native / build-native (push) Has been cancelled
Landing Page / Build (release) Has been cancelled
Landing Page / Deploy (release) Has been cancelled
Comprehensive visual overhaul of the Atlas Landing Site:
- Dual-theme token system with light mode support via [data-theme="light"]
- Glassmorphism + glow effects across all components (blur, gradient borders, glow shadows)
- Theme toggle in navbar with localStorage persistence and system preference detection
- Hero section: aurora glow orbs, gradient headline, floating screenshot animation
- Animated gradient CTA buttons, glass card hover effects with gradient border reveal
- Stagger scroll reveal animations, noise texture overlay, section gradient backgrounds
- All 18 component files updated, no new files created
2026-03-27 23:17:53 +08:00
github-actions[bot]
7930c73db2 chore: auto format code 2026-03-23 13:57:42 +00:00
zhukang
84a2a2d0b7 ci(release): generate release notes from changelog 2026-03-23 21:56:30 +08:00
zhukang
bdd370ec98 docs(release): record v1.0.3 release outcome 2026-03-23 20:04:25 +08:00
github-actions[bot]
71807bdeb5 chore: auto format code 2026-03-23 11:59:23 +00:00
zhukang
b8259ecfc7 docs(release): refresh v1.0.3 release collateral 2026-03-23 19:57:24 +08:00
zhukang
c6a3086eb5 chore(release): prepare v1.0.3 2026-03-23 19:50:18 +08:00
zhukang
9cd8d593fb ralph-loop[epic-a-to-d-mainline]: iteration 3 2026-03-23 17:40:07 +08:00
zhukang
78ecca3a15 ralph-loop[epic-a-to-d-mainline]: iteration 2 2026-03-23 17:35:05 +08:00
zhukang
0550568a2b ralph-loop[epic-a-to-d-mainline]: checkpoint before iteration 2026-03-23 17:14:09 +08:00
zhukang
889cecd383 feat: add review-only uninstall evidence and container cleanup 2026-03-23 16:48:59 +08:00
zhukang
1f62600532 docs: add selective parity strategy planning 2026-03-23 16:48:26 +08:00
zhukang
92da918281 fix(landing): correct GitHub repo URL from nicekid1 to CSZHK
All repo links were pointing to nicekid1/CleanMyPc which 404s.
The actual repo is CSZHK/CleanMyPc. Fixed in Hero, Footer,
OpenSource, release.ts, release-fallback.json, and fetch-release.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 10:47:38 +08:00
zhukang
c2e2e924f6 fix(landing): use instant meta refresh instead of Astro.redirect
The Astro.redirect() generates a visible "Redirecting from / to /zh/"
message before navigating. Replace with a meta http-equiv refresh for
an instant, invisible redirect from root to the default locale.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 10:39:12 +08:00
zhukang
be949a22b5 fix(ci): include workflow file in landing page trigger paths
Without this, changes to the workflow file itself do not trigger a new
run, since the path filter only matched Apps/LandingSite/**.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 10:32:24 +08:00
zhukang
8ab3b68054 fix(ci): use version tags instead of pinned SHAs for landing page workflow
The pinned SHA references for actions/deploy-pages and other actions
were not resolving correctly, causing the Deploy job to fail. Switch
to @v4 version tags for all GitHub Actions used in the workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 10:30:24 +08:00
zhukang
03fe98c163 feat(landing): add Atlas landing page with Astro static site
Implement the full landing page for atlas.atomstorm.ai per the PRD at
Docs/Execution/Landing-Page-PRD-2026-03-14.md. Includes design spec,
bilingual Astro site, CI pipeline, and all assets.

Key deliverables:
- DESIGN.md: 7-section Atom Web Design specification
- Astro 5.x static site with 15 components and 11 page sections
- Bilingual i18n (zh/en) with path-based routing
- Build-time GitHub release manifest integration
- Release channel state machine (Stable/Prerelease/Coming Soon)
- CSS design tokens mapped from AtlasBrand.swift
- Self-hosted fonts (Space Grotesk, Instrument Sans, IBM Plex Mono)
- OG social sharing images for both locales
- GitHub Actions workflow for GitHub Pages deployment
- Zero client JS, 227KB page weight, 17/17 quality checks passed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 10:18:27 +08:00
zhukang
972ca3589b docs(readme): add prerelease install warning image 2026-03-14 23:04:44 +08:00
github-actions[bot]
2f6689ea27 chore: auto format code 2026-03-14 14:42:17 +00:00
109 changed files with 12130 additions and 189 deletions

71
.github/workflows/landing-page.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: Landing Page
on:
push:
paths:
- 'Apps/LandingSite/**'
- '.github/workflows/landing-page.yml'
branches: [main]
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: Apps/LandingSite/pnpm-lock.yaml
- name: Install dependencies
working-directory: Apps/LandingSite
run: pnpm install --frozen-lockfile
- name: Fetch release manifest
working-directory: Apps/LandingSite
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run fetch-release
- name: Build static site
working-directory: Apps/LandingSite
run: pnpm run build
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: Apps/LandingSite/dist
deploy:
name: Deploy
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -171,15 +171,7 @@ jobs:
- 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
./scripts/atlas/generate-release-body.sh "${GITHUB_REF_NAME#V}" "${{ needs.native.outputs.packaging_mode }}" RELEASE_BODY.md
- name: Display structure of downloaded files
run: ls -R bin/

1
.gitignore vendored
View File

@@ -45,6 +45,7 @@ temp/
.agents/
.gemini/
.kiro/
.ralph-loop/
CLAUDE.md
GEMINI.md
ANTIGRAVITY.md

View File

@@ -100,11 +100,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.3"
}
var appBuild: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "3"
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "4"
}
func checkForUpdate() async {
@@ -236,7 +236,7 @@ final class AtlasAppModel: ObservableObject {
}
var currentSmartCleanPlanHasExecutableTargets: Bool {
let executableItems = currentPlan.items.filter { $0.kind != .inspectPermission }
let executableItems = currentPlan.items.filter { $0.kind != .inspectPermission && $0.kind != .reviewEvidence }
guard !executableItems.isEmpty else {
return false
}
@@ -391,29 +391,7 @@ final class AtlasAppModel: ObservableObject {
}
func refreshApps() async {
guard !isAppActionRunning else {
return
}
selection = .apps
isAppActionRunning = true
activePreviewAppID = nil
activeUninstallAppID = nil
currentAppPreview = nil
currentPreviewedAppID = nil
latestAppsSummary = AtlasL10n.string("model.apps.refreshing")
do {
let output = try await workspaceController.listApps()
withAnimation(.snappy(duration: 0.24)) {
snapshot = output.snapshot
latestAppsSummary = output.summary
}
} catch {
latestAppsSummary = error.localizedDescription
}
isAppActionRunning = false
await reloadAppsInventory(navigateToApps: true, resetPreview: true)
}
func previewAppUninstall(appID: UUID) async {
@@ -473,6 +451,8 @@ final class AtlasAppModel: ObservableObject {
return
}
let restoredItem = snapshot.recoveryItems.first(where: { $0.id == itemID })
let shouldRefreshAppsAfterRestore = restoredItem?.isAppPayload == true
restoringRecoveryItemID = itemID
do {
@@ -481,8 +461,21 @@ final class AtlasAppModel: ObservableObject {
snapshot = output.snapshot
latestScanSummary = output.summary
smartCleanExecutionIssue = nil
if shouldRefreshAppsAfterRestore {
currentAppPreview = nil
currentPreviewedAppID = nil
latestAppsSummary = output.summary
}
}
if shouldRefreshAppsAfterRestore {
await reloadAppsInventory(
navigateToApps: false,
resetPreview: true,
loadingSummary: output.summary
)
} else {
await refreshPlanPreview()
}
await refreshPlanPreview()
} catch {
let persistedState = repository.loadState()
withAnimation(.snappy(duration: 0.24)) {
@@ -605,6 +598,40 @@ final class AtlasAppModel: ObservableObject {
}
}
private func reloadAppsInventory(
navigateToApps: Bool,
resetPreview: Bool,
loadingSummary: String? = nil
) async {
guard !isAppActionRunning else {
return
}
if navigateToApps {
selection = .apps
}
isAppActionRunning = true
activePreviewAppID = nil
activeUninstallAppID = nil
if resetPreview {
currentAppPreview = nil
currentPreviewedAppID = nil
}
latestAppsSummary = loadingSummary ?? AtlasL10n.string("model.apps.refreshing")
do {
let output = try await workspaceController.listApps()
withAnimation(.snappy(duration: 0.24)) {
snapshot = output.snapshot
latestAppsSummary = output.summary
}
} catch {
latestAppsSummary = error.localizedDescription
}
isAppActionRunning = false
}
private func filter<Element>(
_ elements: [Element],
route: AtlasRoute,
@@ -627,6 +654,15 @@ final class AtlasAppModel: ObservableObject {
}
}
private extension RecoveryItem {
var isAppPayload: Bool {
if case .app = payload {
return true
}
return false
}
}
private extension AtlasAppModel {
func resolvedTargetPaths(for item: ActionItem) -> [String] {
if let targetPaths = item.targetPaths, !targetPaths.isEmpty {

View File

@@ -63,9 +63,9 @@ private struct AtlasReadmeAssetExporter {
AtlasL10n.setCurrentLanguage(screenshotLanguage)
let state = AtlasScaffoldWorkspace.state(language: screenshotLanguage)
let canExecuteSmartCleanPlan = state.currentPlan.items.contains(where: { $0.kind != .inspectPermission })
let canExecuteSmartCleanPlan = state.currentPlan.items.contains(where: { $0.kind != .inspectPermission && $0.kind != .reviewEvidence })
&& state.currentPlan.items
.filter { $0.kind != .inspectPermission }
.filter { $0.kind != .inspectPermission && $0.kind != .reviewEvidence }
.allSatisfy { !($0.targetPaths ?? []).isEmpty }
try exportAppIcon()

View File

@@ -44,6 +44,40 @@ final class AtlasAppModelTests: XCTestCase {
XCTAssertFalse(model.canExecuteCurrentSmartCleanPlan)
}
func testReviewEvidenceItemsDoNotMakeSmartCleanPlanExecutable() {
let repository = makeRepository()
let state = AtlasWorkspaceState(
snapshot: AtlasWorkspaceSnapshot(
reclaimableSpaceBytes: 0,
findings: [],
apps: [],
taskRuns: [],
recoveryItems: [],
permissions: [],
healthSnapshot: nil
),
currentPlan: ActionPlan(
title: "Review only",
items: [
ActionItem(
title: "Review caches (1)",
detail: "Found 12 KB across 1 item.",
kind: .reviewEvidence,
recoverable: false,
evidencePaths: ["/Users/test/Library/Caches/com.example"]
)
],
estimatedBytes: 12
),
settings: AtlasScaffoldWorkspace.state().settings
)
_ = try? repository.saveState(state)
let model = AtlasAppModel(repository: repository, workerService: AtlasScaffoldWorkerService(allowStateOnlyCleanExecution: true))
XCTAssertFalse(model.currentSmartCleanPlanHasExecutableTargets)
XCTAssertFalse(model.canExecuteCurrentSmartCleanPlan)
}
func testRunSmartCleanScanMarksPlanAsFreshForCurrentSession() async throws {
let repository = makeRepository()
let worker = AtlasScaffoldWorkerService(
@@ -151,6 +185,67 @@ final class AtlasAppModelTests: XCTestCase {
XCTAssertEqual(model.latestAppsSummary, AtlasL10n.string("application.apps.loaded.one"))
}
func testPreviewAppUninstallStoresEvidenceBackedPlan() async throws {
let repository = makeRepository()
let fileManager = FileManager.default
let sandboxRoot = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let homeRoot = sandboxRoot.appendingPathComponent("Home", isDirectory: true)
let appSupportURL = homeRoot.appendingPathComponent("Library/Application Support/Sample App", isDirectory: true)
let cacheURL = homeRoot.appendingPathComponent("Library/Caches/com.example.sample", isDirectory: true)
try fileManager.createDirectory(at: appSupportURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: cacheURL, withIntermediateDirectories: true)
try Data(repeating: 0x1, count: 64).write(to: appSupportURL.appendingPathComponent("settings.json"))
try Data(repeating: 0x2, count: 64).write(to: cacheURL.appendingPathComponent("cache.bin"))
addTeardownBlock {
try? FileManager.default.removeItem(at: sandboxRoot)
}
let app = AppFootprint(
id: UUID(),
name: "Sample App",
bundleIdentifier: "com.example.sample",
bundlePath: "/Applications/Sample App.app",
bytes: 2_048_000_000,
leftoverItems: 2
)
var settings = AtlasScaffoldWorkspace.state().settings
settings.language = .en
settings.acknowledgementText = AtlasL10n.acknowledgement(language: .en)
settings.thirdPartyNoticesText = AtlasL10n.thirdPartyNotices(language: .en)
let state = AtlasWorkspaceState(
snapshot: AtlasWorkspaceSnapshot(
reclaimableSpaceBytes: 0,
findings: [],
apps: [app],
taskRuns: [],
recoveryItems: [],
permissions: [],
healthSnapshot: nil
),
currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0),
settings: settings
)
_ = try repository.saveState(state)
let worker = AtlasScaffoldWorkerService(
repository: repository,
appUninstallEvidenceAnalyzer: AtlasAppUninstallEvidenceAnalyzer(homeDirectoryURL: homeRoot),
allowStateOnlyCleanExecution: true
)
let model = AtlasAppModel(repository: repository, workerService: worker)
await model.previewAppUninstall(appID: app.id)
XCTAssertEqual(model.currentPreviewedAppID, app.id)
XCTAssertEqual(model.currentAppPreview?.items.count, 3)
XCTAssertTrue(model.currentAppPreview?.items.dropFirst().allSatisfy { !$0.recoverable } == true)
XCTAssertEqual(model.latestAppsSummary, AtlasL10n.string("application.apps.previewUpdated", "Uninstall Sample App"))
}
func testRestoreRecoveryItemReturnsFindingToWorkspace() async throws {
let repository = makeRepository()
let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: true)
@@ -189,6 +284,72 @@ final class AtlasAppModelTests: XCTestCase {
XCTAssertNil(model.smartCleanExecutionIssue)
}
func testRestoreAppRecoveryItemClearsPreviewAndRefreshesInventoryWithoutLeavingHistory() async throws {
let repository = makeRepository()
let app = AppFootprint(
id: UUID(),
name: "Recovered App",
bundleIdentifier: "com.example.recovered",
bundlePath: "/Applications/Recovered App.app",
bytes: 2_048,
leftoverItems: 9
)
let recoveryItem = RecoveryItem(
id: UUID(),
title: app.name,
detail: "Restorable app payload",
originalPath: app.bundlePath,
bytes: app.bytes,
deletedAt: Date(),
expiresAt: Date().addingTimeInterval(3600),
payload: .app(
AtlasAppRecoveryPayload(
app: app,
uninstallEvidence: AtlasAppUninstallEvidence(
bundlePath: app.bundlePath,
bundleBytes: app.bytes,
reviewOnlyGroups: []
)
)
),
restoreMappings: nil
)
let state = AtlasWorkspaceState(
snapshot: AtlasWorkspaceSnapshot(
reclaimableSpaceBytes: 0,
findings: [],
apps: [app],
taskRuns: [],
recoveryItems: [recoveryItem],
permissions: [],
healthSnapshot: nil
),
currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0),
settings: AtlasScaffoldWorkspace.state().settings
)
_ = try repository.saveState(state)
let worker = AtlasScaffoldWorkerService(
repository: repository,
appsInventoryProvider: RestoredInventoryProvider()
)
let model = AtlasAppModel(repository: repository, workerService: worker)
await model.previewAppUninstall(appID: app.id)
XCTAssertNotNil(model.currentAppPreview)
XCTAssertEqual(model.currentPreviewedAppID, app.id)
model.navigate(to: .history)
await model.restoreRecoveryItem(recoveryItem.id)
XCTAssertEqual(model.selection, .history)
XCTAssertNil(model.currentAppPreview)
XCTAssertNil(model.currentPreviewedAppID)
XCTAssertEqual(model.snapshot.apps.first?.leftoverItems, 1)
XCTAssertEqual(model.latestAppsSummary, AtlasL10n.string("application.apps.loaded.one"))
XCTAssertFalse(model.snapshot.recoveryItems.contains(where: { $0.id == recoveryItem.id }))
}
func testRestoreExpiredRecoveryItemReloadsPersistedState() async throws {
let baseDate = Date(timeIntervalSince1970: 1_710_000_000)
let clock = TestClock(now: baseDate)
@@ -401,6 +562,20 @@ private struct FakeInventoryProvider: AtlasAppInventoryProviding {
}
}
private struct RestoredInventoryProvider: AtlasAppInventoryProviding {
func collectInstalledApps() async throws -> [AppFootprint] {
[
AppFootprint(
name: "Recovered App",
bundleIdentifier: "com.example.recovered",
bundlePath: "/Applications/Recovered App.app",
bytes: 2_048,
leftoverItems: 1
)
]
}
}
private struct FailingSmartCleanProvider: AtlasSmartCleanScanProviding {
func collectSmartCleanScan() async throws -> AtlasSmartCleanScanResult {
throw NSError(domain: "AtlasAppModelTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "Fixture scan failed."])

4
Apps/LandingSite/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.astro/
src/data/release-manifest.json

988
Apps/LandingSite/DESIGN.md Normal file
View File

@@ -0,0 +1,988 @@
# Atlas Landing Page — Atom Web Design Specification
> **Domain**: `atlas.atomstorm.ai` | **Deploy**: GitHub Pages | **Source**: `Apps/LandingSite/`
> **PRD**: `Docs/Execution/Landing-Page-PRD-2026-03-14.md`
> **Date**: 2026-03-15
---
## Table of Contents
1. [Product Definition](#1-product-definition)
2. [Tech Stack Decision](#2-tech-stack-decision)
3. [Data Architecture](#3-data-architecture)
4. [Component Architecture](#4-component-architecture)
5. [Constraint System](#5-constraint-system)
6. [Visual Design System](#6-visual-design-system)
7. [Quality Gates](#7-quality-gates)
---
## 1. Product Definition
### 1.1 User Stories
| ID | Story | Acceptance Criteria |
|----|-------|-------------------|
| US-01 | As a first-time visitor, I want to understand what Atlas does in one screen so I can decide if it solves my problem. | Hero section communicates product promise in < 10 seconds; headline + subheadline + screenshot visible above the fold. |
| US-02 | As a cautious Mac user, I want to see safety and trust signals so I can decide whether Atlas is safe enough to install. | Trust strip, open-source badge, recovery-first messaging, permissions explanation, and Gatekeeper guidance are all visible without deep scrolling. |
| US-03 | As a developer, I want to see that Atlas understands developer disk pressure (Xcode, simulators, caches) so I know it is relevant to my workflow. | Developer cleanup section lists concrete developer artifacts (derived data, simulators, package caches) with Atlas-specific handling. |
| US-04 | As a potential downloader, I want to get the latest build without navigating GitHub so I can install Atlas quickly. | Primary CTA links to the correct release asset; version number, channel badge, and release date are visible next to the CTA. |
| US-05 | As a visitor encountering a prerelease, I want honest disclosure about signing status and Gatekeeper friction so I can install without confusion. | Prerelease badge, warning label, and "Open Anyway" recovery path are shown when the latest release is not Developer ID signed. |
### 1.2 MVP Scope
| Included | Deferred |
|----------|----------|
| Single bilingual single-page site (`/zh/`, `/en/`) | Blog / CMS |
| Responsive hero with release-state CTA | Changelog microsite |
| 11 page sections (Hero → Footer) | Testimonials from named users |
| Screenshot gallery (46 images) | Interactive benchmark calculator |
| Dynamic release state block (build-time) | Gated PDF lead magnet |
| Trust/safety section with Gatekeeper guidance | Multi-page docs hub |
| FAQ with expand/collapse | Account system |
| GitHub Pages deployment with custom domain | Pricing or checkout flow |
| Privacy-respecting analytics (Plausible) | In-browser disk scan demo |
| Optional beta email capture (3rd-party form) | |
### 1.3 Release Channel State Machine
The page must treat release channel status as product truth. Three states drive CTA behavior:
```
┌─────────────────────┐
│ No Public Release │ ← no GitHub Release with assets
│ CTA: "View on │
│ GitHub" / "Join │
│ Beta Updates" │
└──────────┬──────────┘
│ first release published
┌─────────────────────┐
│ Prerelease Only │ ← prerelease=true on latest release
│ CTA: "Download │
│ Prerelease" │
│ + warning label │
│ + Gatekeeper note │
└──────────┬──────────┘
│ Developer ID signing configured
┌─────────────────────┐
│ Stable Release │ ← prerelease=false on latest release
│ CTA: "Download │
│ for macOS" │
│ + version badge │
└─────────────────────┘
```
**State × CTA Behavior Matrix**:
| State | Primary CTA Label | Badge | Warning | Secondary CTA |
|-------|-------------------|-------|---------|---------------|
| Stable | Download for macOS | `Stable` (teal) | None | View on GitHub |
| Prerelease | Download Prerelease | `Prerelease` (amber) | Gatekeeper install note | View on GitHub |
| No Release | View on GitHub | `Coming Soon` (slate) | None | Join Beta Updates |
---
## 2. Tech Stack Decision
### 2.1 Stack Table
| Layer | Choice | Why |
|-------|--------|-----|
| **Framework** | Astro 5.x (static adapter) | Outputs pure HTML/CSS with zero JS by default; matches PRD's "static-first" requirement |
| **Styling** | Vanilla CSS + custom properties | 1:1 token mapping from `AtlasBrand.swift`; avoids Tailwind's generic aesthetic per PRD's "not generic SaaS" direction |
| **i18n** | `@astrojs/i18n` path-based routing | Native Astro feature; produces `/en/` and `/zh/` paths with `hreflang` tags |
| **Fonts** | Self-hosted (Space Grotesk, Instrument Sans, IBM Plex Mono) | Eliminates Google Fonts dependency; keeps < 20KB JS budget; GDPR-safe |
| **Analytics** | Plausible (self-hosted or cloud) | Privacy-respecting, cookie-free, GDPR-compliant; custom events for CTA/FAQ tracking |
| **Search Console** | Google Search Console | Indexing monitoring and query analysis; no client-side impact |
| **Hosting** | GitHub Pages | Free, reliable, native GitHub Actions integration; custom domain with HTTPS |
| **CI/CD** | GitHub Actions (`.github/workflows/landing-page.yml`) | Triggers on source changes + release events; deploys via `actions/deploy-pages` |
| **Release Data** | Build-time GitHub API fetch → static manifest | No client-side API dependency for first paint; fallback to embedded JSON |
| **UI Framework** | None (no React/Vue/Svelte) | Zero framework overhead; `.astro` components render to static HTML |
| **Package Manager** | pnpm | Fast, deterministic, disk-efficient |
### 2.2 Key Technical Decisions
1. **No UI framework islands** — Every component is a `.astro` file that renders to static HTML. The only client JS is: language toggle persistence (`localStorage`, < 200 bytes), FAQ accordion (`<details>` elements with optional progressive enhancement), and Plausible analytics snippet (< 1KB).
2. **Self-hosted fonts** — Font files are committed to `Apps/LandingSite/public/fonts/`. Subset to Latin + CJK ranges. Use `font-display: swap` for all faces.
3. **Build-time release manifest** — A `scripts/fetch-release.ts` script runs at build time to query the GitHub Releases API and emit `src/data/release-manifest.json`. A static fallback (`src/data/release-fallback.json`) is used if the API call fails.
---
## 3. Data Architecture
### 3.1 `ReleaseManifest` Schema
```typescript
/**
* Generated at build time by scripts/fetch-release.ts
* Consumed by Hero, CTA, and Footer components.
* File: src/data/release-manifest.json
*/
interface ReleaseManifest {
/** Release channel: determines CTA behavior and badge */
channel: "stable" | "prerelease" | "none";
/** Semantic version string, e.g. "1.0.2" */
version: string | null;
/** ISO 8601 date string of the release publication */
publishedAt: string | null;
/** GitHub Release page URL */
releaseUrl: string | null;
/** Direct download asset links */
assets: {
dmg: string | null;
zip: string | null;
pkg: string | null;
sha256: string | null;
};
/** Whether Gatekeeper friction is expected */
gatekeeperWarning: boolean;
/** Human-readable install note for prerelease builds */
installNote: string | null;
/** Git tag name, e.g. "V1.0.2" */
tagName: string | null;
/** Timestamp of manifest generation (ISO 8601) */
generatedAt: string;
}
```
**Priority chain** (per PRD):
1. Build-time generated `release-manifest.json` via `scripts/fetch-release.ts`
2. Static fallback `src/data/release-fallback.json` (committed, manually maintained)
3. No client-side GitHub API fetch for first paint
**Manifest generation logic** (`scripts/fetch-release.ts`):
```
1. Fetch latest release from GitHub API (repos/{owner}/{repo}/releases/latest)
2. If no release exists → channel: "none"
3. If release.prerelease === true → channel: "prerelease", gatekeeperWarning: true
4. If release.prerelease === false → channel: "stable", gatekeeperWarning: false
5. Extract .dmg, .zip, .pkg, .sha256 from release.assets[]
6. Write to src/data/release-manifest.json
```
### 3.2 `LandingCopy` i18n Schema
```typescript
/**
* Translation file structure.
* Files: src/i18n/en.json, src/i18n/zh.json
* Keys are grouped by page section for maintainability.
*/
interface LandingCopy {
meta: {
title: string; // <title> and og:title
description: string; // <meta name="description"> and og:description
ogImage: string; // og:image path
};
nav: {
whyAtlas: string;
howItWorks: string;
developers: string;
safety: string;
faq: string;
download: string; // CTA label (dynamic, overridden by channel)
};
hero: {
headline: string;
subheadline: string;
ctaPrimary: string; // Overridden by channel state
ctaSecondary: string; // "View on GitHub"
badgeStable: string;
badgePrerelease: string;
badgeComingSoon: string;
prereleaseWarning: string;
gatekeeperNote: string;
versionLabel: string; // "Version {version} · {date}"
};
trustStrip: {
openSource: string;
recoveryFirst: string;
developerAware: string;
macNative: string;
directDownload: string;
};
problem: {
sectionTitle: string;
scenarios: Array<{
before: string; // Pain point
after: string; // Atlas outcome
}>;
};
features: {
sectionTitle: string;
cards: Array<{
title: string;
value: string; // User-facing value proposition
example: string; // Concrete example
trustCue: string; // Trust signal
}>;
};
howItWorks: {
sectionTitle: string;
steps: Array<{
label: string;
description: string;
}>;
};
developer: {
sectionTitle: string;
subtitle: string;
items: Array<{
title: string;
description: string;
}>;
};
safety: {
sectionTitle: string;
subtitle: string;
points: Array<{
title: string;
description: string;
}>;
gatekeeperGuide: {
title: string;
steps: string[];
};
};
screenshots: {
sectionTitle: string;
items: Array<{
src: string;
alt: string;
caption: string;
}>;
};
openSource: {
sectionTitle: string;
repoLabel: string;
licenseLabel: string;
attributionLabel: string;
changelogLabel: string;
};
faq: {
sectionTitle: string;
items: Array<{
question: string;
answer: string;
}>;
};
footer: {
download: string;
github: string;
documentation: string;
privacy: string;
security: string;
copyright: string;
};
}
```
### 3.3 State Management Map
All state is resolved at build time. The only client-side state is:
| State | Scope | Storage | Purpose |
|-------|-------|---------|---------|
| Language preference | Session | `localStorage` key `atlas-lang` | Remember manual language switch |
| FAQ expanded items | Transient | DOM (`<details open>`) | No persistence needed |
| Release data | Build-time | `release-manifest.json` | Embedded in HTML at build |
| Analytics events | Fire-and-forget | Plausible JS SDK | No local state |
---
## 4. Component Architecture
### 4.1 File Structure
```
Apps/LandingSite/
├── astro.config.mjs # Astro config: static adapter, i18n, site URL
├── package.json # Dependencies: astro, @astrojs/sitemap
├── pnpm-lock.yaml
├── tsconfig.json
├── public/
│ ├── fonts/ # Self-hosted font files (woff2)
│ │ ├── SpaceGrotesk-Bold.woff2
│ │ ├── SpaceGrotesk-Medium.woff2
│ │ ├── InstrumentSans-Regular.woff2
│ │ ├── InstrumentSans-Medium.woff2
│ │ ├── IBMPlexMono-Regular.woff2
│ │ └── IBMPlexMono-Medium.woff2
│ ├── images/
│ │ ├── atlas-icon.png # App icon (from Docs/Media/README/)
│ │ ├── og-image-en.png # Open Graph image (English)
│ │ ├── og-image-zh.png # Open Graph image (Chinese)
│ │ └── screenshots/ # Product screenshots
│ │ ├── atlas-overview.png
│ │ ├── atlas-smart-clean.png
│ │ ├── atlas-apps.png
│ │ ├── atlas-history.png
│ │ ├── atlas-settings.png
│ │ └── atlas-prerelease-warning.png
│ ├── favicon.ico
│ └── robots.txt
├── src/
│ ├── data/
│ │ ├── release-manifest.json # Build-time generated
│ │ └── release-fallback.json # Static fallback (committed)
│ ├── i18n/
│ │ ├── en.json # English translations
│ │ ├── zh.json # Chinese translations
│ │ └── utils.ts # t() helper, locale detection
│ ├── styles/
│ │ ├── tokens.css # Design tokens as CSS custom properties
│ │ ├── reset.css # Minimal CSS reset
│ │ ├── global.css # Global styles (fonts, base elements)
│ │ └── utilities.css # Utility classes (sr-only, container, etc.)
│ ├── layouts/
│ │ └── BaseLayout.astro # HTML shell: <head>, meta, fonts, analytics
│ ├── components/
│ │ ├── NavBar.astro # [interactive] Sticky top nav + language toggle + CTA
│ │ ├── Hero.astro # [static] Headline, subheadline, CTA, badge, screenshot
│ │ ├── TrustStrip.astro # [static] Five trust signal pills
│ │ ├── ProblemOutcome.astro # [static] Three pain → solution cards
│ │ ├── FeatureGrid.astro # [static] Six feature story cards
│ │ ├── HowItWorks.astro # [static] Four-step workflow visualization
│ │ ├── DeveloperSection.astro # [static] Developer cleanup showcase
│ │ ├── SafetySection.astro # [static] Permissions, trust, Gatekeeper guide
│ │ ├── ScreenshotGallery.astro# [interactive] Desktop gallery / mobile carousel
│ │ ├── OpenSourceSection.astro# [static] Repo link, license, attribution
│ │ ├── FaqSection.astro # [interactive] Expandable Q&A using <details>
│ │ ├── FooterSection.astro # [static] Links, privacy, security contact
│ │ ├── CtaButton.astro # [static] Reusable CTA (primary/secondary variants)
│ │ ├── ChannelBadge.astro # [static] Release channel badge (stable/prerelease/coming)
│ │ └── FeatureCard.astro # [static] Reusable card for feature grid
│ └── pages/
│ ├── index.astro # Root redirect → /zh/
│ ├── zh/
│ │ └── index.astro # Chinese landing page
│ └── en/
│ └── index.astro # English landing page
└── scripts/
└── fetch-release.ts # Build-time script: GitHub API → release-manifest.json
```
### 4.2 Component Interaction Map
```
BaseLayout.astro
└── [lang]/index.astro
├── NavBar.astro .................. [interactive: language toggle, mobile menu]
│ ├── CtaButton.astro props: { label, href, variant: "primary" }
│ └── ChannelBadge.astro props: { channel }
├── Hero.astro .................... [static]
│ ├── CtaButton.astro props: { label, href, variant: "primary" }
│ ├── CtaButton.astro props: { label, href, variant: "secondary" }
│ └── ChannelBadge.astro props: { channel, version, date }
├── TrustStrip.astro .............. [static]
├── ProblemOutcome.astro .......... [static]
├── FeatureGrid.astro ............. [static]
│ └── FeatureCard.astro (×6) props: { title, value, example, trustCue, icon }
├── HowItWorks.astro .............. [static]
├── DeveloperSection.astro ........ [static]
├── SafetySection.astro ........... [static]
├── ScreenshotGallery.astro ....... [interactive: carousel on mobile]
├── OpenSourceSection.astro ....... [static]
├── FaqSection.astro .............. [interactive: <details> expand/collapse]
└── FooterSection.astro ........... [static]
└── CtaButton.astro props: { label, href, variant: "primary" }
```
**Boundary annotations**:
- `[static]` — Pure HTML at build time, zero client JS
- `[interactive]` — Minimal client JS via inline `<script>` in the component (no framework island)
### 4.3 Component Props Summary
| Component | Props | Data Source |
|-----------|-------|-------------|
| `CtaButton` | `label: string, href: string, variant: "primary" \| "secondary" \| "ghost"` | i18n + manifest |
| `ChannelBadge` | `channel: "stable" \| "prerelease" \| "none", version?: string, date?: string` | manifest |
| `FeatureCard` | `title: string, value: string, example: string, trustCue: string, icon: string` | i18n |
| `NavBar` | `locale: "en" \| "zh", manifest: ReleaseManifest` | i18n + manifest |
| `Hero` | `locale: "en" \| "zh", manifest: ReleaseManifest` | i18n + manifest |
| `FaqSection` | `items: Array<{ question: string, answer: string }>` | i18n |
| `ScreenshotGallery` | `items: Array<{ src: string, alt: string, caption: string }>` | i18n |
---
## 5. Constraint System
### 5.1 NEVER Rules
| # | Category | Rule |
|---|----------|------|
| N-01 | Brand | NEVER use the `Mole` brand name in any user-facing text, metadata, or alt text. |
| N-02 | Brand | NEVER claim malware protection, antivirus behavior, or security scanning capability. |
| N-03 | Brand | NEVER overstate physical recovery coverage — always qualify with "when supported". |
| N-04 | Brand | NEVER imply all releases are Apple-signed if the current release is a prerelease. |
| N-05 | Security | NEVER include hardcoded GitHub tokens, API keys, or secrets in client-facing code. |
| N-06 | Security | NEVER use client-side GitHub API fetches for critical first-paint release information. |
| N-07 | Security | NEVER rely on a manually committed `CNAME` file when using a custom GitHub Actions Pages workflow. |
| N-08 | Performance | NEVER ship a client JS bundle exceeding 20KB (excluding analytics). |
| N-09 | Performance | NEVER load fonts from external CDNs (Google Fonts, etc.). |
| N-10 | Performance | NEVER use framework islands (React, Vue, Svelte) for any component. |
| N-11 | Aesthetics | NEVER use generic SaaS gradients, purple-heavy palettes, or interchangeable startup layouts. |
| N-12 | Aesthetics | NEVER use endless floating particles, decorative animation loops, or bouncy spring motion. |
| N-13 | Copy | NEVER use hype words: "ultimate", "magic", "AI cleaner", "revolutionary", "blazing fast". |
| N-14 | Copy | NEVER use fear-based maintenance language: "Your Mac is at risk", "Critical error", "You must allow this". |
### 5.2 ALWAYS Rules
| # | Category | Rule |
|---|----------|------|
| A-01 | Disclosure | ALWAYS show exact version number and release date next to the download CTA. |
| A-02 | Disclosure | ALWAYS show a channel badge (`Stable`, `Prerelease`, or `Coming Soon`) next to the CTA. |
| A-03 | Disclosure | ALWAYS show a Gatekeeper install note when the release is a prerelease. |
| A-04 | Accessibility | ALWAYS maintain WCAG 2.1 AA contrast ratios (4.5:1 for normal text, 3:1 for large text). |
| A-05 | Accessibility | ALWAYS provide alt text for every image; screenshot alt text must describe the UI state shown. |
| A-06 | Accessibility | ALWAYS ensure all interactive elements are keyboard-navigable with visible focus indicators. |
| A-07 | i18n | ALWAYS include `hreflang` tags on both `/en/` and `/zh/` pages. |
| A-08 | i18n | ALWAYS serve localized `<title>`, `<meta description>`, and Open Graph metadata per locale. |
| A-09 | Testing | ALWAYS validate HTML output with the W3C validator before each deploy. |
| A-10 | Testing | ALWAYS run Lighthouse CI on both locales before merging to main. |
| A-11 | SEO | ALWAYS use crawlable `<h1>``<h6>` headings; never render hero text only inside images. |
| A-12 | Copy | ALWAYS qualify recovery claims with "when supported" or "while the retention window is open". |
| A-13 | Copy | ALWAYS use concrete verbs for CTAs: `Scan`, `Review`, `Restore`, `Download`. |
### 5.3 Naming Conventions
| Entity | Convention | Example |
|--------|-----------|---------|
| CSS custom property | `--atlas-{category}-{name}` | `--atlas-color-brand` |
| Component file | PascalCase `.astro` | `FeatureCard.astro` |
| CSS class | BEM-lite: `block__element--modifier` | `hero__cta--primary` |
| i18n key | dot-separated section path | `hero.headline` |
| Image file | kebab-case, descriptive | `atlas-overview.png` |
| Script file | kebab-case `.ts` | `fetch-release.ts` |
| Data file | kebab-case `.json` | `release-manifest.json` |
---
## 6. Visual Design System
### 6.1 Design Thinking — Four Questions
**Q1: Who is viewing this page?**
Mac users with disk pressure — both mainstream and developers — evaluating Atlas as an alternative to opaque commercial cleanup apps. They are cautious, technically aware, and skeptical of "magic cleaner" marketing.
**Q2: What should they feel?**
"This tool understands my Mac and will be honest with me." Calm authority, not hype. Precision, not spectacle. The page should feel like a modern macOS-native operations console translated into a polished marketing surface.
**Q3: What is the single most important action?**
Download the latest release (or, if prerelease, understand the install friction and proceed anyway).
**Q4: What could go wrong?**
- User mistakes a prerelease for a stable release → mitigated by mandatory channel badge and warning
- User bounces because the page looks like generic SaaS → mitigated by dark "precision utility" theme with native Mac feel
- User can't find the download → mitigated by persistent CTA in nav + hero + footer
### 6.2 CSS Custom Properties — Design Tokens
All values are derived from `AtlasBrand.swift` and the Xcode color assets. The landing page uses a **dark-only** theme per PRD direction.
```css
/* ══════════════════════════════════════════════════════
Atlas Landing Page — Design Tokens
Source of truth: AtlasBrand.swift + AtlasColors.xcassets
Theme: "Precision Utility" (dark-only)
══════════════════════════════════════════════════════ */
:root {
/* ── Colors: Background ──────────────────────────── */
--atlas-color-bg-base: #0D0F11; /* Graphite / near-black */
--atlas-color-bg-surface: #1A1D21; /* Warm slate cards */
--atlas-color-bg-surface-hover: #22262B; /* Card hover state */
--atlas-color-bg-raised: rgba(255, 255, 255, 0.06); /* Glassmorphic tint (matches AtlasColor.cardRaised dark) */
--atlas-color-bg-code: #151820; /* Code block background */
/* ── Colors: Brand ───────────────────────────────── */
/*
* AtlasBrand.colorset:
* Light: sRGB(0.0588, 0.4627, 0.4314) = #0F766E
* Dark: sRGB(0.0784, 0.5647, 0.5216) = #149085
*
* Landing page uses the dark variant as primary.
*/
--atlas-color-brand: #149085; /* AtlasBrand dark — primary teal */
--atlas-color-brand-light: #0F766E; /* AtlasBrand light — used for hover contrast */
--atlas-color-brand-glow: rgba(20, 144, 133, 0.25); /* CTA shadow glow */
/* ── Colors: Accent ──────────────────────────────── */
/*
* AtlasAccent.colorset:
* Light: sRGB(0.2039, 0.8275, 0.6000) = #34D399
* Dark: sRGB(0.3216, 0.8863, 0.7098) = #52E2B5
*/
--atlas-color-accent: #34D399; /* AtlasAccent light — mint highlight */
--atlas-color-accent-bright: #52E2B5; /* AtlasAccent dark — brighter mint */
/* ── Colors: Semantic ────────────────────────────── */
--atlas-color-success: #22C55E; /* systemGreen equivalent */
--atlas-color-warning: #F59E0B; /* Amber — prerelease/caution states */
--atlas-color-danger: #EF4444; /* systemRed equivalent */
--atlas-color-info: #3B82F6; /* systemBlue equivalent */
/* ── Colors: Text ────────────────────────────────── */
--atlas-color-text-primary: #F1F5F9; /* High contrast on dark bg */
--atlas-color-text-secondary: #94A3B8; /* Muted body text */
--atlas-color-text-tertiary: rgba(148, 163, 184, 0.6); /* Footnotes, timestamps (matches AtlasColor.textTertiary) */
/* ── Colors: Border ──────────────────────────────── */
--atlas-color-border: rgba(241, 245, 249, 0.08); /* Subtle (matches AtlasColor.border) */
--atlas-color-border-emphasis: rgba(241, 245, 249, 0.14); /* Focus/prominent (matches AtlasColor.borderEmphasis) */
/* ── Typography ──────────────────────────────────── */
--atlas-font-display: 'Space Grotesk', system-ui, sans-serif;
--atlas-font-body: 'Instrument Sans', system-ui, sans-serif;
--atlas-font-mono: 'IBM Plex Mono', ui-monospace, monospace;
/* Display sizes */
--atlas-text-hero: clamp(2.5rem, 5vw, 4rem); /* Hero headline */
--atlas-text-hero-weight: 700;
--atlas-text-section: clamp(1.75rem, 3.5vw, 2.5rem); /* Section title */
--atlas-text-section-weight: 700;
--atlas-text-card-title: 1.25rem; /* 20px — card heading */
--atlas-text-card-title-weight: 600;
/* Body sizes (mapped from AtlasTypography) */
--atlas-text-body: 1rem; /* 16px — standard body */
--atlas-text-body-weight: 400;
--atlas-text-body-small: 0.875rem; /* 14px — secondary body */
--atlas-text-label: 0.875rem; /* 14px — semibold label */
--atlas-text-label-weight: 600;
--atlas-text-caption: 0.75rem; /* 12px — chips, footnotes */
--atlas-text-caption-weight: 600;
--atlas-text-caption-small: 0.6875rem; /* 11px — legal, timestamps */
/* Line heights */
--atlas-leading-tight: 1.2; /* Display text */
--atlas-leading-normal: 1.6; /* Body text */
--atlas-leading-relaxed: 1.8; /* Long-form reading */
/* Letter spacing */
--atlas-tracking-tight: -0.02em; /* Display */
--atlas-tracking-normal: 0; /* Body */
--atlas-tracking-wide: 0.05em; /* Overlines, badges */
/* ── Spacing (4pt grid from AtlasSpacing) ────────── */
--atlas-space-xxs: 4px; /* AtlasSpacing.xxs */
--atlas-space-xs: 6px; /* AtlasSpacing.xs */
--atlas-space-sm: 8px; /* AtlasSpacing.sm */
--atlas-space-md: 12px; /* AtlasSpacing.md */
--atlas-space-lg: 16px; /* AtlasSpacing.lg */
--atlas-space-xl: 20px; /* AtlasSpacing.xl */
--atlas-space-xxl: 24px; /* AtlasSpacing.xxl */
--atlas-space-screen-h: 28px; /* AtlasSpacing.screenH */
--atlas-space-section: 32px; /* AtlasSpacing.section */
/* Web-specific extended spacing */
--atlas-space-section-gap: 80px; /* Between page sections */
--atlas-space-section-gap-lg: 120px; /* Hero → Trust strip gap */
/* ── Radius (continuous corners from AtlasRadius) ── */
--atlas-radius-sm: 8px; /* AtlasRadius.sm — chips, tags */
--atlas-radius-md: 12px; /* AtlasRadius.md — inline cards */
--atlas-radius-lg: 16px; /* AtlasRadius.lg — detail rows */
--atlas-radius-xl: 20px; /* AtlasRadius.xl — standard cards */
--atlas-radius-xxl: 24px; /* AtlasRadius.xxl — hero cards */
--atlas-radius-full: 9999px; /* Pills, badges, CTA buttons */
/* ── Elevation (shadow system from AtlasElevation) ── */
/* Flat — no shadow */
--atlas-shadow-flat: none;
/* Raised — default card level */
--atlas-shadow-raised: 0 10px 18px rgba(0, 0, 0, 0.05);
--atlas-shadow-raised-border: rgba(241, 245, 249, 0.08);
/* Prominent — hero cards, primary action areas */
--atlas-shadow-prominent: 0 16px 28px rgba(0, 0, 0, 0.09);
--atlas-shadow-prominent-border: rgba(241, 245, 249, 0.12);
/* CTA glow */
--atlas-shadow-cta: 0 6px 12px rgba(20, 144, 133, 0.25);
--atlas-shadow-cta-hover: 0 8px 20px rgba(20, 144, 133, 0.35);
/* ── Motion (from AtlasMotion) ───────────────────── */
--atlas-motion-fast: 150ms cubic-bezier(0.2, 0, 0, 1); /* Hover, press */
--atlas-motion-standard: 220ms cubic-bezier(0.2, 0, 0, 1); /* Toggle, selection */
--atlas-motion-slow: 350ms cubic-bezier(0.2, 0, 0, 1); /* Page transitions */
--atlas-motion-spring: 450ms cubic-bezier(0.34, 1.56, 0.64, 1); /* Playful feedback */
/* Staggered section reveal */
--atlas-stagger-delay: 80ms; /* Delay between items in stagger */
/* ── Layout (from AtlasLayout) ───────────────────── */
--atlas-width-reading: 920px; /* AtlasLayout.maxReadingWidth */
--atlas-width-workspace: 1200px; /* AtlasLayout.maxWorkspaceWidth */
--atlas-width-content: 1080px; /* AtlasLayout.maxWorkflowWidth — main content ceiling */
/* Responsive breakpoints */
--atlas-bp-sm: 640px; /* Mobile → Tablet */
--atlas-bp-md: 860px; /* Matches AtlasLayout.browserSplitThreshold */
--atlas-bp-lg: 1080px; /* Tablet → Desktop */
--atlas-bp-xl: 1280px; /* Wide desktop */
}
```
### 6.3 Five-Dimension Design Decisions
| Dimension | Decision | Rationale |
|-----------|----------|-----------|
| **Color** | Dark-only graphite base (#0D0F11) with teal brand (#149085) and mint accent (#34D399) | PRD specifies "graphite/near-black" background; teal carries trust; mint provides discovery cues without clashing |
| **Typography** | Space Grotesk (display) + Instrument Sans (body) + IBM Plex Mono (utility) | Geometric display for tech authority; humanist sans for readability; monospace for version/code credibility |
| **Spacing** | 80px section gap, 4pt internal grid, max 1080px content width | Generous whitespace per PRD "not crowded"; 4pt grid matches native app; reading width prevents long lines |
| **Shape** | Continuous corners (824px radius scale), capsule CTAs, no sharp edges | Maps directly from `AtlasRadius`; "rounded but not bubbly" per PRD; capsule CTAs match `AtlasPrimaryButtonStyle` |
| **Motion** | Staggered section reveal on scroll, 150ms hover transitions, no decorative loops | PRD: "snappy but never bouncy"; intersection observer triggers for progressive reveal; respects `prefers-reduced-motion` |
### 6.4 Section Band Pattern
The page alternates between "dark" and "surface" bands to create visual rhythm:
```
Section Background Band
─────────────────────────────────────────────────────
NavBar transparent → bg-base —
Hero bg-base Dark
Trust Strip bg-surface Surface
Problem → Outcome bg-base Dark
Feature Grid bg-surface Surface
How It Works bg-base Dark
Developer Section bg-surface Surface
Safety Section bg-base Dark
Screenshot Gallery bg-surface Surface
Open Source bg-base Dark
FAQ bg-surface Surface
Footer bg-base + border-top Dark
```
### 6.5 Component Styling Convention
**BEM-lite + CSS custom properties**:
```css
/* Block */
.hero { ... }
/* Element */
.hero__headline { ... }
.hero__cta { ... }
/* Modifier */
.hero__cta--primary { ... }
.hero__cta--secondary { ... }
```
**Card pattern** (maps from `AtlasCardModifier`):
```css
.card {
padding: var(--atlas-space-xl); /* 20px — AtlasSpacing.xl */
background: var(--atlas-color-bg-surface);
border: 1px solid var(--atlas-color-border); /* 0.08 opacity */
border-radius: var(--atlas-radius-xl); /* 20px — AtlasRadius.xl */
box-shadow: var(--atlas-shadow-raised); /* 0 10px 18px */
transition: transform var(--atlas-motion-fast),
box-shadow var(--atlas-motion-fast);
}
.card:hover {
transform: scale(1.008); /* Matches AtlasHoverModifier */
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08);
}
.card--prominent {
border-radius: var(--atlas-radius-xxl); /* 24px — AtlasRadius.xxl */
border-width: 1.5px;
border-color: var(--atlas-shadow-prominent-border);
box-shadow: var(--atlas-shadow-prominent);
background:
linear-gradient(135deg, rgba(255,255,255,0.08) 0%, transparent 50%),
var(--atlas-color-bg-surface); /* Top-left inner glow */
}
```
**CTA button pattern** (maps from `AtlasPrimaryButtonStyle`):
```css
.cta--primary {
font-family: var(--atlas-font-body);
font-size: var(--atlas-text-label);
font-weight: var(--atlas-text-label-weight);
color: #FFFFFF;
padding: var(--atlas-space-md) var(--atlas-space-xxl); /* 12px 24px */
background: var(--atlas-color-brand);
border-radius: var(--atlas-radius-full); /* Capsule */
box-shadow: var(--atlas-shadow-cta);
transition: transform var(--atlas-motion-fast),
box-shadow var(--atlas-motion-fast);
cursor: pointer;
border: none;
}
.cta--primary:hover {
box-shadow: var(--atlas-shadow-cta-hover);
transform: translateY(-1px);
}
.cta--primary:active {
transform: scale(0.97);
box-shadow: 0 2px 4px rgba(20, 144, 133, 0.15);
}
.cta--primary:disabled {
background: rgba(20, 144, 133, 0.4);
cursor: not-allowed;
box-shadow: none;
}
```
**Badge pattern** (channel badge):
```css
.badge {
font-family: var(--atlas-font-mono);
font-size: var(--atlas-text-caption);
font-weight: var(--atlas-text-caption-weight);
letter-spacing: var(--atlas-tracking-wide);
text-transform: uppercase;
padding: var(--atlas-space-xxs) var(--atlas-space-sm);
border-radius: var(--atlas-radius-sm);
}
.badge--stable {
background: rgba(20, 144, 133, 0.15);
color: var(--atlas-color-accent);
border: 1px solid rgba(20, 144, 133, 0.3);
}
.badge--prerelease {
background: rgba(245, 158, 11, 0.15);
color: var(--atlas-color-warning);
border: 1px solid rgba(245, 158, 11, 0.3);
}
.badge--coming {
background: rgba(148, 163, 184, 0.1);
color: var(--atlas-color-text-secondary);
border: 1px solid rgba(148, 163, 184, 0.2);
}
```
### 6.6 Responsive Strategy
| Breakpoint | Layout Behavior |
|------------|----------------|
| < 640px (mobile) | Single column; hero screenshot below CTA; feature cards stack; screenshot carousel; hamburger nav |
| 640860px (tablet) | Two-column feature grid; hero screenshot beside text; nav items visible |
| 8601080px (small desktop) | Three-column feature grid; full nav; gallery grid |
| > 1080px (desktop) | Max content width 1080px centered; generous margins |
### 6.7 Font Loading Strategy
```css
@font-face {
font-family: 'Space Grotesk';
src: url('/fonts/SpaceGrotesk-Bold.woff2') format('woff2');
font-weight: 700;
font-display: swap;
unicode-range: U+0000-024F, U+4E00-9FFF; /* Latin + CJK */
}
@font-face {
font-family: 'Instrument Sans';
src: url('/fonts/InstrumentSans-Regular.woff2') format('woff2');
font-weight: 400;
font-display: swap;
unicode-range: U+0000-024F, U+4E00-9FFF;
}
/* ... additional faces for Medium weights and IBM Plex Mono */
```
Preload critical fonts in `<head>`:
```html
<link rel="preload" href="/fonts/SpaceGrotesk-Bold.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/InstrumentSans-Regular.woff2" as="font" type="font/woff2" crossorigin>
```
---
## 7. Quality Gates
### 7.1 Core Web Vitals
| Metric | Target | Tool |
|--------|--------|------|
| Largest Contentful Paint (LCP) | < 2.0s | Lighthouse CI |
| Interaction to Next Paint (INP) | < 100ms | Lighthouse CI |
| Cumulative Layout Shift (CLS) | < 0.05 | Lighthouse CI |
| Lighthouse Performance Score | >= 95 | Lighthouse CI |
| Lighthouse Accessibility Score | >= 95 | Lighthouse CI |
| Lighthouse Best Practices Score | >= 95 | Lighthouse CI |
| Lighthouse SEO Score | >= 95 | Lighthouse CI |
| Total Client JS | < 20KB (gzip) | Build output check |
| Total Page Weight | < 500KB (excl. screenshots) | Build output check |
### 7.2 Accessibility (WCAG 2.1 AA)
| Check | Requirement |
|-------|-------------|
| Color contrast | 4.5:1 minimum for normal text; 3:1 for large text (>= 18px bold / >= 24px) |
| Keyboard navigation | All interactive elements focusable; visible focus ring; logical tab order |
| Screen reader | Semantic HTML (`<nav>`, `<main>`, `<section>`, `<article>`); ARIA labels where needed |
| Alt text | Every `<img>` has descriptive alt text; decorative images use `alt=""` |
| Reduced motion | `@media (prefers-reduced-motion: reduce)` disables all animations |
| Language | `<html lang="zh-Hans">` / `<html lang="en">` set per locale |
### 7.3 CI Pipeline
**File**: `.github/workflows/landing-page.yml`
```yaml
name: Landing Page
on:
push:
paths:
- 'Apps/LandingSite/**'
branches: [main]
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: Apps/LandingSite/pnpm-lock.yaml
- name: Install dependencies
working-directory: Apps/LandingSite
run: pnpm install --frozen-lockfile
- name: Fetch release manifest
working-directory: Apps/LandingSite
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run fetch-release
- name: Build static site
working-directory: Apps/LandingSite
run: pnpm run build
- name: Validate HTML
working-directory: Apps/LandingSite
run: pnpm run validate
- name: Run Lighthouse CI
working-directory: Apps/LandingSite
run: pnpm run lighthouse
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: Apps/LandingSite/dist
deploy:
name: Deploy
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
```
**Trigger conditions**:
1. Push to `main` that modifies `Apps/LandingSite/**`
2. GitHub Release publication (triggers manifest regeneration)
3. Manual dispatch for ad-hoc deploys
### 7.4 Acceptance Criteria → PRD Traceability
| FR | Requirement | How Addressed |
|----|-------------|---------------|
| FR-01 | Release metadata (version, channel, date, asset links) | `ReleaseManifest` schema (§3.1); Hero + Footer render from manifest; build-time fetch |
| FR-02 | Channel-aware UI (prerelease badge, warning, Gatekeeper help) | `ChannelBadge` component; release state machine (§1.3); SafetySection Gatekeeper guide |
| FR-03 | Bilingual support (EN + ZH, stable URLs) | Path-based i18n (`/en/`, `/zh/`); `LandingCopy` schema (§3.2); `hreflang` tags (A-07) |
| FR-04 | Download path clarity (where, which file, prerelease, Gatekeeper) | Hero CTA links to correct asset; ChannelBadge shows state; SafetySection explains Gatekeeper |
| FR-05 | Trust links (GitHub, releases, changelog, security, license) | OpenSourceSection + FooterSection; links derived from constants |
| FR-06 | Optional beta email capture | Deferred to 3rd-party form endpoint slot in FooterSection; no custom backend |
| FR-07 | Responsive behavior (desktop, tablet, mobile) | Responsive strategy (§6.6); breakpoints at 640/860/1080px; no CTA hidden in accordion |
### 7.5 User Story → Verification
| Story | Test |
|-------|------|
| US-01: Understand Atlas in one screen | Lighthouse "First Meaningful Paint" check; manual review that headline + subheadline + screenshot are above the fold on 1280×720 viewport |
| US-02: Safety and trust signals | Automated check: TrustStrip rendered; SafetySection contains "recovery", "permissions", "Gatekeeper" keywords; OpenSourceSection links to GitHub |
| US-03: Developer cleanup awareness | DeveloperSection renders with >= 4 concrete developer artifact types (Xcode derived data, simulators, package caches, build artifacts) |
| US-04: Download without GitHub navigation | Hero CTA `href` matches `release-manifest.json` asset URL; version and date rendered next to CTA |
| US-05: Honest prerelease disclosure | When `channel === "prerelease"`: ChannelBadge shows amber "Prerelease" badge; warning text visible; Gatekeeper "Open Anyway" steps visible |
### 7.6 Pre-Deploy Checklist
- [ ] `pnpm run build` succeeds with zero warnings
- [ ] `release-manifest.json` is valid and matches latest GitHub Release
- [ ] Both `/en/` and `/zh/` render without missing translation keys
- [ ] Lighthouse scores >= 95 on all four categories for both locales
- [ ] All images have alt text; screenshots have descriptive captions
- [ ] `hreflang` tags present and correct on both locale pages
- [ ] Open Graph meta tags render correct title, description, and image per locale
- [ ] No `Mole` brand references in rendered HTML
- [ ] Client JS budget < 20KB confirmed via build output
- [ ] `robots.txt` and `sitemap.xml` present and valid
- [ ] Custom domain DNS verified and HTTPS enforced
- [ ] Plausible analytics tracking confirmed on both locales
- [ ] Mobile viewport (375px) preserves CTA visibility and screenshot clarity

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://atlas.atomstorm.ai',
output: 'static',
integrations: [sitemap()],
i18n: {
defaultLocale: 'zh',
locales: ['zh', 'en'],
routing: {
prefixDefaultLocale: true,
redirectToDefaultLocale: false,
},
},
build: {
assets: '_assets',
},
});

View File

@@ -0,0 +1,21 @@
{
"name": "atlas-landing",
"type": "module",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"fetch-release": "tsx scripts/fetch-release.ts",
"prebuild": "tsx scripts/fetch-release.ts"
},
"dependencies": {
"astro": "^5.7.0",
"@astrojs/sitemap": "^3.3.0"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}

3530
Apps/LandingSite/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://atlas.atomstorm.ai/sitemap-index.xml

View File

@@ -0,0 +1,142 @@
/**
* Build-time script: Fetches latest release from GitHub API
* and writes release-manifest.json for the landing page.
*
* Usage: tsx scripts/fetch-release.ts
* Env: GITHUB_TOKEN (optional, increases rate limit)
*/
import { writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OWNER = 'CSZHK';
const REPO = 'CleanMyPc';
const OUTPUT = join(__dirname, '..', 'src', 'data', 'release-manifest.json');
interface ReleaseManifest {
channel: 'stable' | 'prerelease' | 'none';
version: string | null;
publishedAt: string | null;
releaseUrl: string | null;
assets: {
dmg: string | null;
zip: string | null;
pkg: string | null;
sha256: string | null;
};
gatekeeperWarning: boolean;
installNote: string | null;
tagName: string | null;
generatedAt: string;
}
function findAsset(assets: any[], suffix: string): string | null {
const asset = assets.find((a: any) =>
a.name.toLowerCase().endsWith(suffix.toLowerCase())
);
return asset?.browser_download_url ?? null;
}
async function fetchRelease(): Promise<ReleaseManifest> {
const headers: Record<string, string> = {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'atlas-landing-build',
};
if (process.env.GITHUB_TOKEN) {
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
}
// Try latest release first, then fall back to all releases
let release: any = null;
try {
const latestRes = await fetch(
`https://api.github.com/repos/${OWNER}/${REPO}/releases/latest`,
{ headers }
);
if (latestRes.ok) {
release = await latestRes.json();
}
} catch {
// Ignore — try all releases next
}
if (!release) {
try {
const allRes = await fetch(
`https://api.github.com/repos/${OWNER}/${REPO}/releases?per_page=5`,
{ headers }
);
if (allRes.ok) {
const releases = await allRes.json();
if (Array.isArray(releases) && releases.length > 0) {
release = releases[0];
}
}
} catch {
// Will return "none" channel
}
}
if (!release) {
return {
channel: 'none',
version: null,
publishedAt: null,
releaseUrl: `https://github.com/${OWNER}/${REPO}`,
assets: { dmg: null, zip: null, pkg: null, sha256: null },
gatekeeperWarning: false,
installNote: null,
tagName: null,
generatedAt: new Date().toISOString(),
};
}
const isPrerelease = release.prerelease === true;
const version = (release.tag_name ?? '').replace(/^V/i, '') || null;
const assets = release.assets ?? [];
return {
channel: isPrerelease ? 'prerelease' : 'stable',
version,
publishedAt: release.published_at ?? null,
releaseUrl: release.html_url ?? `https://github.com/${OWNER}/${REPO}/releases`,
assets: {
dmg: findAsset(assets, '.dmg'),
zip: findAsset(assets, '.zip'),
pkg: findAsset(assets, '.pkg'),
sha256: findAsset(assets, '.sha256'),
},
gatekeeperWarning: isPrerelease,
installNote: isPrerelease
? 'This build is development-signed. macOS Gatekeeper may require "Open Anyway" or a right-click "Open" flow.'
: null,
tagName: release.tag_name ?? null,
generatedAt: new Date().toISOString(),
};
}
async function main() {
console.log(`Fetching release data for ${OWNER}/${REPO}...`);
try {
const manifest = await fetchRelease();
writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2) + '\n');
console.log(`Wrote ${OUTPUT}`);
console.log(` channel: ${manifest.channel}`);
console.log(` version: ${manifest.version ?? '(none)'}`);
console.log(` gatekeeperWarning: ${manifest.gatekeeperWarning}`);
} catch (err) {
console.error('Failed to fetch release, using fallback:', err);
// The build will use release-fallback.json via the data loader
process.exit(0); // Don't fail the build
}
}
main();

View File

@@ -0,0 +1,80 @@
---
interface Props {
channel: 'stable' | 'prerelease' | 'none';
label: string;
version?: string;
date?: string;
}
const { channel, label, version, date } = Astro.props;
---
<span class={`badge badge--${channel}`}>
<span class="badge__dot" aria-hidden="true"></span>
<span class="badge__label">{label}</span>
{version && <span class="badge__version">v{version}</span>}
{date && <span class="badge__date">{date}</span>}
</span>
<style>
.badge {
display: inline-flex;
align-items: center;
gap: var(--atlas-space-xs);
font-family: var(--atlas-font-mono);
font-size: var(--atlas-text-caption);
font-weight: var(--atlas-text-caption-weight);
letter-spacing: var(--atlas-tracking-wide);
text-transform: uppercase;
padding: var(--atlas-space-xxs) var(--atlas-space-sm);
border-radius: var(--atlas-radius-sm);
line-height: 1.4;
background: var(--atlas-glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.badge__dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.badge__version,
.badge__date {
opacity: 0.7;
font-size: var(--atlas-text-caption-small);
text-transform: none;
letter-spacing: 0;
}
.badge--stable {
color: var(--atlas-color-accent);
border: 1px solid rgba(20, 144, 133, 0.3);
}
.badge--stable .badge__dot {
background-color: var(--atlas-color-accent);
animation: pulse-dot 2s ease-in-out infinite;
}
.badge--prerelease {
color: var(--atlas-color-warning);
border: 1px solid rgba(245, 158, 11, 0.3);
}
.badge--prerelease .badge__dot {
background-color: var(--atlas-color-warning);
animation: pulse-dot 2s ease-in-out infinite;
}
.badge--none {
color: var(--atlas-color-text-secondary);
border: 1px solid rgba(148, 163, 184, 0.2);
}
.badge--none .badge__dot {
background-color: var(--atlas-color-text-secondary);
}
</style>

View File

@@ -0,0 +1,123 @@
---
interface Props {
label: string;
href: string;
variant?: 'primary' | 'secondary' | 'ghost';
external?: boolean;
class?: string;
}
const {
label,
href,
variant = 'primary',
external = true,
class: className = '',
} = Astro.props;
const classes = `cta cta--${variant} ${className}`.trim();
const linkAttrs = external ? { target: '_blank', rel: 'noopener noreferrer' } : {};
---
<a href={href} class={classes} {...linkAttrs}>
{label}
{variant === 'secondary' && (
<svg class="cta__icon" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M6 3L11 8L6 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)}
</a>
<style>
.cta {
display: inline-flex;
align-items: center;
gap: var(--atlas-space-sm);
font-family: var(--atlas-font-body);
font-size: var(--atlas-text-label);
font-weight: var(--atlas-text-label-weight);
line-height: 1;
text-decoration: none;
border-radius: var(--atlas-radius-full);
transition: transform var(--atlas-motion-fast),
box-shadow var(--atlas-motion-fast),
background-color var(--atlas-motion-fast);
cursor: pointer;
white-space: nowrap;
}
.cta--primary {
color: #ffffff;
padding: var(--atlas-space-md) var(--atlas-space-xxl);
background: var(--atlas-gradient-brand);
background-size: 200% 100%;
box-shadow: var(--atlas-shadow-cta);
animation: gradient-shift 3s ease infinite;
}
.cta--primary:hover {
box-shadow: var(--atlas-shadow-cta-hover);
transform: translateY(-1px);
}
.cta--primary:active {
transform: scale(0.97);
box-shadow: 0 2px 4px rgba(20, 144, 133, 0.15);
}
.cta--secondary {
color: var(--atlas-color-accent);
padding: var(--atlas-space-md) var(--atlas-space-xxl);
background: var(--atlas-glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1.5px solid transparent;
background-clip: padding-box;
position: relative;
}
.cta--secondary::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1.5px;
background: var(--atlas-gradient-brand);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0.4;
}
.cta--secondary:hover {
background: var(--atlas-color-bg-raised);
transform: translateY(-1px);
}
.cta--secondary:hover::before {
opacity: 0.7;
}
.cta--secondary:active {
transform: scale(0.97);
}
.cta--ghost {
color: var(--atlas-color-brand);
padding: var(--atlas-space-sm) var(--atlas-space-lg);
}
.cta--ghost:hover {
background-color: rgba(20, 144, 133, 0.06);
}
.cta--ghost:active {
transform: scale(0.97);
}
.cta__icon {
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,91 @@
---
import { t, type Locale } from '../i18n/utils';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const copy = t(locale);
---
<section class="dev section band--surface fade-in" id="developers">
<div class="dev__inner container">
<div class="dev__header">
<h2 class="dev__title">{copy.developer.sectionTitle}</h2>
<p class="dev__subtitle">{copy.developer.subtitle}</p>
</div>
<div class="dev__grid">
{copy.developer.items.map((item) => (
<div class="dev__card">
<h3 class="dev__card-title">{item.title}</h3>
<p class="dev__card-desc">{item.description}</p>
</div>
))}
</div>
</div>
</section>
<style>
.dev__header {
text-align: center;
margin-bottom: var(--atlas-space-section-gap);
}
.dev__title {
font-size: var(--atlas-text-section);
font-weight: var(--atlas-text-section-weight);
margin-bottom: var(--atlas-space-md);
}
.dev__subtitle {
font-size: clamp(1rem, 2vw, 1.125rem);
color: var(--atlas-color-text-secondary);
max-width: 600px;
margin-inline: auto;
line-height: var(--atlas-leading-normal);
}
.dev__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--atlas-space-xl);
}
@media (min-width: 860px) {
.dev__grid {
grid-template-columns: repeat(2, 1fr);
}
}
.dev__card {
padding: var(--atlas-space-xl);
background: var(--atlas-glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--atlas-glass-border);
border-radius: var(--atlas-radius-lg);
transition: box-shadow var(--atlas-motion-standard),
transform var(--atlas-motion-fast);
}
.dev__card:hover {
box-shadow: var(--atlas-glow-card-hover);
transform: translateY(-2px);
}
.dev__card-title {
font-family: var(--atlas-font-mono);
font-size: var(--atlas-text-body-small);
font-weight: 500;
color: var(--atlas-color-accent);
margin-bottom: var(--atlas-space-sm);
}
.dev__card-desc {
font-size: var(--atlas-text-body-small);
color: var(--atlas-color-text-secondary);
line-height: var(--atlas-leading-normal);
}
</style>

View File

@@ -0,0 +1,119 @@
---
import { t, type Locale } from '../i18n/utils';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const copy = t(locale);
---
<section class="faq section band--surface fade-in" id="faq">
<div class="faq__inner container">
<h2 class="faq__title">{copy.faq.sectionTitle}</h2>
<div class="faq__list">
{copy.faq.items.map((item) => (
<details class="faq__item">
<summary class="faq__question">
<span>{item.question}</span>
<svg class="faq__chevron" width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 8L10 13L15 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</summary>
<div class="faq__answer">
<p>{item.answer}</p>
</div>
</details>
))}
</div>
</div>
</section>
<style>
.faq__title {
font-size: var(--atlas-text-section);
font-weight: var(--atlas-text-section-weight);
text-align: center;
margin-bottom: var(--atlas-space-section-gap);
}
.faq__list {
max-width: var(--atlas-width-reading);
margin-inline: auto;
display: flex;
flex-direction: column;
gap: var(--atlas-space-md);
}
.faq__item {
border: 1px solid var(--atlas-glass-border);
border-radius: var(--atlas-radius-lg);
background: var(--atlas-glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
overflow: hidden;
transition: border-color var(--atlas-motion-fast),
box-shadow var(--atlas-motion-standard);
position: relative;
}
.faq__item::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 3px;
background: var(--atlas-gradient-brand);
opacity: 0;
transition: opacity var(--atlas-motion-standard);
border-radius: 3px 0 0 3px;
}
.faq__item[open] {
border-color: var(--atlas-color-border-emphasis);
}
.faq__item[open]::before {
opacity: 1;
}
.faq__question {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--atlas-space-lg);
padding: var(--atlas-space-xl);
font-size: var(--atlas-text-body);
font-weight: 500;
color: var(--atlas-color-text-primary);
list-style: none;
}
.faq__question:hover {
color: var(--atlas-color-accent);
}
.faq__chevron {
flex-shrink: 0;
color: var(--atlas-color-text-tertiary);
transition: transform var(--atlas-motion-fast);
}
.faq__item[open] .faq__chevron {
transform: rotate(180deg);
color: var(--atlas-color-brand);
}
.faq__answer {
padding: 0 var(--atlas-space-xl) var(--atlas-space-xl);
}
.faq__answer p {
font-size: var(--atlas-text-body-small);
color: var(--atlas-color-text-secondary);
line-height: var(--atlas-leading-relaxed);
}
</style>

View File

@@ -0,0 +1,97 @@
---
interface Props {
title: string;
value: string;
example: string;
trustCue: string;
}
const { title, value, example, trustCue } = Astro.props;
---
<article class="feature-card fade-in">
<h3 class="feature-card__title">{title}</h3>
<p class="feature-card__value">{value}</p>
<p class="feature-card__example">{example}</p>
<p class="feature-card__trust">
<svg class="feature-card__trust-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M11.5 4L5.5 10L2.5 7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{trustCue}
</p>
</article>
<style>
.feature-card {
padding: var(--atlas-space-xl);
background: var(--atlas-glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--atlas-glass-border);
border-radius: var(--atlas-radius-xl);
box-shadow: var(--atlas-shadow-raised);
position: relative;
transition: transform var(--atlas-motion-fast),
box-shadow var(--atlas-motion-standard);
}
.feature-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: var(--atlas-gradient-brand);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0;
transition: opacity var(--atlas-motion-standard);
}
.feature-card:hover {
transform: scale(1.02);
box-shadow: var(--atlas-glow-card-hover);
}
.feature-card:hover::before {
opacity: 0.4;
}
.feature-card__title {
font-family: var(--atlas-font-display);
font-size: var(--atlas-text-card-title);
font-weight: var(--atlas-text-card-title-weight);
color: var(--atlas-color-text-primary);
margin-bottom: var(--atlas-space-sm);
}
.feature-card__value {
font-size: var(--atlas-text-body);
color: var(--atlas-color-text-primary);
line-height: var(--atlas-leading-normal);
margin-bottom: var(--atlas-space-md);
}
.feature-card__example {
font-size: var(--atlas-text-body-small);
color: var(--atlas-color-text-secondary);
line-height: var(--atlas-leading-normal);
margin-bottom: var(--atlas-space-lg);
}
.feature-card__trust {
display: flex;
align-items: center;
gap: var(--atlas-space-xs);
font-size: var(--atlas-text-caption);
font-weight: var(--atlas-text-caption-weight);
color: var(--atlas-color-accent);
}
.feature-card__trust-icon {
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,49 @@
---
import FeatureCard from './FeatureCard.astro';
import { t, type Locale } from '../i18n/utils';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const copy = t(locale);
---
<section class="features section band--surface" id="features">
<div class="features__inner container">
<h2 class="features__title fade-in">{copy.features.sectionTitle}</h2>
<div class="features__grid">
{copy.features.cards.map((card) => (
<FeatureCard
title={card.title}
value={card.value}
example={card.example}
trustCue={card.trustCue}
/>
))}
</div>
</div>
</section>
<style>
.features__title {
font-size: var(--atlas-text-section);
font-weight: var(--atlas-text-section-weight);
text-align: center;
margin-bottom: var(--atlas-space-section-gap);
}
.features__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--atlas-space-xxl);
}
@media (min-width: 860px) {
.features__grid {
grid-template-columns: repeat(3, 1fr);
}
}
</style>

View File

@@ -0,0 +1,136 @@
---
import CtaButton from './CtaButton.astro';
import { t, type Locale } from '../i18n/utils';
import { getRelease, getDownloadUrl } from '../data/release';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const copy = t(locale);
const manifest = getRelease();
const downloadUrl = getDownloadUrl(manifest);
const REPO_URL = 'https://github.com/CSZHK/CleanMyPc';
---
<footer class="footer">
<div class="footer__inner container">
<div class="footer__top">
<div class="footer__brand">
<img src="/images/atlas-icon.png" alt="" width="40" height="40" class="footer__logo" />
<div>
<p class="footer__name">Atlas for Mac</p>
<p class="footer__tagline">{locale === 'zh' ? '可解释、恢复优先的 Mac 维护工作区' : 'Explainable, recovery-first Mac maintenance'}</p>
</div>
</div>
<CtaButton
label={copy.footer.download}
href={downloadUrl}
variant="primary"
/>
</div>
<nav class="footer__links" aria-label="Footer navigation">
<a href={downloadUrl} class="footer__link">{copy.footer.download}</a>
<a href={REPO_URL} class="footer__link" target="_blank" rel="noopener noreferrer">{copy.footer.github}</a>
<a href={`${REPO_URL}#readme`} class="footer__link" target="_blank" rel="noopener noreferrer">{copy.footer.documentation}</a>
<a href={`${REPO_URL}/blob/main/Docs/PRIVACY.md`} class="footer__link" target="_blank" rel="noopener noreferrer">{copy.footer.privacy}</a>
<a href={`${REPO_URL}/security`} class="footer__link" target="_blank" rel="noopener noreferrer">{copy.footer.security}</a>
</nav>
<div class="footer__bottom">
<p class="footer__copyright">{copy.footer.copyright}</p>
</div>
</div>
</footer>
<style>
.footer {
background: var(--atlas-glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid transparent;
background-clip: padding-box;
position: relative;
padding-block: var(--atlas-space-section-gap);
}
.footer::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: var(--atlas-gradient-brand);
opacity: 0.3;
}
.footer__inner {
display: flex;
flex-direction: column;
gap: var(--atlas-space-section);
}
.footer__top {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--atlas-space-xl);
}
.footer__brand {
display: flex;
align-items: center;
gap: var(--atlas-space-md);
}
.footer__logo {
width: 40px;
height: 40px;
border-radius: var(--atlas-radius-md);
}
.footer__name {
font-family: var(--atlas-font-display);
font-size: 1.125rem;
font-weight: 700;
color: var(--atlas-color-text-primary);
}
.footer__tagline {
font-size: var(--atlas-text-body-small);
color: var(--atlas-color-text-tertiary);
}
.footer__links {
display: flex;
flex-wrap: wrap;
gap: var(--atlas-space-xxl);
}
.footer__link {
font-size: var(--atlas-text-body-small);
color: var(--atlas-color-text-secondary);
text-decoration: none;
transition: color var(--atlas-motion-fast);
}
.footer__link:hover {
color: var(--atlas-color-text-primary);
}
.footer__bottom {
padding-top: var(--atlas-space-xl);
border-top: 1px solid var(--atlas-color-border);
}
.footer__copyright {
font-size: var(--atlas-text-caption-small);
color: var(--atlas-color-text-tertiary);
}
</style>

View File

@@ -0,0 +1,248 @@
---
import CtaButton from './CtaButton.astro';
import ChannelBadge from './ChannelBadge.astro';
import { t, type Locale } from '../i18n/utils';
import { getRelease, getDownloadUrl, formatDate } from '../data/release';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const copy = t(locale);
const manifest = getRelease();
const downloadUrl = getDownloadUrl(manifest);
const badgeLabels = {
stable: copy.hero.badgeStable,
prerelease: copy.hero.badgePrerelease,
none: copy.hero.badgeComingSoon,
};
const REPO_URL = 'https://github.com/CSZHK/CleanMyPc';
// Per state machine: stable/prerelease → download CTA + GitHub secondary
// none → GitHub as primary, no secondary download
const primaryLabel = manifest.channel === 'none'
? copy.hero.ctaSecondary // "View Source" / "查看源码"
: copy.hero.ctaPrimary; // "Download Atlas" / "下载 Atlas"
const primaryHref = manifest.channel === 'none'
? REPO_URL
: downloadUrl;
const showSecondary = manifest.channel !== 'none';
const publishedDate = formatDate(manifest.publishedAt, locale);
---
<section class="hero band--dark" id="hero">
<!-- Background glow orbs -->
<div class="hero__glow-orb hero__glow-orb--1" aria-hidden="true"></div>
<div class="hero__glow-orb hero__glow-orb--2" aria-hidden="true"></div>
<div class="hero__glow-orb hero__glow-orb--3" aria-hidden="true"></div>
<div class="hero__inner container">
<div class="hero__content">
<ChannelBadge
channel={manifest.channel}
label={badgeLabels[manifest.channel]}
version={manifest.version ?? undefined}
date={publishedDate || undefined}
/>
<h1 class="hero__headline">{copy.hero.headline}</h1>
<p class="hero__subheadline">{copy.hero.subheadline}</p>
<div class="hero__actions">
<CtaButton
label={primaryLabel}
href={primaryHref}
variant="primary"
/>
{showSecondary && (
<CtaButton
label={copy.hero.ctaSecondary}
href={REPO_URL}
variant="secondary"
/>
)}
</div>
{manifest.channel === 'prerelease' && (
<p class="hero__warning">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8 1L15 14H1L8 1Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M8 6V9M8 11.5V12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
{copy.hero.prereleaseWarning}
</p>
)}
{manifest.version && (
<p class="hero__version">
{copy.hero.versionLabel
.replace('{version}', manifest.version)
.replace('{date}', publishedDate)}
</p>
)}
</div>
<div class="hero__visual">
<div class="hero__frame">
<img
src="/images/screenshots/atlas-overview.png"
alt={locale === 'zh' ? 'Atlas for Mac 概览界面' : 'Atlas for Mac overview interface'}
width="800"
height="560"
loading="eager"
class="hero__screenshot"
/>
</div>
</div>
</div>
</section>
<style>
.hero {
padding-top: calc(80px + var(--atlas-space-section-gap));
padding-bottom: var(--atlas-space-section-gap);
overflow: hidden;
position: relative;
}
/* Glow orbs — aurora-like background effect */
.hero__glow-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
animation: pulse-glow 8s ease-in-out infinite;
}
.hero__glow-orb--1 {
width: 500px;
height: 500px;
top: -100px;
left: -100px;
background: radial-gradient(circle, rgba(20, 144, 133, 0.2) 0%, transparent 70%);
}
.hero__glow-orb--2 {
width: 400px;
height: 400px;
top: 50%;
right: -80px;
background: radial-gradient(circle, rgba(52, 211, 153, 0.15) 0%, transparent 70%);
animation-delay: -3s;
}
.hero__glow-orb--3 {
width: 300px;
height: 300px;
bottom: -50px;
left: 30%;
background: radial-gradient(circle, rgba(20, 144, 133, 0.12) 0%, transparent 70%);
animation-delay: -5s;
}
.hero__inner {
display: grid;
grid-template-columns: 1fr;
gap: var(--atlas-space-section-gap);
align-items: center;
}
@media (min-width: 860px) {
.hero__inner {
grid-template-columns: 1fr 1.1fr;
gap: var(--atlas-space-section);
}
}
.hero__content {
display: flex;
flex-direction: column;
gap: var(--atlas-space-lg);
position: relative;
z-index: 1;
}
.hero__headline {
font-size: var(--atlas-text-hero);
font-weight: var(--atlas-text-hero-weight);
line-height: var(--atlas-leading-tight);
letter-spacing: var(--atlas-tracking-tight);
background: linear-gradient(135deg, #F1F5F9 0%, #34D399 50%, #149085 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
[data-theme="light"] .hero__headline {
background: linear-gradient(135deg, #0F172A 0%, #149085 50%, #0F766E 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero__subheadline {
font-size: clamp(1rem, 2vw, 1.25rem);
color: var(--atlas-color-text-secondary);
line-height: var(--atlas-leading-relaxed);
max-width: 480px;
}
.hero__actions {
display: flex;
flex-wrap: wrap;
gap: var(--atlas-space-md);
margin-top: var(--atlas-space-sm);
}
.hero__warning {
display: flex;
align-items: center;
gap: var(--atlas-space-sm);
font-size: var(--atlas-text-body-small);
color: var(--atlas-color-warning);
padding: var(--atlas-space-sm) var(--atlas-space-md);
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: var(--atlas-radius-md);
max-width: max-content;
}
.hero__version {
font-family: var(--atlas-font-mono);
font-size: var(--atlas-text-caption-small);
color: var(--atlas-color-text-tertiary);
}
.hero__visual {
display: flex;
justify-content: center;
min-width: 0;
position: relative;
z-index: 1;
}
.hero__frame {
position: relative;
border-radius: var(--atlas-radius-xxl);
overflow: hidden;
background: var(--atlas-glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--atlas-glass-border);
max-width: 100%;
box-shadow: var(--atlas-shadow-prominent), var(--atlas-glow-brand);
animation: float var(--atlas-duration-float) ease-in-out infinite;
}
.hero__screenshot {
display: block;
width: 100%;
height: auto;
}
</style>

View File

@@ -0,0 +1,118 @@
---
import { t, type Locale } from '../i18n/utils';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const copy = t(locale);
---
<section class="how section band--dark fade-in" id="how">
<div class="how__inner container">
<h2 class="how__title">{copy.howItWorks.sectionTitle}</h2>
<div class="how__steps stagger-parent">
{copy.howItWorks.steps.map((step, i) => (
<div class="how__step fade-in">
<div class="how__step-card">
<div class="how__step-number" aria-hidden="true">{String(i + 1).padStart(2, '0')}</div>
<h3 class="how__step-label">{step.label}</h3>
<p class="how__step-desc">{step.description}</p>
</div>
{i < copy.howItWorks.steps.length - 1 && (
<div class="how__connector" aria-hidden="true" />
)}
</div>
))}
</div>
</div>
</section>
<style>
.how__title {
font-size: var(--atlas-text-section);
font-weight: var(--atlas-text-section-weight);
text-align: center;
margin-bottom: var(--atlas-space-section-gap);
}
.how__steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--atlas-space-xxl);
position: relative;
}
@media (min-width: 860px) {
.how__steps {
grid-template-columns: repeat(4, 1fr);
}
}
.how__step {
text-align: center;
position: relative;
}
.how__step-card {
padding: var(--atlas-space-xl);
background: var(--atlas-glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--atlas-glass-border);
border-radius: var(--atlas-radius-xl);
position: relative;
transition: box-shadow var(--atlas-motion-standard),
transform var(--atlas-motion-fast);
}
.how__step-card:hover {
box-shadow: var(--atlas-glow-card-hover);
transform: translateY(-2px);
}
.how__step-number {
font-family: var(--atlas-font-mono);
font-size: var(--atlas-text-section);
font-weight: 700;
background: var(--atlas-gradient-brand);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
opacity: 0.5;
margin-bottom: var(--atlas-space-md);
}
.how__step-label {
font-family: var(--atlas-font-display);
font-size: var(--atlas-text-card-title);
font-weight: var(--atlas-text-card-title-weight);
color: var(--atlas-color-text-primary);
margin-bottom: var(--atlas-space-sm);
}
.how__step-desc {
font-size: var(--atlas-text-body-small);
color: var(--atlas-color-text-secondary);
line-height: var(--atlas-leading-normal);
}
.how__connector {
display: none;
}
@media (min-width: 860px) {
.how__connector {
display: block;
position: absolute;
top: 50%;
right: -12px;
width: 24px;
height: 2px;
background: var(--atlas-gradient-brand);
opacity: 0.3;
}
}
</style>

View File

@@ -0,0 +1,265 @@
---
import CtaButton from './CtaButton.astro';
import { t, type Locale } from '../i18n/utils';
import { getRelease, getDownloadUrl } from '../data/release';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const copy = t(locale);
const manifest = getRelease();
const downloadUrl = getDownloadUrl(manifest);
const altLocale = locale === 'zh' ? 'en' : 'zh';
const altPath = locale === 'zh' ? '/en/' : '/zh/';
const altLabel = locale === 'zh' ? 'EN' : '中文';
const navItems = [
{ label: copy.nav.whyAtlas, href: '#why' },
{ label: copy.nav.howItWorks, href: '#how' },
{ label: copy.nav.developers, href: '#developers' },
{ label: copy.nav.safety, href: '#safety' },
{ label: copy.nav.faq, href: '#faq' },
];
---
<header class="navbar" id="navbar">
<nav class="navbar__inner container" aria-label="Main navigation">
<a href={`/${locale}/`} class="navbar__brand" aria-label="Atlas for Mac">
<img src="/images/atlas-icon.png" alt="" width="32" height="32" class="navbar__logo" />
<span class="navbar__wordmark">Atlas</span>
</a>
<ul class="navbar__links" id="nav-links">
{navItems.map((item) => (
<li><a href={item.href} class="navbar__link">{item.label}</a></li>
))}
</ul>
<div class="navbar__actions">
<button class="navbar__theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
<svg class="navbar__theme-icon navbar__theme-icon--sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg class="navbar__theme-icon navbar__theme-icon--moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
<a href={altPath} class="navbar__lang" aria-label={`Switch to ${altLabel}`}>
{altLabel}
</a>
<CtaButton
label={copy.nav.download}
href={downloadUrl}
variant="primary"
class="navbar__cta"
/>
<button class="navbar__menu-btn" id="menu-btn" aria-label="Menu" aria-expanded="false">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
</div>
</nav>
</header>
<script>
const btn = document.getElementById('menu-btn');
const links = document.getElementById('nav-links');
if (btn && links) {
btn.addEventListener('click', () => {
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!expanded));
links.classList.toggle('is-open');
});
}
// Sticky background on scroll
let ticking = false;
const navbar = document.getElementById('navbar');
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
if (navbar) {
navbar.classList.toggle('is-scrolled', window.scrollY > 40);
}
ticking = false;
});
ticking = true;
}
});
// Theme toggle
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('atlas-theme', next);
});
}
</script>
<style>
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
padding-block: var(--atlas-space-md);
transition: background-color var(--atlas-motion-standard),
backdrop-filter var(--atlas-motion-standard);
}
.navbar.is-scrolled {
background-color: var(--atlas-navbar-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--atlas-color-border);
}
.navbar__inner {
display: flex;
align-items: center;
gap: var(--atlas-space-xxl);
}
.navbar__brand {
display: flex;
align-items: center;
gap: var(--atlas-space-sm);
text-decoration: none;
flex-shrink: 0;
}
.navbar__logo {
width: 32px;
height: 32px;
border-radius: var(--atlas-radius-sm);
}
.navbar__wordmark {
font-family: var(--atlas-font-display);
font-size: 1.125rem;
font-weight: 700;
color: var(--atlas-color-text-primary);
}
.navbar__links {
display: flex;
align-items: center;
gap: var(--atlas-space-xxl);
flex: 1;
}
.navbar__link {
font-size: var(--atlas-text-body-small);
color: var(--atlas-color-text-secondary);
text-decoration: none;
transition: color var(--atlas-motion-fast);
white-space: nowrap;
}
.navbar__link:hover {
color: var(--atlas-color-text-primary);
}
.navbar__actions {
display: flex;
align-items: center;
gap: var(--atlas-space-lg);
flex-shrink: 0;
}
.navbar__theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--atlas-radius-sm);
border: 1px solid var(--atlas-color-border);
background: transparent;
color: var(--atlas-color-text-secondary);
cursor: pointer;
transition: color var(--atlas-motion-fast),
border-color var(--atlas-motion-fast);
}
.navbar__theme-toggle:hover {
color: var(--atlas-color-text-primary);
border-color: var(--atlas-color-border-emphasis);
}
/* Show sun in dark mode, moon in light mode */
.navbar__theme-icon--moon { display: none; }
.navbar__theme-icon--sun { display: block; }
[data-theme="light"] .navbar__theme-icon--sun { display: none; }
[data-theme="light"] .navbar__theme-icon--moon { display: block; }
.navbar__lang {
font-family: var(--atlas-font-mono);
font-size: var(--atlas-text-caption);
font-weight: 500;
color: var(--atlas-color-text-secondary);
text-decoration: none;
padding: var(--atlas-space-xxs) var(--atlas-space-sm);
border: 1px solid var(--atlas-color-border);
border-radius: var(--atlas-radius-sm);
transition: color var(--atlas-motion-fast),
border-color var(--atlas-motion-fast);
}
.navbar__lang:hover {
color: var(--atlas-color-text-primary);
border-color: var(--atlas-color-border-emphasis);
}
.navbar__menu-btn {
display: none;
color: var(--atlas-color-text-secondary);
}
@media (max-width: 860px) {
.navbar__links {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
flex-direction: column;
padding: var(--atlas-space-xl);
background-color: var(--atlas-mobile-menu-bg);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--atlas-color-border);
gap: var(--atlas-space-lg);
}
.navbar__links.is-open {
display: flex;
}
.navbar__menu-btn {
display: block;
}
:global(.navbar__cta) {
display: none;
}
}
</style>

View File

@@ -0,0 +1,108 @@
---
import { t, type Locale } from '../i18n/utils';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const copy = t(locale);
const REPO_URL = 'https://github.com/CSZHK/CleanMyPc';
---
<section class="oss section band--dark fade-in" id="opensource">
<div class="oss__inner container">
<h2 class="oss__title">{copy.openSource.sectionTitle}</h2>
<div class="oss__grid">
<a href={REPO_URL} class="oss__card" target="_blank" rel="noopener noreferrer">
<span class="oss__card-label">{copy.openSource.repoLabel}</span>
<span class="oss__card-value">GitHub</span>
<svg class="oss__card-arrow" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 12L12 4M12 4H6M12 4V10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
<div class="oss__card">
<span class="oss__card-label">{copy.openSource.licenseLabel}</span>
<span class="oss__card-value">MIT</span>
</div>
<a href={`${REPO_URL}/blob/main/Docs/ATTRIBUTION.md`} class="oss__card" target="_blank" rel="noopener noreferrer">
<span class="oss__card-label">{copy.openSource.attributionLabel}</span>
<span class="oss__card-value">ATTRIBUTION.md</span>
<svg class="oss__card-arrow" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 12L12 4M12 4H6M12 4V10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
<a href={`${REPO_URL}/releases`} class="oss__card" target="_blank" rel="noopener noreferrer">
<span class="oss__card-label">{copy.openSource.changelogLabel}</span>
<span class="oss__card-value">Releases</span>
<svg class="oss__card-arrow" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 12L12 4M12 4H6M12 4V10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
</div>
</div>
</section>
<style>
.oss__title {
font-size: var(--atlas-text-section);
font-weight: var(--atlas-text-section-weight);
text-align: center;
margin-bottom: var(--atlas-space-section-gap);
}
.oss__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--atlas-space-lg);
max-width: 800px;
margin-inline: auto;
}
.oss__card {
display: flex;
flex-direction: column;
gap: var(--atlas-space-xxs);
padding: var(--atlas-space-xl);
background: var(--atlas-glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--atlas-glass-border);
border-radius: var(--atlas-radius-lg);
text-decoration: none;
position: relative;
transition: box-shadow var(--atlas-motion-standard),
transform var(--atlas-motion-fast);
}
a.oss__card:hover {
box-shadow: var(--atlas-glow-card-hover);
transform: translateY(-2px);
}
.oss__card-label {
font-size: var(--atlas-text-caption);
font-weight: var(--atlas-text-caption-weight);
color: var(--atlas-color-text-tertiary);
text-transform: uppercase;
letter-spacing: var(--atlas-tracking-wide);
}
.oss__card-value {
font-family: var(--atlas-font-mono);
font-size: var(--atlas-text-body);
color: var(--atlas-color-text-primary);
}
.oss__card-arrow {
position: absolute;
top: var(--atlas-space-lg);
right: var(--atlas-space-lg);
color: var(--atlas-color-text-tertiary);
}
</style>

View File

@@ -0,0 +1,106 @@
---
import { t, type Locale } from '../i18n/utils';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const copy = t(locale);
---
<section class="problem section band--dark fade-in" id="why">
<div class="problem__inner container">
<h2 class="problem__title">{copy.problem.sectionTitle}</h2>
<div class="problem__grid">
{copy.problem.scenarios.map((scenario) => (
<div class="problem__card">
<p class="problem__before">{scenario.before}</p>
<div class="problem__arrow" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 5V19M12 19L5 12M12 19L19 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<p class="problem__after">{scenario.after}</p>
</div>
))}
</div>
</div>
</section>
<style>
.problem__title {
font-size: var(--atlas-text-section);
font-weight: var(--atlas-text-section-weight);
text-align: center;
margin-bottom: var(--atlas-space-section-gap);
}
.problem__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--atlas-space-xxl);
}
.problem__card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--atlas-space-lg);
padding: var(--atlas-space-xxl);
background: var(--atlas-glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--atlas-glass-border);
border-radius: var(--atlas-radius-xl);
position: relative;
transition: box-shadow var(--atlas-motion-standard);
}
.problem__card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: var(--atlas-gradient-brand);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0;
transition: opacity var(--atlas-motion-standard);
}
.problem__card:hover::before {
opacity: 0.3;
}
.problem__card:hover {
box-shadow: var(--atlas-glow-card-hover);
}
.problem__before {
font-size: var(--atlas-text-body);
color: var(--atlas-color-text-secondary);
line-height: var(--atlas-leading-normal);
}
.problem__arrow {
color: var(--atlas-color-brand);
}
.problem__arrow svg {
stroke: currentColor;
}
.problem__after {
font-size: var(--atlas-text-body);
font-weight: 500;
color: var(--atlas-color-accent);
line-height: var(--atlas-leading-normal);
}
</style>

View File

@@ -0,0 +1,150 @@
---
import { t, type Locale } from '../i18n/utils';
import { getRelease } from '../data/release';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const copy = t(locale);
const manifest = getRelease();
---
<section class="safety section band--dark fade-in" id="safety">
<div class="safety__inner container">
<div class="safety__header">
<h2 class="safety__title">{copy.safety.sectionTitle}</h2>
<p class="safety__subtitle">{copy.safety.subtitle}</p>
</div>
<div class="safety__grid">
{copy.safety.points.map((point) => (
<div class="safety__card">
<h3 class="safety__card-title">{point.title}</h3>
<p class="safety__card-desc">{point.description}</p>
</div>
))}
</div>
{manifest.gatekeeperWarning && (
<div class="safety__gatekeeper">
<h3 class="safety__gk-title">{copy.safety.gatekeeperGuide.title}</h3>
<ol class="safety__gk-steps">
{copy.safety.gatekeeperGuide.steps.map((step) => (
<li class="safety__gk-step">{step}</li>
))}
</ol>
<div class="safety__gk-visual">
<img
src="/images/screenshots/atlas-prerelease-warning.png"
alt={locale === 'zh' ? 'macOS Gatekeeper 预发布警告示意' : 'macOS Gatekeeper prerelease warning'}
width="400"
height="280"
loading="lazy"
class="safety__gk-img"
/>
</div>
</div>
)}
</div>
</section>
<style>
.safety__header {
text-align: center;
margin-bottom: var(--atlas-space-section-gap);
}
.safety__title {
font-size: var(--atlas-text-section);
font-weight: var(--atlas-text-section-weight);
margin-bottom: var(--atlas-space-md);
}
.safety__subtitle {
font-size: clamp(1rem, 2vw, 1.125rem);
color: var(--atlas-color-text-secondary);
max-width: 600px;
margin-inline: auto;
line-height: var(--atlas-leading-normal);
}
.safety__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--atlas-space-xxl);
margin-bottom: var(--atlas-space-section-gap);
}
.safety__card {
padding: var(--atlas-space-xl);
background: var(--atlas-glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--atlas-glass-border);
border-radius: var(--atlas-radius-xl);
transition: box-shadow var(--atlas-motion-standard),
transform var(--atlas-motion-fast);
}
.safety__card:hover {
box-shadow: var(--atlas-glow-card-hover);
transform: translateY(-2px);
}
.safety__card-title {
font-family: var(--atlas-font-display);
font-size: var(--atlas-text-card-title);
font-weight: var(--atlas-text-card-title-weight);
color: var(--atlas-color-text-primary);
margin-bottom: var(--atlas-space-sm);
}
.safety__card-desc {
font-size: var(--atlas-text-body-small);
color: var(--atlas-color-text-secondary);
line-height: var(--atlas-leading-normal);
}
.safety__gatekeeper {
padding: var(--atlas-space-xxl);
background: rgba(245, 158, 11, 0.05);
border: 1px solid rgba(245, 158, 11, 0.15);
border-radius: var(--atlas-radius-xl);
}
.safety__gk-title {
font-family: var(--atlas-font-display);
font-size: var(--atlas-text-card-title);
font-weight: var(--atlas-text-card-title-weight);
color: var(--atlas-color-warning);
margin-bottom: var(--atlas-space-lg);
}
.safety__gk-steps {
list-style: decimal;
list-style-position: inside;
display: flex;
flex-direction: column;
gap: var(--atlas-space-sm);
margin-bottom: var(--atlas-space-xl);
}
.safety__gk-step {
font-size: var(--atlas-text-body-small);
color: var(--atlas-color-text-secondary);
line-height: var(--atlas-leading-normal);
}
.safety__gk-visual {
display: flex;
justify-content: center;
}
.safety__gk-img {
border-radius: var(--atlas-radius-md);
border: 1px solid var(--atlas-color-border);
max-width: 400px;
}
</style>

View File

@@ -0,0 +1,121 @@
---
import { t, type Locale } from '../i18n/utils';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const copy = t(locale);
---
<section class="gallery section band--surface fade-in" id="screenshots">
<div class="gallery__inner container">
<h2 class="gallery__title">{copy.screenshots.sectionTitle}</h2>
<div class="gallery__grid stagger-parent" id="screenshot-gallery">
{copy.screenshots.items.map((item, i) => (
<figure class="gallery__item fade-in" data-index={i}>
<div class="gallery__frame">
<img
src={item.src}
alt={item.alt}
width="800"
height="560"
loading="lazy"
class="gallery__img"
/>
</div>
<figcaption class="gallery__caption">{item.caption}</figcaption>
</figure>
))}
</div>
</div>
</section>
<style>
.gallery__title {
font-size: var(--atlas-text-section);
font-weight: var(--atlas-text-section-weight);
text-align: center;
margin-bottom: var(--atlas-space-section-gap);
}
.gallery__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: var(--atlas-space-xxl);
}
@media (max-width: 640px) {
.gallery__grid {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
gap: var(--atlas-space-lg);
padding-bottom: var(--atlas-space-lg);
}
.gallery__item {
flex: 0 0 85vw;
scroll-snap-align: center;
}
}
.gallery__item {
display: flex;
flex-direction: column;
gap: var(--atlas-space-md);
}
.gallery__frame {
border-radius: var(--atlas-radius-xl);
overflow: hidden;
background: var(--atlas-glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--atlas-glass-border);
position: relative;
transition: transform var(--atlas-motion-fast),
box-shadow var(--atlas-motion-standard);
}
.gallery__frame::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: var(--atlas-gradient-brand);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0;
transition: opacity var(--atlas-motion-standard);
}
.gallery__frame:hover {
transform: scale(1.02);
box-shadow: var(--atlas-glow-card-hover);
}
.gallery__frame:hover::before {
opacity: 0.4;
}
.gallery__img {
display: block;
width: 100%;
height: auto;
}
.gallery__caption {
font-size: var(--atlas-text-body-small);
color: var(--atlas-color-text-secondary);
text-align: center;
line-height: var(--atlas-leading-normal);
}
</style>

View File

@@ -0,0 +1,80 @@
---
import { t, type Locale } from '../i18n/utils';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const copy = t(locale);
const items = [
copy.trustStrip.openSource,
copy.trustStrip.recoveryFirst,
copy.trustStrip.developerAware,
copy.trustStrip.macNative,
copy.trustStrip.directDownload,
];
---
<section class="trust-strip band--surface" aria-label="Trust signals">
<div class="trust-strip__inner container">
{items.map((item) => (
<span class="trust-strip__pill">{item}</span>
))}
</div>
</section>
<style>
.trust-strip {
padding-block: var(--atlas-space-xxl);
border-top: 1px solid var(--atlas-color-border);
border-bottom: 1px solid var(--atlas-color-border);
}
.trust-strip__inner {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--atlas-space-md);
}
.trust-strip__pill {
font-size: var(--atlas-text-body-small);
font-weight: 500;
color: var(--atlas-color-text-secondary);
padding: var(--atlas-space-xs) var(--atlas-space-lg);
background: var(--atlas-glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--atlas-glass-border);
border-radius: var(--atlas-radius-full);
white-space: nowrap;
transition: box-shadow var(--atlas-motion-standard);
position: relative;
}
.trust-strip__pill::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: var(--atlas-gradient-brand);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0.2;
transition: opacity var(--atlas-motion-standard);
}
.trust-strip__pill:hover {
box-shadow: 0 0 20px rgba(20, 144, 133, 0.08);
}
.trust-strip__pill:hover::before {
opacity: 0.4;
}
</style>

View File

@@ -0,0 +1,16 @@
{
"channel": "prerelease",
"version": "1.0.3",
"publishedAt": null,
"releaseUrl": "https://github.com/CSZHK/CleanMyPc/releases",
"assets": {
"dmg": null,
"zip": null,
"pkg": null,
"sha256": null
},
"gatekeeperWarning": true,
"installNote": null,
"tagName": null,
"generatedAt": "static-fallback"
}

View File

@@ -0,0 +1,70 @@
/**
* Release manifest loader.
* Tries build-time generated manifest first, then falls back to static fallback.
*/
export interface ReleaseManifest {
channel: 'stable' | 'prerelease' | 'none';
version: string | null;
publishedAt: string | null;
releaseUrl: string | null;
assets: {
dmg: string | null;
zip: string | null;
pkg: string | null;
sha256: string | null;
};
gatekeeperWarning: boolean;
installNote: string | null;
tagName: string | null;
generatedAt: string;
}
let cached: ReleaseManifest | null = null;
export function getRelease(): ReleaseManifest {
if (cached) return cached;
try {
// Try build-time generated manifest first
const manifest = import.meta.glob('./release-manifest.json', { eager: true });
const key = Object.keys(manifest)[0];
if (key) {
cached = (manifest[key] as any).default as ReleaseManifest;
return cached;
}
} catch {
// Fall through to fallback
}
// Use static fallback
const fallback = import.meta.glob('./release-fallback.json', { eager: true });
const key = Object.keys(fallback)[0];
cached = (fallback[key] as any).default as ReleaseManifest;
return cached;
}
export function getDownloadUrl(manifest: ReleaseManifest): string {
// Prefer DMG > ZIP > PKG > release page
return (
manifest.assets.dmg ??
manifest.assets.zip ??
manifest.assets.pkg ??
manifest.releaseUrl ??
'https://github.com/CSZHK/CleanMyPc/releases'
);
}
export function formatDate(iso: string | null, locale: string): string {
if (!iso) return '';
try {
const date = new Date(iso);
return date.toLocaleDateString(locale === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return '';
}
}

View File

@@ -0,0 +1,236 @@
{
"meta": {
"title": "Atlas for Mac — Explainable, Recovery-First Mac Maintenance",
"description": "Atlas is an open-source macOS maintenance tool. Preview before you scan, restore after you clean. Built for developers and power users.",
"ogImage": "/images/og-atlas-en.png"
},
"nav": {
"whyAtlas": "Why Atlas",
"howItWorks": "How It Works",
"developers": "Developers",
"safety": "Safety",
"faq": "FAQ",
"download": "Download"
},
"hero": {
"headline": "Mac maintenance you can explain and reverse",
"subheadline": "Atlas is an open-source macOS maintenance workspace. Every scan result is explained, every clean operation is recoverable. Nothing is silently deleted.",
"ctaPrimary": "Download Atlas",
"ctaSecondary": "View Source",
"badgeStable": "Stable",
"badgePrerelease": "Prerelease",
"badgeComingSoon": "Coming Soon",
"prereleaseWarning": "Prerelease builds are for testing only and may contain unresolved issues. Do not use in production.",
"gatekeeperNote": "On first launch, right-click and select \"Open\" to pass macOS Gatekeeper verification.",
"versionLabel": "Version {version} · {date}"
},
"trustStrip": {
"openSource": "Fully Open Source",
"recoveryFirst": "Recovery First",
"developerAware": "Developer Aware",
"macNative": "Native macOS",
"directDownload": "Direct Download"
},
"problem": {
"sectionTitle": "Why Atlas",
"scenarios": [
{
"before": "Cleanup tools delete files without telling you what was removed",
"after": "Atlas lists every scan result for your review before executing"
},
{
"before": "Accidentally deleted a config file with no way to recover",
"after": "Atlas supports per-item restore within the retention window, when supported"
},
{
"before": "Tools demand Full Disk Access without explaining why",
"after": "Atlas requests permissions on demand and explains each one"
}
]
},
"features": {
"sectionTitle": "Core Features",
"cards": [
{
"title": "Overview",
"value": "System health at a glance",
"example": "Disk usage, cache breakdown, reclaimable space — all on one screen",
"trustCue": "Read-only scan, no modifications made"
},
{
"title": "Smart Clean",
"value": "Scan, review the plan, then execute",
"example": "System cache 3.2 GB — inspect details before deciding to clean",
"trustCue": "Auto-backup before every clean, supports rollback"
},
{
"title": "Apps",
"value": "Analyze full app footprint and plan uninstalls",
"example": "See every associated file an app leaves on disk",
"trustCue": "Full file list shown before uninstall, confirm each item"
},
{
"title": "History",
"value": "Complete audit trail and recovery timeline",
"example": "Review detailed records of every clean operation in the past 30 days",
"trustCue": "Every action is traceable, no silent operations"
},
{
"title": "Recovery",
"value": "Per-item restore within the retention window, when supported",
"example": "Accidentally removed config files can be restored within the retention period",
"trustCue": "File-level backup, not snapshot-based"
},
{
"title": "Permissions",
"value": "Least privilege, explained before asking",
"example": "Downloads folder access is requested only when scanning that directory",
"trustCue": "Every permission comes with a usage explanation"
}
]
},
"howItWorks": {
"sectionTitle": "How It Works",
"steps": [
{
"label": "Scan",
"description": "Atlas performs a read-only scan of your disk, identifying caches, logs, leftover files, and other reclaimable items."
},
{
"label": "Review",
"description": "Scan results are listed item by item with explanations and size estimates. You decide what to keep and what to clean."
},
{
"label": "Execute",
"description": "After confirmation, Atlas performs the cleanup. Removed files are automatically backed up within the retention period, when supported."
},
{
"label": "Restore",
"description": "Within the retention window, you can restore any cleaned file from the operation history on a per-item basis."
}
]
},
"developer": {
"sectionTitle": "Developer Friendly",
"subtitle": "Atlas recognizes development toolchains and avoids removing critical files.",
"items": [
{
"title": "Toolchain Awareness",
"description": "Automatically identifies caches and artifacts from Homebrew, Xcode, Node.js, and other development environments, marking them as safe or cautious."
},
{
"title": "Project Directory Protection",
"description": "Detects .git, node_modules, build directories, and other project folders. These are excluded from scans by default to avoid disrupting your workflow."
},
{
"title": "CLI Integration",
"description": "Provides command-line tools for scripted scanning and cleaning, ready to integrate into automated workflows."
},
{
"title": "Transparent Operation Logs",
"description": "Every operation produces a complete audit log with file paths, sizes, and timestamps for easy troubleshooting."
}
]
},
"safety": {
"sectionTitle": "Safety",
"subtitle": "Atlas is built on one principle: inform before acting, restore after acting.",
"points": [
{
"title": "Preview Before Action",
"description": "A complete file list is shown before every clean operation. There is no black-box \"one-click clean\" behavior."
},
{
"title": "File-Level Backup",
"description": "Cleaned files are automatically backed up during the retention period. Per-item restore is available from the operation history, when supported."
},
{
"title": "Least Privilege",
"description": "Atlas only requests specific permissions when needed and explains each one. Core features work without Full Disk Access."
}
],
"gatekeeperGuide": {
"title": "Opening Atlas for the First Time",
"steps": [
"Download the Atlas .dmg file from the official website",
"Drag Atlas into your Applications folder",
"Right-click the Atlas icon and select \"Open\"",
"Click \"Open\" in the confirmation dialog"
]
}
},
"screenshots": {
"sectionTitle": "Interface Preview",
"items": [
{
"src": "/images/screenshots/atlas-overview.png",
"alt": "Atlas Overview interface",
"caption": "Overview — Disk health, cache breakdown, reclaimable space"
},
{
"src": "/images/screenshots/atlas-smart-clean.png",
"alt": "Atlas Smart Clean interface",
"caption": "Smart Clean — Scan results listed item by item, execute after review"
},
{
"src": "/images/screenshots/atlas-apps.png",
"alt": "Atlas Apps interface",
"caption": "Apps — View full app footprint and associated files"
},
{
"src": "/images/screenshots/atlas-history.png",
"alt": "Atlas History interface",
"caption": "History — Complete audit trail and restore entry point"
},
{
"src": "/images/screenshots/atlas-settings.png",
"alt": "Atlas Settings interface",
"caption": "Settings — Permission management and retention configuration"
}
]
},
"openSource": {
"sectionTitle": "Open Source",
"repoLabel": "GitHub Repository",
"licenseLabel": "License",
"attributionLabel": "Third-Party Attribution",
"changelogLabel": "Changelog"
},
"faq": {
"sectionTitle": "FAQ",
"items": [
{
"question": "Is Atlas signed and notarized?",
"answer": "Atlas is signed with an Apple Developer ID and verified through Apple's notarization service. On first launch, macOS Gatekeeper automatically checks its integrity. Prerelease builds may not be notarized and require manual confirmation to open."
},
{
"question": "What happens with prerelease installs?",
"answer": "Prerelease builds may include incomplete features or known issues and are intended for testing only. Since they may not be notarized by Apple, you will need to right-click and select \"Open\" on first launch. We recommend not using them as your primary tool on important machines."
},
{
"question": "Does Atlas upload my files?",
"answer": "No. Atlas runs entirely on your local machine. It does not collect or upload any user files or personal data. Scan results and operation records are stored locally. The source code is fully open for your review."
},
{
"question": "What does recovery actually mean?",
"answer": "Before cleaning, Atlas backs up files to a local retention directory. Within the retention period (30 days by default), you can restore cleaned files individually through the operation history. After the retention period expires, backups are automatically purged. This is file-level backup, not a system snapshot — some scenarios may not be covered."
},
{
"question": "Does Atlas require Full Disk Access?",
"answer": "Core features work without Full Disk Access. Atlas follows the principle of least privilege and only requests access to specific protected directories when needed, explaining the purpose before each request."
},
{
"question": "Is this a Mac App Store app?",
"answer": "Not currently. Atlas is distributed as a direct download from the official website to retain full system maintenance capabilities. The Mac App Store sandbox restrictions would limit core cleaning and recovery features. A simplified App Store version may be considered in the future."
}
]
},
"footer": {
"download": "Download Atlas",
"github": "GitHub",
"documentation": "Documentation",
"privacy": "Privacy Policy",
"security": "Security",
"copyright": "Atlas for Mac. Open-source project released under the MIT License."
}
}

View File

@@ -0,0 +1,145 @@
import zh from "./zh.json";
import en from "./en.json";
// ─── Locale types ────────────────────────────────────────────────
export type Locale = "en" | "zh";
export const defaultLocale: Locale = "zh";
export const locales: Locale[] = ["zh", "en"];
// ─── LandingCopy schema ─────────────────────────────────────────
export interface LandingCopy {
meta: {
title: string;
description: string;
ogImage: string;
};
nav: {
whyAtlas: string;
howItWorks: string;
developers: string;
safety: string;
faq: string;
download: string;
};
hero: {
headline: string;
subheadline: string;
ctaPrimary: string;
ctaSecondary: string;
badgeStable: string;
badgePrerelease: string;
badgeComingSoon: string;
prereleaseWarning: string;
gatekeeperNote: string;
versionLabel: string;
};
trustStrip: {
openSource: string;
recoveryFirst: string;
developerAware: string;
macNative: string;
directDownload: string;
};
problem: {
sectionTitle: string;
scenarios: Array<{ before: string; after: string }>;
};
features: {
sectionTitle: string;
cards: Array<{
title: string;
value: string;
example: string;
trustCue: string;
}>;
};
howItWorks: {
sectionTitle: string;
steps: Array<{ label: string; description: string }>;
};
developer: {
sectionTitle: string;
subtitle: string;
items: Array<{ title: string; description: string }>;
};
safety: {
sectionTitle: string;
subtitle: string;
points: Array<{ title: string; description: string }>;
gatekeeperGuide: {
title: string;
steps: string[];
};
};
screenshots: {
sectionTitle: string;
items: Array<{ src: string; alt: string; caption: string }>;
};
openSource: {
sectionTitle: string;
repoLabel: string;
licenseLabel: string;
attributionLabel: string;
changelogLabel: string;
};
faq: {
sectionTitle: string;
items: Array<{ question: string; answer: string }>;
};
footer: {
download: string;
github: string;
documentation: string;
privacy: string;
security: string;
copyright: string;
};
}
// ─── Translation map ────────────────────────────────────────────
const translations: Record<Locale, LandingCopy> = {
zh: zh as LandingCopy,
en: en as LandingCopy,
};
// ─── Public API ─────────────────────────────────────────────────
/**
* Returns the full translation object for the given locale.
*
* @example
* ```ts
* const copy = t('zh');
* console.log(copy.hero.headline);
* ```
*/
export function t(locale: Locale): LandingCopy {
return translations[locale] ?? translations[defaultLocale];
}
/**
* Extracts the locale from a URL pathname.
* Expects paths like `/en/...` or `/zh/...`.
* Falls back to `defaultLocale` when the prefix is not a known locale.
*
* @example
* ```ts
* getLocaleFromUrl(new URL('https://atlas.atomstorm.ai/en/'));
* // => 'en'
*
* getLocaleFromUrl(new URL('https://atlas.atomstorm.ai/'));
* // => 'zh'
* ```
*/
export function getLocaleFromUrl(url: URL): Locale {
const [, segment] = url.pathname.split("/");
if (segment && locales.includes(segment as Locale)) {
return segment as Locale;
}
return defaultLocale;
}

View File

@@ -0,0 +1,236 @@
{
"meta": {
"title": "Atlas for Mac — 可解释、可恢复的 Mac 维护工作台",
"description": "Atlas 是一款开源 macOS 维护工具,扫描前可预览,操作后可恢复。面向开发者和进阶用户设计。",
"ogImage": "/images/og-atlas-zh.png"
},
"nav": {
"whyAtlas": "为什么选 Atlas",
"howItWorks": "工作流程",
"developers": "开发者友好",
"safety": "安全机制",
"faq": "常见问题",
"download": "下载"
},
"hero": {
"headline": "Mac 维护,每一步都可解释、可恢复",
"subheadline": "Atlas 是开源的 macOS 维护工作台。扫描结果逐项说明,清理操作支持回滚,不静默删除任何文件。",
"ctaPrimary": "下载 Atlas",
"ctaSecondary": "查看源码",
"badgeStable": "稳定版",
"badgePrerelease": "预发布版",
"badgeComingSoon": "即将推出",
"prereleaseWarning": "预发布版本仅供测试,可能存在未知问题。请勿在生产环境使用。",
"gatekeeperNote": "首次打开需右键选择「打开」以通过 macOS Gatekeeper 验证。",
"versionLabel": "版本 {version} · {date}"
},
"trustStrip": {
"openSource": "完整开源",
"recoveryFirst": "恢复优先",
"developerAware": "开发者友好",
"macNative": "原生 macOS",
"directDownload": "直接下载"
},
"problem": {
"sectionTitle": "为什么选 Atlas",
"scenarios": [
{
"before": "清理工具删了文件,不告诉你删的是什么",
"after": "Atlas 扫描后逐项列出结果,你确认后才执行"
},
{
"before": "误删了配置文件,无法恢复",
"after": "Atlas 在保留期内支持逐项恢复(如适用)"
},
{
"before": "工具要求完全磁盘访问权限,却不解释原因",
"after": "Atlas 按需申请权限,每次都说明用途"
}
]
},
"features": {
"sectionTitle": "核心功能",
"cards": [
{
"title": "系统概览",
"value": "一屏掌握 Mac 健康状态",
"example": "磁盘用量、缓存占比、可回收空间,一目了然",
"trustCue": "只读扫描,不做任何修改"
},
{
"title": "智能清理",
"value": "扫描、审查计划、确认后执行",
"example": "系统缓存 3.2 GB — 查看明细后决定是否清理",
"trustCue": "每次清理前自动备份,支持回滚"
},
{
"title": "应用管理",
"value": "分析应用完整足迹并规划卸载",
"example": "查看某应用在磁盘上遗留的所有关联文件",
"trustCue": "卸载前列出全部待删文件,逐项确认"
},
{
"title": "操作历史",
"value": "完整审计轨迹与恢复时间线",
"example": "查看过去 30 天内每次清理操作的详细记录",
"trustCue": "所有操作均可追溯,不存在静默行为"
},
{
"title": "文件恢复",
"value": "保留期内可逐项还原(如适用)",
"example": "误删的配置文件可在保留窗口内恢复",
"trustCue": "基于文件级备份,非快照机制"
},
{
"title": "权限管理",
"value": "最小权限原则,用前先说明",
"example": "仅在扫描下载目录时才请求对应权限",
"trustCue": "每项权限均附带用途说明"
}
]
},
"howItWorks": {
"sectionTitle": "工作流程",
"steps": [
{
"label": "扫描",
"description": "Atlas 只读扫描磁盘,识别缓存、日志、残留文件等可清理项目。"
},
{
"label": "审查",
"description": "扫描结果逐项展示,附带说明和空间占用,由你决定保留或清理。"
},
{
"label": "执行",
"description": "确认后执行清理。被移除的文件在保留期内自动备份(如适用)。"
},
{
"label": "恢复",
"description": "在保留窗口内,可通过操作历史逐项还原已清理的文件。"
}
]
},
"developer": {
"sectionTitle": "开发者友好",
"subtitle": "Atlas 识别开发工具链,避免误删关键文件。",
"items": [
{
"title": "工具链感知",
"description": "自动识别 Homebrew、Xcode、Node.js 等开发环境的缓存和产物,标记为安全项或谨慎项。"
},
{
"title": "项目目录保护",
"description": "检测 .git、node_modules、build 等项目目录,扫描时默认排除,避免干扰开发流程。"
},
{
"title": "命令行集成",
"description": "提供 CLI 工具支持脚本化扫描和清理,方便集成到自动化工作流。"
},
{
"title": "透明的操作日志",
"description": "所有操作记录完整的审计日志,包含路径、大小和时间戳,便于排查问题。"
}
]
},
"safety": {
"sectionTitle": "安全机制",
"subtitle": "Atlas 的设计原则:操作前告知,操作后可恢复。",
"points": [
{
"title": "操作前预览",
"description": "每次清理前完整展示待处理文件列表,不存在「一键清理」的黑箱操作。"
},
{
"title": "文件级备份",
"description": "被清理的文件在保留期内自动备份,支持从操作历史中逐项恢复(如适用)。"
},
{
"title": "最小权限",
"description": "Atlas 仅在需要时请求特定权限,每次均说明用途。不要求完全磁盘访问权限即可运行基础功能。"
}
],
"gatekeeperGuide": {
"title": "首次打开 Atlas",
"steps": [
"从官网下载 Atlas .dmg 文件",
"将 Atlas 拖入「应用程序」文件夹",
"右键点击 Atlas 图标,选择「打开」",
"在弹出的对话框中点击「打开」以确认"
]
}
},
"screenshots": {
"sectionTitle": "界面预览",
"items": [
{
"src": "/images/screenshots/atlas-overview.png",
"alt": "Atlas 系统概览界面",
"caption": "系统概览 — 磁盘健康、缓存占比、可回收空间"
},
{
"src": "/images/screenshots/atlas-smart-clean.png",
"alt": "Atlas 智能清理界面",
"caption": "智能清理 — 扫描结果逐项展示,确认后执行"
},
{
"src": "/images/screenshots/atlas-apps.png",
"alt": "Atlas 应用管理界面",
"caption": "应用管理 — 查看应用完整足迹和关联文件"
},
{
"src": "/images/screenshots/atlas-history.png",
"alt": "Atlas 操作历史界面",
"caption": "操作历史 — 完整审计轨迹与恢复入口"
},
{
"src": "/images/screenshots/atlas-settings.png",
"alt": "Atlas 设置界面",
"caption": "设置 — 权限管理与保留期配置"
}
]
},
"openSource": {
"sectionTitle": "开源透明",
"repoLabel": "GitHub 仓库",
"licenseLabel": "开源协议",
"attributionLabel": "第三方致谢",
"changelogLabel": "更新日志"
},
"faq": {
"sectionTitle": "常见问题",
"items": [
{
"question": "Atlas 经过签名和公证吗?",
"answer": "Atlas 使用 Apple Developer ID 签名,并通过 Apple 公证服务验证。首次打开时macOS Gatekeeper 会自动校验完整性。预发布版本可能未经公证,需要手动确认打开。"
},
{
"question": "预发布版本安装后会怎样?",
"answer": "预发布版本可能包含未完成的功能或已知问题,仅供测试用途。由于未经 Apple 公证,首次打开需要右键选择「打开」。建议不要在重要设备上作为主力工具使用。"
},
{
"question": "Atlas 会上传我的文件吗?",
"answer": "不会。Atlas 完全在本地运行,不收集、不上传任何用户文件或个人数据。扫描结果和操作记录均存储在本机。源代码完整开源,可自行审查。"
},
{
"question": "「可恢复」具体指什么?",
"answer": "Atlas 在执行清理前,会将待删除文件备份到本地保留目录。在保留期(默认 30 天)内,你可以通过操作历史逐项恢复已清理的文件。保留期过后,备份文件将被自动清除。此机制基于文件级备份,并非系统快照,部分场景可能不适用。"
},
{
"question": "Atlas 需要「完全磁盘访问」权限吗?",
"answer": "基础功能无需完全磁盘访问权限即可运行。Atlas 遵循最小权限原则,仅在需要扫描特定受保护目录时才会请求对应权限,并在请求前说明用途。"
},
{
"question": "Atlas 是 Mac App Store 应用吗?",
"answer": "目前不是。Atlas 通过官网直接下载分发以保留完整的系统维护能力。Mac App Store 的沙箱限制会影响核心清理和恢复功能。未来可能考虑上架精简版本。"
}
]
},
"footer": {
"download": "下载 Atlas",
"github": "GitHub",
"documentation": "使用文档",
"privacy": "隐私政策",
"security": "安全说明",
"copyright": "Atlas for Mac. 开源项目,基于 MIT 协议发布。"
}
}

View File

@@ -0,0 +1,113 @@
---
import '../styles/global.css';
import '../styles/utilities.css';
interface Props {
title: string;
description: string;
locale: 'en' | 'zh';
ogImage?: string;
}
const { title, description, locale, ogImage = '/images/og-image-zh.png' } = Astro.props;
const htmlLang = locale === 'zh' ? 'zh-Hans' : 'en';
const altLocale = locale === 'zh' ? 'en' : 'zh';
const altPath = locale === 'zh' ? '/en/' : '/zh/';
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
const altUrl = new URL(altPath, Astro.site);
---
<!doctype html>
<html lang={htmlLang} data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<meta name="description" content={description} />
<!-- Canonical & hreflang -->
<link rel="canonical" href={canonicalUrl.href} />
<link rel="alternate" hreflang={htmlLang} href={canonicalUrl.href} />
<link rel="alternate" hreflang={altLocale === 'zh' ? 'zh-Hans' : 'en'} href={altUrl.href} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(ogImage, Astro.site).href} />
<meta property="og:url" content={canonicalUrl.href} />
<meta property="og:locale" content={locale === 'zh' ? 'zh_CN' : 'en_US'} />
<meta property="og:site_name" content="Atlas for Mac" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={new URL(ogImage, Astro.site).href} />
<!-- Favicon -->
<link rel="icon" type="image/png" href="/images/atlas-icon.png" />
<!-- Font preload -->
<link rel="preload" href="/fonts/SpaceGrotesk-Bold.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/InstrumentSans-Regular.woff2" as="font" type="font/woff2" crossorigin />
<!-- Structured Data: SoftwareApplication -->
<script type="application/ld+json" set:html={JSON.stringify({
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Atlas for Mac",
"operatingSystem": "macOS",
"applicationCategory": "UtilitiesApplication",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"description": description,
"url": canonicalUrl.href
})} />
<!-- Theme initialization (prevent flash) -->
<script is:inline>
(function() {
const stored = localStorage.getItem('atlas-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
<slot />
<!-- Scroll-reveal observer with stagger support -->
<script>
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
document.querySelectorAll('.fade-in').forEach((el) => observer.observe(el));
// Stagger animation support
document.querySelectorAll('.stagger-parent').forEach((parent) => {
const children = parent.querySelectorAll('.fade-in');
children.forEach((child, i) => {
child.style.setProperty('--stagger-index', String(i));
child.style.transitionDelay = `calc(var(--atlas-stagger-delay) * ${i})`;
observer.observe(child);
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,43 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import NavBar from '../../components/NavBar.astro';
import Hero from '../../components/Hero.astro';
import TrustStrip from '../../components/TrustStrip.astro';
import ProblemOutcome from '../../components/ProblemOutcome.astro';
import FeatureGrid from '../../components/FeatureGrid.astro';
import HowItWorks from '../../components/HowItWorks.astro';
import DeveloperSection from '../../components/DeveloperSection.astro';
import SafetySection from '../../components/SafetySection.astro';
import ScreenshotGallery from '../../components/ScreenshotGallery.astro';
import OpenSourceSection from '../../components/OpenSourceSection.astro';
import FaqSection from '../../components/FaqSection.astro';
import FooterSection from '../../components/FooterSection.astro';
import { t } from '../../i18n/utils';
const locale = 'en' as const;
const copy = t(locale);
---
<BaseLayout
title={copy.meta.title}
description={copy.meta.description}
locale={locale}
ogImage={copy.meta.ogImage}
>
<NavBar locale={locale} />
<main>
<Hero locale={locale} />
<TrustStrip locale={locale} />
<ProblemOutcome locale={locale} />
<FeatureGrid locale={locale} />
<HowItWorks locale={locale} />
<DeveloperSection locale={locale} />
<SafetySection locale={locale} />
<ScreenshotGallery locale={locale} />
<OpenSourceSection locale={locale} />
<FaqSection locale={locale} />
</main>
<FooterSection locale={locale} />
</BaseLayout>

View File

@@ -0,0 +1,14 @@
---
// Root redirects to default locale /zh/ instantly
---
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="refresh" content="0;url=/zh/" />
<link rel="canonical" href="/zh/" />
<title>Atlas for Mac</title>
</head>
<body></body>
</html>

View File

@@ -0,0 +1,43 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import NavBar from '../../components/NavBar.astro';
import Hero from '../../components/Hero.astro';
import TrustStrip from '../../components/TrustStrip.astro';
import ProblemOutcome from '../../components/ProblemOutcome.astro';
import FeatureGrid from '../../components/FeatureGrid.astro';
import HowItWorks from '../../components/HowItWorks.astro';
import DeveloperSection from '../../components/DeveloperSection.astro';
import SafetySection from '../../components/SafetySection.astro';
import ScreenshotGallery from '../../components/ScreenshotGallery.astro';
import OpenSourceSection from '../../components/OpenSourceSection.astro';
import FaqSection from '../../components/FaqSection.astro';
import FooterSection from '../../components/FooterSection.astro';
import { t } from '../../i18n/utils';
const locale = 'zh' as const;
const copy = t(locale);
---
<BaseLayout
title={copy.meta.title}
description={copy.meta.description}
locale={locale}
ogImage={copy.meta.ogImage}
>
<NavBar locale={locale} />
<main>
<Hero locale={locale} />
<TrustStrip locale={locale} />
<ProblemOutcome locale={locale} />
<FeatureGrid locale={locale} />
<HowItWorks locale={locale} />
<DeveloperSection locale={locale} />
<SafetySection locale={locale} />
<ScreenshotGallery locale={locale} />
<OpenSourceSection locale={locale} />
<FaqSection locale={locale} />
</main>
<FooterSection locale={locale} />
</BaseLayout>

View File

@@ -0,0 +1,115 @@
@import './reset.css';
@import './tokens.css';
/* ── Font Faces ────────────────────────────────────── */
@font-face {
font-family: 'Space Grotesk';
src: url('/fonts/SpaceGrotesk-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Space Grotesk';
src: url('/fonts/SpaceGrotesk-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Instrument Sans';
src: url('/fonts/InstrumentSans-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Instrument Sans';
src: url('/fonts/InstrumentSans-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Mono';
src: url('/fonts/IBMPlexMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Mono';
src: url('/fonts/IBMPlexMono-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
/* ── Theme Defaults ────────────────────────────────── */
html {
color-scheme: dark;
}
[data-theme="light"] {
color-scheme: light;
}
/* ── Base ──────────────────────────────────────────── */
body {
font-family: var(--atlas-font-body);
font-size: var(--atlas-text-body);
font-weight: var(--atlas-text-body-weight);
color: var(--atlas-color-text-primary);
background-color: var(--atlas-color-bg-base);
transition: background-color 0.3s, color 0.3s;
position: relative;
}
/* Noise texture overlay */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: var(--atlas-noise-opacity);
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
/* Ensure content sits above noise */
body > * {
position: relative;
z-index: 1;
}
h1, h2, h3 {
font-family: var(--atlas-font-display);
line-height: var(--atlas-leading-tight);
letter-spacing: var(--atlas-tracking-tight);
}
code, pre {
font-family: var(--atlas-font-mono);
}
a:focus-visible,
button:focus-visible {
outline: 2px solid var(--atlas-color-accent);
outline-offset: 2px;
border-radius: var(--atlas-radius-sm);
}
::selection {
background-color: rgba(20, 144, 133, 0.3);
color: var(--atlas-color-text-primary);
}

View File

@@ -0,0 +1,65 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
scroll-behavior: smooth;
}
body {
min-height: 100vh;
line-height: var(--atlas-leading-normal);
}
img, picture, video, svg {
display: block;
max-width: 100%;
height: auto;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
cursor: pointer;
border: none;
background: none;
}
ul, ol {
list-style: none;
}
h1, h2, h3, h4, h5, h6 {
overflow-wrap: break-word;
}
p {
overflow-wrap: break-word;
}
details summary {
cursor: pointer;
}
details summary::-webkit-details-marker {
display: none;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -0,0 +1,150 @@
/* ══════════════════════════════════════════════════════
Atlas Landing Page — Design Tokens
Source of truth: AtlasBrand.swift + AtlasColors.xcassets
Theme: "Precision Utility" (dark + light)
══════════════════════════════════════════════════════ */
:root {
/* ── Colors: Background ──────────────────────────── */
--atlas-color-bg-base: #0D0F11;
--atlas-color-bg-surface: #1A1D21;
--atlas-color-bg-surface-hover: #22262B;
--atlas-color-bg-raised: rgba(255, 255, 255, 0.06);
--atlas-color-bg-code: #151820;
/* ── Colors: Brand ───────────────────────────────── */
--atlas-color-brand: #149085;
--atlas-color-brand-light: #0F766E;
--atlas-color-brand-glow: rgba(20, 144, 133, 0.25);
/* ── Colors: Accent ──────────────────────────────── */
--atlas-color-accent: #34D399;
--atlas-color-accent-bright: #52E2B5;
/* ── Colors: Semantic ────────────────────────────── */
--atlas-color-success: #22C55E;
--atlas-color-warning: #F59E0B;
--atlas-color-danger: #EF4444;
--atlas-color-info: #3B82F6;
/* ── Colors: Text ────────────────────────────────── */
--atlas-color-text-primary: #F1F5F9;
--atlas-color-text-secondary: #94A3B8;
--atlas-color-text-tertiary: rgba(148, 163, 184, 0.6);
/* ── Colors: Border ──────────────────────────────── */
--atlas-color-border: rgba(241, 245, 249, 0.08);
--atlas-color-border-emphasis: rgba(241, 245, 249, 0.14);
/* ── Gradients & Effects ─────────────────────────── */
--atlas-gradient-brand: linear-gradient(135deg, #149085, #34D399);
--atlas-gradient-hero: radial-gradient(ellipse 80% 50% at 50% -20%, rgba(20, 144, 133, 0.15) 0%, transparent 70%);
--atlas-glow-brand: 0 0 60px rgba(20, 144, 133, 0.15);
--atlas-glow-card-hover: 0 8px 30px rgba(20, 144, 133, 0.12), 0 0 0 1px rgba(52, 211, 153, 0.1);
--atlas-glass-bg: rgba(255, 255, 255, 0.04);
--atlas-glass-border: rgba(255, 255, 255, 0.08);
--atlas-noise-opacity: 0.03;
/* ── Typography ──────────────────────────────────── */
--atlas-font-display: 'Space Grotesk', system-ui, sans-serif;
--atlas-font-body: 'Instrument Sans', system-ui, sans-serif;
--atlas-font-mono: 'IBM Plex Mono', ui-monospace, monospace;
--atlas-text-hero: clamp(2.5rem, 5vw, 4rem);
--atlas-text-hero-weight: 700;
--atlas-text-section: clamp(1.75rem, 3.5vw, 2.5rem);
--atlas-text-section-weight: 700;
--atlas-text-card-title: 1.25rem;
--atlas-text-card-title-weight: 600;
--atlas-text-body: 1rem;
--atlas-text-body-weight: 400;
--atlas-text-body-small: 0.875rem;
--atlas-text-label: 0.875rem;
--atlas-text-label-weight: 600;
--atlas-text-caption: 0.75rem;
--atlas-text-caption-weight: 600;
--atlas-text-caption-small: 0.6875rem;
--atlas-leading-tight: 1.2;
--atlas-leading-normal: 1.6;
--atlas-leading-relaxed: 1.8;
--atlas-tracking-tight: -0.02em;
--atlas-tracking-normal: 0;
--atlas-tracking-wide: 0.05em;
/* ── Spacing (4pt grid) ──────────────────────────── */
--atlas-space-xxs: 4px;
--atlas-space-xs: 6px;
--atlas-space-sm: 8px;
--atlas-space-md: 12px;
--atlas-space-lg: 16px;
--atlas-space-xl: 20px;
--atlas-space-xxl: 24px;
--atlas-space-screen-h: 28px;
--atlas-space-section: 32px;
--atlas-space-section-gap: 80px;
--atlas-space-section-gap-lg: 120px;
/* ── Radius ──────────────────────────────────────── */
--atlas-radius-sm: 8px;
--atlas-radius-md: 12px;
--atlas-radius-lg: 16px;
--atlas-radius-xl: 20px;
--atlas-radius-xxl: 24px;
--atlas-radius-full: 9999px;
/* ── Elevation ───────────────────────────────────── */
--atlas-shadow-flat: none;
--atlas-shadow-raised: 0 10px 18px rgba(0, 0, 0, 0.05);
--atlas-shadow-prominent: 0 16px 28px rgba(0, 0, 0, 0.09);
--atlas-shadow-cta: 0 6px 12px rgba(20, 144, 133, 0.25);
--atlas-shadow-cta-hover: 0 8px 20px rgba(20, 144, 133, 0.35);
/* ── Motion ──────────────────────────────────────── */
--atlas-motion-fast: 150ms cubic-bezier(0.2, 0, 0, 1);
--atlas-motion-standard: 220ms cubic-bezier(0.2, 0, 0, 1);
--atlas-motion-slow: 350ms cubic-bezier(0.2, 0, 0, 1);
--atlas-stagger-delay: 80ms;
--atlas-duration-float: 6s;
/* ── Layout ──────────────────────────────────────── */
--atlas-width-reading: 920px;
--atlas-width-content: 1080px;
--atlas-width-workspace: 1200px;
/* ── Navbar ──────────────────────────────────────── */
--atlas-navbar-bg: rgba(13, 15, 17, 0.85);
--atlas-mobile-menu-bg: rgba(13, 15, 17, 0.95);
}
/* ── Light Theme Overrides ─────────────────────────── */
[data-theme="light"] {
--atlas-color-bg-base: #FAFBFC;
--atlas-color-bg-surface: #F1F5F9;
--atlas-color-bg-surface-hover: #E2E8F0;
--atlas-color-bg-raised: rgba(0, 0, 0, 0.02);
--atlas-color-bg-code: #F8FAFC;
--atlas-color-text-primary: #0F172A;
--atlas-color-text-secondary: #475569;
--atlas-color-text-tertiary: rgba(71, 85, 105, 0.6);
--atlas-color-border: rgba(15, 23, 42, 0.08);
--atlas-color-border-emphasis: rgba(15, 23, 42, 0.14);
--atlas-gradient-hero: radial-gradient(ellipse 80% 50% at 50% -20%, rgba(20, 144, 133, 0.08) 0%, transparent 70%);
--atlas-glow-brand: 0 0 60px rgba(20, 144, 133, 0.1);
--atlas-glow-card-hover: 0 8px 30px rgba(20, 144, 133, 0.08), 0 0 0 1px rgba(52, 211, 153, 0.08);
--atlas-glass-bg: rgba(255, 255, 255, 0.7);
--atlas-glass-border: rgba(0, 0, 0, 0.08);
--atlas-noise-opacity: 0.02;
--atlas-shadow-raised: 0 10px 18px rgba(0, 0, 0, 0.04);
--atlas-shadow-prominent: 0 16px 28px rgba(0, 0, 0, 0.06);
--atlas-shadow-cta: 0 6px 12px rgba(20, 144, 133, 0.2);
--atlas-shadow-cta-hover: 0 8px 20px rgba(20, 144, 133, 0.3);
--atlas-navbar-bg: rgba(250, 251, 252, 0.85);
--atlas-mobile-menu-bg: rgba(250, 251, 252, 0.95);
}

View File

@@ -0,0 +1,185 @@
/* ── Layout Utilities ──────────────────────────────── */
.container {
width: 100%;
max-width: var(--atlas-width-content);
margin-inline: auto;
padding-inline: var(--atlas-space-xl);
}
@media (min-width: 640px) {
.container {
padding-inline: var(--atlas-space-section);
}
}
.section {
padding-block: var(--atlas-space-section-gap);
}
.section--lg {
padding-block: var(--atlas-space-section-gap-lg);
}
.band--dark {
background-color: var(--atlas-color-bg-base);
position: relative;
}
.band--dark::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 60% 40% at 50% 50%, rgba(20, 144, 133, 0.04) 0%, transparent 70%);
pointer-events: none;
}
.band--surface {
background-color: var(--atlas-color-bg-surface);
position: relative;
}
/* ── Accessibility ─────────────────────────────────── */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ── Text Utilities ────────────────────────────────── */
.text-secondary {
color: var(--atlas-color-text-secondary);
}
.text-tertiary {
color: var(--atlas-color-text-tertiary);
}
.text-accent {
color: var(--atlas-color-accent);
}
.text-brand {
color: var(--atlas-color-brand);
}
.text-warning {
color: var(--atlas-color-warning);
}
.font-display {
font-family: var(--atlas-font-display);
}
.font-mono {
font-family: var(--atlas-font-mono);
}
/* ── Animation Utilities ──────────────────────────── */
.fade-in {
opacity: 0;
transform: translateY(16px);
transition: opacity var(--atlas-motion-slow), transform var(--atlas-motion-slow);
}
.fade-in.is-visible {
opacity: 1;
transform: translateY(0);
}
/* ── Glass Card ──────────────────────────────────── */
.glass-card {
background: var(--atlas-glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--atlas-glass-border);
border-radius: var(--atlas-radius-xl);
}
/* ── Gradient Text ───────────────────────────────── */
.gradient-text {
background: var(--atlas-gradient-brand);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* ── Glow Hover ──────────────────────────────────── */
.glow-hover {
transition: box-shadow var(--atlas-motion-standard),
transform var(--atlas-motion-fast);
}
.glow-hover:hover {
box-shadow: var(--atlas-glow-card-hover);
}
/* ── Stagger Delays ──────────────────────────────── */
.stagger-1 { transition-delay: calc(var(--atlas-stagger-delay) * 0); }
.stagger-2 { transition-delay: calc(var(--atlas-stagger-delay) * 1); }
.stagger-3 { transition-delay: calc(var(--atlas-stagger-delay) * 2); }
.stagger-4 { transition-delay: calc(var(--atlas-stagger-delay) * 3); }
.stagger-5 { transition-delay: calc(var(--atlas-stagger-delay) * 4); }
.stagger-6 { transition-delay: calc(var(--atlas-stagger-delay) * 5); }
/* ── Float Animation ─────────────────────────────── */
.float-animation {
animation: float var(--atlas-duration-float) ease-in-out infinite;
}
/* ── Gradient Border ─────────────────────────────── */
.gradient-border {
position: relative;
}
.gradient-border::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: var(--atlas-gradient-brand);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
/* ── Keyframe Animations ─────────────────────────── */
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes pulse-glow {
0%, 100% { opacity: 0.15; }
50% { opacity: 0.25; }
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}

View File

@@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View File

@@ -458,7 +458,7 @@
buildSettings = {
AD_HOC_CODE_SIGNING_ALLOWED = YES;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 4;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)";
INFOPLIST_KEY_CFBundleVersion = "$(CURRENT_PROJECT_VERSION)";
@@ -467,7 +467,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.2;
MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app.worker;
PRODUCT_NAME = AtlasWorkerXPC;
SDKROOT = macosx;
@@ -535,7 +535,7 @@
buildSettings = {
AD_HOC_CODE_SIGNING_ALLOWED = YES;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 4;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)";
INFOPLIST_KEY_CFBundleVersion = "$(CURRENT_PROJECT_VERSION)";
@@ -544,7 +544,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.2;
MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app.worker;
PRODUCT_NAME = AtlasWorkerXPC;
SDKROOT = macosx;
@@ -557,7 +557,7 @@
AD_HOC_CODE_SIGNING_ALLOWED = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 4;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Atlas for Mac";
INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)";
@@ -569,7 +569,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.2;
MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app;
PRODUCT_MODULE_NAME = AtlasApp;
PRODUCT_NAME = "Atlas for Mac";
@@ -665,7 +665,7 @@
AD_HOC_CODE_SIGNING_ALLOWED = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 4;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Atlas for Mac";
INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)";
@@ -677,7 +677,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.2;
MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app;
PRODUCT_MODULE_NAME = AtlasApp;
PRODUCT_NAME = "Atlas for Mac";

View File

@@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
## [1.0.3] - 2026-03-23
### Added
- `Apps` uninstall preview now records structured review-only evidence groups, including observed paths for support files, caches, preferences, logs, and launch items.
- Added scripted fixture baselines for `Apps` evidence and `Smart Clean` verification through `scripts/atlas/apps-manual-fixtures.sh`, `scripts/atlas/apps-evidence-acceptance.sh`, and the expanded Smart Clean fixture script.
- Added versioned workspace-state persistence and schema-versioned app recovery payloads so future recovery hardening can evolve without immediately dropping older local state.
- Expanded real `Smart Clean` execution coverage for additional high-confidence user-owned roots, including CoreSimulator caches and common developer cache locations such as Gradle, Ivy, and SwiftPM caches.
### Changed
- App restore now clears stale uninstall preview state and refreshes app inventory before the `Apps` surface reuses footprint counts.
- `History` recovery detail now surfaces recorded recovery evidence, including payload schema version, review-only uninstall groups, and restore-path mappings when available.
- `full-acceptance` now treats fixture-script validation as a routine release-readiness gate alongside packaging, install, launch, and UI automation checks.
- Local protocol and persistence contracts were tightened to distinguish versioned workspace-state storage from legacy compatibility fallback, while keeping review-only evidence explicitly non-executable.
### Fixed
- Legacy unversioned workspace-state files now migrate forward into the current persisted envelope instead of breaking on the newer persistence shape.
- Revalidated the `1.0.3` release candidate with package tests, app tests, native packaging, DMG install verification, installed-app launch smoke, and native UI automation.
- Fixed the post-restore `Apps` trust gap where recovered app payloads could leave stale uninstall preview or stale footprint evidence visible until a manual refresh.
## [1.0.2] - 2026-03-14
### Added

View File

@@ -0,0 +1,31 @@
# ADR-008: Versioned Workspace State and Recovery Payload Compatibility
## Status
Accepted
## Context
Atlas persists a local workspace-state file and stores recovery payloads for both `Finding` and app uninstall flows. Those payloads have already evolved in place: app recovery entries gained uninstall evidence, and the active roadmap now explicitly calls for payload stability, older-state compatibility, and more trustworthy history behavior.
Without an explicit persistence envelope, Atlas can only rely on best-effort shape decoding. That makes future recovery hardening riskier and leaves migration implicit. The `Apps` flow also risks showing stale preview or stale footprint counts after restore unless restored app payloads are reconciled with fresh app inventory.
## Decision
- Atlas persists workspace state inside a versioned JSON envelope containing `schemaVersion`, `savedAt`, `snapshot`, `currentPlan`, and `settings`.
- Atlas continues to decode legacy top-level `AtlasWorkspaceState` files and rewrites them into the current envelope after a successful load when possible.
- `AppRecoveryPayload` carries an explicit `schemaVersion` and remains backward-compatible with the older raw-`AppFootprint` recovery payload shape.
- App restore flows clear stale uninstall preview state and refresh app inventory before the `Apps` surface reuses footprint counts.
## Consequences
- Atlas now has an explicit persistence contract for future migration work instead of relying on implicit shape matching alone.
- Older state files remain loadable while the repo transitions to the versioned envelope.
- App recovery payloads become safer to evolve because compatibility is now a stated requirement.
- The `Apps` surface becomes more trustworthy after restore because it no longer depends only on stale pre-uninstall preview state.
## Alternatives Considered
- Keep the unversioned top-level state file and rely on ad hoc per-type decoding: rejected because it scales poorly as recovery payloads evolve.
- Break compatibility and require a fresh state file: rejected because it damages trust in `History` and `Recovery`.
- Refresh app inventory only on explicit user action after restore: rejected because it leaves a visible stale-evidence gap in a trust-critical workflow.

View File

@@ -39,7 +39,7 @@
### Infrastructure
- XPC transport
- JSON-backed workspace state persistence
- Versioned JSON-backed workspace state persistence with legacy-shape migration on load
- Recovery-state normalization that prunes expired recovery entries on load/save
- Logging and audit events
- Best-effort permission inspection
@@ -56,6 +56,7 @@
- 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
- App recovery payloads now carry an explicit schema version, and app restores should be followed by inventory refresh so the `Apps` surface does not keep stale footprint evidence
## Process Boundaries

View File

@@ -51,6 +51,11 @@
- `EPIC-18` Public Beta Feedback and Trust Closure
- `EPIC-19` GA Recovery and Execution Hardening
- `EPIC-20` GA Launch Readiness
- `EPIC-21` Marketing Site and Direct Distribution Landing Page
- `EPIC-A` Apps Evidence Execution
- `EPIC-B` Smart Clean Safe Coverage Expansion
- `EPIC-C` Recovery Payload Hardening
- `EPIC-D` Release Readiness
## Now / Next / Later
@@ -207,6 +212,7 @@
- `Complete` — frozen MVP is implemented and internally beta-ready.
- `Blocked` — release trust still depends on removing silent fallback and tightening execution/recovery honesty.
- `Dormant` — signed public beta work is inactive until Apple signing/notarization credentials exist.
- `Superseded` — the live post-hardening epic sequence now lives in `Current Mainline Priority Order` below.
### Focus
@@ -231,13 +237,15 @@
- Run bilingual manual QA on a clean machine
- Validate packaged first-launch behavior with a fresh state file
- Tighten release-facing copy where execution or recovery is overstated
- Map competitor pressure from `Mole`, `Lemon`, and `Pearcleaner` into frozen-MVP parity work only
#### Next
- Expand real `Smart Clean` execute coverage for the highest-value safe targets
- Expand real `Smart Clean` execute coverage for the highest-value safe targets most likely compared to `Mole` and `Lemon`
- Add stronger `scan -> execute -> rescan` contract coverage
- Implement physical restore for file-backed recoverable actions, or narrow product claims
- Freeze recovery-related copy only after behavior is proven
- Deepen the `Apps` module against the most obvious `Pearcleaner` and `Lemon` comparison points without expanding beyond MVP
#### Later
@@ -259,7 +267,7 @@
#### Release Phase 2: Smart Clean Execution Credibility
- `ATL-211` Expand real `Smart Clean` execute coverage for top safe target classes — `System Agent`
- `ATL-211` Expand real `Smart Clean` execute coverage for top safe target classes most likely compared to `Mole` and `Lemon``System Agent`
- `ATL-212` Carry executable structured targets through the worker path — `Core Agent`
- `ATL-213` Add stronger `scan -> execute -> rescan` contract coverage — `QA Agent`
- `ATL-214` Make history and completion states reflect real side effects only — `Mac App Agent`
@@ -273,7 +281,15 @@
- `ATL-224` Freeze recovery contract and acceptance evidence — `Product Agent`
- `ATL-225` Recovery credibility gate review — `Product Agent`
#### Conditional Release Phase 4: Signed Distribution and External Beta
#### Release Phase 4: Apps Competitive Depth
- `ATL-226` Build a competitor-pressure matrix for `Apps` using representative `Pearcleaner` and `Lemon` uninstall scenarios — `Product Agent` + `QA Agent`
- `ATL-227` Expand uninstall preview taxonomy and leftover evidence for supported app footprint categories — `Core Agent` + `Mac App Agent`
- `ATL-228` Surface recoverability, auditability, and supported-vs-review-only cues directly in the `Apps` flow — `UX Agent` + `Mac App Agent`
- `ATL-229` Validate uninstall depth on mainstream and developer-heavy fixture apps — `QA Agent`
- `ATL-230` Apps competitive depth gate review — `Product Agent`
#### Conditional Release Phase 5: Signed Distribution and External Beta
- `ATL-231` Obtain Apple release signing credentials — `Release Agent`
- `ATL-232` Pass `signing-preflight.sh` on the release machine — `Release Agent`
@@ -282,6 +298,94 @@
- `ATL-235` Run a trusted hardware-diverse signed beta cohort — `Product Agent`
- `ATL-236` Triage public-beta issues before any GA candidate naming — `Product Agent`
#### Launch Surface Phase 6: Landing Page and Domain
- `ATL-241` Finalize landing-page PRD, CTA policy, and bilingual information architecture — `Product Agent`
- `ATL-242` Design and implement the marketing site in `Apps/LandingSite/``Mac App Agent`
- `ATL-243` Add GitHub Pages deployment workflow and environment protection — `Release Agent`
- `ATL-244` Bind and verify a dedicated custom domain with HTTPS enforcement — `Release Agent`
- `ATL-245` Surface release-channel state, download guidance, and prerelease install help on the page — `UX Agent`
- `ATL-246` Add privacy-respecting analytics and launch QA for desktop/mobile — `QA Agent`
## Current Mainline Priority Order
### Current Status
- `Complete` — internal beta hardening established the current execution-honesty baseline.
- `Open` — the most visible comparison pressure is now concentrated in `Apps` and `Smart Clean`.
- `Blocked` — final public-signing work still depends on `Developer ID` and notarization materials, so release mechanics are not the immediate product-path blocker.
### Order Rule
Execute the next mainline epics in this order only:
1. `EPIC-A` Apps Evidence Execution
2. `EPIC-B` Smart Clean Safe Coverage Expansion
3. `EPIC-C` Recovery Payload Hardening
4. `EPIC-D` Release Readiness
Reason:
- the clearest competitive differentiation pressure is in `Apps` and `Smart Clean`
- the current release chain is already mostly working in pre-signing form
- the gating blocker for public release remains missing signing materials, not packaging mechanics
### Epics
- `EPIC-A` Apps Evidence Execution
- `EPIC-B` Smart Clean Safe Coverage Expansion
- `EPIC-C` Recovery Payload Hardening
- `EPIC-D` Release Readiness
### Now / Next / Later
#### Now
- `EPIC-A` Apps Evidence Execution
#### Next
- `EPIC-B` Smart Clean Safe Coverage Expansion
#### Later
- `EPIC-C` Recovery Payload Hardening
- `EPIC-D` Release Readiness
### Seed Issues
#### EPIC-A: Apps Evidence Execution
- `ATL-251` Define the fixture app baseline for mainstream and developer-heavy uninstall scenarios — `QA Agent`
- `ATL-252` Make `Apps` preview, completion, and history render the same uninstall evidence model end to end — `Core Agent` + `Mac App Agent`
- `ATL-253` Define the restore-triggered app-footprint refresh policy and stale-evidence behavior after recovery — `Core Agent` + `System Agent`
- `ATL-254` Script the manual acceptance flow for uninstall evidence, restore, and post-restore refresh verification — `QA Agent`
- `ATL-255` Apps evidence execution gate review — `Product Agent`
#### EPIC-B: Smart Clean Safe Coverage Expansion
- `ATL-256` Define the next batch of high-confidence safe roots outside app containers and freeze the no-go boundaries — `System Agent`
- `ATL-257` Stabilize `review-only` vs `executable` boundary metadata and UI cues across scan, review, execute, completion, and history — `Core Agent` + `Mac App Agent`
- `ATL-258` Add `scan -> execute -> rescan` evidence capture and contract coverage for the expanded safe roots — `QA Agent`
- `ATL-259` Implement and validate the next safe-root execution slice without widening into high-risk cleanup paths — `System Agent`
- `ATL-260` Smart Clean safe coverage gate review — `Product Agent`
#### EPIC-C: Recovery Payload Hardening
- `ATL-261` Freeze the recovery payload schema, versioning rules, and compatibility contract — `Core Agent`
- `ATL-262` Add migration and compatibility handling for older workspace and history state files — `Core Agent` + `System Agent`
- `ATL-263` Expand `History` detail evidence to show restore payload, conflict, expiry, and partial-restore outcomes — `Mac App Agent`
- `ATL-264` Add regression coverage for restore conflict, expired payload, and partial-restore scenarios — `QA Agent`
- `ATL-265` Recovery payload hardening gate review — `Product Agent`
#### EPIC-D: Release Readiness
- `ATL-266` Make `full-acceptance` a routine gate on the candidate build instead of a one-off release exercise — `QA Agent`
- `ATL-267` Stabilize UI automation for trust-critical `Overview`, `Smart Clean`, `Apps`, `History`, and `Recovery` flows — `QA Agent` + `Mac App Agent`
- `ATL-268` Freeze packaging, install, and launch smoke checks as repeatable release scripts — `Release Agent`
- `ATL-269` Switch from the pre-signing release chain to `Developer ID + notarization` once credentials are available — `Release Agent`
- `ATL-270` Release readiness gate review — `Product Agent`
## Definition of Ready
- Scope is clear and bounded

View File

@@ -58,6 +58,22 @@
- Atlas must not silently fall back to scaffold behavior for release-facing cleanup execution
- Smart Clean execute must not claim success until real filesystem side effects are implemented
### D-010 Competitive Response Stays Inside Frozen MVP
- Atlas responds to competitor pressure through `selective parity`, not breadth racing
- `Mole` and `Tencent Lemon Cleaner` set the main comparison pressure for `Smart Clean`
- `Pearcleaner` and `Tencent Lemon Cleaner` set the main comparison pressure for `Apps`
- Competitor response work must deepen existing MVP flows rather than reopen deferred scope
- `Storage treemap`, `Menu Bar`, and `Automation` remain out of scope unless the decision log is updated explicitly
- Atlas should compete as an `explainable, recovery-first Mac maintenance workspace`, not as a generic all-in-one cleaner
### D-011 Versioned Workspace State and Recovery Payload Compatibility
- Persisted workspace state uses a versioned JSON envelope instead of an unversioned top-level payload
- Atlas must continue decoding older top-level workspace-state files and rewrite them into the current envelope when possible
- App recovery payloads carry an explicit schema version and must remain backward-compatible with legacy app-only recovery payload shapes
- App payload restores must refresh app inventory before `Apps` reuses footprint counts or uninstall preview state
## Update Rule
Add a new decision entry whenever product scope, protocol, privilege boundaries, release route, or recovery model changes.

View File

@@ -57,6 +57,11 @@ Before starting beta acceptance, confirm all of the following:
- [ ] User can execute preview
- [ ] Execution updates `History`
- [ ] Execution creates `Recovery` entries for recoverable items
- [ ] Supported cleanup classes are explicit
- [ ] Unsupported or review-only cleanup paths fail closed visibly
- [ ] `scan -> execute -> rescan` proof exists for the validating supported fixture
- [ ] The validating supported fixture is an app-container cache or temp target under `~/Library/Containers/...`
- [ ] The validating unsupported fixture is a launch-agent, service-adjacent, or otherwise review-only target
### Apps
- [ ] User can open `Apps`
@@ -65,6 +70,11 @@ Before starting beta acceptance, confirm all of the following:
- [ ] User can execute uninstall
- [ ] Uninstall updates `History`
- [ ] Uninstall creates `Recovery` entry
- [ ] Uninstall preview explains supported footprint categories, not only totals
- [ ] Review-only evidence displays concrete observed paths for at least one validating fixture
- [ ] Completion state explains what Atlas actually removed and what was recorded for recovery/history
- [ ] History / Recovery detail distinguishes the recoverable bundle from review-only leftover evidence
- [ ] Fixture coverage includes one mainstream GUI app, one developer-heavy app, one launch-item/service-adjacent app, and one sparse-leftover app
### History / Recovery
- [ ] History shows recent task runs
@@ -113,6 +123,7 @@ Mark beta candidate as ready only if all are true:
- [ ] Install path has been validated on the current candidate build
- [ ] Known unsupported areas are explicitly documented
- [ ] Release-signing status is explicit
- [ ] `Smart Clean` and `Apps` do not overclaim parity with broader cleaner tools
## Sign-off

View File

@@ -0,0 +1,249 @@
# Competitive Strategy Plan — 2026-03-21
## Purpose
Turn the findings in [Open-Source-Competitor-Research-2026-03-21.md](./Open-Source-Competitor-Research-2026-03-21.md) into a practical strategy for Atlas for Mac's next execution window.
This plan assumes:
- MVP scope remains frozen to the current modules
- direct distribution remains the only MVP release route
- strategy should improve competitive position by deepening existing flows, not by reopening deferred scope
## Strategic Options Considered
### Option A: Breadth Race
Try to match `Mole` and `Lemon` feature-for-feature as quickly as possible.
- Upside:
- easier feature checklist comparisons
- broader marketing claims
- Downside:
- high scope pressure
- weakens Atlas's current trust advantage
- increases risk of half-implemented cleanup claims
- encourages pulling deferred items like `Storage treemap`
Decision: reject.
### Option B: Trust-Only Niche
Ignore breadth pressure and compete only on recovery, permissions, and execution honesty.
- Upside:
- strongest alignment with Atlas architecture
- lowest scope risk
- Downside:
- leaves obvious product comparison gaps open
- makes Atlas look elegant but underpowered next to `Mole`, `Lemon`, and `Pearcleaner`
Decision: reject.
### Option C: Recommended Strategy
Compete on `trust-first native workspace`, while selectively closing the most visible parity gaps inside frozen MVP.
- Upside:
- preserves Atlas's strongest differentiation
- improves user-visible competitiveness where comparison pressure is highest
- avoids scope creep
- Downside:
- requires disciplined prioritization and clear no-go boundaries
Decision: adopt.
## Strategic Thesis
Atlas should not try to become a generic all-in-one cleaner. It should become the most trustworthy native Mac maintenance workspace, then remove the most painful reasons users would otherwise choose `Mole`, `Lemon`, or `Pearcleaner`.
That means:
1. Win on execution honesty, recoverability, auditability, and permission clarity.
2. Close only the highest-pressure breadth gaps inside existing MVP flows.
3. Make Atlas's differentiation visible enough that users can understand it without reading architecture docs.
## Competitive Reading
### `Mole`
Primary pressure:
- broad cleanup coverage
- developer-oriented cleanup
- disk analysis and status breadth
- strong dry-run and automation posture
Atlas response:
- do not chase terminal ergonomics
- close the most visible safe cleanup coverage gaps in `Smart Clean`
- keep the trust advantage by failing closed and showing real side effects only
### `Tencent Lemon Cleaner`
Primary pressure:
- native GUI breadth
- large-file / duplicate / privacy / uninstall / startup-item utility expectations
- Chinese-speaking user familiarity with one-click cleaner workflows
Atlas response:
- stay native and polished
- avoid claiming equivalent breadth until behavior is real
- compete with safer workflows, clearer recommendations, and higher trust in destructive actions
### `Pearcleaner`
Primary pressure:
- uninstall depth
- leftovers and app-adjacent cleanup
- macOS-native integration quality
Atlas response:
- treat `Apps` as a serious competitive surface, not just an MVP checklist module
- deepen uninstall preview and explain what will be removed, what is recoverable, and what remains review-only
### `Czkawka` and `GrandPerspective`
Primary pressure:
- high-performance file hygiene primitives
- treemap-based storage analysis
Atlas response:
- borrow architectural lessons only
- keep `Storage treemap` deferred
- do not import GPL-constrained UI paths into Atlas
## Strategic Pillars
### Pillar 1: Build a Trust Moat
Atlas's strongest defendable position is trust architecture:
- structured worker/helper boundaries
- recoverable destructive actions
- history and auditability
- permission explanations instead of permission ambush
- honest failure when Atlas cannot prove execution
This must remain the primary product story and the primary release gate.
### Pillar 2: Close Selective Parity Gaps
Atlas should close the gaps users notice immediately in side-by-side evaluation:
- `Smart Clean` coverage on high-confidence safe targets users expect from `Mole` and `Lemon`
- `Apps` uninstall depth and leftovers clarity users expect from `Pearcleaner` and `Lemon`
This is selective parity, not full parity. The rule is: only deepen flows already inside frozen MVP.
### Pillar 3: Make Differentiation Visible
Atlas cannot rely on architecture alone. The product must visibly communicate:
- what is recoverable
- what is executable now
- what requires permission and why
- what changed on disk after execution
- what Atlas intentionally refuses to do
If users cannot see these differences in the UI and release materials, Atlas will be compared as "another cleaner" and lose to broader tools.
## 90-Day Execution Direction
### Phase 1: Trust and Claim Discipline
Target outcome:
- Atlas's release-facing claims are narrower than its real behavior, never broader
Priority work:
- execution honesty
- recovery claim discipline
- permission and limited-mode clarity
- visible trust markers in `Smart Clean`, `Apps`, `History`, and `Permissions`
### Phase 2: Smart Clean Competitive Depth
Target outcome:
- the highest-value safe cleanup classes compared against `Mole` and `Lemon` have real execution paths
Priority work:
- expand safe cleanup target coverage
- strengthen `scan -> execute -> rescan` proof
- make history reflect only real side effects
### Phase 3: Recovery Credibility
Target outcome:
- Atlas's recovery promise is provable and product-facing copy can be frozen without caveats that undercut trust
Priority work:
- physical restore where safe
- clear split between file-backed restore and Atlas-only state restore
- explicit validation evidence
### Phase 4: Apps Competitive Depth
Target outcome:
- Atlas's `Apps` module is defensible against `Pearcleaner` and `Lemon` for the most common uninstall decision paths
Priority work:
- deeper uninstall preview taxonomy
- clearer leftovers and footprint reasoning
- visible recoverability and audit cues in the uninstall flow
- fixture-based validation on mainstream and developer-heavy apps
## No-Go Boundaries
The competitor response must not trigger:
- `Storage treemap`
- `Menu Bar`
- `Automation`
- duplicate-file or similar-photo modules as new product surfaces
- privacy-cleaning module expansion outside existing MVP framing
- code reuse from `Lemon`, `GrandPerspective`, or GPL-constrained `Czkawka` paths
- monetization-sensitive reuse from `Pearcleaner`
## Metrics and Gates
### Product Metrics
- first scan completion rate
- scan-to-execution conversion rate
- uninstall preview-to-execute conversion rate
- permission completion rate
- recovery success rate
- user-visible reclaimed space
### Competitive Readiness Gates
- `Smart Clean` can prove meaningful gains on the top safe categories users compare against `Mole` and `Lemon`
- `Apps` uninstall preview is detailed enough that users understand footprint, leftovers, and recoverability before confirmation
- no release-facing copy implies full parity with broader tools when Atlas only supports a narrower subset
- recovery language stays tied to shipped behavior only
## Resulting Strategy Call
For the next planning window, Atlas should be managed as:
- a `trust-first Mac maintenance workspace`
- with `selective parity` against `Mole` and `Lemon` in `Smart Clean`
- with `targeted depth` against `Pearcleaner` and `Lemon` in `Apps`
- while keeping all non-MVP expansion pressure explicitly frozen
This is the narrowest strategy that still improves Atlas's competitive position in a way users will actually feel.

View File

@@ -0,0 +1,243 @@
# ATL-211 / ATL-214 / ATL-226-230 Implementation Plan
> **For Codex:** Use this as the first coding-slice plan for the selective-parity strategy. Keep scope inside frozen MVP. Verify behavior with narrow tests and fixture-driven checks before broader UI validation.
## Goal
Make Atlas more defensible against `Mole`, `Tencent Lemon Cleaner`, and `Pearcleaner` without expanding beyond MVP:
- increase `Smart Clean` competitive credibility on the most visible safe cleanup classes
- deepen the `Apps` module so uninstall preview and completion evidence feel trustworthy and specific
## Product Rules
- Do not add new top-level product surfaces.
- Do not imply parity with broader tools when Atlas still supports only a narrower subset.
- Prefer explicit supported-vs-review-only states over speculative or partial execution.
- Prefer categories Atlas can prove and recover over categories that merely look complete in the UI.
## Target Outcomes
### Smart Clean
- Atlas can execute more of the high-confidence safe cleanup classes users naturally compare to `Mole` and `Lemon`.
- Completion and history copy only describe real disk-backed side effects.
- Unsupported paths remain explicit and fail closed.
### Apps
- Atlas explains what an uninstall preview actually contains.
- Users can understand bundle footprint, leftovers, launch-item/service implications, and recoverability before confirming.
- The uninstall flow leaves stronger audit and completion evidence than the current baseline.
## Competitive Pressure Matrix
### Smart Clean comparison pressure
Top overlap with `Mole` and `Lemon`:
- user cache roots
- logs and temp data
- developer artifact roots
- high-confidence app-specific junk paths already represented structurally in Atlas
Non-goals for this slice:
- duplicate file cleanup
- similar photos
- privacy-cleaning as a standalone surface
- treemap storage analysis
### Apps comparison pressure
Top overlap with `Pearcleaner` and `Lemon`:
- uninstall leftovers clarity
- launch-item / service-adjacent footprint awareness
- better explanation of what Atlas will remove, what it will not remove, and what is recoverable
Non-goals for this slice:
- Homebrew manager as a new standalone module
- PKG manager as a new standalone module
- plugin manager as a new standalone module
- auto-cleaning daemon behavior
## Task 1: Freeze Fixture and Benchmark Set
### Goal
Define the exact scenarios the coding slice must satisfy before implementation starts drifting.
### Deliverables
- a `Smart Clean` target-class list used for parity work
- an `Apps` fixture list covering:
- mainstream GUI app
- developer-heavy app
- app with launch item or service artifact
- app with sparse leftovers
### Proposed file touch map
- `Docs/Execution/MVP-Acceptance-Matrix.md`
- `Docs/Execution/Beta-Acceptance-Checklist.md`
- future test fixtures under `Testing/` or package test directories as implementation chooses
### Acceptance
- each benchmark scenario is tied to a real competitor comparison pressure
- each benchmark scenario is tied to a current MVP surface
## Task 2: Smart Clean Selective Parity Increment
### Goal
Land one implementation slice that widens competitive coverage without weakening trust.
### Likely code areas
- `Packages/AtlasCoreAdapters/Sources/AtlasCoreAdapters/MoleSmartCleanAdapter.swift`
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift`
- `Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift`
- `Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift`
- `Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/SmartCleanFeatureView.swift`
### Work shape
- extend safe allowlisted target support only where Atlas already has structured evidence
- tighten plan/execution/result summaries so history reflects actual side effects only
- expose unsupported findings as review-only rather than ambiguous ready-to-run items
### Tests
- package-level contract tests for `scan -> execute -> rescan`
- app-model tests for explicit unsupported or unavailable states
### Acceptance
- at least one new high-confidence safe target class is supported end to end
- unsupported target classes remain visibly unsupported
- no history or completion surface overclaims what happened on disk
## Task 3: Apps Preview Taxonomy and Evidence
### Goal
Make uninstall preview feel materially stronger against `Pearcleaner` and `Lemon`.
### Likely code areas
- `Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift`
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift`
- `Packages/AtlasProtocol/`
- `Packages/AtlasFeaturesApps/Sources/AtlasFeaturesApps/AppsFeatureView.swift`
- `Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift`
### Work shape
- expand preview categories so the UI can group footprint evidence more explicitly
- distinguish:
- application bundle
- support files
- caches
- preferences
- logs
- launch/service-adjacent items where Atlas can safely classify them
- keep unclear or unsupported items explicitly separate instead of silently mixing them into a generic leftover count
### Tests
- application-layer tests for preview taxonomy
- infrastructure tests for structured footprint generation
- UI snapshot or app-model assertions for preview grouping if feasible
### Acceptance
- uninstall preview provides more than a count and byte total
- supported footprint categories are explicit and user-comprehensible
- ambiguous items are clearly labeled rather than overstated
## Task 4: Apps Trust Cues and Completion Evidence
### Goal
Turn `Apps` from a functional uninstall screen into a trust-differentiated uninstall workflow.
### Likely code areas
- `Packages/AtlasFeaturesApps/Sources/AtlasFeaturesApps/AppsFeatureView.swift`
- `Apps/AtlasApp/Sources/AtlasApp/AppShellView.swift`
- localization resources in `Packages/AtlasDomain`
### Work shape
- add supported-vs-review-only messaging where relevant
- show recoverability and audit implications before destructive confirmation
- tighten completion summaries so they state what Atlas actually removed and what was recorded for recovery/history
### Tests
- app-model tests for completion summaries and history-driven state
- focused UI verification for uninstall preview -> execute -> history/recovery path
### Acceptance
- the flow answers, before confirm:
- what will be removed
- what evidence Atlas has
- what is recoverable
- the flow answers, after confirm:
- what was actually removed
- what was recorded
## Task 5: Fixture-Based Validation and Gate
### Goal
Prove the coding slice against realistic comparison cases instead of synthetic happy-path only.
### Validation layers
- narrow package tests first
- app-model tests second
- manual or scripted fixture walkthrough third
### Required fixture scenarios
- one mainstream app uninstall
- one developer-heavy app uninstall
- one `Smart Clean` scenario with newly supported target class
- one unsupported scenario that must fail closed visibly
### Gate outputs
- evidence summary for `ATL-229`
- short gate review for `ATL-230`
- any claim narrowing needed in UI or release-facing docs
## Recommended Coding Order
1. freeze fixtures and target classes
2. implement one `Smart Clean` selective-parity increment
3. land uninstall preview taxonomy changes
4. land uninstall trust cues and completion evidence
5. rerun fixture validation and produce gate evidence
## Commands To Prefer During Implementation
```bash
swift test --package-path Packages
swift test --package-path Apps
swift test --package-path Packages --filter AtlasInfrastructureTests
swift test --package-path Apps --filter AtlasAppModelTests
```
Add narrower filters once the exact tests are created.
## Done Criteria
- `Smart Clean` parity increment is real and test-backed
- `Apps` preview is structurally richer and more trustworthy
- fixture validation shows visible improvement against the current baseline
- docs remain honest about supported vs unsupported behavior

View File

@@ -0,0 +1,509 @@
# Landing Page PRD — 2026-03-14
## Summary
- Product: `Atlas for Mac` landing page
- Type: marketing site + release-distribution entry
- Primary goal: turn qualified visitors into `download`, `GitHub visit/star`, and `beta updates` conversions
- Deployment baseline: `GitHub Pages` with a custom domain and GitHub Actions deployment workflow
- Recommended source location at implementation time: `Apps/LandingSite/`
This PRD is intentionally separate from the core app MVP PRD. It does not change the frozen app-module scope. It defines a branded acquisition and trust surface for direct distribution.
## Background
Atlas for Mac now has a usable direct-distribution path, GitHub Releases, packaging automation, and prerelease support. What it does not yet have is a purpose-built landing page that:
- explains the product quickly to first-time visitors
- distinguishes signed releases from prereleases honestly
- reduces fear around cleanup, permissions, and Gatekeeper warnings
- converts open-source interest into actual downloads
- gives the project a canonical domain instead of relying on the repository README alone
The landing page must behave like a launch surface, not a generic OSS README mirror.
## Goals
- Present Atlas as a modern, trust-first Mac utility, not a vague “cleaner”
- Clarify the product promise in under 10 seconds
- Make direct download, GitHub proof, and safety signals visible above the fold
- Support both `English` and `简体中文`
- Explain prerelease installation honestly when Apple-signed distribution is unavailable
- Provide a stable deployment path on GitHub with a separately managed custom domain
- Stay lightweight, static-first, and easy to maintain by a small team
## Non-Goals
- No full documentation portal in v1
- No in-browser disk scan demo
- No account system
- No pricing or checkout flow in v1
- No blog/CMS dependency required for initial launch
- No analytics stack that requires invasive tracking cookies by default
## Target Users
### Primary
- Mac users with recurring disk pressure or system clutter
- Developers with Xcode, simulators, containers, caches, package-manager artifacts, and build leftovers
- Users evaluating alternatives to opaque commercial cleanup apps
### Secondary
- Open-source-oriented users who want to inspect the code before installing
- Tech creators and reviewers looking for screenshots, positioning, and trust signals
- Early beta testers willing to tolerate prerelease install friction
## User Jobs
- “Tell me what Atlas actually does in one screen.”
- “Help me decide whether this is safe enough to install.”
- “Show me how Atlas is different from aggressive one-click cleaners.”
- “Let me download the latest build without digging through GitHub.”
- “Explain whether this is a signed release or a prerelease before I install.”
## Positioning
### Core Positioning Statement
Atlas for Mac is an explainable, recovery-first Mac maintenance workspace that helps people understand why their Mac is slow, full, or disorganized, then take safer action.
### Core Differentiators To Surface
- `Explainable` instead of opaque
- `Recovery-first` instead of destructive-by-default
- `Developer-aware` instead of mainstream-only cleanup
- `Open source` instead of black-box utility
- `Least-privilege` instead of front-loaded permission pressure
### Messaging Constraints
- Do not use the `Mole` brand in user-facing naming
- Do not claim malware protection or antivirus behavior
- Do not overstate physical recovery coverage
- Do not imply that all releases are Apple-signed if they are not
## Success Metrics
### Primary Metrics
- Landing-page visitor to download CTA click-through rate
- Download CTA click to GitHub Release visit rate
- Stable-release vs prerelease download split
- GitHub repo visit/star rate from the landing page
- Beta updates signup conversion rate if email capture ships
### Secondary Metrics
- Time on page
- Scroll depth to trust/safety section
- Screenshot gallery interaction rate
- FAQ expansion rate
- Core Web Vitals pass rate
## Release and Distribution Strategy
The page must treat release channel status as product truth, not as hidden implementation detail.
### Required States
- `Signed Release Available`
- `Prerelease Only`
- `No Public Download Yet`
### CTA Behavior
- If a signed stable release exists: primary CTA is `Download for macOS`
- If only prerelease exists: primary CTA is `Download Prerelease`, with an explicit warning label
- If no downloadable release exists: primary CTA becomes `View on GitHub` or `Join Beta Updates`
### Required Disclosure
- Show exact version number and release date
- Show channel badge: `Stable`, `Prerelease`, or `Internal Beta`
- Show a short install note if the selected asset may trigger Gatekeeper friction
## Information Architecture
The landing page should be a single-scroll page with anchored sections and a sticky top nav.
### Top Navigation
- Logo / wordmark
- `Why Atlas`
- `How It Works`
- `Developers`
- `Safety`
- `FAQ`
- language toggle
- primary CTA
### Page Sections
#### 1. Hero
- Purpose: make the product understandable immediately
- Content:
- strong product headline
- short subheadline
- primary CTA
- secondary CTA to GitHub
- release-state badge
- macOS screenshot or stylized product frame
- Copy angle:
- “Understand whats taking space”
- “Review before cleaning”
- “Recover when supported”
#### 2. Trust Signal Strip
- Open source
- Recovery-first
- Developer-aware cleanup
- macOS native workspace
- Direct download / GitHub Releases
#### 3. Problem-to-Outcome Narrative
- “Mac is full” -> Atlas explains why
- “Caches and leftovers are unclear” -> Atlas turns findings into an action plan
- “Cleanup feels risky” -> Atlas emphasizes reversibility and history
#### 4. Feature Story Grid
- `Overview`
- `Smart Clean`
- `Apps`
- `History`
- `Recovery`
- `Permissions`
Each card must show:
- user-facing value
- one concrete example
- one trust cue
#### 5. How It Works
- Scan
- Review plan
- Execute safe actions
- Restore when supported
This section should visualize the workflow as a clear four-step progression.
#### 6. Developer Cleanup Section
- Xcode derived data
- simulators
- package manager caches
- build artifacts
- developer-oriented disk pressure scenarios
This section exists because developer cleanup coverage is one of Atlass most differentiated acquisition angles.
#### 7. Safety and Permissions Section
- explain least-privilege behavior
- explain why Atlas does not request everything up front
- explain release channel status honestly
- include the prerelease Gatekeeper warning visual when relevant
#### 8. Screenshots / Product Tour
- 4 to 6 macOS screenshots
- captions tied to user outcomes, not just feature names
- desktop-first layout with mobile fallback carousel
#### 9. Open Source and Credibility
- GitHub repo link
- MIT license
- attribution statement
- optional changelog/release notes links
#### 10. FAQ
- Is Atlas signed and notarized?
- What happens in prerelease installs?
- Does Atlas upload my files?
- What does recovery actually mean?
- Does Atlas require Full Disk Access?
- Is this a Mac App Store app?
#### 11. Footer
- download links
- GitHub
- documentation
- privacy statement
- security contact
## Functional Requirements
### FR-01 Release Metadata
The page must display:
- latest downloadable version
- release channel
- published date
- links to `.dmg`, `.zip`, `.pkg` or the GitHub release page
### FR-02 Channel-Aware UI
If the latest downloadable build is a prerelease, the UI must:
- display a `Prerelease` badge above the primary CTA
- show a short warning near the CTA
- expose a help path for Gatekeeper friction
### FR-03 Bilingual Support
The page must support `English` and `简体中文` with:
- manual language switch
- stable URL strategy or query/path handling
- localized metadata where feasible
### FR-04 Download Path Clarity
Users must be able to tell:
- where to download
- which file is recommended
- whether they are installing a prerelease
- what to do if macOS blocks the app
### FR-05 Trust Links
The page must link to:
- GitHub repository
- GitHub Releases
- changelog or release notes
- security disclosure path
- open-source attribution / license references
### FR-06 Optional Beta Updates Capture
The page should support an optional email capture block using a third-party form endpoint or mailing-list provider without introducing a custom backend in v1.
### FR-07 Responsive Behavior
The experience must work on:
- desktop
- iPad/tablet portrait
- mobile phones
No critical CTA or release information may fall below inaccessible accordion depth on mobile.
## Visual Direction
### Design Theme
`Precision Utility`
The page should feel like a modern macOS-native operations console translated into a polished marketing surface. It should avoid generic SaaS gradients, purple-heavy palettes, and interchangeable startup layouts.
### Visual Principles
- clean but not sterile
- high trust over hype
- native Mac feel over abstract Web3-style spectacle
- strong hierarchy over crowded feature dumping
- product screenshots as proof, not decoration
### Typography
- Display: `Space Grotesk`
- Body: `Instrument Sans`
- Utility / telemetry labels: `IBM Plex Mono`
### Color System
- Background base: graphite / near-black
- Surface: warm slate cards
- Primary accent: mint-teal
- Secondary accent: cold cyan
- Support accent: controlled amber for caution or prerelease states
### Layout Style
- strong hero with diagonal or staggered composition
- large product framing device
- generous spacing
- rounded but not bubbly surfaces
- visual rhythm built from alternating dark/light emphasis bands
### Motion
- staggered section reveal on first load
- subtle screenshot parallax only if performance budget permits
- badge/CTA hover states with restrained motion
- no endless floating particles or decorative loops
## Copy Direction
- tone: direct, calm, technically credible
- avoid hype words like “ultimate”, “magic”, or “AI cleaner”
- prefer concrete verbs: `Scan`, `Review`, `Restore`, `Download`
- always qualify prerelease or unsigned friction honestly
## SEO Requirements
- page title and meta description in both supported languages
- Open Graph and Twitter card metadata
- software-application structured data
- canonical URL
- `hreflang` support if separate localized URLs are used
- crawlable, text-based headings; no hero text rendered only inside images
## Analytics Requirements
Use privacy-respecting analytics by default.
### Required Events
- primary CTA click
- GitHub CTA click
- release file choice click
- FAQ expand
- screenshot gallery interaction
- beta signup submit
### Recommended Stack
- `Plausible` or `Umami`
- Google Search Console for indexing and query monitoring
## Technical and Deployment Requirements
### Hosting
- Use `GitHub Pages`
- Deploy via `GitHub Actions` custom workflow
- Keep the site static-first
### Recommended Workflow
Build job:
- install dependencies
- build static output
- upload Pages artifact
Deploy job:
- use `actions/deploy-pages`
- grant `pages: write` and `id-token: write`
- publish to the `github-pages` environment
This aligns with GitHubs current Pages guidance for custom workflows and deployment permissions.
### Custom Domain Strategy
- Use a dedicated brand domain
- Prefer `www` as canonical host
- Redirect apex to `www` or configure both correctly
- Verify the domain at the GitHub account or organization level before binding it to the repository
- Enforce HTTPS after DNS propagation
- Do not use wildcard DNS
- Do not rely on a manually committed `CNAME` file when using a custom GitHub Actions Pages workflow
### DNS Requirements
For `www`:
- configure `CNAME` -> `<account>.github.io`
For apex:
- use `A`/`AAAA` records or `ALIAS/ANAME` according to GitHub Pages documentation
### Repository Integration
- Source should live in this repository under `Apps/LandingSite/`
- Landing page deployment should not interfere with app release workflows
- Landing page builds should trigger on landing-page source changes and optionally on GitHub Release publication
### Release Integration
The landing page should not depend on client-side GitHub API fetches for critical first-paint release messaging if it can be avoided. Preferred order:
1. build-time generated release manifest
2. static embedded release metadata
3. client-side GitHub API fetch as fallback
## Recommended MVP Scope
### Included
- one bilingual single-page site
- responsive hero
- feature story sections
- screenshot gallery
- trust/safety section
- FAQ
- dynamic release state block
- GitHub Pages deployment
- custom domain binding
- privacy-respecting analytics
### Deferred
- blog
- changelog microsite
- testimonials from named users
- interactive benchmark calculator
- gated PDF lead magnet
- multi-page docs hub
## Acceptance Criteria
- A first-time visitor can understand Atlas in under 10 seconds from the hero
- The page clearly distinguishes `Stable` vs `Prerelease`
- A prerelease visitor can discover the `Open Anyway` recovery path without leaving the page confused
- The site is deployable from GitHub to a custom domain with HTTPS
- Desktop and mobile layouts preserve screenshot clarity and CTA visibility
- The page links cleanly to GitHub Releases and the repository
- Language switching works without broken layout or missing content
## Delivery Plan
### Phase 1
- finalize PRD
- confirm domain choice
- confirm release CTA policy for prerelease vs stable
### Phase 2
- design mock or coded prototype
- implement static site in `Apps/LandingSite/`
- add GitHub Pages workflow
### Phase 3
- bind custom domain
- enable HTTPS
- add analytics and search-console verification
- run launch QA on desktop and mobile
## Risks
- The site may overpromise signed-distribution status if release metadata is not surfaced dynamically
- GitHub Pages custom-domain misconfiguration can create HTTPS or takeover risk
- A generic SaaS aesthetic would dilute Atlass product differentiation
- Screenshots can become stale if app UI evolves faster than site updates
- Email capture can add privacy or maintenance overhead if introduced too early
## Open Questions
- Should the canonical domain be a product-only brand domain or a broader studio-owned domain path?
- Should prerelease downloads be direct asset links or always route through the GitHub Release page first?
- Is email capture required for v1, or is GitHub + download conversion sufficient?
- Should bilingual content use one URL with client-side switching or separate localized routes?

View File

@@ -20,7 +20,9 @@ Track the frozen Atlas for Mac MVP against user-visible acceptance criteria, aut
|--------|----------------------|--------------------|---------------------|--------|
| `Overview` | Shows health snapshot, reclaimable space, permissions summary, and recent activity | `swift test --package-path Packages`, `AtlasApplicationTests`, native build | Launch app and confirm overview renders without crash | covered |
| `Smart Clean` | User can scan, preview, and execute a recovery-first cleanup plan | `AtlasApplicationTests`, `AtlasInfrastructureTests`, `AtlasAppTests` | Launch app, run scan, review lanes, execute preview | covered |
| `Smart Clean` competitive readiness | Supported high-confidence safe cleanup classes are explicit, disk-backed where claimed, and unsupported paths fail closed visibly | focused `AtlasInfrastructureTests`, `AtlasAppTests`, `scan -> execute -> rescan` tests | Validate one supported and one unsupported scenario against current comparison targets | partial |
| `Apps` | User can refresh apps, preview uninstall, and execute uninstall through worker flow | `AtlasApplicationTests`, `AtlasInfrastructureTests`, `AtlasAppTests`, `MacAppsInventoryAdapterTests` | Launch app, preview uninstall, execute uninstall, confirm history updates | covered |
| `Apps` competitive readiness | Uninstall preview explains supported footprint categories, concrete leftover evidence paths, and recoverability/audit implications clearly enough to stand against open-source uninstall specialists | `AtlasApplicationTests`, `AtlasInfrastructureTests`, `AtlasAppTests` plus fixture validation | Preview uninstall for fixture apps, confirm category clarity, observed paths, and completion/history evidence | partial |
| `History` | User can inspect runs and restore recovery items | `AtlasInfrastructureTests`, `AtlasAppTests` | Launch app, restore an item, verify it disappears from recovery list | covered |
| `Recovery` | Destructive flows create structured recovery items with expiry | `AtlasInfrastructureTests` | Inspect history/recovery entries after execute or uninstall | covered |
| `Permissions` | User can refresh best-effort macOS permission states | package tests + app build | Launch app, refresh permissions, inspect cards | partial-manual |
@@ -47,12 +49,37 @@ Track the frozen Atlas for Mac MVP against user-visible acceptance criteria, aut
4. Execute uninstall.
5. Confirm the item appears in `History` / `Recovery`.
### Scenario 2b: App uninstall trust verification
1. Open `Apps`.
2. Preview uninstall for a fixture app.
3. Confirm the preview distinguishes supported footprint categories rather than only showing a generic leftover count.
4. Confirm at least one review-only evidence group shows concrete observed paths.
5. Execute uninstall.
6. Confirm completion and history/recovery surfaces describe what Atlas actually removed and recorded.
7. Confirm recovery detail distinguishes the recoverable bundle from review-only leftover evidence.
### Scenario 3: DMG install verification
1. Build distribution artifacts.
2. Open `Atlas-for-Mac.dmg`.
3. Copy `Atlas for Mac.app` to `Applications`.
4. Launch the installed app.
### Scenario 4: Smart Clean supported vs unsupported verification
1. Run a `Smart Clean` scan with one supported high-confidence target class.
2. Execute and rescan.
3. Confirm on-disk effect is visible and history reflects a real side effect.
4. Run a scenario with an unsupported or unstructured finding.
5. Confirm Atlas blocks or rejects execution clearly instead of implying success.
## Selective Parity Validating Fixtures
- `Smart Clean` supported fixture: app-container cache or temp data under `~/Library/Containers/<bundle-id>/Data/Library/Caches` or `~/Library/Containers/<bundle-id>/Data/tmp`, validated with `scan -> execute -> rescan`.
- `Smart Clean` fail-closed fixture: launch-agent, service-adjacent, or otherwise unsupported target such as `~/Library/LaunchAgents/<bundle-id>.plist`, validated as review-only or rejected.
- `Apps` mainstream GUI fixture: one large GUI app with visible support files and caches, such as `Final Cut Pro`.
- `Apps` developer-heavy fixture: one developer-oriented app with larger support/caches footprint, such as `Xcode`.
- `Apps` launch-item or service-adjacent fixture: one app with launch-agent evidence, such as `Docker`.
- `Apps` sparse-leftover fixture: one app that produces only a small preference or saved-state trail, validated so Atlas does not overstate removal scope.
## Current Blocking Item
- Signed/notarized public distribution remains blocked by missing Apple Developer release credentials.

View File

@@ -0,0 +1,479 @@
# Open-Source Competitor Research — 2026-03-21
## Objective
Research open-source competitors relevant to `Atlas for Mac` and compare them against Atlas from two angles:
- feature overlap with the frozen MVP
- technical patterns worth copying, avoiding, or tracking
This report is scoped to Atlas MVP as defined in [PRD.md](../PRD.md): `Overview`, `Smart Clean`, `Apps`, `History`, `Recovery`, `Permissions`, and `Settings`. Deferred items such as `Storage treemap`, `Menu Bar`, and `Automation` are treated as adjacent references, not MVP targets.
## Method
- Internal product/architecture baseline:
- [PRD.md](../PRD.md)
- [Architecture.md](../Architecture.md)
- [Smart-Clean-Execution-Coverage-2026-03-09.md](./Smart-Clean-Execution-Coverage-2026-03-09.md)
- External research date:
- All GitHub and SourceForge metadata in this report was checked on `2026-03-21`.
- External research workflow:
- 2 focused web searches to identify the relevant open-source landscape
- deep reads of the most representative projects: `Mole`, `Pearcleaner`, `Czkawka`
- repo metadata, release metadata, license files, and selected source files for technical verification
## Middle Findings
- There is no single open-source product that matches Atlas's intended combination of `native macOS UI + explainable action plan + history + recovery + permission guidance`.
- The market is fragmented:
- `Mole` is the closest breadth benchmark for cleanup depth and developer-oriented coverage.
- `腾讯柠檬清理 / lemon-cleaner` is the closest native-GUI breadth benchmark from the Chinese Mac utility ecosystem.
- `Pearcleaner` is the strongest open-source benchmark for app uninstall depth on macOS.
- `Czkawka` is the strongest reusable file-analysis engine pattern, but it is not a Mac maintenance workspace.
- `GrandPerspective` is the strongest adjacent open-source reference for storage visualization, but Atlas has explicitly deferred treemap from MVP.
- Licensing is a major strategic boundary:
- `Mole` uses `MIT`, which aligns with Atlas's current reuse strategy.
- `Pearcleaner` is `Apache 2.0 + Commons Clause`, so it is source-available but not a safe upstream for monetized derivative shipping.
- `Czkawka` mixes `MIT` and `GPL-3.0-only` depending on component.
- `GrandPerspective` is `GPL`.
- Atlas's strongest differentiation is architectural trust. Atlas's current weakest point is breadth of release-grade executable cleanup compared with how broad `Mole` already looks to users.
## Executive Summary
If Atlas wants to win in open source, it should not position itself as "another cleaner." That lane is already occupied by `Mole` on breadth and `Pearcleaner` on uninstall specialization. Atlas's credible lane is a `native macOS maintenance workspace` with structured worker/helper boundaries, honest permission handling, and recovery-first operations.
The biggest threat is not that an open-source competitor already matches Atlas end to end. The threat is that users may compare Atlas's current MVP against a combination of `Mole + Pearcleaner + GrandPerspective/Czkawka` and conclude Atlas is cleaner in design but behind in raw capability. That makes execution credibility, uninstall depth, and product messaging more important than adding new surface area.
The one important omission in an open-source-only Mac comparison would be `腾讯柠檬清理 / Tencent lemon-cleaner`, because it is both open-source and closer than most projects to a native GUI maintenance suite. Atlas should treat it as a real comparison point, especially for Chinese-speaking users.
## Landscape Map
| Project | Type | Why it matters to Atlas | Current signal |
| --- | --- | --- | --- |
| `tw93/Mole` | Direct breadth competitor | Closest open-source "all-in-one Mac maintenance" positioning | Very strong community and recent release activity |
| `Tencent/lemon-cleaner` | Direct breadth competitor | Closest open-source native GUI maintenance suite, especially relevant in Chinese market | Established product and recognizable feature breadth |
| `alienator88/Pearcleaner` | Direct module competitor | Strongest open-source benchmark for `Apps` uninstall depth on macOS | Strong adoption, but maintainer bandwidth is constrained |
| `qarmin/czkawka` | Adjacent engine competitor | Best open-source file hygiene / duplicate / temporary-file engine pattern | Mature and active, but not macOS-native |
| `GrandPerspective` | Adjacent UX competitor | Best open-source reference for storage visualization / treemap | Active, but outside Atlas MVP scope |
| `sanketk2020/MacSpaceCleaner` | Emerging minor competitor | Shows appetite for lightweight native Mac cleaners | Low maturity; not a primary benchmark |
## Functional Comparison
Legend:
- `Strong` = clear product strength
- `Partial` = present but narrower or less central
- `No` = not a meaningful capability
| Capability | Atlas for Mac | Mole | Lemon | Pearcleaner | Czkawka | GrandPerspective |
| --- | --- | --- | --- | --- | --- | --- |
| Broad junk / cache cleanup | Partial | Strong | Strong | Partial | Partial | No |
| App uninstall with leftovers | Strong | Strong | Strong | Strong | No | No |
| Developer-oriented cleanup | Strong | Strong | Partial | Partial | Partial | No |
| Disk usage analysis | Partial | Strong | Strong | No | Partial | Strong |
| Live health / system status | Partial | Strong | Strong | No | No | No |
| History / audit trail | Strong | Partial | Low | No | No | No |
| Recovery / restore model | Strong | Partial | No | No | No | No |
| Permission guidance UX | Strong | Low | Partial | Partial | Low | Low |
| Native macOS GUI | Strong | No | Strong | Strong | Partial | Strong |
| CLI / automation surface | Partial | Strong | Low | Partial | Strong | No |
### Notes Behind The Table
- Atlas:
- Atlas is strongest where it combines cleanup with `History`, `Recovery`, and `Permissions`.
- Atlas already has real `Apps` list / preview uninstall / execute uninstall flows in the current architecture and protocol, with recovery-backed app uninstall behavior; the remaining question is depth and polish versus Pearcleaner, not whether the module exists.
- Per [Smart-Clean-Execution-Coverage-2026-03-09.md](./Smart-Clean-Execution-Coverage-2026-03-09.md), real Smart Clean execution is still limited to a safe structured subset. So Atlas's cleanup breadth is not yet at `Mole` level.
- Mole:
- Mole covers `clean`, `uninstall`, `optimize`, `analyze`, `status`, `purge`, and `installer`, which is broader than Atlas's current release-grade execution coverage.
- Mole exposes JSON for some commands and has strong dry-run patterns, but it does not center recovery/history as a product promise.
- Lemon:
- Lemon combines deep cleaning, large-file cleanup, duplicate cleanup, similar-photo cleanup, privacy cleaning, app uninstall, login-item management, and status-bar monitoring in one native Mac app.
- It is a much more direct GUI comparison than Mole for users who expect a polished desktop utility instead of a terminal-first tool.
- Pearcleaner:
- Pearcleaner is deep on `Apps`, but it is not a full maintenance workspace.
- It extends beyond uninstall into Homebrew, PKG, plugin, services, and updater utilities.
- Czkawka:
- Czkawka is powerful for duplicate finding, big files, temp files, similar media, broken files, and metadata cleanup.
- It is not a Mac workflow app and does not cover uninstall, permissions guidance, or recovery.
- GrandPerspective:
- Very strong for treemap-based disk visualization.
- It is analysis-first, not cleanup-orchestration-first.
## Technical Comparison
| Area | Atlas for Mac | Mole | Lemon | Pearcleaner | Czkawka | GrandPerspective |
| --- | --- | --- | --- | --- | --- | --- |
| App shape | Native macOS app | CLI / TUI plus scripts | Native macOS app | Native macOS app | Cross-platform workspace | Native macOS app |
| Main stack | SwiftUI + AppKit bridges + XPC/helper | Shell + Go | Objective-C/Cocoa + Xcode workspace + pods | SwiftUI + AppKit + helper targets | Rust workspace with core/CLI/GTK/Slint frontends | Cocoa / Objective-C / Xcode project |
| Process boundary | App + worker + privileged helper | Mostly single local toolchain | App plus multiple internal modules/daemons | App + helper + Finder extension + Sentinel monitor | Shared core with multiple frontends | Single app process |
| Privileged action model | Structured helper boundary | Direct shell operations with safety checks | Native app cleanup modules; license files indicate separate daemon licensing | Privileged helper plus Full Disk Access | Mostly user-space file operations | Read/analyze oriented |
| Recoverability | Explicit product-level recovery model | Safety-focused, but not recovery-first | No clear recovery-first model | No clear recovery-first model | No built-in recovery model | Not applicable |
| Auditability | History and structured recovery items | Operation logs | No first-class history model | No first-class history model | No first-class history model | Not applicable |
| Packaging | `.zip`, `.dmg`, `.pkg`, direct distribution | Homebrew, install script, prebuilt binaries | Native app distribution via official site/App ecosystem | DMG/ZIP/Homebrew cask | Large prebuilt binary matrix | SourceForge / App Store / source tree |
| License shape | MIT, with attribution for reused upstream code | MIT | GPL v2 for daemon, GPL v3 for most other modules | Apache 2.0 + Commons Clause | Mixed: MIT plus GPL-3.0-only for some frontends | GPL |
## Competitor Deep Dives
### 1. Mole
#### Why it matters
`Mole` is the closest open-source breadth competitor and also Atlas's most important upstream-adjacent reference. It markets itself as an all-in-one Mac maintenance toolkit and already bundles many of the comparisons users naturally make against commercial utilities.
#### What it does well
- Broad feature surface in one install:
- cleanup
- app uninstall
- disk analyze
- live status
- project artifact purge
- installer cleanup
- Strong developer-user fit:
- Xcode and Node-related cleanup are explicitly called out
- `purge` is a strong developer-specific wedge
- Safe defaults are well communicated:
- dry-run
- path validation
- protected directories
- explicit confirmation
- operation logs
- Good automation posture:
- `mo analyze --json`
- `mo status --json`
#### Technical takeaways
- Repo composition is pragmatic rather than layered:
- heavy Shell footprint
- Go core dependencies including `bubbletea`, `lipgloss`, and `gopsutil`
- Distribution is optimized for speed and reach:
- Homebrew
- shell install script
- architecture-specific binaries
- Safety is implemented inside one local toolchain, not via an app-worker-helper separation.
#### Weaknesses relative to Atlas
- Terminal-first experience limits mainstream Mac adoption.
- Product trust is based on careful scripting and dry-run, not on a native explainable workflow with recovery.
- History, audit, and restore are not a first-class user-facing value proposition.
#### Implication for Atlas
`Mole` should be treated as Atlas's primary benchmark for `Smart Clean` breadth and developer-oriented cleanup coverage. Atlas should not try to beat Mole on shell ergonomics. Atlas should beat it on:
- explainability
- permissions UX
- structured execution boundaries
- history / recovery credibility
- native product polish
### 2. Pearcleaner
#### Why it matters
`Pearcleaner` is the strongest open-source benchmark for Atlas's `Apps` module. It is native, widely adopted, and much deeper on uninstall-adjacent workflows than most open-source Mac utilities.
#### What it does well
- Strong uninstall-centered feature cluster:
- app uninstall
- orphaned file search
- file search
- Homebrew manager
- PKG manager
- plugin manager
- services manager
- updater
- Native platform integrations:
- Finder extension
- helper target
- Sentinel monitor for automatic cleanup when apps hit Trash
- CLI support and deep-link automation
- Clear macOS assumptions:
- Full Disk Access required for search
- privileged helper required for system-folder actions
#### Technical takeaways
- Repo structure shows native macOS product thinking:
- `Pearcleaner.xcodeproj`
- `Pearcleaner`
- `PearcleanerHelper`
- `PearcleanerSentinel`
- `FinderOpen`
- Source confirms a SwiftUI app entrypoint:
- `import SwiftUI`
- `@main struct PearcleanerApp: App`
- Helper code confirms XPC-like privileged helper behavior with code-sign validation before accepting client requests.
#### Weaknesses relative to Atlas
- It is not a full maintenance workspace.
- No strong user-facing recovery/history model.
- Maintainer note in the README says updates slowed due to limited spare time, which is a maintainability risk.
- Licensing is a hard boundary:
- Apache 2.0 with Commons Clause prevents monetized derivative use.
#### Implication for Atlas
For `Apps`, Atlas should benchmark against Pearcleaner rather than against generic cleaners. The gap to close is not "can Atlas delete apps" but:
- uninstall footprint depth
- service / launch item cleanup coverage
- package-manager and installer awareness
- native workflow polish
Atlas should not depend on Pearcleaner code for shipped product behavior due license constraints.
### 3. Tencent Lemon Cleaner
#### Why it matters
`Tencent/lemon-cleaner` is one of the most relevant omissions if Atlas only compares itself with Western or terminal-first open-source tools. It is a native macOS maintenance utility with broad GUI feature coverage and obvious overlap with what many users expect from a Mac cleaning app.
#### What it does well
- Broad native GUI utility bundle:
- deep scan cleanup
- large-file cleanup
- duplicate-file cleanup
- similar-photo cleanup
- browser privacy cleanup
- app uninstall
- startup item management
- status-bar monitoring
- disk space analysis
- Product positioning is close to mainstream cleaner expectations:
- one-click cleaning
- software-specific cleanup rules
- real-time device status in menu bar / status area
- Chinese-market relevance is high:
- the README and official site are aimed directly at Chinese macOS users and their cleanup habits
#### Technical takeaways
- Repo structure is a classic native Mac app workspace:
- `Lemon.xcodeproj`
- `Lemon.xcworkspace`
- multiple feature modules such as `LemonSpaceAnalyse`, `LemonUninstaller`, `LemonPrivacyClean`, `LemonLoginItemManager`, and `LemonCleaner`
- The repository is primarily `Objective-C` and keeps a separate daemon license file.
- This is a good example of a feature-suite style monolithic Mac utility rather than Atlas's more explicitly layered app/worker/helper model.
#### Weaknesses relative to Atlas
- No visible recovery-first promise comparable to Atlas.
- No obvious user-facing history/audit model.
- Architecture appears more utility-suite oriented than trust-boundary oriented.
- License is restrictive for Atlas reuse:
- GPL v2 for the daemon
- GPL v3 for most other modules
#### Implication for Atlas
Lemon is a direct product benchmark, especially for:
- native GUI breadth
- large-file / duplicate / privacy / startup-item utility coverage
- Chinese-language market expectations
Atlas should study Lemon as a product benchmark, but not as a code-reuse candidate.
### 4. Czkawka
#### Why it matters
`Czkawka` is not a direct Mac maintenance workspace competitor, but it is the strongest open-source reference for fast multi-platform file analysis and cleanup primitives.
#### What it does well
- High-performance file hygiene coverage:
- duplicates
- empty files/folders
- big files
- temp files
- similar images/videos
- broken files
- Exif remover
- video optimizer
- Strong engineering posture:
- memory-safe Rust emphasis
- reusable `czkawka_core`
- CLI plus multiple GUI frontends
- explicit note that it does not collect user data or access the Internet
- Platform strategy is mature:
- macOS, Linux, Windows, FreeBSD, Android
#### Technical takeaways
- Workspace composition is clear:
- `czkawka_core`
- `czkawka_cli`
- `czkawka_gui`
- `krokiet`
- `cedinia`
- The newer `Krokiet` frontend is built in `Slint` because the maintainer found GTK inconsistent and high-friction on Windows and macOS.
- This is a strong example of separating reusable scan logic from frontends.
#### Weaknesses relative to Atlas
- It is not macOS-native in product feel.
- It does not cover uninstall, permissions workflow, history, or restore semantics.
- Mixed licensing matters:
- core/CLI/GTK app are MIT
- `Krokiet` and `Cedinia` are GPL-3.0-only due Slint-related restrictions
#### Implication for Atlas
`Czkawka` is best used as an engineering reference, not as a product model. Atlas can learn from:
- reusable core logic boundaries
- fast scanning primitives
- cross-front-end separation
Atlas should avoid importing GPL-constrained UI paths into shipping code.
### 5. GrandPerspective
#### Why it matters
`GrandPerspective` is not an MVP competitor but it is the clearest open-source reference for treemap-based storage visualization on macOS.
#### What it does well
- Strong single-purpose focus:
- visual treemap disk usage analysis
- Mature native Mac implementation:
- `GrandPerspective.xcodeproj`
- `main.m`
- Still active:
- SourceForge tree shows commits in January and February 2026 and a `3.6.3` version update in January 2026.
#### Weaknesses relative to Atlas
- It is an analyzer, not a cleanup workspace.
- GPL license makes it unattractive for direct reuse in Atlas.
#### Implication for Atlas
Keep `GrandPerspective` as a post-MVP reference for `Storage treemap` only. Do not let it pull Atlas out of frozen MVP scope without an explicit product decision update.
### 6. Watchlist: MacSpaceCleaner
`MacSpaceCleaner` is useful as a signal, not as a primary benchmark.
- Positives:
- native Swift-based Mac utility
- MIT licensed
- explicit developer/Xcode cleanup slant
- Limitations:
- only `136` GitHub stars on `2026-03-21`
- much weaker ecosystem signal than Mole, Pearcleaner, or Czkawka
- repository structure is less mature and less informative
It is worth monitoring for specific ideas, but it should not drive Atlas roadmap decisions.
## What Atlas Is Actually Competing With
The real competitive picture is not one app. It is a user assembling a toolkit:
- `Mole` for broad cleanup and monitoring
- `Lemon` for native GUI all-in-one cleanup expectations
- `Pearcleaner` for uninstall depth
- `GrandPerspective` or similar tools for disk visualization
- `Czkawka` for duplicate / large-file hygiene
That means Atlas wins only if it makes the integrated workflow meaningfully safer and easier than stitching together multiple specialist tools.
## Strategic Implications For Atlas
### 1. Atlas should own the trust architecture lane
This is the strongest differentiator that the current open-source set does not combine well:
- explainable findings
- structured worker/helper boundary
- visible permission rationale
- history
- recoverable actions
### 2. `Smart Clean` breadth is the highest product risk
Per [Smart-Clean-Execution-Coverage-2026-03-09.md](./Smart-Clean-Execution-Coverage-2026-03-09.md), Atlas currently executes a safe structured subset of targets. That is honest and correct, but it also means Atlas can lose obvious comparisons to `Mole` unless release messaging stays precise and execution coverage expands.
### 3. `Apps` depth should be benchmarked against Pearcleaner, not generic cleaners
Atlas already includes app uninstall flows, but the market standard for open-source Mac uninstall depth is closer to Pearcleaner's footprint search, services/package awareness, and native integrations.
### 4. License hygiene must stay strict
- `Mole` is the only clearly safe major upstream from this set for Atlas's current MIT-oriented posture.
- `Lemon`, `GrandPerspective`, and parts of `Czkawka` carry GPL constraints and should be treated as product/UX references, not casual reuse candidates.
- `Pearcleaner` and `GrandPerspective` should be treated as product references, not code reuse candidates.
- `Czkawka` components need per-component license review before any adaptation.
### 5. Deferred scope must stay deferred
`GrandPerspective` makes storage treemap look attractive, but Atlas has explicitly deferred `Storage treemap` from MVP. The correct move is to use it as future design reference, not as a reason to reopen MVP.
## Recommended Next Steps
- Product:
- Position Atlas explicitly as an `explainable, recovery-first Mac maintenance workspace`, not just a cleaner.
- Smart Clean:
- Expand release-grade execution coverage on the categories users will compare most directly with Mole: caches, developer artifacts, and high-confidence junk roots.
- Apps:
- Run a gap review against Pearcleaner feature depth for uninstall leftovers, services, package artifacts, and automation entry points.
- Architecture:
- Keep leaning into worker/helper and structured recovery. That is Atlas's most defensible open-source differentiation.
- Messaging:
- Be exact about what runs for real today. Over-claiming breadth would erase Atlas's trust advantage.
## Sources
### Internal Atlas docs
1. [PRD.md](../PRD.md)
2. [Architecture.md](../Architecture.md)
3. [Smart-Clean-Execution-Coverage-2026-03-09.md](./Smart-Clean-Execution-Coverage-2026-03-09.md)
### Mole
1. [tw93/Mole](https://github.com/tw93/Mole)
2. [Mole README](https://raw.githubusercontent.com/tw93/Mole/main/README.md)
3. [Mole go.mod](https://raw.githubusercontent.com/tw93/Mole/main/go.mod)
4. [Mole latest release `V1.30.0` published on 2026-03-08](https://github.com/tw93/Mole/releases/tag/V1.30.0)
### Pearcleaner
1. [alienator88/Pearcleaner](https://github.com/alienator88/Pearcleaner)
2. [Pearcleaner README](https://raw.githubusercontent.com/alienator88/Pearcleaner/main/README.md)
3. [Pearcleaner app entrypoint](https://github.com/alienator88/Pearcleaner/blob/main/Pearcleaner/PearcleanerApp.swift)
4. [Pearcleaner helper entrypoint](https://github.com/alienator88/Pearcleaner/blob/main/PearcleanerHelper/main.swift)
5. [Pearcleaner license](https://github.com/alienator88/Pearcleaner/blob/main/LICENSE.md)
6. [Pearcleaner latest release `5.4.3` published on 2025-11-26](https://github.com/alienator88/Pearcleaner/releases/tag/5.4.3)
### Lemon
1. [Tencent/lemon-cleaner](https://github.com/Tencent/lemon-cleaner)
2. [Lemon README](https://raw.githubusercontent.com/Tencent/lemon-cleaner/master/README.md)
3. [腾讯柠檬清理官网](https://lemon.qq.com)
### Czkawka
1. [qarmin/czkawka](https://github.com/qarmin/czkawka)
2. [Czkawka README](https://raw.githubusercontent.com/qarmin/czkawka/master/README.md)
3. [Czkawka Cargo workspace](https://github.com/qarmin/czkawka/blob/master/Cargo.toml)
4. [Krokiet README](https://github.com/qarmin/czkawka/blob/master/krokiet/README.md)
5. [Czkawka latest release `11.0.1` published on 2026-02-21](https://github.com/qarmin/czkawka/releases/tag/11.0.1)
### GrandPerspective
1. [GrandPerspective SourceForge source tree](https://sourceforge.net/p/grandperspectiv/source/ci/master/tree/)
### MacSpaceCleaner
1. [sanketk2020/MacSpaceCleaner](https://github.com/sanketk2020/MacSpaceCleaner)
2. [MacSpaceCleaner README](https://raw.githubusercontent.com/sanketk2020/MacSpaceCleaner/main/README.md)

View File

@@ -0,0 +1,22 @@
task_id: atl-211-214-226-230
goal: >
按 Docs/Execution/Implementation-Plan-ATL-211-214-226-230-2026-03-21.md
实现第一阶段 selective parity 开发切片:提升 Smart Clean 在高置信安全清理类目上的竞争可信度,
并增强 Apps 卸载预览、完成态与审计/恢复线索。必须保持 MVP 冻结,不引入新产品面。
constraints:
- 不要扩展出 frozen MVP 范围之外的新模块或新表面。
- 不要引入 Storage treemap、Menu Bar、Automation、重复文件/相似照片/隐私清理新模块。
- 不要在 UI、文档或完成态中暗示 Atlas 已与 Mole、Lemon、Pearcleaner 全面同等。
- unsupported 或 review-only 路径必须保持 fail-closed 和明确提示。
verify_commands:
- swift test --package-path Packages --filter AtlasInfrastructureTests
- swift test --package-path Packages --filter AtlasApplicationTests
- swift test --package-path Apps --filter AtlasAppModelTests
adapter: codex_cli
adapter_config:
sandbox: workspace-write
full_auto: true
timeout_seconds: 600
max_iterations: 5
timeout_minutes: 45
rollback_on_fail: false

View File

@@ -0,0 +1,31 @@
# Release Run — 1.0.3 — 2026-03-23
## Goal
Drive `v1.0.3` from release-prepared source state to a pushed release tag and observe whether GitHub publishes a normal release or falls back to a prerelease.
## Task List
- [x] Confirm version bump, changelog, and release notes are prepared in source.
- [x] Rebuild native artifacts and verify the bundled app reports `1.0.3 (4)`.
- [x] Reinstall the local DMG candidate and verify the installed app reports `1.0.3 (4)`.
- [x] Run `./scripts/atlas/full-acceptance.sh` on the release candidate.
- [x] Clean the worktree by resolving remaining README and screenshot collateral updates.
- [x] Commit release-collateral updates required for a clean tagging state.
- [x] Create annotated tag `V1.0.3`.
- [x] Push `main` and `V1.0.3` to `origin`.
- [x] Observe the GitHub `release.yml` workflow result.
- [x] Confirm whether GitHub published a normal release or a prerelease fallback.
## Known Release Gate
- Local signing preflight still reports missing `Developer ID Application`, `Developer ID Installer`, and `ATLAS_NOTARY_PROFILE`.
- GitHub Actions may still produce a formal signed release if the required repository secrets are configured.
- If those secrets are missing, the tag push will publish a development-signed prerelease instead of a formal signed release.
## Outcome
- Git tag `V1.0.3` was pushed successfully.
- GitHub published `https://github.com/CSZHK/CleanMyPc/releases/tag/V1.0.3`.
- The published release is `prerelease=true`, not a formal signed release.
- The release body confirms GitHub Actions fell back to development-mode native packaging because `Developer ID` release-signing credentials were not configured for that run.

View File

@@ -49,13 +49,13 @@ If preflight passes, the current machine is ready for signed packaging.
Before pushing a release tag, align the app version, build number, and changelog skeleton:
```bash
./scripts/atlas/prepare-release.sh 1.0.2
./scripts/atlas/prepare-release.sh 1.0.3
```
Optional arguments:
```bash
./scripts/atlas/prepare-release.sh 1.0.2 3 2026-03-14
./scripts/atlas/prepare-release.sh 1.0.3 4 2026-03-23
```
This updates:
@@ -64,7 +64,7 @@ This updates:
- `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.
The script increments `CURRENT_PROJECT_VERSION` automatically when you omit the build number. Review the new changelog section before creating the `V1.0.3` tag.
## Signed Packaging
@@ -97,6 +97,13 @@ KEEP_INSTALLED_APP=1 ./scripts/atlas/verify-dmg-install.sh
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`.
The GitHub Release body is generated from the matching version section in `CHANGELOG.md`, then appends the actual packaging mode note:
- `developer-id` -> signed/notarized packaging note
- `development` -> prerelease fallback note
If a changelog section is missing for the pushed tag version, the workflow falls back to a short placeholder instead of publishing an empty body.
Required GitHub Actions secrets:
- `ATLAS_RELEASE_APP_CERT_P12_BASE64`
@@ -120,8 +127,8 @@ If those secrets are missing, the workflow automatically falls back to:
Release flow:
```bash
git tag -a V1.0.2 -m "Release V1.0.2"
git push origin V1.0.2
git tag -a V1.0.3 -m "Release V1.0.3"
git push origin V1.0.3
```
That tag creates one GitHub Release containing:

View File

@@ -0,0 +1,148 @@
# Selective Parity Week — 2026-03-23
## Context
Atlas now has a documented competitive strategy:
- keep MVP frozen
- preserve trust as the primary moat
- selectively close the most visible comparison gaps against `Mole`, `Tencent Lemon Cleaner`, and `Pearcleaner`
This week plan turns that strategy into the next development-ready execution window.
Related docs:
- [Open-Source-Competitor-Research-2026-03-21.md](./Open-Source-Competitor-Research-2026-03-21.md)
- [Competitive-Strategy-Plan-2026-03-21.md](./Competitive-Strategy-Plan-2026-03-21.md)
- [ROADMAP.md](../ROADMAP.md)
## Planned Window
- `2026-03-23` to `2026-03-27`
## Goal
Prepare Atlas's next coding phase with a strict mainline order so development starts on the highest-pressure competitive surfaces first and does not drift into premature release work:
- `EPIC-A` `Apps` evidence execution against `Pearcleaner` and `Lemon`
- `EPIC-B` `Smart Clean` safe coverage expansion against `Mole` and `Lemon`
- `EPIC-C` `Recovery` payload hardening
- `EPIC-D` release readiness only after the product-path epics stabilize
## Scope
Stay inside frozen MVP:
- `Overview`
- `Smart Clean`
- `Apps`
- `History`
- `Recovery`
- `Permissions`
- `Settings`
Do not expand into:
- `Storage treemap`
- `Menu Bar`
- `Automation`
- duplicate-file or similar-photo cleanup as new Atlas modules
- privacy-cleaning as a new standalone module
## Sequencing Rule
Execute the next mainline epics in this order only:
1. `EPIC-A` Apps Evidence Execution
2. `EPIC-B` Smart Clean Safe Coverage Expansion
3. `EPIC-C` Recovery Payload Hardening
4. `EPIC-D` Release Readiness
Why this order:
- the clearest competitive comparison pressure is already concentrated in `Apps` and `Smart Clean`
- the release chain is mostly working in pre-signing form
- public release remains blocked by missing signing materials, not by packaging mechanics
## Must Deliver
- A concrete fixture-backed baseline for `EPIC-A` uninstall evidence work
- One implementation-ready plan for the first `Apps` evidence coding slice
- A bounded target list for the next `Smart Clean` safe roots after `Apps`
- Updated acceptance criteria for `Apps`, `Smart Clean`, and `Recovery`
- Updated release-facing beta checklist so release work stays downstream of the product-path epics
## Backlog Mapping
- `ATL-251` Define the fixture app baseline for mainstream and developer-heavy uninstall scenarios
- `ATL-252` Make `Apps` preview, completion, and history render the same uninstall evidence model end to end
- `ATL-253` Define the restore-triggered app-footprint refresh policy and stale-evidence behavior after recovery
- `ATL-254` Script the manual acceptance flow for uninstall evidence, restore, and post-restore refresh verification
- `ATL-256` Define the next batch of high-confidence safe roots outside app containers and freeze the no-go boundaries
- `ATL-257` Stabilize `review-only` vs `executable` boundary metadata and UI cues across scan, review, execute, completion, and history
- `ATL-261` Freeze the recovery payload schema, versioning rules, and compatibility contract
- `ATL-266` Make `full-acceptance` a routine gate on the candidate build instead of a one-off release exercise
## Day Plan
- `Day 1`
- freeze the `EPIC-A -> EPIC-D` execution order
- finalize the fixture app baseline and non-go boundaries for the first `Apps` slice
- `Day 2`
- define the cross-surface uninstall evidence model for preview, completion, and history
- confirm restore-refresh expectations and acceptance criteria for `Apps`
- `Day 3`
- write the detailed implementation plan for the first `Apps` coding slice
- identify likely file-touch map and contract-test map
- `Day 4`
- define the next `Smart Clean` safe roots and the `review-only` vs `executable` boundary rules
- align roadmap, risks, and acceptance docs with the ordered epic sequence
- `Day 5`
- hold an internal doc gate for development readiness
- confirm the next coding session can begin with `EPIC-A` and without another sequencing pass
## Owner Tasks
- `Product Agent`
- keep parity work bounded to visible comparison pressure only
- reject backlog inflation that does not strengthen the frozen MVP
- `UX Agent`
- define visible trust cues for supported, unsupported, and review-only actions
- keep Atlas's UI difference legible against broader cleaner tools
- `Mac App Agent`
- identify concrete UI surfaces that must change in `Smart Clean`, `Apps`, `History`, and completion states
- `Core Agent`
- define the preview taxonomy and structured evidence that the UI can actually render
- `System Agent`
- define which additional safe cleanup classes are realistic next targets for `Smart Clean`
- `QA Agent`
- define the fixture set, comparison scenarios, and contract-style checks
- `Docs Agent`
- keep strategy, acceptance, roadmap, and release-check documents aligned
## Validation Plan
### Planning Validation
- every new task maps to existing MVP surfaces
- every new acceptance criterion is testable
- every parity goal has an explicit competitor reference and an explicit Atlas non-goal
### Readiness Validation
- at least one implementation-ready plan exists for the next coding slice
- acceptance matrix and beta checklist reflect the new competitive gates
- no document implies that Atlas is reopening deferred scope
## Exit Criteria
- selective parity work is expressed as tasks, acceptance, and validation rather than just strategy prose
- `Smart Clean` and `Apps` both have explicit competitor-driven targets
- next coding session can start on `EPIC-A` without another planning pass
## Known Blockers
- signed public beta remains blocked by missing Apple release credentials
- `Smart Clean` breadth still has to stay subordinate to execution honesty
- `Apps` depth work must remain bounded by what Atlas can safely prove and recover
- release readiness still cannot close the public-distribution gap until Apple signing materials exist

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -8,7 +8,7 @@
## Protocol Version
- Current implementation version: `0.3.1`
- Current implementation version: `0.3.2`
## UI ↔ Worker Commands
@@ -93,6 +93,7 @@
- `kind`
- `recoverable`
- `targetPaths` (optional structured execution targets carried by the current plan)
- `evidencePaths` (optional structured review-only evidence paths carried by the current plan; not executable intent)
### TaskRun
@@ -124,6 +125,28 @@
- `payload`
- `restoreMappings` (optional original-path ↔ trashed-path records for physical restoration)
### AppRecoveryPayload
- `schemaVersion`
- `app`
- `uninstallEvidence`
## Workspace State Persistence
Atlas persists local workspace state in a versioned JSON envelope:
- `schemaVersion`
- `savedAt`
- `snapshot`
- `currentPlan`
- `settings`
Compatibility rules:
- legacy top-level `AtlasWorkspaceState` files must still decode on load
- after a successful legacy decode, Atlas may rewrite the file into the current versioned envelope
- legacy app recovery payloads that stored a raw `AppFootprint` must still decode into the current `AppRecoveryPayload` shape
### AtlasSettings
- `recoveryRetentionDays`
@@ -141,20 +164,24 @@
- Recoverable flows must produce structured recovery items.
- Helper actions must remain allowlisted structured actions, never arbitrary command strings.
- Fresh Smart Clean preview plans should carry `ActionItem.targetPaths` for executable items so execution does not have to reconstruct destructive intent from UI state.
- Review-only uninstall evidence may carry `ActionItem.evidencePaths`, which are informational only and must not be treated as execution targets.
## Current Implementation Note
- `health.snapshot` is backed by `lib/check/health_json.sh` through `MoleHealthAdapter`.
- `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.
- `apps.list` is backed by `MacAppsInventoryAdapter`, which scans local app bundles and derives lightweight leftover counts suitable for interactive refresh.
- The worker persists a versioned local JSON workspace state containing the latest snapshot, current Smart Clean plan, and settings, including the persisted app-language preference.
- Legacy top-level workspace-state files are migrated on load into the current versioned envelope when possible.
- 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.
- Structured Smart Clean action items now also carry `targetPaths`, and `plan.execute` prefers those plan-carried targets. Older cached plans can still fall back to finding-carried targets for backward compatibility.
- App uninstall preview items may also carry `evidencePaths` for review-only leftover evidence. These are visible in UI detail but must never be executed as destructive targets.
- The app shell communicates with the worker over structured XPC `Data` payloads that encode Atlas request and result envelopes.
- `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`.
- App payload restores should be followed by app-inventory refresh so the `Apps` surface does not reuse stale uninstall preview or stale footprint counts after recovery.

View File

@@ -32,6 +32,7 @@ This directory contains the working product, design, engineering, and compliance
- `Execution/Execution-Credibility-Gate-Review-2026-03-12.md` — gate review for ATL-211, ATL-212, and ATL-215 Smart Clean execution credibility work
- `Execution/Recovery-Contract-2026-03-13.md` — frozen recovery semantics, claim boundaries, and acceptance evidence for ATL-221 through ATL-224
- `Execution/Recovery-Credibility-Gate-Review-2026-03-13.md` — gate review for ATL-221 through ATL-225 recovery credibility work
- `Execution/Landing-Page-PRD-2026-03-14.md` — PRD for the public landing page, GitHub Pages deployment, custom domain strategy, and launch-surface requirements
- `Execution/Smart-Clean-Execution-Coverage-2026-03-09.md` — user-facing summary of what Smart Clean can execute for real today
- `Execution/Smart-Clean-QA-Checklist-2026-03-09.md` — QA checklist for scan, execute, rescan, and physical restore validation
- `Execution/Smart-Clean-Manual-Verification-2026-03-09.md` — local-machine fixture workflow for validating real Smart Clean execution and restore

View File

@@ -112,3 +112,43 @@
- Owner: `Product Agent`
- Risk: GA release notes, README copy, or in-app messaging may overstate Atlas's recovery model before physical restore is actually shipped for file-backed recoverable actions.
- Mitigation: Treat recovery wording as a gated release artifact. Either ship physical restore for file-backed recoverable actions before GA or narrow all GA-facing recovery claims to the shipped behavior.
## R-015 Launch Surface Trust Drift
- Impact: High
- Probability: Medium
- Owner: `Product Agent`
- Risk: A future landing page or custom-domain launch surface may overstate release readiness, signed-install status, recovery behavior, or permission expectations relative to the actual downloadable build.
- Mitigation: Make release-channel state and install guidance dynamic, keep prerelease warnings visible, and gate launch-surface copy review with the same trust standards used for README and release materials.
## R-016 Competitive Breadth Perception Gap
- Impact: High
- Probability: High
- Owner: `Product Agent`
- Risk: Users comparing Atlas with `Mole` or `Tencent Lemon Cleaner` may conclude Atlas is cleaner in presentation but weaker in practical cleanup breadth if `Smart Clean` execution coverage stays too narrow or too invisible.
- Mitigation: Expand only the highest-value safe target classes inside frozen MVP, and make supported-vs-unsupported execution scope explicit in product copy and UI states.
## R-017 Apps Depth Comparison Gap
- Impact: High
- Probability: Medium
- Owner: `Mac App Agent`
- Risk: Users comparing Atlas with `Pearcleaner` or `Tencent Lemon Cleaner` may find the `Apps` module less credible if uninstall preview taxonomy, leftover visibility, and completion evidence remain too shallow.
- Mitigation: Add fixture-based uninstall benchmarking, deepen supported footprint categories, and surface recoverability/audit cues directly in the `Apps` flow.
## R-018 License Contamination From Competitor Reuse
- Impact: High
- Probability: Medium
- Owner: `Docs Agent`
- Risk: Competitive pressure may tempt reuse of code or assets from `Tencent Lemon Cleaner`, `GrandPerspective`, or GPL-constrained `Czkawka` components, creating license conflict with Atlas's shipping posture. `Pearcleaner` also remains unsuitable for monetized derivative reuse due `Commons Clause`.
- Mitigation: Treat these projects as product and technical references only, require explicit license review before adapting any third-party implementation, and prefer MIT-compatible upstream or original Atlas implementations for shipped code.
## R-019 Release-First Sequencing Drift
- Impact: High
- Probability: Medium
- Owner: `Product Agent`
- Risk: The team may over-rotate toward release mechanics because the packaging chain mostly works, even though the real public-release blocker is still missing signing materials and the sharper product pressure is in `Apps` and `Smart Clean`.
- Mitigation: Keep the active mainline order at `Apps -> Smart Clean -> Recovery -> Release`, and treat the `Developer ID + notarization` switch as the final convergence step once product-path evidence and credentials both exist.

View File

@@ -6,9 +6,10 @@
- Product state: `Frozen MVP complete`
- Validation state: `Internal beta passed with conditions on 2026-03-07`
- Immediate priorities:
- remove silent XPC fallback from release-facing trust assumptions
- make `Smart Clean` execution honesty match real filesystem behavior
- make `Recovery` claims match shipped restore behavior
- turn `Apps` review-only evidence into verifiable and comparable uninstall evidence
- expand `Smart Clean` safe coverage only on the next high-confidence roots
- harden `Recovery` payload compatibility and restore evidence after execution boundaries stabilize
- keep release-readiness work behind the product-path epics until signing materials exist
- Release-path blocker:
- no Apple signing and notarization credentials are available on the current machine
@@ -23,10 +24,24 @@
- `Permissions`
- `Settings`
- Do not pull `Storage treemap`, `Menu Bar`, or `Automation` into this roadmap.
- Respond to competitor pressure by deepening the frozen MVP flows rather than adding new surfaces for parity theater.
- Treat trust and recovery honesty as release-critical product work, not polish.
- Keep direct distribution as the only eventual release route.
- Do not plan around public beta dates until signing credentials exist.
## Competitive Strategy Overlay
- Primary breadth comparison pressure comes from `Mole` and `Tencent Lemon Cleaner`.
- Primary `Apps` comparison pressure comes from `Pearcleaner` and `Tencent Lemon Cleaner`.
- Atlas should compete as an `explainable, recovery-first Mac maintenance workspace`, not as a generic all-in-one cleaner.
- The roadmap response is:
- preserve trust as the primary release gate
- deepen the `Apps` module first where `Pearcleaner` and `Lemon` set expectations
- then close the most visible `Smart Clean` safe-coverage gaps users compare against `Mole` and `Lemon`
- harden `Recovery` only after execution boundaries and evidence models are stable
- treat release readiness as the final convergence step because signing materials, not packaging mechanics, remain the public-release blocker
- keep `Storage treemap`, `Menu Bar`, and `Automation` out of scope
## Active Milestones
### Milestone 1: Internal Beta Hardening
@@ -44,65 +59,95 @@
- unsupported execution paths fail clearly instead of appearing successful
- recovery wording matches the shipped restore behavior
### Milestone 2: Smart Clean Execution Credibility
### Milestone 2: Apps Evidence Execution
- Dates: `2026-03-31` to `2026-04-18`
- Goal: prove that the highest-value safe cleanup paths have real disk-backed side effects.
- Dates: `2026-03-31` to `2026-04-11`
- Goal: turn `Apps` review-only evidence from merely visible into verifiable, comparable, and recoverably consistent.
- Focus:
- expand real `Smart Clean` execute coverage for top safe target classes
- carry executable structured targets through the worker path
- add stronger `scan -> execute -> rescan` contract coverage
- make history and completion states reflect real side effects only
- define the fixture app baseline for mainstream and developer-heavy uninstall scenarios
- make preview, completion, and history reflect the same uninstall evidence model
- define the restore-triggered app-footprint refresh strategy and stale-evidence handling
- script the manual acceptance flow for uninstall evidence and restore verification
- Exit criteria:
- top safe cleanup paths show real post-execution scan improvement
- history does not claim success without real side effects
- release-facing docs clearly distinguish supported vs unsupported cleanup paths
- supported fixture apps produce consistent evidence across preview, completion, and history
- restore follows a defined footprint refresh path or shows explicit stale-evidence state
- the `Apps` acceptance path is scriptable and repeatable
### Milestone 3: Recovery Credibility
### Milestone 3: Smart Clean Safe Coverage Expansion
- Dates: `2026-04-21` to `2026-05-09`
- Goal: close the gap between Atlas's recovery promise and its shipped restore behavior.
- Dates: `2026-04-14` to `2026-05-02`
- Goal: expand only the next batch of high-confidence safe cleanup roots and prove real side effects without widening into high-risk cleanup.
- Focus:
- implement physical restore for file-backed recoverable actions where safe
- or narrow product and release messaging if physical restore cannot land safely
- validate restore behavior on real file-backed test cases
- freeze recovery-related copy only after behavior is confirmed
- add the next safe roots outside app containers
- stabilize the `review-only` vs `executable` boundary across scan, review, execute, completion, and history
- strengthen the `scan -> execute -> rescan` evidence chain for the expanded safe roots
- keep unsupported or high-risk paths explicitly non-executable
- Exit criteria:
- recovery language matches shipped behavior
- file-backed recoverable actions either restore physically or are no longer described as if they do
- QA has explicit evidence for restore behavior on the candidate build
- newly supported safe roots show real post-execution rescan improvement
- unsupported roots remain clearly marked as `review-only`
- release-facing surfaces distinguish supported and unsupported execution scope without ambiguity
### Milestone 4: Recovery Payload Hardening
- Dates: `2026-05-05` to `2026-05-23`
- Goal: make recovery state structurally stable, backward-compatible, and historically trustworthy.
- Focus:
- stabilize the recovery payload schema and versioning contract
- add migration and compatibility handling for older workspace and history state files
- deepen `History` detail evidence for restore payloads, conflicts, expiry, and partial restore outcomes
- add regression coverage for conflict, expired payload, and partial-restore scenarios
- Exit criteria:
- recovery payloads follow a stable versioned schema
- older state files migrate cleanly or fail with explicit compatibility behavior
- `History` detail can explain real restore evidence and degraded outcomes
- regression coverage exists for the main restore edge cases
### Milestone 5: Release Readiness
- Dates: `2026-05-26` to `2026-06-13`
- Goal: turn the stabilized product path into a repeatable release candidate process, then switch to the signing chain when credentials exist.
- Focus:
- make `full-acceptance` a routine gate on candidate builds
- stabilize UI automation for trust-critical MVP flows
- freeze packaging, install, and launch smoke checks as repeatable release scripts
- switch from the pre-signing release chain to `Developer ID + notarization` once credentials become available
- Exit criteria:
- `full-acceptance` runs routinely on candidate builds
- trust-critical UI automation is stable enough for release gating
- packaging, install, and launch smoke checks are repeatable
- the signed chain either passes with credentials present or remains explicitly blocked only by missing credentials
## Conditional Release Branch
These milestones do not start until Apple release credentials are available.
These milestones do not start until Milestone `5` is complete and Apple release credentials are available.
### Conditional Milestone A: Signed Public Beta Candidate
### Conditional Milestone A: Signed External Beta Candidate
- Trigger:
- Milestones `1` through `5` are complete
- `Developer ID Application` is available
- `Developer ID Installer` is available
- `ATLAS_NOTARY_PROFILE` is available
- Goal: produce a signed and notarized external beta candidate.
- Focus:
- pass `./scripts/atlas/signing-preflight.sh`
- rerun signed packaging
- rerun the release scripts on the signed chain
- validate signed `.app`, `.dmg`, and `.pkg` install paths on a clean machine
- prepare public beta notes and known limitations
- prepare external beta notes and known limitations
- Exit criteria:
- signed and notarized artifacts install without bypass instructions
- clean-machine install verification passes on the signed candidate
### Conditional Milestone B: Public Beta Learn Loop
### Conditional Milestone B: External Beta Learn Loop
- Trigger:
- Conditional Milestone A is complete
- Goal: run a small external beta after internal hardening is already complete.
- Goal: run a small external beta only after the mainline product path is stable.
- Focus:
- use a hardware-diverse trusted beta cohort
- triage install, permission, execution, and restore regressions
- close P0 issues before any GA candidate is named
- Exit criteria:
- no public-beta P0 remains open
- no external-beta P0 remains open
- primary workflows are validated on more than one machine profile
### Conditional Milestone C: GA Candidate and Launch

View File

@@ -65,6 +65,7 @@
- `scan` emits monotonic progress and finishes with a preview-ready plan when the upstream scan adapter succeeds; otherwise the request should fail rather than silently fabricate findings.
- `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` can physically restore items when structured recovery mappings are present, and can still rehydrate a `Finding` or an app payload 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.
- When `restore` rehydrates an app payload, the `Apps` surface should refresh inventory before presenting footprint counts or a new uninstall preview.
- User-visible task summaries and settings-driven text should reflect the persisted app-language preference when generated.

View File

@@ -42,6 +42,50 @@ public struct AtlasWorkspaceState: Codable, Hashable, Sendable {
}
}
public enum AtlasWorkspaceStateSchemaVersion {
public static let current = 1
}
public struct AtlasPersistedWorkspaceState: Codable, Hashable, Sendable {
public var schemaVersion: Int
public var savedAt: Date
public var snapshot: AtlasWorkspaceSnapshot
public var currentPlan: ActionPlan
public var settings: AtlasSettings
public init(
schemaVersion: Int = AtlasWorkspaceStateSchemaVersion.current,
savedAt: Date = Date(),
snapshot: AtlasWorkspaceSnapshot,
currentPlan: ActionPlan,
settings: AtlasSettings
) {
self.schemaVersion = schemaVersion
self.savedAt = savedAt
self.snapshot = snapshot
self.currentPlan = currentPlan
self.settings = settings
}
public init(
schemaVersion: Int = AtlasWorkspaceStateSchemaVersion.current,
savedAt: Date = Date(),
state: AtlasWorkspaceState
) {
self.init(
schemaVersion: schemaVersion,
savedAt: savedAt,
snapshot: state.snapshot,
currentPlan: state.currentPlan,
settings: state.settings
)
}
public var workspaceState: AtlasWorkspaceState {
AtlasWorkspaceState(snapshot: snapshot, currentPlan: currentPlan, settings: settings)
}
}
public enum AtlasScaffoldWorkspace {
public static func state(language: AtlasLanguage = AtlasL10n.currentLanguage) -> AtlasWorkspaceState {
let snapshot = AtlasWorkspaceSnapshot(

View File

@@ -1,4 +1,3 @@
import AtlasInfrastructure
import AtlasProtocol
import Foundation

View File

@@ -146,12 +146,24 @@ public struct MoleSmartCleanAdapter: AtlasSmartCleanScanProviding {
let path = displayPath.lowercased()
let last = url.lastPathComponent
let parent = url.deletingLastPathComponent().lastPathComponent
let containerIdentifier = appContainerIdentifier(from: url)
if path.contains("/google/chrome/default") { return "Chrome cache" }
if path.contains("component_crx_cache") { return "Chrome component cache" }
if path.contains("googleupdater") { return "Google Updater cache" }
if path.contains("deriveddata") { return "Xcode DerivedData" }
if path.contains("/library/pnpm/store") { return "pnpm store" }
if path.contains("/library/containers/"), let containerIdentifier {
if path.contains("/data/library/caches") {
return "\(containerIdentifier) container cache"
}
if path.contains("/data/tmp") || path.contains("/data/library/tmp") {
return "\(containerIdentifier) container temp files"
}
if path.contains("/data/library/logs") || path.contains("/data/logs") {
return "\(containerIdentifier) container logs"
}
}
if path.contains("/__pycache__") || last == "__pycache__" { return "Python bytecode cache" }
if path.contains("/.next/cache") { return "Next.js build cache" }
if path.contains("/.npm/") || path.hasSuffix("/.npm") || path.contains("_cacache") { return "npm cache" }
@@ -315,6 +327,15 @@ public struct MoleSmartCleanAdapter: AtlasSmartCleanScanProviding {
return text.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
}
private static func appContainerIdentifier(from url: URL) -> String? {
let components = url.pathComponents
guard let containersIndex = components.firstIndex(of: "Containers"),
containersIndex + 1 < components.count else {
return nil
}
return components[containersIndex + 1]
}
private static var defaultCleanScriptURL: URL {
MoleRuntimeLocator.url(for: "bin/clean.sh")
}

View File

@@ -34,6 +34,8 @@ Developer tools /Users/test/Library/Developer/Xcode/DerivedData/ProjectA 1024
Developer tools /Users/test/Library/Developer/Xcode/DerivedData/ProjectB 2048
Browsers /Users/test/Library/Caches/Google/Chrome/Default/Cache_Data 512
Developer tools /Users/test/Library/pnpm/store/v3/files/atlas-fixture/package.tgz 256
Developer tools /Users/test/Library/Containers/com.example.preview/Data/Library/Caches/cache.db 128
Developer tools /Users/test/Library/Containers/com.example.preview/Data/Library/Logs/runtime.log 64
""".write(to: fileURL, atomically: true, encoding: .utf8)
let findings = MoleSmartCleanAdapter.parseDetailedFindings(from: fileURL)
@@ -41,5 +43,7 @@ Developer tools /Users/test/Library/pnpm/store/v3/files/atlas-fixture/package.tg
XCTAssertTrue(findings.contains(where: { $0.title == "Xcode DerivedData" && ($0.targetPaths?.count ?? 0) == 2 }))
XCTAssertTrue(findings.contains(where: { $0.title == "Chrome cache" && ($0.targetPaths?.first?.contains("Chrome/Default") ?? false) }))
XCTAssertTrue(findings.contains(where: { $0.title == "pnpm store" && ($0.targetPaths?.first?.contains("/Library/pnpm/store") ?? false) }))
XCTAssertTrue(findings.contains(where: { $0.title == "com.example.preview container cache" && ($0.targetPaths?.first?.contains("/Data/Library/Caches") ?? false) }))
XCTAssertTrue(findings.contains(where: { $0.title == "com.example.preview container logs" && ($0.targetPaths?.first?.contains("/Data/Library/Logs") ?? false) }))
}
}

View File

@@ -63,6 +63,8 @@ public extension ActionItem.Kind {
return "archivebox"
case .inspectPermission:
return "lock.shield"
case .reviewEvidence:
return "doc.text.magnifyingglass"
}
}
}

View File

@@ -171,6 +171,7 @@ public struct ActionItem: Identifiable, Codable, Hashable, Sendable {
case removeApp
case archiveFile
case inspectPermission
case reviewEvidence
}
public var id: UUID
@@ -179,6 +180,7 @@ public struct ActionItem: Identifiable, Codable, Hashable, Sendable {
public var kind: Kind
public var recoverable: Bool
public var targetPaths: [String]?
public var evidencePaths: [String]?
public init(
id: UUID = UUID(),
@@ -186,7 +188,8 @@ public struct ActionItem: Identifiable, Codable, Hashable, Sendable {
detail: String,
kind: Kind,
recoverable: Bool,
targetPaths: [String]? = nil
targetPaths: [String]? = nil,
evidencePaths: [String]? = nil
) {
self.id = id
self.title = title
@@ -194,6 +197,7 @@ public struct ActionItem: Identifiable, Codable, Hashable, Sendable {
self.kind = kind
self.recoverable = recoverable
self.targetPaths = targetPaths
self.evidencePaths = evidencePaths
}
}
@@ -287,9 +291,166 @@ public struct TaskRun: Identifiable, Codable, Hashable, Sendable {
}
}
public enum AtlasAppFootprintEvidenceCategory: String, CaseIterable, Codable, Hashable, Sendable {
case supportFiles
case caches
case preferences
case logs
case launchItems
}
public struct AtlasAppFootprintEvidenceItem: Identifiable, Codable, Hashable, Sendable {
public var path: String
public var bytes: Int64
public var id: String { path }
public init(path: String, bytes: Int64) {
self.path = path
self.bytes = bytes
}
}
public struct AtlasAppFootprintEvidenceGroup: Identifiable, Codable, Hashable, Sendable {
public var category: AtlasAppFootprintEvidenceCategory
public var items: [AtlasAppFootprintEvidenceItem]
public var id: AtlasAppFootprintEvidenceCategory { category }
public var totalBytes: Int64 {
items.map(\.bytes).reduce(0, +)
}
public init(category: AtlasAppFootprintEvidenceCategory, items: [AtlasAppFootprintEvidenceItem]) {
self.category = category
self.items = items
}
}
public struct AtlasAppUninstallEvidence: Codable, Hashable, Sendable {
public var bundlePath: String
public var bundleBytes: Int64
public var reviewOnlyGroups: [AtlasAppFootprintEvidenceGroup]
public var reviewOnlyGroupCount: Int {
reviewOnlyGroups.count
}
public var reviewOnlyItemCount: Int {
reviewOnlyGroups.reduce(0) { partial, group in
partial + group.items.count
}
}
public var reviewOnlyBytes: Int64 {
reviewOnlyGroups.reduce(0) { partial, group in
partial + group.totalBytes
}
}
public init(bundlePath: String, bundleBytes: Int64, reviewOnlyGroups: [AtlasAppFootprintEvidenceGroup]) {
self.bundlePath = bundlePath
self.bundleBytes = bundleBytes
self.reviewOnlyGroups = reviewOnlyGroups
}
}
public enum AtlasRecoveryPayloadSchemaVersion {
public static let current = 1
}
public struct AtlasAppRecoveryPayload: Codable, Hashable, Sendable {
public var schemaVersion: Int
public var app: AppFootprint
public var uninstallEvidence: AtlasAppUninstallEvidence
public init(
schemaVersion: Int = AtlasRecoveryPayloadSchemaVersion.current,
app: AppFootprint,
uninstallEvidence: AtlasAppUninstallEvidence
) {
self.schemaVersion = schemaVersion
self.app = app
self.uninstallEvidence = uninstallEvidence
}
private enum CodingKeys: String, CodingKey {
case schemaVersion
case app
case uninstallEvidence
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion)
?? AtlasRecoveryPayloadSchemaVersion.current
self.app = try container.decode(AppFootprint.self, forKey: .app)
self.uninstallEvidence = try container.decode(AtlasAppUninstallEvidence.self, forKey: .uninstallEvidence)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(schemaVersion, forKey: .schemaVersion)
try container.encode(app, forKey: .app)
try container.encode(uninstallEvidence, forKey: .uninstallEvidence)
}
}
public enum RecoveryPayload: Codable, Hashable, Sendable {
case finding(Finding)
case app(AppFootprint)
case app(AtlasAppRecoveryPayload)
private enum CodingKeys: String, CodingKey {
case finding
case app
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if container.contains(.finding) {
self = .finding(try container.decode(Finding.self, forKey: .finding))
return
}
if container.contains(.app) {
if let payload = try? container.decode(AtlasAppRecoveryPayload.self, forKey: .app) {
self = .app(payload)
return
}
let legacyApp = try container.decode(AppFootprint.self, forKey: .app)
self = .app(
AtlasAppRecoveryPayload(
app: legacyApp,
uninstallEvidence: AtlasAppUninstallEvidence(
bundlePath: legacyApp.bundlePath,
bundleBytes: legacyApp.bytes,
reviewOnlyGroups: []
)
)
)
return
}
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "RecoveryPayload must contain either a finding or app payload."
)
)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case let .finding(finding):
try container.encode(finding, forKey: .finding)
case let .app(payload):
try container.encode(payload, forKey: .app)
}
}
}
public struct RecoveryPathMapping: Codable, Hashable, Sendable {

View File

@@ -141,9 +141,12 @@
"infrastructure.apps.loaded.one" = "Loaded 1 app footprint.";
"infrastructure.apps.loaded.other" = "Loaded %d app footprints.";
"infrastructure.apps.preview.summary" = "Generated an uninstall plan for %@.";
"infrastructure.apps.uninstall.summary" = "Moved %@ and its leftovers into recovery.";
"infrastructure.recovery.app.detail.one" = "Recoverable uninstall bundle with 1 leftover item.";
"infrastructure.recovery.app.detail.other" = "Recoverable uninstall bundle with %d leftover items.";
"infrastructure.apps.uninstall.summary" = "Moved %@ into recovery and kept leftover evidence review-only.";
"infrastructure.apps.uninstall.summary.review.one" = "Moved %@ into recovery and kept 1 review-only evidence group visible.";
"infrastructure.apps.uninstall.summary.review.other" = "Moved %@ into recovery and kept %2$d review-only evidence groups visible.";
"infrastructure.apps.uninstall.reviewCategories" = "Review-only categories: %@.";
"infrastructure.recovery.app.detail.one" = "Recoverable uninstall bundle with 1 review-only leftover item.";
"infrastructure.recovery.app.detail.other" = "Recoverable uninstall bundle with %d review-only leftover items.";
"infrastructure.plan.review.one" = "Review 1 selected finding";
"infrastructure.plan.review.other" = "Review %d selected findings";
"infrastructure.plan.uninstall.title" = "Uninstall %@";
@@ -152,6 +155,13 @@
"infrastructure.plan.uninstall.archive.one" = "Archive 1 leftover item";
"infrastructure.plan.uninstall.archive.other" = "Archive %d leftover items";
"infrastructure.plan.uninstall.archive.detail" = "Support files, caches, and launch items remain reviewable through recovery history.";
"infrastructure.plan.uninstall.review.title" = "Review %@ (%d)";
"infrastructure.plan.uninstall.review.detail" = "Found %@ across %d item(s). Atlas keeps this review-only for now.";
"infrastructure.plan.uninstall.review.supportFiles" = "support files";
"infrastructure.plan.uninstall.review.caches" = "caches";
"infrastructure.plan.uninstall.review.preferences" = "preferences";
"infrastructure.plan.uninstall.review.logs" = "logs";
"infrastructure.plan.uninstall.review.launchItems" = "launch items";
"infrastructure.action.reviewUninstall" = "Review uninstall plan for %@";
"infrastructure.action.inspectPrivileged" = "Inspect privileged cleanup for %@";
"infrastructure.action.archiveRecovery" = "Archive %@ into recovery";
@@ -359,17 +369,26 @@
"apps.refresh.hint" = "Refreshes the installed app inventory and recalculates footprints.";
"apps.preview.title" = "Uninstall Plan";
"apps.preview.metric.size.title" = "Estimated Space";
"apps.preview.metric.size.detail" = "Estimated space this uninstall plan would remove, including leftovers.";
"apps.preview.metric.size.detail" = "Estimated space Atlas can remove in the current uninstall flow.";
"apps.preview.metric.actions.title" = "Plan Steps";
"apps.preview.metric.actions.detail" = "Every uninstall step is listed before Atlas removes anything.";
"apps.preview.metric.recoverable.title" = "Recoverable Steps";
"apps.preview.metric.recoverable.detail" = "These steps stay visible in History and Recovery when supported.";
"apps.preview.metric.reviewOnly.title" = "Review-Only Groups";
"apps.preview.metric.reviewOnly.detail" = "Evidence Atlas found but does not remove automatically in this uninstall flow.";
"apps.preview.reviewOnly.title" = "Review-Only Evidence";
"apps.preview.reviewOnly.subtitle.one" = "1 evidence group remains review-only in the current uninstall flow.";
"apps.preview.reviewOnly.subtitle.other" = "%d evidence groups remain review-only in the current uninstall flow.";
"apps.preview.reviewOnly.footnote" = "Atlas found these related files but does not remove them automatically in this uninstall flow.";
"apps.preview.reviewOnly.paths.title" = "Observed paths";
"apps.preview.reviewOnly.paths.detail.one" = "1 path currently contributes to this review-only evidence.";
"apps.preview.reviewOnly.paths.detail.other" = "%d paths currently contribute to this review-only evidence.";
"apps.preview.callout.safe.title" = "This uninstall plan stays mostly recoverable";
"apps.preview.callout.safe.detail" = "Atlas preserves a recovery path for the selected app and related files where possible.";
"apps.preview.callout.review.title" = "Some steps in this uninstall plan need a closer review";
"apps.preview.callout.review.detail" = "Review each step so the uninstall and leftover cleanup match your expectations.";
"apps.preview.row.recoverable" = "Recoverable through History when supported.";
"apps.preview.row.review" = "Run only after review.";
"apps.preview.row.review" = "Review-only evidence. Atlas will not remove this in the uninstall flow today.";
"apps.preview.action" = "Review Plan";
"apps.preview.running" = "Building Plan…";
"apps.preview.hint" = "Builds the uninstall plan for this app before anything is removed.";
@@ -396,7 +415,7 @@
"apps.detail.callout.preview.title" = "Build the uninstall plan first";
"apps.detail.callout.preview.detail" = "Atlas keeps uninstall confidence high by showing the exact steps before anything is removed.";
"apps.detail.callout.ready.title" = "This app is ready for reviewed uninstall";
"apps.detail.callout.ready.detail" = "The plan below shows what will be removed and what remains recoverable.";
"apps.detail.callout.ready.detail" = "The plan below shows what Atlas can remove now and what stays review-only.";
"history.screen.title" = "History";
"history.screen.subtitle" = "See what ran, what changed, and what you can still restore before recovery retention expires.";
@@ -475,6 +494,22 @@
"history.detail.recovery.deleted" = "Deleted";
"history.detail.recovery.window" = "Retention window";
"history.detail.recovery.window.open" = "Still recoverable";
"history.detail.recovery.evidence.title" = "Recorded Recovery Evidence";
"history.detail.recovery.evidence.subtitle" = "Atlas keeps the app payload, review-only groups, and restore path records together for this recovery item.";
"history.detail.recovery.evidence.payload" = "Recorded payload";
"history.detail.recovery.evidence.schema" = "Payload schema";
"history.detail.recovery.evidence.reviewGroups" = "Review-only groups";
"history.detail.recovery.evidence.reviewGroups.detail.one" = "%@ · 1 item";
"history.detail.recovery.evidence.reviewGroups.detail.other" = "%1$@ · %2$d items";
"history.detail.recovery.evidence.restorePaths" = "Restore path records";
"history.detail.recovery.evidence.restorePaths.detail.one" = "1 original-path to Trash-path record was saved for this item.";
"history.detail.recovery.evidence.restorePaths.detail.other" = "%d original-path to Trash-path records were saved for this item.";
"history.detail.recovery.reviewOnly.title" = "Review-Only Leftover Evidence";
"history.detail.recovery.reviewOnly.subtitle.one" = "1 related leftover item was recorded during uninstall.";
"history.detail.recovery.reviewOnly.subtitle.other" = "%d related leftover items were recorded during uninstall.";
"history.detail.recovery.reviewOnly.callout.title" = "The uninstall kept this evidence informational";
"history.detail.recovery.reviewOnly.callout.detail.fileBacked" = "Atlas removed the app bundle and can restore that bundle to disk. The leftover evidence remained review-only and was not automatically removed.";
"history.detail.recovery.reviewOnly.callout.detail.stateOnly" = "Atlas recorded this uninstall and its leftover evidence, but this recovery entry does not claim an on-disk return path for the leftover evidence.";
"history.detail.recovery.callout.available.title" = "This item is still recoverable";
"history.detail.recovery.callout.available.detail" = "Restore it while the retention window remains open. On-disk return is available only when Atlas has a supported restore path for this item.";
"history.detail.recovery.callout.expiring.title" = "Restore soon if you still need this";
@@ -604,7 +639,7 @@
"smartclean.confirm.execute.title" = "Run this cleanup plan?";
"smartclean.confirm.execute.message" = "This will process the reviewed plan. Recoverable items stay visible in History, and on-disk restore is available only when Atlas has a supported recovery path.";
"apps.confirm.uninstall.title" = "Uninstall this app?";
"apps.confirm.uninstall.message" = "This will remove %@ and its leftovers. Recoverable items can be restored from History.";
"apps.confirm.uninstall.message" = "This will remove %@. Review-only leftover evidence stays visible in the plan, and Atlas records the uninstall in History.";
"emptystate.action.scan" = "Run Smart Clean";
"emptystate.action.refresh" = "Refresh Apps";
"emptystate.action.viewHistory" = "View History";

View File

@@ -141,9 +141,12 @@
"infrastructure.apps.loaded.one" = "已载入 1 个应用占用项。";
"infrastructure.apps.loaded.other" = "已载入 %d 个应用占用项。";
"infrastructure.apps.preview.summary" = "已为 %@ 生成卸载计划。";
"infrastructure.apps.uninstall.summary" = "已将 %@ 及其残留移入恢复区。";
"infrastructure.recovery.app.detail.one" = "可恢复的卸载包,包含 1 个残留项目。";
"infrastructure.recovery.app.detail.other" = "可恢复的卸载包,包含 %d 个残留项目。";
"infrastructure.apps.uninstall.summary" = "已将 %@ 应用包移入恢复区,并将残留证据保留为仅供复核。";
"infrastructure.apps.uninstall.summary.review.one" = "已将 %@ 应用包移入恢复区,并保留 1 组仅供复核的证据。";
"infrastructure.apps.uninstall.summary.review.other" = "已将 %@ 应用包移入恢复区,并保留 %2$d 组仅供复核的证据。";
"infrastructure.apps.uninstall.reviewCategories" = "仅供复核的分类:%@。";
"infrastructure.recovery.app.detail.one" = "可恢复的卸载包,另记录了 1 个仅供复核的残留项目。";
"infrastructure.recovery.app.detail.other" = "可恢复的卸载包,另记录了 %d 个仅供复核的残留项目。";
"infrastructure.plan.review.one" = "复核 1 个已选发现项";
"infrastructure.plan.review.other" = "复核 %d 个已选发现项";
"infrastructure.plan.uninstall.title" = "卸载 %@";
@@ -152,6 +155,13 @@
"infrastructure.plan.uninstall.archive.one" = "归档 1 个残留项目";
"infrastructure.plan.uninstall.archive.other" = "归档 %d 个残留项目";
"infrastructure.plan.uninstall.archive.detail" = "支持文件、缓存和启动项仍会通过恢复历史保留可追溯性。";
"infrastructure.plan.uninstall.review.title" = "复核%@%d项";
"infrastructure.plan.uninstall.review.detail" = "发现 %@、共 %d 项。Atlas 目前仅将这些内容保留为复核证据,不会在这次卸载中直接移除。";
"infrastructure.plan.uninstall.review.supportFiles" = "支持文件";
"infrastructure.plan.uninstall.review.caches" = "缓存";
"infrastructure.plan.uninstall.review.preferences" = "偏好设置";
"infrastructure.plan.uninstall.review.logs" = "日志";
"infrastructure.plan.uninstall.review.launchItems" = "启动项";
"infrastructure.action.reviewUninstall" = "复核 %@ 的卸载计划";
"infrastructure.action.inspectPrivileged" = "检查 %@ 的受权限影响清理项";
"infrastructure.action.archiveRecovery" = "将 %@ 归档到恢复区";
@@ -359,17 +369,26 @@
"apps.refresh.hint" = "刷新已安装应用清单,并重新计算占用。";
"apps.preview.title" = "卸载计划";
"apps.preview.metric.size.title" = "预计释放空间";
"apps.preview.metric.size.detail" = "执行这份卸载计划后,预计可释放的空间,包含残留文件。";
"apps.preview.metric.size.detail" = "当前这条卸载流程里Atlas 实际可以移除的预计空间。";
"apps.preview.metric.actions.title" = "计划步骤";
"apps.preview.metric.actions.detail" = "Atlas 会在真正移除前先列出每一个卸载步骤。";
"apps.preview.metric.recoverable.title" = "可恢复步骤";
"apps.preview.metric.recoverable.detail" = "支持恢复的步骤会在历史和恢复中保留。";
"apps.preview.metric.reviewOnly.title" = "仅供复核分组";
"apps.preview.metric.reviewOnly.detail" = "Atlas 找到了这些证据,但当前不会在这条卸载流程里自动移除。";
"apps.preview.reviewOnly.title" = "仅供复核的证据";
"apps.preview.reviewOnly.subtitle.one" = "当前这条卸载流程里,还有 1 组证据仅供复核。";
"apps.preview.reviewOnly.subtitle.other" = "当前这条卸载流程里,还有 %d 组证据仅供复核。";
"apps.preview.reviewOnly.footnote" = "Atlas 已找到这些相关文件,但今天不会在这条卸载流程里自动移除它们。";
"apps.preview.reviewOnly.paths.title" = "已观测路径";
"apps.preview.reviewOnly.paths.detail.one" = "当前有 1 条路径构成这组仅供复核的证据。";
"apps.preview.reviewOnly.paths.detail.other" = "当前有 %d 条路径构成这组仅供复核的证据。";
"apps.preview.callout.safe.title" = "这份卸载计划大多可恢复";
"apps.preview.callout.safe.detail" = "在支持的情况下Atlas 会为所选应用及相关文件保留恢复路径。";
"apps.preview.callout.review.title" = "这份卸载计划中仍有步骤需要复核";
"apps.preview.callout.review.detail" = "建议逐项查看计划,确保卸载结果和残留清理方式符合你的预期。";
"apps.preview.row.recoverable" = "支持时可通过历史恢复。";
"apps.preview.row.review" = "执行前需复核。";
"apps.preview.row.review" = "仅供复核的证据项Atlas 今天不会在这次卸载中直接移除。";
"apps.preview.action" = "查看计划";
"apps.preview.running" = "正在生成计划…";
"apps.preview.hint" = "先为这个应用生成卸载计划,再决定是否真正移除。";
@@ -396,7 +415,7 @@
"apps.detail.callout.preview.title" = "先生成卸载计划";
"apps.detail.callout.preview.detail" = "为了让卸载更可控Atlas 会先展示准确步骤,再决定是否真正移除。";
"apps.detail.callout.ready.title" = "这个应用已经可以在复核后卸载";
"apps.detail.callout.ready.detail" = "下面的计划会说明将删除什么,以及哪些内容仍可恢复。";
"apps.detail.callout.ready.detail" = "下面的计划会区分 Atlas 现在能删除什么,以及哪些内容仍然只是复核证据。";
"history.screen.title" = "历史";
"history.screen.subtitle" = "查看执行过的任务、发生的变更,以及在恢复窗口关闭前仍可找回的项目。";
@@ -475,6 +494,22 @@
"history.detail.recovery.deleted" = "删除时间";
"history.detail.recovery.window" = "保留窗口";
"history.detail.recovery.window.open" = "仍可恢复";
"history.detail.recovery.evidence.title" = "恢复证据记录";
"history.detail.recovery.evidence.subtitle" = "Atlas 会把这条恢复项的应用载荷、仅供复核分组和恢复路径记录一起保留下来。";
"history.detail.recovery.evidence.payload" = "已记录载荷";
"history.detail.recovery.evidence.schema" = "载荷版本";
"history.detail.recovery.evidence.reviewGroups" = "仅供复核分组";
"history.detail.recovery.evidence.reviewGroups.detail.one" = "%@ · 1 项";
"history.detail.recovery.evidence.reviewGroups.detail.other" = "%1$@ · %2$d 项";
"history.detail.recovery.evidence.restorePaths" = "恢复路径记录";
"history.detail.recovery.evidence.restorePaths.detail.one" = "这条恢复项记录了 1 组原路径与废纸篓路径映射。";
"history.detail.recovery.evidence.restorePaths.detail.other" = "这条恢复项记录了 %d 组原路径与废纸篓路径映射。";
"history.detail.recovery.reviewOnly.title" = "仅供复核的残留证据";
"history.detail.recovery.reviewOnly.subtitle.one" = "这次卸载还记录了 1 个相关残留项目。";
"history.detail.recovery.reviewOnly.subtitle.other" = "这次卸载还记录了 %d 个相关残留项目。";
"history.detail.recovery.reviewOnly.callout.title" = "这次卸载将这些证据保留为信息记录";
"history.detail.recovery.reviewOnly.callout.detail.fileBacked" = "Atlas 已移除应用包,也可以把这个应用包恢复回磁盘。相关残留证据仍然只是复核信息,并没有被自动删除。";
"history.detail.recovery.reviewOnly.callout.detail.stateOnly" = "Atlas 记录了这次卸载及其残留证据,但这个恢复项并不声明这些残留证据具备磁盘级恢复路径。";
"history.detail.recovery.callout.available.title" = "这个项目仍可恢复";
"history.detail.recovery.callout.available.detail" = "只要保留窗口还在,你就可以恢复。只有当 Atlas 为该项目记录了受支持的恢复路径时,它才会真正回到磁盘。";
"history.detail.recovery.callout.expiring.title" = "如果还需要它,请尽快恢复";
@@ -604,7 +639,7 @@
"smartclean.confirm.execute.title" = "执行这份清理计划?";
"smartclean.confirm.execute.message" = "将按复核后的计划执行清理。可恢复项目会保留在历史中;只有具备受支持恢复路径的项目,才支持磁盘级恢复。";
"apps.confirm.uninstall.title" = "卸载这个应用?";
"apps.confirm.uninstall.message" = "将移除 %@ 及其残留文件。可恢复的项目可以在历史中找回。";
"apps.confirm.uninstall.message" = "将移除 %@ 应用包。仅供复核的残留证据会继续显示在计划里,本次卸载也会记录到历史中。";
"emptystate.action.scan" = "运行智能清理";
"emptystate.action.refresh" = "刷新应用";
"emptystate.action.viewHistory" = "查看历史";

View File

@@ -43,4 +43,33 @@ final class AtlasDomainTests: XCTestCase {
XCTAssertFalse(PermissionKind.notifications.isRequiredForCurrentWorkflows)
}
func testRecoveryPayloadDecodesLegacyAppShape() throws {
let data = Data(
"""
{
"app": {
"id": "10000000-0000-0000-0000-000000000111",
"name": "Legacy App",
"bundleIdentifier": "com.example.legacy",
"bundlePath": "/Applications/Legacy App.app",
"bytes": 1024,
"leftoverItems": 2
}
}
""".utf8
)
let payload = try JSONDecoder().decode(RecoveryPayload.self, from: data)
guard case let .app(appPayload) = payload else {
return XCTFail("Expected app payload")
}
XCTAssertEqual(appPayload.schemaVersion, AtlasRecoveryPayloadSchemaVersion.current)
XCTAssertEqual(appPayload.app.name, "Legacy App")
XCTAssertEqual(appPayload.app.leftoverItems, 2)
XCTAssertEqual(appPayload.uninstallEvidence.reviewOnlyGroupCount, 0)
XCTAssertEqual(appPayload.uninstallEvidence.bundlePath, "/Applications/Legacy App.app")
}
}

View File

@@ -442,6 +442,8 @@ private struct AppDetailView: View {
}
if let previewPlan {
let recoverableItems = previewPlan.items.filter(\.recoverable)
let reviewOnlyItems = previewPlan.items.filter { !$0.recoverable }
AtlasInfoCard(
title: AtlasL10n.string("apps.preview.title"),
subtitle: previewPlan.title,
@@ -464,26 +466,95 @@ private struct AppDetailView: View {
)
AtlasMetricCard(
title: AtlasL10n.string("apps.preview.metric.recoverable.title"),
value: "\(previewPlan.items.filter(\.recoverable).count)",
value: "\(recoverableItems.count)",
detail: AtlasL10n.string("apps.preview.metric.recoverable.detail"),
tone: .success,
systemImage: "arrow.uturn.backward.circle"
)
AtlasMetricCard(
title: AtlasL10n.string("apps.preview.metric.reviewOnly.title"),
value: "\(reviewOnlyItems.count)",
detail: AtlasL10n.string("apps.preview.metric.reviewOnly.detail"),
tone: reviewOnlyItems.isEmpty ? .neutral : .warning,
systemImage: "doc.text.magnifyingglass"
)
}
VStack(alignment: .leading, spacing: AtlasSpacing.md) {
ForEach(previewPlan.items) { item in
AtlasDetailRow(
title: item.title,
subtitle: item.detail,
footnote: item.recoverable ? AtlasL10n.string("apps.preview.row.recoverable") : AtlasL10n.string("apps.preview.row.review"),
systemImage: item.kind.atlasSystemImage,
tone: item.recoverable ? .success : .warning
) {
AtlasStatusChip(
item.recoverable ? AtlasL10n.string("common.recoverable") : AtlasL10n.string("common.manualReview"),
tone: item.recoverable ? .success : .warning
)
AtlasCallout(
title: AtlasL10n.string(
reviewOnlyItems.isEmpty
? "apps.preview.callout.safe.title"
: "apps.preview.callout.review.title"
),
detail: AtlasL10n.string(
reviewOnlyItems.isEmpty
? "apps.preview.callout.safe.detail"
: "apps.preview.callout.review.detail"
),
tone: reviewOnlyItems.isEmpty ? .success : .warning,
systemImage: reviewOnlyItems.isEmpty ? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
)
if !recoverableItems.isEmpty {
VStack(alignment: .leading, spacing: AtlasSpacing.md) {
ForEach(recoverableItems) { item in
AtlasDetailRow(
title: item.title,
subtitle: item.detail,
footnote: AtlasL10n.string("apps.preview.row.recoverable"),
systemImage: item.kind.atlasSystemImage,
tone: .success
) {
AtlasStatusChip(
AtlasL10n.string("common.recoverable"),
tone: .success
)
}
}
}
}
if !reviewOnlyItems.isEmpty {
AtlasInfoCard(
title: AtlasL10n.string("apps.preview.reviewOnly.title"),
subtitle: AtlasL10n.string(
reviewOnlyItems.count == 1
? "apps.preview.reviewOnly.subtitle.one"
: "apps.preview.reviewOnly.subtitle.other",
reviewOnlyItems.count
),
tone: .neutral
) {
VStack(alignment: .leading, spacing: AtlasSpacing.md) {
ForEach(reviewOnlyItems) { item in
VStack(alignment: .leading, spacing: AtlasSpacing.sm) {
AtlasDetailRow(
title: item.title,
subtitle: item.detail,
footnote: AtlasL10n.string("apps.preview.reviewOnly.footnote"),
systemImage: item.kind.atlasSystemImage,
tone: .neutral
) {
AtlasStatusChip(
AtlasL10n.string("common.manualReview"),
tone: .warning
)
}
if let evidencePaths = item.evidencePaths, !evidencePaths.isEmpty {
AtlasMachineTextBlock(
title: AtlasL10n.string("apps.preview.reviewOnly.paths.title"),
value: evidencePaths.joined(separator: "\n"),
detail: AtlasL10n.string(
evidencePaths.count == 1
? "apps.preview.reviewOnly.paths.detail.one"
: "apps.preview.reviewOnly.paths.detail.other",
evidencePaths.count
)
)
}
}
}
}
}
}

View File

@@ -981,6 +981,78 @@ private struct HistoryRecoveryDetailView: View {
.strokeBorder(AtlasColor.border, lineWidth: 1)
)
if appRecoveryPayload != nil || item.hasPhysicalRestorePath {
AtlasInfoCard(
title: AtlasL10n.string("history.detail.recovery.evidence.title"),
subtitle: AtlasL10n.string("history.detail.recovery.evidence.subtitle"),
tone: item.hasPhysicalRestorePath ? .success : .neutral
) {
VStack(alignment: .leading, spacing: AtlasSpacing.md) {
if let payload = appRecoveryPayload {
AtlasKeyValueRow(
title: AtlasL10n.string("history.detail.recovery.evidence.payload"),
value: payload.app.name,
detail: payload.app.bundleIdentifier
)
AtlasKeyValueRow(
title: AtlasL10n.string("history.detail.recovery.evidence.schema"),
value: "\(payload.schemaVersion)",
detail: item.hasPhysicalRestorePath
? AtlasL10n.string("history.detail.recovery.callout.available.fileBacked.title")
: AtlasL10n.string("history.detail.recovery.callout.available.stateOnly.title")
)
if payload.uninstallEvidence.reviewOnlyGroupCount > 0 {
AtlasKeyValueRow(
title: AtlasL10n.string("history.detail.recovery.evidence.reviewGroups"),
value: "\(payload.uninstallEvidence.reviewOnlyGroupCount)",
detail: appReviewOnlyGroupSummary(for: payload)
)
}
}
if let restoreMappings = item.restoreMappings, !restoreMappings.isEmpty {
AtlasMachineTextBlock(
title: AtlasL10n.string("history.detail.recovery.evidence.restorePaths"),
value: restoreMappings
.map { "\($0.originalPath)\n-> \($0.trashedPath)" }
.joined(separator: "\n\n"),
detail: AtlasL10n.string(
restoreMappings.count == 1
? "history.detail.recovery.evidence.restorePaths.detail.one"
: "history.detail.recovery.evidence.restorePaths.detail.other",
restoreMappings.count
)
)
}
}
}
}
if let payload = appRecoveryPayload, payload.uninstallEvidence.reviewOnlyItemCount > 0 {
AtlasInfoCard(
title: AtlasL10n.string("history.detail.recovery.reviewOnly.title"),
subtitle: AtlasL10n.string(
payload.uninstallEvidence.reviewOnlyItemCount == 1
? "history.detail.recovery.reviewOnly.subtitle.one"
: "history.detail.recovery.reviewOnly.subtitle.other",
payload.uninstallEvidence.reviewOnlyItemCount
),
tone: .neutral
) {
AtlasCallout(
title: AtlasL10n.string("history.detail.recovery.reviewOnly.callout.title"),
detail: AtlasL10n.string(
item.hasPhysicalRestorePath
? "history.detail.recovery.reviewOnly.callout.detail.fileBacked"
: "history.detail.recovery.reviewOnly.callout.detail.stateOnly"
),
tone: .neutral,
systemImage: "doc.text.magnifyingglass"
)
}
}
ViewThatFits(in: .horizontal) {
HStack(alignment: .center, spacing: AtlasSpacing.md) {
Spacer(minLength: 0)
@@ -1023,6 +1095,20 @@ private struct HistoryRecoveryDetailView: View {
}
}
private var appPayload: AppFootprint? {
guard case let .app(payload)? = item.payload else {
return nil
}
return payload.app
}
private var appRecoveryPayload: AtlasAppRecoveryPayload? {
guard case let .app(payload)? = item.payload else {
return nil
}
return payload
}
private var restoreButton: some View {
Button(isRestoring ? restoreRunningTitle : restoreActionTitle) {
onRestore()
@@ -1090,6 +1176,32 @@ private struct HistoryRecoveryDetailView: View {
? AtlasL10n.string("history.restore.hint.fileBacked")
: AtlasL10n.string("history.restore.hint.stateOnly")
}
private func appReviewOnlyGroupSummary(for payload: AtlasAppRecoveryPayload) -> String {
payload.uninstallEvidence.reviewOnlyGroups.map { group in
let categoryLabel: String
switch group.category {
case .supportFiles:
categoryLabel = AtlasL10n.string("infrastructure.plan.uninstall.review.supportFiles")
case .caches:
categoryLabel = AtlasL10n.string("infrastructure.plan.uninstall.review.caches")
case .preferences:
categoryLabel = AtlasL10n.string("infrastructure.plan.uninstall.review.preferences")
case .logs:
categoryLabel = AtlasL10n.string("infrastructure.plan.uninstall.review.logs")
case .launchItems:
categoryLabel = AtlasL10n.string("infrastructure.plan.uninstall.review.launchItems")
}
return AtlasL10n.string(
group.items.count == 1
? "history.detail.recovery.evidence.reviewGroups.detail.one"
: "history.detail.recovery.evidence.reviewGroups.detail.other",
categoryLabel,
group.items.count
)
}
.joined(separator: "")
}
}
private extension TaskRun {

View File

@@ -303,7 +303,7 @@ public struct SmartCleanFeatureView: View {
}
private func isPhysicallyExecutable(_ item: ActionItem) -> Bool {
guard item.kind != .inspectPermission else {
guard item.kind != .inspectPermission, item.kind != .reviewEvidence else {
return false
}
if let targetPaths = item.targetPaths, !targetPaths.isEmpty {
@@ -474,6 +474,8 @@ public struct SmartCleanFeatureView: View {
return AtlasL10n.string("smartclean.support.archiveFile")
case .inspectPermission:
return AtlasL10n.string("smartclean.support.inspectPermission")
case .reviewEvidence:
return AtlasL10n.string("smartclean.support.archiveFile")
}
}

View File

@@ -0,0 +1,108 @@
import AtlasDomain
import Foundation
public struct AtlasAppUninstallEvidenceAnalyzer: Sendable {
private let homeDirectoryURL: URL
public init(homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser) {
self.homeDirectoryURL = homeDirectoryURL
}
public func analyze(
appName: String,
bundleIdentifier: String,
bundlePath: String,
bundleBytes: Int64
) -> AtlasAppUninstallEvidence {
let reviewOnlyGroups = AtlasAppFootprintEvidenceCategory.allCases.compactMap { category -> AtlasAppFootprintEvidenceGroup? in
let items = existingItems(for: category, appName: appName, bundleIdentifier: bundleIdentifier)
guard !items.isEmpty else {
return nil
}
return AtlasAppFootprintEvidenceGroup(category: category, items: items)
}
return AtlasAppUninstallEvidence(
bundlePath: bundlePath,
bundleBytes: bundleBytes,
reviewOnlyGroups: reviewOnlyGroups
)
}
private func existingItems(
for category: AtlasAppFootprintEvidenceCategory,
appName: String,
bundleIdentifier: String
) -> [AtlasAppFootprintEvidenceItem] {
let urls = candidateURLs(for: category, appName: appName, bundleIdentifier: bundleIdentifier)
let uniqueURLs = Array(Set(urls.map { $0.resolvingSymlinksInPath().path })).sorted().map(URL.init(fileURLWithPath:))
return uniqueURLs.compactMap { url in
guard FileManager.default.fileExists(atPath: url.path) else {
return nil
}
return AtlasAppFootprintEvidenceItem(path: url.path, bytes: allocatedSize(for: url))
}
}
private func candidateURLs(
for category: AtlasAppFootprintEvidenceCategory,
appName: String,
bundleIdentifier: String
) -> [URL] {
switch category {
case .supportFiles:
return [
homeDirectoryURL.appendingPathComponent("Library/Application Support/\(appName)", isDirectory: true),
homeDirectoryURL.appendingPathComponent("Library/Application Support/\(bundleIdentifier)", isDirectory: true),
homeDirectoryURL.appendingPathComponent("Library/Containers/\(bundleIdentifier)/Data/Library/Application Support", isDirectory: true),
homeDirectoryURL.appendingPathComponent("Library/Saved Application State/\(bundleIdentifier).savedState", isDirectory: true),
]
case .caches:
return [
homeDirectoryURL.appendingPathComponent("Library/Caches/\(bundleIdentifier)", isDirectory: true),
homeDirectoryURL.appendingPathComponent("Library/Containers/\(bundleIdentifier)/Data/Library/Caches", isDirectory: true),
]
case .preferences:
return [
homeDirectoryURL.appendingPathComponent("Library/Preferences/\(bundleIdentifier).plist"),
homeDirectoryURL.appendingPathComponent("Library/Containers/\(bundleIdentifier)/Data/Library/Preferences/\(bundleIdentifier).plist"),
]
case .logs:
return [
homeDirectoryURL.appendingPathComponent("Library/Logs/\(appName)", isDirectory: true),
homeDirectoryURL.appendingPathComponent("Library/Logs/\(bundleIdentifier)", isDirectory: true),
homeDirectoryURL.appendingPathComponent("Library/Containers/\(bundleIdentifier)/Data/Library/Logs", isDirectory: true),
]
case .launchItems:
return [
homeDirectoryURL.appendingPathComponent("Library/LaunchAgents/\(bundleIdentifier).plist"),
homeDirectoryURL.appendingPathComponent("Library/LaunchDaemons/\(bundleIdentifier).plist"),
]
}
}
private func allocatedSize(for url: URL) -> Int64 {
if let values = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey]),
let size = values.totalFileAllocatedSize ?? values.fileAllocatedSize {
return Int64(size)
}
var total: Int64 = 0
if let enumerator = FileManager.default.enumerator(
at: url,
includingPropertiesForKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileAllocatedSizeKey],
options: [.skipsHiddenFiles]
) {
for case let fileURL as URL in enumerator {
let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileAllocatedSizeKey])
guard values?.isRegularFile == true else {
continue
}
let size = values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0
total += Int64(size)
}
}
return total
}
}

View File

@@ -33,22 +33,6 @@ public actor AtlasAuditStore {
}
}
public struct AtlasCapabilityStatus: Hashable, Sendable {
public var workerConnected: Bool
public var helperInstalled: Bool
public var protocolVersion: String
public init(
workerConnected: Bool = false,
helperInstalled: Bool = false,
protocolVersion: String = AtlasProtocolVersion.current
) {
self.workerConnected = workerConnected
self.helperInstalled = helperInstalled
self.protocolVersion = protocolVersion
}
}
public struct AtlasPermissionInspector: Sendable {
private let fullDiskAccessProbeURLs: [URL]
private let protectedLocationReader: @Sendable (URL) -> Bool
@@ -205,10 +189,14 @@ public enum AtlasSmartCleanExecutionSupport {
home + "/Library/Logs",
home + "/Library/Suggestions",
home + "/Library/Messages/Caches",
home + "/Library/Developer/CoreSimulator/Caches",
home + "/Library/Developer/CoreSimulator/tmp",
home + "/Library/Developer/Xcode/DerivedData",
home + "/Library/pnpm/store",
home + "/.npm",
home + "/.npm_cache",
home + "/.gradle/caches",
home + "/.ivy2/cache",
home + "/.oh-my-zsh/cache",
home + "/.cache",
home + "/.pytest_cache",
@@ -236,6 +224,7 @@ public enum AtlasSmartCleanExecutionSupport {
home + "/.android/build-cache",
home + "/.android/cache",
home + "/.cache/swift-package-manager",
home + "/.swiftpm/cache",
home + "/.expo/expo-go",
home + "/.expo/android-apk-cache",
home + "/.expo/ios-simulator-app-cache",
@@ -249,6 +238,10 @@ public enum AtlasSmartCleanExecutionSupport {
return true
}
if isSupportedContainerCleanupPath(path, homeDirectoryURL: homeDirectoryURL) {
return true
}
let safeFragments = [
"/__pycache__",
"/.next/cache",
@@ -279,6 +272,24 @@ public enum AtlasSmartCleanExecutionSupport {
return safeBasenamePrefixes.contains(where: { basename.hasPrefix($0) })
}
private static func isSupportedContainerCleanupPath(_ path: String, homeDirectoryURL: URL) -> Bool {
let containerRoot = homeDirectoryURL.appendingPathComponent("Library/Containers", isDirectory: true).path
guard path == containerRoot || path.hasPrefix(containerRoot + "/") else {
return false
}
let allowedContainerFragments = [
"/Data/Library/Caches",
"/Data/Library/Logs",
"/Data/tmp",
"/Data/Library/tmp",
]
return allowedContainerFragments.contains(where: { fragment in
path == containerRoot + fragment || path.contains(fragment + "/") || path.hasSuffix(fragment)
})
}
public static func isSupportedExecutionTarget(_ targetURL: URL, homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser) -> Bool {
requiresHelper(for: targetURL, homeDirectoryURL: homeDirectoryURL)
|| isDirectlyTrashable(targetURL, homeDirectoryURL: homeDirectoryURL)
@@ -313,9 +324,10 @@ public struct AtlasWorkspaceRepository: Sendable {
if FileManager.default.fileExists(atPath: stateFileURL.path) {
do {
let data = try Data(contentsOf: stateFileURL)
let decoded = try decoder.decode(AtlasWorkspaceState.self, from: data)
let decodedResult = try decodePersistedState(from: data, using: decoder)
let decoded = decodedResult.state
let normalized = normalizedState(decoded)
if normalized != decoded {
if decodedResult.usedLegacyShape || normalized != decoded {
_ = try? saveState(normalized)
}
return normalized
@@ -355,7 +367,12 @@ public struct AtlasWorkspaceRepository: Sendable {
let data: Data
do {
data = try encoder.encode(normalizedState)
data = try encoder.encode(
AtlasPersistedWorkspaceState(
savedAt: nowProvider(),
state: normalizedState
)
)
} catch {
throw AtlasWorkspaceRepositoryError.encodeFailed(error.localizedDescription)
}
@@ -397,6 +414,14 @@ public struct AtlasWorkspaceRepository: Sendable {
return normalized
}
private func decodePersistedState(from data: Data, using decoder: JSONDecoder) throws -> (state: AtlasWorkspaceState, usedLegacyShape: Bool) {
if let persisted = try? decoder.decode(AtlasPersistedWorkspaceState.self, from: data) {
return (persisted.workspaceState, false)
}
return (try decoder.decode(AtlasWorkspaceState.self, from: data), true)
}
private static var defaultStateFileURL: URL {
if let explicit = ProcessInfo.processInfo.environment["ATLAS_STATE_FILE"], !explicit.isEmpty {
return URL(fileURLWithPath: explicit)
@@ -423,6 +448,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
private let smartCleanScanProvider: (any AtlasSmartCleanScanProviding)?
private let appsInventoryProvider: (any AtlasAppInventoryProviding)?
private let helperExecutor: (any AtlasPrivilegedActionExecuting)?
private let appUninstallEvidenceAnalyzer: AtlasAppUninstallEvidenceAnalyzer
private let nowProvider: @Sendable () -> Date
private let allowProviderFailureFallback: Bool
private let allowStateOnlyCleanExecution: Bool
@@ -435,6 +461,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
smartCleanScanProvider: (any AtlasSmartCleanScanProviding)? = nil,
appsInventoryProvider: (any AtlasAppInventoryProviding)? = nil,
helperExecutor: (any AtlasPrivilegedActionExecuting)? = nil,
appUninstallEvidenceAnalyzer: AtlasAppUninstallEvidenceAnalyzer = AtlasAppUninstallEvidenceAnalyzer(),
auditStore: AtlasAuditStore = AtlasAuditStore(),
nowProvider: @escaping @Sendable () -> Date = { Date() },
allowProviderFailureFallback: Bool = ProcessInfo.processInfo.environment["ATLAS_ALLOW_PROVIDER_FAILURE_FALLBACK"] == "1",
@@ -447,6 +474,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
self.smartCleanScanProvider = smartCleanScanProvider
self.appsInventoryProvider = appsInventoryProvider
self.helperExecutor = helperExecutor
self.appUninstallEvidenceAnalyzer = appUninstallEvidenceAnalyzer
self.nowProvider = nowProvider
self.allowProviderFailureFallback = allowProviderFailureFallback
self.allowStateOnlyCleanExecution = allowStateOnlyCleanExecution
@@ -632,7 +660,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
}
let executableSelections = selectedItems.compactMap { item -> SmartCleanExecutableSelection? in
guard item.kind != .inspectPermission, let finding = findingsByID[item.id] else {
guard item.kind != .inspectPermission, item.kind != .reviewEvidence, let finding = findingsByID[item.id] else {
return nil
}
return SmartCleanExecutableSelection(
@@ -794,9 +822,13 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
if !state.snapshot.findings.contains(where: { $0.id == finding.id }) {
state.snapshot.findings.insert(finding, at: 0)
}
case let .app(app):
if !state.snapshot.apps.contains(where: { $0.id == app.id }) {
state.snapshot.apps.insert(app, at: 0)
case let .app(payload):
var restoredApp = payload.app
if payload.uninstallEvidence.reviewOnlyItemCount > 0 {
restoredApp.leftoverItems = payload.uninstallEvidence.reviewOnlyItemCount
}
if !state.snapshot.apps.contains(where: { $0.id == restoredApp.id }) {
state.snapshot.apps.insert(restoredApp, at: 0)
}
case nil:
break
@@ -874,6 +906,13 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
)
}
let uninstallEvidence = appUninstallEvidenceAnalyzer.analyze(
appName: app.name,
bundleIdentifier: app.bundleIdentifier,
bundlePath: app.bundlePath,
bundleBytes: app.bytes
)
var appRestoreMappings: [RecoveryPathMapping]?
if !app.bundlePath.isEmpty, FileManager.default.fileExists(atPath: app.bundlePath) {
guard let helperExecutor else {
@@ -901,13 +940,16 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
let taskID = UUID()
state.snapshot.apps.removeAll { $0.id == appID }
state.snapshot.recoveryItems.insert(makeRecoveryItem(for: app, deletedAt: Date(), restoreMappings: appRestoreMappings), at: 0)
state.snapshot.recoveryItems.insert(
makeRecoveryItem(for: app, uninstallEvidence: uninstallEvidence, deletedAt: Date(), restoreMappings: appRestoreMappings),
at: 0
)
let completedRun = TaskRun(
id: taskID,
kind: .uninstallApp,
status: .completed,
summary: AtlasL10n.string("infrastructure.apps.uninstall.summary", language: state.settings.language, app.name),
summary: appUninstallSummary(for: app, uninstallEvidence: uninstallEvidence),
startedAt: request.issuedAt,
finishedAt: Date()
)
@@ -1263,18 +1305,69 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
}
private func makeRecoveryItem(for app: AppFootprint, deletedAt: Date, restoreMappings: [RecoveryPathMapping]? = nil) -> RecoveryItem {
RecoveryItem(
makeRecoveryItem(
for: app,
uninstallEvidence: AtlasAppUninstallEvidence(bundlePath: app.bundlePath, bundleBytes: app.bytes, reviewOnlyGroups: []),
deletedAt: deletedAt,
restoreMappings: restoreMappings
)
}
private func makeRecoveryItem(
for app: AppFootprint,
uninstallEvidence: AtlasAppUninstallEvidence,
deletedAt: Date,
restoreMappings: [RecoveryPathMapping]? = nil
) -> RecoveryItem {
let reviewOnlyItemCount = uninstallEvidence.reviewOnlyItemCount > 0 ? uninstallEvidence.reviewOnlyItemCount : app.leftoverItems
let baseDetail = AtlasL10n.string(
reviewOnlyItemCount == 1 ? "infrastructure.recovery.app.detail.one" : "infrastructure.recovery.app.detail.other",
language: state.settings.language,
reviewOnlyItemCount
)
return RecoveryItem(
title: app.name,
detail: AtlasL10n.string(app.leftoverItems == 1 ? "infrastructure.recovery.app.detail.one" : "infrastructure.recovery.app.detail.other", language: state.settings.language, app.leftoverItems),
detail: appReviewOnlyEvidenceDetail(baseDetail: baseDetail, uninstallEvidence: uninstallEvidence),
originalPath: app.bundlePath,
bytes: app.bytes,
deletedAt: deletedAt,
expiresAt: recoveryExpiryDate(from: deletedAt),
payload: .app(app),
payload: .app(AtlasAppRecoveryPayload(app: app, uninstallEvidence: uninstallEvidence)),
restoreMappings: restoreMappings
)
}
private func appUninstallSummary(for app: AppFootprint, uninstallEvidence: AtlasAppUninstallEvidence) -> String {
let reviewOnlyGroupCount = uninstallEvidence.reviewOnlyGroupCount
guard reviewOnlyGroupCount > 0 else {
return AtlasL10n.string("infrastructure.apps.uninstall.summary", language: state.settings.language, app.name)
}
let baseSummary = AtlasL10n.string(
reviewOnlyGroupCount == 1 ? "infrastructure.apps.uninstall.summary.review.one" : "infrastructure.apps.uninstall.summary.review.other",
language: state.settings.language,
app.name,
reviewOnlyGroupCount
)
return appReviewOnlyEvidenceDetail(baseDetail: baseSummary, uninstallEvidence: uninstallEvidence)
}
private func appReviewOnlyEvidenceDetail(baseDetail: String, uninstallEvidence: AtlasAppUninstallEvidence) -> String {
let categorySummary = appReviewOnlyEvidenceCategorySummary(for: uninstallEvidence.reviewOnlyGroups)
guard !categorySummary.isEmpty else {
return baseDetail
}
return baseDetail + " " + AtlasL10n.string(
"infrastructure.apps.uninstall.reviewCategories",
language: state.settings.language,
categorySummary
)
}
private func appReviewOnlyEvidenceCategorySummary(for groups: [AtlasAppFootprintEvidenceGroup]) -> String {
groups.map { appReviewOnlyEvidenceLabel(for: $0.category) }.joined(separator: ", ")
}
private func inferredPath(for finding: Finding) -> String {
if let firstTargetPath = finding.targetPaths?.first {
return firstTargetPath
@@ -1324,34 +1417,82 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
}
private func makeAppUninstallPreview(for app: AppFootprint) -> ActionPlan {
ActionPlan(
let uninstallEvidence = appUninstallEvidenceAnalyzer.analyze(
appName: app.name,
bundleIdentifier: app.bundleIdentifier,
bundlePath: app.bundlePath,
bundleBytes: app.bytes
)
var items: [ActionItem] = [
ActionItem(
id: app.id,
title: AtlasL10n.string("infrastructure.plan.uninstall.moveBundle.title", language: state.settings.language, app.name),
detail: AtlasL10n.string("infrastructure.plan.uninstall.moveBundle.detail", language: state.settings.language, app.bundlePath),
kind: .removeApp,
recoverable: true,
targetPaths: [app.bundlePath]
),
]
items.append(contentsOf: uninstallEvidence.reviewOnlyGroups.map { group in
ActionItem(
title: appReviewOnlyEvidenceTitle(for: group),
detail: AtlasL10n.string(
"infrastructure.plan.uninstall.review.detail",
language: state.settings.language,
formattedByteCount(group.totalBytes),
group.items.count
),
kind: .reviewEvidence,
recoverable: false,
evidencePaths: group.items.map(\.path)
)
})
return ActionPlan(
title: AtlasL10n.string("infrastructure.plan.uninstall.title", language: state.settings.language, app.name),
items: [
ActionItem(
id: app.id,
title: AtlasL10n.string("infrastructure.plan.uninstall.moveBundle.title", language: state.settings.language, app.name),
detail: AtlasL10n.string("infrastructure.plan.uninstall.moveBundle.detail", language: state.settings.language, app.bundlePath),
kind: .removeApp,
recoverable: true,
targetPaths: [app.bundlePath]
),
ActionItem(
title: AtlasL10n.string(app.leftoverItems == 1 ? "infrastructure.plan.uninstall.archive.one" : "infrastructure.plan.uninstall.archive.other", language: state.settings.language, app.leftoverItems),
detail: AtlasL10n.string("infrastructure.plan.uninstall.archive.detail", language: state.settings.language),
kind: .removeCache,
recoverable: true
),
],
items: items,
estimatedBytes: app.bytes
)
}
private func appReviewOnlyEvidenceTitle(for group: AtlasAppFootprintEvidenceGroup) -> String {
AtlasL10n.string(
"infrastructure.plan.uninstall.review.title",
language: state.settings.language,
appReviewOnlyEvidenceLabel(for: group.category),
group.items.count
)
}
private func appReviewOnlyEvidenceLabel(for category: AtlasAppFootprintEvidenceCategory) -> String {
switch category {
case .supportFiles:
return AtlasL10n.string("infrastructure.plan.uninstall.review.supportFiles", language: state.settings.language)
case .caches:
return AtlasL10n.string("infrastructure.plan.uninstall.review.caches", language: state.settings.language)
case .preferences:
return AtlasL10n.string("infrastructure.plan.uninstall.review.preferences", language: state.settings.language)
case .logs:
return AtlasL10n.string("infrastructure.plan.uninstall.review.logs", language: state.settings.language)
case .launchItems:
return AtlasL10n.string("infrastructure.plan.uninstall.review.launchItems", language: state.settings.language)
}
}
private func formattedByteCount(_ bytes: Int64) -> String {
ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
}
private func actionTitle(for finding: Finding) -> String {
switch actionKind(for: finding) {
case .removeApp:
return AtlasL10n.string("infrastructure.action.reviewUninstall", language: state.settings.language, finding.title)
case .inspectPermission:
return AtlasL10n.string("infrastructure.action.inspectPrivileged", language: state.settings.language, finding.title)
case .reviewEvidence:
return AtlasL10n.string("infrastructure.action.inspectPrivileged", language: state.settings.language, finding.title)
case .archiveFile:
return AtlasL10n.string("infrastructure.action.archiveRecovery", language: state.settings.language, finding.title)
case .removeCache:

View File

@@ -18,6 +18,35 @@ final class AtlasInfrastructureTests: XCTestCase {
XCTAssertEqual(loaded.snapshot.apps.count, state.snapshot.apps.count)
}
func testRepositoryPersistsVersionedWorkspaceEnvelope() throws {
let fileURL = temporaryStateFileURL()
let repository = AtlasWorkspaceRepository(stateFileURL: fileURL)
XCTAssertNoThrow(try repository.saveState(AtlasScaffoldWorkspace.state()))
let data = try Data(contentsOf: fileURL)
let persisted = try JSONDecoder().decode(AtlasPersistedWorkspaceState.self, from: data)
XCTAssertEqual(persisted.schemaVersion, AtlasWorkspaceStateSchemaVersion.current)
XCTAssertFalse(persisted.snapshot.apps.isEmpty)
}
func testRepositoryLoadsLegacyWorkspaceStateAndRewritesEnvelope() throws {
let fileURL = temporaryStateFileURL()
let legacyState = AtlasScaffoldWorkspace.state()
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try JSONEncoder().encode(legacyState).write(to: fileURL)
let repository = AtlasWorkspaceRepository(stateFileURL: fileURL)
let loaded = repository.loadState()
XCTAssertEqual(loaded.snapshot.apps.count, legacyState.snapshot.apps.count)
let migratedData = try Data(contentsOf: fileURL)
let persisted = try JSONDecoder().decode(AtlasPersistedWorkspaceState.self, from: migratedData)
XCTAssertEqual(persisted.schemaVersion, AtlasWorkspaceStateSchemaVersion.current)
XCTAssertEqual(persisted.snapshot.apps.count, legacyState.snapshot.apps.count)
}
func testRepositorySaveStateThrowsForInvalidParentURL() {
let repository = AtlasWorkspaceRepository(
@@ -439,6 +468,91 @@ final class AtlasInfrastructureTests: XCTestCase {
XCTAssertTrue(AtlasSmartCleanExecutionSupport.isFindingExecutionSupported(finding))
}
func testGradleCacheTargetIsSupportedExecutionTarget() {
let targetURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".gradle/caches/modules-2/files-2.1/atlas-fixture.bin")
let finding = Finding(
id: UUID(),
title: "Gradle cache",
detail: targetURL.path,
bytes: 1,
risk: .safe,
category: "Developer tools",
targetPaths: [targetURL.path]
)
XCTAssertTrue(AtlasSmartCleanExecutionSupport.isSupportedExecutionTarget(targetURL))
XCTAssertTrue(AtlasSmartCleanExecutionSupport.isFindingExecutionSupported(finding))
}
func testCoreSimulatorCacheTargetIsSupportedExecutionTarget() {
let targetURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Developer/CoreSimulator/Caches/atlas-fixture/device-cache.db")
let finding = Finding(
id: UUID(),
title: "CoreSimulator cache",
detail: targetURL.path,
bytes: 1,
risk: .safe,
category: "Developer tools",
targetPaths: [targetURL.path]
)
XCTAssertTrue(AtlasSmartCleanExecutionSupport.isSupportedExecutionTarget(targetURL))
XCTAssertTrue(AtlasSmartCleanExecutionSupport.isFindingExecutionSupported(finding))
}
func testContainerCacheTargetIsSupportedExecutionTarget() {
let targetURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Containers/com.example.preview/Data/Library/Caches/cache.db")
let finding = Finding(
id: UUID(),
title: "Container cache",
detail: targetURL.path,
bytes: 1,
risk: .safe,
category: "Developer tools",
targetPaths: [targetURL.path]
)
XCTAssertTrue(AtlasSmartCleanExecutionSupport.isSupportedExecutionTarget(targetURL))
XCTAssertTrue(AtlasSmartCleanExecutionSupport.isFindingExecutionSupported(finding))
}
func testContainerLogsTargetIsSupportedExecutionTarget() {
let targetURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Containers/com.example.preview/Data/Library/Logs/runtime.log")
let finding = Finding(
id: UUID(),
title: "Container logs",
detail: targetURL.path,
bytes: 1,
risk: .safe,
category: "Developer tools",
targetPaths: [targetURL.path]
)
XCTAssertTrue(AtlasSmartCleanExecutionSupport.isSupportedExecutionTarget(targetURL))
XCTAssertTrue(AtlasSmartCleanExecutionSupport.isFindingExecutionSupported(finding))
}
func testContainerTempTargetIsSupportedExecutionTarget() {
let targetURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Containers/com.example.preview/Data/tmp/runtime.tmp")
let finding = Finding(
id: UUID(),
title: "Container temp",
detail: targetURL.path,
bytes: 1,
risk: .safe,
category: "Developer tools",
targetPaths: [targetURL.path]
)
XCTAssertTrue(AtlasSmartCleanExecutionSupport.isSupportedExecutionTarget(targetURL))
XCTAssertTrue(AtlasSmartCleanExecutionSupport.isFindingExecutionSupported(finding))
}
func testExecutePlanTrashesRealTargetsWhenAvailable() async throws {
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
let home = FileManager.default.homeDirectoryForCurrentUser
@@ -610,6 +724,72 @@ final class AtlasInfrastructureTests: XCTestCase {
XCTAssertEqual(secondScan.snapshot.reclaimableSpaceBytes, 0)
}
func testScanExecuteRescanRemovesExecutedGradleCacheTargetFromRealResults() async throws {
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
let home = FileManager.default.homeDirectoryForCurrentUser
let targetDirectory = home.appendingPathComponent(".gradle/caches/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: targetDirectory, withIntermediateDirectories: true)
let targetFile = targetDirectory.appendingPathComponent("modules.bin")
try Data("gradle-cache".utf8).write(to: targetFile)
let provider = FileBackedSmartCleanProvider(targetFileURL: targetFile, title: "Gradle cache")
let worker = AtlasScaffoldWorkerService(
repository: repository,
smartCleanScanProvider: provider,
allowProviderFailureFallback: false,
allowStateOnlyCleanExecution: false
)
let firstScan = try await worker.submit(AtlasRequestEnvelope(command: .startScan(taskID: UUID())))
XCTAssertEqual(firstScan.snapshot.findings.count, 1)
let planID = try XCTUnwrap(firstScan.previewPlan?.id)
let execute = try await worker.submit(AtlasRequestEnvelope(command: .executePlan(planID: planID)))
if case let .accepted(task) = execute.response.response {
XCTAssertEqual(task.kind, .executePlan)
} else {
XCTFail("Expected accepted execute-plan response")
}
XCTAssertFalse(FileManager.default.fileExists(atPath: targetFile.path))
let secondScan = try await worker.submit(AtlasRequestEnvelope(command: .startScan(taskID: UUID())))
XCTAssertEqual(secondScan.snapshot.findings.count, 0)
XCTAssertEqual(secondScan.snapshot.reclaimableSpaceBytes, 0)
}
func testScanExecuteRescanRemovesExecutedContainerCacheTargetFromRealResults() async throws {
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
let home = FileManager.default.homeDirectoryForCurrentUser
let targetDirectory = home.appendingPathComponent("Library/Containers/com.example.atlas-fixture/Data/Library/Caches/" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: targetDirectory, withIntermediateDirectories: true)
let targetFile = targetDirectory.appendingPathComponent("cache.db")
try Data("container-cache".utf8).write(to: targetFile)
let provider = FileBackedSmartCleanProvider(targetFileURL: targetFile, title: "com.example.atlas-fixture container cache")
let worker = AtlasScaffoldWorkerService(
repository: repository,
smartCleanScanProvider: provider,
allowProviderFailureFallback: false,
allowStateOnlyCleanExecution: false
)
let firstScan = try await worker.submit(AtlasRequestEnvelope(command: .startScan(taskID: UUID())))
XCTAssertEqual(firstScan.snapshot.findings.count, 1)
let planID = try XCTUnwrap(firstScan.previewPlan?.id)
let execute = try await worker.submit(AtlasRequestEnvelope(command: .executePlan(planID: planID)))
if case let .accepted(task) = execute.response.response {
XCTAssertEqual(task.kind, .executePlan)
} else {
XCTFail("Expected accepted execute-plan response")
}
XCTAssertFalse(FileManager.default.fileExists(atPath: targetFile.path))
let secondScan = try await worker.submit(AtlasRequestEnvelope(command: .startScan(taskID: UUID())))
XCTAssertEqual(secondScan.snapshot.findings.count, 0)
XCTAssertEqual(secondScan.snapshot.reclaimableSpaceBytes, 0)
}
func testExecutePlanDoesNotCreateRecoveryEntryWhenTargetIsAlreadyGone() async throws {
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
let home = FileManager.default.homeDirectoryForCurrentUser
@@ -1057,7 +1237,16 @@ final class AtlasInfrastructureTests: XCTestCase {
bytes: helperApp.bytes,
deletedAt: Date(),
expiresAt: Date().addingTimeInterval(3600),
payload: .app(helperApp),
payload: .app(
AtlasAppRecoveryPayload(
app: helperApp,
uninstallEvidence: AtlasAppUninstallEvidence(
bundlePath: helperApp.bundlePath,
bundleBytes: helperApp.bytes,
reviewOnlyGroups: []
)
)
),
restoreMappings: [RecoveryPathMapping(originalPath: helperApp.bundlePath, trashedPath: appTrashedPath)]
)
let state = AtlasWorkspaceState(
@@ -1119,6 +1308,166 @@ final class AtlasInfrastructureTests: XCTestCase {
XCTAssertFalse(result.snapshot.apps.contains(where: { $0.id == app.id }))
XCTAssertTrue(result.snapshot.recoveryItems.contains(where: { $0.title == app.name }))
XCTAssertEqual(result.snapshot.taskRuns.first?.kind, .uninstallApp)
XCTAssertEqual(
result.snapshot.taskRuns.first?.summary,
AtlasL10n.string("infrastructure.apps.uninstall.summary", language: initialState.settings.language, app.name)
)
}
func testExecuteAppUninstallSummaryMentionsReviewOnlyEvidenceGroupsWhenPresent() async throws {
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
let fileManager = FileManager.default
let sandboxRoot = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let homeRoot = sandboxRoot.appendingPathComponent("Home", isDirectory: true)
let cacheURL = homeRoot.appendingPathComponent("Library/Caches/com.example.execute", isDirectory: true)
let logsURL = homeRoot.appendingPathComponent("Library/Logs/Execute Preview", isDirectory: true)
try fileManager.createDirectory(at: cacheURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: logsURL, withIntermediateDirectories: true)
try Data(repeating: 0x1, count: 64).write(to: cacheURL.appendingPathComponent("cache.bin"))
try Data(repeating: 0x2, count: 64).write(to: logsURL.appendingPathComponent("run.log"))
addTeardownBlock {
try? FileManager.default.removeItem(at: sandboxRoot)
}
let appRoot = fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Applications/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true)
let appBundleURL = appRoot.appendingPathComponent("Execute Preview.app", isDirectory: true)
try fileManager.createDirectory(at: appBundleURL, withIntermediateDirectories: true)
let executableURL = appBundleURL.appendingPathComponent("Contents/MacOS/ExecutePreview")
try fileManager.createDirectory(at: executableURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("#!/bin/sh\nexit 0\n".utf8).write(to: executableURL)
addTeardownBlock {
try? FileManager.default.removeItem(at: appRoot)
}
let app = AppFootprint(
id: UUID(),
name: "Execute Preview",
bundleIdentifier: "com.example.execute",
bundlePath: appBundleURL.path,
bytes: 17,
leftoverItems: 2
)
var settings = AtlasScaffoldWorkspace.state().settings
settings.language = .en
settings.acknowledgementText = AtlasL10n.acknowledgement(language: .en)
settings.thirdPartyNoticesText = AtlasL10n.thirdPartyNotices(language: .en)
let state = AtlasWorkspaceState(
snapshot: AtlasWorkspaceSnapshot(
reclaimableSpaceBytes: app.bytes,
findings: [],
apps: [app],
taskRuns: [],
recoveryItems: [],
permissions: [],
healthSnapshot: nil
),
currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0),
settings: settings
)
_ = try repository.saveState(state)
let worker = AtlasScaffoldWorkerService(
repository: repository,
helperExecutor: StubPrivilegedHelperExecutor(),
appUninstallEvidenceAnalyzer: AtlasAppUninstallEvidenceAnalyzer(homeDirectoryURL: homeRoot),
allowStateOnlyCleanExecution: false
)
let result = try await worker.submit(
AtlasRequestEnvelope(command: .executeAppUninstall(appID: app.id))
)
XCTAssertEqual(
result.snapshot.taskRuns.first?.summary,
AtlasL10n.string("infrastructure.apps.uninstall.summary.review.other", language: .en, app.name, 2)
+ " "
+ AtlasL10n.string("infrastructure.apps.uninstall.reviewCategories", language: .en, "caches, logs")
)
XCTAssertEqual(
result.snapshot.recoveryItems.first?.detail,
AtlasL10n.string("infrastructure.recovery.app.detail.other", language: .en, 2)
+ " "
+ AtlasL10n.string("infrastructure.apps.uninstall.reviewCategories", language: .en, "caches, logs")
)
}
func testPreviewAppUninstallBuildsReviewOnlyEvidenceItems() async throws {
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
let fileManager = FileManager.default
let sandboxRoot = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let homeRoot = sandboxRoot.appendingPathComponent("Home", isDirectory: true)
let appSupportURL = homeRoot.appendingPathComponent("Library/Application Support/Atlas Preview", isDirectory: true)
let cacheURL = homeRoot.appendingPathComponent("Library/Caches/com.example.preview", isDirectory: true)
let launchAgentURL = homeRoot.appendingPathComponent("Library/LaunchAgents/com.example.preview.plist")
try fileManager.createDirectory(at: appSupportURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: cacheURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: launchAgentURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data(repeating: 0x1, count: 256).write(to: appSupportURL.appendingPathComponent("state.db"))
try Data(repeating: 0x2, count: 128).write(to: cacheURL.appendingPathComponent("cache.bin"))
try Data("<?xml version=\"1.0\"?><plist></plist>".utf8).write(to: launchAgentURL)
addTeardownBlock {
try? FileManager.default.removeItem(at: sandboxRoot)
}
let app = AppFootprint(
id: UUID(),
name: "Atlas Preview",
bundleIdentifier: "com.example.preview",
bundlePath: "/Applications/Atlas Preview.app",
bytes: 2_048,
leftoverItems: 3
)
var settings = AtlasScaffoldWorkspace.state().settings
settings.language = .en
settings.acknowledgementText = AtlasL10n.acknowledgement(language: .en)
settings.thirdPartyNoticesText = AtlasL10n.thirdPartyNotices(language: .en)
let state = AtlasWorkspaceState(
snapshot: AtlasWorkspaceSnapshot(
reclaimableSpaceBytes: app.bytes,
findings: [],
apps: [app],
taskRuns: [],
recoveryItems: [],
permissions: [],
healthSnapshot: nil
),
currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0),
settings: settings
)
_ = try repository.saveState(state)
let worker = AtlasScaffoldWorkerService(
repository: repository,
appUninstallEvidenceAnalyzer: AtlasAppUninstallEvidenceAnalyzer(homeDirectoryURL: homeRoot),
allowStateOnlyCleanExecution: true
)
let result = try await worker.submit(
AtlasRequestEnvelope(command: .previewAppUninstall(appID: app.id))
)
guard case let .preview(plan) = result.response.response else {
return XCTFail("Expected preview response")
}
XCTAssertEqual(plan.estimatedBytes, app.bytes)
XCTAssertEqual(plan.items.first?.kind, .removeApp)
XCTAssertEqual(plan.items.first?.targetPaths, [app.bundlePath])
XCTAssertEqual(plan.items.count, 4)
let supportFilesItem = try XCTUnwrap(plan.items.first(where: { $0.title == "Review support files (1)" && !$0.recoverable }))
let cachesItem = try XCTUnwrap(plan.items.first(where: { $0.title == "Review caches (1)" && !$0.recoverable }))
let launchItemsItem = try XCTUnwrap(plan.items.first(where: { $0.title == "Review launch items (1)" && !$0.recoverable }))
XCTAssertEqual(supportFilesItem.evidencePaths, [appSupportURL.path])
XCTAssertEqual(cachesItem.evidencePaths, [cacheURL.path])
XCTAssertEqual(launchItemsItem.evidencePaths, [launchAgentURL.path])
}
func testExecuteAppUninstallRestorePhysicallyRestoresAppBundle() async throws {
@@ -1199,6 +1548,78 @@ final class AtlasInfrastructureTests: XCTestCase {
)
}
func testRestoreAppUsesPersistedUninstallEvidenceCountWhenAvailable() async throws {
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
let app = AppFootprint(
id: UUID(),
name: "Atlas Restore Evidence",
bundleIdentifier: "com.example.restore-evidence",
bundlePath: "/Applications/Atlas Restore Evidence.app",
bytes: 42,
leftoverItems: 1
)
let recoveryItem = RecoveryItem(
id: UUID(),
title: app.name,
detail: app.bundlePath,
originalPath: app.bundlePath,
bytes: app.bytes,
deletedAt: Date(),
expiresAt: Date().addingTimeInterval(3600),
payload: .app(
AtlasAppRecoveryPayload(
app: app,
uninstallEvidence: AtlasAppUninstallEvidence(
bundlePath: app.bundlePath,
bundleBytes: app.bytes,
reviewOnlyGroups: [
AtlasAppFootprintEvidenceGroup(
category: .caches,
items: [
AtlasAppFootprintEvidenceItem(path: "/Users/test/Library/Caches/com.example.restore-evidence", bytes: 12),
AtlasAppFootprintEvidenceItem(path: "/Users/test/Library/Caches/com.example.restore-evidence-2", bytes: 12),
]
),
AtlasAppFootprintEvidenceGroup(
category: .logs,
items: [
AtlasAppFootprintEvidenceItem(path: "/Users/test/Library/Logs/Atlas Restore Evidence", bytes: 8)
]
)
]
)
)
),
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,
allowStateOnlyCleanExecution: true
)
let restore = try await worker.submit(
AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [recoveryItem.id]))
)
let restoredApp = try XCTUnwrap(restore.snapshot.apps.first(where: { $0.id == app.id }))
XCTAssertEqual(restoredApp.leftoverItems, 3)
}
private func temporaryStateFileURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)

View File

@@ -2,7 +2,23 @@ import AtlasDomain
import Foundation
public enum AtlasProtocolVersion {
public static let current = "0.3.1"
public static let current = "0.3.2"
}
public struct AtlasCapabilityStatus: Codable, Hashable, Sendable {
public var workerConnected: Bool
public var helperInstalled: Bool
public var protocolVersion: String
public init(
workerConnected: Bool = false,
helperInstalled: Bool = false,
protocolVersion: String = AtlasProtocolVersion.current
) {
self.workerConnected = workerConnected
self.helperInstalled = helperInstalled
self.protocolVersion = protocolVersion
}
}
public enum AtlasCommand: Codable, Hashable, Sendable {

View File

@@ -56,4 +56,31 @@ final class AtlasProtocolTests: XCTestCase {
XCTAssertEqual(decoded.response, envelope.response)
}
func testPreviewResponseRoundTripsReviewOnlyEvidencePaths() throws {
let plan = ActionPlan(
title: "Uninstall Example",
items: [
ActionItem(
title: "Review support files (2)",
detail: "Found 12 KB across 2 item(s).",
kind: .inspectPermission,
recoverable: false,
evidencePaths: [
"/Users/test/Library/Application Support/Example",
"/Users/test/Library/Saved Application State/com.example.savedState"
]
)
],
estimatedBytes: 1_024
)
let envelope = AtlasResponseEnvelope(
requestID: UUID(),
response: .preview(plan)
)
let data = try JSONEncoder().encode(envelope)
let decoded = try JSONDecoder().decode(AtlasResponseEnvelope.self, from: data)
XCTAssertEqual(decoded.response, envelope.response)
}
}

View File

@@ -50,7 +50,7 @@ let package = Package(
),
.target(
name: "AtlasCoreAdapters",
dependencies: ["AtlasApplication", "AtlasDomain", "AtlasInfrastructure", "AtlasProtocol"],
dependencies: ["AtlasApplication", "AtlasDomain", "AtlasProtocol"],
path: "AtlasCoreAdapters/Sources/AtlasCoreAdapters",
resources: [.copy("Resources/MoleRuntime")]
),

View File

@@ -9,7 +9,7 @@
</p>
<p align="center">
<img src="Docs/Media/README/atlas-overview.png" alt="Atlas for Mac overview screen" width="1000" />
<img src="Docs/Media/README/atlas-smart-clean.png" alt="Atlas for Mac Smart Clean screen" width="1000" />
</p>
Atlas for Mac is a native macOS application for people who need to understand why their Mac is slow, full, or disorganized, then take safe and reversible action. The current MVP unifies system overview, Smart Clean, app uninstall workflows, permissions guidance, history, and recovery into a single desktop workspace.
@@ -32,6 +32,12 @@ Download the latest release from the [Releases](https://github.com/CSZHK/CleanMy
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.
If you install a prerelease and macOS blocks the app, you will see a warning similar to this in `System Settings -> Privacy & Security`:
<p align="center">
<img src="Docs/Media/README/atlas-prerelease-warning.png" alt="macOS Security warning for Atlas for Mac prerelease build with Open Anyway action" width="900" />
</p>
### Requirements
- macOS 14.0 (Sonoma) or later

Some files were not shown because too many files have changed in this diff Show More