feat: add Atlas native app UX overhaul

This commit is contained in:
zhukang
2026-03-10 17:09:35 +08:00
parent 0fabc6feec
commit 994e63f0b3
199 changed files with 38705 additions and 0 deletions

23
.github/workflows/atlas-acceptance.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Atlas Acceptance
on:
workflow_dispatch:
push:
paths:
- 'Apps/**'
- 'Packages/**'
- 'Helpers/**'
- 'XPC/**'
- 'Testing/**'
- 'scripts/atlas/**'
- 'Docs/Execution/MVP-Acceptance-Matrix.md'
- '.github/workflows/atlas-acceptance.yml'
jobs:
acceptance:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Run Atlas acceptance pipeline
run: ./scripts/atlas/full-acceptance.sh

31
.github/workflows/atlas-native.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Atlas Native
on:
workflow_dispatch:
push:
paths:
- 'Apps/**'
- 'Packages/**'
- 'XPC/**'
- 'Helpers/**'
- 'project.yml'
- 'scripts/atlas/**'
- '.github/workflows/atlas-native.yml'
jobs:
build-native:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Build and package Atlas native app
run: ./scripts/atlas/package-native.sh
- name: Verify DMG can install to the user Applications folder
run: ./scripts/atlas/verify-dmg-install.sh
- name: Upload native app and installer artifacts
uses: actions/upload-artifact@v4
with:
name: atlas-native-app
path: dist/native/*

15
Apps/AtlasApp/README.md Normal file
View File

@@ -0,0 +1,15 @@
# AtlasApp
## Responsibility
- Main macOS application target
- `NavigationSplitView` shell for the frozen MVP modules
- Shared app-state wiring for search, task center, and route selection
- Dependency handoff into feature packages and worker-backed Smart Clean actions
## Current Scaffold
- `AtlasApp.swift``@main` entry for the macOS app shell
- `AppShellView.swift` — sidebar navigation, toolbar, and task-center popover
- `AtlasAppModel.swift` — shared scaffold state backed by the application-layer workspace controller
- `TaskCenterView.swift` — global task surface placeholder wired to `History`

View File

@@ -0,0 +1,279 @@
import AtlasDesignSystem
import AtlasDomain
import AtlasFeaturesApps
import AtlasFeaturesHistory
import AtlasFeaturesOverview
import AtlasFeaturesPermissions
import AtlasFeaturesSettings
import AtlasFeaturesSmartClean
import SwiftUI
struct AppShellView: View {
@ObservedObject var model: AtlasAppModel
var body: some View {
NavigationSplitView {
List(AtlasRoute.allCases, selection: $model.selection) { route in
SidebarRouteRow(route: route)
.tag(route)
}
.navigationTitle(AtlasL10n.string("app.name"))
.navigationSplitViewColumnWidth(min: AtlasLayout.sidebarMinWidth, ideal: AtlasLayout.sidebarIdealWidth)
.listStyle(.sidebar)
.accessibilityIdentifier("atlas.sidebar")
} detail: {
let route = model.selection ?? .overview
detailView(for: route)
.id(route)
.transition(.opacity)
.searchable(
text: Binding(
get: { model.searchText(for: route) },
set: { model.setSearchText($0, for: route) }
),
prompt: AtlasL10n.string("app.search.prompt.route", route.searchPromptLabel)
)
.accessibilityHint(AtlasL10n.string("app.search.hint.route", route.searchPromptLabel))
.toolbar {
ToolbarItemGroup {
Button {
model.openTaskCenter()
} label: {
Label {
Text(AtlasL10n.string("toolbar.taskcenter"))
} icon: {
ZStack(alignment: .topTrailing) {
Image(systemName: AtlasIcon.taskCenter)
.symbolRenderingMode(.hierarchical)
if activeTaskCount > 0 {
Text(activeTaskCount > 99 ? "99+" : "\(activeTaskCount)")
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, activeTaskCount > 9 ? AtlasSpacing.xxs : AtlasSpacing.xs)
.padding(.vertical, 2)
.background(Capsule(style: .continuous).fill(AtlasColor.accent))
.offset(x: 10, y: -8)
}
}
}
}
.help(AtlasL10n.string("toolbar.taskcenter.help"))
.accessibilityIdentifier("toolbar.taskCenter")
.accessibilityLabel(AtlasL10n.string("toolbar.taskcenter.accessibilityLabel"))
.accessibilityHint(AtlasL10n.string("toolbar.taskcenter.accessibilityHint"))
Button {
model.navigate(to: .permissions)
Task {
await model.inspectPermissions()
}
} label: {
Label {
Text(AtlasL10n.string("toolbar.permissions"))
} icon: {
Image(systemName: AtlasIcon.permissions)
.symbolRenderingMode(.hierarchical)
}
}
.help(AtlasL10n.string("toolbar.permissions.help"))
.accessibilityIdentifier("toolbar.permissions")
.accessibilityLabel(AtlasL10n.string("toolbar.permissions.accessibilityLabel"))
.accessibilityHint(AtlasL10n.string("toolbar.permissions.accessibilityHint"))
Button {
model.navigate(to: .settings)
} label: {
Label {
Text(AtlasL10n.string("toolbar.settings"))
} icon: {
Image(systemName: AtlasIcon.settings)
.symbolRenderingMode(.hierarchical)
}
}
.help(AtlasL10n.string("toolbar.settings.help"))
.accessibilityIdentifier("toolbar.settings")
.accessibilityLabel(AtlasL10n.string("toolbar.settings.accessibilityLabel"))
.accessibilityHint(AtlasL10n.string("toolbar.settings.accessibilityHint"))
}
}
.animation(AtlasMotion.slow, value: model.selection)
}
.navigationSplitViewStyle(.balanced)
.task {
await model.refreshHealthSnapshotIfNeeded()
await model.refreshPermissionsIfNeeded()
}
.onChange(of: model.selection, initial: false) { _, selection in
guard selection == .permissions else {
return
}
Task {
await model.inspectPermissions()
}
}
.popover(isPresented: $model.isTaskCenterPresented) {
TaskCenterView(
taskRuns: model.taskCenterTaskRuns,
summary: model.taskCenterSummary
) {
model.closeTaskCenter()
model.navigate(to: .history)
}
.onExitCommand {
model.closeTaskCenter()
}
}
}
@ViewBuilder
private func detailView(for route: AtlasRoute) -> some View {
switch route {
case .overview:
OverviewFeatureView(
snapshot: model.filteredSnapshot,
isRefreshingHealthSnapshot: model.isHealthSnapshotRefreshing
)
case .smartClean:
SmartCleanFeatureView(
findings: model.filteredFindings,
plan: model.currentPlan,
scanSummary: model.latestScanSummary,
scanProgress: model.latestScanProgress,
isScanning: model.isScanRunning,
isExecutingPlan: model.isPlanRunning,
isCurrentPlanFresh: model.isCurrentSmartCleanPlanFresh,
canExecutePlan: model.canExecuteCurrentSmartCleanPlan,
planIssue: model.smartCleanPlanIssue,
onStartScan: {
Task { await model.runSmartCleanScan() }
},
onRefreshPreview: {
Task { await model.refreshPlanPreview() }
},
onExecutePlan: {
Task { await model.executeCurrentPlan() }
}
)
case .apps:
AppsFeatureView(
apps: model.filteredApps,
previewPlan: model.currentAppPreview,
currentPreviewedAppID: model.currentPreviewedAppID,
summary: model.latestAppsSummary,
isRunning: model.isAppActionRunning,
activePreviewAppID: model.activePreviewAppID,
activeUninstallAppID: model.activeUninstallAppID,
onRefreshApps: {
Task { await model.refreshApps() }
},
onPreviewAppUninstall: { appID in
Task { await model.previewAppUninstall(appID: appID) }
},
onExecuteAppUninstall: { appID in
Task { await model.executeAppUninstall(appID: appID) }
}
)
case .history:
HistoryFeatureView(
taskRuns: model.filteredTaskRuns,
recoveryItems: model.filteredRecoveryItems,
restoringItemID: model.restoringRecoveryItemID,
onRestoreItem: { itemID in
Task { await model.restoreRecoveryItem(itemID) }
}
)
case .permissions:
PermissionsFeatureView(
permissionStates: model.filteredPermissionStates,
summary: model.latestPermissionsSummary,
isRefreshing: model.isPermissionsRefreshing,
onRefresh: {
Task { await model.inspectPermissions() }
},
onRequestNotificationPermission: {
Task { await model.requestNotificationPermission() }
}
)
case .settings:
SettingsFeatureView(
settings: model.settings,
onSetLanguage: { language in
Task { await model.setLanguage(language) }
},
onSetRecoveryRetention: { days in
Task { await model.setRecoveryRetentionDays(days) }
},
onToggleNotifications: { isEnabled in
Task { await model.setNotificationsEnabled(isEnabled) }
}
)
}
}
private var activeTaskCount: Int {
model.snapshot.taskRuns.filter { taskRun in
taskRun.status == .queued || taskRun.status == .running
}.count
}
}
private struct SidebarRouteRow: View {
let route: AtlasRoute
var body: some View {
Label {
VStack(alignment: .leading, spacing: AtlasSpacing.xxs) {
Text(route.title)
.font(AtlasTypography.rowTitle)
Text(route.subtitle)
.font(AtlasTypography.captionSmall)
.foregroundStyle(.secondary)
.lineLimit(2)
}
} icon: {
ZStack {
RoundedRectangle(cornerRadius: AtlasRadius.sm, style: .continuous)
.fill(AtlasColor.brand.opacity(0.1))
.frame(width: AtlasLayout.sidebarIconSize, height: AtlasLayout.sidebarIconSize)
Image(systemName: route.systemImage)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AtlasColor.brand)
.accessibilityHidden(true)
}
}
.padding(.vertical, AtlasSpacing.sm)
.contentShape(Rectangle())
.listRowSeparator(.hidden)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("route.\(route.id)")
.accessibilityLabel("\(route.title). \(route.subtitle)")
.accessibilityHint(AtlasL10n.string("sidebar.route.hint", route.shortcutNumber))
}
}
private extension AtlasRoute {
var searchPromptLabel: String {
title
}
var shortcutNumber: String {
switch self {
case .overview:
return "1"
case .smartClean:
return "2"
case .apps:
return "3"
case .history:
return "4"
case .permissions:
return "5"
case .settings:
return "6"
}
}
}

View File

@@ -0,0 +1,68 @@
{
"images": [
{
"filename": "icon_16x16.png",
"idiom": "mac",
"scale": "1x",
"size": "16x16"
},
{
"filename": "icon_32x32.png",
"idiom": "mac",
"scale": "2x",
"size": "16x16"
},
{
"filename": "icon_32x32.png",
"idiom": "mac",
"scale": "1x",
"size": "32x32"
},
{
"filename": "icon_64x64.png",
"idiom": "mac",
"scale": "2x",
"size": "32x32"
},
{
"filename": "icon_128x128.png",
"idiom": "mac",
"scale": "1x",
"size": "128x128"
},
{
"filename": "icon_256x256.png",
"idiom": "mac",
"scale": "2x",
"size": "128x128"
},
{
"filename": "icon_256x256.png",
"idiom": "mac",
"scale": "1x",
"size": "256x256"
},
{
"filename": "icon_512x512.png",
"idiom": "mac",
"scale": "2x",
"size": "256x256"
},
{
"filename": "icon_512x512.png",
"idiom": "mac",
"scale": "1x",
"size": "512x512"
},
{
"filename": "icon_1024x1024.png",
"idiom": "mac",
"scale": "2x",
"size": "512x512"
}
],
"info": {
"author": "atlas-icon-generator",
"version": 1
}
}

View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<defs>
<!-- Brand gradient: darker premium teal to deep emerald -->
<linearGradient id="bgGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#031B1A"/>
<stop offset="50%" stop-color="#0A5C56"/>
<stop offset="100%" stop-color="#073936"/>
</linearGradient>
<!-- Inner glow from top-left -->
<radialGradient id="innerGlow" cx="0.3" cy="0.25" r="0.7">
<stop offset="0%" stop-color="#D1FAE5" stop-opacity="0.16"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<!-- Globe gradient -->
<radialGradient id="globeGrad" cx="0.4" cy="0.35" r="0.55">
<stop offset="0%" stop-color="#A7F3D0" stop-opacity="0.38"/>
<stop offset="60%" stop-color="#5EEAD4" stop-opacity="0.22"/>
<stop offset="100%" stop-color="#0A5C56" stop-opacity="0.10"/>
</radialGradient>
<!-- Mint accent gradient -->
<linearGradient id="mintGrad" x1="0" y1="0" x2="1" y2="0.5">
<stop offset="0%" stop-color="#D1FAE5" stop-opacity="0.98"/>
<stop offset="100%" stop-color="#6EE7B7" stop-opacity="0.82"/>
</linearGradient>
<!-- Clip to rounded square -->
<clipPath id="roundClip">
<rect x="0" y="0" width="1024" height="1024" rx="225" ry="225"/>
</clipPath>
</defs>
<g clip-path="url(#roundClip)">
<!-- Background -->
<rect width="1024" height="1024" fill="url(#bgGrad)"/>
<rect width="1024" height="1024" fill="url(#innerGlow)"/>
<!-- Globe circle -->
<circle cx="512" cy="512" r="327"
fill="url(#globeGrad)" stroke="#CCFBF1" stroke-width="4" stroke-opacity="0.24"/>
<!-- Meridian lines (longitude) -->
<g fill="none" stroke="#CCFBF1" stroke-width="2" stroke-opacity="0.24">
<!-- Vertical center line -->
<line x1="512" y1="184" x2="512" y2="839"/>
<!-- Elliptical meridians -->
<ellipse cx="512" cy="512" rx="122" ry="327"/>
<ellipse cx="512" cy="512" rx="245" ry="327"/>
</g>
<!-- Latitude lines (horizontal) -->
<g fill="none" stroke="#CCFBF1" stroke-width="2" stroke-opacity="0.18">
<line x1="184" y1="512" x2="839" y2="512"/>
<ellipse cx="512" cy="512" rx="327" ry="122"/>
<ellipse cx="512" cy="512" rx="327" ry="225"/>
</g>
<!-- Mint accent arc — the "mapping" highlight -->
<path d="M 286 593
Q 512 358, 737 430"
fill="none" stroke="url(#mintGrad)" stroke-width="18"
stroke-linecap="round" stroke-opacity="0.92"/>
<!-- Small mint dot at arc start -->
<circle cx="286" cy="593" r="9"
fill="#A7F3D0" opacity="0.95"/>
<!-- Small mint dot at arc end -->
<circle cx="737" cy="430" r="9"
fill="#A7F3D0" opacity="0.95"/>
<!-- Subtle sparkle at top-right of globe -->
<g transform="translate(634, 286)" opacity="0.5">
<line x1="0" y1="-20" x2="0" y2="20"
stroke="white" stroke-width="2" stroke-linecap="round"/>
<line x1="-20" y1="0" x2="20" y2="0"
stroke="white" stroke-width="2" stroke-linecap="round"/>
</g>
<!-- Bottom subtle reflection -->
<rect x="0" y="768" width="1024" height="256"
fill="url(#bgGrad)" opacity="0.3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,19 @@
import AtlasDomain
import SwiftUI
@main
struct AtlasApp: App {
@StateObject private var model = AtlasAppModel()
var body: some Scene {
WindowGroup(AtlasL10n.string("app.name")) {
AppShellView(model: model)
.environment(\.locale, model.appLanguage.locale)
.frame(minWidth: 1120, minHeight: 720)
}
.commands {
AtlasAppCommands(model: model)
}
.windowStyle(.hiddenTitleBar)
}
}

View File

@@ -0,0 +1,87 @@
import AtlasDomain
import SwiftUI
struct AtlasAppCommands: Commands {
@ObservedObject var model: AtlasAppModel
var body: some Commands {
CommandMenu(AtlasL10n.string("commands.navigate.menu")) {
ForEach(AtlasRoute.allCases) { route in
Button(route.title) {
model.navigate(to: route)
}
.keyboardShortcut(route.shortcutKey, modifiers: .command)
}
Divider()
Button(model.isTaskCenterPresented ? AtlasL10n.string("commands.taskcenter.close") : AtlasL10n.string("commands.taskcenter.open")) {
model.toggleTaskCenter()
}
.keyboardShortcut("7", modifiers: .command)
}
CommandMenu(AtlasL10n.string("commands.actions.menu")) {
Button(AtlasL10n.string("commands.actions.refreshCurrent")) {
Task {
await model.refreshCurrentRoute()
}
}
.keyboardShortcut("r", modifiers: .command)
Button(AtlasL10n.string("commands.actions.runScan")) {
Task {
await model.runSmartCleanScan()
}
}
.keyboardShortcut("r", modifiers: [.command, .shift])
.disabled(model.isWorkflowBusy)
Button(AtlasL10n.string("commands.actions.refreshApps")) {
Task {
model.navigate(to: .apps)
await model.refreshApps()
}
}
.keyboardShortcut("a", modifiers: [.command, .option])
.disabled(model.isWorkflowBusy)
Button(AtlasL10n.string("commands.actions.refreshPermissions")) {
Task {
model.navigate(to: .permissions)
await model.inspectPermissions()
}
}
.keyboardShortcut("p", modifiers: [.command, .option])
.disabled(model.isWorkflowBusy)
Button(AtlasL10n.string("commands.actions.refreshHealth")) {
Task {
model.navigate(to: .overview)
await model.refreshHealthSnapshot()
}
}
.keyboardShortcut("h", modifiers: [.command, .option])
.disabled(model.isWorkflowBusy)
}
}
}
private extension AtlasRoute {
var shortcutKey: KeyEquivalent {
switch self {
case .overview:
return "1"
case .smartClean:
return "2"
case .apps:
return "3"
case .history:
return "4"
case .permissions:
return "5"
case .settings:
return "6"
}
}
}

View File

@@ -0,0 +1,576 @@
import AtlasApplication
import AtlasCoreAdapters
import AtlasDomain
import AtlasInfrastructure
import Combine
import Foundation
import SwiftUI
import UserNotifications
@MainActor
final class AtlasAppModel: ObservableObject {
@Published var selection: AtlasRoute? = .overview
@Published private var searchTextByRoute: [AtlasRoute: String] = [:]
@Published var isTaskCenterPresented = false
@Published private(set) var snapshot: AtlasWorkspaceSnapshot
@Published private(set) var currentPlan: ActionPlan
@Published private(set) var currentAppPreview: ActionPlan?
@Published private(set) var currentPreviewedAppID: UUID?
@Published private(set) var settings: AtlasSettings
@Published private(set) var isHealthSnapshotRefreshing = false
@Published private(set) var isScanRunning = false
@Published private(set) var isPlanRunning = false
@Published private(set) var isPermissionsRefreshing = false
@Published private(set) var isAppActionRunning = false
@Published private(set) var activePreviewAppID: UUID?
@Published private(set) var activeUninstallAppID: UUID?
@Published private(set) var restoringRecoveryItemID: UUID?
@Published private(set) var latestScanSummary: String
@Published private(set) var latestAppsSummary: String
@Published private(set) var latestPermissionsSummary: String
@Published private(set) var latestScanProgress: Double = 0
@Published private(set) var isCurrentSmartCleanPlanFresh: Bool
@Published private(set) var smartCleanPlanIssue: String?
private let workspaceController: AtlasWorkspaceController
private let notificationPermissionRequester: @Sendable () async -> Bool
private var didRequestInitialHealthSnapshot = false
private var didRequestInitialPermissionSnapshot = false
init(
repository: AtlasWorkspaceRepository = AtlasWorkspaceRepository(),
workerService: (any AtlasWorkerServing)? = nil,
notificationPermissionRequester: (@Sendable () async -> Bool)? = nil
) {
let state = repository.loadState()
self.snapshot = state.snapshot
self.currentPlan = state.currentPlan
self.settings = state.settings
AtlasL10n.setCurrentLanguage(state.settings.language)
self.latestScanSummary = AtlasL10n.string("model.scan.ready")
self.latestAppsSummary = AtlasL10n.string("model.apps.ready")
self.latestPermissionsSummary = AtlasL10n.string("model.permissions.ready")
self.isCurrentSmartCleanPlanFresh = false
self.smartCleanPlanIssue = nil
let directWorker = AtlasScaffoldWorkerService(
repository: repository,
healthSnapshotProvider: MoleHealthAdapter(),
smartCleanScanProvider: MoleSmartCleanAdapter(),
appsInventoryProvider: MacAppsInventoryAdapter(),
helperExecutor: AtlasPrivilegedHelperClient()
)
let prefersXPCWorker = ProcessInfo.processInfo.environment["ATLAS_PREFER_XPC_WORKER"] == "1"
let defaultWorker: any AtlasWorkerServing = prefersXPCWorker
? AtlasPreferredWorkerService(
fallbackWorker: directWorker,
allowFallback: true
)
: directWorker
self.workspaceController = AtlasWorkspaceController(
worker: workerService ?? defaultWorker
)
self.notificationPermissionRequester = notificationPermissionRequester ?? {
await withCheckedContinuation { continuation in
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
continuation.resume(returning: granted)
}
}
}
}
var appLanguage: AtlasLanguage {
settings.language
}
func searchText(for route: AtlasRoute) -> String {
searchTextByRoute[route, default: ""]
}
func setSearchText(_ text: String, for route: AtlasRoute) {
searchTextByRoute[route] = text
}
var filteredSnapshot: AtlasWorkspaceSnapshot {
var filtered = snapshot
filtered.findings = filter(snapshot.findings, route: .overview) { finding in
[finding.title, finding.detail, AtlasL10n.localizedCategory(finding.category), finding.risk.title]
}
filtered.apps = filter(snapshot.apps, route: .overview) { app in
[app.name, app.bundleIdentifier, app.bundlePath, "\(app.leftoverItems)"]
}
filtered.taskRuns = filter(snapshot.taskRuns, route: .overview) { task in
[task.kind.title, task.status.title, task.summary]
}
filtered.recoveryItems = filter(snapshot.recoveryItems, route: .overview) { item in
[item.title, item.detail, item.originalPath]
}
filtered.permissions = filter(snapshot.permissions, route: .overview) { permission in
[
permission.kind.title,
permission.rationale,
permissionStatusText(for: permission)
]
}
return filtered
}
var filteredFindings: [Finding] {
filter(snapshot.findings, route: .smartClean) { finding in
[finding.title, finding.detail, AtlasL10n.localizedCategory(finding.category), finding.risk.title]
}
}
var filteredApps: [AppFootprint] {
filter(snapshot.apps, route: .apps) { app in
[app.name, app.bundleIdentifier, app.bundlePath, "\(app.leftoverItems)"]
}
}
var filteredTaskRuns: [TaskRun] {
filter(snapshot.taskRuns, route: .history) { task in
[task.kind.title, task.status.title, task.summary]
}
}
var filteredRecoveryItems: [RecoveryItem] {
filter(snapshot.recoveryItems, route: .history) { item in
[item.title, item.detail, item.originalPath]
}
}
var filteredPermissionStates: [PermissionState] {
filter(snapshot.permissions, route: .permissions) { permission in
[
permission.kind.title,
permission.rationale,
permissionStatusText(for: permission)
]
}
}
var taskCenterTaskRuns: [TaskRun] {
snapshot.taskRuns
}
var taskCenterSummary: String {
let activeTaskCount = snapshot.taskRuns.filter { taskRun in
taskRun.status == .queued || taskRun.status == .running
}.count
if activeTaskCount == 0 {
return AtlasL10n.string("model.taskcenter.none")
}
let key = activeTaskCount == 1 ? "model.taskcenter.active.one" : "model.taskcenter.active.other"
return AtlasL10n.string(key, activeTaskCount)
}
var isWorkflowBusy: Bool {
isHealthSnapshotRefreshing
|| isScanRunning
|| isPlanRunning
|| isPermissionsRefreshing
|| isAppActionRunning
|| restoringRecoveryItemID != nil
}
var canExecuteCurrentSmartCleanPlan: Bool {
!currentPlan.items.isEmpty && isCurrentSmartCleanPlanFresh && currentSmartCleanPlanHasExecutableTargets
}
var currentSmartCleanPlanHasExecutableTargets: Bool {
let selectedIDs = Set(currentPlan.items.map(\.id))
let executableFindings = snapshot.findings.filter { selectedIDs.contains($0.id) && !$0.targetPathsDescriptionIsInspectionOnly }
guard !executableFindings.isEmpty else {
return false
}
return executableFindings.allSatisfy { !($0.targetPaths ?? []).isEmpty }
}
func refreshHealthSnapshotIfNeeded() async {
guard !didRequestInitialHealthSnapshot else {
return
}
didRequestInitialHealthSnapshot = true
await refreshHealthSnapshot()
}
func refreshPermissionsIfNeeded() async {
guard !didRequestInitialPermissionSnapshot else {
return
}
didRequestInitialPermissionSnapshot = true
await inspectPermissions()
}
func refreshHealthSnapshot() async {
guard !isHealthSnapshotRefreshing else {
return
}
isHealthSnapshotRefreshing = true
do {
let output = try await workspaceController.healthSnapshot()
withAnimation(.snappy(duration: 0.24)) {
snapshot = output.snapshot
}
} catch {
latestScanSummary = error.localizedDescription
}
isHealthSnapshotRefreshing = false
}
func inspectPermissions() async {
guard !isPermissionsRefreshing else {
return
}
isPermissionsRefreshing = true
latestPermissionsSummary = AtlasL10n.string("model.permissions.refreshing")
do {
let output = try await workspaceController.inspectPermissions()
withAnimation(.snappy(duration: 0.24)) {
snapshot = output.snapshot
}
let grantedCount = output.snapshot.permissions.filter(\.isGranted).count
latestPermissionsSummary = AtlasL10n.string(
output.snapshot.permissions.count == 1 ? "model.permissions.summary.one" : "model.permissions.summary.other",
grantedCount,
output.snapshot.permissions.count
)
} catch {
latestPermissionsSummary = error.localizedDescription
}
isPermissionsRefreshing = false
}
func runSmartCleanScan() async {
guard !isScanRunning else {
return
}
selection = .smartClean
isScanRunning = true
latestScanSummary = AtlasL10n.string("model.scan.submitting")
latestScanProgress = 0
do {
let output = try await workspaceController.startScan()
withAnimation(.snappy(duration: 0.24)) {
snapshot = output.snapshot
currentPlan = output.actionPlan ?? currentPlan
latestScanSummary = output.summary
latestScanProgress = output.progressFraction
isCurrentSmartCleanPlanFresh = output.actionPlan != nil
smartCleanPlanIssue = nil
}
} catch {
latestScanSummary = error.localizedDescription
latestScanProgress = 0
smartCleanPlanIssue = error.localizedDescription
}
isScanRunning = false
}
@discardableResult
func refreshPlanPreview() async -> Bool {
do {
let output = try await workspaceController.previewPlan(findingIDs: snapshot.findings.map(\.id))
withAnimation(.snappy(duration: 0.24)) {
snapshot = output.snapshot
currentPlan = output.actionPlan
latestScanSummary = output.summary
latestScanProgress = min(max(latestScanProgress, 1), 1)
isCurrentSmartCleanPlanFresh = true
smartCleanPlanIssue = nil
}
return true
} catch {
latestScanSummary = error.localizedDescription
smartCleanPlanIssue = error.localizedDescription
return false
}
}
func executeCurrentPlan() async {
guard !isPlanRunning, !currentPlan.items.isEmpty else {
return
}
selection = .smartClean
isPlanRunning = true
do {
let output = try await workspaceController.executePlan(planID: currentPlan.id)
withAnimation(.snappy(duration: 0.24)) {
snapshot = output.snapshot
latestScanSummary = output.summary
latestScanProgress = output.progressFraction
smartCleanPlanIssue = nil
}
let didRefreshPlan = await refreshPlanPreview()
if !didRefreshPlan {
isCurrentSmartCleanPlanFresh = false
}
} catch {
latestScanSummary = error.localizedDescription
smartCleanPlanIssue = error.localizedDescription
}
isPlanRunning = false
}
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
}
func previewAppUninstall(appID: UUID) async {
guard !isAppActionRunning else {
return
}
selection = .apps
isAppActionRunning = true
activePreviewAppID = appID
activeUninstallAppID = nil
do {
let output = try await workspaceController.previewAppUninstall(appID: appID)
withAnimation(.snappy(duration: 0.24)) {
snapshot = output.snapshot
currentAppPreview = output.actionPlan
currentPreviewedAppID = appID
latestAppsSummary = output.summary
}
} catch {
latestAppsSummary = error.localizedDescription
}
activePreviewAppID = nil
isAppActionRunning = false
}
func executeAppUninstall(appID: UUID) async {
guard !isAppActionRunning else {
return
}
selection = .apps
isAppActionRunning = true
activePreviewAppID = nil
activeUninstallAppID = appID
do {
let output = try await workspaceController.executeAppUninstall(appID: appID)
withAnimation(.snappy(duration: 0.24)) {
snapshot = output.snapshot
currentAppPreview = nil
currentPreviewedAppID = nil
latestAppsSummary = output.summary
}
} catch {
latestAppsSummary = error.localizedDescription
}
activeUninstallAppID = nil
isAppActionRunning = false
}
func restoreRecoveryItem(_ itemID: UUID) async {
guard restoringRecoveryItemID == nil else {
return
}
restoringRecoveryItemID = itemID
do {
let output = try await workspaceController.restoreItems(itemIDs: [itemID])
withAnimation(.snappy(duration: 0.24)) {
snapshot = output.snapshot
latestScanSummary = output.summary
}
await refreshPlanPreview()
} catch {
latestScanSummary = error.localizedDescription
}
restoringRecoveryItemID = nil
}
func setRecoveryRetentionDays(_ days: Int) async {
await updateSettings { settings in
settings.recoveryRetentionDays = days
}
}
func setNotificationsEnabled(_ isEnabled: Bool) async {
if isEnabled, snapshot.permissions.first(where: { $0.kind == .notifications })?.isGranted != true {
_ = await notificationPermissionRequester()
}
await updateSettings { settings in
settings.notificationsEnabled = isEnabled
}
await inspectPermissions()
}
func requestNotificationPermission() async {
_ = await notificationPermissionRequester()
await inspectPermissions()
}
func setLanguage(_ language: AtlasLanguage) async {
guard settings.language != language else {
return
}
await updateSettings { settings in
settings.language = language
settings.acknowledgementText = AtlasL10n.acknowledgement(language: language)
settings.thirdPartyNoticesText = AtlasL10n.thirdPartyNotices(language: language)
}
AtlasL10n.setCurrentLanguage(language)
refreshLocalizedReadySummaries()
if !snapshot.findings.isEmpty {
await refreshPlanPreview()
}
currentAppPreview = nil
currentPreviewedAppID = nil
}
func refreshCurrentRoute() async {
switch selection ?? .overview {
case .overview:
await refreshHealthSnapshot()
case .smartClean:
await runSmartCleanScan()
case .apps:
await refreshApps()
case .history:
break
case .permissions:
await inspectPermissions()
case .settings:
break
}
}
func navigate(to route: AtlasRoute) {
withAnimation(.snappy(duration: 0.2)) {
selection = route
}
}
func openTaskCenter() {
withAnimation(.snappy(duration: 0.2)) {
isTaskCenterPresented = true
}
}
func closeTaskCenter() {
withAnimation(.snappy(duration: 0.2)) {
isTaskCenterPresented = false
}
}
func toggleTaskCenter() {
withAnimation(.snappy(duration: 0.2)) {
isTaskCenterPresented.toggle()
}
}
private func updateSettings(_ mutate: (inout AtlasSettings) -> Void) async {
var updated = settings
mutate(&updated)
do {
let output = try await workspaceController.updateSettings(updated)
AtlasL10n.setCurrentLanguage(output.settings.language)
withAnimation(.snappy(duration: 0.2)) {
settings = output.settings
}
} catch {
latestAppsSummary = error.localizedDescription
}
}
private func refreshLocalizedReadySummaries() {
if !isScanRunning && !isPlanRunning {
latestScanSummary = AtlasL10n.string("model.scan.ready")
}
if !isAppActionRunning {
latestAppsSummary = AtlasL10n.string("model.apps.ready")
}
if !isPermissionsRefreshing {
latestPermissionsSummary = AtlasL10n.string("model.permissions.ready")
}
}
private func filter<Element>(
_ elements: [Element],
route: AtlasRoute,
fields: (Element) -> [String]
) -> [Element] {
let query = searchText(for: route)
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard !query.isEmpty else {
return elements
}
return elements.filter { element in
fields(element)
.joined(separator: " ")
.lowercased()
.contains(query)
}
}
}
private extension Finding {
var targetPathsDescriptionIsInspectionOnly: Bool {
risk == .advanced || !AtlasSmartCleanExecutionSupport.isFindingExecutionSupported(self)
}
}
private extension AtlasAppModel {
func permissionStatusText(for permission: PermissionState) -> String {
if permission.isGranted {
return AtlasL10n.string("common.granted")
}
return permission.kind.isRequiredForCurrentWorkflows
? AtlasL10n.string("permissions.status.required")
: AtlasL10n.string("permissions.status.optional")
}
}

View File

@@ -0,0 +1,106 @@
import AtlasDesignSystem
import AtlasDomain
import SwiftUI
struct TaskCenterView: View {
let taskRuns: [TaskRun]
let summary: String
let onOpenHistory: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: AtlasSpacing.xl) {
VStack(alignment: .leading, spacing: AtlasSpacing.sm) {
Text(AtlasL10n.string("taskcenter.title"))
.font(AtlasTypography.sectionTitle)
Text(summary)
.font(AtlasTypography.body)
.foregroundStyle(.secondary)
}
Divider()
AtlasCallout(
title: taskRuns.isEmpty ? AtlasL10n.string("taskcenter.callout.empty.title") : AtlasL10n.string("taskcenter.callout.active.title"),
detail: taskRuns.isEmpty
? AtlasL10n.string("taskcenter.callout.empty.detail")
: AtlasL10n.string("taskcenter.callout.active.detail"),
tone: taskRuns.isEmpty ? .neutral : .success,
systemImage: taskRuns.isEmpty ? "clock.badge.questionmark" : "clock.arrow.circlepath"
)
if taskRuns.isEmpty {
AtlasEmptyState(
title: AtlasL10n.string("taskcenter.empty.title"),
detail: AtlasL10n.string("taskcenter.empty.detail"),
systemImage: "list.bullet.rectangle.portrait",
tone: .neutral
)
} else {
VStack(alignment: .leading, spacing: AtlasSpacing.md) {
ForEach(taskRuns.prefix(5)) { taskRun in
AtlasDetailRow(
title: taskRun.kind.title,
subtitle: taskRun.summary,
footnote: timelineFootnote(for: taskRun),
systemImage: icon(for: taskRun.kind),
tone: taskRun.status.tintTone
) {
AtlasStatusChip(taskRun.status.title, tone: taskRun.status.tintTone)
}
}
}
}
Button(action: onOpenHistory) {
Label(AtlasL10n.string("taskcenter.openHistory"), systemImage: "arrow.right.circle.fill")
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.keyboardShortcut(.defaultAction)
.accessibilityIdentifier("taskcenter.openHistory")
.accessibilityHint(AtlasL10n.string("taskcenter.openHistory.hint"))
}
.padding(AtlasSpacing.xl)
.frame(width: 430)
.accessibilityIdentifier("taskcenter.panel")
}
private func timelineFootnote(for taskRun: TaskRun) -> String {
let start = AtlasFormatters.shortDate(taskRun.startedAt)
if let finishedAt = taskRun.finishedAt {
return AtlasL10n.string("taskcenter.timeline.finished", start, AtlasFormatters.shortDate(finishedAt))
}
return AtlasL10n.string("taskcenter.timeline.running", start)
}
private func icon(for kind: TaskKind) -> String {
switch kind {
case .scan:
return "sparkles"
case .executePlan:
return "play.circle"
case .uninstallApp:
return "trash"
case .restore:
return "arrow.uturn.backward.circle"
case .inspectPermissions:
return "lock.shield"
}
}
}
private extension TaskStatus {
var tintTone: AtlasTone {
switch self {
case .queued:
return .neutral
case .running:
return .warning
case .completed:
return .success
case .failed, .cancelled:
return .danger
}
}
}

View File

@@ -0,0 +1,297 @@
import XCTest
@testable import AtlasApp
import AtlasApplication
import AtlasDomain
import AtlasInfrastructure
@MainActor
final class AtlasAppModelTests: XCTestCase {
func testCurrentSmartCleanPlanStartsAsCachedUntilSessionRefresh() {
let model = AtlasAppModel(repository: makeRepository(), workerService: AtlasScaffoldWorkerService(allowStateOnlyCleanExecution: true))
XCTAssertFalse(model.isCurrentSmartCleanPlanFresh)
XCTAssertFalse(model.canExecuteCurrentSmartCleanPlan)
XCTAssertNil(model.smartCleanPlanIssue)
}
func testFailedSmartCleanScanKeepsCachedPlanAndExposesFailureReason() async {
let repository = makeRepository()
let worker = AtlasScaffoldWorkerService(
repository: repository,
smartCleanScanProvider: FailingSmartCleanProvider()
)
let model = AtlasAppModel(repository: repository, workerService: worker)
await model.runSmartCleanScan()
XCTAssertFalse(model.isCurrentSmartCleanPlanFresh)
XCTAssertFalse(model.canExecuteCurrentSmartCleanPlan)
XCTAssertNotNil(model.smartCleanPlanIssue)
XCTAssertTrue(model.latestScanSummary.contains("Smart Clean scan is unavailable"))
}
func testRefreshPlanPreviewKeepsPlanNonExecutableWhenFindingsLackTargets() async {
let repository = makeRepository()
let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: true)
let model = AtlasAppModel(repository: repository, workerService: worker)
let refreshed = await model.refreshPlanPreview()
XCTAssertTrue(refreshed)
XCTAssertTrue(model.isCurrentSmartCleanPlanFresh)
XCTAssertFalse(model.canExecuteCurrentSmartCleanPlan)
}
func testRunSmartCleanScanMarksPlanAsFreshForCurrentSession() async throws {
let repository = makeRepository()
let worker = AtlasScaffoldWorkerService(
repository: repository,
smartCleanScanProvider: FakeSmartCleanProvider(),
allowStateOnlyCleanExecution: true
)
let model = AtlasAppModel(repository: repository, workerService: worker)
await model.runSmartCleanScan()
XCTAssertTrue(model.isCurrentSmartCleanPlanFresh)
XCTAssertNil(model.smartCleanPlanIssue)
XCTAssertTrue(model.canExecuteCurrentSmartCleanPlan)
}
func testRunSmartCleanScanUpdatesSummaryProgressAndPlan() async throws {
let repository = makeRepository()
let worker = AtlasScaffoldWorkerService(
repository: repository,
smartCleanScanProvider: FakeSmartCleanProvider()
)
let model = AtlasAppModel(repository: repository, workerService: worker)
await model.runSmartCleanScan()
XCTAssertEqual(model.snapshot.findings.count, 2)
XCTAssertEqual(model.currentPlan.items.count, 2)
XCTAssertEqual(model.latestScanProgress, 1)
XCTAssertTrue(model.latestScanSummary.contains("2 reclaimable item"))
}
func testExecuteCurrentPlanMovesFindingsIntoRecovery() async throws {
let repository = makeRepository()
let worker = AtlasScaffoldWorkerService(
repository: repository,
smartCleanScanProvider: FakeSmartCleanProvider(),
allowStateOnlyCleanExecution: true
)
let model = AtlasAppModel(repository: repository, workerService: worker)
let initialRecoveryCount = model.snapshot.recoveryItems.count
await model.runSmartCleanScan()
await model.executeCurrentPlan()
XCTAssertGreaterThan(model.snapshot.recoveryItems.count, initialRecoveryCount)
XCTAssertEqual(model.snapshot.taskRuns.first?.kind, .executePlan)
XCTAssertGreaterThan(model.latestScanProgress, 0)
}
func testRefreshAppsUsesInventoryProvider() async throws {
let repository = makeRepository()
let worker = AtlasScaffoldWorkerService(
repository: repository,
appsInventoryProvider: FakeInventoryProvider()
)
let model = AtlasAppModel(repository: repository, workerService: worker)
await model.refreshApps()
XCTAssertEqual(model.snapshot.apps.count, 1)
XCTAssertEqual(model.snapshot.apps.first?.name, "Sample App")
XCTAssertEqual(model.latestAppsSummary, AtlasL10n.string("application.apps.loaded.one"))
}
func testRestoreRecoveryItemReturnsFindingToWorkspace() async throws {
let repository = makeRepository()
let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: true)
let model = AtlasAppModel(repository: repository, workerService: worker)
await model.executeCurrentPlan()
let recoveryItemID = try XCTUnwrap(model.snapshot.recoveryItems.first?.id)
let findingsCountAfterExecute = model.snapshot.findings.count
await model.restoreRecoveryItem(recoveryItemID)
XCTAssertGreaterThan(model.snapshot.findings.count, findingsCountAfterExecute)
XCTAssertFalse(model.snapshot.recoveryItems.contains(where: { $0.id == recoveryItemID }))
}
func testSettingsUpdatePersistsThroughWorker() async throws {
let repository = makeRepository()
let permissionInspector = AtlasPermissionInspector(
homeDirectoryURL: FileManager.default.temporaryDirectory,
fullDiskAccessProbeURLs: [URL(fileURLWithPath: "/tmp/fda-probe")],
protectedLocationReader: { _ in false },
accessibilityStatusProvider: { false },
notificationsAuthorizationProvider: { false }
)
let worker = AtlasScaffoldWorkerService(
repository: repository,
permissionInspector: permissionInspector,
allowStateOnlyCleanExecution: true
)
let model = AtlasAppModel(
repository: repository,
workerService: worker,
notificationPermissionRequester: { true }
)
await model.setRecoveryRetentionDays(14)
await model.setNotificationsEnabled(false)
XCTAssertEqual(model.settings.recoveryRetentionDays, 14)
XCTAssertFalse(model.settings.notificationsEnabled)
XCTAssertEqual(repository.loadSettings().recoveryRetentionDays, 14)
XCTAssertFalse(repository.loadSettings().notificationsEnabled)
}
func testRefreshCurrentRouteRefreshesAppsWhenAppsSelected() async throws {
let repository = makeRepository()
let worker = AtlasScaffoldWorkerService(
repository: repository,
appsInventoryProvider: FakeInventoryProvider()
)
let model = AtlasAppModel(repository: repository, workerService: worker)
model.navigate(to: .apps)
await model.refreshCurrentRoute()
XCTAssertEqual(model.selection, .apps)
XCTAssertEqual(model.snapshot.apps.count, 1)
XCTAssertEqual(model.snapshot.apps.first?.name, "Sample App")
XCTAssertEqual(model.latestAppsSummary, AtlasL10n.string("application.apps.loaded.one"))
}
func testSetNotificationsEnabledRequestsNotificationPermissionWhenEnabling() async {
let repository = makeRepository()
let permissionInspector = AtlasPermissionInspector(
homeDirectoryURL: FileManager.default.temporaryDirectory,
fullDiskAccessProbeURLs: [URL(fileURLWithPath: "/tmp/fda-probe")],
protectedLocationReader: { _ in false },
accessibilityStatusProvider: { false },
notificationsAuthorizationProvider: { false }
)
let worker = AtlasScaffoldWorkerService(
repository: repository,
permissionInspector: permissionInspector,
allowStateOnlyCleanExecution: true
)
let recorder = NotificationPermissionRecorder()
let model = AtlasAppModel(
repository: repository,
workerService: worker,
notificationPermissionRequester: { await recorder.request() }
)
await model.setNotificationsEnabled(false)
await model.setNotificationsEnabled(true)
let callCount = await recorder.callCount()
XCTAssertEqual(callCount, 1)
}
func testRefreshPermissionsIfNeededUpdatesSnapshotFromWorker() async {
let repository = makeRepository()
let permissionInspector = AtlasPermissionInspector(
homeDirectoryURL: FileManager.default.temporaryDirectory,
fullDiskAccessProbeURLs: [URL(fileURLWithPath: "/tmp/fda-probe")],
protectedLocationReader: { _ in true },
accessibilityStatusProvider: { true },
notificationsAuthorizationProvider: { false }
)
let worker = AtlasScaffoldWorkerService(
repository: repository,
permissionInspector: permissionInspector,
allowStateOnlyCleanExecution: true
)
let model = AtlasAppModel(repository: repository, workerService: worker)
await model.refreshPermissionsIfNeeded()
XCTAssertEqual(model.snapshot.permissions.first(where: { $0.kind == .fullDiskAccess })?.isGranted, true)
XCTAssertEqual(model.snapshot.permissions.first(where: { $0.kind == .accessibility })?.isGranted, true)
XCTAssertEqual(model.snapshot.permissions.first(where: { $0.kind == .notifications })?.isGranted, false)
}
func testToggleTaskCenterFlipsPresentationState() {
let model = AtlasAppModel(repository: makeRepository(), workerService: AtlasScaffoldWorkerService(allowStateOnlyCleanExecution: true))
XCTAssertFalse(model.isTaskCenterPresented)
model.toggleTaskCenter()
XCTAssertTrue(model.isTaskCenterPresented)
model.toggleTaskCenter()
XCTAssertFalse(model.isTaskCenterPresented)
}
func testSetLanguagePersistsThroughWorkerAndUpdatesLocalization() async throws {
let repository = makeRepository()
let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: true)
let model = AtlasAppModel(repository: repository, workerService: worker)
await model.setLanguage(.en)
XCTAssertEqual(model.settings.language, .en)
XCTAssertEqual(repository.loadSettings().language, .en)
XCTAssertEqual(AtlasRoute.overview.title, "Overview")
}
private func makeRepository() -> AtlasWorkspaceRepository {
AtlasWorkspaceRepository(
stateFileURL: FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
.appendingPathComponent("workspace-state.json")
)
}
}
private struct FakeSmartCleanProvider: AtlasSmartCleanScanProviding {
func collectSmartCleanScan() async throws -> AtlasSmartCleanScanResult {
AtlasSmartCleanScanResult(
findings: [
Finding(title: "Build Cache", detail: "Temporary build outputs.", bytes: 512_000_000, risk: .safe, category: "Developer", targetPaths: [FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Caches/FakeBuildCache.bin").path]),
Finding(title: "Old Runtime", detail: "Unused runtime assets.", bytes: 1_024_000_000, risk: .review, category: "Developer", targetPaths: [FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Developer/Xcode/DerivedData/FakeOldRuntime").path]),
],
summary: "Smart Clean dry run found 2 reclaimable items."
)
}
}
private struct FakeInventoryProvider: AtlasAppInventoryProviding {
func collectInstalledApps() async throws -> [AppFootprint] {
[
AppFootprint(
name: "Sample App",
bundleIdentifier: "com.example.sample",
bundlePath: "/Applications/Sample App.app",
bytes: 2_048_000_000,
leftoverItems: 3
)
]
}
}
private struct FailingSmartCleanProvider: AtlasSmartCleanScanProviding {
func collectSmartCleanScan() async throws -> AtlasSmartCleanScanResult {
throw NSError(domain: "AtlasAppModelTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "Fixture scan failed."])
}
}
private actor NotificationPermissionRecorder {
private var calls = 0
func request() -> Bool {
calls += 1
return true
}
func callCount() -> Int {
calls
}
}

View File

@@ -0,0 +1,85 @@
import XCTest
final class AtlasAppUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testSidebarShowsFrozenMVPRoutes() {
let app = makeApp()
app.launch()
XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 5))
let sidebar = app.outlines["atlas.sidebar"]
XCTAssertTrue(sidebar.waitForExistence(timeout: 5))
for routeID in ["overview", "smartClean", "apps", "history", "permissions", "settings"] {
XCTAssertTrue(app.staticTexts["route.\(routeID)"].waitForExistence(timeout: 3), "Missing route: \(routeID)")
}
}
func testDefaultLanguageIsChineseAndCanSwitchToEnglish() {
let app = makeApp()
app.launch()
XCTAssertTrue(app.staticTexts["概览"].waitForExistence(timeout: 5))
app.staticTexts["route.settings"].click()
let englishButton = app.buttons["English"]
let englishRadio = app.radioButtons["English"]
let didFindEnglishControl = englishButton.waitForExistence(timeout: 3) || englishRadio.waitForExistence(timeout: 3)
XCTAssertTrue(didFindEnglishControl)
if englishButton.exists {
englishButton.click()
XCTAssertTrue(englishButton.exists)
} else {
englishRadio.click()
XCTAssertTrue(englishRadio.exists)
}
}
func testSmartCleanAndSettingsPrimaryControlsExist() {
let app = makeApp()
app.launch()
let sidebar = app.outlines["atlas.sidebar"]
XCTAssertTrue(sidebar.waitForExistence(timeout: 5))
app.staticTexts["route.smartClean"].click()
XCTAssertTrue(app.buttons["smartclean.runScan"].waitForExistence(timeout: 5))
XCTAssertTrue(app.buttons["smartclean.refreshPreview"].waitForExistence(timeout: 5))
XCTAssertFalse(app.buttons["smartclean.executePreview"].waitForExistence(timeout: 2))
app.staticTexts["route.settings"].click()
XCTAssertTrue(app.segmentedControls["settings.language"].waitForExistence(timeout: 5) || app.radioGroups["settings.language"].waitForExistence(timeout: 5))
XCTAssertTrue(app.switches["settings.notifications"].waitForExistence(timeout: 5))
let recoveryPanelButton = app.buttons["settings.panel.recovery"]
XCTAssertTrue(recoveryPanelButton.waitForExistence(timeout: 5))
recoveryPanelButton.click()
XCTAssertTrue(app.steppers["settings.recoveryRetention"].waitForExistence(timeout: 5))
}
func testKeyboardShortcutsNavigateAndOpenTaskCenter() {
let app = makeApp()
app.launch()
let window = app.windows.firstMatch
XCTAssertTrue(window.waitForExistence(timeout: 5))
window.typeKey("2", modifierFlags: .command)
XCTAssertTrue(app.buttons["smartclean.runScan"].waitForExistence(timeout: 5))
window.typeKey("5", modifierFlags: .command)
XCTAssertTrue(app.buttons["permissions.refresh"].waitForExistence(timeout: 5))
window.typeKey("7", modifierFlags: .command)
XCTAssertTrue(app.otherElements["taskcenter.panel"].waitForExistence(timeout: 5))
}
private func makeApp() -> XCUIApplication {
let app = XCUIApplication()
let stateFile = NSTemporaryDirectory() + UUID().uuidString + "/workspace-state.json"
app.launchEnvironment["ATLAS_STATE_FILE"] = stateFile
return app
}
}

43
Apps/Package.swift Normal file
View File

@@ -0,0 +1,43 @@
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "AtlasApps",
platforms: [.macOS(.v14)],
products: [
.executable(name: "AtlasApp", targets: ["AtlasApp"]),
],
dependencies: [
.package(path: "../Packages"),
],
targets: [
.executableTarget(
name: "AtlasApp",
dependencies: [
.product(name: "AtlasApplication", package: "Packages"),
.product(name: "AtlasCoreAdapters", package: "Packages"),
.product(name: "AtlasDesignSystem", package: "Packages"),
.product(name: "AtlasDomain", package: "Packages"),
.product(name: "AtlasFeaturesApps", package: "Packages"),
.product(name: "AtlasFeaturesHistory", package: "Packages"),
.product(name: "AtlasFeaturesOverview", package: "Packages"),
.product(name: "AtlasFeaturesPermissions", package: "Packages"),
.product(name: "AtlasFeaturesSettings", package: "Packages"),
.product(name: "AtlasFeaturesSmartClean", package: "Packages"),
.product(name: "AtlasInfrastructure", package: "Packages"),
],
path: "AtlasApp/Sources/AtlasApp",
resources: [.process("Assets.xcassets")]
),
.testTarget(
name: "AtlasAppTests",
dependencies: [
"AtlasApp",
.product(name: "AtlasApplication", package: "Packages"),
.product(name: "AtlasDomain", package: "Packages"),
.product(name: "AtlasInfrastructure", package: "Packages"),
],
path: "AtlasApp/Tests/AtlasAppTests"
),
]
)

10
Apps/README.md Normal file
View File

@@ -0,0 +1,10 @@
# Apps
This directory contains user-facing application targets.
## Current Entry
- `AtlasApp/` hosts the main native macOS shell.
- `Package.swift` exposes the app shell as a SwiftPM executable target for local iteration.
- The app shell now wires fallback health, Smart Clean, app inventory, and helper integrations through the structured worker path.
- Root `project.yml` can regenerate `Atlas.xcodeproj` with `xcodegen generate` for native app packaging and installer production.

View File

@@ -0,0 +1,21 @@
# ADR-001: Worker and Helper Boundary
## Status
Accepted
## Context
Atlas for Mac needs long-running scanning and cleanup operations, but must avoid running privileged or shell-oriented logic directly inside the UI process.
## Decision
- Use a non-privileged worker process for orchestration and progress streaming.
- Use a separate privileged helper for approved structured actions only.
- Disallow arbitrary shell passthrough from the UI.
## Consequences
- Better crash isolation
- Clearer audit boundaries
- More initial setup complexity

View File

@@ -0,0 +1,21 @@
# ADR-002: Structured Protocol and Adapter Layer
## Status
Accepted
## Context
Existing upstream capabilities are terminal-oriented and not suitable as a direct contract for a native GUI.
## Decision
- Define a structured local JSON protocol.
- Wrap reusable upstream logic behind adapters.
- Keep UI components unaware of script or terminal output format.
## Consequences
- Faster GUI iteration
- Safer schema evolution
- Additional adapter maintenance cost

View File

@@ -0,0 +1,29 @@
# ADR-003: Workspace State Persistence and MVP Command Expansion
## Status
Accepted
## Context
Atlas for Mac already had a native shell, worker transport, and upstream-backed adapters for health and Smart Clean dry runs, but several frozen MVP flows still depended on in-memory scaffold state. That left history, recovery, settings, and app uninstall behavior incomplete across launches and weakened the value of the worker boundary.
## Decision
- Persist a local JSON-backed workspace state for MVP.
- Store the latest workspace snapshot, current Smart Clean plan, and user settings together.
- Expand the structured worker protocol to cover missing frozen-scope flows: Smart Clean execute, recovery restore, apps list, app uninstall preview/execute, and settings get/set.
- Keep these flows behind the existing application/protocol/worker boundaries instead of adding direct UI-side mutations.
## Consequences
- History, recovery, app removal, and settings now survive beyond a single process lifetime.
- The UI can complete more of the MVP through stable worker commands without parsing or mutating raw script state.
- The protocol surface is larger and must stay synchronized with docs and tests.
- Local JSON persistence is acceptable for MVP, but future production hardening may require a more robust store.
## Alternatives Considered
- Keep all new flows in memory only — rejected because recovery and settings would reset across launches.
- Let the UI mutate app/history/settings state directly — rejected because it breaks the worker-first architecture.
- Introduce a database immediately — rejected because it adds complexity beyond MVP needs.

View File

@@ -0,0 +1,29 @@
# ADR-004: Helper Executable and Native Packaging Pipeline
## Status
Accepted
## Context
Atlas for Mac needed to move beyond a print-only helper stub and legacy CLI release workflows. The MVP required a structured helper execution path for destructive actions plus a native build/package pipeline that could produce a distributable macOS app bundle.
## Decision
- Implement the helper as a JSON-driven executable that validates allowlisted target paths before acting.
- Invoke the helper from the worker through a structured client rather than direct UI mutations.
- Build the app with `xcodegen + xcodebuild`, embed the helper binary into `Contents/Helpers/`, then emit `.zip`, `.dmg`, and `.pkg` distribution artifacts during packaging.
- Add a native GitHub Actions workflow that packages the app artifact and can optionally extend to signing/notarization when release credentials are available.
## Consequences
- The worker/helper boundary is now implemented as code, not just documentation.
- Local and CI environments can produce a real `.app` bundle, `.zip`, `.dmg`, and `.pkg` installer artifacts for MVP verification, with DMG installation validated into the user Applications folder.
- The helper is still not a fully blessed privileged service, so future release hardening may deepen this path.
- Packaging now depends on Xcode project generation remaining synchronized with `project.yml`.
## Alternatives Considered
- Keep the helper as a stub — rejected because uninstall and destructive flows would remain architecturally incomplete.
- Bundle no helper and let the worker mutate files directly — rejected because it weakens privilege boundaries.
- Delay native packaging until release week — rejected because it postpones critical integration risk discovery.

View File

@@ -0,0 +1,29 @@
# ADR-005: Localization Framework and App-Language Preference
## Status
Accepted
## Context
Atlas for Mac needed a real multilingual foundation rather than scattered hard-coded English strings. The user requirement was to support Chinese and English first, default to Chinese, and keep the language choice aligned across the app shell, settings, and worker-generated summaries.
## Decision
- Add a package-scoped localization layer with structured resources in `AtlasDomain` so the Swift package graph can share one localization source.
- Persist the app-language preference in `AtlasSettings` and default it to `zh-Hans`.
- Inject the selected locale at the app shell while also using the persisted setting to localize worker-generated summaries and settings-derived copy.
- Keep localized legal copy derived from the selected language rather than treating it as ad hoc free text.
## Consequences
- The app now supports `简体中文` and `English` with Chinese as the default user experience.
- Settings persistence, protocol payloads, and local workspace state now include the app-language preference.
- UI automation needs stable identifiers rather than relying only on visible text, because visible labels can now change by language.
- Future languages can be added by extending the shared localization resources rather than editing each screen in isolation.
## Alternatives Considered
- Use system language only and skip an in-app switch — rejected because the requirement explicitly needed in-app Chinese/English switching.
- Store language only in app-local UI state — rejected because worker-generated summaries and persisted settings copy would drift from the selected language.
- Localize each feature independently without a shared resource layer — rejected because it would create duplication and drift across the package graph.

View File

@@ -0,0 +1,33 @@
# ADR-006: Fail-Closed Execution Capability
## Status
Accepted
## Context
Atlas currently mixes real scanning with scaffold/state-based execution in some flows, especially `Smart Clean`. This creates a user trust gap: the product can appear to have cleaned disk space even when a subsequent real scan rediscovers the same data.
The worker selection path also allowed silent fallback from XPC to the scaffold worker, which could mask infrastructure failures and blur the line between real execution and development fallback behavior.
## Decision
- Release-facing execution paths must fail closed when real execution capability is unavailable.
- Silent fallback from XPC to the scaffold worker is opt-in for development only.
- `Smart Clean` scan must reject when the upstream scan adapter fails, instead of silently fabricating findings from scaffold data.
- `Smart Clean` execute must reject while only state-based execution is available, but may execute a real Trash-based path for structured safe targets.
- Recovery may physically restore targets when structured trash-to-original mappings are available.
## Consequences
- Users get a truthful failure instead of a misleading success.
- Development and tests can still opt into scaffold fallback and state-only execution explicitly.
- `Smart Clean` execute now supports a partial real execution path for structured safe targets.
- The system now carries structured executable targets and `scan -> execute -> rescan` contract coverage for that subset.
- Broader Smart Clean categories and full physical recovery coverage still need follow-up implementation.
## Alternatives Considered
- Keep silent fallback and state-only execution — rejected because it misrepresents execution capability.
- Run `bin/clean.sh` directly for plan execution — rejected because the current upstream command surface is not scoped to the reviewed Atlas plan and would bypass recovery-first guarantees.
- Hide the execute button only in UI — rejected because the trust problem exists in the worker boundary, not only the presentation layer.

26
Docs/ATTRIBUTION.md Normal file
View File

@@ -0,0 +1,26 @@
# Attribution
## Product Branding Rule
Atlas for Mac is an independent product and does not use the Mole brand in user-facing naming.
## Upstream Acknowledgement
The project acknowledges the open-source project `Mole` by `tw93 and contributors` as an upstream inspiration and potential source of reused or adapted code.
## User-Facing Copy
Recommended acknowledgement copy:
> Atlas for Mac includes software derived from the open-source project Mole by tw93 and contributors, used under the MIT License. Atlas for Mac is an independent product and is not affiliated with or endorsed by the original authors.
## Placement
- `Settings > Acknowledgements`
- `About > Open Source`
- repository-level third-party notice files
- release bundle notice materials
## Maintenance Rule
If upstream-derived code is shipped, keep the copyright notice and MIT license text available in distributed materials.

81
Docs/Architecture.md Normal file
View File

@@ -0,0 +1,81 @@
# Architecture
## High-Level Topology
- `AtlasApp` — main macOS application shell
- `AtlasWorkerXPC` — non-privileged worker service
- `AtlasPrivilegedHelper` — allowlisted helper executable for structured destructive actions
- `AtlasCoreAdapters` — wrappers around reusable upstream and local system capabilities
- `AtlasStore` — persistence for runs, rules, recovery, settings, diagnostics, and the app-language preference
## Layering
### Presentation
- SwiftUI scenes and views
- Navigation state
- View models or reducers
- App-language selection and locale injection at the app shell
### Application
- Use cases such as `StartScan`, `PreviewPlan`, `ExecutePlan`, `RestoreItems`
- App uninstall flows: `ListApps`, `PreviewAppUninstall`, `ExecuteAppUninstall`
- Settings flows: `GetSettings`, `UpdateSettings`
### Domain
- `Finding`
- `ActionPlan`
- `ActionItem`
- `TaskRun`
- `RecoveryItem`
- `RecoveryPayload`
- `AppFootprint`
- `PermissionState`
- `AtlasSettings`
- `AtlasLanguage`
### Infrastructure
- XPC transport
- JSON-backed workspace state persistence
- Logging and audit events
- Best-effort permission inspection
- Helper executable client
- Process orchestration
### Execution
- Upstream adapters: `MoleHealthAdapter`, `MoleSmartCleanAdapter`
- Release and packaged worker flows load upstream shell runtime from bundled `MoleRuntime` resources instead of source-tree paths
- Local adapters: `MacAppsInventoryAdapter`
- Recovery-first state mutation for Smart Clean and app uninstall flows
- Allowlisted helper actions for bundle trashing, restoration, and launch-service removal
- Release-facing execution must fail closed when real worker/adapter/helper capability is unavailable; scaffold fallback is development-only by opt-in
- Smart Clean now supports a real Trash-based execution path for a safe structured subset of user-owned targets, plus physical restoration when recovery mappings are present
## Process Boundaries
- UI must not parse shell output directly.
- UI must not execute privileged shell commands directly.
- `AtlasWorkerXPC` owns long-running task orchestration and progress events.
- Direct-distribution builds default to the same real worker implementation in-process; `AtlasWorkerXPC` remains available behind `ATLAS_PREFER_XPC_WORKER=1` for explicit runtime validation.
- `AtlasPrivilegedHelper` accepts structured actions only and validates paths before acting.
- Persistent workspace mutation belongs behind the repository/worker boundary rather than ad hoc UI state.
- UI copy localization is sourced from structured package resources instead of hard-coded per-screen strings.
## Distribution Direction
- Distribution target: `Developer ID + Hardened Runtime + Notarization`
- Initial release target: direct distribution, not Mac App Store
- Native packaging currently uses `xcodegen + xcodebuild`, embeds the helper into `Contents/Helpers/`, and emits `.zip`, `.dmg`, and `.pkg` distribution artifacts.
- Local internal packaging now prefers a stable non-ad-hoc app signature when a usable identity is available, so macOS TCC decisions can survive rebuilds more reliably during development.
- If Apple release certificates are unavailable, Atlas can fall back to a repo-managed local signing keychain for stable app-bundle identity; public release artifacts still require `Developer ID`.
## Security Principles
- Least privilege by default
- Explain permission need before request
- Prefer `Trash` or recovery-backed restore paths
- Audit all destructive actions

212
Docs/Backlog.md Normal file
View File

@@ -0,0 +1,212 @@
# Backlog
## Board Model
### Status
- `Backlog`
- `Ready`
- `In Progress`
- `In Review`
- `Blocked`
- `Done`
- `Frozen`
### Priority
- `P0` — required for MVP viability
- `P1` — important but can follow MVP
- `P2` — exploratory or future work
## MVP Scope
- `Overview`
- `Smart Clean`
- `Apps`
- `History`
- `Recovery`
- `Permissions`
- `Settings`
## Deferred to P1
- `Storage treemap`
- `Menu Bar`
- `Automation`
## Epics
- `EPIC-01` Brand and Compliance
- `EPIC-02` Information Architecture and Interaction Design
- `EPIC-03` Protocol and Domain Model
- `EPIC-04` App Shell and Engineering Scaffold
- `EPIC-05` Scan and Action Plan
- `EPIC-06` Apps and Uninstall
- `EPIC-07` History and Recovery
- `EPIC-08` Permissions and System Integration
- `EPIC-09` Quality and Verification
- `EPIC-10` Packaging, Signing, and Release
## Now / Next / Later
### Now
- Week 1 scope freeze
- Week 2 design freeze for core screens
- Week 3 architecture and protocol freeze
### Next
- Week 4 scaffold creation
- Week 5 scan pipeline
- Week 6 action-plan preview and execute path
### Later
- Week 7 apps flow
- Week 8 permissions, history, recovery
- Week 9 helper integration
- Week 10 hardening
- Week 11 beta candidate
- Week 12 release-readiness review
## Seed Issues
### Week 1
- `ATL-001` Freeze naming rules — `Product Agent`
- `ATL-002` Freeze MVP scope — `Product Agent`
- `ATL-003` Define goals and metrics — `Product Agent`
- `ATL-004` Start decision and risk log — `Product Agent`
- `ATL-005` Draft `IA v1``UX Agent`
- `ATL-006` Draft three core flows — `UX Agent`
- `ATL-007` Draft page-state matrix — `UX Agent`
- `ATL-008` Define domain models — `Core Agent`
- `ATL-009` Define protocol — `Core Agent`
- `ATL-010` Define task state and errors — `Core Agent`
- `ATL-011` Draft worker/helper boundary — `System Agent`
- `ATL-012` Draft permission matrix — `System Agent`
- `ATL-013` Audit upstream reusable capabilities — `Adapter Agent`
- `ATL-014` Report JSON adaptation blockers — `Adapter Agent`
- `ATL-017` Create acceptance matrix — `QA Agent`
- `ATL-019` Draft attribution docs — `Docs Agent`
- `ATL-020` Week 1 gate review — `Product Agent`
### Week 2
- `ATL-021` `Overview` high-fidelity design — `UX Agent`
- `ATL-022` `Smart Clean` high-fidelity design — `UX Agent`
- `ATL-023` `Apps` high-fidelity design — `UX Agent`
- `ATL-024` Permission explainer sheets — `UX Agent`
- `ATL-025` Freeze `Protocol v1.1``Core Agent`
- `ATL-026` Freeze persistence model — `Core Agent`
- `ATL-027` Draft worker XPC interface — `System Agent`
- `ATL-028` Draft helper allowlist — `System Agent`
- `ATL-029` Draft package and target graph — `Mac App Agent`
- `ATL-030` Draft navigation and state model — `Mac App Agent`
- `ATL-031` Draft scan adapter chain — `Adapter Agent`
- `ATL-032` Draft app-footprint adapter chain — `Adapter Agent`
- `ATL-034` MVP acceptance matrix v1 — `QA Agent`
- `ATL-036` Attribution file v1 — `Docs Agent`
- `ATL-037` Third-party notices v1 — `Docs Agent`
- `ATL-040` Week 2 gate review — `Product Agent`
### Week 3
- `ATL-041` Freeze `Architecture v1``Core Agent` + `System Agent`
- `ATL-042` Freeze `Protocol Schema v1``Core Agent`
- `ATL-043` Freeze error registry — `Core Agent`
- `ATL-044` Freeze task state machine — `Core Agent`
- `ATL-045` Freeze persistence model — `Core Agent`
- `ATL-046` Freeze worker XPC method set — `System Agent`
- `ATL-047` Freeze helper action allowlist — `System Agent`
- `ATL-048` Freeze helper validation rules — `System Agent`
- `ATL-049` Freeze app-shell route map — `Mac App Agent`
- `ATL-050` Freeze package dependency graph — `Mac App Agent`
- `ATL-052` Freeze scan adapter path — `Adapter Agent`
- `ATL-053` Freeze apps list adapter path — `Adapter Agent`
- `ATL-056` Draft contract test suite — `QA Agent`
- `ATL-060` Week 3 gate review — `Product Agent`
## Post-MVP Polish Track
### Current Status
- `Complete` — UI audit completed with explicit `P0 / P1 / P2` remediation directions in `Docs/Execution/UI-Audit-2026-03-08.md`.
- `Complete` — frozen MVP workflows are implemented end to end.
- `Complete` — post-MVP polish for trust, hierarchy, loading states, keyboard flow, and accessibility.
- `Complete` — Chinese-first bilingual localization framework with persisted app-language switching.
- `Open` — manual localization QA and release-signing/notarization remain as the main next steps.
### Focus
- Make the existing MVP feel safe, clear, and native before expanding scope.
- Prioritize first-use trust, smooth feedback, and visual consistency across the frozen MVP modules.
- Keep polish work inside `Overview`, `Smart Clean`, `Apps`, `History`, `Recovery`, `Permissions`, and `Settings`.
### Epics
- `EPIC-11` First-Run Activation and Permission Trust
- `EPIC-12` Smart Clean Explainability and Execution Confidence
- `EPIC-13` Apps Uninstall Confidence and Recovery Clarity
- `EPIC-14` Visual System and Interaction Consistency
- `EPIC-15` Perceived Performance and State Coverage
### Now / Next / Later
#### Now
- Run manual bilingual QA on a clean machine
- Validate first-launch behavior with a fresh workspace-state file
- Prepare signed packaging inputs if external distribution is needed
#### Next
- Add additional supported languages only after translation QA and copy governance are in place
- Revisit post-beta manual polish items that require human UX review rather than more structural engineering work
- Convert the current unsigned packaging flow into a signed and notarized release path
#### Later
- Extend localization coverage to future deferred modules when scope reopens
- Add localization linting or snapshot checks if the language matrix expands
- Revisit copy tone and translation review during release hardening
### Seed Issues
#### Polish Week 1
- `ATL-101` Audit state coverage for all MVP screens — `UX Agent`
- `ATL-102` Define polish scorecard and acceptance targets — `Product Agent`
- `ATL-103` Refresh shared design tokens and card hierarchy — `Mac App Agent`
- `ATL-104` Polish `Smart Clean` scan controls, preview hierarchy, and execution feedback — `Mac App Agent`
- `ATL-105` Polish `Apps` uninstall preview, leftovers messaging, and recovery cues — `Mac App Agent`
- `ATL-106` Rewrite trust-critical copy for permissions, destructive actions, and restore paths — `UX Agent`
- `ATL-107` Add loading, empty, error, and partial-permission states to the primary screens — `Mac App Agent`
- `ATL-108` Add narrow UI verification for first-run, scan, and uninstall flows — `QA Agent`
- `ATL-110` Polish Week 1 gate review — `Product Agent`
#### Polish Week 2
- `ATL-111` Tighten `Overview` information density and recommendation ranking — `UX Agent`
- `ATL-112` Improve `History` readability and restore confidence markers — `Mac App Agent`
- `ATL-113` Improve `Permissions` guidance for limited mode and just-in-time prompts — `UX Agent`
- `ATL-114` Normalize cross-screen action labels, confirmation sheets, and completion summaries — `Docs Agent`
- `ATL-115` Measure perceived latency and remove avoidable visual jumps in core flows — `QA Agent`
- `ATL-116` Polish Week 2 gate review — `Product Agent`
## Definition of Ready
- Scope is clear and bounded
- Dependencies are listed
- Owner Agent is assigned
- Acceptance criteria are testable
- Deliverable format is known
## Definition of Done
- Acceptance criteria are satisfied
- Relevant docs are updated
- Decision log is updated if scope or architecture changed
- Risks and blockers are recorded
- Handoff notes are attached

53
Docs/COPY_GUIDELINES.md Normal file
View File

@@ -0,0 +1,53 @@
# Copy Guidelines
## Tone
- Calm
- Direct
- Reassuring
- Technical only when necessary
## Product Voice
- Explain what happened first.
- Explain impact second.
- Offer a next step every time.
- Avoid fear-based maintenance language.
## Good Patterns
- `Results may be incomplete without Full Disk Access.`
- `You can keep using limited mode and grant access later.`
- `Most selected actions are recoverable.`
## Avoid
- `Critical error`
- `Illegal operation`
- `You must allow this`
- `Your Mac is at risk`
## CTA Style
- Use clear verbs: `Retry`, `Open System Settings`, `Review Plan`, `Restore`
- Avoid generic CTA labels such as `OK` and `Continue`
## Glossary
- `Scan` — read-only analysis that collects findings; it never removes anything by itself.
- `Cleanup Plan` / `Uninstall Plan` — the actionable set of reviewed steps Atlas proposes from current findings.
- `Review` — the user checks the plan before it runs. Avoid using `preview` as the primary noun when the UI is really showing a plan.
- `Run Plan` / `Run Uninstall` — apply a reviewed plan. Use this for the action that changes the system.
- `Reclaimable Space` — the estimated space the current plan can free. Make it explicit when the value recalculates after execution.
- `Recoverable` — Atlas can restore the result from History while the retention window is still open.
- `App Footprint` — the current disk space an app uses.
- `Leftover Files` — extra support files, caches, or launch items related to an app uninstall.
- `Limited Mode` — Atlas works with partial permissions and asks for more access only when a specific workflow needs it.
## Consistency Rules
- Prefer `plan` over `preview` when referring to the actionable object the user can run.
- Use `review` for the decision step before execution, not for the execution step itself.
- If a button opens macOS settings, label it `Open System Settings` instead of implying Atlas grants access directly.
- Distinguish `current plan` from `remaining items after execution` whenever reclaimable-space values can change.
- Keep permission language calm and reversible: explain what access unlocks, whether it can wait, and what the next step is.

61
Docs/DECISIONS.md Normal file
View File

@@ -0,0 +1,61 @@
# Decision Log
## Frozen Decisions
### D-001 Naming
- Internal product name: `Atlas for Mac`
- User-facing naming must not use `Mole`
### D-002 Open-Source Attribution
- Atlas for Mac is an independent product
- Upstream attribution must acknowledge Mole by tw93 and contributors
- Shipped materials must include MIT notice when upstream-derived code is distributed
### D-003 Distribution
- MVP distribution target is direct distribution
- Use `Developer ID + Hardened Runtime + Notarization`
- Do not target Mac App Store for MVP
### D-004 MVP Scope
- In scope: `Overview`, `Smart Clean`, `Apps`, `History`, `Recovery`, `Permissions`, `Settings`
- Out of MVP: `Storage treemap`, `Menu Bar`, `Automation`
### D-005 Process Boundaries
- UI must not parse terminal output directly
- Privileged actions must go through a structured helper boundary
- Worker owns long-running orchestration and progress streaming
### D-006 MVP Persistence and Command Surface
- MVP workspace state is persisted locally as a structured JSON store
- Settings, history, recovery, Smart Clean execute, and app uninstall flows use structured worker commands
- UI state should reflect repository-backed worker results instead of direct mutation
### D-007 Helper Execution and Native Packaging
- Destructive helper actions use a structured executable boundary with path validation
- Native MVP packaging uses `xcodegen + xcodebuild`, then embeds the helper into the app bundle
- Signing and notarization remain optional release-time steps driven by credentials
- Internal packaging should prefer a stable local app-signing identity over ad hoc signing whenever possible so macOS permission state does not drift across rebuilds
### D-008 App Language Preference and Localization
- MVP now supports `简体中文` and `English` through a persisted app-language preference
- The default app language is `简体中文`
- User-facing shell copy is localized through package-scoped resources instead of hard-coded per-screen strings
- The language preference is stored alongside other settings so worker-generated summaries stay aligned with UI language
### D-009 Execution Capability Honesty
- User-facing execution flows must fail closed when real disk-backed execution is unavailable
- 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
## Update Rule
Add a new decision entry whenever product scope, protocol, privilege boundaries, release route, or recovery model changes.

878
Docs/DESIGN_SPEC.md Normal file
View File

@@ -0,0 +1,878 @@
# Atlas for Mac — Design Specification v2
> **Status**: Ready for implementation
> **Brand Token 文件**: `Packages/AtlasDesignSystem/Sources/AtlasDesignSystem/AtlasBrand.swift` (已创建并编译通过)
---
## 1. Brand Identity
### 1.1 品牌概念Calm Authority沉稳的权威感
Atlas — 如同制图师为你的系统绘制地形图。精确、可信、从容不迫。
### 1.2 色彩体系
| Token | Light Mode | Dark Mode | 用途 |
|-------|-----------|-----------|------|
| `AtlasColor.brand` | `#0F766E` 深青绿 | `#148F85` 亮青绿 | 主色调、主要按钮、激活状态 |
| `AtlasColor.accent` | `#34D399` 清新薄荷绿 | `#52E2B5` 明亮薄荷绿 | 高亮、徽章、品牌点缀 |
| `AtlasColor.success` | systemGreen | systemGreen | 安全、已授权、已完成 |
| `AtlasColor.warning` | systemOrange | systemOrange | 需审查、运行中 |
| `AtlasColor.danger` | systemRed | systemRed | 失败、高级风险 |
| `AtlasColor.card` | controlBackgroundColor | controlBackgroundColor | 卡片基底 |
| `AtlasColor.cardRaised` | `white @ 65%` | `white @ 6%` | 浮起卡片的玻璃质感层 |
| `AtlasColor.border` | `primary @ 8%` | `primary @ 8%` | 普通卡片描边 |
| `AtlasColor.borderEmphasis` | `primary @ 14%` | `primary @ 14%` | 高亮卡片/焦点态描边 |
### 1.3 字体标尺
| Token | 定义 | 使用场景 |
|-------|------|---------|
| `AtlasTypography.heroMetric` | 40pt bold rounded | Dashboard 最重要的单一数值 |
| `AtlasTypography.screenTitle` | 34pt bold rounded | 每个屏幕的大标题 |
| `AtlasTypography.cardMetric` | 28pt bold rounded | 网格中的指标卡数值 |
| `AtlasTypography.sectionTitle` | title3 semibold | InfoCard 内的分区标题 |
| `AtlasTypography.label` | subheadline semibold | 指标标题、侧边栏主文本 |
| `AtlasTypography.rowTitle` | headline | DetailRow 标题 |
| `AtlasTypography.body` | subheadline | 正文说明 |
| `AtlasTypography.caption` | caption semibold | Chip、脚注、overline |
### 1.4 间距网格 (4pt base)
| Token | 值 | 场景 |
|-------|-----|------|
| `AtlasSpacing.xxs` | 4pt | 最小内边距 |
| `AtlasSpacing.xs` | 6pt | Chip 内边距 |
| `AtlasSpacing.sm` | 8pt | 行间距紧凑 |
| `AtlasSpacing.md` | 12pt | 元素间默认间距 |
| `AtlasSpacing.lg` | 16pt | 卡片内边距、分区间距 |
| `AtlasSpacing.xl` | 20pt | 宽卡片内边距 |
| `AtlasSpacing.xxl` | 24pt | 屏幕级垂直节奏 |
| `AtlasSpacing.screenH` | 28pt | 屏幕水平边距 |
| `AtlasSpacing.section` | 32pt | 大分区间隔 |
### 1.5 圆角
| Token | 值 | 场景 |
|-------|-----|------|
| `AtlasRadius.sm` | 8pt | Chip、Tag |
| `AtlasRadius.md` | 12pt | Callout、内嵌卡片 |
| `AtlasRadius.lg` | 16pt | DetailRow、紧凑卡片 |
| `AtlasRadius.xl` | 20pt | 标准 InfoCard/MetricCard |
| `AtlasRadius.xxl` | 24pt | 高亮/英雄卡片 |
### 1.6 三级高程Elevation
| 级别 | 阴影 | 圆角 | 描边 | 用途 |
|------|------|------|------|------|
| `.flat` | 无 | 16pt | 4% opacity | 嵌套内容、行内子卡片 |
| `.raised` | r18 y10 @5% | 20pt | 8% opacity | 默认卡片AtlasInfoCard/MetricCard |
| `.prominent` | r28 y16 @9% + 内发光 | 24pt | 12% opacity, 1.5pt | 英雄指标、主操作区 |
### 1.7 动画曲线
| Token | 值 | 场景 |
|-------|-----|------|
| `AtlasMotion.fast` | snappy 0.15s | hover、按压、chip |
| `AtlasMotion.standard` | snappy 0.22s | 选择、切换、卡片状态 |
| `AtlasMotion.slow` | snappy 0.35s | 页面转场、英雄揭示 |
| `AtlasMotion.spring` | spring(0.45, 0.7) | 完成庆祝、弹性反馈 |
### 1.8 按钮层级
| 样式 | 外观 | 场景 |
|------|------|------|
| `.atlasPrimary` | 品牌色填充胶囊 + 投影 + 按压缩放 | 每屏唯一最重要 CTA |
| `.atlasSecondary` | 品牌色描边胶囊 + 淡底 | 辅助操作 |
| `.atlasGhost` | 纯文字 + hover 淡底 | 低频操作 |
---
## 2. 设计系统组件迁移
> 所有修改在 `AtlasDesignSystem.swift` 中进行。`AtlasBrand.swift` 已包含新 Token不需要修改。
### 2.1 AtlasScreen — 约束阅读宽度 + 移除冗余 overline
**文件**: `Packages/AtlasDesignSystem/Sources/AtlasDesignSystem/AtlasDesignSystem.swift`
**当前问题**:
- line 100: `.frame(maxWidth: .infinity)` 导致宽窗口下文本行过长
- line 109: 每屏都显示 "Atlas for Mac" overline冗余
**改动**:
```swift
// body ScrollView VStack
ScrollView {
VStack(alignment: .leading, spacing: AtlasSpacing.xxl) {
header
content
}
.frame(maxWidth: AtlasLayout.maxReadingWidth, alignment: .leading)
.padding(.horizontal, AtlasSpacing.screenH)
.padding(.vertical, AtlasSpacing.xxl)
.frame(maxWidth: .infinity, alignment: .leading) //
}
```
**header 改为**:
- 移除 "Atlas for Mac" overlineline 109-113 整块删除)
- 使用 `AtlasTypography.screenTitle` 替换 line 117 的硬编码字号
```swift
private var header: some View {
VStack(alignment: .leading, spacing: AtlasSpacing.sm) {
Text(title)
.font(AtlasTypography.screenTitle)
Text(subtitle)
.font(AtlasTypography.body)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
```
### 2.2 AtlasMetricCard — 支持 elevation 参数 + 使用 Token
**文件**: `AtlasDesignSystem.swift`
**改动**:
- 新增 `elevation: AtlasElevation = .raised` 参数
- 替换 line 165 硬编码字号为 `AtlasTypography.cardMetric`
- 替换 line 160 硬编码字号为 `AtlasTypography.label`
- 替换 line 175 硬编码 `padding(18)``padding(AtlasSpacing.xl)`
- 替换 line 176-177 的 `cardBackground`/`cardBorder``atlasCardBackground`/`atlasCardBorder`(传入 elevation
```swift
public struct AtlasMetricCard: View {
private let title: String
private let value: String
private let detail: String
private let tone: AtlasTone
private let systemImage: String?
private let elevation: AtlasElevation //
public init(
title: String,
value: String,
detail: String,
tone: AtlasTone = .neutral,
systemImage: String? = nil,
elevation: AtlasElevation = .raised //
) {
self.title = title
self.value = value
self.detail = detail
self.tone = tone
self.systemImage = systemImage
self.elevation = elevation
}
public var body: some View {
VStack(alignment: .leading, spacing: AtlasSpacing.lg) {
HStack(alignment: .center, spacing: AtlasSpacing.md) {
if let systemImage {
Image(systemName: systemImage)
.font(.headline)
.foregroundStyle(tone.tint)
.accessibilityHidden(true)
}
Text(title)
.font(AtlasTypography.label)
.foregroundStyle(.secondary)
}
Text(value)
.font(elevation == .prominent ? AtlasTypography.heroMetric : AtlasTypography.cardMetric)
.foregroundStyle(.primary)
.contentTransition(.numericText())
Text(detail)
.font(AtlasTypography.body)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AtlasSpacing.xl)
.background(atlasCardBackground(tone: tone, elevation: elevation))
.overlay(atlasCardBorder(tone: tone, elevation: elevation))
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(title))
.accessibilityValue(Text(value))
.accessibilityHint(Text(detail))
}
}
```
### 2.3 AtlasInfoCard — 使用 Token
**文件**: `AtlasDesignSystem.swift`
**改动**:
- 替换 line 204 `spacing: 18``AtlasSpacing.xl`
- 替换 line 209 `.title3.weight(.semibold)``AtlasTypography.sectionTitle`
- 替换 line 214 `.subheadline``AtlasTypography.body`
- 替换 line 224 `padding(22)``padding(AtlasSpacing.xxl)`
- 替换 line 225-226 为 `atlasCardBackground`/`atlasCardBorder`
### 2.4 AtlasCallout — 使用 Token
**文件**: `AtlasDesignSystem.swift`
**改动**:
- 替换 line 249 `spacing: 14``AtlasSpacing.lg`
- 替换 line 256 `spacing: 6``AtlasSpacing.xs`
- 替换 line 258 `.headline``AtlasTypography.rowTitle`
- 替换 line 261 `.subheadline``AtlasTypography.body`
- 替换 line 266 `padding(16)``padding(AtlasSpacing.lg)`
- 替换 line 269 `cornerRadius: 16``AtlasRadius.lg`
- 替换 line 273 `cornerRadius: 16``AtlasRadius.lg`
### 2.5 AtlasDetailRow — 使用 Token + 添加 hover 效果
**文件**: `AtlasDesignSystem.swift`
**改动**:
- line 307 `spacing: 14``AtlasSpacing.lg`
- line 312 `frame(width: 36, height: 36)``frame(width: AtlasLayout.sidebarIconSize + 4, height: AtlasLayout.sidebarIconSize + 4)`
- line 321 `spacing: 6``AtlasSpacing.xs`
- line 338 `Spacer(minLength: 16)``Spacer(minLength: AtlasSpacing.lg)`
- line 343 `padding(16)``padding(AtlasSpacing.lg)`
- line 345-347 替换为 `.fill(AtlasColor.cardRaised)` 并使用 `AtlasRadius.lg`
- line 350 `Color.primary.opacity(0.06)``AtlasColor.border`
- **新增**: 在 `.overlay` 之后添加 `.atlasHover()`
### 2.6 AtlasStatusChip — 使用 Token
**文件**: `AtlasDesignSystem.swift`
**改动**:
- line 421 `.caption.weight(.semibold)``AtlasTypography.caption`
- line 422 `padding(.horizontal, 10)``padding(.horizontal, AtlasSpacing.md)`
- line 423 `padding(.vertical, 6)``padding(.vertical, AtlasSpacing.xs)`
### 2.7 AtlasEmptyState — 更有个性
**文件**: `AtlasDesignSystem.swift`
**改动**:
- 图标容器从 56x56 放大到 72x72
- 圆形背景改为渐变填充
- 添加外圈装饰环
- 增加整体 padding
```swift
public var body: some View {
VStack(spacing: AtlasSpacing.lg) {
ZStack {
//
Circle()
.strokeBorder(tone.border, lineWidth: 0.5)
.frame(width: 80, height: 80)
//
Circle()
.fill(
LinearGradient(
colors: [tone.softFill, tone.softFill.opacity(0.3)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 72, height: 72)
Image(systemName: systemImage)
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(tone.tint)
.accessibilityHidden(true)
}
VStack(spacing: AtlasSpacing.xs) {
Text(title)
.font(AtlasTypography.rowTitle)
Text(detail)
.font(AtlasTypography.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity)
.padding(AtlasSpacing.section)
.background(
RoundedRectangle(cornerRadius: AtlasRadius.xl, style: .continuous)
.fill(Color.primary.opacity(0.03))
)
.overlay(
RoundedRectangle(cornerRadius: AtlasRadius.xl, style: .continuous)
.strokeBorder(Color.primary.opacity(0.06), lineWidth: 1)
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(title))
.accessibilityValue(Text(detail))
}
```
### 2.8 AtlasLoadingState — 添加脉冲动画 + 使用 Token
**文件**: `AtlasDesignSystem.swift`
**改动**:
```swift
public struct AtlasLoadingState: View {
private let title: String
private let detail: String
private let progress: Double?
@State private var pulsePhase = false
public init(title: String, detail: String, progress: Double? = nil) {
self.title = title
self.detail = detail
self.progress = progress
}
public var body: some View {
VStack(alignment: .leading, spacing: AtlasSpacing.lg) {
HStack(spacing: AtlasSpacing.md) {
ProgressView()
.controlSize(.small)
.accessibilityHidden(true)
Text(title)
.font(AtlasTypography.rowTitle)
}
Text(detail)
.font(AtlasTypography.body)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let progress {
ProgressView(value: progress, total: 1)
.controlSize(.large)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AtlasSpacing.xl)
.background(
RoundedRectangle(cornerRadius: AtlasRadius.lg, style: .continuous)
.fill(Color.primary.opacity(pulsePhase ? 0.05 : 0.03))
)
.overlay(
RoundedRectangle(cornerRadius: AtlasRadius.lg, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
)
.onAppear {
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
pulsePhase = true
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(title))
.accessibilityValue(Text(progress.map { "\(Int(($0 * 100).rounded())) percent complete" } ?? detail))
.accessibilityHint(Text(detail))
}
}
```
### 2.9 删除旧的私有辅助函数
**文件**: `AtlasDesignSystem.swift`
删除 line 540-560 的旧 `cardBackground``cardBorder` 函数。它们被 `AtlasBrand.swift` 中的 `atlasCardBackground``atlasCardBorder` 替代。
**注意**: 确保所有引用点都已迁移到新函数后再删除。也删除旧的 `AtlasPalette` 枚举line 66-73因为它被 `AtlasColor` 替代。对 `AtlasScreen` 中引用 `AtlasPalette.canvasTop`/`canvasBottom` 的地方,改为 `AtlasColor.canvasTop`/`AtlasColor.canvasBottom`
---
## 3. App Shell 改进
### 3.1 侧边栏行视觉升级
**文件**: `Apps/AtlasApp/Sources/AtlasApp/AppShellView.swift`
**当前** (line 162-186): 标准 Label + VStack无视觉亮点。
**改为**:
```swift
private struct SidebarRouteRow: View {
let route: AtlasRoute
var body: some View {
Label {
VStack(alignment: .leading, spacing: AtlasSpacing.xxs) {
Text(route.title)
.font(AtlasTypography.rowTitle)
Text(route.subtitle)
.font(AtlasTypography.captionSmall)
.foregroundStyle(.secondary)
.lineLimit(2)
}
} icon: {
// Apple System Settings
ZStack {
RoundedRectangle(cornerRadius: AtlasRadius.sm, style: .continuous)
.fill(AtlasColor.brand.opacity(0.1))
.frame(width: AtlasLayout.sidebarIconSize, height: AtlasLayout.sidebarIconSize)
Image(systemName: route.systemImage)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AtlasColor.brand)
.accessibilityHidden(true)
}
}
.padding(.vertical, AtlasSpacing.sm)
.contentShape(Rectangle())
.listRowSeparator(.hidden)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("route.\(route.id)")
.accessibilityLabel("\(route.title). \(route.subtitle)")
.accessibilityHint(AtlasL10n.string("sidebar.route.hint", route.shortcutNumber))
}
}
```
### 3.2 工具栏图标增强
**文件**: `AppShellView.swift`
**当前** (line 28-61): 标准 toolbar 按钮,无视觉层次。
**改动**:
- 对所有 toolbar `Image(systemName:)` 添加 `.symbolRenderingMode(.hierarchical)`
- 给 TaskCenter 按钮添加活跃任务计数徽章
```swift
ToolbarItemGroup {
Button {
model.openTaskCenter()
} label: {
Label(AtlasL10n.string("toolbar.taskcenter"), systemImage: AtlasIcon.taskCenter)
.symbolRenderingMode(.hierarchical)
}
// ...
Button {
model.navigate(to: .permissions)
Task { await model.inspectPermissions() }
} label: {
Label(AtlasL10n.string("toolbar.permissions"), systemImage: AtlasIcon.permissions)
.symbolRenderingMode(.hierarchical)
}
// ...
Button {
model.navigate(to: .settings)
} label: {
Label(AtlasL10n.string("toolbar.settings"), systemImage: AtlasIcon.settings)
.symbolRenderingMode(.hierarchical)
}
// ...
}
```
### 3.3 详情页转场动画
**文件**: `AppShellView.swift`
**当前** (line 24): `detailView(for:)` 无转场效果。
**改动**: 在 detail 闭包中添加视图标识和转场:
```swift
} detail: {
detailView(for: model.selection ?? .overview)
.id(model.selection) //
.transition(.opacity)
.searchable(...)
.toolbar { ... }
.animation(AtlasMotion.slow, value: model.selection)
}
```
---
## 4. Feature Screen 改进
### 4.1 OverviewFeatureView — 英雄指标 + 共享列定义
**文件**: `Packages/AtlasFeaturesOverview/Sources/AtlasFeaturesOverview/OverviewFeatureView.swift`
**改动 1** — 英雄指标差异化 (line 31-53):
将"可回收空间"指标升级为 `.prominent` 高程,其余保持 `.raised`
```swift
LazyVGrid(columns: AtlasLayout.metricColumns, spacing: AtlasSpacing.lg) {
AtlasMetricCard(
title: AtlasL10n.string("overview.metric.reclaimable.title"),
value: AtlasFormatters.byteCount(snapshot.reclaimableSpaceBytes),
detail: AtlasL10n.string("overview.metric.reclaimable.detail"),
tone: .success,
systemImage: "sparkles",
elevation: .prominent //
)
AtlasMetricCard(
title: AtlasL10n.string("overview.metric.findings.title"),
value: "\(snapshot.findings.count)",
detail: AtlasL10n.string("overview.metric.findings.detail"),
tone: .neutral,
systemImage: "line.3.horizontal.decrease.circle"
// elevation .raised
)
AtlasMetricCard(
title: AtlasL10n.string("overview.metric.permissions.title"),
value: "\(grantedPermissionCount)/\(snapshot.permissions.count)",
detail: grantedPermissionCount == snapshot.permissions.count
? AtlasL10n.string("overview.metric.permissions.ready")
: AtlasL10n.string("overview.metric.permissions.limited"),
tone: grantedPermissionCount == snapshot.permissions.count ? .success : .warning,
systemImage: "lock.shield"
// elevation .raised
)
}
```
**改动 2** — 删除私有 `columns` 属性 (line 185-191),全部替换为 `AtlasLayout.metricColumns`
**改动 3** — 所有 `spacing: 16` 替换为 `AtlasSpacing.lg`,所有 `spacing: 12` 替换为 `AtlasSpacing.md`
### 4.2 SmartCleanFeatureView — 解决双 CTA 竞争
**文件**: `Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/SmartCleanFeatureView.swift`
**核心问题**: line 85 和 line 112 同时使用 `.borderedProminent`,导致两个主要按钮视觉权重相同。
**改动**: 根据当前状态动态切换按钮层级。
```swift
HStack(spacing: AtlasSpacing.md) {
// Run Scan
Button(action: onStartScan) {
Label(AtlasL10n.string("smartclean.action.runScan"), systemImage: "sparkles")
}
.buttonStyle(plan.items.isEmpty ? .atlasPrimary : .atlasSecondary)
.disabled(isScanning || isExecutingPlan)
.keyboardShortcut(plan.items.isEmpty ? .defaultAction : KeyEquivalent("s"), modifiers: plan.items.isEmpty ? [] : [.command, .option])
.accessibilityIdentifier("smartclean.runScan")
.accessibilityHint(AtlasL10n.string("smartclean.action.runScan.hint"))
// Refresh Preview
Button(action: onRefreshPreview) {
Label(AtlasL10n.string("smartclean.action.refreshPreview"), systemImage: "arrow.clockwise")
}
.buttonStyle(.atlasGhost)
.disabled(isScanning || isExecutingPlan)
.accessibilityIdentifier("smartclean.refreshPreview")
.accessibilityHint(AtlasL10n.string("smartclean.action.refreshPreview.hint"))
Spacer()
// Execute plan
Button(action: onExecutePlan) {
Label(AtlasL10n.string("smartclean.action.execute"), systemImage: "play.fill")
}
.buttonStyle(plan.items.isEmpty ? .atlasSecondary : .atlasPrimary)
.disabled(isScanning || isExecutingPlan || plan.items.isEmpty)
.keyboardShortcut(plan.items.isEmpty ? nil : .defaultAction)
.accessibilityIdentifier("smartclean.executePreview")
.accessibilityHint(AtlasL10n.string("smartclean.action.execute.hint"))
}
```
> **注意**: `.keyboardShortcut` 条件赋值在 SwiftUI 中需要用 `if/else` 包裹两个完整的 `Button`,不能直接三元。保持现有的 `Group { if ... else ... }` 结构,但把内部的 `.buttonStyle` 改为条件化。
**实际可编译方案**(考虑 SwiftUI 限制):
```swift
HStack(spacing: AtlasSpacing.md) {
Group {
if plan.items.isEmpty {
Button(action: onStartScan) {
Label(AtlasL10n.string("smartclean.action.runScan"), systemImage: "sparkles")
}
.keyboardShortcut(.defaultAction)
} else {
Button(action: onStartScan) {
Label(AtlasL10n.string("smartclean.action.runScan"), systemImage: "sparkles")
}
}
}
.buttonStyle(plan.items.isEmpty ? .borderedProminent : .bordered) //
.controlSize(.large)
.disabled(isScanning || isExecutingPlan)
.accessibilityIdentifier("smartclean.runScan")
Button(action: onRefreshPreview) {
Label(AtlasL10n.string("smartclean.action.refreshPreview"), systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.controlSize(.large)
.disabled(isScanning || isExecutingPlan)
.accessibilityIdentifier("smartclean.refreshPreview")
Spacer()
Group {
if !plan.items.isEmpty {
Button(action: onExecutePlan) {
Label(AtlasL10n.string("smartclean.action.execute"), systemImage: "play.fill")
}
.keyboardShortcut(.defaultAction)
} else {
Button(action: onExecutePlan) {
Label(AtlasL10n.string("smartclean.action.execute"), systemImage: "play.fill")
}
}
}
.buttonStyle(!plan.items.isEmpty ? .borderedProminent : .bordered) //
.controlSize(.large)
.disabled(isScanning || isExecutingPlan || plan.items.isEmpty)
.accessibilityIdentifier("smartclean.executePreview")
}
```
**额外改动**: 删除私有 `columns` (line 231-237),替换为 `AtlasLayout.metricColumns`。所有 `spacing: 16``AtlasSpacing.lg`
### 4.3 AppsFeatureView — 行内按钮水平化
**文件**: `Packages/AtlasFeaturesApps/Sources/AtlasFeaturesApps/AppsFeatureView.swift`
**当前问题**: line 181-208 的 trailing 区域是 VStack包含 byteCount + chip + HStack(两个按钮),导致每行非常高。
**改动**: 将 trailing 重构为更紧凑的布局:
```swift
// line 181 trailing
VStack(alignment: .trailing, spacing: AtlasSpacing.sm) {
HStack(spacing: AtlasSpacing.sm) {
AtlasStatusChip(
AtlasL10n.string("apps.list.row.leftovers", app.leftoverItems),
tone: app.leftoverItems > 0 ? .warning : .success
)
Text(AtlasFormatters.byteCount(app.bytes))
.font(AtlasTypography.label)
.foregroundStyle(.secondary)
}
HStack(spacing: AtlasSpacing.sm) {
Button(activePreviewAppID == app.id ? AtlasL10n.string("apps.preview.running") : AtlasL10n.string("apps.preview.action")) {
onPreviewAppUninstall(app.id)
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(isRunning)
Button(activeUninstallAppID == app.id ? AtlasL10n.string("apps.uninstall.running") : AtlasL10n.string("apps.uninstall.action")) {
onExecuteAppUninstall(app.id)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(isRunning)
}
}
```
**额外改动**: 删除私有 `columns`,替换为 `AtlasLayout.metricColumns`
### 4.4 SettingsFeatureView — 轻量化设置页
**文件**: `Packages/AtlasFeaturesSettings/Sources/AtlasFeaturesSettings/SettingsFeatureView.swift`
**当前问题**: 5 个 `AtlasInfoCard` 连续堆叠,视觉过重。
**改动**:
1. **General 区域** (line 35): 保留 `AtlasInfoCard`,不变
2. **Exclusions 区域** (line 118): 保留,不变
3. **Trust & Transparency** (line 143): 保留,不变
4. **Acknowledgement** (line 177): 改为 `DisclosureGroup`
5. **Notices** (line 187): 改为 `DisclosureGroup`
```swift
// line 177-195 AtlasInfoCard
AtlasInfoCard(
title: AtlasL10n.string("settings.legal.title"), // ""
subtitle: AtlasL10n.string("settings.legal.subtitle")
) {
VStack(alignment: .leading, spacing: AtlasSpacing.md) {
DisclosureGroup(AtlasL10n.string("settings.acknowledgement.title")) {
Text(settings.acknowledgementText)
.font(AtlasTypography.body)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.padding(.top, AtlasSpacing.sm)
}
Divider()
DisclosureGroup(AtlasL10n.string("settings.notices.title")) {
Text(settings.thirdPartyNoticesText)
.font(AtlasTypography.body)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.padding(.top, AtlasSpacing.sm)
}
}
}
```
> **注意**: 需要在 Localizable.strings 中新增 `settings.legal.title` 和 `settings.legal.subtitle` 两个 key。中文值分别为 "法律信息" 和 "致谢与第三方声明"。英文值分别为 "Legal" 和 "Acknowledgements and third-party notices"。
### 4.5 PermissionsFeatureView — 添加授权入口
**文件**: `Packages/AtlasFeaturesPermissions/Sources/AtlasFeaturesPermissions/PermissionsFeatureView.swift`
**当前问题**: 未授权的权限行只显示 "Needed Later" chip无操作入口。
**改动**: 在 line 109-113 的 trailing 区域添加条件按钮:
```swift
// line 109 trailing
VStack(alignment: .trailing, spacing: AtlasSpacing.sm) {
AtlasStatusChip(
state.isGranted ? AtlasL10n.string("common.granted") : AtlasL10n.string("common.neededLater"),
tone: state.isGranted ? .success : .warning
)
if !state.isGranted {
Button(AtlasL10n.string("permissions.grant.action")) {
openSystemPreferences(for: state.kind)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
```
添加跳转函数:
```swift
private func openSystemPreferences(for kind: PermissionKind) {
let urlString: String
switch kind {
case .fullDiskAccess:
urlString = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"
case .accessibility:
urlString = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
case .notifications:
urlString = "x-apple.systempreferences:com.apple.preference.security?Privacy_Notifications"
}
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
}
```
**额外改动**: 删除私有 `columns`,替换为 `AtlasLayout.metricColumns`
### 4.6 HistoryFeatureView — 使用 Token
**文件**: `Packages/AtlasFeaturesHistory/Sources/AtlasFeaturesHistory/HistoryFeatureView.swift`
**改动**: 仅 Token 替换,无结构性变化。
- 所有 `spacing: 12``AtlasSpacing.md`
- 所有 `spacing: 10``AtlasSpacing.md`
### 4.7 TaskCenterView — 使用 Token + 添加分隔线
**文件**: `Apps/AtlasApp/Sources/AtlasApp/TaskCenterView.swift`
**改动**:
- line 11 `spacing: 18``AtlasSpacing.xl`
- line 12 `spacing: 8``AtlasSpacing.sm`
- line 14 `.title2.weight(.semibold)``AtlasTypography.sectionTitle`
- line 17 `.subheadline``AtlasTypography.body`
- line 38 `spacing: 10``AtlasSpacing.md`
- line 62 `padding(20)``padding(AtlasSpacing.xl)`
- 在标题和 callout 之间添加 `Divider()`
---
## 5. 全局搜索替换清单
以下是可以安全地在所有 Feature View 文件中批量替换的模式:
| 搜索 | 替换 | 范围 |
|------|------|------|
| `spacing: 16)` (在 LazyVGrid/VStack 中) | `spacing: AtlasSpacing.lg)` | 所有 Feature View |
| `spacing: 12)` (在 VStack 中) | `spacing: AtlasSpacing.md)` | 所有 Feature View |
| `spacing: 8)` (在 VStack 中) | `spacing: AtlasSpacing.sm)` | 所有 Feature View |
| `spacing: 10)` | `spacing: AtlasSpacing.md)` | TaskCenterView |
| `.font(.subheadline)` (非 `.weight`) | `.font(AtlasTypography.body)` | 所有文件 |
| `.font(.subheadline.weight(.semibold))` | `.font(AtlasTypography.label)` | 所有文件 |
| `.font(.headline)` | `.font(AtlasTypography.rowTitle)` | 所有文件(非 icon 处) |
| `.font(.caption.weight(.semibold))` | `.font(AtlasTypography.caption)` | 所有文件 |
| 私有 `columns` 属性 | `AtlasLayout.metricColumns` | Overview/SmartClean/Apps/Permissions |
---
## 6. 新增本地化字符串
`zh-Hans.lproj/Localizable.strings``en.lproj/Localizable.strings` 中添加:
| Key | 中文 | English |
|-----|------|---------|
| `settings.legal.title` | 法律信息 | Legal |
| `settings.legal.subtitle` | 致谢与第三方声明 | Acknowledgements and third-party notices |
| `permissions.grant.action` | 前往授权 | Grant Access |
---
## 7. 实施顺序
### Phase 1 — 设计系统核心迁移
1.`AtlasDesignSystem.swift` 中删除 `AtlasPalette`,所有引用改为 `AtlasColor.*`
2. 删除旧的 `cardBackground`/`cardBorder` 函数,所有引用改为 `atlasCardBackground`/`atlasCardBorder`
3. 用 Token 重写 `AtlasScreen`§2.1
4. 用 Token 重写 `AtlasMetricCard`§2.2
5. 用 Token 重写 `AtlasInfoCard`§2.3
6. 用 Token 重写 `AtlasCallout`§2.4
7. 用 Token 重写 `AtlasDetailRow`§2.5
8. 用 Token 重写 `AtlasStatusChip`§2.6
9. 用 Token 重写 `AtlasEmptyState`§2.7
10. 用 Token 重写 `AtlasLoadingState`§2.8
### Phase 2 — App Shell
11. 侧边栏行升级§3.1
12. 工具栏图标增强§3.2
13. 详情页转场动画§3.3
### Phase 3 — Feature Screen 优化
14. Overview 英雄指标§4.1
15. SmartClean 双 CTA 修复§4.2
16. Apps 行内按钮§4.3
17. Settings 轻量化§4.4
18. Permissions 授权入口§4.5
19. History Token 替换§4.6
20. TaskCenter Token 替换§4.7
### Phase 4 — 全局清理
21. 批量替换 spacing/font 硬编码§5
22. 新增本地化字符串§6
23. 编译验证 + 全量 UI 测试
---
## 8. 文件清单
| 文件 | 改动类型 |
|------|---------|
| `Packages/AtlasDesignSystem/Sources/AtlasDesignSystem/AtlasBrand.swift` | ✅ 已创建 |
| `Packages/AtlasDesignSystem/Sources/AtlasDesignSystem/AtlasDesignSystem.swift` | 重构 |
| `Apps/AtlasApp/Sources/AtlasApp/AppShellView.swift` | 修改 |
| `Apps/AtlasApp/Sources/AtlasApp/TaskCenterView.swift` | 修改 |
| `Packages/AtlasFeaturesOverview/Sources/.../OverviewFeatureView.swift` | 修改 |
| `Packages/AtlasFeaturesSmartClean/Sources/.../SmartCleanFeatureView.swift` | 修改 |
| `Packages/AtlasFeaturesApps/Sources/.../AppsFeatureView.swift` | 修改 |
| `Packages/AtlasFeaturesHistory/Sources/.../HistoryFeatureView.swift` | 修改 |
| `Packages/AtlasFeaturesPermissions/Sources/.../PermissionsFeatureView.swift` | 修改 |
| `Packages/AtlasFeaturesSettings/Sources/.../SettingsFeatureView.swift` | 修改 |
| `Packages/AtlasDomain/Sources/.../Resources/zh-Hans.lproj/Localizable.strings` | 新增 3 个 key |
| `Packages/AtlasDomain/Sources/.../Resources/en.lproj/Localizable.strings` | 新增 3 个 key |

37
Docs/ErrorCodes.md Normal file
View File

@@ -0,0 +1,37 @@
# Error Codes
## Principles
- Use stable machine-readable codes.
- Map each code to a user-facing title, body, and next step.
- Separate recoverable conditions from fatal conditions.
## Registry
- `permission_denied`
- `permission_limited`
- `admin_required`
- `path_protected`
- `path_not_found`
- `action_not_allowed`
- `helper_unavailable`
- `execution_unavailable`
- `worker_crashed`
- `protocol_mismatch`
- `partial_failure`
- `task_cancelled`
- `restore_expired`
- `restore_conflict`
- `idempotency_conflict`
## Mapping Rules
- Use inline presentation for row-level issues.
- Use banners for limited access and incomplete results.
- Use sheets for permission and destructive confirmation flows.
- Use result pages for partial success, cancellation, and recovery outcomes.
## Format
- User-visible format recommendation: `ATLAS-<DOMAIN>-<NUMBER>`
- Example: `ATLAS-EXEC-004`

View File

@@ -0,0 +1,125 @@
# Beta Acceptance Checklist
## Goal
Provide a release-facing checklist for deciding whether Atlas for Mac is ready to enter or exit the beta phase.
## Scope
This checklist applies to the frozen MVP modules:
- `Overview`
- `Smart Clean`
- `Apps`
- `History`
- `Recovery`
- `Permissions`
- `Settings`
## Entry Criteria
Before starting beta acceptance, confirm all of the following:
- [ ] `swift test --package-path Packages` passes
- [ ] `swift test --package-path Apps` passes
- [ ] `./scripts/atlas/full-acceptance.sh` passes
- [ ] `dist/native/Atlas for Mac.app` is freshly built
- [ ] `dist/native/Atlas-for-Mac.dmg` is freshly built
- [ ] `dist/native/Atlas-for-Mac.pkg` is freshly built
- [ ] `Docs/Execution/MVP-Acceptance-Matrix.md` is up to date
- [ ] Known blockers are documented in `Docs/RISKS.md`
## Build & Artifact Checks
- [ ] App bundle opens from `dist/native/Atlas for Mac.app`
- [ ] DMG mounts successfully
- [ ] DMG contains `Atlas for Mac.app`
- [ ] DMG contains `Applications` shortcut
- [ ] PKG expands with `pkgutil --expand`
- [ ] SHA256 file exists and matches current artifacts
- [ ] Embedded helper exists at `Contents/Helpers/AtlasPrivilegedHelper`
- [ ] Embedded XPC service exists at `Contents/XPCServices/AtlasWorkerXPC.xpc`
- [ ] `./scripts/atlas/verify-bundle-contents.sh` passes
## Functional Beta Checks
### Overview
- [ ] App launches to a visible primary window
- [ ] Sidebar navigation shows all frozen MVP routes
- [ ] Overview displays health summary cards
- [ ] Overview displays reclaimable space summary
- [ ] Overview displays recent activity without crash
### Smart Clean
- [ ] User can open `Smart Clean`
- [ ] User can run scan
- [ ] User can refresh preview
- [ ] User can execute preview
- [ ] Execution updates `History`
- [ ] Execution creates `Recovery` entries for recoverable items
### Apps
- [ ] User can open `Apps`
- [ ] User can refresh app footprints
- [ ] User can preview uninstall
- [ ] User can execute uninstall
- [ ] Uninstall updates `History`
- [ ] Uninstall creates `Recovery` entry
### History / Recovery
- [ ] History shows recent task runs
- [ ] Recovery shows recoverable items after destructive flows
- [ ] User can restore a recovery item
- [ ] Restored item disappears from recovery list
### Permissions
- [ ] Permissions screen opens without crash
- [ ] User can refresh permission status
- [ ] Permission cards render all expected states
### Settings
- [ ] Settings screen opens without crash
- [ ] User can change recovery retention
- [ ] User can toggle notifications
- [ ] Settings persist after relaunch
- [ ] Acknowledgement copy is visible
- [ ] Third-party notices copy is visible
## Install Checks
### DMG Path
- [ ] DMG install validation passes with `KEEP_INSTALLED_APP=1 ./scripts/atlas/verify-dmg-install.sh`
- [ ] Installed app exists at `~/Applications/Atlas for Mac.app`
- [ ] Installed app launches successfully
### PKG Path
- [ ] PKG builds successfully
- [ ] PKG expands successfully
- [ ] PKG signing status is known (`Developer ID signed`, `ad hoc signed`, or `unsigned`)
## Native UI Automation Checks
- [ ] `./scripts/atlas/ui-automation-preflight.sh` passes on the validating machine
- [ ] `./scripts/atlas/run-ui-automation.sh` passes
- [ ] UI smoke confirms sidebar routes and primary Smart Clean / Settings controls
## Beta Exit Criteria
Mark beta candidate as ready only if all are true:
- [ ] No P0 functional blocker remains open
- [ ] No P0 crash-on-launch or crash-on-primary-workflow remains open
- [ ] All frozen MVP workflows complete end to end
- [ ] Install path has been validated on the current candidate build
- [ ] Known unsupported areas are explicitly documented
- [ ] Release-signing status is explicit
## Sign-off
| Role | Name | Status | Notes |
|------|------|--------|-------|
| `QA Agent` | | Pending | |
| `Mac App Agent` | | Pending | |
| `System Agent` | | Pending | |
| `Release Agent` | | Pending | |
| `Product Agent` | | Pending | |

View File

@@ -0,0 +1,95 @@
# Beta Gate Review
## Gate
- `Beta Candidate`
## Review Date
- `2026-03-07`
## Scope Reviewed
- `Overview`
- `Smart Clean`
- `Apps`
- `History`
- `Recovery`
- `Permissions`
- `Settings`
- native packaging and install flow
- native UI smoke coverage
- Chinese-first app-language switching and localized shell copy
## Readiness Checklist
- [x] Required P0 tasks complete
- [x] Docs updated
- [x] Risks reviewed
- [x] Open questions below threshold for internal beta
- [x] Next-stage inputs available
## Evidence Reviewed
- `Docs/Execution/MVP-Acceptance-Matrix.md`
- `Docs/Execution/Beta-Acceptance-Checklist.md`
- `Docs/Execution/Manual-Test-SOP.md`
- `scripts/atlas/full-acceptance.sh`
- `scripts/atlas/run-ui-automation.sh`
- `scripts/atlas/signing-preflight.sh`
## Automated Validation Summary
- `swift test --package-path Packages` — pass
- `swift test --package-path Apps` — pass
- `./scripts/atlas/full-acceptance.sh` — pass
- `./scripts/atlas/run-ui-automation.sh` — pass on a machine with Accessibility trust (`4` UI tests, including language switching)
- `./scripts/atlas/verify-dmg-install.sh` — pass
- `./scripts/atlas/verify-app-launch.sh` — pass
- `./scripts/atlas/package-native.sh` — pass
## Beta Assessment
### Product Functionality
- Core frozen MVP workflows are complete end to end.
- Recovery-first behavior is visible in both Smart Clean and Apps flows.
- Settings and permission refresh flows are functional.
- The app now defaults to `简体中文` and supports switching to `English` through persisted settings.
### Packaging and Installability
- `.app`, `.zip`, `.dmg`, and `.pkg` artifacts are produced successfully.
- Native packaging has been rerun successfully after the localization work.
- DMG installation into `~/Applications/Atlas for Mac.app` is validated.
- Installed app launch is validated.
### Test Coverage
- Shared package tests are green.
- App-layer tests are green.
- Native UI smoke is green on a machine with Accessibility trust.
- Manual beta checklist and SOP are now present for human validation.
## Blockers
- Public signed/notarized distribution is still blocked by missing Apple release credentials:
- `Developer ID Application`
- `Developer ID Installer`
- `ATLAS_NOTARY_PROFILE`
## Decision
- `Pass with Conditions`
## Conditions
- Internal beta / trusted-user beta can proceed with the current ad hoc-signed local artifacts.
- Public beta or broad external distribution must wait until signing and notarization credentials are available and the release packaging path is re-run.
## Follow-up Actions
- Obtain Apple signing and notarization credentials.
- Re-run `./scripts/atlas/signing-preflight.sh`.
- Re-run `./scripts/atlas/package-native.sh` with signing/notarization environment variables.
- Validate signed DMG / PKG install behavior on a clean machine.

View File

@@ -0,0 +1,69 @@
# Current Engineering Status — 2026-03-07
## Overall Status
- Product state: `Frozen MVP complete`
- Experience state: `Post-MVP polish pass complete`
- Localization state: `Chinese-first bilingual framework complete`
- Packaging state: `Unsigned native artifacts refreshed`
- Release state: `Internal beta ready, public release still gated by signing/notarization credentials`
## What Is Complete
### Frozen MVP Workflows
- `Overview`
- `Smart Clean`
- `Apps`
- `History`
- `Recovery`
- `Permissions`
- `Settings`
### Productization and Polish
- Shared SwiftUI design-system uplift landed
- Empty/loading/error/trust states were strengthened across the MVP shell
- Keyboard navigation and command shortcuts landed for the main shell
- Accessibility semantics and stable UI-automation identifiers landed
- Native UI smoke is green on a trusted local machine
### Localization
- Shared localization framework added across the Swift package graph
- Supported app languages: `简体中文`, `English`
- Default app language: `简体中文`
- User language preference now persists through `AtlasSettings`
- Worker-generated summaries and settings-driven copy now follow the selected app language
### Packaging
- Native `.app`, `.zip`, `.dmg`, and `.pkg` artifacts build successfully
- Latest local packaging rerun completed after localization work
- Current artifact directory: `dist/native/`
## Validation Snapshot
- `swift build --package-path Packages` — pass
- `swift build --package-path Apps` — pass
- `swift test --package-path Packages` — pass (`23` tests)
- `swift test --package-path Apps` — pass (`8` tests)
- `./scripts/atlas/run-ui-automation.sh` — environment-conditional on the current machine; standalone repro confirms current timeout is machine-level, not Atlas-specific
- `./scripts/atlas/package-native.sh` — pass
- `./scripts/atlas/full-acceptance.sh` — pass with documented UI-automation environment condition
## Current Blockers
- `Smart Clean` execute now supports a real Trash-based path for structured safe targets, and those targets can be physically restored. Full disk-backed coverage is still incomplete, and unsupported targets fail closed. See `Docs/Execution/Execution-Chain-Audit-2026-03-09.md`.
- Silent fallback from XPC to the scaffold worker can mask execution-path failures in user-facing flows. See `Docs/Execution/Execution-Chain-Audit-2026-03-09.md`.
- Public signed distribution is still blocked by missing Apple release credentials:
- `Developer ID Application`
- `Developer ID Installer`
- `ATLAS_NOTARY_PROFILE`
## Recommended Next Steps
1. Run a dedicated manual localization QA pass for Chinese and English on a clean machine.
2. Reinstall the latest packaged app and verify first-launch language behavior with a fresh state file.
3. Re-check macOS UI automation on a clean/trusted machine if native XCUITest evidence is needed without the current environment condition.
4. If release-ready output is needed, obtain signing/notarization credentials and rerun native packaging.

View File

@@ -0,0 +1,146 @@
# Execution Chain Audit — 2026-03-09
## Scope
This audit reviews the user-visible execution path for:
- `Smart Clean` scan
- `Smart Clean` execute
- `Apps` uninstall preview / execute
- `Recovery` restore
- worker selection and fallback behavior
## Summary
Atlas currently ships a mixed execution model:
- `Smart Clean` scan is backed by a real upstream dry-run adapter.
- `Apps` inventory is backed by a real local inventory adapter.
- `App uninstall` can invoke the packaged helper for the main app bundle path.
- `Smart Clean` execute now supports a real Trash-based execution path for a safe subset of structured user-owned cleanup targets, but broader execution coverage is still incomplete.
- `Restore` is currently state rehydration, not physical file restoration.
- Worker submission can silently fall back from XPC to the scaffold worker, which makes execution capability look stronger than it really is.
## End-to-End Chain
### 1. UI and App Model
- `Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift:190` starts Smart Clean scan through `workspaceController.startScan()`.
- `Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift:230` runs the current Smart Clean plan through `workspaceController.executePlan(planID:)`.
- `Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift:245` immediately refreshes the plan after execution, so the UI shows the remaining plan rather than the just-executed plan.
### 2. Application Layer
- `Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift:281` maps scan requests into structured worker requests.
- `Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift:319` maps plan execution into a worker request and trusts the returned snapshot/events.
- The application layer does not distinguish between “state-only execution” and “real filesystem side effects”.
### 3. Worker Selection
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasXPCTransport.swift:272` defines `AtlasPreferredWorkerService`.
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasXPCTransport.swift:288` submits to XPC first.
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasXPCTransport.swift:291` silently falls back to `AtlasScaffoldWorkerService` on any XPC error.
## Real vs Scaffold Classification
### Real or Mostly Real
#### Smart Clean scan
- `XPC/AtlasWorkerXPC/Sources/AtlasWorkerXPC/main.swift:5` wires `MoleSmartCleanAdapter` into the worker.
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift:319` uses the configured `smartCleanScanProvider` when available.
- `Packages/AtlasCoreAdapters/Sources/AtlasCoreAdapters/MoleSmartCleanAdapter.swift:12` runs the upstream `bin/clean.sh --dry-run` flow and parses findings.
Result:
- The scan result can reflect the actual machine state.
#### Apps list
- `XPC/AtlasWorkerXPC/Sources/AtlasWorkerXPC/main.swift:8` wires `MacAppsInventoryAdapter` into the worker.
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift:495` refreshes app inventory from the real adapter when available.
Result:
- App footprint listing is grounded in real local inventory.
#### App uninstall bundle removal
- `XPC/AtlasWorkerXPC/Sources/AtlasWorkerXPC/main.swift:9` wires the helper client into the worker.
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift:533` checks whether the bundle path exists.
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift:552` invokes the helper with `AtlasHelperAction(kind: .trashItems, targetPath: app.bundlePath)`.
- `Helpers/AtlasPrivilegedHelper/Sources/AtlasPrivilegedHelperCore/HelperActionExecutor.swift:35` supports `trashItems`.
Result:
- The main `.app` bundle path can be moved through the helper boundary.
### Mixed Real / Incomplete
#### Smart Clean execute
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift:383` begins Smart Clean plan execution.
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift:414` removes selected findings from the in-memory snapshot.
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift:416` recalculates reclaimable space from the remaining findings only.
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift:417` rebuilds the current plan from the remaining findings.
What is now real:
- Structured scan findings can carry concrete `targetPaths`.
- Safe user-owned targets are moved to Trash during execution.
- `scan -> execute -> rescan` is now covered for a file-backed safe target path.
What is still missing:
- Broad execution coverage for all Smart Clean categories.
- A helper-backed strategy for protected or privileged Smart Clean targets.
- A physical restoration flow that mirrors the new real Trash-based execution path.
User-visible consequence:
- Safe structured targets can now disappear on the next real scan. Unsupported targets fail closed instead of pretending to be cleaned.
#### Restore
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift:445` restores items by re-inserting stored payloads into Atlas state.
- No physical restore operation is performed against the filesystem.
User-visible consequence:
- Recovery currently restores Atlass structured workspace model, not a verified on-disk artifact.
## Protocol and Domain Gaps
### Current protocol shape
- `Packages/AtlasProtocol/Sources/AtlasProtocol/AtlasProtocol.swift:92` only allows helper actions such as `trashItems` and `removeLaunchService`.
- `Packages/AtlasDomain/Sources/AtlasDomain/AtlasDomain.swift:109` defines `ActionItem.Kind` values such as `removeCache`, `removeApp`, `archiveFile`, and `inspectPermission`.
Gap:
- `ActionItem.Kind` communicates user intent, but it does not carry the executable path set or helper-ready structured target information required to make Smart Clean execution real.
## Risks
### R-011 Smart Clean Execution Trust Gap
- Severity: `High`
- Area: `Execution / UX / Trust`
- Risk: The UI presents Smart Clean execution as if it performs disk cleanup, but the current worker only mutates Atlas state for Smart Clean items.
- User impact: Users can believe cleanup succeeded even when the next scan rediscovers the same disk usage.
- Recommended action: Make execution capability explicit and block release-facing trust claims until Smart Clean execution is backed by real side effects.
### R-012 Silent Fallback Masks Capability Loss
- Severity: `High`
- Area: `System / Execution`
- Risk: Silent fallback from XPC to the scaffold worker can hide worker/XPC failures and blur the line between real execution and fallback behavior.
- User impact: Local execution may look successful even when the primary worker path is unavailable.
- Recommended action: Remove or narrow silent fallback in user-facing execution paths and surface a concrete error when real execution infrastructure is unavailable.
## Recommended Fix Order
1. Remove silent fallback for release-facing execution flows or gate it behind an explicit development-only mode.
2. Introduce executable structured targets for Smart Clean action items so the worker can perform real side effects.
3. Route Smart Clean destructive actions through the helper boundary where privilege or safety validation is required.
4. Add `scan -> execute -> rescan` contract coverage proving real disk impact.
5. Separate “logical recovery in Atlas state” from “physical file restoration” in both UI copy and implementation.
## Acceptance Criteria for the Follow-up Fix
- Running Smart Clean on real findings reduces those findings on a subsequent real scan.
- If the worker/helper cannot perform the action, the user sees a clear failure rather than a silent fallback success.
- History records only claim completion when the filesystem side effect actually happened.
- Recovery messaging distinguishes between physical restoration and model restoration until both are truly implemented.

View File

@@ -0,0 +1,58 @@
# MVP Acceptance Matrix
## Goal
Track the frozen Atlas for Mac MVP against user-visible acceptance criteria, automated coverage, and manual verification needs.
## Scope
- `Overview`
- `Smart Clean`
- `Apps`
- `History`
- `Recovery`
- `Permissions`
- `Settings`
## Matrix
| Module | Acceptance Criterion | Automated Coverage | Manual Verification | Status |
|--------|----------------------|--------------------|---------------------|--------|
| `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 |
| `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 |
| `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 |
| `Settings` | User can update recovery retention and notifications and persist them | `AtlasApplicationTests`, `AtlasAppTests` | Relaunch app and verify settings remain persisted | covered |
| Packaging | App produces `.zip`, `.dmg`, `.pkg` | `scripts/atlas/package-native.sh` | Inspect output artifacts | covered |
| Installation | User can install from DMG into Applications | `scripts/atlas/verify-dmg-install.sh` | Open DMG and drag app to Applications | covered |
| Signed Distribution | Installer is signed and notarized | `scripts/atlas/signing-preflight.sh` + packaging with credentials | Verify Gatekeeper-friendly install on a clean machine | blocked-by-credentials |
| UI smoke | Sidebar and primary controls are automatable through native UI tests | `scripts/atlas/run-ui-automation.sh` | Run on a trusted local machine or CI agent with automation enabled | covered |
## Required Manual Scenarios
### Scenario 1: Smart Clean end-to-end
1. Launch the app.
2. Open `Smart Clean`.
3. Run scan.
4. Refresh preview.
5. Execute preview.
6. Confirm `History` and `Recovery` update.
### Scenario 2: App uninstall end-to-end
1. Open `Apps`.
2. Refresh app footprints.
3. Preview uninstall for one app.
4. Execute uninstall.
5. Confirm the item appears in `History` / `Recovery`.
### 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.
## Current Blocking Item
- Signed/notarized public distribution remains blocked by missing Apple Developer release credentials.

View File

@@ -0,0 +1,196 @@
# Manual Test SOP
## Goal
Provide a repeatable manual test procedure for Atlas for Mac beta validation on a real macOS machine.
## Intended Tester
- Internal QA
- Product owner
- Developer performing release candidate validation
## Test Environment Preparation
### Machine Requirements
- macOS 14 or newer
- Ability to grant Accessibility permission to the terminal and Xcode when UI automation is used
- Access to `dist/native` artifacts from the current candidate build
### Clean Start Checklist
- Quit all running Atlas for Mac instances
- Remove old local install if testing a clean DMG install:
- `~/Applications/Atlas for Mac.app`
- Clear temporary validation folders if needed:
- `.build/atlas-native/`
- `.build/atlas-dmg-verify/`
- Ensure the build under test is freshly produced
## Preflight Commands
Run these before starting manual validation:
```bash
swift test --package-path Packages
swift test --package-path Apps
./scripts/atlas/package-native.sh
./scripts/atlas/verify-bundle-contents.sh
KEEP_INSTALLED_APP=1 ./scripts/atlas/verify-dmg-install.sh
./scripts/atlas/verify-app-launch.sh
./scripts/atlas/ui-automation-preflight.sh || true
./scripts/atlas/run-ui-automation.sh || true
```
## Manual Test Logging Rules
For every issue found, record:
- build timestamp
- artifact used (`.app`, `.dmg`, `.pkg`)
- screen name
- exact steps
- expected result
- actual result
- screenshot or screen recording if possible
- whether it blocks the beta exit criteria
## Scenario SOP
### SOP-01 Launch and Navigation
1. Open `Atlas for Mac.app`.
2. Confirm the main window appears.
3. Confirm sidebar routes are visible:
- `Overview`
- `Smart Clean`
- `Apps`
- `History`
- `Permissions`
- `Settings`
4. Switch through all routes once.
5. Confirm no crash or blank screen occurs.
**Pass condition**
- All routes render and navigation remains responsive.
### SOP-02 Smart Clean Workflow
Reference: `Docs/Execution/Smart-Clean-Manual-Verification-2026-03-09.md` for disposable local fixtures and rescan/restore verification.
1. Open `Smart Clean`.
2. Click `Run Scan`.
3. Wait for summary and progress to update.
4. Click `Refresh Preview`.
5. Review `Safe`, `Review`, and `Advanced` sections.
6. Click `Execute Preview`.
7. Open `History`.
8. Confirm a new execution record exists.
9. Confirm `Recovery` shows new entries.
**Pass condition**
- Scan, preview, and execute complete without crash and leave history/recovery evidence.
### SOP-03 Apps Workflow
1. Open `Apps`.
2. Click `Refresh App Footprints`.
3. Pick one app and click `Preview`.
4. Review the uninstall preview.
5. Click `Uninstall` for the selected app.
6. Open `History`.
7. Confirm an uninstall task run exists.
8. Confirm `Recovery` includes the app recovery entry.
**Pass condition**
- Preview and uninstall flow complete through worker-backed behavior.
### SOP-04 Recovery Restore Workflow
1. Open `History`.
2. In `Recovery`, choose one item.
3. Click `Restore`.
4. Confirm the item disappears from the recovery list.
5. Return to the relevant screen (`Smart Clean` or `Apps`) and confirm state reflects the restore.
**Pass condition**
- Recovery restore succeeds and updates visible state.
### SOP-05 Permissions Workflow
1. Open `Permissions`.
2. Click `Refresh Permission Status`.
3. Confirm cards render for:
- `Full Disk Access`
- `Accessibility`
- `Notifications`
4. If you enable `Full Disk Access`, fully quit and reopen Atlas, then confirm `Refresh Permission Status` can reflect the new state.
5. Confirm the page does not hang or crash if some permissions are missing.
**Pass condition**
- Best-effort permission inspection returns a stable UI state, and Full Disk Access guidance matches the real macOS relaunch requirement.
### SOP-06 Settings Workflow
1. Open `Settings`.
2. Change recovery retention.
3. Toggle notifications.
4. Quit the app.
5. Relaunch the app.
6. Return to `Settings`.
7. Confirm both values persisted.
8. Confirm acknowledgement and third-party notices text are visible.
**Pass condition**
- Settings persist across relaunch and trust content is visible.
### SOP-07 DMG Install Path
1. Double-click `dist/native/Atlas-for-Mac.dmg`.
2. Drag `Atlas for Mac.app` into `Applications`.
3. Launch the installed app from `Applications`.
4. Confirm the main window appears.
**Pass condition**
- DMG install path behaves like a normal user install.
### SOP-08 PKG Verification Path
1. Run `pkgutil --expand dist/native/Atlas-for-Mac.pkg dist/native/pkg-expand-manual`.
2. Confirm the package expands without error.
3. Run `pkgutil --check-signature dist/native/Atlas-for-Mac.pkg`.
4. Record whether the current build is `Developer ID signed`, `ad hoc signed`, or `unsigned`.
**Pass condition**
- PKG structure is valid and signing state is explicitly recorded.
### SOP-09 Native UI Smoke
1. Run `./scripts/atlas/ui-automation-preflight.sh`.
2. If the machine is trusted for Accessibility, run `./scripts/atlas/run-ui-automation.sh`.
3. Confirm the UI smoke test suite passes.
**Pass condition**
- Native UI smoke passes on a machine with proper macOS automation permissions.
## Failure Classification
### P0
- App does not launch
- Primary workflow crashes
- Smart Clean or Apps core flow cannot complete
- Recovery restore fails consistently
### P1
- Non-blocking but severe UX issue
- Persistent visual corruption on a core screen
- Packaging/install issue with a documented workaround
### P2
- Minor UI issue
- Copy inconsistency
- Non-core polish regression
## Final Tester Output
At the end of a run, summarize:
- build under test
- artifact path used
- scenarios executed
- pass/fail for each scenario
- P0 / P1 / P2 issues found
- recommendation:
- `Pass`
- `Pass with Conditions`
- `Fail`

View File

@@ -0,0 +1,36 @@
# Polish Week 1 Plan
## Goal
Establish a shared polish baseline and improve the two most trust-sensitive MVP flows: `Smart Clean` and `Apps`.
## Must Deliver
- MVP state audit covering default, loading, empty, partial-permission, success, and failure states
- Shared polish baseline for spacing, card hierarchy, CTA priority, status tone, and destructive-action language
- `Smart Clean` improvements for scan controls, preview readability, execution confidence, and result continuity
- `Apps` improvements for uninstall preview clarity, leftover visibility, and recovery confidence
- Narrow verification for first-run, scan, preview, execute, uninstall, and restore-adjacent flows
## Day Plan
- `Day 1` Audit all frozen MVP routes and record the missing states and trust gaps
- `Day 2` Tighten shared design-system primitives and copy rules before page-specific tweaks
- `Day 3` Polish `Smart Clean` from scan initiation through preview and execute feedback
- `Day 4` Polish `Apps` from refresh through uninstall preview and completion messaging
- `Day 5` Run focused verification and hold a gate review for Week 2 polish work
## Owner Tasks
- `Product Agent` define the polish scorecard and keep work inside the frozen MVP scope
- `UX Agent` close wording, hierarchy, and permission-guidance gaps in trust-critical surfaces
- `Mac App Agent` implement design-system and feature-level refinements for `Smart Clean` and `Apps`
- `QA Agent` verify the state matrix and catch visual or flow regressions in the primary paths
- `Docs Agent` keep backlog, execution notes, and follow-up risks in sync with the week output
## Exit Criteria
- `Smart Clean` and `Apps` read clearly without requiring implementation knowledge
- Primary CTAs are obvious, secondary actions are quieter, and destructive actions feel reversible
- The top-level screens no longer fall back to generic empty or ambiguous progress states in core flows
- Week 2 can focus on `Overview`, `History`, and `Permissions` without reopening Week 1 trust issues

View File

@@ -0,0 +1,70 @@
# Release Signing and Notarization
## Goal
Turn Atlas for Mac from an installable local build into a publicly distributable macOS release.
## Required Credentials
- `Developer ID Application` certificate for app signing
- `Developer ID Installer` certificate for installer signing
- `notarytool` keychain profile for notarization
## Environment Variables Used by Packaging
- `ATLAS_CODESIGN_IDENTITY`
- `ATLAS_CODESIGN_KEYCHAIN`
- `ATLAS_INSTALLER_SIGN_IDENTITY`
- `ATLAS_NOTARY_PROFILE`
## Stable Local Signing
For local development machines that do not have Apple release certificates yet, provision a stable app-signing identity once:
```bash
./scripts/atlas/ensure-local-signing-identity.sh
```
After that, `./scripts/atlas/package-native.sh` automatically prefers this local identity over ad hoc signing. This keeps the installed app bundle identity stable enough for macOS permission prompts and TCC decisions to behave consistently across rebuilds.
Notes:
- This local identity is only for internal/dev packaging.
- `.pkg` signing and notarization still require Apple `Developer ID Installer` and `notarytool` credentials.
- The local identity is stored in a dedicated keychain at `~/Library/Keychains/AtlasLocalSigning.keychain-db` unless overridden by env vars.
## Preflight
Run:
```bash
./scripts/atlas/signing-preflight.sh
```
If preflight passes, the current machine is ready for signed packaging.
## Signed Packaging
Run:
```bash
ATLAS_CODESIGN_IDENTITY="Developer ID Application: <Name> (<TEAMID>)" \
ATLAS_INSTALLER_SIGN_IDENTITY="Developer ID Installer: <Name> (<TEAMID>)" \
ATLAS_NOTARY_PROFILE="<profile-name>" \
./scripts/atlas/package-native.sh
```
This signs the app bundle, emits `.zip`, `.dmg`, and `.pkg`, submits artifacts for notarization, and staples results when credentials are available.
## Install Verification
After packaging, validate the DMG installation path with:
```bash
KEEP_INSTALLED_APP=1 ./scripts/atlas/verify-dmg-install.sh
```
## Current Repo State
- Internal packaging can now use a stable local app-signing identity instead of ad hoc signing.
- Signed/notarized release artifacts remain blocked only by missing Apple release credentials on this machine.

View File

@@ -0,0 +1,142 @@
# Smart Clean Execution Coverage — 2026-03-09
## Goal
Explain, in user-facing and release-facing terms, what `Smart Clean` can execute for real today, what still fails closed, and how recovery behaves for executed items.
This document is intentionally simpler than `Docs/Execution/Execution-Chain-Audit-2026-03-09.md`. It is meant to support product, UX, QA, and release communication.
## Current Position
`Smart Clean` no longer presents a misleading “success” when only scaffold/state-based execution is available.
The current behavior is now:
- real scan when the upstream clean workflow succeeds
- real execution for a safe structured subset of targets
- physical restoration for executed items when recovery mappings are present
- explicit failure for unsupported or unstructured targets
- the UI now distinguishes cached plans from current-session verified plans and blocks execution until a plan is refreshed in the current session
## What Runs for Real Today
`Smart Clean` can physically move supported targets to Trash when the scan adapter returns structured execution targets.
### Supported direct Trash targets
These user-owned targets can be moved to Trash directly by the worker when they are returned as structured `targetPaths`:
- `~/Library/Caches/*`
- `~/Library/Logs/*`
- `~/Library/Suggestions/*`
- `~/Library/Messages/Caches/*`
- `~/Library/Developer/Xcode/DerivedData/*`
- `~/.npm/*`
- `~/.npm_cache/*`
- `~/.oh-my-zsh/cache/*`
- paths containing:
- `__pycache__`
- `.next/cache`
- `component_crx_cache`
- `GoogleUpdater`
- `CoreSimulator.log`
- `.pyc` files under the current user home directory
### Supported helper-backed targets
Targets under these allowlisted roots can run through the helper boundary:
- `/Applications/*`
- `~/Applications/*`
- `~/Library/LaunchAgents/*`
- `/Library/LaunchAgents/*`
- `/Library/LaunchDaemons/*`
## What Does Not Run Yet
The following categories remain incomplete unless they resolve to the supported structured targets above:
- broader `System` cleanup paths
- partially aggregated dry-run results that do not yet carry executable sub-paths
- categories that only expose a summary concept rather than concrete target paths
- any Smart Clean item that requires a more privileged or more specific restore model than the current Trash-backed flow supports
For these items, Atlas should fail closed rather than claim completion.
## User-Facing Meaning
### Cached vs current plan
Atlas can persist the last generated Smart Clean plan across launches. That cached plan is useful for orientation, but it is not treated as directly executable until the current session successfully runs a fresh scan or plan update.
The UI now makes this explicit by:
- marking cached plans as previous results
- disabling `Run Plan` until the plan is revalidated
- showing which plan steps can run directly and which remain review-only
### When execution succeeds
It means:
- Atlas had concrete target paths for the selected plan items
- those targets were actually moved to Trash
- recovery records were created with enough information to support physical restoration for those targets
It does **not** mean:
- every Smart Clean category is fully implemented
- every reviewed item is physically restorable in every environment
- privileged or protected targets are universally supported yet
### When execution is rejected
It means Atlas is protecting trust by refusing to report a cleanup it cannot prove.
Typical reasons:
- the scan adapter did not produce executable targets for the item
- the current path falls outside the supported execution allowlist
- the required worker/helper capability is unavailable
## Recovery Model
### Physical restore available
When a recovery item contains structured `restoreMappings`, Atlas can move the trashed item back to its original path.
This is currently the most trustworthy recovery path because it corresponds to a real on-disk side effect.
### Model-only restore still possible
Older or unstructured recovery records may only restore Atlass internal workspace model.
That means:
- the item can reappear in Atlas UI state
- the underlying file may not be physically restored on disk
## Product Messaging Guidance
Use these statements consistently in user-facing communication:
- `Smart Clean runs real cleanup only for supported items in the current plan.`
- `Unsupported items stay review-only until Atlas can execute them safely.`
- `Recoverable items can be restored when a recovery path is available.`
- `If Atlas cannot prove the cleanup step, it should fail instead of claiming success.`
Avoid saying:
- `Smart Clean cleans everything it scans`
- `All recoverable items can always be restored physically`
- `Execution succeeded` when the action only changed in-app state
## Release Gate Recommendation
Do not describe `Smart Clean` as fully disk-backed until all major frozen-MVP Smart Clean categories support:
- structured execution targets
- real filesystem side effects
- physical recovery where promised
- `scan -> execute -> rescan` verification

View File

@@ -0,0 +1,142 @@
# Smart Clean Manual Verification — 2026-03-09
## Goal
Provide a fast, repeatable, machine-local verification flow for the current `Smart Clean` real-execution subset and physical recovery path.
Use this when you want to verify real cleanup behavior on your own Mac without relying on arbitrary personal files.
## Fixture Helper
Use the helper script below to create disposable files only in currently supported Smart Clean execution areas:
- `scripts/atlas/smart-clean-manual-fixtures.sh`
Supported commands:
```bash
./scripts/atlas/smart-clean-manual-fixtures.sh create
./scripts/atlas/smart-clean-manual-fixtures.sh status
./scripts/atlas/smart-clean-manual-fixtures.sh cleanup
```
## What the Helper Creates
The helper creates disposable fixtures under these locations:
- `~/Library/Caches/AtlasExecutionFixturesCache`
- `~/Library/Logs/AtlasExecutionFixturesLogs`
- `~/Library/Developer/Xcode/DerivedData/AtlasExecutionFixturesDerivedData`
- `~/Library/Caches/AtlasExecutionFixturesPycache`
These locations are chosen because the current Smart Clean implementation can execute and restore them for real.
## Verification Steps
### 1. Prepare fixtures
```bash
./scripts/atlas/smart-clean-manual-fixtures.sh create
```
Expected:
- The script prints the created roots and files.
- `status` shows non-zero size under all four fixture roots.
### 2. Confirm upstream dry-run sees the fixtures
```bash
bash bin/clean.sh --dry-run
```
Expected:
- The dry-run output or `~/.config/mole/clean-list.txt` reflects the fixture size increase under one or more higher-level roots such as:
- `~/Library/Caches`
- `~/Library/Logs`
- `~/Library/Developer/Xcode/DerivedData`
- The fixture helper `status` output gives you the exact on-disk paths to compare before and after execution.
### 3. Run Smart Clean scan in the app
- Open `Atlas for Mac`
- Go to `Smart Clean`
- Click `Run Scan`
- If needed, use the app search field and search for related visible terms such as `DerivedData`, `cache`, `logs`, or the exact original path shown by the helper script.
Expected:
- A cleanup plan is generated.
- At least one fixture-backed item appears in the plan or filtered findings.
- `Estimated Space` / `预计释放空间` is non-zero.
### 4. Execute the plan
- Review the plan.
- Click `Run Plan` / `执行计划`.
Expected:
- Execution completes successfully for supported fixture items.
- The app creates recovery entries.
- Atlas does not silently claim success for unsupported items.
### 5. Verify physical side effects
```bash
./scripts/atlas/smart-clean-manual-fixtures.sh status
```
Expected:
- Executed fixture files no longer exist at their original paths.
- The corresponding recovery entry exists inside the app.
### 6. Verify scan → execute → rescan
- Run another Smart Clean scan in the app.
Expected:
- The executed fixture-backed items are no longer rediscovered.
- Estimated space drops accordingly.
### 7. Verify physical restore
- Go to `History` / `Recovery`
- Restore the executed fixture-backed item(s)
Then run:
```bash
./scripts/atlas/smart-clean-manual-fixtures.sh status
```
Expected:
- The restored file or directory is back at its original path.
- If the restored item is still reclaimable, a fresh scan can rediscover it.
### 8. Clean up after verification
```bash
./scripts/atlas/smart-clean-manual-fixtures.sh cleanup
```
Expected:
- All disposable fixture roots are removed.
## Failure Interpretation
### Expected failure
If a scanned item is outside the currently supported structured safe subset, Atlas should fail closed instead of pretending to clean it.
### Unexpected failure
Treat these as regressions:
- fixture files remain in place after a reported successful execution
- fixture items reappear immediately on rescan even though the original files are gone
- restore reports success but the original files do not return
- Smart Clean claims success when no executable targets exist
## Recommended Companion Docs
- `Docs/Execution/Smart-Clean-Execution-Coverage-2026-03-09.md`
- `Docs/Execution/Smart-Clean-QA-Checklist-2026-03-09.md`
- `Docs/Execution/Execution-Chain-Audit-2026-03-09.md`

View File

@@ -0,0 +1,102 @@
# Smart Clean QA Checklist — 2026-03-09
## Goal
Provide a focused acceptance checklist for validating the current `Smart Clean` real-execution subset and physical recovery path.
## Preconditions
- Use a local machine where `Smart Clean` scan can run successfully
- Start from a fresh or known workspace-state file when possible
- Prefer disposable cache/test paths under the current user home directory
## Test Matrix
### 1. Real scan still works
- [ ] Run `Smart Clean` scan
- [ ] Confirm the page shows a non-empty cleanup plan for at least one supported target
- [ ] Confirm the plan shows `Estimated Space` / `预计释放空间`
Expected:
- Scan completes successfully
- The current cleanup plan is generated from real findings
### 2. Real execution for safe target
Recommended sample targets:
- a disposable file under `~/Library/Caches/...`
- a disposable `__pycache__` directory
- a disposable file under `~/Library/Developer/Xcode/DerivedData/...`
Steps:
- [ ] Create a disposable target under a supported path
- [ ] Run scan and confirm the target appears in the plan
- [ ] Run the plan
- [ ] Confirm the target disappears from its original path
- [ ] Confirm a recovery entry is created
Expected:
- Execution is accepted
- The file or directory is moved to Trash
- History and recovery both update
### 3. Scan → execute → rescan
- [ ] Run scan and note the item count / estimated space for the test target
- [ ] Run the plan for that target
- [ ] Run a fresh scan again
Expected:
- The previously executed target no longer appears in scan results
- Estimated space decreases accordingly
- The app does not rediscover the same target immediately unless the target still exists physically
### 4. Physical restore
- [ ] Select the recovery item created from the executed target
- [ ] Run restore
- [ ] Confirm the file or directory returns to its original path
- [ ] Run scan again if relevant
Expected:
- Restore succeeds
- The item reappears at the original path
- If the restored item still qualifies as reclaimable, a new scan can rediscover it
### 5. Unsupported target fails closed
Use an item that is scanned but not currently covered by the structured safe execution subset.
- [ ] Run scan until the unsupported item appears in the plan
- [ ] Attempt execution
Expected:
- Atlas rejects execution instead of claiming success
- The rejection reason explains that executable cleanup targets are unavailable or unsupported
- No misleading drop in reclaimable space is shown as if cleanup succeeded
### 6. Worker/XPC fallback honesty
- [ ] Simulate or observe worker unavailability in a development environment without `ATLAS_ALLOW_SCAFFOLD_FALLBACK=1`
- [ ] Attempt a release-facing execution action
Expected:
- Atlas surfaces a concrete failure
- It does not silently fall back to scaffold behavior and report success
## Regression Checks
- [ ] `Apps` uninstall still works for bundle removal
- [ ] Existing app/package tests remain green
- [ ] `clean.sh --dry-run` still starts and exports cleanup lists successfully
## Pass Criteria
A Smart Clean execution change is acceptable only if all of the following are true:
- supported targets are physically moved to Trash
- executed targets disappear on the next scan
- recovery can physically restore executed targets when mappings are present
- unsupported targets fail closed
- the UI does not imply broader execution coverage than what is actually implemented

View File

@@ -0,0 +1,361 @@
# UI Audit — 2026-03-08
## Scope
This audit reviews the current Atlas for Mac frozen-MVP shell after the post-MVP polish and bilingual localization work.
Audited surfaces:
- `Overview`
- `Smart Clean`
- `Apps`
- `History`
- `Permissions`
- `Settings`
- `Task Center`
- app shell navigation, toolbar, keyboard shortcuts, and shared design system
## Audit Method
The review combines:
- current product IA and copy guidance
- the shared SwiftUI design-system implementation
- screen-by-screen source review
- SwiftUI-focused UI guidance for hierarchy, keyboard flow, and accessibility
Evidence anchors:
- `Docs/IA.md`
- `Docs/COPY_GUIDELINES.md`
- `Packages/AtlasDesignSystem/Sources/AtlasDesignSystem/AtlasDesignSystem.swift`
- `Apps/AtlasApp/Sources/AtlasApp/AppShellView.swift`
- `Apps/AtlasApp/Sources/AtlasApp/AtlasAppCommands.swift`
- feature screen implementations under `Packages/AtlasFeatures*/Sources/*`
## Executive Summary
Atlas for Mac has moved beyond MVP-shell quality and now reads as a real product. The UX is coherent, trust-aware, keyboard-aware, and bilingual. The strongest improvements are in information clarity, reversibility cues, and consistency of shared surfaces.
The current gap is no longer “is this usable?” but “does this feel premium and native enough for a polished Mac utility?”
### Current Assessment
- Information architecture: `Strong`
- Trust and safety framing: `Strong`
- State coverage: `Strong`
- Accessibility and keyboard support: `Strong`
- Visual hierarchy depth: `Moderate`
- Density and reading rhythm: `Moderate`
- Secondary-surface polish: `Moderate`
- Premium native feel: `Moderate`
## What Is Working Well
### Product Clarity
- The app shell presents a stable frozen-MVP navigation model.
- `Overview`, `Smart Clean`, and `Apps` now tell a coherent story rather than reading like disconnected feature demos.
- Trust-sensitive actions are consistently framed as preview-first and recovery-aware.
### Interaction Model
- Main routes are keyboard reachable.
- Core task triggers are available from both screen UI and command menus.
- Task Center behaves like a real secondary control surface, not just a debug panel.
### Accessibility and Localization Foundations
- Shared UI primitives now expose meaningful accessibility labels and hints.
- Stable identifiers make UI automation resilient even when visible text changes by language.
- Chinese-first localization with English switching is now structurally correct, not just cosmetic.
## Primary Issues
### P0 — Highest Priority
#### 1. Card Hierarchy Is Still Too Flat
Most major pages rely on the same card weight and spacing rhythm. This keeps the product tidy, but it reduces scan efficiency because too many sections feel equally important.
Impact:
- Users need more effort to identify the single most important panel on a page.
- High-signal guidance competes visually with secondary detail.
Best-practice direction:
- Establish one dominant “hero” block per screen.
- Reduce visual competition among secondary sections.
- Reserve stronger elevation/tone for the first action-worthy surface.
Recommended changes:
- `Overview`: promote the top health / reclaimable / next-step zone into a more dominant summary block.
- `Smart Clean`: make the scan / execute area the unmistakable primary zone.
- `Apps`: make uninstall preview or inventory summary visually dominant depending on state.
#### 2. Reading Width Is Too Loose on Large Windows
Pages currently stretch very comfortably, but on wide desktop windows the reading path becomes longer than necessary.
Impact:
- Long explanatory copy becomes harder to scan.
- Secondary cards feel visually disconnected.
Best-practice direction:
- Introduce a content-width ceiling for main reading regions.
- Let metric clusters stretch, but keep explanatory sections tighter.
Recommended changes:
- Add a constrained content container inside `AtlasScreen`.
- Allow dense metric rows to use more width than narrative sections.
#### 3. Smart Clean Still Feels Like Two Primary CTA Zones
The `Run Scan` and `Execute Preview` actions are logically distinct, but visually they still compete for primary importance.
Impact:
- The next best action is not always instantly obvious.
- The page feels more tool-like than guided.
Best-practice direction:
- Only one dominant primary action should exist at a time.
- The primary CTA should depend on state:
- no preview → `Run Scan`
- actionable preview → `Execute Preview`
Recommended changes:
- Downgrade the non-primary action to bordered / secondary treatment in each state.
- Keep `Refresh Preview` secondary at all times.
#### 4. Settings Is Correct but Too Heavy
The screen is comprehensive, but it reads as a long structured form rather than a calm preference center.
Impact:
- Lower discoverability of the most important controls.
- Legal / trust text visually outweighs active preferences.
Best-practice direction:
- Split into clearer subsections or collapsible regions.
- Keep active settings short and above long-form informational content.
Recommended changes:
- Separate into `General`, `Language`, `Trust`, and `Notices` visual groups.
- Move long acknowledgement text behind expansion or a deeper detail view.
### P1 — Important Next Improvements
#### 5. Sidebar Density Is Slightly Too High for Daily Use
The two-line route treatment helps onboarding, but the constant subtitle presence adds noise once the user already understands the product.
Recommended changes:
- Reduce subtitle prominence.
- Consider showing subtitle only on selection, hover, or in onboarding mode.
#### 6. Secondary Surfaces Trail the Primary Routes
`Task Center` and some lower-priority sections now work well, but still feel more functional than premium.
Recommended changes:
- Tighten spacing and emphasis rules for popovers and secondary panels.
- Add a stronger visual relationship between summary text and follow-up action.
#### 7. Typography Scale Could Be More Intentional
The type hierarchy is good, but still somewhat conservative for a desktop utility with a lot of summary-driven UX.
Recommended changes:
- Slightly enlarge the primary summary tier.
- Slightly quiet secondary captions and helper text.
- Keep a more visible difference between page title, section title, and row title.
#### 8. Cross-Screen Density Rules Need One More Pass
Some screens are comfortably airy, others slightly dense.
Recommended changes:
- Standardize vertical rhythm for:
- card header to body spacing
- row spacing inside cards
- gap between stacked cards
### P2 — Valuable but Not Immediate
#### 9. Native Delight Layer
The app is stable and clear, but not yet especially memorable.
Potential upgrades:
- more refined hover transitions
- better selected-state polish in the sidebar
- subtle page-entry choreography
- richer system-native empty-state illustration language
#### 10. Progressive Disclosure for Advanced Users
Future polish can separate mainstream users from power users without expanding scope.
Potential upgrades:
- compact vs comfortable density mode
- “advanced detail” toggles in `Smart Clean` and `Apps`
- richer developer-specific explanations where relevant
## Screen-by-Screen Notes
### Overview
Strengths:
- Clear high-level summary
- Good trust framing
- Useful activity surface
Main issue:
- Too many blocks feel equally important
Priority:
- `P0`
### Smart Clean
Strengths:
- Strong preview-first structure
- Good risk grouping
- Good recoverability language
Main issue:
- CTA hierarchy still needs stronger state-based dominance
Priority:
- `P0`
### Apps
Strengths:
- Good trust framing for uninstall
- Good leftover visibility
- Good preview-before-execute structure
Main issue:
- Preview state and inventory state should diverge more visually
Priority:
- `P0`
### History
Strengths:
- Good audit and recovery framing
- Rows are readable and trustworthy
Main issue:
- Could feel more timeline-like and less card-list-like
Priority:
- `P1`
### Permissions
n
Strengths:
- Limited-mode messaging is strong
- Permission rationale now feels respectful and clear
Main issue:
- Still somewhat uniform visually; could use stronger “what to do now” emphasis
Priority:
- `P1`
### Settings
Strengths:
- Good scope coverage
- Language switching is correctly placed
- Trust information is discoverable
Main issue:
- Too long and text-heavy for a premium settings surface
Priority:
- `P0`
### Task Center
Strengths:
- Useful and keyboard friendly
- Clear bridge into History
Main issue:
- Still visually closer to a utility panel than a polished product surface
Priority:
- `P1`
## Recommended Execution Order
### Wave 1
- Reduce card hierarchy flatness
- Introduce content-width ceiling
- Make `Smart Clean` a single-primary-action screen per state
- Reduce `Settings` reading burden
### Wave 2
- Refine sidebar density
- Upgrade `Task Center` and other secondary surfaces
- Tighten typography and spacing rules
### Wave 3
- Add native delight polish
- Add advanced progressive disclosure where it improves clarity
## Done When
This UI audit is considered addressed when:
- each major screen has an obvious primary focus region
- each state has one clearly dominant next action
- reading width feels intentionally controlled on large windows
- `Settings` no longer feels like a long documentation page
- secondary surfaces feel visually consistent with the main shell
- the product feels recognizably “Mac-native polished,” not just “well-organized SwiftUI”

View File

@@ -0,0 +1,92 @@
# UI Automation Blocker
## Status
Investigated and resolved locally after granting Accessibility trust to the calling process.
## Goal
Add native macOS UI automation for `AtlasApp` using Xcode/XCTest automation targets.
## What Was Tried
### Attempt 1: Main project native UI testing
- Added stable accessibility identifiers to the app UI for sidebar and primary controls.
- Tried a generated UI-testing bundle path from the main project.
- Tried a host-linked unit-test bundle path to probe `XCUIApplication` support.
### Result
- `bundle.unit-test` is not valid for `XCUIApplication`; XCTest rejects that path.
- The main-project UI-testing setup remained noisy and unsuitable as a stable repository default.
### Attempt 2: Independent minimal repro
- Built a standalone repro under `Testing/XCUITestRepro/` with:
- one minimal SwiftUI app target
- one UI test target
- one test using `XCUIApplication`
- Generated the project with `xcodegen`
- Ran:
```bash
xcodebuild test \
-project Testing/XCUITestRepro/XCUITestRepro.xcodeproj \
-scheme XCUITestRepro \
-destination 'platform=macOS'
```
### Result
- The minimal repro builds, signs, launches the UI test runner, and gets farther than the main-project experiment.
- It then fails with:
- `Timed out while enabling automation mode.`
## Conclusion
- The dominant blocker is now identified as local macOS UI automation enablement, not Atlas business logic.
- Specifically, the current shell process is not trusted for Accessibility APIs, which is consistent with macOS UI automation bootstrap failure.
- After granting Accessibility trust to the terminal process, both the standalone repro and the Atlas main-project UI smoke tests succeed locally.
## Evidence
### Local permission check
```bash
swift -e 'import ApplicationServices; print(AXIsProcessTrusted())'
```
Initial result on this machine before granting Accessibility trust:
```text
false
```
Current result after granting Accessibility trust:
```text
true
```
### Minimal repro location
- `Testing/XCUITestRepro/project.yml`
- `Testing/XCUITestRepro/App/XCUITestReproApp.swift`
- `Testing/XCUITestRepro/UITests/XCUITestReproUITests.swift`
### Preflight helper
- `scripts/atlas/ui-automation-preflight.sh`
## Outcome
- `scripts/atlas/ui-automation-preflight.sh` now passes on this machine.
- `Testing/XCUITestRepro/` UI tests pass.
- Atlas main-project UI smoke tests pass through `scripts/atlas/run-ui-automation.sh`.
## Remaining Constraint
- Native UI automation still depends on Accessibility trust being granted for the process that runs `xcodebuild`. On a new machine, run the preflight first.
## 2026-03-08 Update
- The current machine can still hit `Timed out while enabling automation mode.` even when `AXIsProcessTrusted()` returns `true`.
- The standalone repro under `Testing/XCUITestRepro/` reproduced the same failure on 2026-03-08, which confirms the blocker is currently machine-level / environment-level rather than Atlas-product-specific.
- `scripts/atlas/run-ui-automation.sh` now retries after cleanup, and `scripts/atlas/full-acceptance.sh` now classifies the failure against the standalone repro before failing the product acceptance run.

View File

@@ -0,0 +1,213 @@
# UI Copy Walkthrough — 2026-03-09
## Goal
This checklist translates the current Atlas UI copy system into a page-by-page review guide so future edits keep the same business meaning, terminology, and user-facing tone.
This document assumes the frozen MVP scope:
- `Overview`
- `Smart Clean`
- `Apps`
- `History`
- `Permissions`
- `Settings`
- supporting surfaces such as `Task Center`, toolbar, command menus, and route labels
## Core Glossary
Use these terms consistently across product UI, docs, QA, and release notes.
- `Scan` — read-only analysis that collects findings. It never changes the system by itself.
- `Cleanup Plan` — the reviewed set of cleanup steps Atlas proposes from current findings.
- `Uninstall Plan` — the reviewed set of uninstall steps Atlas proposes for one app.
- `Review` — the human confirmation step before a plan runs.
- `Run Plan` / `Run Uninstall` — the action that applies a reviewed plan.
- `Estimated Space` / `预计释放空间` — the amount the current plan can free. It may decrease after execution because the plan is rebuilt from remaining items.
- `Recoverable` — Atlas can restore the result while the retention window is still open.
- `App Footprint` — the current disk space an app uses.
- `Leftover Files` — related support files, caches, or launch items shown before uninstall.
- `Limited Mode` — Atlas works with partial permissions and asks for more access only when a specific workflow needs it.
## Global Rules
### Meaning
- Always explain what the user is looking at before suggesting an action.
- Distinguish `current plan` from `remaining items after execution`.
- Use `plan` as the primary noun for actionable work. Avoid relying on `preview` alone when the object is something the user can run.
- If the action opens macOS settings, say `Open System Settings` / `打开系统设置`.
### Tone
- Calm
- Direct
- Reassuring
- Technical only when necessary
### CTA Rules
- Prefer explicit verbs: `Run Scan`, `Update Plan`, `Run Plan`, `Review Plan`, `Restore`, `Open System Settings`
- Avoid vague actions such as `Continue`, `Process`, `Confirm`, `Do It`
- Secondary actions must still communicate outcome, not just mechanism
## Surface Checklist
### Navigation
#### `Overview`
Primary promise:
- Users understand current system state, estimated space opportunity, and the next safe step.
Copy checks:
- Route subtitle must mention `health`, `estimated space`, and `next safe step`
- Main metric must say `Estimated Space` / `预计释放空间`, not a vague size label
- If a number can change after execution, the detail copy must say so explicitly
Reject if:
- It says `reclaimable` without clarifying it comes from the current plan
- It implies cleanup has already happened when it is only estimated
#### `Smart Clean`
Primary promise:
- Users scan first, review the cleanup plan second, and run it only when ready.
Copy checks:
- Screen subtitle must express the order: `scan → plan → run`
- The primary object on the page must be called `Cleanup Plan` / `清理计划`
- The primary execution CTA must say `Run Plan` / `执行计划`
- The plan-size metric must say `Estimated Space` / `预计释放空间`
- Empty states must say `No cleanup plan yet` / `还没有清理计划`
- Result copy after execution must not imply the remaining plan is the same as the one that just ran
Reject if:
- The UI mixes `preview`, `plan`, and `execution` as if they were the same concept
- The primary CTA implies execution when the user is only rebuilding the plan
- The metric label can be mistaken for already-freed space
#### `Apps`
Primary promise:
- Users inspect app footprint, leftover files, and the uninstall plan before removal.
Copy checks:
- `Preview` should be a review verb, not the main object noun
- The actionable object must be `Uninstall Plan` / `卸载计划`
- `Footprint` and `Leftover Files` must remain distinct concepts
- The destructive CTA must say `Run Uninstall` / `执行卸载`
- Row footnotes should identify leftovers clearly and avoid generic file language
Reject if:
- App size and uninstall result size are described with the same noun without context
- `Preview` is used as the label for something the user is actually about to run
- Leftovers are described as errors or threats
#### `History`
Primary promise:
- Users can understand what happened and what can still be restored.
Copy checks:
- Timeline language must distinguish `ran`, `finished`, and `still in progress`
- Recovery copy must mention the retention window where relevant
- Restore CTA and hints must make reversibility explicit
Reject if:
- It sounds like recovery is permanent
- It hides the time window for restoration
#### `Permissions`
Primary promise:
- Users understand why access matters, whether it can wait, and how to proceed safely.
Copy checks:
- The screen must frame permissions as optional until needed by a concrete workflow
- `Not Needed Yet` / `暂不需要` is preferred over pressure-heavy phrases
- The settings-opening CTA must say `Open System Settings` / `打开系统设置`
- Per-permission support text must explain when the permission matters, not just what it is
Reject if:
- It implies Atlas itself grants access
- It pressures the user with mandatory or fear-based wording
- It mentions system scope without user-facing benefit
#### `Settings`
Primary promise:
- Users adjust preferences and review trust/legal information in one calm surface.
Copy checks:
- Active preferences must read like operational controls, not legal copy
- Legal and trust text must stay descriptive and low-pressure
- Exclusions must clearly say they stay out of scans and plans
- Recovery retention wording must describe what remains recoverable and for how long
Reject if:
- Legal copy dominates action-oriented settings
- Exclusions sound like deletions or irreversible removals
### Supporting Surfaces
#### `Task Center`
Primary promise:
- Users see recent task activity and know when to open History.
Copy checks:
- Empty state must name concrete actions that populate the timeline
- Active state must point to History for the full audit trail
- Use `task activity`, `timeline`, `audit trail`, and `recovery items` consistently
Reject if:
- It uses internal terms such as queue/event payload/job object
#### Toolbar and Commands
Primary promise:
- Users understand what happens immediately when they click a global command.
Copy checks:
- `Permissions` global action should say `check status`, not just `refresh`
- `Task Center` should describe recent activity, not background internals
- Command labels should mirror the current screen vocabulary (`Run Plan`, `Check Permission Status`, `Refresh Current Screen`)
Reject if:
- Global actions use different verbs than in-page actions for the same behavior
## State-by-State Review Checklist
Use this table whenever copy changes on any screen.
| State | Must explain | Must avoid |
|------|--------------|------------|
| Loading | What Atlas is doing right now | vague spinner-only language |
| Empty | Why the page is empty and what action repopulates it | blame, dead ends |
| Ready | What the user can review now | implying work already ran |
| Executing | What is currently being applied | silent destructive behavior |
| Completed | What finished and what changed | overstating certainty or permanence |
| Recoverable | What can still be restored and for how long | implying indefinite restore availability |
| Limited mode | What still works and when more access might help | coercive permission language |
## Fast Acceptance Pass
A copy change is ready to ship when all of the following are true:
- Every primary surface has one clear noun for the object the user is acting on
- Every destructive CTA names the actual action outcome
- Every permission CTA names the real system destination
- Every reclaimable-space metric says whether it is estimated and whether it recalculates
- Recovery language always mentions reversibility or the retention window where relevant
- Chinese and English versions communicate the same product model, not just literal translations
## Recommended Use
Use this walkthrough when:
- editing `Localizable.strings`
- reviewing new screens or empty states
- preparing beta QA scripts
- checking regression after feature or IA changes
- writing release notes that reference Smart Clean, Apps, History, or Permissions

38
Docs/Execution/Week-01.md Normal file
View File

@@ -0,0 +1,38 @@
# Week 1 Plan
## Goal
Freeze product scope, naming, compliance, and core definitions.
## Must Deliver
- `PRD v1`
- `IA v1`
- `Protocol v1`
- `Permission strategy v1`
- `Attribution and third-party notices v1`
- Decision and risk logs
## Day Plan
- `Day 1` Kickoff, naming, MVP scope, decision-log creation
- `Day 2` IA, key flows, page states, permission outline
- `Day 3` Protocol, state machine, system-boundary outline
- `Day 4` Resolve open questions
- `Day 5` Gate review and freeze
## Owner Tasks
- `Product Agent` freeze scope, goals, metrics, non-goals
- `UX Agent` draft IA and core flows
- `Core Agent` define models and protocol
- `System Agent` define permission matrix and boundaries
- `Adapter Agent` audit upstream capabilities
- `QA Agent` define acceptance matrix
- `Docs Agent` draft attribution and notice files
## Exit Criteria
- All P0 scope questions resolved
- Week 2 inputs are frozen
- Decision log updated

35
Docs/Execution/Week-02.md Normal file
View File

@@ -0,0 +1,35 @@
# Week 2 Plan
## Goal
Freeze high-fidelity design input and refine schemas required for implementation.
## Must Deliver
- High-fidelity drafts for `Overview`, `Smart Clean`, and `Apps`
- Permission explainer sheets
- `Protocol v1.1`
- Engineering scaffold proposal
- MVP acceptance matrix v1
## Day Plan
- `Day 1` Review low-fidelity flows and lock layout direction
- `Day 2` Produce `Overview` and `Smart Clean` high-fidelity drafts
- `Day 3` Produce `Apps` high-fidelity draft and uninstall preview details
- `Day 4` Freeze package and target graph
- `Day 5` Gate review
## Owner Tasks
- `UX Agent` deliver design screens and state variants
- `Mac App Agent` define shell, navigation, and dependency wiring
- `Core Agent` refine protocol and persistence models
- `System Agent` refine XPC and helper interfaces
- `Adapter Agent` define scan and app-footprint chains
- `QA Agent` refine acceptance matrix
## Exit Criteria
- Three key screens are ready for implementation slicing
- Protocol and persistence models are stable enough for Week 3 freeze

35
Docs/Execution/Week-03.md Normal file
View File

@@ -0,0 +1,35 @@
# Week 3 Plan
## Goal
Freeze architecture, protocol, worker/helper boundaries, and scaffold inputs.
## Must Deliver
- `Architecture v1`
- `Protocol Schema v1`
- `Task State Machine v1`
- `Error Registry v1`
- `Worker XPC` interface
- `Privileged Helper` action allowlist
- Package and target dependency graph
## Day Plan
- `Day 1` Confirm minimum data required by the three core screens
- `Day 2` Freeze schema, state machine, error mapping
- `Day 3` Freeze worker/helper interfaces and validations
- `Day 4` Freeze scaffold structure and dependency graph
- `Day 5` Gate review for Week 4 engineering start
## Owner Tasks
- `Core Agent` freeze protocol, state, persistence
- `System Agent` freeze worker/helper interfaces
- `Mac App Agent` freeze shell and package graph
- `Adapter Agent` freeze MVP adapter paths
- `QA Agent` define contract and boundary test coverage
## Exit Criteria
- Week 4 can start implementation without unresolved P0 architecture blockers

View File

@@ -0,0 +1,43 @@
# Help Center Outline
## Getting Started
- What Atlas for Mac does
- What Atlas for Mac does not do
- First scan walkthrough
- Understanding permissions
## Smart Clean
- What Smart Clean can execute today
- Why some items stay review-only
- What the risk groups mean
- Why some items are rebuildable
- How exclusions and rules work
- What happens when a task partially fails
## Apps
- How uninstall preview works
- Why leftovers appear after app removal
- Background items and login items
## History and Recovery
- Where to find past runs
- When recovery restores a file physically
- Which actions are recoverable
- What happens when recovery expires
## Permissions
- Full Disk Access, including when a relaunch is required before status updates
- Admin-authorized actions
- Notifications and background monitoring
## Troubleshooting
- Scan results look incomplete
- A task was cancelled or failed
- A restore cannot return to the original path
- How to export diagnostics

68
Docs/IA.md Normal file
View File

@@ -0,0 +1,68 @@
# Information Architecture
## Primary Navigation
- `Overview`
- `Smart Clean`
- `Apps`
- `History`
- `Permissions`
- `Settings`
## MVP Navigation Notes
- `History` contains recovery entry points.
- `Settings` contains acknowledgements and third-party notices.
- `Storage` remains scaffolded at the package layer but is not part of the frozen MVP app shell.
## Screen Responsibilities
### Overview
- Health summary
- Reclaimable space summary
- Top issues
- Recommended actions
- Recent activity
### Smart Clean
- Scan initiation
- Findings grouped by `Safe`, `Review`, `Advanced`
- Selection and preview
- Execution and result summary
### Apps
- Installed app list
- Footprint details
- Leftovers and background item visibility
- Uninstall preview and execution
### History
- Timeline of runs
- Task detail view
- Recovery item access
### Permissions
- Permission status cards
- Guidance and system-settings links
### Settings
- General preferences
- App language
- Rules and exclusions
- Recovery retention
- Notifications
- Acknowledgements
## Global Surfaces
- Toolbar search
- Task center
- Confirmation sheets
- Error detail sheet
- Permission explainer sheet

70
Docs/PRD.md Normal file
View File

@@ -0,0 +1,70 @@
# PRD
## Product
- Working name: `Atlas for Mac`
- Category: `Mac Maintenance Workspace`
- Target platform: `macOS`
## Positioning
Atlas for Mac is a native desktop maintenance application that helps users understand why their Mac is slow, full, or disorganized, then take safe and explainable action.
## Product Goals
- Help users complete a safe space-recovery decision in minutes.
- Turn scanning into an explainable action plan.
- Unify cleanup, uninstall, permissions, history, and recovery into one workflow.
- Prefer reversible actions over permanent deletion.
- Support heavy Mac users and developer-oriented cleanup scenarios.
## Non-Goals
- No anti-malware suite in MVP.
- No Mac App Store release in MVP.
- No full automation rule engine in MVP.
- No advanced storage treemap in MVP.
- No menu bar utility in MVP.
## Target Users
- Heavy Mac users with persistent disk pressure.
- Developers with Xcode, simulators, containers, package-manager caches, and build artifacts.
- Creative users with large local media libraries.
- Cautious mainstream users who want safer maintenance than terminal tools.
## MVP Modules
- `Overview`
- `Smart Clean`
- `Apps`
- `History`
- `Recovery`
- `Permissions`
- `Settings`
## Core Differentiators
- Explainable cleanup recommendations
- Recovery-first execution model
- Unified maintenance workflow
- Developer-aware cleanup coverage
- Least-privilege permission design
## Success Metrics
- First scan completion rate
- Scan-to-execution conversion rate
- Permission completion rate
- Recovery success rate
- Task success rate
- User-visible space reclaimed
## MVP Acceptance Summary
- Users can run a scan without granting all permissions up front.
- Findings are grouped by risk and explained before execution.
- Users can preview app uninstall footprint before removal.
- Every destructive task produces a history record.
- Recoverable actions expose a restoration path.
- The app includes a visible open-source acknowledgement and third-party notices page.

145
Docs/Protocol.md Normal file
View File

@@ -0,0 +1,145 @@
# Local Protocol
## Goals
- Provide a stable local contract between UI, worker, and helper components.
- Avoid parsing terminal-oriented text output.
- Support progress, execution, history, recovery, settings, and helper handoff.
## Protocol Version
- Current implementation version: `0.3.0`
## UI ↔ Worker Commands
- `health.snapshot`
- `permissions.inspect`
- `scan.start`
- `plan.preview`
- `plan.execute`
- `recovery.restore`
- `apps.list`
- `apps.uninstall.preview`
- `apps.uninstall.execute`
- `settings.get`
- `settings.set`
## Worker ↔ Helper Models
### `AtlasHelperAction`
- `id`
- `kind`
- `targetPath`
- `destinationPath` (required for restore-style actions)
### `AtlasHelperActionResult`
- `action`
- `success`
- `message`
- `resolvedPath`
## Response Payloads
- `accepted(task)`
- `health(snapshot)`
- `permissions(permissionStates)`
- `apps(appFootprints)`
- `preview(actionPlan)`
- `settings(settings)`
- `rejected(code, reason)`
### Error Codes in Current Use
- `unsupportedCommand`
- `permissionRequired`
- `helperUnavailable`
- `executionUnavailable`
- `invalidSelection`
## Event Payloads
- `taskProgress(taskID, completed, total)`
- `taskFinished(taskRun)`
- `permissionUpdated(permissionState)`
## Core Schemas
### Finding
- `id`
- `category`
- `title`
- `detail`
- `bytes`
- `risk`
- `targetPaths` (optional structured execution targets derived from the scan adapter)
### ActionPlan
- `id`
- `title`
- `items`
- `estimatedBytes`
### TaskRun
- `id`
- `kind`
- `status`
- `summary`
- `startedAt`
- `finishedAt`
### AppFootprint
- `id`
- `name`
- `bundleIdentifier`
- `bundlePath`
- `bytes`
- `leftoverItems`
### RecoveryItem
- `id`
- `title`
- `detail`
- `originalPath`
- `bytes`
- `deletedAt`
- `expiresAt`
- `payload`
- `restoreMappings` (optional original-path ↔ trashed-path records for physical restoration)
### AtlasSettings
- `recoveryRetentionDays`
- `notificationsEnabled`
- `excludedPaths`
- `language`
- `acknowledgementText`
- `thirdPartyNoticesText`
## Protocol Rules
- Progress must be monotonic.
- Rejected requests return a stable code plus a user-facing reason.
- Destructive flows must end in a history record.
- Recoverable flows must produce structured recovery items.
- Helper actions must remain allowlisted structured actions, never arbitrary command strings.
## 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.
- 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.
- 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.

58
Docs/README.md Normal file
View File

@@ -0,0 +1,58 @@
# Atlas for Mac Docs
This directory contains the working product, design, engineering, and compliance documents for the Atlas for Mac desktop application.
## Principles
- Atlas for Mac is an independent product.
- The project does not use the Mole brand in user-facing naming.
- The project may reuse or adapt parts of the upstream Mole codebase under the MIT License.
- User-facing flows should prefer explainability, reversibility, and least privilege.
## Document Map
- `PRD.md` — product requirements and MVP scope
- `IA.md` — information architecture and navigation model
- `Architecture.md` — application architecture and process boundaries
- `Protocol.md` — local JSON protocol and core schemas
- `TaskStateMachine.md` — task lifecycle rules
- `ErrorCodes.md` — user-facing and system error registry
- `ROADMAP.md` — 12-week MVP execution plan
- `Backlog.md` — epics, issue seeds, and board conventions
- `DECISIONS.md` — frozen product and architecture decisions
- `RISKS.md` — active project risk register
- `Execution/` — weekly execution plans, status snapshots, beta checklists, gate reviews, manual test SOPs, and release execution notes
- `Execution/Current-Status-2026-03-07.md` — current engineering status snapshot
- `Execution/UI-Audit-2026-03-08.md` — UI design audit and prioritized remediation directions
- `Execution/UI-Copy-Walkthrough-2026-03-09.md` — page-by-page UI copy glossary, consistency checklist, and acceptance guide
- `Execution/Execution-Chain-Audit-2026-03-09.md` — end-to-end review of real vs scaffold execution paths and release-facing trust gaps
- `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
- `Templates/` — issue, epic, ADR, gate, and handoff templates
- `WORKSPACE_LAYOUT.md` — planned repository and module structure
- `HELP_CENTER_OUTLINE.md` — help center structure
- `COPY_GUIDELINES.md` — product voice and UI copy rules
- `ATTRIBUTION.md` — upstream acknowledgement strategy
- `THIRD_PARTY_NOTICES.md` — third-party notices and license references
- `ADR/` — architecture decision records
- `Sequence/` — flow-level engineering sequence documents
## Ownership
- Product decisions: `Product Agent`
- Interaction and content design: `UX Agent`
- App implementation: `Mac App Agent`
- Protocol and domain model: `Core Agent`
- XPC and privileged integration: `System Agent`
- Upstream adaptation: `Adapter Agent`
- Verification: `QA Agent`
- Distribution and release: `Release Agent`
- Compliance and docs: `Docs Agent`
## Update Rules
- Update `PRD.md` before changing MVP scope.
- Update `Protocol.md` and `TaskStateMachine.md` together when task lifecycle or schema changes.
- Add or update an ADR for any process-boundary, privilege, or storage decision.
- Keep `ATTRIBUTION.md` and `THIRD_PARTY_NOTICES.md` in sync with shipped code.

98
Docs/RISKS.md Normal file
View File

@@ -0,0 +1,98 @@
# Risk Register
## R-001 XPC and Helper Complexity
- Impact: High
- Probability: Medium
- Owner: `System Agent`
- Risk: Worker/helper setup and privilege boundaries may delay implementation.
- Mitigation: Complete architecture and helper allowlist freeze before scaffold build.
## R-002 Upstream Adapter Instability
- Impact: High
- Probability: High
- Owner: `Adapter Agent`
- Risk: Existing upstream commands may not expose stable structured data.
- Mitigation: Add adapter normalization layer and rewrite hot paths if JSON mapping is brittle.
## R-003 Permission Friction
- Impact: High
- Probability: Medium
- Owner: `UX Agent`
- Risk: Aggressive permission prompts may reduce activation.
- Mitigation: Use just-in-time prompts and support limited mode.
## R-004 Recovery Trust Gap
- Impact: High
- Probability: Medium
- Owner: `Core Agent`
- Risk: Users may not trust destructive actions without clear rollback behavior.
- Mitigation: Prefer reversible actions and preserve detailed history.
## R-005 Scope Creep
- Impact: High
- Probability: High
- Owner: `Product Agent`
- Risk: P1 features may leak into MVP.
- Mitigation: Freeze MVP scope and require explicit decision-log updates for scope changes.
## R-006 Signing and Notarization Surprises
- Impact: High
- Probability: Medium
- Owner: `Release Agent`
- Risk: Helper signing or notarization may fail late in the schedule.
- Mitigation: Validate packaging flow before feature-complete milestone. Current repo now includes native build/package scripts and CI workflow, but signing and notarization still depend on release credentials.
## R-007 Experience Polish Drift
- Impact: High
- Probability: High
- Owner: `Mac App Agent`
- Risk: MVP screens may continue to diverge in spacing, CTA hierarchy, and state handling as teams polish pages independently.
- Mitigation: Route visual and interaction changes through shared design-system components before page-level tweaks land.
## R-008 Trust Gap in Destructive Flows
- Impact: High
- Probability: Medium
- Owner: `UX Agent`
- Risk: Users may still hesitate to run `Smart Clean` or uninstall actions if recovery, review, and consequence messaging stay too subtle.
- Mitigation: Make recoverability, risk level, and next-step guidance visible at decision points and in completion states.
## R-009 State Coverage Debt
- Impact: High
- Probability: Medium
- Owner: `QA Agent`
- Risk: Loading, empty, partial-permission, and failure states may feel unfinished even when the happy path is functional.
- Mitigation: Require state-matrix coverage for primary screens before additional visual polish is considered complete.
## R-010 Localization Drift
- Impact: Medium
- Probability: Medium
- Owner: `Docs Agent`
- Risk: Newly added Chinese and English strings may drift between UI, worker summaries, and future screens if copy changes bypass the shared localization layer.
- Mitigation: Keep user-facing shell copy in shared localization resources and require bilingual QA before release-facing packaging.
## R-011 Smart Clean Execution Trust Gap
- Impact: High
- Probability: High
- Owner: `System Agent`
- Risk: `Smart Clean` execution now supports a real Trash-based path for a safe subset of targets, but unsupported or unstructured findings still cannot be executed and must fail closed. Physical restore also remains partial and depends on structured recovery mappings.
- Mitigation: Add real Smart Clean execution targets and block release-facing execution claims until `scan -> execute -> rescan` proves real disk impact.
## R-012 Silent Worker Fallback Masks Execution Capability
- Impact: High
- Probability: Medium
- Owner: `System Agent`
- Risk: Silent fallback from XPC to the scaffold worker can make user-facing execution appear successful even when the primary worker path is unavailable.
- Mitigation: Restrict fallback to explicit development mode or surface a concrete error when real execution infrastructure is unavailable.

59
Docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,59 @@
# MVP Roadmap
## Timeline
### Week 1
- Freeze MVP scope
- Freeze naming, compliance, and acknowledgement strategy
- Freeze product goals and success metrics
### Week 2
- Freeze IA and high-fidelity design input for key screens
- Freeze interaction states and permission explainers
### Week 3
- Freeze architecture, protocol, state machine, and helper boundaries
### Week 4
- Create engineering scaffold and mock-data application shell
### Week 5
- Ship scan initiation and result pipeline
### Week 6
- Ship action-plan preview and cleanup execution path
### Week 7
- Ship apps list and uninstall preview flow
### Week 8
- Ship permissions center, history, and recovery views
### Week 9
- Integrate privileged helper path and audit trail
### Week 10
- Run quality, regression, and performance hardening
### Week 11
- Produce beta candidate and packaging pipeline
### Week 12
- Internal beta wrap-up and release-readiness review
## MVP Scope
- In scope: `Overview`, `Smart Clean`, `Apps`, `History`, `Recovery`, `Permissions`
- Deferred to P1: `Storage treemap`, `Menu Bar`, `Automation`

View File

@@ -0,0 +1,21 @@
# Execute Flow
## Actors
- User
- AtlasApp
- AtlasWorkerClient
- AtlasWorkerXPC
- AtlasPrivilegedHelper
- AtlasStore
## Sequence
1. User previews a plan and confirms execution.
2. App sends `task.execute`.
3. Worker splits actions into privileged and non-privileged work.
4. Worker performs non-privileged actions directly.
5. Worker submits allowlisted privileged actions to helper when needed.
6. Worker streams progress, warnings, and per-item results.
7. Worker persists task result and recoverable items.
8. App renders a result page with success, warnings, and recovery actions.

View File

@@ -0,0 +1,19 @@
# Restore Flow
## Actors
- User
- AtlasApp
- AtlasWorkerClient
- AtlasWorkerXPC
- AtlasPrivilegedHelper
- AtlasStore
## Sequence
1. User selects one or more recovery items.
2. App sends `recovery.restore`.
3. Worker validates recovery windows and target conflicts.
4. Worker restores items directly or via helper when required.
5. Worker persists restore result and updates recovery status.
6. App renders restored, failed, or expired outcomes.

View File

@@ -0,0 +1,21 @@
# Scan Flow
## Actors
- User
- AtlasApp
- AtlasWorkerClient
- AtlasWorkerXPC
- AtlasCoreAdapters
- AtlasStore
## Sequence
1. User starts a scan.
2. App sends `scan.start`.
3. Worker validates scope and permissions.
4. Worker invokes one or more adapters.
5. Worker streams progress events.
6. Worker aggregates findings and summary.
7. Worker persists scan summary.
8. App renders grouped findings or a limited-results banner.

View File

@@ -0,0 +1,24 @@
# Third-Party Notices
This project includes planning for third-party and upstream open-source acknowledgements.
## Upstream Project
- Project: `Mole`
- Source: `https://github.com/tw93/mole`
- License: `MIT`
- Copyright: `tw93`
## Distribution Rule
If Atlas for Mac ships code derived from upstream Mole sources, the applicable copyright notice and MIT license text must be included in copies or substantial portions of the software.
## Notice Template
```text
This product includes software derived from the open-source project Mole by tw93 and contributors, used under the MIT License.
```
## Future Additions
Add all additional third-party libraries, bundled binaries, or copied source components here before release.

68
Docs/TaskStateMachine.md Normal file
View File

@@ -0,0 +1,68 @@
# Task State Machine
## Task Types
- `scan`
- `execute_clean`
- `execute_uninstall`
- `restore`
- `inspect_permissions`
- `health_snapshot`
## Main States
- `draft`
- `submitted`
- `validating`
- `awaiting_permission`
- `queued`
- `running`
- `cancelling`
- `completed`
- `partial_failed`
- `failed`
- `cancelled`
- `expired`
## Terminal States
- `completed`
- `partial_failed`
- `failed`
- `cancelled`
- `expired`
## Core Transition Rules
- `draft -> submitted`
- `submitted -> validating`
- `validating -> awaiting_permission | queued | failed`
- `awaiting_permission -> queued | cancelled | failed`
- `queued -> running | cancelled`
- `running -> cancelling | completed | partial_failed | failed`
- `cancelling -> cancelled`
## Action Item States
- `pending`
- `running`
- `succeeded`
- `skipped`
- `failed`
- `cancelled`
## Guarantees
- Terminal states are immutable.
- Progress must not move backwards.
- Destructive tasks must be audited.
- Recoverable tasks must leave structured recovery entries until restored or expired.
- Repeated write requests must honor idempotency rules when those flows become externally reentrant.
## Current MVP Notes
- `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. 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.
- User-visible task summaries and settings-driven text should reflect the persisted app-language preference when generated.

View File

@@ -0,0 +1,24 @@
# ADR-XXX: Title
## Status
- Proposed
- Accepted
- Superseded
## Context
Describe the problem, tradeoffs, and constraints.
## Decision
State the architectural decision clearly.
## Consequences
List positive, negative, and follow-on consequences.
## Alternatives Considered
- Alternative A
- Alternative B

View File

@@ -0,0 +1,23 @@
# Agent Handoff Template
## Completed
- What is done?
## Changed Artifacts
- Files or documents changed
## Open Questions
- Question 1
- Question 2
## Risks and Blockers
- Blocker 1
- Risk 1
## Recommended Next Step
What should the next Agent do immediately?

View File

@@ -0,0 +1,43 @@
# Epic Template
## Epic Name
## Problem Statement
What user or product problem does this epic solve?
## User Outcome
What should users be able to do after this epic is complete?
## In Scope
- Item 1
- Item 2
## Out of Scope
- Item 1
- Item 2
## Dependencies
- Decision log entries
- Related epics
- Blocking technical capabilities
## Milestones
- Milestone 1
- Milestone 2
- Milestone 3
## Success Metrics
- Metric 1
- Metric 2
## Risks
- Risk 1
- Risk 2

View File

@@ -0,0 +1,32 @@
# Gate Review Template
## Gate
- `Week 1`
- `Week 2`
- `Week 3`
- `Feature Complete`
- `Beta Candidate`
## Readiness Checklist
- [ ] Required P0 tasks complete
- [ ] Docs updated
- [ ] Risks reviewed
- [ ] Open questions below threshold
- [ ] Next-stage inputs available
## Blockers
- Blocker 1
## Decision
- `Pass`
- `Pass with Conditions`
- `Fail`
## Follow-up Actions
- Action 1
- Action 2

View File

@@ -0,0 +1,56 @@
# Issue Template
## Summary
Describe the intended outcome in one sentence.
## Type
- `Feature`
- `Design`
- `Architecture`
- `Protocol`
- `System`
- `QA`
- `Release`
- `Docs`
## Owner Agent
Assign exactly one primary owner.
## Priority
- `P0`
- `P1`
- `P2`
## Dependencies
List blocking issues, decisions, or documents.
## Scope
What is included in this issue?
## Non-Scope
What should not be done as part of this issue?
## Acceptance Criteria
- Criterion 1
- Criterion 2
- Criterion 3
## Deliverables
- Document, schema, design file, code path, or test artifact
## Risks
List known implementation or coordination risks.
## Handoff Notes
Capture what the next Agent needs to know.

65
Docs/WORKSPACE_LAYOUT.md Normal file
View File

@@ -0,0 +1,65 @@
# Workspace Layout
## Top-Level Directories
- `Apps/` — user-facing app targets
- `Packages/` — shared Swift packages
- `XPC/` — XPC service targets
- `Helpers/` — privileged helper targets
- `MenuBar/` — deferred menu-bar target area
- `Testing/` — shared testing support and future test targets
- `Docs/` — product, design, engineering, and compliance documents
## Planned Module Layout
### App Shell
- `Apps/AtlasApp/`
- `Apps/Package.swift`
### Shared Packages
- `Packages/Package.swift`
- `Packages/AtlasDesignSystem/`
- `Packages/AtlasDomain/`
- `Packages/AtlasApplication/`
- `Packages/AtlasProtocol/`
- `Packages/AtlasInfrastructure/`
- `Packages/AtlasCoreAdapters/`
- `Packages/AtlasFeaturesOverview/`
- `Packages/AtlasFeaturesSmartClean/`
- `Packages/AtlasFeaturesApps/`
- `Packages/AtlasFeaturesStorage/`
- `Packages/AtlasFeaturesHistory/`
- `Packages/AtlasFeaturesPermissions/`
- `Packages/AtlasFeaturesSettings/`
### Services
- `XPC/Package.swift`
- `Helpers/Package.swift`
- `XPC/AtlasWorkerXPC/`
- `Helpers/AtlasPrivilegedHelper/`
### Deferred Targets
- `MenuBar/AtlasMenuBar/`
### Test Support
- `Testing/Package.swift`
- `Testing/AtlasTestingSupport/`
## Current Scaffold Conventions
- `Apps/Package.swift` hosts the main `AtlasApp` executable target.
- `Packages/Package.swift` hosts shared library products with sources under `Packages/*/Sources/*`.
- `XPC/Package.swift` and `Helpers/Package.swift` host the worker and helper executable stubs.
- Root `project.yml` also generates an `AtlasWorkerXPC` macOS `xpc-service` target for the app bundle.
- `Testing/Package.swift` hosts shared fixtures and future contract-test helpers.
- `MenuBar/` remains README-only until deferred P1 scope is explicitly reopened.
- Root `project.yml` generates `Atlas.xcodeproj` through `xcodegen` for the native app shell.
## Rule
Create implementation files inside these directories rather than introducing new top-level structures unless an ADR records the change. Keep `project.yml` as the source of truth for regenerating `Atlas.xcodeproj`.

View File

@@ -0,0 +1,14 @@
# AtlasPrivilegedHelper
## Responsibility
- Execute allowlisted structured actions only
- Validate target paths before execution
- Return structured JSON results to the worker boundary
## Current Actions
- `trashItems`
- `restoreItem`
- `removeLaunchService`
- `repairOwnership` (reserved placeholder for future privileged expansion)

View File

@@ -0,0 +1,36 @@
import AtlasPrivilegedHelperCore
import AtlasProtocol
import Foundation
@main
struct AtlasPrivilegedHelperMain {
static func main() {
if CommandLine.arguments.contains("--action-json") {
runJSONActionMode()
return
}
let actions = AtlasPrivilegedActionKind.allCases.map(\.rawValue).joined(separator: ", ")
print("AtlasPrivilegedHelper ready")
print("Allowlisted actions: \(actions)")
}
private static func runJSONActionMode() {
let inputData = FileHandle.standardInput.readDataToEndOfFile()
let decoder = JSONDecoder()
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
do {
let action = try decoder.decode(AtlasHelperAction.self, from: inputData)
let result = try AtlasPrivilegedHelperActionExecutor().perform(action)
FileHandle.standardOutput.write(try encoder.encode(result))
} catch {
let fallbackAction = AtlasHelperAction(kind: .trashItems, targetPath: "")
let result = AtlasHelperActionResult(action: fallbackAction, success: false, message: error.localizedDescription)
if let data = try? encoder.encode(result) {
FileHandle.standardOutput.write(data)
}
}
}
}

View File

@@ -0,0 +1,204 @@
import AtlasProtocol
import Foundation
import Darwin
public struct AtlasPrivilegedHelperActionExecutor {
private let fileManager: FileManager
private let allowedRoots: [String]
private let currentUserID: UInt32
private let currentGroupID: UInt32
private let homeDirectoryURL: URL
public init(
fileManager: FileManager = .default,
allowedRoots: [String]? = nil,
currentUserID: UInt32 = getuid(),
currentGroupID: UInt32 = getgid(),
homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser
) {
self.fileManager = fileManager
self.currentUserID = currentUserID
self.currentGroupID = currentGroupID
self.homeDirectoryURL = homeDirectoryURL
self.allowedRoots = allowedRoots ?? [
URL(fileURLWithPath: "/Applications", isDirectory: true).path,
homeDirectoryURL.appendingPathComponent("Applications", isDirectory: true).path,
homeDirectoryURL.appendingPathComponent("Library/LaunchAgents", isDirectory: true).path,
URL(fileURLWithPath: "/Library/LaunchAgents", isDirectory: true).path,
URL(fileURLWithPath: "/Library/LaunchDaemons", isDirectory: true).path,
]
}
public func perform(_ action: AtlasHelperAction) throws -> AtlasHelperActionResult {
let targetURL = URL(fileURLWithPath: action.targetPath).resolvingSymlinksInPath()
let destinationURL = action.destinationPath.map { URL(fileURLWithPath: $0).resolvingSymlinksInPath() }
try validate(action: action, targetURL: targetURL, destinationURL: destinationURL)
switch action.kind {
case .trashItems:
var trashedURL: NSURL?
try fileManager.trashItem(at: targetURL, resultingItemURL: &trashedURL)
return AtlasHelperActionResult(
action: action,
success: true,
message: "Moved item to Trash.",
resolvedPath: (trashedURL as URL?)?.path
)
case .restoreItem:
guard let destinationURL else {
throw HelperValidationError.invalidRestoreDestination(nil)
}
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try fileManager.moveItem(at: targetURL, to: destinationURL)
return AtlasHelperActionResult(
action: action,
success: true,
message: "Restored item from Trash.",
resolvedPath: destinationURL.path
)
case .removeLaunchService:
try fileManager.removeItem(at: targetURL)
return AtlasHelperActionResult(
action: action,
success: true,
message: "Removed launch service file.",
resolvedPath: targetURL.path
)
case .repairOwnership:
return try repairOwnership(for: action, targetURL: targetURL)
}
}
private func repairOwnership(for action: AtlasHelperAction, targetURL: URL) throws -> AtlasHelperActionResult {
let targets = try ownershipTargets(for: targetURL)
var updatedCount = 0
var failedPaths: [String] = []
for url in targets {
do {
let attributes = try fileManager.attributesOfItem(atPath: url.path)
let ownerID = attributes[.ownerAccountID] as? NSNumber
let groupID = attributes[.groupOwnerAccountID] as? NSNumber
let alreadyOwned = ownerID?.uint32Value == currentUserID && groupID?.uint32Value == currentGroupID
if !alreadyOwned {
try fileManager.setAttributes([
.ownerAccountID: NSNumber(value: currentUserID),
.groupOwnerAccountID: NSNumber(value: currentGroupID),
], ofItemAtPath: url.path)
updatedCount += 1
}
} catch {
failedPaths.append(url.path)
}
}
if !failedPaths.isEmpty {
throw HelperValidationError.repairOwnershipFailed(failedPaths)
}
let message: String
if updatedCount == 0 {
message = "Ownership already matched the current user."
} else {
message = "Repaired ownership for \(updatedCount) item\(updatedCount == 1 ? "" : "s")."
}
return AtlasHelperActionResult(
action: action,
success: true,
message: message,
resolvedPath: targetURL.path
)
}
private func ownershipTargets(for rootURL: URL) throws -> [URL] {
var urls: [URL] = [rootURL]
let values = try rootURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
guard values.isDirectory == true, values.isSymbolicLink != true else {
return urls
}
if let enumerator = fileManager.enumerator(
at: rootURL,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
) {
for case let url as URL in enumerator {
let resourceValues = try? url.resourceValues(forKeys: [.isSymbolicLinkKey])
if resourceValues?.isSymbolicLink == true {
continue
}
urls.append(url)
}
}
return urls
}
private func validate(action: AtlasHelperAction, targetURL: URL, destinationURL: URL?) throws {
guard fileManager.fileExists(atPath: targetURL.path) else {
throw HelperValidationError.pathNotFound(targetURL.path)
}
let isAllowed = { (url: URL) in
allowedRoots.contains { root in
url.path == root || url.path.hasPrefix(root + "/")
}
}
switch action.kind {
case .trashItems, .removeLaunchService, .repairOwnership:
guard isAllowed(targetURL) else {
throw HelperValidationError.pathNotAllowed(targetURL.path)
}
case .restoreItem:
let trashRoot = homeDirectoryURL.appendingPathComponent(".Trash", isDirectory: true).path
guard targetURL.path == trashRoot || targetURL.path.hasPrefix(trashRoot + "/") else {
throw HelperValidationError.pathNotAllowed(targetURL.path)
}
guard let destinationURL else {
throw HelperValidationError.invalidRestoreDestination(nil)
}
guard isAllowed(destinationURL) else {
throw HelperValidationError.invalidRestoreDestination(destinationURL.path)
}
if fileManager.fileExists(atPath: destinationURL.path) {
throw HelperValidationError.restoreDestinationExists(destinationURL.path)
}
}
if action.kind == .removeLaunchService {
guard targetURL.pathExtension == "plist" else {
throw HelperValidationError.invalidLaunchServicePath(targetURL.path)
}
}
}
}
enum HelperValidationError: LocalizedError {
case pathNotFound(String)
case pathNotAllowed(String)
case invalidLaunchServicePath(String)
case invalidRestoreDestination(String?)
case restoreDestinationExists(String)
case repairOwnershipFailed([String])
var errorDescription: String? {
switch self {
case let .pathNotFound(path):
return "Target path not found: \(path)"
case let .pathNotAllowed(path):
return "Target path is outside the helper allowlist: \(path)"
case let .invalidLaunchServicePath(path):
return "Launch service removal requires a plist path: \(path)"
case let .invalidRestoreDestination(path):
return "Restore destination is invalid: \(path ?? "<missing>")"
case let .restoreDestinationExists(path):
return "Restore destination already exists: \(path)"
case let .repairOwnershipFailed(paths):
return "Failed to repair ownership for: \(paths.joined(separator: ", "))"
}
}
}

View File

@@ -0,0 +1,51 @@
import XCTest
@testable import AtlasPrivilegedHelperCore
import AtlasProtocol
final class AtlasPrivilegedHelperTests: XCTestCase {
func testRepairOwnershipSucceedsForAllowedCurrentUserFile() throws {
let root = makeAllowedRoot()
let fileURL = root.appendingPathComponent("Sample.txt")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
try Data("sample".utf8).write(to: fileURL)
let executor = AtlasPrivilegedHelperActionExecutor(allowedRoots: [root.path])
let result = try executor.perform(AtlasHelperAction(kind: .repairOwnership, targetPath: fileURL.path))
XCTAssertTrue(result.success)
XCTAssertEqual(result.resolvedPath, fileURL.path)
}
func testRestoreItemMovesTrashedFileBackToAllowedDestination() throws {
let home = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let trash = home.appendingPathComponent(".Trash", isDirectory: true)
let root = home.appendingPathComponent("Applications", isDirectory: true)
let sourceURL = trash.appendingPathComponent("Sample.app", isDirectory: true)
let destinationURL = root.appendingPathComponent("Sample.app", isDirectory: true)
try FileManager.default.createDirectory(at: sourceURL, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let executor = AtlasPrivilegedHelperActionExecutor(allowedRoots: [root.path], homeDirectoryURL: home)
let result = try executor.perform(AtlasHelperAction(kind: .restoreItem, targetPath: sourceURL.path, destinationPath: destinationURL.path))
XCTAssertTrue(result.success)
XCTAssertEqual(result.resolvedPath, destinationURL.path)
XCTAssertTrue(FileManager.default.fileExists(atPath: destinationURL.path))
}
func testRemoveLaunchServiceRejectsNonPlistPath() throws {
let root = makeAllowedRoot()
let fileURL = root.appendingPathComponent("not-a-plist.txt")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
try Data("sample".utf8).write(to: fileURL)
let executor = AtlasPrivilegedHelperActionExecutor(allowedRoots: [root.path])
XCTAssertThrowsError(try executor.perform(AtlasHelperAction(kind: .removeLaunchService, targetPath: fileURL.path)))
}
private func makeAllowedRoot() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
}
}

36
Helpers/Package.swift Normal file
View File

@@ -0,0 +1,36 @@
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "AtlasHelpers",
platforms: [.macOS(.v14)],
products: [
.library(name: "AtlasPrivilegedHelperCore", targets: ["AtlasPrivilegedHelperCore"]),
.executable(name: "AtlasPrivilegedHelper", targets: ["AtlasPrivilegedHelper"]),
],
dependencies: [
.package(path: "../Packages"),
],
targets: [
.target(
name: "AtlasPrivilegedHelperCore",
dependencies: [
.product(name: "AtlasProtocol", package: "Packages"),
],
path: "AtlasPrivilegedHelper/Sources/AtlasPrivilegedHelperCore"
),
.executableTarget(
name: "AtlasPrivilegedHelper",
dependencies: [
"AtlasPrivilegedHelperCore",
.product(name: "AtlasProtocol", package: "Packages"),
],
path: "AtlasPrivilegedHelper/Sources/AtlasPrivilegedHelper"
),
.testTarget(
name: "AtlasPrivilegedHelperTests",
dependencies: ["AtlasPrivilegedHelperCore", .product(name: "AtlasProtocol", package: "Packages")],
path: "AtlasPrivilegedHelper/Tests/AtlasPrivilegedHelperTests"
),
]
)

9
Helpers/README.md Normal file
View File

@@ -0,0 +1,9 @@
# Helpers
This directory contains helper targets for Atlas for Mac.
## Current Entry
- `AtlasPrivilegedHelper/` contains the allowlisted helper executable.
- The helper accepts structured JSON actions and validates target paths before execution.
- `Package.swift` exposes the helper as a SwiftPM executable target for local development and packaging.

View File

@@ -0,0 +1,10 @@
# AtlasMenuBar
## Responsibility
- Menu-bar entry point for P1
- Lightweight health summary and quick-entry actions
## Status
- Deferred until after MVP

3
MenuBar/README.md Normal file
View File

@@ -0,0 +1,3 @@
# MenuBar
This directory contains planned menu-bar targets and helpers.

View File

@@ -0,0 +1,19 @@
# AtlasApplication
## Responsibility
- Use cases and orchestration interfaces
- Structured application-layer coordination between the app shell and worker boundary
## Planned Use Cases
- `StartScan`
- `PreviewPlan`
- `ExecutePlan`
- `RestoreItems`
- `InspectPermissions`
## Current Scaffold
- `AtlasWorkspaceController` turns structured worker responses into app-facing scan, preview, and permission outputs.
- `AtlasWorkerServing` defines the worker boundary without leaking UI concerns into infrastructure.

View File

@@ -0,0 +1,549 @@
import AtlasDomain
import AtlasProtocol
import Foundation
public struct AtlasWorkspaceSnapshot: Codable, Hashable, Sendable {
public var reclaimableSpaceBytes: Int64
public var findings: [Finding]
public var apps: [AppFootprint]
public var taskRuns: [TaskRun]
public var recoveryItems: [RecoveryItem]
public var permissions: [PermissionState]
public var healthSnapshot: AtlasHealthSnapshot?
public init(
reclaimableSpaceBytes: Int64,
findings: [Finding],
apps: [AppFootprint],
taskRuns: [TaskRun],
recoveryItems: [RecoveryItem],
permissions: [PermissionState],
healthSnapshot: AtlasHealthSnapshot? = nil
) {
self.reclaimableSpaceBytes = reclaimableSpaceBytes
self.findings = findings
self.apps = apps
self.taskRuns = taskRuns
self.recoveryItems = recoveryItems
self.permissions = permissions
self.healthSnapshot = healthSnapshot
}
}
public struct AtlasWorkspaceState: Codable, Hashable, Sendable {
public var snapshot: AtlasWorkspaceSnapshot
public var currentPlan: ActionPlan
public var settings: AtlasSettings
public init(snapshot: AtlasWorkspaceSnapshot, currentPlan: ActionPlan, settings: AtlasSettings) {
self.snapshot = snapshot
self.currentPlan = currentPlan
self.settings = settings
}
}
public enum AtlasScaffoldWorkspace {
public static func state(language: AtlasLanguage = AtlasL10n.currentLanguage) -> AtlasWorkspaceState {
let snapshot = AtlasWorkspaceSnapshot(
reclaimableSpaceBytes: AtlasScaffoldFixtures.findings(language: language).map(\.bytes).reduce(0, +),
findings: AtlasScaffoldFixtures.findings(language: language),
apps: AtlasScaffoldFixtures.apps,
taskRuns: AtlasScaffoldFixtures.taskRuns(language: language),
recoveryItems: AtlasScaffoldFixtures.recoveryItems(language: language),
permissions: AtlasScaffoldFixtures.permissions(language: language),
healthSnapshot: AtlasScaffoldFixtures.healthSnapshot(language: language)
)
return AtlasWorkspaceState(
snapshot: snapshot,
currentPlan: makeInitialPlan(from: snapshot.findings),
settings: AtlasScaffoldFixtures.settings(language: language)
)
}
public static func snapshot(language: AtlasLanguage = AtlasL10n.currentLanguage) -> AtlasWorkspaceSnapshot {
state(language: language).snapshot
}
private static func makeInitialPlan(from findings: [Finding]) -> ActionPlan {
let items = findings.map { finding in
ActionItem(
id: finding.id,
title: finding.risk == .advanced
? AtlasL10n.string("application.plan.inspectPrivileged", finding.title)
: AtlasL10n.string("application.plan.reviewFinding", finding.title),
detail: finding.detail,
kind: finding.category == "Apps" ? .removeApp : (finding.risk == .advanced ? .inspectPermission : .removeCache),
recoverable: finding.risk != .advanced
)
}
let titleKey = findings.count == 1 ? "application.plan.reviewSelected.one" : "application.plan.reviewSelected.other"
return ActionPlan(
title: AtlasL10n.string(titleKey, findings.count),
items: items,
estimatedBytes: findings.map(\.bytes).reduce(0, +)
)
}
}
public protocol AtlasHealthSnapshotProviding: Sendable {
func collectHealthSnapshot() async throws -> AtlasHealthSnapshot
}
public struct AtlasSmartCleanScanResult: Codable, Hashable, Sendable {
public var findings: [Finding]
public var summary: String
public init(findings: [Finding], summary: String) {
self.findings = findings
self.summary = summary
}
}
public protocol AtlasSmartCleanScanProviding: Sendable {
func collectSmartCleanScan() async throws -> AtlasSmartCleanScanResult
}
public protocol AtlasAppInventoryProviding: Sendable {
func collectInstalledApps() async throws -> [AppFootprint]
}
public struct AtlasWorkerCommandResult: Codable, Hashable, Sendable {
public var request: AtlasRequestEnvelope
public var response: AtlasResponseEnvelope
public var events: [AtlasEventEnvelope]
public var snapshot: AtlasWorkspaceSnapshot
public var previewPlan: ActionPlan?
public init(
request: AtlasRequestEnvelope,
response: AtlasResponseEnvelope,
events: [AtlasEventEnvelope],
snapshot: AtlasWorkspaceSnapshot,
previewPlan: ActionPlan? = nil
) {
self.request = request
self.response = response
self.events = events
self.snapshot = snapshot
self.previewPlan = previewPlan
}
}
public protocol AtlasWorkerServing: Sendable {
func submit(_ request: AtlasRequestEnvelope) async throws -> AtlasWorkerCommandResult
}
public enum AtlasWorkspaceControllerError: LocalizedError, Sendable {
case rejected(code: AtlasProtocolErrorCode, reason: String)
case unexpectedResponse(String)
public var errorDescription: String? {
switch self {
case let .rejected(code, reason):
return AtlasL10n.string("application.error.workerRejected", code.rawValue, reason)
case let .unexpectedResponse(reason):
return reason
}
}
}
public struct AtlasPermissionInspectionOutput: Sendable {
public var snapshot: AtlasWorkspaceSnapshot
public var events: [AtlasEventEnvelope]
public init(snapshot: AtlasWorkspaceSnapshot, events: [AtlasEventEnvelope]) {
self.snapshot = snapshot
self.events = events
}
}
public struct AtlasHealthSnapshotOutput: Sendable {
public var snapshot: AtlasWorkspaceSnapshot
public var healthSnapshot: AtlasHealthSnapshot
public init(snapshot: AtlasWorkspaceSnapshot, healthSnapshot: AtlasHealthSnapshot) {
self.snapshot = snapshot
self.healthSnapshot = healthSnapshot
}
}
public struct AtlasPlanPreviewOutput: Sendable {
public var snapshot: AtlasWorkspaceSnapshot
public var actionPlan: ActionPlan
public var summary: String
public init(snapshot: AtlasWorkspaceSnapshot, actionPlan: ActionPlan, summary: String) {
self.snapshot = snapshot
self.actionPlan = actionPlan
self.summary = summary
}
}
public struct AtlasScanOutput: Sendable {
public var snapshot: AtlasWorkspaceSnapshot
public var actionPlan: ActionPlan?
public var events: [AtlasEventEnvelope]
public var progressFraction: Double
public var summary: String
public init(
snapshot: AtlasWorkspaceSnapshot,
actionPlan: ActionPlan?,
events: [AtlasEventEnvelope],
progressFraction: Double,
summary: String
) {
self.snapshot = snapshot
self.actionPlan = actionPlan
self.events = events
self.progressFraction = progressFraction
self.summary = summary
}
}
public struct AtlasTaskActionOutput: Sendable {
public var snapshot: AtlasWorkspaceSnapshot
public var events: [AtlasEventEnvelope]
public var progressFraction: Double
public var summary: String
public init(
snapshot: AtlasWorkspaceSnapshot,
events: [AtlasEventEnvelope],
progressFraction: Double,
summary: String
) {
self.snapshot = snapshot
self.events = events
self.progressFraction = progressFraction
self.summary = summary
}
}
public struct AtlasAppsOutput: Sendable {
public var snapshot: AtlasWorkspaceSnapshot
public var apps: [AppFootprint]
public var summary: String
public init(snapshot: AtlasWorkspaceSnapshot, apps: [AppFootprint], summary: String) {
self.snapshot = snapshot
self.apps = apps
self.summary = summary
}
}
public struct AtlasSettingsOutput: Sendable {
public var settings: AtlasSettings
public init(settings: AtlasSettings) {
self.settings = settings
}
}
public struct AtlasWorkspaceController: Sendable {
private let worker: any AtlasWorkerServing
public init(worker: any AtlasWorkerServing) {
self.worker = worker
}
public func healthSnapshot() async throws -> AtlasHealthSnapshotOutput {
let request = HealthSnapshotUseCase().makeRequest()
let result = try await worker.submit(request)
switch result.response.response {
case let .health(healthSnapshot):
return AtlasHealthSnapshotOutput(snapshot: result.snapshot, healthSnapshot: healthSnapshot)
case let .rejected(code, reason):
throw AtlasWorkspaceControllerError.rejected(code: code, reason: reason)
default:
throw AtlasWorkspaceControllerError.unexpectedResponse("Expected health response for healthSnapshot.")
}
}
public func inspectPermissions() async throws -> AtlasPermissionInspectionOutput {
let request = InspectPermissionsUseCase().makeRequest()
let result = try await worker.submit(request)
switch result.response.response {
case .permissions:
return AtlasPermissionInspectionOutput(snapshot: result.snapshot, events: result.events)
case let .rejected(code, reason):
throw AtlasWorkspaceControllerError.rejected(code: code, reason: reason)
default:
throw AtlasWorkspaceControllerError.unexpectedResponse("Expected permissions response for inspectPermissions.")
}
}
public func startScan(taskID: UUID = UUID()) async throws -> AtlasScanOutput {
let request = StartScanUseCase().makeRequest(taskID: taskID)
let result = try await worker.submit(request)
switch result.response.response {
case .accepted:
return AtlasScanOutput(
snapshot: result.snapshot,
actionPlan: result.previewPlan,
events: result.events,
progressFraction: progressFraction(from: result.events),
summary: summary(from: result.events, fallback: AtlasL10n.string("application.scan.completed"))
)
case let .rejected(code, reason):
throw AtlasWorkspaceControllerError.rejected(code: code, reason: reason)
default:
throw AtlasWorkspaceControllerError.unexpectedResponse("Expected accepted response for startScan.")
}
}
public func previewPlan(findingIDs: [UUID]) async throws -> AtlasPlanPreviewOutput {
let request = PreviewPlanUseCase().makeRequest(findingIDs: findingIDs)
let result = try await worker.submit(request)
switch result.response.response {
case let .preview(plan):
return AtlasPlanPreviewOutput(
snapshot: result.snapshot,
actionPlan: plan,
summary: AtlasL10n.string(plan.items.count == 1 ? "application.preview.updated.one" : "application.preview.updated.other", plan.items.count)
)
case let .rejected(code, reason):
throw AtlasWorkspaceControllerError.rejected(code: code, reason: reason)
default:
throw AtlasWorkspaceControllerError.unexpectedResponse("Expected preview response for previewPlan.")
}
}
public func executePlan(planID: UUID) async throws -> AtlasTaskActionOutput {
let request = ExecutePlanUseCase().makeRequest(planID: planID)
let result = try await worker.submit(request)
switch result.response.response {
case .accepted:
return AtlasTaskActionOutput(
snapshot: result.snapshot,
events: result.events,
progressFraction: progressFraction(from: result.events),
summary: summary(from: result.events, fallback: AtlasL10n.string("application.plan.executed"))
)
case let .rejected(code, reason):
throw AtlasWorkspaceControllerError.rejected(code: code, reason: reason)
default:
throw AtlasWorkspaceControllerError.unexpectedResponse("Expected accepted response for executePlan.")
}
}
public func restoreItems(taskID: UUID = UUID(), itemIDs: [UUID]) async throws -> AtlasTaskActionOutput {
let request = RestoreItemsUseCase().makeRequest(taskID: taskID, itemIDs: itemIDs)
let result = try await worker.submit(request)
switch result.response.response {
case .accepted:
return AtlasTaskActionOutput(
snapshot: result.snapshot,
events: result.events,
progressFraction: progressFraction(from: result.events),
summary: summary(from: result.events, fallback: AtlasL10n.string("application.recovery.completed"))
)
case let .rejected(code, reason):
throw AtlasWorkspaceControllerError.rejected(code: code, reason: reason)
default:
throw AtlasWorkspaceControllerError.unexpectedResponse("Expected accepted response for restoreItems.")
}
}
public func listApps() async throws -> AtlasAppsOutput {
let request = AppsListUseCase().makeRequest()
let result = try await worker.submit(request)
switch result.response.response {
case let .apps(apps):
return AtlasAppsOutput(
snapshot: result.snapshot,
apps: apps,
summary: AtlasL10n.string(apps.count == 1 ? "application.apps.loaded.one" : "application.apps.loaded.other", apps.count)
)
case let .rejected(code, reason):
throw AtlasWorkspaceControllerError.rejected(code: code, reason: reason)
default:
throw AtlasWorkspaceControllerError.unexpectedResponse("Expected apps response for listApps.")
}
}
public func previewAppUninstall(appID: UUID) async throws -> AtlasPlanPreviewOutput {
let request = PreviewAppUninstallUseCase().makeRequest(appID: appID)
let result = try await worker.submit(request)
switch result.response.response {
case let .preview(plan):
return AtlasPlanPreviewOutput(
snapshot: result.snapshot,
actionPlan: plan,
summary: AtlasL10n.string("application.apps.previewUpdated", plan.title)
)
case let .rejected(code, reason):
throw AtlasWorkspaceControllerError.rejected(code: code, reason: reason)
default:
throw AtlasWorkspaceControllerError.unexpectedResponse("Expected preview response for previewAppUninstall.")
}
}
public func executeAppUninstall(appID: UUID) async throws -> AtlasTaskActionOutput {
let request = ExecuteAppUninstallUseCase().makeRequest(appID: appID)
let result = try await worker.submit(request)
switch result.response.response {
case .accepted:
return AtlasTaskActionOutput(
snapshot: result.snapshot,
events: result.events,
progressFraction: progressFraction(from: result.events),
summary: summary(from: result.events, fallback: AtlasL10n.string("application.apps.uninstallCompleted"))
)
case let .rejected(code, reason):
throw AtlasWorkspaceControllerError.rejected(code: code, reason: reason)
default:
throw AtlasWorkspaceControllerError.unexpectedResponse("Expected accepted response for executeAppUninstall.")
}
}
public func settings() async throws -> AtlasSettingsOutput {
let request = SettingsGetUseCase().makeRequest()
let result = try await worker.submit(request)
switch result.response.response {
case let .settings(settings):
return AtlasSettingsOutput(settings: settings)
case let .rejected(code, reason):
throw AtlasWorkspaceControllerError.rejected(code: code, reason: reason)
default:
throw AtlasWorkspaceControllerError.unexpectedResponse("Expected settings response for settings.")
}
}
public func updateSettings(_ settings: AtlasSettings) async throws -> AtlasSettingsOutput {
let request = SettingsSetUseCase().makeRequest(settings: settings)
let result = try await worker.submit(request)
switch result.response.response {
case let .settings(settings):
return AtlasSettingsOutput(settings: settings)
case let .rejected(code, reason):
throw AtlasWorkspaceControllerError.rejected(code: code, reason: reason)
default:
throw AtlasWorkspaceControllerError.unexpectedResponse("Expected settings response for updateSettings.")
}
}
private func progressFraction(from events: [AtlasEventEnvelope]) -> Double {
let fractions = events.compactMap { event -> Double? in
guard case let .taskProgress(_, completed, total) = event.event, total > 0 else {
return nil
}
return Double(completed) / Double(total)
}
return fractions.last ?? 0
}
private func summary(from events: [AtlasEventEnvelope], fallback: String) -> String {
for event in events.reversed() {
if case let .taskFinished(taskRun) = event.event {
return taskRun.summary
}
}
return fallback
}
}
public struct HealthSnapshotUseCase: Sendable {
public init() {}
public func makeRequest() -> AtlasRequestEnvelope {
AtlasRequestEnvelope(command: .healthSnapshot)
}
}
public struct StartScanUseCase: Sendable {
public init() {}
public func makeRequest(taskID: UUID = UUID()) -> AtlasRequestEnvelope {
AtlasRequestEnvelope(command: .startScan(taskID: taskID))
}
}
public struct InspectPermissionsUseCase: Sendable {
public init() {}
public func makeRequest() -> AtlasRequestEnvelope {
AtlasRequestEnvelope(command: .inspectPermissions)
}
}
public struct PreviewPlanUseCase: Sendable {
public init() {}
public func makeRequest(taskID: UUID = UUID(), findingIDs: [UUID]) -> AtlasRequestEnvelope {
AtlasRequestEnvelope(command: .previewPlan(taskID: taskID, findingIDs: findingIDs))
}
}
public struct ExecutePlanUseCase: Sendable {
public init() {}
public func makeRequest(planID: UUID) -> AtlasRequestEnvelope {
AtlasRequestEnvelope(command: .executePlan(planID: planID))
}
}
public struct RestoreItemsUseCase: Sendable {
public init() {}
public func makeRequest(taskID: UUID = UUID(), itemIDs: [UUID]) -> AtlasRequestEnvelope {
AtlasRequestEnvelope(command: .restoreItems(taskID: taskID, itemIDs: itemIDs))
}
}
public struct AppsListUseCase: Sendable {
public init() {}
public func makeRequest() -> AtlasRequestEnvelope {
AtlasRequestEnvelope(command: .appsList)
}
}
public struct PreviewAppUninstallUseCase: Sendable {
public init() {}
public func makeRequest(appID: UUID) -> AtlasRequestEnvelope {
AtlasRequestEnvelope(command: .previewAppUninstall(appID: appID))
}
}
public struct ExecuteAppUninstallUseCase: Sendable {
public init() {}
public func makeRequest(appID: UUID) -> AtlasRequestEnvelope {
AtlasRequestEnvelope(command: .executeAppUninstall(appID: appID))
}
}
public struct SettingsGetUseCase: Sendable {
public init() {}
public func makeRequest() -> AtlasRequestEnvelope {
AtlasRequestEnvelope(command: .settingsGet)
}
}
public struct SettingsSetUseCase: Sendable {
public init() {}
public func makeRequest(settings: AtlasSettings) -> AtlasRequestEnvelope {
AtlasRequestEnvelope(command: .settingsSet(settings))
}
}

View File

@@ -0,0 +1,177 @@
import XCTest
@testable import AtlasApplication
import AtlasDomain
import AtlasProtocol
final class AtlasApplicationTests: XCTestCase {
func testStartScanUsesWorkerEventsToBuildProgressAndSummary() async throws {
let taskID = UUID(uuidString: "20000000-0000-0000-0000-000000000001") ?? UUID()
let request = AtlasRequestEnvelope(command: .startScan(taskID: taskID))
let finishedRun = TaskRun(
id: taskID,
kind: .scan,
status: .completed,
summary: "Scanned 4 finding groups and prepared a Smart Clean preview.",
startedAt: request.issuedAt,
finishedAt: Date()
)
let result = AtlasWorkerCommandResult(
request: request,
response: AtlasResponseEnvelope(
requestID: request.id,
response: .accepted(task: AtlasTaskDescriptor(taskID: taskID, kind: .scan))
),
events: [
AtlasEventEnvelope(event: .taskProgress(taskID: taskID, completed: 1, total: 4)),
AtlasEventEnvelope(event: .taskProgress(taskID: taskID, completed: 4, total: 4)),
AtlasEventEnvelope(event: .taskFinished(finishedRun)),
],
snapshot: AtlasScaffoldWorkspace.snapshot(),
previewPlan: AtlasScaffoldWorkspace.state().currentPlan
)
let controller = AtlasWorkspaceController(worker: FakeWorker(result: result))
let output = try await controller.startScan(taskID: taskID)
XCTAssertEqual(output.progressFraction, 1)
XCTAssertEqual(output.summary, finishedRun.summary)
XCTAssertEqual(output.actionPlan?.items.count, AtlasScaffoldWorkspace.state().currentPlan.items.count)
XCTAssertEqual(output.snapshot.findings.count, AtlasScaffoldWorkspace.snapshot().findings.count)
}
func testPreviewPlanReturnsStructuredPlanFromWorkerResponse() async throws {
let plan = AtlasScaffoldWorkspace.state().currentPlan
let request = AtlasRequestEnvelope(command: .previewPlan(taskID: UUID(), findingIDs: AtlasScaffoldFixtures.findings.map(\.id)))
let result = AtlasWorkerCommandResult(
request: request,
response: AtlasResponseEnvelope(requestID: request.id, response: .preview(plan)),
events: [],
snapshot: AtlasScaffoldWorkspace.snapshot(),
previewPlan: plan
)
let controller = AtlasWorkspaceController(worker: FakeWorker(result: result))
let output = try await controller.previewPlan(findingIDs: AtlasScaffoldFixtures.findings.map(\.id))
XCTAssertEqual(output.actionPlan.title, plan.title)
XCTAssertEqual(output.actionPlan.estimatedBytes, plan.estimatedBytes)
}
func testExecutePlanUsesWorkerEventsToBuildSummary() async throws {
let plan = AtlasScaffoldWorkspace.state().currentPlan
let taskID = UUID(uuidString: "20000000-0000-0000-0000-000000000002") ?? UUID()
let request = AtlasRequestEnvelope(command: .executePlan(planID: plan.id))
let finishedRun = TaskRun(
id: taskID,
kind: .executePlan,
status: .completed,
summary: "Moved 2 Smart Clean items into recovery.",
startedAt: request.issuedAt,
finishedAt: Date()
)
let result = AtlasWorkerCommandResult(
request: request,
response: AtlasResponseEnvelope(
requestID: request.id,
response: .accepted(task: AtlasTaskDescriptor(taskID: taskID, kind: .executePlan))
),
events: [
AtlasEventEnvelope(event: .taskProgress(taskID: taskID, completed: 1, total: 3)),
AtlasEventEnvelope(event: .taskProgress(taskID: taskID, completed: 3, total: 3)),
AtlasEventEnvelope(event: .taskFinished(finishedRun)),
],
snapshot: AtlasScaffoldWorkspace.snapshot(),
previewPlan: nil
)
let controller = AtlasWorkspaceController(worker: FakeWorker(result: result))
let output = try await controller.executePlan(planID: plan.id)
XCTAssertEqual(output.progressFraction, 1)
XCTAssertEqual(output.summary, finishedRun.summary)
}
func testListAppsReturnsStructuredAppFootprints() async throws {
let apps = AtlasScaffoldFixtures.apps
let request = AtlasRequestEnvelope(command: .appsList)
let result = AtlasWorkerCommandResult(
request: request,
response: AtlasResponseEnvelope(requestID: request.id, response: .apps(apps)),
events: [],
snapshot: AtlasScaffoldWorkspace.snapshot(),
previewPlan: nil
)
let controller = AtlasWorkspaceController(worker: FakeWorker(result: result))
let output = try await controller.listApps()
XCTAssertEqual(output.apps.count, apps.count)
XCTAssertEqual(output.snapshot.apps.count, apps.count)
}
func testSettingsUpdateReturnsStructuredSettings() async throws {
var updated = AtlasScaffoldFixtures.settings
updated.recoveryRetentionDays = 14
let request = AtlasRequestEnvelope(command: .settingsSet(updated))
let result = AtlasWorkerCommandResult(
request: request,
response: AtlasResponseEnvelope(requestID: request.id, response: .settings(updated)),
events: [],
snapshot: AtlasScaffoldWorkspace.snapshot(),
previewPlan: nil
)
let controller = AtlasWorkspaceController(worker: FakeWorker(result: result))
let output = try await controller.updateSettings(updated)
XCTAssertEqual(output.settings.recoveryRetentionDays, 14)
}
func testHealthSnapshotReturnsStructuredOverviewData() async throws {
let healthSnapshot = AtlasScaffoldFixtures.healthSnapshot
let request = AtlasRequestEnvelope(command: .healthSnapshot)
let result = AtlasWorkerCommandResult(
request: request,
response: AtlasResponseEnvelope(requestID: request.id, response: .health(healthSnapshot)),
events: [],
snapshot: AtlasScaffoldWorkspace.snapshot(),
previewPlan: nil
)
let controller = AtlasWorkspaceController(worker: FakeWorker(result: result))
let output = try await controller.healthSnapshot()
XCTAssertEqual(output.healthSnapshot.optimizations.count, healthSnapshot.optimizations.count)
XCTAssertEqual(output.healthSnapshot.diskUsedPercent, healthSnapshot.diskUsedPercent)
}
func testInspectPermissionsPropagatesUpdatedSnapshot() async throws {
let permissions = AtlasScaffoldFixtures.permissions
let request = AtlasRequestEnvelope(command: .inspectPermissions)
let result = AtlasWorkerCommandResult(
request: request,
response: AtlasResponseEnvelope(requestID: request.id, response: .permissions(permissions)),
events: permissions.map { AtlasEventEnvelope(event: .permissionUpdated($0)) },
snapshot: AtlasScaffoldWorkspace.snapshot(),
previewPlan: nil
)
let controller = AtlasWorkspaceController(worker: FakeWorker(result: result))
let output = try await controller.inspectPermissions()
XCTAssertEqual(output.snapshot.permissions.count, permissions.count)
XCTAssertEqual(output.events.count, permissions.count)
}
}
private actor FakeWorker: AtlasWorkerServing {
let result: AtlasWorkerCommandResult
init(result: AtlasWorkerCommandResult) {
self.result = result
}
func submit(_ request: AtlasRequestEnvelope) async throws -> AtlasWorkerCommandResult {
result
}
}

View File

@@ -0,0 +1,11 @@
# AtlasCoreAdapters
## Responsibility
- Wrap reusable upstream and local system capabilities behind structured interfaces
## Current Adapters
- `MoleHealthAdapter` wraps `lib/check/health_json.sh` and returns structured overview health data.
- `MoleSmartCleanAdapter` wraps `bin/clean.sh --dry-run` behind a temporary state directory and parses reclaimable findings for Smart Clean.
- `MacAppsInventoryAdapter` scans local application bundles, estimates footprint size, and derives leftover counts for the `Apps` MVP workflow.

View File

@@ -0,0 +1,44 @@
import AtlasInfrastructure
import AtlasProtocol
import Foundation
public struct AtlasLegacyAdapterDescriptor: Identifiable, Hashable, Sendable {
public var id: String { name }
public var name: String
public var capability: String
public var status: AtlasCapabilityStatus
public init(name: String, capability: String, status: AtlasCapabilityStatus) {
self.name = name
self.capability = capability
self.status = status
}
}
public enum AtlasCoreAdapterCatalog {
public static func defaultDescriptors(
status: AtlasCapabilityStatus = AtlasCapabilityStatus()
) -> [AtlasLegacyAdapterDescriptor] {
[
AtlasLegacyAdapterDescriptor(
name: "MoleScanAdapter",
capability: "Structured Smart Clean scanning bridge",
status: status
),
AtlasLegacyAdapterDescriptor(
name: "MoleAppsAdapter",
capability: "Installed apps and leftovers inspection bridge",
status: status
),
AtlasLegacyAdapterDescriptor(
name: "MoleStatusAdapter",
capability: "Overview health and diagnostics bridge",
status: status
),
]
}
public static func bootstrapEvent(taskID: UUID = UUID()) -> AtlasEventEnvelope {
AtlasEventEnvelope(event: .taskProgress(taskID: taskID, completed: 0, total: 1))
}
}

View File

@@ -0,0 +1,103 @@
import AtlasApplication
import AtlasDomain
import Foundation
public struct MacAppsInventoryAdapter: AtlasAppInventoryProviding {
private let searchRoots: [URL]
private let homeDirectoryURL: URL
public init(
searchRoots: [URL]? = nil,
homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser
) {
self.homeDirectoryURL = homeDirectoryURL
self.searchRoots = searchRoots ?? [
URL(fileURLWithPath: "/Applications", isDirectory: true),
homeDirectoryURL.appendingPathComponent("Applications", isDirectory: true),
]
}
public func collectInstalledApps() async throws -> [AppFootprint] {
var apps: [AppFootprint] = []
var seenPaths = Set<String>()
for root in searchRoots where FileManager.default.fileExists(atPath: root.path) {
let entries = (try? FileManager.default.contentsOfDirectory(
at: root,
includingPropertiesForKeys: [.isApplicationKey, .isDirectoryKey],
options: [.skipsHiddenFiles]
)) ?? []
for entry in entries where entry.pathExtension == "app" {
let standardizedPath = entry.resolvingSymlinksInPath().path
guard seenPaths.insert(standardizedPath).inserted else { continue }
if let app = makeAppFootprint(for: entry) {
apps.append(app)
}
}
}
return apps.sorted { lhs, rhs in
if lhs.bytes == rhs.bytes {
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
return lhs.bytes > rhs.bytes
}
}
private func makeAppFootprint(for appURL: URL) -> AppFootprint? {
guard let bundle = Bundle(url: appURL) else { return nil }
let name = bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String
?? appURL.deletingPathExtension().lastPathComponent
let bundleIdentifier = bundle.bundleIdentifier ?? "unknown.\(name.replacingOccurrences(of: " ", with: "-").lowercased())"
let bytes = allocatedSize(for: appURL)
let leftoverItems = leftoverPaths(for: name, bundleIdentifier: bundleIdentifier).filter {
FileManager.default.fileExists(atPath: $0.path)
}.count
return AppFootprint(
name: name,
bundleIdentifier: bundleIdentifier,
bundlePath: appURL.path,
bytes: bytes,
leftoverItems: leftoverItems
)
}
private func leftoverPaths(for appName: String, bundleIdentifier: String) -> [URL] {
[
homeDirectoryURL.appendingPathComponent("Library/Application Support/\(appName)", isDirectory: true),
homeDirectoryURL.appendingPathComponent("Library/Application Support/\(bundleIdentifier)", isDirectory: true),
homeDirectoryURL.appendingPathComponent("Library/Caches/\(bundleIdentifier)", isDirectory: true),
homeDirectoryURL.appendingPathComponent("Library/Containers/\(bundleIdentifier)", isDirectory: true),
homeDirectoryURL.appendingPathComponent("Library/Preferences/\(bundleIdentifier).plist"),
homeDirectoryURL.appendingPathComponent("Library/Saved Application State/\(bundleIdentifier).savedState", isDirectory: true),
homeDirectoryURL.appendingPathComponent("Library/LaunchAgents/\(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

@@ -0,0 +1,109 @@
import AtlasApplication
import AtlasDomain
import Foundation
public struct MoleHealthAdapter: AtlasHealthSnapshotProviding {
private let scriptURL: URL
private let decoder = JSONDecoder()
public init(scriptURL: URL? = nil) {
self.scriptURL = scriptURL ?? Self.defaultScriptURL
}
public func collectHealthSnapshot() async throws -> AtlasHealthSnapshot {
let output = try runHealthScript()
let payload = try decoder.decode(HealthJSONPayload.self, from: output)
return payload.atlasSnapshot
}
private func runHealthScript() throws -> Data {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = [scriptURL.path]
let stdout = Pipe()
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr
try process.run()
process.waitUntilExit()
let errorData = stderr.fileHandleForReading.readDataToEndOfFile()
guard process.terminationStatus == 0 else {
let message = String(data: errorData, encoding: .utf8) ?? "unknown error"
throw MoleHealthAdapterError.commandFailed(message)
}
return stdout.fileHandleForReading.readDataToEndOfFile()
}
private static var defaultScriptURL: URL {
MoleRuntimeLocator.url(for: "lib/check/health_json.sh")
}
}
private enum MoleHealthAdapterError: LocalizedError {
case commandFailed(String)
var errorDescription: String? {
switch self {
case let .commandFailed(message):
return "Mole health adapter failed: \(message)"
}
}
}
private struct HealthJSONPayload: Decodable {
let memoryUsedGB: Double
let memoryTotalGB: Double
let diskUsedGB: Double
let diskTotalGB: Double
let diskUsedPercent: Double
let uptimeDays: Double
let optimizations: [OptimizationPayload]
enum CodingKeys: String, CodingKey {
case memoryUsedGB = "memory_used_gb"
case memoryTotalGB = "memory_total_gb"
case diskUsedGB = "disk_used_gb"
case diskTotalGB = "disk_total_gb"
case diskUsedPercent = "disk_used_percent"
case uptimeDays = "uptime_days"
case optimizations
}
var atlasSnapshot: AtlasHealthSnapshot {
let fallbackMemoryTotalGB = Double(ProcessInfo.processInfo.physicalMemory) / (1024 * 1024 * 1024)
let normalizedMemoryTotalGB = memoryTotalGB > 0 ? memoryTotalGB : fallbackMemoryTotalGB
let normalizedUptimeDays = uptimeDays > 0 ? uptimeDays : (ProcessInfo.processInfo.systemUptime / 86_400)
return AtlasHealthSnapshot(
memoryUsedGB: memoryUsedGB,
memoryTotalGB: normalizedMemoryTotalGB,
diskUsedGB: diskUsedGB,
diskTotalGB: diskTotalGB,
diskUsedPercent: diskUsedPercent,
uptimeDays: normalizedUptimeDays,
optimizations: optimizations.map(\.atlasOptimization)
)
}
}
private struct OptimizationPayload: Decodable {
let category: String
let name: String
let description: String
let action: String
let safe: Bool
var atlasOptimization: AtlasOptimizationRecommendation {
AtlasOptimizationRecommendation(
category: category,
name: name,
detail: description,
action: action,
isSafe: safe
)
}
}

View File

@@ -0,0 +1,22 @@
import Foundation
enum MoleRuntimeLocator {
static func runtimeURL() -> URL {
if let bundled = Bundle.module.resourceURL?.appendingPathComponent("MoleRuntime", isDirectory: true),
FileManager.default.fileExists(atPath: bundled.path) {
return bundled
}
let sourceURL = URL(fileURLWithPath: #filePath)
return sourceURL
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
}
static func url(for relativePath: String) -> URL {
runtimeURL().appendingPathComponent(relativePath)
}
}

View File

@@ -0,0 +1,331 @@
import AtlasApplication
import AtlasDomain
import Foundation
public struct MoleSmartCleanAdapter: AtlasSmartCleanScanProviding {
private let cleanScriptURL: URL
public init(cleanScriptURL: URL? = nil) {
self.cleanScriptURL = cleanScriptURL ?? Self.defaultCleanScriptURL
}
public func collectSmartCleanScan() async throws -> AtlasSmartCleanScanResult {
let stateDirectory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("atlas-smart-clean-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true)
let exportFileURL = stateDirectory.appendingPathComponent("clean-list.txt")
let detailedExportFileURL = stateDirectory.appendingPathComponent("clean-list-detailed.tsv")
let output = try runDryRun(stateDirectory: stateDirectory, exportFileURL: exportFileURL, detailedExportFileURL: detailedExportFileURL)
let findings = Self.parseDetailedFindings(from: detailedExportFileURL).isEmpty
? Self.parseFindings(from: output)
: Self.parseDetailedFindings(from: detailedExportFileURL)
let summary = findings.isEmpty
? "Smart Clean dry run found no reclaimable items from the upstream clean workflow."
: "Smart Clean dry run found \(findings.count) reclaimable item\(findings.count == 1 ? "" : "s")."
return AtlasSmartCleanScanResult(findings: findings, summary: summary)
}
private func runDryRun(stateDirectory: URL, exportFileURL: URL, detailedExportFileURL: URL) throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = [cleanScriptURL.path, "--dry-run"]
var environment = ProcessInfo.processInfo.environment
environment["MO_NO_OPLOG"] = "1"
environment["MOLE_STATE_DIR"] = stateDirectory.path
environment["MOLE_EXPORT_LIST_FILE"] = exportFileURL.path
environment["MOLE_DETAILED_EXPORT_FILE"] = detailedExportFileURL.path
process.environment = environment
let stdout = Pipe()
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr
try process.run()
process.waitUntilExit()
let outputData = stdout.fileHandleForReading.readDataToEndOfFile()
let errorData = stderr.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outputData, encoding: .utf8) ?? ""
guard process.terminationStatus == 0 else {
let message = String(data: errorData, encoding: .utf8) ?? "unknown error"
throw MoleSmartCleanAdapterError.commandFailed(message)
}
return output
}
static func parseDetailedFindings(from exportFileURL: URL) -> [Finding] {
guard let content = try? String(contentsOf: exportFileURL), !content.isEmpty else {
return []
}
struct Entry {
let section: String
let path: String
let sizeKB: Int64
}
let entries: [Entry] = content
.split(whereSeparator: \.isNewline)
.compactMap { rawLine in
let parts = rawLine.split(separator: "\t", omittingEmptySubsequences: false)
guard parts.count == 3 else { return nil }
guard let sizeKB = Int64(parts[2]) else { return nil }
return Entry(section: String(parts[0]), path: String(parts[1]), sizeKB: sizeKB)
}
guard !entries.isEmpty else {
return []
}
let homePath = FileManager.default.homeDirectoryForCurrentUser.path
var parentCounts: [String: Int] = [:]
for entry in entries {
let parentPath = URL(fileURLWithPath: entry.path).deletingLastPathComponent().path
let key = entry.section + "\u{001F}" + parentPath
parentCounts[key, default: 0] += 1
}
struct Group {
var section: String
var displayPath: String
var bytes: Int64
var targetPaths: [String]
var childCount: Int
var order: Int
}
var groups: [String: Group] = [:]
var order = 0
for entry in entries {
let parentPath = URL(fileURLWithPath: entry.path).deletingLastPathComponent().path
let parentKey = entry.section + "\u{001F}" + parentPath
let shouldGroupByParent = parentCounts[parentKey, default: 0] > 1 && parentPath != homePath
let displayPath = shouldGroupByParent ? parentPath : entry.path
let groupKey = entry.section + "\u{001F}" + displayPath
if groups[groupKey] == nil {
groups[groupKey] = Group(
section: entry.section,
displayPath: displayPath,
bytes: 0,
targetPaths: [],
childCount: 0,
order: order
)
order += 1
}
groups[groupKey]!.bytes += entry.sizeKB * 1024
groups[groupKey]!.targetPaths.append(entry.path)
groups[groupKey]!.childCount += 1
}
return groups.values
.sorted { lhs, rhs in
if lhs.bytes == rhs.bytes { return lhs.order < rhs.order }
return lhs.bytes > rhs.bytes
}
.map { group in
Finding(
title: makeDetailedTitle(for: group.displayPath, section: group.section),
detail: makeDetailedDetail(for: group.displayPath, section: group.section, childCount: group.childCount),
bytes: group.bytes,
risk: riskLevel(for: group.section, title: group.displayPath),
category: group.section,
targetPaths: group.targetPaths
)
}
}
private static func makeDetailedTitle(for displayPath: String, section: String) -> String {
let url = URL(fileURLWithPath: displayPath)
let path = displayPath.lowercased()
let last = url.lastPathComponent
let parent = url.deletingLastPathComponent().lastPathComponent
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("/__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" }
if path.contains("/.npm_cache/_npx") { return "npm npx cache" }
if path.contains("/.npm_cache/_logs") { return "npm logs" }
if path.contains("/.oh-my-zsh/cache") { return "Oh My Zsh cache" }
if last == "Caches" { return section == "User essentials" ? "User app caches" : "Caches" }
if last == "Logs" { return "App logs" }
if last == "Attachments" { return "Messages attachment previews" }
if last == FileManager.default.homeDirectoryForCurrentUser.lastPathComponent { return section }
if last == "Default" && !parent.isEmpty { return parent }
return last.replacingOccurrences(of: "_", with: " ")
}
private static func makeDetailedDetail(for displayPath: String, section: String, childCount: Int) -> String {
if childCount > 1 {
return "\(displayPath)\(childCount) items from \(section)"
}
return "\(displayPath)\(section)"
}
static func parseFindings(from output: String) -> [Finding] {
let cleanedOutput = stripANSI(from: output)
let lines = cleanedOutput
.split(whereSeparator: \.isNewline)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
var currentSection = "Smart Clean"
var pendingRuntimeVolumeIndex: Int?
var findings: [Finding] = []
var seenKeys = Set<String>()
for line in lines where !line.isEmpty {
if line.hasPrefix("") {
currentSection = String(line.dropFirst(2)).trimmingCharacters(in: .whitespacesAndNewlines)
pendingRuntimeVolumeIndex = nil
continue
}
if line.hasPrefix("") {
if let finding = makeFinding(from: line, section: currentSection) {
let key = "\(finding.category)|\(finding.title)|\(finding.bytes)"
if seenKeys.insert(key).inserted {
findings.append(finding)
if finding.title == "Xcode runtime volumes" {
pendingRuntimeVolumeIndex = findings.indices.last
} else {
pendingRuntimeVolumeIndex = nil
}
}
}
continue
}
if line.hasPrefix("• Runtime volumes total:"), let index = pendingRuntimeVolumeIndex,
let bytes = parseRuntimeVolumeUnusedBytes(from: line) {
findings[index].bytes = bytes
findings[index].detail = line
pendingRuntimeVolumeIndex = nil
}
}
return findings.sorted { lhs, rhs in
if lhs.bytes == rhs.bytes { return lhs.title < rhs.title }
return lhs.bytes > rhs.bytes
}
}
private static func makeFinding(from line: String, section: String) -> Finding? {
let content = line.replacingOccurrences(of: "", with: "")
let bytes = parseSize(from: content) ?? 0
let title = normalizeTitle(parseTitle(from: content))
guard !title.isEmpty else { return nil }
let detail = parseDetail(from: content, fallbackSection: section)
let risk = riskLevel(for: section, title: title)
return Finding(title: title, detail: detail, bytes: bytes, risk: risk, category: section)
}
private static func normalizeTitle(_ title: String) -> String {
if title.hasPrefix("Would remove ") {
return String(title.dropFirst("Would remove ".count)).trimmingCharacters(in: .whitespacesAndNewlines)
}
return title
}
private static func parseTitle(from content: String) -> String {
let separators = [" · ", ","]
for separator in separators {
if let range = content.range(of: separator) {
return String(content[..<range.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
}
}
return content.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func parseDetail(from content: String, fallbackSection: String) -> String {
if let range = content.range(of: " · ") {
return String(content[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
}
if let range = content.range(of: ",") {
return String(content[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
}
return "Detected from the upstream \(fallbackSection) dry-run preview."
}
private static func riskLevel(for section: String, title: String) -> RiskLevel {
let normalized = "\(section) \(title)".lowercased()
if normalized.contains("launch agent") || normalized.contains("system service") || normalized.contains("orphan") {
return .advanced
}
if normalized.contains("application") || normalized.contains("large file") || normalized.contains("device backup") || normalized.contains("runtime") || normalized.contains("simulator") {
return .review
}
return .safe
}
private static func parseRuntimeVolumeUnusedBytes(from line: String) -> Int64? {
guard let match = line.range(of: #"unused\s+([0-9.]+(?:B|KB|MB|GB|TB))"#, options: .regularExpression) else {
return nil
}
let token = String(line[match]).replacingOccurrences(of: "unused", with: "").trimmingCharacters(in: .whitespaces)
return parseByteCount(token)
}
private static func parseSize(from content: String) -> Int64? {
if let range = content.range(of: #"([0-9.]+(?:B|KB|MB|GB|TB))\s+dry"#, options: .regularExpression) {
let token = String(content[range]).replacingOccurrences(of: "dry", with: "").trimmingCharacters(in: .whitespaces)
return parseByteCount(token)
}
if let range = content.range(of: #"would clean\s+([0-9.]+(?:B|KB|MB|GB|TB))"#, options: .regularExpression) {
let token = String(content[range]).replacingOccurrences(of: "would clean", with: "").trimmingCharacters(in: .whitespaces)
return parseByteCount(token)
}
if let range = content.range(of: #",\s*([0-9.]+(?:B|KB|MB|GB|TB))(?:\s+dry)?$"#, options: .regularExpression) {
let token = String(content[range]).replacingOccurrences(of: ",", with: "").replacingOccurrences(of: "dry", with: "").trimmingCharacters(in: .whitespaces)
return parseByteCount(token)
}
if let range = content.range(of: #"\(([0-9.]+(?:B|KB|MB|GB|TB))\)"#, options: .regularExpression) {
let token = String(content[range]).trimmingCharacters(in: CharacterSet(charactersIn: "()"))
return parseByteCount(token)
}
return nil
}
private static func parseByteCount(_ token: String) -> Int64? {
let cleaned = token.uppercased().replacingOccurrences(of: " ", with: "")
let units: [(String, Double)] = [("TB", 1024 * 1024 * 1024 * 1024), ("GB", 1024 * 1024 * 1024), ("MB", 1024 * 1024), ("KB", 1024), ("B", 1)]
for (suffix, multiplier) in units {
if cleaned.hasSuffix(suffix) {
let valueString = String(cleaned.dropLast(suffix.count))
guard let value = Double(valueString) else { return nil }
return Int64(value * multiplier)
}
}
return nil
}
private static func stripANSI(from text: String) -> String {
let pattern = String("\u{001B}") + "\\[[0-9;]*m"
return text.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
}
private static var defaultCleanScriptURL: URL {
MoleRuntimeLocator.url(for: "bin/clean.sh")
}
}
private enum MoleSmartCleanAdapterError: LocalizedError {
case commandFailed(String)
var errorDescription: String? {
switch self {
case let .commandFailed(message):
return "Mole Smart Clean adapter failed: \(message)"
}
}
}

View File

@@ -0,0 +1,15 @@
#!/bin/bash
# Mole - Analyze command.
# Runs the Go disk analyzer UI.
# Uses bundled analyze-go binary.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GO_BIN="$SCRIPT_DIR/analyze-go"
if [[ -x "$GO_BIN" ]]; then
exec "$GO_BIN" "$@"
fi
echo "Bundled analyzer binary not found. Please reinstall Mole or run mo update to restore it." >&2
exit 1

View File

@@ -0,0 +1,101 @@
#!/bin/bash
set -euo pipefail
# Fix locale issues (similar to Issue #83)
export LC_ALL=C
export LANG=C
# Load common functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$SCRIPT_DIR/lib/core/common.sh"
source "$SCRIPT_DIR/lib/core/sudo.sh"
source "$SCRIPT_DIR/lib/manage/update.sh"
source "$SCRIPT_DIR/lib/manage/autofix.sh"
source "$SCRIPT_DIR/lib/check/all.sh"
cleanup_all() {
stop_inline_spinner 2> /dev/null || true
stop_sudo_session
cleanup_temp_files
}
handle_interrupt() {
cleanup_all
exit 130
}
main() {
# Register unified cleanup handler
trap cleanup_all EXIT
trap handle_interrupt INT TERM
if [[ -t 1 ]]; then
clear
fi
printf '\n'
# Create temp files for parallel execution
local updates_file=$(mktemp_file)
local health_file=$(mktemp_file)
local security_file=$(mktemp_file)
local config_file=$(mktemp_file)
# Run all checks in parallel with spinner
if [[ -t 1 ]]; then
echo -ne "${PURPLE_BOLD}System Check${NC} "
start_inline_spinner "Running checks..."
else
echo -e "${PURPLE_BOLD}System Check${NC}"
echo ""
fi
# Parallel execution
{
check_all_updates > "$updates_file" 2>&1 &
check_system_health > "$health_file" 2>&1 &
check_all_security > "$security_file" 2>&1 &
check_all_config > "$config_file" 2>&1 &
wait
}
if [[ -t 1 ]]; then
stop_inline_spinner
printf '\n'
fi
# Display results
echo -e "${BLUE}${ICON_ARROW}${NC} System updates"
cat "$updates_file"
printf '\n'
echo -e "${BLUE}${ICON_ARROW}${NC} System health"
cat "$health_file"
printf '\n'
echo -e "${BLUE}${ICON_ARROW}${NC} Security posture"
cat "$security_file"
printf '\n'
echo -e "${BLUE}${ICON_ARROW}${NC} Configuration"
cat "$config_file"
# Show suggestions
show_suggestions
# Ask about auto-fix
if ask_for_auto_fix; then
perform_auto_fix
fi
# Ask about updates
if ask_for_updates; then
perform_updates
fi
printf '\n'
}
main "$@"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,292 @@
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$ROOT_DIR/lib/core/common.sh"
source "$ROOT_DIR/lib/core/commands.sh"
command_names=()
for entry in "${MOLE_COMMANDS[@]}"; do
command_names+=("${entry%%:*}")
done
command_words="${command_names[*]}"
emit_zsh_subcommands() {
for entry in "${MOLE_COMMANDS[@]}"; do
printf " '%s:%s'\n" "${entry%%:*}" "${entry#*:}"
done
}
emit_fish_completions() {
local cmd="$1"
for entry in "${MOLE_COMMANDS[@]}"; do
local name="${entry%%:*}"
local desc="${entry#*:}"
printf 'complete -c %s -n "__fish_mole_no_subcommand" -a %s -d "%s"\n' "$cmd" "$name" "$desc"
done
printf '\n'
printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a bash -d "generate bash completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a zsh -d "generate zsh completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
}
if [[ $# -gt 0 ]]; then
normalized_args=()
for arg in "$@"; do
case "$arg" in
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
*)
normalized_args+=("$arg")
;;
esac
done
if [[ ${#normalized_args[@]} -gt 0 ]]; then
set -- "${normalized_args[@]}"
else
set --
fi
fi
# Auto-install mode when run without arguments
if [[ $# -eq 0 ]]; then
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, shell config files will not be modified"
echo ""
fi
# Detect current shell
current_shell="${SHELL##*/}"
if [[ -z "$current_shell" ]]; then
current_shell="$(ps -p "$PPID" -o comm= 2> /dev/null | awk '{print $1}')"
fi
completion_name=""
if command -v mole > /dev/null 2>&1; then
completion_name="mole"
elif command -v mo > /dev/null 2>&1; then
completion_name="mo"
fi
case "$current_shell" in
bash)
config_file="${HOME}/.bashrc"
[[ -f "${HOME}/.bash_profile" ]] && config_file="${HOME}/.bash_profile"
# shellcheck disable=SC2016
completion_line='if output="$('"$completion_name"' completion bash 2>/dev/null)"; then eval "$output"; fi'
;;
zsh)
config_file="${HOME}/.zshrc"
# shellcheck disable=SC2016
completion_line='if output="$('"$completion_name"' completion zsh 2>/dev/null)"; then eval "$output"; fi'
;;
fish)
config_file="${HOME}/.config/fish/config.fish"
# shellcheck disable=SC2016
completion_line='set -l output ('"$completion_name"' completion fish 2>/dev/null); and echo "$output" | source'
;;
*)
log_error "Unsupported shell: $current_shell"
echo " mole completion <bash|zsh|fish>"
exit 1
;;
esac
if [[ -z "$completion_name" ]]; then
if [[ -f "$config_file" ]] && grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would remove stale completion entries from $config_file${NC}"
echo ""
else
original_mode=""
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
temp_file="$(mktemp)"
grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
mv "$temp_file" "$config_file"
if [[ -n "$original_mode" ]]; then
chmod "$original_mode" "$config_file" 2> /dev/null || true
fi
echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file"
echo ""
fi
fi
log_error "mole not found in PATH, install Mole before enabling completion"
exit 1
fi
# Check if already installed and normalize to latest line
if [[ -f "$config_file" ]] && grep -Eq "(mole|mo)[[:space:]]+completion" "$config_file" 2> /dev/null; then
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would normalize completion entry in $config_file${NC}"
echo ""
exit 0
fi
original_mode=""
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
temp_file="$(mktemp)"
grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
mv "$temp_file" "$config_file"
if [[ -n "$original_mode" ]]; then
chmod "$original_mode" "$config_file" 2> /dev/null || true
fi
{
echo ""
echo "# Mole shell completion"
echo "$completion_line"
} >> "$config_file"
echo ""
echo -e "${GREEN}${ICON_SUCCESS}${NC} Shell completion updated in $config_file"
echo ""
exit 0
fi
# Prompt user for installation
echo ""
echo -e "${GRAY}Will add to ${config_file}:${NC}"
echo " $completion_line"
echo ""
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made"
exit 0
fi
echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}${current_shell}${NC}? ${GRAY}Enter confirm / Q cancel${NC}: "
IFS= read -r -s -n1 key || key=""
drain_pending_input
echo ""
case "$key" in
$'\e' | [Qq] | [Nn])
echo -e "${YELLOW}Cancelled${NC}"
exit 0
;;
"" | $'\n' | $'\r' | [Yy]) ;;
*)
log_error "Invalid key"
exit 1
;;
esac
# Create config file if it doesn't exist
if [[ ! -f "$config_file" ]]; then
mkdir -p "$(dirname "$config_file")"
touch "$config_file"
fi
# Remove previous Mole completion lines to avoid duplicates
if [[ -f "$config_file" ]]; then
original_mode=""
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
temp_file="$(mktemp)"
grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
mv "$temp_file" "$config_file"
if [[ -n "$original_mode" ]]; then
chmod "$original_mode" "$config_file" 2> /dev/null || true
fi
fi
# Add completion line
{
echo ""
echo "# Mole shell completion"
echo "$completion_line"
} >> "$config_file"
echo -e "${GREEN}${ICON_SUCCESS}${NC} Completion added to $config_file"
echo ""
echo ""
echo -e "${GRAY}To activate now:${NC}"
echo -e " ${GREEN}source $config_file${NC}"
exit 0
fi
case "$1" in
bash)
cat << EOF
_mole_completions()
{
local cur_word prev_word
cur_word="\${COMP_WORDS[\$COMP_CWORD]}"
prev_word="\${COMP_WORDS[\$COMP_CWORD-1]}"
if [ "\$COMP_CWORD" -eq 1 ]; then
COMPREPLY=( \$(compgen -W "$command_words" -- "\$cur_word") )
else
case "\$prev_word" in
completion)
COMPREPLY=( \$(compgen -W "bash zsh fish" -- "\$cur_word") )
;;
*)
COMPREPLY=()
;;
esac
fi
}
complete -F _mole_completions mole mo
EOF
;;
zsh)
printf '#compdef mole mo\n\n'
printf '_mole() {\n'
printf ' local -a subcommands\n'
printf ' subcommands=(\n'
emit_zsh_subcommands
printf ' )\n'
printf " _describe 'subcommand' subcommands\n"
printf '}\n\n'
printf 'compdef _mole mole mo\n'
;;
fish)
printf '# Completions for mole\n'
emit_fish_completions mole
printf '\n# Completions for mo (alias)\n'
emit_fish_completions mo
printf '\nfunction __fish_mole_no_subcommand\n'
printf ' for i in (commandline -opc)\n'
# shellcheck disable=SC2016
printf ' if contains -- $i %s\n' "$command_words"
printf ' return 1\n'
printf ' end\n'
printf ' end\n'
printf ' return 0\n'
printf 'end\n\n'
printf 'function __fish_see_subcommand_path\n'
printf ' string match -q -- "completion" (commandline -opc)[1]\n'
printf 'end\n'
;;
*)
cat << 'EOF'
Usage: mole completion [bash|zsh|fish]
Setup shell tab completion for mole and mo commands.
Auto-install:
mole completion # Auto-detect shell and install
mole completion --dry-run # Preview config changes without writing files
Manual install:
mole completion bash # Generate bash completion script
mole completion zsh # Generate zsh completion script
mole completion fish # Generate fish completion script
Examples:
# Auto-install (recommended)
mole completion
# Manual install - Bash
eval "$(mole completion bash)"
# Manual install - Zsh
eval "$(mole completion zsh)"
# Manual install - Fish
mole completion fish | source
EOF
exit 1
;;
esac

View File

@@ -0,0 +1,725 @@
#!/bin/bash
# Mole - Installer command
# Find and remove installer files - .dmg, .pkg, .mpkg, .iso, .xip, .zip
set -euo pipefail
# shellcheck disable=SC2154
# External variables set by menu_paginated.sh and environment
declare MOLE_SELECTION_RESULT
declare MOLE_INSTALLER_SCAN_MAX_DEPTH
export LC_ALL=C
export LANG=C
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../lib/core/common.sh"
source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh"
cleanup() {
if [[ "${IN_ALT_SCREEN:-0}" == "1" ]]; then
leave_alt_screen
IN_ALT_SCREEN=0
fi
show_cursor
cleanup_temp_files
}
trap cleanup EXIT
trap 'trap - EXIT; cleanup; exit 130' INT TERM
# Scan configuration
readonly INSTALLER_SCAN_MAX_DEPTH_DEFAULT=2
readonly INSTALLER_SCAN_PATHS=(
"$HOME/Downloads"
"$HOME/Desktop"
"$HOME/Documents"
"$HOME/Public"
"$HOME/Library/Downloads"
"/Users/Shared"
"/Users/Shared/Downloads"
"$HOME/Library/Caches/Homebrew"
"$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads"
"$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads"
"$HOME/Library/Application Support/Telegram Desktop"
"$HOME/Downloads/Telegram Desktop"
)
readonly MAX_ZIP_ENTRIES=50
ZIP_LIST_CMD=()
IN_ALT_SCREEN=0
if command -v zipinfo > /dev/null 2>&1; then
ZIP_LIST_CMD=(zipinfo -1)
elif command -v unzip > /dev/null 2>&1; then
ZIP_LIST_CMD=(unzip -Z -1)
fi
TERMINAL_WIDTH=0
# Check for installer payloads inside ZIP - check first N entries for installer patterns
is_installer_zip() {
local zip="$1"
local cap="$MAX_ZIP_ENTRIES"
[[ ${#ZIP_LIST_CMD[@]} -gt 0 ]] || return 1
if ! "${ZIP_LIST_CMD[@]}" "$zip" 2> /dev/null |
head -n "$cap" |
awk '
/\.(app|pkg|dmg|xip)(\/|$)/ { found=1; exit 0 }
END { exit found ? 0 : 1 }
'; then
return 1
fi
return 0
}
handle_candidate_file() {
local file="$1"
[[ -L "$file" ]] && return 0 # Skip symlinks explicitly
case "$file" in
*.dmg | *.pkg | *.mpkg | *.iso | *.xip)
echo "$file"
;;
*.zip)
[[ -r "$file" ]] || return 0
if is_installer_zip "$file" 2> /dev/null; then
echo "$file"
fi
;;
esac
}
scan_installers_in_path() {
local path="$1"
local max_depth="${MOLE_INSTALLER_SCAN_MAX_DEPTH:-$INSTALLER_SCAN_MAX_DEPTH_DEFAULT}"
[[ -d "$path" ]] || return 0
local file
if command -v fd > /dev/null 2>&1; then
while IFS= read -r file; do
handle_candidate_file "$file"
done < <(
fd --no-ignore --hidden --type f --max-depth "$max_depth" \
-e dmg -e pkg -e mpkg -e iso -e xip -e zip \
. "$path" 2> /dev/null || true
)
else
while IFS= read -r file; do
handle_candidate_file "$file"
done < <(
find "$path" -maxdepth "$max_depth" -type f \
\( -name '*.dmg' -o -name '*.pkg' -o -name '*.mpkg' \
-o -name '*.iso' -o -name '*.xip' -o -name '*.zip' \) \
2> /dev/null || true
)
fi
}
scan_all_installers() {
for path in "${INSTALLER_SCAN_PATHS[@]}"; do
scan_installers_in_path "$path"
done
}
# Initialize stats
declare -i total_deleted=0
declare -i total_size_freed_kb=0
# Global arrays for installer data
declare -a INSTALLER_PATHS=()
declare -a INSTALLER_SIZES=()
declare -a INSTALLER_SOURCES=()
declare -a DISPLAY_NAMES=()
# Get source directory display name - for example "Downloads" or "Desktop"
get_source_display() {
local file_path="$1"
local dir_path="${file_path%/*}"
# Match against known paths and return friendly names
case "$dir_path" in
"$HOME/Downloads"*) echo "Downloads" ;;
"$HOME/Desktop"*) echo "Desktop" ;;
"$HOME/Documents"*) echo "Documents" ;;
"$HOME/Public"*) echo "Public" ;;
"$HOME/Library/Downloads"*) echo "Library" ;;
"/Users/Shared"*) echo "Shared" ;;
"$HOME/Library/Caches/Homebrew"*) echo "Homebrew" ;;
"$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads"*) echo "iCloud" ;;
"$HOME/Library/Containers/com.apple.mail"*) echo "Mail" ;;
*"Telegram Desktop"*) echo "Telegram" ;;
*) echo "${dir_path##*/}" ;;
esac
}
get_terminal_width() {
if [[ $TERMINAL_WIDTH -le 0 ]]; then
TERMINAL_WIDTH=$(tput cols 2> /dev/null || echo 80)
fi
echo "$TERMINAL_WIDTH"
}
# Format installer display with alignment - similar to purge command
format_installer_display() {
local filename="$1"
local size_str="$2"
local source="$3"
# Terminal width for alignment
local terminal_width
terminal_width=$(get_terminal_width)
local fixed_width=24 # Reserve for size and source
local available_width=$((terminal_width - fixed_width))
# Bounds check: 20-40 chars for filename
[[ $available_width -lt 20 ]] && available_width=20
[[ $available_width -gt 40 ]] && available_width=40
# Truncate filename if needed
local truncated_name
truncated_name=$(truncate_by_display_width "$filename" "$available_width")
local current_width
current_width=$(get_display_width "$truncated_name")
local char_count=${#truncated_name}
local padding=$((available_width - current_width))
local printf_width=$((char_count + padding))
# Format: "filename size | source"
printf "%-*s %8s | %-10s" "$printf_width" "$truncated_name" "$size_str" "$source"
}
# Collect all installers with their metadata
collect_installers() {
# Clear previous results
INSTALLER_PATHS=()
INSTALLER_SIZES=()
INSTALLER_SOURCES=()
DISPLAY_NAMES=()
# Start scanning with spinner
if [[ -t 1 ]]; then
start_inline_spinner "Scanning for installers..."
fi
# Start debug session
debug_operation_start "Collect Installers" "Scanning for redundant installer files"
# Scan all paths, deduplicate, and sort results
local -a all_files=()
while IFS= read -r file; do
[[ -z "$file" ]] && continue
all_files+=("$file")
debug_file_action "Found installer" "$file"
done < <(scan_all_installers | sort -u)
if [[ -t 1 ]]; then
stop_inline_spinner
fi
if [[ ${#all_files[@]} -eq 0 ]]; then
if [[ "${IN_ALT_SCREEN:-0}" != "1" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No installer files to clean"
fi
return 1
fi
# Calculate sizes with spinner
if [[ -t 1 ]]; then
start_inline_spinner "Calculating sizes..."
fi
# Process each installer
for file in "${all_files[@]}"; do
# Calculate file size
local file_size=0
if [[ -f "$file" ]]; then
file_size=$(get_file_size "$file")
fi
# Get source directory
local source
source=$(get_source_display "$file")
# Format human readable size
local size_human
size_human=$(bytes_to_human "$file_size")
# Get display filename - strip Homebrew hash prefix if present
local display_name
display_name=$(basename "$file")
if [[ "$source" == "Homebrew" ]]; then
# Homebrew names often look like: sha256--name--version
# Strip the leading hash if it matches [0-9a-f]{64}--
if [[ "$display_name" =~ ^[0-9a-f]{64}--(.*) ]]; then
display_name="${BASH_REMATCH[1]}"
fi
fi
# Format display with alignment
local display
display=$(format_installer_display "$display_name" "$size_human" "$source")
# Store installer data in parallel arrays
INSTALLER_PATHS+=("$file")
INSTALLER_SIZES+=("$file_size")
INSTALLER_SOURCES+=("$source")
DISPLAY_NAMES+=("$display")
done
if [[ -t 1 ]]; then
stop_inline_spinner
fi
return 0
}
# Installer selector with Select All / Invert support
select_installers() {
local -a items=("$@")
local total_items=${#items[@]}
local clear_line=$'\r\033[2K'
if [[ $total_items -eq 0 ]]; then
return 1
fi
# Calculate items per page based on terminal height
_get_items_per_page() {
local term_height=24
if [[ -t 0 ]] || [[ -t 2 ]]; then
term_height=$(stty size < /dev/tty 2> /dev/null | awk '{print $1}')
fi
if [[ -z "$term_height" || $term_height -le 0 ]]; then
if command -v tput > /dev/null 2>&1; then
term_height=$(tput lines 2> /dev/null || echo "24")
else
term_height=24
fi
fi
local reserved=6
local available=$((term_height - reserved))
if [[ $available -lt 3 ]]; then
echo 3
elif [[ $available -gt 50 ]]; then
echo 50
else
echo "$available"
fi
}
local items_per_page=$(_get_items_per_page)
local cursor_pos=0
local top_index=0
# Initialize selection (all unselected by default)
local -a selected=()
for ((i = 0; i < total_items; i++)); do
selected[i]=false
done
local original_stty=""
if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
original_stty=$(stty -g 2> /dev/null || echo "")
fi
restore_terminal() {
trap - EXIT INT TERM
if [[ "${IN_ALT_SCREEN:-0}" == "1" ]]; then
leave_alt_screen
IN_ALT_SCREEN=0
fi
show_cursor
if [[ -n "${original_stty:-}" ]]; then
stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true
fi
}
handle_interrupt() {
restore_terminal
exit 130
}
draw_menu() {
items_per_page=$(_get_items_per_page)
local max_top_index=0
if [[ $total_items -gt $items_per_page ]]; then
max_top_index=$((total_items - items_per_page))
fi
if [[ $top_index -gt $max_top_index ]]; then
top_index=$max_top_index
fi
if [[ $top_index -lt 0 ]]; then
top_index=0
fi
local visible_count=$((total_items - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -gt $((visible_count - 1)) ]]; then
cursor_pos=$((visible_count - 1))
fi
if [[ $cursor_pos -lt 0 ]]; then
cursor_pos=0
fi
printf "\033[H"
# Calculate selected size and count
local selected_size=0
local selected_count=0
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
selected_size=$((selected_size + ${INSTALLER_SIZES[i]:-0}))
((selected_count++))
fi
done
local selected_human
selected_human=$(bytes_to_human "$selected_size")
# Show position indicator if scrolling is needed
local scroll_indicator=""
if [[ $total_items -gt $items_per_page ]]; then
local current_pos=$((top_index + cursor_pos + 1))
scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}"
fi
printf "${PURPLE_BOLD}Select Installers to Remove${NC}%s ${GRAY}, ${selected_human}, ${selected_count} selected${NC}\n" "$scroll_indicator"
printf "%s\n" "$clear_line"
# Calculate visible range
local end_index=$((top_index + visible_count))
# Draw only visible items
for ((i = top_index; i < end_index; i++)); do
local checkbox="$ICON_EMPTY"
[[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID"
local rel_pos=$((i - top_index))
if [[ $rel_pos -eq $cursor_pos ]]; then
printf "%s${CYAN}${ICON_ARROW} %s %s${NC}\n" "$clear_line" "$checkbox" "${items[i]}"
else
printf "%s %s %s\n" "$clear_line" "$checkbox" "${items[i]}"
fi
done
# Fill empty slots
local items_shown=$visible_count
for ((i = items_shown; i < items_per_page; i++)); do
printf "%s\n" "$clear_line"
done
printf "%s\n" "$clear_line"
printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}\n" "$clear_line"
}
trap restore_terminal EXIT
trap handle_interrupt INT TERM
stty -echo -icanon intr ^C 2> /dev/null || true
hide_cursor
if [[ -t 1 ]]; then
printf "\033[2J\033[H" >&2
fi
# Main loop
while true; do
draw_menu
IFS= read -r -s -n1 key || key=""
case "$key" in
$'\x1b')
IFS= read -r -s -n1 -t 1 key2 || key2=""
if [[ "$key2" == "[" ]]; then
IFS= read -r -s -n1 -t 1 key3 || key3=""
case "$key3" in
A) # Up arrow
if [[ $cursor_pos -gt 0 ]]; then
((cursor_pos--))
elif [[ $top_index -gt 0 ]]; then
((top_index--))
fi
;;
B) # Down arrow
local absolute_index=$((top_index + cursor_pos))
local last_index=$((total_items - 1))
if [[ $absolute_index -lt $last_index ]]; then
local visible_count=$((total_items - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
((cursor_pos++))
elif [[ $((top_index + visible_count)) -lt $total_items ]]; then
((top_index++))
fi
fi
;;
esac
else
# ESC alone
restore_terminal
return 1
fi
;;
" ") # Space - toggle current item
local idx=$((top_index + cursor_pos))
if [[ ${selected[idx]} == true ]]; then
selected[idx]=false
else
selected[idx]=true
fi
;;
"a" | "A") # Select all
for ((i = 0; i < total_items; i++)); do
selected[i]=true
done
;;
"i" | "I") # Invert selection
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
selected[i]=false
else
selected[i]=true
fi
done
;;
"q" | "Q" | $'\x03') # Quit or Ctrl-C
restore_terminal
return 1
;;
"" | $'\n' | $'\r') # Enter - confirm
MOLE_SELECTION_RESULT=""
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
[[ -n "$MOLE_SELECTION_RESULT" ]] && MOLE_SELECTION_RESULT+=","
MOLE_SELECTION_RESULT+="$i"
fi
done
restore_terminal
return 0
;;
esac
done
}
# Show menu for user selection
show_installer_menu() {
if [[ ${#DISPLAY_NAMES[@]} -eq 0 ]]; then
return 1
fi
echo ""
MOLE_SELECTION_RESULT=""
if ! select_installers "${DISPLAY_NAMES[@]}"; then
return 1
fi
return 0
}
# Delete selected installers
delete_selected_installers() {
# Parse selection indices
local -a selected_indices=()
[[ -n "$MOLE_SELECTION_RESULT" ]] && IFS=',' read -ra selected_indices <<< "$MOLE_SELECTION_RESULT"
if [[ ${#selected_indices[@]} -eq 0 ]]; then
return 1
fi
# Calculate total size for confirmation
local confirm_size=0
for idx in "${selected_indices[@]}"; do
if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_SIZES[@]} ]]; then
confirm_size=$((confirm_size + ${INSTALLER_SIZES[$idx]:-0}))
fi
done
local confirm_human
confirm_human=$(bytes_to_human "$confirm_size")
# Show files to be deleted
echo -e "${PURPLE_BOLD}Files to be removed:${NC}"
for idx in "${selected_indices[@]}"; do
if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_PATHS[@]} ]]; then
local file_path="${INSTALLER_PATHS[$idx]}"
local file_size="${INSTALLER_SIZES[$idx]}"
local size_human
size_human=$(bytes_to_human "$file_size")
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(basename "$file_path") ${GRAY}, ${size_human}${NC}"
fi
done
# Confirm deletion
echo ""
echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#selected_indices[@]} installers, ${confirm_human} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: "
IFS= read -r -s -n1 confirm || confirm=""
case "$confirm" in
$'\e' | q | Q)
return 1
;;
"" | $'\n' | $'\r')
printf "\r\033[K" # Clear prompt line
echo "" # Single line break
;;
*)
return 1
;;
esac
# Delete each selected installer with spinner
total_deleted=0
total_size_freed_kb=0
if [[ -t 1 ]]; then
start_inline_spinner "Removing installers..."
fi
for idx in "${selected_indices[@]}"; do
if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then
continue
fi
local file_path="${INSTALLER_PATHS[$idx]}"
local file_size="${INSTALLER_SIZES[$idx]}"
# Validate path before deletion
if ! validate_path_for_deletion "$file_path"; then
continue
fi
# Delete the file
if safe_remove "$file_path" true; then
total_size_freed_kb=$((total_size_freed_kb + ((file_size + 1023) / 1024)))
total_deleted=$((total_deleted + 1))
fi
done
if [[ -t 1 ]]; then
stop_inline_spinner
fi
return 0
}
# Perform the installers cleanup
perform_installers() {
# Enter alt screen for scanning and selection
if [[ -t 1 ]]; then
enter_alt_screen
IN_ALT_SCREEN=1
printf "\033[2J\033[H" >&2
fi
# Collect installers
if ! collect_installers; then
if [[ -t 1 ]]; then
leave_alt_screen
IN_ALT_SCREEN=0
fi
printf '\n'
echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No installer files to clean"
printf '\n'
return 2 # Nothing to clean
fi
# Show menu
if ! show_installer_menu; then
if [[ -t 1 ]]; then
leave_alt_screen
IN_ALT_SCREEN=0
fi
return 1 # User cancelled
fi
# Leave alt screen before deletion (so confirmation and results are on main screen)
if [[ -t 1 ]]; then
leave_alt_screen
IN_ALT_SCREEN=0
fi
# Delete selected
if ! delete_selected_installers; then
return 1
fi
return 0
}
show_summary() {
local summary_heading="Installers cleaned"
local -a summary_details=()
local dry_run_mode="${MOLE_DRY_RUN:-0}"
if [[ "$dry_run_mode" == "1" ]]; then
summary_heading="Dry run complete - no changes made"
fi
if [[ $total_deleted -gt 0 ]]; then
local freed_mb
freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}')
if [[ "$dry_run_mode" == "1" ]]; then
summary_details+=("Would remove ${GREEN}$total_deleted${NC} installers, free ${GREEN}${freed_mb}MB${NC}")
else
summary_details+=("Removed ${GREEN}$total_deleted${NC} installers, freed ${GREEN}${freed_mb}MB${NC}")
summary_details+=("Your Mac is cleaner now!")
fi
else
summary_details+=("No installers were removed")
fi
print_summary_block "$summary_heading" "${summary_details[@]}"
printf '\n'
}
main() {
for arg in "$@"; do
case "$arg" in
"--help" | "-h")
show_installer_help
exit 0
;;
"--debug")
export MO_DEBUG=1
;;
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
*)
echo "Unknown option: $arg"
exit 1
;;
esac
done
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No installer files will be removed"
printf '\n'
fi
hide_cursor
perform_installers
local exit_code=$?
show_cursor
case $exit_code in
0)
show_summary
;;
1)
printf '\n'
;;
2)
# Already handled by collect_installers
;;
esac
return 0
}
# Only run main if not in test mode
if [[ "${MOLE_TEST_MODE:-0}" != "1" ]]; then
main "$@"
fi

View File

@@ -0,0 +1,521 @@
#!/bin/bash
# Mole - Optimize command.
# Runs system maintenance checks and fixes.
# Supports dry-run where applicable.
set -euo pipefail
# Fix locale issues.
export LC_ALL=C
export LANG=C
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$SCRIPT_DIR/lib/core/common.sh"
# Clean temp files on exit.
trap cleanup_temp_files EXIT INT TERM
source "$SCRIPT_DIR/lib/core/sudo.sh"
source "$SCRIPT_DIR/lib/manage/update.sh"
source "$SCRIPT_DIR/lib/manage/autofix.sh"
source "$SCRIPT_DIR/lib/optimize/maintenance.sh"
source "$SCRIPT_DIR/lib/optimize/tasks.sh"
source "$SCRIPT_DIR/lib/check/health_json.sh"
source "$SCRIPT_DIR/lib/check/all.sh"
source "$SCRIPT_DIR/lib/manage/whitelist.sh"
print_header() {
printf '\n'
echo -e "${PURPLE_BOLD}Optimize and Check${NC}"
}
run_system_checks() {
# Skip checks in dry-run mode.
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
return 0
fi
unset AUTO_FIX_SUMMARY AUTO_FIX_DETAILS
unset MOLE_SECURITY_FIXES_SHOWN
unset MOLE_SECURITY_FIXES_SKIPPED
echo ""
check_all_updates
echo ""
check_system_health
echo ""
check_all_security
if ask_for_security_fixes; then
perform_security_fixes
fi
if [[ "${MOLE_SECURITY_FIXES_SKIPPED:-}" != "true" ]]; then
echo ""
fi
check_all_config
echo ""
show_suggestions
if ask_for_updates; then
perform_updates
fi
if ask_for_auto_fix; then
perform_auto_fix
fi
}
show_optimization_summary() {
local safe_count="${OPTIMIZE_SAFE_COUNT:-0}"
local confirm_count="${OPTIMIZE_CONFIRM_COUNT:-0}"
if ((safe_count == 0 && confirm_count == 0)) && [[ -z "${AUTO_FIX_SUMMARY:-}" ]]; then
return
fi
local summary_title
local -a summary_details=()
local total_applied=$((safe_count + confirm_count))
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
summary_title="Dry Run Complete, No Changes Made"
summary_details+=("Would apply ${YELLOW}${total_applied:-0}${NC} optimizations")
summary_details+=("Run without ${YELLOW}--dry-run${NC} to apply these changes")
else
summary_title="Optimization and Check Complete"
# Build statistics summary
local -a stats=()
local cache_kb="${OPTIMIZE_CACHE_CLEANED_KB:-0}"
local db_count="${OPTIMIZE_DATABASES_COUNT:-0}"
local config_count="${OPTIMIZE_CONFIGS_REPAIRED:-0}"
if [[ "$cache_kb" =~ ^[0-9]+$ ]] && [[ "$cache_kb" -gt 0 ]]; then
local cache_human=$(bytes_to_human "$((cache_kb * 1024))")
stats+=("${cache_human} cache cleaned")
fi
if [[ "$db_count" =~ ^[0-9]+$ ]] && [[ "$db_count" -gt 0 ]]; then
stats+=("${db_count} databases optimized")
fi
if [[ "$config_count" =~ ^[0-9]+$ ]] && [[ "$config_count" -gt 0 ]]; then
stats+=("${config_count} configs repaired")
fi
# Build first summary line with most important stat only
local key_stat=""
if [[ "$cache_kb" =~ ^[0-9]+$ ]] && [[ "$cache_kb" -gt 0 ]]; then
local cache_human=$(bytes_to_human "$((cache_kb * 1024))")
key_stat="${cache_human} cache cleaned"
elif [[ "$db_count" =~ ^[0-9]+$ ]] && [[ "$db_count" -gt 0 ]]; then
key_stat="${db_count} databases optimized"
elif [[ "$config_count" =~ ^[0-9]+$ ]] && [[ "$config_count" -gt 0 ]]; then
key_stat="${config_count} configs repaired"
fi
if [[ -n "$key_stat" ]]; then
summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations, ${key_stat}")
else
summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations, all services tuned")
fi
local summary_line3=""
if [[ -n "${AUTO_FIX_SUMMARY:-}" ]]; then
summary_line3="${AUTO_FIX_SUMMARY}"
if [[ -n "${AUTO_FIX_DETAILS:-}" ]]; then
local detail_join
detail_join=$(echo "${AUTO_FIX_DETAILS}" | paste -sd ", " -)
[[ -n "$detail_join" ]] && summary_line3+=": ${detail_join}"
fi
summary_details+=("$summary_line3")
fi
summary_details+=("System fully optimized")
fi
print_summary_block "$summary_title" "${summary_details[@]}"
}
show_system_health() {
local health_json="$1"
local mem_used=$(echo "$health_json" | jq -r '.memory_used_gb // 0' 2> /dev/null || echo "0")
local mem_total=$(echo "$health_json" | jq -r '.memory_total_gb // 0' 2> /dev/null || echo "0")
local disk_used=$(echo "$health_json" | jq -r '.disk_used_gb // 0' 2> /dev/null || echo "0")
local disk_total=$(echo "$health_json" | jq -r '.disk_total_gb // 0' 2> /dev/null || echo "0")
local disk_percent=$(echo "$health_json" | jq -r '.disk_used_percent // 0' 2> /dev/null || echo "0")
local uptime=$(echo "$health_json" | jq -r '.uptime_days // 0' 2> /dev/null || echo "0")
mem_used=${mem_used:-0}
mem_total=${mem_total:-0}
disk_used=${disk_used:-0}
disk_total=${disk_total:-0}
disk_percent=${disk_percent:-0}
uptime=${uptime:-0}
printf "${ICON_ADMIN} System %.0f/%.0f GB RAM | %.0f/%.0f GB Disk | Uptime %.0fd\n" \
"$mem_used" "$mem_total" "$disk_used" "$disk_total" "$uptime"
}
parse_optimizations() {
local health_json="$1"
echo "$health_json" | jq -c '.optimizations[]' 2> /dev/null
}
announce_action() {
local name="$1"
local desc="$2"
local kind="$3"
if [[ "${FIRST_ACTION:-true}" == "true" ]]; then
export FIRST_ACTION=false
else
echo ""
fi
echo -e "${BLUE}${ICON_ARROW} ${name}${NC}"
}
touchid_configured() {
local pam_file="/etc/pam.d/sudo"
[[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null
}
touchid_supported() {
if command -v bioutil > /dev/null 2>&1; then
if bioutil -r 2> /dev/null | grep -qi "Touch ID"; then
return 0
fi
fi
# Fallback: Apple Silicon Macs usually have Touch ID.
if [[ "$(uname -m)" == "arm64" ]]; then
return 0
fi
return 1
}
cleanup_path() {
local raw_path="$1"
local label="$2"
local expanded_path="${raw_path/#\~/$HOME}"
if [[ ! -e "$expanded_path" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} $label"
return
fi
if should_protect_path "$expanded_path"; then
echo -e "${GRAY}${ICON_WARNING}${NC} Protected $label"
return
fi
local size_kb
size_kb=$(get_path_size_kb "$expanded_path")
local size_display=""
if [[ "$size_kb" =~ ^[0-9]+$ && "$size_kb" -gt 0 ]]; then
size_display=$(bytes_to_human "$((size_kb * 1024))")
fi
local removed=false
if safe_remove "$expanded_path" true; then
removed=true
elif request_sudo_access "Removing $label requires admin access"; then
if safe_sudo_remove "$expanded_path"; then
removed=true
fi
fi
if [[ "$removed" == "true" ]]; then
if [[ -n "$size_display" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}${size_display}${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS}${NC} $label"
fi
else
echo -e "${GRAY}${ICON_WARNING}${NC} Skipped $label${NC}"
echo -e "${GRAY}${ICON_REVIEW}${NC} ${GRAY}Grant Full Disk Access to your terminal, then retry${NC}"
fi
}
ensure_directory() {
local raw_path="$1"
local expanded_path="${raw_path/#\~/$HOME}"
ensure_user_dir "$expanded_path"
}
declare -a SECURITY_FIXES=()
collect_security_fix_actions() {
SECURITY_FIXES=()
if [[ "${FIREWALL_DISABLED:-}" == "true" ]]; then
if ! is_whitelisted "firewall"; then
SECURITY_FIXES+=("firewall|Enable macOS firewall")
fi
fi
if [[ "${GATEKEEPER_DISABLED:-}" == "true" ]]; then
if ! is_whitelisted "gatekeeper"; then
SECURITY_FIXES+=("gatekeeper|Enable Gatekeeper, app download protection")
fi
fi
if touchid_supported && ! touchid_configured; then
if ! is_whitelisted "check_touchid"; then
SECURITY_FIXES+=("touchid|Enable Touch ID for sudo")
fi
fi
((${#SECURITY_FIXES[@]} > 0))
}
ask_for_security_fixes() {
if ! collect_security_fix_actions; then
return 1
fi
echo ""
echo -e "${BLUE}SECURITY FIXES${NC}"
for entry in "${SECURITY_FIXES[@]}"; do
IFS='|' read -r _ label <<< "$entry"
echo -e " ${ICON_LIST} $label"
done
echo ""
export MOLE_SECURITY_FIXES_SHOWN=true
echo -ne "${GRAY}${ICON_REVIEW}${NC} ${YELLOW}Apply now?${NC} ${GRAY}Enter confirm / Space cancel${NC}: "
local key
if ! key=$(read_key); then
export MOLE_SECURITY_FIXES_SKIPPED=true
echo -e "\n ${GRAY}${ICON_WARNING}${NC} Security fixes skipped"
echo ""
return 1
fi
if [[ "$key" == "ENTER" ]]; then
echo ""
return 0
else
export MOLE_SECURITY_FIXES_SKIPPED=true
echo -e "\n ${GRAY}${ICON_WARNING}${NC} Security fixes skipped"
echo ""
return 1
fi
}
apply_firewall_fix() {
if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Firewall enabled"
FIREWALL_DISABLED=false
return 0
fi
echo -e " ${GRAY}${ICON_WARNING}${NC} Failed to enable firewall, check permissions"
return 1
}
apply_gatekeeper_fix() {
if sudo spctl --master-enable 2> /dev/null; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Gatekeeper enabled"
GATEKEEPER_DISABLED=false
return 0
fi
echo -e " ${GRAY}${ICON_WARNING}${NC} Failed to enable Gatekeeper"
return 1
}
apply_touchid_fix() {
if "$SCRIPT_DIR/bin/touchid.sh" enable; then
return 0
fi
return 1
}
perform_security_fixes() {
if ! ensure_sudo_session "Security changes require admin access"; then
echo -e "${GRAY}${ICON_WARNING}${NC} Skipped security fixes, sudo denied"
return 1
fi
local applied=0
for entry in "${SECURITY_FIXES[@]}"; do
IFS='|' read -r action _ <<< "$entry"
case "$action" in
firewall)
apply_firewall_fix && ((applied++))
;;
gatekeeper)
apply_gatekeeper_fix && ((applied++))
;;
touchid)
apply_touchid_fix && ((applied++))
;;
esac
done
if ((applied > 0)); then
log_success "Security settings updated"
fi
SECURITY_FIXES=()
}
cleanup_all() {
stop_inline_spinner 2> /dev/null || true
stop_sudo_session
cleanup_temp_files
# Log session end
log_operation_session_end "optimize" "${OPTIMIZE_SAFE_COUNT:-0}" "0"
}
handle_interrupt() {
cleanup_all
exit 130
}
main() {
# Set current command for operation logging
export MOLE_CURRENT_COMMAND="optimize"
local health_json
for arg in "$@"; do
case "$arg" in
"--help" | "-h")
show_optimize_help
exit 0
;;
"--debug")
export MO_DEBUG=1
;;
"--dry-run")
export MOLE_DRY_RUN=1
;;
"--whitelist")
manage_whitelist "optimize"
exit 0
;;
esac
done
log_operation_session_start "optimize"
trap cleanup_all EXIT
trap handle_interrupt INT TERM
if [[ -t 1 ]]; then
clear_screen
fi
print_header
# Dry-run indicator.
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No files will be modified\n"
fi
if ! command -v jq > /dev/null 2>&1; then
echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: jq"
echo -e "${GRAY}Install with: ${GREEN}brew install jq${NC}"
exit 1
fi
if ! command -v bc > /dev/null 2>&1; then
echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: bc"
echo -e "${GRAY}Install with: ${GREEN}brew install bc${NC}"
exit 1
fi
if [[ -t 1 ]]; then
start_inline_spinner "Collecting system info..."
fi
if ! health_json=$(generate_health_json 2> /dev/null); then
if [[ -t 1 ]]; then
stop_inline_spinner
fi
echo ""
log_error "Failed to collect system health data"
exit 1
fi
if ! echo "$health_json" | jq empty 2> /dev/null; then
if [[ -t 1 ]]; then
stop_inline_spinner
fi
echo ""
log_error "Invalid system health data format"
echo -e "${GRAY}${ICON_REVIEW}${NC} Check if jq, awk, sysctl, and df commands are available"
exit 1
fi
if [[ -t 1 ]]; then
stop_inline_spinner
fi
show_system_health "$health_json"
load_whitelist "optimize"
if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then
local count=${#CURRENT_WHITELIST_PATTERNS[@]}
if [[ $count -le 3 ]]; then
local patterns_list=$(
IFS=', '
echo "${CURRENT_WHITELIST_PATTERNS[*]}"
)
echo -e "${ICON_ADMIN} Active Whitelist: ${patterns_list}"
fi
fi
local -a safe_items=()
local -a confirm_items=()
local opts_file
opts_file=$(mktemp_file)
parse_optimizations "$health_json" > "$opts_file"
while IFS= read -r opt_json; do
[[ -z "$opt_json" ]] && continue
local name=$(echo "$opt_json" | jq -r '.name')
local desc=$(echo "$opt_json" | jq -r '.description')
local action=$(echo "$opt_json" | jq -r '.action')
local path=$(echo "$opt_json" | jq -r '.path // ""')
local safe=$(echo "$opt_json" | jq -r '.safe')
local item="${name}|${desc}|${action}|${path}"
if [[ "$safe" == "true" ]]; then
safe_items+=("$item")
else
confirm_items+=("$item")
fi
done < "$opts_file"
echo ""
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
ensure_sudo_session "System optimization requires admin access" || true
fi
export FIRST_ACTION=true
if [[ ${#safe_items[@]} -gt 0 ]]; then
for item in "${safe_items[@]}"; do
IFS='|' read -r name desc action path <<< "$item"
announce_action "$name" "$desc" "safe"
execute_optimization "$action" "$path"
done
fi
if [[ ${#confirm_items[@]} -gt 0 ]]; then
for item in "${confirm_items[@]}"; do
IFS='|' read -r name desc action path <<< "$item"
announce_action "$name" "$desc" "confirm"
execute_optimization "$action" "$path"
done
fi
local safe_count=${#safe_items[@]}
local confirm_count=${#confirm_items[@]}
run_system_checks
export OPTIMIZE_SAFE_COUNT=$safe_count
export OPTIMIZE_CONFIRM_COUNT=$confirm_count
show_optimization_summary
printf '\n'
}
main "$@"

View File

@@ -0,0 +1,294 @@
#!/bin/bash
# Mole - Purge command.
# Cleans heavy project build artifacts.
# Interactive selection by project.
set -euo pipefail
# Fix locale issues (avoid Perl warnings on non-English systems)
export LC_ALL=C
export LANG=C
# Get script directory and source common functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../lib/core/common.sh"
# Set up cleanup trap for temporary files
trap cleanup_temp_files EXIT INT TERM
source "$SCRIPT_DIR/../lib/core/log.sh"
source "$SCRIPT_DIR/../lib/clean/project.sh"
# Configuration
CURRENT_SECTION=""
# Section management
start_section() {
local section_name="$1"
CURRENT_SECTION="$section_name"
printf '\n'
echo -e "${BLUE}━━━ ${section_name} ━━━${NC}"
}
end_section() {
CURRENT_SECTION=""
}
# Note activity for export list
note_activity() {
if [[ -n "$CURRENT_SECTION" ]]; then
printf '%s\n' "$CURRENT_SECTION" >> "$EXPORT_LIST_FILE"
fi
}
# Main purge function
start_purge() {
# Set current command for operation logging
export MOLE_CURRENT_COMMAND="purge"
log_operation_session_start "purge"
# Clear screen for better UX
if [[ -t 1 ]]; then
printf '\033[2J\033[H'
fi
# Initialize stats file in user cache directory
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
ensure_user_dir "$stats_dir"
ensure_user_file "$stats_dir/purge_stats"
ensure_user_file "$stats_dir/purge_count"
ensure_user_file "$stats_dir/purge_scanning"
echo "0" > "$stats_dir/purge_stats"
echo "0" > "$stats_dir/purge_count"
echo "" > "$stats_dir/purge_scanning"
}
# Perform the purge
perform_purge() {
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
local monitor_pid=""
# Cleanup function - use flag to prevent duplicate execution
_cleanup_done=false
cleanup_monitor() {
# Prevent multiple cleanup executions from trap conflicts
[[ "$_cleanup_done" == "true" ]] && return
_cleanup_done=true
# Remove scanning file to stop monitor
rm -f "$stats_dir/purge_scanning" 2> /dev/null || true
if [[ -n "$monitor_pid" ]]; then
kill "$monitor_pid" 2> /dev/null || true
wait "$monitor_pid" 2> /dev/null || true
fi
if [[ -t 1 ]]; then
printf '\r\033[2K\n' > /dev/tty 2> /dev/null || true
fi
}
# Ensure Ctrl-C/TERM always stops spinner(s) and exits immediately.
handle_interrupt() {
cleanup_monitor
stop_inline_spinner 2> /dev/null || true
show_cursor 2> /dev/null || true
printf '\n' >&2
exit 130
}
# Set up trap for cleanup + abort
trap handle_interrupt INT TERM
# Show scanning with spinner below the title line
if [[ -t 1 ]]; then
# Print title ONCE with newline; spinner occupies the line below
printf '%s\n' "${PURPLE_BOLD}Purge Project Artifacts${NC}"
# Capture terminal width in parent (most reliable before forking)
local _parent_cols=80
local _stty_out
if _stty_out=$(stty size < /dev/tty 2> /dev/null); then
_parent_cols="${_stty_out##* }" # "rows cols" -> take cols
else
_parent_cols=$(tput cols 2> /dev/null || echo 80)
fi
[[ "$_parent_cols" =~ ^[0-9]+$ && $_parent_cols -gt 0 ]] || _parent_cols=80
# Start background monitor: writes directly to /dev/tty to avoid stdout state issues
(
local spinner_chars="|/-\\"
local spinner_idx=0
local last_path=""
# Use parent-captured width; never refresh inside the loop (avoids unreliable tput in bg)
local term_cols="$_parent_cols"
# Visible prefix "| Scanning " = 11 chars; reserve 25 total for safety margin
local max_path_len=$((term_cols - 25))
((max_path_len < 5)) && max_path_len=5
# Set up trap to exit cleanly (erase the spinner line via /dev/tty)
trap 'printf "\r\033[2K" >/dev/tty 2>/dev/null; exit 0' INT TERM
# Truncate path to guaranteed fit
truncate_path() {
local path="$1"
if [[ ${#path} -le $max_path_len ]]; then
echo "$path"
return
fi
local side_len=$(((max_path_len - 3) / 2))
echo "${path:0:$side_len}...${path: -$side_len}"
}
while [[ -f "$stats_dir/purge_scanning" ]]; do
local current_path
current_path=$(cat "$stats_dir/purge_scanning" 2> /dev/null || echo "")
if [[ -n "$current_path" ]]; then
local display_path="${current_path/#$HOME/~}"
display_path=$(truncate_path "$display_path")
last_path="$display_path"
fi
local spin_char="${spinner_chars:$spinner_idx:1}"
spinner_idx=$(((spinner_idx + 1) % ${#spinner_chars}))
# Write directly to /dev/tty: \033[2K clears entire current line, \r goes to start
if [[ -n "$last_path" ]]; then
printf '\r\033[2K%s %sScanning %s%s' \
"${BLUE}${spin_char}${NC}" \
"${GRAY}" "$last_path" "${NC}" > /dev/tty 2> /dev/null
else
printf '\r\033[2K%s %sScanning...%s' \
"${BLUE}${spin_char}${NC}" \
"${GRAY}" "${NC}" > /dev/tty 2> /dev/null
fi
sleep 0.05
done
printf '\r\033[2K' > /dev/tty 2> /dev/null
exit 0
) &
monitor_pid=$!
else
echo -e "${PURPLE_BOLD}Purge Project Artifacts${NC}"
fi
clean_project_artifacts
local exit_code=$?
# Clean up
trap - INT TERM
cleanup_monitor
# Exit codes:
# 0 = success, show summary
# 1 = user cancelled
# 2 = nothing to clean
if [[ $exit_code -ne 0 ]]; then
return 0
fi
# Final summary (matching clean.sh format)
echo ""
local summary_heading="Purge complete"
local -a summary_details=()
local total_size_cleaned=0
local total_items_cleaned=0
if [[ -f "$stats_dir/purge_stats" ]]; then
total_size_cleaned=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0")
rm -f "$stats_dir/purge_stats"
fi
if [[ -f "$stats_dir/purge_count" ]]; then
total_items_cleaned=$(cat "$stats_dir/purge_count" 2> /dev/null || echo "0")
rm -f "$stats_dir/purge_count"
fi
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
summary_heading="Dry run complete - no changes made"
fi
if [[ $total_size_cleaned -gt 0 ]]; then
local freed_size_human
freed_size_human=$(bytes_to_human_kb "$total_size_cleaned")
local summary_line="Space freed: ${GREEN}${freed_size_human}${NC}"
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
summary_line="Would free: ${GREEN}${freed_size_human}${NC}"
fi
[[ $total_items_cleaned -gt 0 ]] && summary_line+=" | Items: $total_items_cleaned"
summary_line+=" | Free: $(get_free_space)"
summary_details+=("$summary_line")
else
summary_details+=("No old project artifacts to clean.")
summary_details+=("Free space: $(get_free_space)")
fi
# Log session end
log_operation_session_end "purge" "${total_items_cleaned:-0}" "${total_size_cleaned:-0}"
print_summary_block "$summary_heading" "${summary_details[@]}"
printf '\n'
}
# Show help message
show_help() {
echo -e "${PURPLE_BOLD}Mole Purge${NC}, Clean old project build artifacts"
echo ""
echo -e "${YELLOW}Usage:${NC} mo purge [options]"
echo ""
echo -e "${YELLOW}Options:${NC}"
echo " --paths Edit custom scan directories"
echo " --dry-run Preview purge actions without making changes"
echo " --debug Enable debug logging"
echo " --help Show this help message"
echo ""
echo -e "${YELLOW}Default Paths:${NC}"
for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
echo " * $path"
done
}
# Main entry point
main() {
# Set up signal handling
trap 'show_cursor; exit 130' INT TERM
# Parse arguments
for arg in "$@"; do
case "$arg" in
"--paths")
source "$SCRIPT_DIR/../lib/manage/purge_paths.sh"
manage_purge_paths
exit 0
;;
"--help")
show_help
exit 0
;;
"--debug")
export MO_DEBUG=1
;;
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
*)
echo "Unknown option: $arg"
echo "Use 'mo purge --help' for usage information"
exit 1
;;
esac
done
start_purge
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No project artifacts will be removed"
printf '\n'
fi
hide_cursor
perform_purge
show_cursor
}
main "$@"

View File

@@ -0,0 +1,15 @@
#!/bin/bash
# Mole - Status command.
# Runs the Go system status panel.
# Shows live system metrics.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GO_BIN="$SCRIPT_DIR/status-go"
if [[ -x "$GO_BIN" ]]; then
exec "$GO_BIN" "$@"
fi
echo "Bundled status binary not found. Please reinstall Mole or run mo update to restore it." >&2
exit 1

View File

@@ -0,0 +1,382 @@
#!/bin/bash
# Mole - Touch ID command.
# Configures sudo with Touch ID.
# Guided toggle with safety checks.
set -euo pipefail
# Determine script location and source common functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="$(cd "$SCRIPT_DIR/../lib" && pwd)"
# Source common functions
# shellcheck source=../lib/core/common.sh
source "$LIB_DIR/core/common.sh"
# Set up global cleanup trap
trap cleanup_temp_files EXIT INT TERM
PAM_SUDO_FILE="${MOLE_PAM_SUDO_FILE:-/etc/pam.d/sudo}"
PAM_SUDO_LOCAL_FILE="${MOLE_PAM_SUDO_LOCAL_FILE:-$(dirname "$PAM_SUDO_FILE")/sudo_local}"
readonly PAM_SUDO_FILE
readonly PAM_SUDO_LOCAL_FILE
readonly PAM_TID_LINE="auth sufficient pam_tid.so"
# Check if Touch ID is already configured
is_touchid_configured() {
# Check sudo_local first
if [[ -f "$PAM_SUDO_LOCAL_FILE" ]]; then
grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null && return 0
fi
# Fallback to standard sudo file
if [[ ! -f "$PAM_SUDO_FILE" ]]; then
return 1
fi
grep -q "pam_tid.so" "$PAM_SUDO_FILE" 2> /dev/null
}
# Check if system supports Touch ID
supports_touchid() {
# Check if bioutil exists and has Touch ID capability
if command -v bioutil &> /dev/null; then
bioutil -r 2> /dev/null | grep -q "Touch ID" && return 0
fi
# Fallback: check if running on Apple Silicon or modern Intel Mac
local arch
arch=$(uname -m)
if [[ "$arch" == "arm64" ]]; then
return 0
fi
# For Intel Macs, check if it's 2018 or later (approximation)
local model_year
model_year=$(system_profiler SPHardwareDataType 2> /dev/null | grep "Model Identifier" | grep -o "[0-9]\{4\}" | head -1)
if [[ -n "$model_year" ]] && [[ "$model_year" -ge 2018 ]]; then
return 0
fi
return 1
}
touchid_dry_run_enabled() {
[[ "${MOLE_DRY_RUN:-0}" == "1" ]]
}
# Show current Touch ID status
show_status() {
if is_touchid_configured; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Touch ID is enabled for sudo"
else
echo -e "${YELLOW}${NC} Touch ID is not configured for sudo"
fi
}
# Enable Touch ID for sudo
enable_touchid() {
# Cleanup trap handled by global EXIT trap
local temp_file=""
if touchid_dry_run_enabled; then
if is_touchid_configured; then
echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled, no changes needed${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS} [DRY RUN] Would enable Touch ID for sudo${NC}"
echo -e "${GRAY}${ICON_REVIEW} Target files: ${PAM_SUDO_FILE} and/or ${PAM_SUDO_LOCAL_FILE}${NC}"
fi
return 0
fi
# First check if system supports Touch ID
if ! supports_touchid; then
log_warning "This Mac may not support Touch ID"
read -rp "Continue anyway? [y/N] " confirm
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}Cancelled${NC}"
return 1
fi
echo ""
fi
# Check if we should use sudo_local (Sonoma+)
if grep -q "sudo_local" "$PAM_SUDO_FILE"; then
# Check if already correctly configured in sudo_local
if [[ -f "$PAM_SUDO_LOCAL_FILE" ]] && grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then
# It is in sudo_local, but let's check if it's ALSO in sudo (incomplete migration)
if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then
# Clean up legacy config
temp_file=$(create_temp_file)
grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file"
if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then
echo -e "${GREEN}${ICON_SUCCESS} Cleanup legacy configuration${NC}"
fi
fi
echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled${NC}"
return 0
fi
# Not configured in sudo_local yet.
# Check if configured in sudo (Legacy)
local is_legacy_configured=false
if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then
is_legacy_configured=true
fi
# Function to write to sudo_local
local write_success=false
if [[ ! -f "$PAM_SUDO_LOCAL_FILE" ]]; then
# Create the file
echo "# sudo_local: local customizations for sudo" | sudo tee "$PAM_SUDO_LOCAL_FILE" > /dev/null
echo "$PAM_TID_LINE" | sudo tee -a "$PAM_SUDO_LOCAL_FILE" > /dev/null
sudo chmod 444 "$PAM_SUDO_LOCAL_FILE"
sudo chown root:wheel "$PAM_SUDO_LOCAL_FILE"
write_success=true
else
# Append if not present
if ! grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then
temp_file=$(create_temp_file)
cp "$PAM_SUDO_LOCAL_FILE" "$temp_file"
echo "$PAM_TID_LINE" >> "$temp_file"
sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE"
sudo chmod 444 "$PAM_SUDO_LOCAL_FILE"
sudo chown root:wheel "$PAM_SUDO_LOCAL_FILE"
write_success=true
else
write_success=true # Already there (should be caught by first check, but safe fallback)
fi
fi
if $write_success; then
# If we migrated from legacy, clean it up now
if $is_legacy_configured; then
temp_file=$(create_temp_file)
grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file"
sudo mv "$temp_file" "$PAM_SUDO_FILE"
log_success "Touch ID migrated to sudo_local"
else
log_success "Touch ID enabled, via sudo_local, try: sudo ls"
fi
return 0
else
log_error "Failed to write to sudo_local"
return 1
fi
fi
# Legacy method: Modify sudo file directly
# Check if already configured (Legacy)
if is_touchid_configured; then
echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled${NC}"
return 0
fi
# Create backup only if it doesn't exist to preserve original state
if [[ ! -f "${PAM_SUDO_FILE}.mole-backup" ]]; then
if ! sudo cp "$PAM_SUDO_FILE" "${PAM_SUDO_FILE}.mole-backup" 2> /dev/null; then
log_error "Failed to create backup"
return 1
fi
fi
# Create temp file
temp_file=$(create_temp_file)
# Insert pam_tid.so after the first comment block
awk '
BEGIN { inserted = 0 }
/^#/ { print; next }
!inserted && /^[^#]/ {
print "'"$PAM_TID_LINE"'"
inserted = 1
}
{ print }
' "$PAM_SUDO_FILE" > "$temp_file"
# Verify content change
if cmp -s "$PAM_SUDO_FILE" "$temp_file"; then
log_error "Failed to modify configuration"
return 1
fi
# Apply the changes
if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then
log_success "Touch ID enabled, try: sudo ls"
return 0
else
log_error "Failed to enable Touch ID"
return 1
fi
}
# Disable Touch ID for sudo
disable_touchid() {
# Cleanup trap handled by global EXIT trap
local temp_file=""
if touchid_dry_run_enabled; then
if ! is_touchid_configured; then
echo -e "${YELLOW}Touch ID is not currently enabled${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS} [DRY RUN] Would disable Touch ID for sudo${NC}"
echo -e "${GRAY}${ICON_REVIEW} Target files: ${PAM_SUDO_FILE} and/or ${PAM_SUDO_LOCAL_FILE}${NC}"
fi
return 0
fi
if ! is_touchid_configured; then
echo -e "${YELLOW}Touch ID is not currently enabled${NC}"
return 0
fi
# Check sudo_local first
if [[ -f "$PAM_SUDO_LOCAL_FILE" ]] && grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then
# Remove from sudo_local
temp_file=$(create_temp_file)
grep -v "pam_tid.so" "$PAM_SUDO_LOCAL_FILE" > "$temp_file"
if sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null; then
# Since we modified sudo_local, we should also check if it's in sudo file (legacy cleanup)
if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then
temp_file=$(create_temp_file)
grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file"
sudo mv "$temp_file" "$PAM_SUDO_FILE"
fi
echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled, removed from sudo_local${NC}"
echo ""
return 0
else
log_error "Failed to disable Touch ID from sudo_local"
return 1
fi
fi
# Fallback to sudo file (legacy)
if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then
# Create backup only if it doesn't exist
if [[ ! -f "${PAM_SUDO_FILE}.mole-backup" ]]; then
if ! sudo cp "$PAM_SUDO_FILE" "${PAM_SUDO_FILE}.mole-backup" 2> /dev/null; then
log_error "Failed to create backup"
return 1
fi
fi
# Remove pam_tid.so line
temp_file=$(create_temp_file)
grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file"
if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then
echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled${NC}"
echo ""
return 0
else
log_error "Failed to disable Touch ID"
return 1
fi
fi
# Should not reach here if is_touchid_configured was true
log_error "Could not find Touch ID configuration to disable"
return 1
}
# Interactive menu
show_menu() {
echo ""
show_status
if is_touchid_configured; then
echo -ne "${PURPLE}${NC} Press ${GREEN}Enter${NC} to disable, ${GRAY}Q${NC} to quit: "
IFS= read -r -s -n1 key || key=""
drain_pending_input # Clean up any escape sequence remnants
echo ""
case "$key" in
$'\e') # ESC
return 0
;;
"" | $'\n' | $'\r') # Enter
printf "\r\033[K" # Clear the prompt line
disable_touchid
;;
*)
echo ""
log_error "Invalid key"
;;
esac
else
echo -ne "${PURPLE}${NC} Press ${GREEN}Enter${NC} to enable, ${GRAY}Q${NC} to quit: "
IFS= read -r -s -n1 key || key=""
drain_pending_input # Clean up any escape sequence remnants
case "$key" in
$'\e') # ESC
return 0
;;
"" | $'\n' | $'\r') # Enter
printf "\r\033[K" # Clear the prompt line
enable_touchid
;;
*)
echo ""
log_error "Invalid key"
;;
esac
fi
}
# Main
main() {
local command=""
local arg
for arg in "$@"; do
case "$arg" in
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
"--help" | "-h")
show_touchid_help
return 0
;;
enable | disable | status)
if [[ -z "$command" ]]; then
command="$arg"
else
log_error "Only one touchid command is supported per run"
return 1
fi
;;
*)
log_error "Unknown command: $arg"
return 1
;;
esac
done
if touchid_dry_run_enabled; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No sudo authentication files will be modified"
echo ""
fi
case "$command" in
enable)
enable_touchid
;;
disable)
disable_touchid
;;
status)
show_status
;;
"")
show_menu
;;
*)
log_error "Unknown command: $command"
exit 1
;;
esac
}
main "$@"

View File

@@ -0,0 +1,714 @@
#!/bin/bash
# System Checks Module
# Combines configuration, security, updates, and health checks
set -euo pipefail
# ============================================================================
# Helper Functions
# ============================================================================
list_login_items() {
if ! command -v osascript > /dev/null 2>&1; then
return
fi
local raw_items
raw_items=$(osascript -e 'tell application "System Events" to get the name of every login item' 2> /dev/null || echo "")
[[ -z "$raw_items" || "$raw_items" == "missing value" ]] && return
IFS=',' read -ra login_items_array <<< "$raw_items"
for entry in "${login_items_array[@]}"; do
local trimmed
trimmed=$(echo "$entry" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
[[ -n "$trimmed" ]] && printf "%s\n" "$trimmed"
done
}
# ============================================================================
# Configuration Checks
# ============================================================================
check_touchid_sudo() {
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "check_touchid"; then return; fi
# Check if Touch ID is configured for sudo
local pam_file="/etc/pam.d/sudo"
if [[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null; then
echo -e " ${GREEN}${NC} Touch ID Biometric authentication enabled"
else
# Check if Touch ID is supported
local is_supported=false
if command -v bioutil > /dev/null 2>&1; then
if bioutil -r 2> /dev/null | grep -q "Touch ID"; then
is_supported=true
fi
elif [[ "$(uname -m)" == "arm64" ]]; then
is_supported=true
fi
if [[ "$is_supported" == "true" ]]; then
echo -e " ${GRAY}${ICON_WARNING}${NC} Touch ID ${YELLOW}Not configured for sudo${NC}"
export TOUCHID_NOT_CONFIGURED=true
fi
fi
}
check_rosetta() {
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "check_rosetta"; then return; fi
# Check Rosetta 2 (for Apple Silicon Macs) - informational only, not auto-fixed
if [[ "$(uname -m)" == "arm64" ]]; then
if [[ -f "/Library/Apple/usr/share/rosetta/rosetta" ]]; then
echo -e " ${GREEN}${NC} Rosetta 2 Intel app translation ready"
else
echo -e " ${GRAY}${ICON_EMPTY}${NC} Rosetta 2 ${GRAY}Not installed${NC}"
fi
fi
}
check_git_config() {
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "check_git_config"; then return; fi
# Check basic Git configuration
if command -v git > /dev/null 2>&1; then
local git_name=$(git config --global user.name 2> /dev/null || echo "")
local git_email=$(git config --global user.email 2> /dev/null || echo "")
if [[ -n "$git_name" && -n "$git_email" ]]; then
echo -e " ${GREEN}${NC} Git Global identity configured"
else
echo -e " ${GRAY}${ICON_WARNING}${NC} Git ${YELLOW}User identity not set${NC}"
fi
fi
}
check_all_config() {
echo -e "${BLUE}${ICON_ARROW}${NC} System Configuration"
check_touchid_sudo
check_rosetta
check_git_config
}
# ============================================================================
# Security Checks
# ============================================================================
check_filevault() {
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "check_filevault"; then return; fi
# Check FileVault encryption status
if command -v fdesetup > /dev/null 2>&1; then
local fv_status=$(fdesetup status 2> /dev/null || echo "")
if echo "$fv_status" | grep -q "FileVault is On"; then
echo -e " ${GREEN}${NC} FileVault Disk encryption active"
else
echo -e " ${RED}${NC} FileVault ${RED}Disk encryption disabled${NC}"
export FILEVAULT_DISABLED=true
fi
fi
}
check_firewall() {
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "firewall"; then return; fi
unset FIREWALL_DISABLED
# Check third-party firewalls first (lightweight path-based detection, no sudo required)
local third_party_firewall=""
if [[ -d "/Applications/Little Snitch.app" ]] || [[ -d "/Library/Little Snitch" ]]; then
third_party_firewall="Little Snitch"
elif [[ -d "/Applications/LuLu.app" ]]; then
third_party_firewall="LuLu"
elif [[ -d "/Applications/Radio Silence.app" ]]; then
third_party_firewall="Radio Silence"
elif [[ -d "/Applications/Hands Off!.app" ]]; then
third_party_firewall="Hands Off!"
elif [[ -d "/Applications/Murus.app" ]]; then
third_party_firewall="Murus"
elif [[ -d "/Applications/Vallum.app" ]]; then
third_party_firewall="Vallum"
fi
if [[ -n "$third_party_firewall" ]]; then
echo -e " ${GREEN}${NC} Firewall ${third_party_firewall} active"
return
fi
# Fall back to macOS built-in firewall check
local firewall_output=$(sudo /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2> /dev/null || echo "")
if [[ "$firewall_output" == *"State = 1"* ]] || [[ "$firewall_output" == *"State = 2"* ]]; then
echo -e " ${GREEN}${NC} Firewall Network protection enabled"
else
echo -e " ${GRAY}${ICON_WARNING}${NC} Firewall ${YELLOW}Network protection disabled${NC}"
export FIREWALL_DISABLED=true
fi
}
check_gatekeeper() {
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "gatekeeper"; then return; fi
# Check Gatekeeper status
if command -v spctl > /dev/null 2>&1; then
local gk_status=$(spctl --status 2> /dev/null || echo "")
if echo "$gk_status" | grep -q "enabled"; then
echo -e " ${GREEN}${NC} Gatekeeper App download protection active"
unset GATEKEEPER_DISABLED
else
echo -e " ${GRAY}${ICON_WARNING}${NC} Gatekeeper ${YELLOW}App security disabled${NC}"
export GATEKEEPER_DISABLED=true
fi
fi
}
check_sip() {
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "check_sip"; then return; fi
# Check System Integrity Protection
if command -v csrutil > /dev/null 2>&1; then
local sip_status=$(csrutil status 2> /dev/null || echo "")
if echo "$sip_status" | grep -q "enabled"; then
echo -e " ${GREEN}${NC} SIP System integrity protected"
else
echo -e " ${GRAY}${ICON_WARNING}${NC} SIP ${YELLOW}System protection disabled${NC}"
fi
fi
}
check_all_security() {
echo -e "${BLUE}${ICON_ARROW}${NC} Security Status"
check_filevault
check_firewall
check_gatekeeper
check_sip
}
# ============================================================================
# Software Update Checks
# ============================================================================
# Cache configuration
CACHE_DIR="${HOME}/.cache/mole"
CACHE_TTL=600 # 10 minutes in seconds
# Ensure cache directory exists
ensure_user_dir "$CACHE_DIR"
clear_cache_file() {
local file="$1"
rm -f "$file" 2> /dev/null || true
}
reset_brew_cache() {
clear_cache_file "$CACHE_DIR/brew_updates"
}
reset_softwareupdate_cache() {
clear_cache_file "$CACHE_DIR/softwareupdate_list"
SOFTWARE_UPDATE_LIST=""
}
reset_mole_cache() {
clear_cache_file "$CACHE_DIR/mole_version"
}
# Check if cache is still valid
is_cache_valid() {
local cache_file="$1"
local ttl="${2:-$CACHE_TTL}"
if [[ ! -f "$cache_file" ]]; then
return 1
fi
local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file")))
[[ $cache_age -lt $ttl ]]
}
# Cache software update list to avoid calling softwareupdate twice
SOFTWARE_UPDATE_LIST=""
get_software_updates() {
local cache_file="$CACHE_DIR/softwareupdate_list"
# Optimized: Use defaults to check if updates are pending (much faster)
local pending_updates
pending_updates=$(defaults read /Library/Preferences/com.apple.SoftwareUpdate LastRecommendedUpdatesAvailable 2> /dev/null || echo "0")
if [[ "$pending_updates" -gt 0 ]]; then
echo "Updates Available"
else
echo ""
fi
}
check_homebrew_updates() {
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "check_homebrew_updates"; then return; fi
export BREW_OUTDATED_COUNT=0
export BREW_FORMULA_OUTDATED_COUNT=0
export BREW_CASK_OUTDATED_COUNT=0
if ! command -v brew > /dev/null 2>&1; then
printf " ${GRAY}${ICON_EMPTY}${NC} %-12s %s\n" "Homebrew" "Not installed"
return
fi
local cache_file="$CACHE_DIR/brew_updates"
local formula_count=0
local cask_count=0
local total_count=0
local use_cache=false
if is_cache_valid "$cache_file"; then
local cached_formula=""
local cached_cask=""
IFS=' ' read -r cached_formula cached_cask < "$cache_file" || true
if [[ "$cached_formula" =~ ^[0-9]+$ && "$cached_cask" =~ ^[0-9]+$ ]]; then
formula_count="$cached_formula"
cask_count="$cached_cask"
use_cache=true
fi
fi
if [[ "$use_cache" == "false" ]]; then
local formula_outdated=""
local cask_outdated=""
local formula_status=0
local cask_status=0
local spinner_started=false
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Homebrew updates..."
spinner_started=true
fi
if formula_outdated=$(run_with_timeout 8 brew outdated --formula --quiet 2> /dev/null); then
:
else
formula_status=$?
fi
if cask_outdated=$(run_with_timeout 8 brew outdated --cask --quiet 2> /dev/null); then
:
else
cask_status=$?
fi
if [[ "$spinner_started" == "true" ]]; then
stop_inline_spinner
fi
if [[ $formula_status -eq 0 || $cask_status -eq 0 ]]; then
formula_count=$(printf '%s\n' "$formula_outdated" | awk 'NF {count++} END {print count + 0}')
cask_count=$(printf '%s\n' "$cask_outdated" | awk 'NF {count++} END {print count + 0}')
ensure_user_file "$cache_file"
printf '%s %s\n' "$formula_count" "$cask_count" > "$cache_file" 2> /dev/null || true
elif [[ $formula_status -eq 124 || $cask_status -eq 124 ]]; then
printf " ${GRAY}${ICON_WARNING}${NC} %-12s ${YELLOW}%s${NC}\n" "Homebrew" "Check timed out"
return
else
printf " ${GRAY}${ICON_WARNING}${NC} %-12s ${YELLOW}%s${NC}\n" "Homebrew" "Check failed"
return
fi
fi
total_count=$((formula_count + cask_count))
export BREW_FORMULA_OUTDATED_COUNT="$formula_count"
export BREW_CASK_OUTDATED_COUNT="$cask_count"
export BREW_OUTDATED_COUNT="$total_count"
if [[ $total_count -gt 0 ]]; then
local detail=""
if [[ $formula_count -gt 0 ]]; then
detail="${formula_count} formula"
fi
if [[ $cask_count -gt 0 ]]; then
[[ -n "$detail" ]] && detail="${detail}, "
detail="${detail}${cask_count} cask"
fi
[[ -z "$detail" ]] && detail="${total_count} updates"
printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "Homebrew" "${detail} available"
else
printf " ${GREEN}${NC} %-12s %s\n" "Homebrew" "Up to date"
fi
}
check_appstore_updates() {
# Skipped for speed optimization - consolidated into check_macos_update
# We can't easily distinguish app store vs macos updates without the slow softwareupdate -l call
export APPSTORE_UPDATE_COUNT=0
}
check_macos_update() {
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "check_macos_updates"; then return; fi
# Fast check using system preferences
local updates_available="false"
if [[ $(get_software_updates) == "Updates Available" ]]; then
updates_available="true"
# Verify with softwareupdate using --no-scan to avoid triggering a fresh scan
# which can timeout. We prioritize avoiding false negatives (missing actual updates)
# over false positives, so we only clear the update flag when softwareupdate
# explicitly reports "No new software available"
local sw_output=""
local sw_status=0
local spinner_started=false
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking macOS updates..."
spinner_started=true
fi
local softwareupdate_timeout=10
if sw_output=$(run_with_timeout "$softwareupdate_timeout" softwareupdate -l --no-scan 2> /dev/null); then
:
else
sw_status=$?
fi
if [[ "$spinner_started" == "true" ]]; then
stop_inline_spinner
fi
# Debug logging for troubleshooting
if [[ -n "${MO_DEBUG:-}" ]]; then
echo "[DEBUG] softwareupdate exit status: $sw_status, output lines: $(echo "$sw_output" | wc -l | tr -d ' ')" >&2
fi
# Prefer avoiding false negatives: if the system indicates updates are pending,
# only clear the flag when softwareupdate returns a list without any update entries.
if [[ $sw_status -eq 0 && -n "$sw_output" ]]; then
if ! echo "$sw_output" | grep -qE '^[[:space:]]*\*'; then
updates_available="false"
fi
fi
fi
export MACOS_UPDATE_AVAILABLE="$updates_available"
if [[ "$updates_available" == "true" ]]; then
printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "macOS" "Update available"
else
printf " ${GREEN}${NC} %-12s %s\n" "macOS" "System up to date"
fi
}
check_mole_update() {
if command -v is_whitelisted > /dev/null && is_whitelisted "check_mole_update"; then return; fi
# Check if Mole has updates
# Auto-detect version from mole main script
local current_version
if [[ -f "${SCRIPT_DIR:-/usr/local/bin}/mole" ]]; then
current_version=$(grep '^VERSION=' "${SCRIPT_DIR:-/usr/local/bin}/mole" 2> /dev/null | head -1 | sed 's/VERSION="\(.*\)"/\1/' || echo "unknown")
else
current_version="${VERSION:-unknown}"
fi
local latest_version=""
local cache_file="$CACHE_DIR/mole_version"
export MOLE_UPDATE_AVAILABLE="false"
# Check cache first
if is_cache_valid "$cache_file"; then
latest_version=$(cat "$cache_file" 2> /dev/null || echo "")
else
# Show spinner while checking
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Mole version..."
fi
# Try to get latest version from GitHub
if command -v curl > /dev/null 2>&1; then
# Run in background to allow Ctrl+C to interrupt
local temp_version
temp_version=$(mktemp_file "mole_version_check")
curl -fsSL --connect-timeout 3 --max-time 5 https://api.github.com/repos/tw93/mole/releases/latest 2> /dev/null | grep '"tag_name"' | sed -E 's/.*"v?([^"]+)".*/\1/' > "$temp_version" &
local curl_pid=$!
# Wait for curl to complete (allows Ctrl+C to interrupt)
if wait "$curl_pid" 2> /dev/null; then
latest_version=$(cat "$temp_version" 2> /dev/null || echo "")
# Save to cache
if [[ -n "$latest_version" ]]; then
ensure_user_file "$cache_file"
echo "$latest_version" > "$cache_file" 2> /dev/null || true
fi
fi
rm -f "$temp_version" 2> /dev/null || true
fi
# Stop spinner
if [[ -t 1 ]]; then
stop_inline_spinner
fi
fi
# Normalize version strings (remove leading 'v' or 'V')
current_version="${current_version#v}"
current_version="${current_version#V}"
latest_version="${latest_version#v}"
latest_version="${latest_version#V}"
if [[ -n "$latest_version" && "$current_version" != "$latest_version" ]]; then
# Compare versions
if [[ "$(printf '%s\n' "$current_version" "$latest_version" | sort -V | head -1)" == "$current_version" ]]; then
export MOLE_UPDATE_AVAILABLE="true"
printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}, running %s\n" "$ICON_WARNING" "Mole" "${latest_version} available" "${current_version}"
else
printf " ${GREEN}${NC} %-12s %s\n" "Mole" "Latest version ${current_version}"
fi
else
printf " ${GREEN}${NC} %-12s %s\n" "Mole" "Latest version ${current_version}"
fi
}
check_all_updates() {
# Reset spinner flag for softwareupdate
unset SOFTWAREUPDATE_SPINNER_SHOWN
# Preload software update data to avoid delays between subsequent checks
# Only redirect stdout, keep stderr for spinner display
get_software_updates > /dev/null
echo -e "${BLUE}${ICON_ARROW}${NC} System Updates"
check_homebrew_updates
check_appstore_updates
check_macos_update
check_mole_update
}
get_appstore_update_labels() {
get_software_updates | awk '
/^\*/ {
label=$0
sub(/^[[:space:]]*\* Label: */, "", label)
sub(/,.*/, "", label)
lower=tolower(label)
if (index(lower, "macos") == 0) {
print label
}
}
'
}
get_macos_update_labels() {
get_software_updates | awk '
/^\*/ {
label=$0
sub(/^[[:space:]]*\* Label: */, "", label)
sub(/,.*/, "", label)
lower=tolower(label)
if (index(lower, "macos") != 0) {
print label
}
}
'
}
# ============================================================================
# System Health Checks
# ============================================================================
check_disk_space() {
# Use df -k to get KB values (always numeric), then calculate GB via math
# This avoids unit suffix parsing issues (df -H can return MB or GB)
local free_kb=$(command df -k / | awk 'NR==2 {print $4}')
local free_gb=$(awk "BEGIN {printf \"%.1f\", $free_kb / 1048576}")
local free_num=$(awk "BEGIN {printf \"%d\", $free_kb / 1048576}")
export DISK_FREE_GB=$free_num
if [[ $free_num -lt 20 ]]; then
echo -e " ${RED}${NC} Disk Space ${RED}${free_gb}GB free${NC}, Critical"
elif [[ $free_num -lt 50 ]]; then
echo -e " ${GRAY}${ICON_WARNING}${NC} Disk Space ${YELLOW}${free_gb}GB free${NC}, Low"
else
echo -e " ${GREEN}${NC} Disk Space ${free_gb}GB free"
fi
}
check_memory_usage() {
local mem_total
mem_total=$(sysctl -n hw.memsize 2> /dev/null || echo "0")
if [[ -z "$mem_total" || "$mem_total" -le 0 ]]; then
echo -e " ${GRAY}-${NC} Memory Unable to determine"
return
fi
local vm_output
vm_output=$(vm_stat 2> /dev/null || echo "")
local page_size
page_size=$(echo "$vm_output" | awk '/page size of/ {print $8}')
[[ -z "$page_size" ]] && page_size=4096
local free_pages inactive_pages spec_pages
free_pages=$(echo "$vm_output" | awk '/Pages free/ {gsub(/\./,"",$3); print $3}')
inactive_pages=$(echo "$vm_output" | awk '/Pages inactive/ {gsub(/\./,"",$3); print $3}')
spec_pages=$(echo "$vm_output" | awk '/Pages speculative/ {gsub(/\./,"",$3); print $3}')
free_pages=${free_pages:-0}
inactive_pages=${inactive_pages:-0}
spec_pages=${spec_pages:-0}
# Estimate used percent: (total - free - inactive - speculative) / total
local total_pages=$((mem_total / page_size))
local free_total=$((free_pages + inactive_pages + spec_pages))
local used_pages=$((total_pages - free_total))
if ((used_pages < 0)); then
used_pages=0
fi
local used_percent
used_percent=$(awk "BEGIN {printf \"%.0f\", ($used_pages / $total_pages) * 100}")
((used_percent > 100)) && used_percent=100
((used_percent < 0)) && used_percent=0
if [[ $used_percent -gt 90 ]]; then
echo -e " ${RED}${NC} Memory ${RED}${used_percent}% used${NC}, Critical"
elif [[ $used_percent -gt 80 ]]; then
echo -e " ${GRAY}${ICON_WARNING}${NC} Memory ${YELLOW}${used_percent}% used${NC}, High"
else
echo -e " ${GREEN}${NC} Memory ${used_percent}% used"
fi
}
check_login_items() {
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "check_login_items"; then return; fi
local login_items_count=0
local -a login_items_list=()
if [[ -t 0 ]]; then
# Show spinner while getting login items
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking login items..."
fi
while IFS= read -r login_item; do
[[ -n "$login_item" ]] && login_items_list+=("$login_item")
done < <(list_login_items || true)
login_items_count=${#login_items_list[@]}
# Stop spinner before output
if [[ -t 1 ]]; then
stop_inline_spinner
fi
fi
if [[ $login_items_count -gt 15 ]]; then
echo -e " ${GRAY}${ICON_WARNING}${NC} Login Items ${YELLOW}${login_items_count} apps${NC}"
elif [[ $login_items_count -gt 0 ]]; then
echo -e " ${GREEN}${NC} Login Items ${login_items_count} apps"
else
echo -e " ${GREEN}${NC} Login Items None"
return
fi
# Show items in a single line (compact)
local preview_limit=3
((preview_limit > login_items_count)) && preview_limit=$login_items_count
local items_display=""
for ((i = 0; i < preview_limit; i++)); do
if [[ $i -eq 0 ]]; then
items_display="${login_items_list[$i]}"
else
items_display="${items_display}, ${login_items_list[$i]}"
fi
done
if ((login_items_count > preview_limit)); then
local remaining=$((login_items_count - preview_limit))
items_display="${items_display} +${remaining}"
fi
echo -e " ${GRAY}${items_display}${NC}"
}
check_cache_size() {
local cache_size_kb=0
# Check common cache locations
local -a cache_paths=(
"$HOME/Library/Caches"
"$HOME/Library/Logs"
)
# Show spinner while calculating cache size
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning cache..."
fi
for cache_path in "${cache_paths[@]}"; do
if [[ -d "$cache_path" ]]; then
local size_output
size_output=$(get_path_size_kb "$cache_path")
[[ "$size_output" =~ ^[0-9]+$ ]] || size_output=0
cache_size_kb=$((cache_size_kb + size_output))
fi
done
local cache_size_gb=$(echo "scale=1; $cache_size_kb / 1024 / 1024" | bc)
export CACHE_SIZE_GB=$cache_size_gb
# Stop spinner before output
if [[ -t 1 ]]; then
stop_inline_spinner
fi
# Convert to integer for comparison
local cache_size_int=$(echo "$cache_size_gb" | cut -d'.' -f1)
if [[ $cache_size_int -gt 10 ]]; then
echo -e " ${GRAY}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable"
elif [[ $cache_size_int -gt 5 ]]; then
echo -e " ${GRAY}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable"
else
echo -e " ${GREEN}${NC} Cache Size ${cache_size_gb}GB"
fi
}
check_swap_usage() {
# Check swap usage
if command -v sysctl > /dev/null 2>&1; then
local swap_info=$(sysctl vm.swapusage 2> /dev/null || echo "")
if [[ -n "$swap_info" ]]; then
local swap_used=$(echo "$swap_info" | grep -o "used = [0-9.]*[GM]" | awk 'NR==1{print $3}')
swap_used=${swap_used:-0M}
local swap_num="${swap_used//[GM]/}"
if [[ "$swap_used" == *"G"* ]]; then
local swap_gb=${swap_num%.*}
if [[ $swap_gb -gt 2 ]]; then
echo -e " ${GRAY}${ICON_WARNING}${NC} Swap Usage ${YELLOW}${swap_used}${NC}, High"
else
echo -e " ${GREEN}${NC} Swap Usage ${swap_used}"
fi
else
echo -e " ${GREEN}${NC} Swap Usage ${swap_used}"
fi
fi
fi
}
check_brew_health() {
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "check_brew_health"; then return; fi
}
check_system_health() {
echo -e "${BLUE}${ICON_ARROW}${NC} System Health"
check_disk_space
check_memory_usage
check_swap_usage
check_login_items
check_cache_size
# Time Machine check is optional; skip by default to avoid noise on systems without backups
}

View File

@@ -0,0 +1,184 @@
#!/bin/bash
# System Health Check - JSON Generator
# Extracted from tasks.sh
set -euo pipefail
# Ensure dependencies are loaded (only if running standalone)
if [[ -z "${MOLE_FILE_OPS_LOADED:-}" ]]; then
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$SCRIPT_DIR/lib/core/file_ops.sh"
fi
# Get memory info in GB
get_memory_info() {
local total_bytes used_gb total_gb
# Total memory
total_bytes=$(sysctl -n hw.memsize 2> /dev/null || echo "0")
total_gb=$(LC_ALL=C awk "BEGIN {printf \"%.2f\", $total_bytes / (1024*1024*1024)}" 2> /dev/null || echo "0")
[[ -z "$total_gb" || "$total_gb" == "" ]] && total_gb="0"
# Used memory from vm_stat
local vm_output active wired compressed page_size
vm_output=$(vm_stat 2> /dev/null || echo "")
page_size=4096
active=$(echo "$vm_output" | LC_ALL=C awk '/Pages active:/ {print $NF}' | tr -d '.\n' 2> /dev/null)
wired=$(echo "$vm_output" | LC_ALL=C awk '/Pages wired down:/ {print $NF}' | tr -d '.\n' 2> /dev/null)
compressed=$(echo "$vm_output" | LC_ALL=C awk '/Pages occupied by compressor:/ {print $NF}' | tr -d '.\n' 2> /dev/null)
active=${active:-0}
wired=${wired:-0}
compressed=${compressed:-0}
local used_bytes=$(((active + wired + compressed) * page_size))
used_gb=$(LC_ALL=C awk "BEGIN {printf \"%.2f\", $used_bytes / (1024*1024*1024)}" 2> /dev/null || echo "0")
[[ -z "$used_gb" || "$used_gb" == "" ]] && used_gb="0"
echo "$used_gb $total_gb"
}
# Get disk info
get_disk_info() {
local home="${HOME:-/}"
local df_output total_gb used_gb used_percent
df_output=$(command df -k "$home" 2> /dev/null | tail -1)
local total_kb used_kb
total_kb=$(echo "$df_output" | LC_ALL=C awk 'NR==1{print $2}' 2> /dev/null)
used_kb=$(echo "$df_output" | LC_ALL=C awk 'NR==1{print $3}' 2> /dev/null)
total_kb=${total_kb:-0}
used_kb=${used_kb:-0}
[[ "$total_kb" == "0" ]] && total_kb=1 # Avoid division by zero
total_gb=$(LC_ALL=C awk "BEGIN {printf \"%.2f\", $total_kb / (1024*1024)}" 2> /dev/null || echo "0")
used_gb=$(LC_ALL=C awk "BEGIN {printf \"%.2f\", $used_kb / (1024*1024)}" 2> /dev/null || echo "0")
used_percent=$(LC_ALL=C awk "BEGIN {printf \"%.1f\", ($used_kb / $total_kb) * 100}" 2> /dev/null || echo "0")
[[ -z "$total_gb" || "$total_gb" == "" ]] && total_gb="0"
[[ -z "$used_gb" || "$used_gb" == "" ]] && used_gb="0"
[[ -z "$used_percent" || "$used_percent" == "" ]] && used_percent="0"
echo "$used_gb $total_gb $used_percent"
}
# Get uptime in days
get_uptime_days() {
local boot_output boot_time uptime_days
boot_output=$(sysctl -n kern.boottime 2> /dev/null || echo "")
boot_time=$(echo "$boot_output" | awk -F 'sec = |, usec' '{print $2}' 2> /dev/null || echo "")
if [[ -n "$boot_time" && "$boot_time" =~ ^[0-9]+$ ]]; then
local now
now=$(get_epoch_seconds)
local uptime_sec=$((now - boot_time))
uptime_days=$(LC_ALL=C awk "BEGIN {printf \"%.1f\", $uptime_sec / 86400}" 2> /dev/null || echo "0")
else
uptime_days="0"
fi
[[ -z "$uptime_days" || "$uptime_days" == "" ]] && uptime_days="0"
echo "$uptime_days"
}
# JSON escape helper
json_escape() {
# Escape backslash, double quote, tab, and newline
local escaped
escaped=$(echo -n "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr '\n' ' ')
echo -n "${escaped% }"
}
# Generate JSON output
generate_health_json() {
# System info
read -r mem_used mem_total <<< "$(get_memory_info)"
read -r disk_used disk_total disk_percent <<< "$(get_disk_info)"
local uptime=$(get_uptime_days)
# Ensure all values are valid numbers (fallback to 0)
mem_used=${mem_used:-0}
mem_total=${mem_total:-0}
disk_used=${disk_used:-0}
disk_total=${disk_total:-0}
disk_percent=${disk_percent:-0}
uptime=${uptime:-0}
# Start JSON
cat << EOF
{
"memory_used_gb": $mem_used,
"memory_total_gb": $mem_total,
"disk_used_gb": $disk_used,
"disk_total_gb": $disk_total,
"disk_used_percent": $disk_percent,
"uptime_days": $uptime,
"optimizations": [
EOF
# Collect all optimization items
local -a items=()
# Core optimizations (safe and valuable)
items+=('system_maintenance|DNS & Spotlight Check|Refresh DNS cache & verify Spotlight status|true')
items+=('cache_refresh|Finder Cache Refresh|Refresh QuickLook thumbnails & icon services cache|true')
items+=('saved_state_cleanup|App State Cleanup|Remove old saved application states (30+ days)|true')
items+=('fix_broken_configs|Broken Config Repair|Fix corrupted preferences files|true')
items+=('network_optimization|Network Cache Refresh|Optimize DNS cache & restart mDNSResponder|true')
# Advanced optimizations (high value, auto-run with safety checks)
items+=('sqlite_vacuum|Database Optimization|Compress SQLite databases for Mail, Safari & Messages (skips if apps are running)|true')
items+=('launch_services_rebuild|LaunchServices Repair|Repair "Open with" menu & file associations|true')
items+=('font_cache_rebuild|Font Cache Rebuild|Rebuild font database to fix rendering issues (skips if browsers are running)|true')
items+=('dock_refresh|Dock Refresh|Fix broken icons and visual glitches in the Dock|true')
# System performance optimizations (new)
items+=('memory_pressure_relief|Memory Optimization|Release inactive memory to improve system responsiveness|true')
items+=('network_stack_optimize|Network Stack Refresh|Flush routing table and ARP cache to resolve network issues|true')
items+=('disk_permissions_repair|Permission Repair|Fix user directory permission issues|true')
items+=('bluetooth_reset|Bluetooth Refresh|Restart Bluetooth module to fix connectivity (skips if in use)|true')
items+=('spotlight_index_optimize|Spotlight Optimization|Rebuild index if search is slow (smart detection)|true')
# Removed high-risk optimizations:
# - startup_items_cleanup: Risk of deleting legitimate app helpers
# - system_services_refresh: Risk of data loss when killing system services
# - dyld_cache_update: Low benefit, time-consuming, auto-managed by macOS
# Output items as JSON
local first=true
for item in "${items[@]}"; do
IFS='|' read -r action name desc safe <<< "$item"
# Escape strings
action=$(json_escape "$action")
name=$(json_escape "$name")
desc=$(json_escape "$desc")
[[ "$first" == "true" ]] && first=false || echo ","
cat << EOF
{
"category": "system",
"name": "$name",
"description": "$desc",
"action": "$action",
"safe": $safe
}
EOF
done
# Close JSON
cat << 'EOF'
]
}
EOF
}
# Main execution (for testing)
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
generate_health_json
fi

View File

@@ -0,0 +1,265 @@
#!/bin/bash
# User GUI Applications Cleanup Module (desktop apps, media, utilities).
set -euo pipefail
# Xcode and iOS tooling.
clean_xcode_tools() {
# Skip DerivedData/Archives while Xcode is running.
local xcode_running=false
if pgrep -x "Xcode" > /dev/null 2>&1; then
xcode_running=true
fi
safe_clean ~/Library/Developer/CoreSimulator/Caches/* "Simulator cache"
safe_clean ~/Library/Developer/CoreSimulator/Devices/*/data/tmp/* "Simulator temp files"
safe_clean ~/Library/Caches/com.apple.dt.Xcode/* "Xcode cache"
safe_clean ~/Library/Developer/Xcode/iOS\ Device\ Logs/* "iOS device logs"
safe_clean ~/Library/Developer/Xcode/watchOS\ Device\ Logs/* "watchOS device logs"
safe_clean ~/Library/Logs/CoreSimulator/* "CoreSimulator logs"
safe_clean ~/Library/Developer/Xcode/Products/* "Xcode build products"
if [[ "$xcode_running" == "false" ]]; then
safe_clean ~/Library/Developer/Xcode/DerivedData/* "Xcode derived data"
safe_clean ~/Library/Developer/Xcode/Archives/* "Xcode archives"
safe_clean ~/Library/Developer/Xcode/DocumentationCache/* "Xcode documentation cache"
safe_clean ~/Library/Developer/Xcode/DocumentationIndex/* "Xcode documentation index"
else
echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode is running, skipping DerivedData/Archives/Documentation cleanup"
fi
}
# Code editors.
clean_code_editors() {
safe_clean ~/Library/Application\ Support/Code/logs/* "VS Code logs"
safe_clean ~/Library/Application\ Support/Code/Cache/* "VS Code cache"
safe_clean ~/Library/Application\ Support/Code/CachedExtensions/* "VS Code extension cache"
safe_clean ~/Library/Application\ Support/Code/CachedData/* "VS Code data cache"
safe_clean ~/Library/Caches/com.sublimetext.*/* "Sublime Text cache"
}
# Communication apps.
clean_communication_apps() {
safe_clean ~/Library/Application\ Support/discord/Cache/* "Discord cache"
safe_clean ~/Library/Application\ Support/legcord/Cache/* "Legcord cache"
safe_clean ~/Library/Application\ Support/Slack/Cache/* "Slack cache"
safe_clean ~/Library/Caches/us.zoom.xos/* "Zoom cache"
safe_clean ~/Library/Caches/com.tencent.xinWeChat/* "WeChat cache"
safe_clean ~/Library/Caches/ru.keepcoder.Telegram/* "Telegram cache"
safe_clean ~/Library/Caches/com.microsoft.teams2/* "Microsoft Teams cache"
safe_clean ~/Library/Caches/net.whatsapp.WhatsApp/* "WhatsApp cache"
safe_clean ~/Library/Caches/com.skype.skype/* "Skype cache"
safe_clean ~/Library/Caches/com.tencent.meeting/* "Tencent Meeting cache"
safe_clean ~/Library/Caches/com.tencent.WeWorkMac/* "WeCom cache"
safe_clean ~/Library/Caches/com.feishu.*/* "Feishu cache"
safe_clean ~/Library/Application\ Support/Microsoft/Teams/Cache/* "Microsoft Teams legacy cache"
safe_clean ~/Library/Application\ Support/Microsoft/Teams/Application\ Cache/* "Microsoft Teams legacy application cache"
safe_clean ~/Library/Application\ Support/Microsoft/Teams/Code\ Cache/* "Microsoft Teams legacy code cache"
safe_clean ~/Library/Application\ Support/Microsoft/Teams/GPUCache/* "Microsoft Teams legacy GPU cache"
safe_clean ~/Library/Application\ Support/Microsoft/Teams/logs/* "Microsoft Teams legacy logs"
safe_clean ~/Library/Application\ Support/Microsoft/Teams/tmp/* "Microsoft Teams legacy temp files"
}
# DingTalk.
clean_dingtalk() {
safe_clean ~/Library/Caches/dd.work.exclusive4aliding/* "DingTalk iDingTalk cache"
safe_clean ~/Library/Caches/com.alibaba.AliLang.osx/* "AliLang security component"
safe_clean ~/Library/Application\ Support/iDingTalk/log/* "DingTalk logs"
safe_clean ~/Library/Application\ Support/iDingTalk/holmeslogs/* "DingTalk holmes logs"
}
# AI assistants.
clean_ai_apps() {
safe_clean ~/Library/Caches/com.openai.chat/* "ChatGPT cache"
safe_clean ~/Library/Caches/com.anthropic.claudefordesktop/* "Claude desktop cache"
safe_clean ~/Library/Logs/Claude/* "Claude logs"
}
# Design and creative tools.
clean_design_tools() {
safe_clean ~/Library/Caches/com.bohemiancoding.sketch3/* "Sketch cache"
safe_clean ~/Library/Application\ Support/com.bohemiancoding.sketch3/cache/* "Sketch app cache"
safe_clean ~/Library/Caches/Adobe/* "Adobe cache"
safe_clean ~/Library/Caches/com.adobe.*/* "Adobe app caches"
safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache"
safe_clean ~/Library/Application\ Support/Adobe/Common/Media\ Cache\ Files/* "Adobe media cache files"
# Raycast cache is protected (clipboard history, images).
}
# Video editing tools.
clean_video_tools() {
safe_clean ~/Library/Caches/net.telestream.screenflow10/* "ScreenFlow cache"
safe_clean ~/Library/Caches/com.apple.FinalCut/* "Final Cut Pro cache"
safe_clean ~/Library/Caches/com.blackmagic-design.DaVinciResolve/* "DaVinci Resolve cache"
safe_clean ~/Library/Caches/com.adobe.PremierePro.*/* "Premiere Pro cache"
}
# 3D and CAD tools.
clean_3d_tools() {
safe_clean ~/Library/Caches/org.blenderfoundation.blender/* "Blender cache"
safe_clean ~/Library/Caches/com.maxon.cinema4d/* "Cinema 4D cache"
safe_clean ~/Library/Caches/com.autodesk.*/* "Autodesk cache"
safe_clean ~/Library/Caches/com.sketchup.*/* "SketchUp cache"
}
# Productivity apps.
clean_productivity_apps() {
safe_clean ~/Library/Caches/com.tw93.MiaoYan/* "MiaoYan cache"
safe_clean ~/Library/Caches/com.klee.desktop/* "Klee cache"
safe_clean ~/Library/Caches/klee_desktop/* "Klee desktop cache"
safe_clean ~/Library/Caches/com.orabrowser.app/* "Ora browser cache"
safe_clean ~/Library/Caches/com.filo.client/* "Filo cache"
safe_clean ~/Library/Caches/com.flomoapp.mac/* "Flomo cache"
safe_clean ~/Library/Application\ Support/Quark/Cache/videoCache/* "Quark video cache"
}
# Music/media players (protect Spotify offline music).
clean_media_players() {
local spotify_cache="$HOME/Library/Caches/com.spotify.client"
local spotify_data="$HOME/Library/Application Support/Spotify"
local has_offline_music=false
# Heuristics: offline DB or large cache.
if [[ -f "$spotify_data/PersistentCache/Storage/offline.bnk" ]] ||
[[ -d "$spotify_data/PersistentCache/Storage" && -n "$(find "$spotify_data/PersistentCache/Storage" -type f -name "*.file" 2> /dev/null | head -1)" ]]; then
has_offline_music=true
elif [[ -d "$spotify_cache" ]]; then
local cache_size_kb
cache_size_kb=$(get_path_size_kb "$spotify_cache")
if [[ $cache_size_kb -ge 512000 ]]; then
has_offline_music=true
fi
fi
if [[ "$has_offline_music" == "true" ]]; then
echo -e " ${GRAY}${ICON_WARNING}${NC} Spotify cache protected · offline music detected"
note_activity
else
safe_clean ~/Library/Caches/com.spotify.client/* "Spotify cache"
fi
safe_clean ~/Library/Caches/com.apple.Music "Apple Music cache"
safe_clean ~/Library/Caches/com.apple.podcasts "Apple Podcasts cache"
# Apple Podcasts sandbox container: zombie sparse files and stale artwork cache (#387)
safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/StreamedMedia "Podcasts streamed media"
safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/*.heic "Podcasts artwork cache"
safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/*.img "Podcasts image cache"
safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/*CFNetworkDownload*.tmp "Podcasts download temp"
safe_clean ~/Library/Caches/com.apple.TV/* "Apple TV cache"
safe_clean ~/Library/Caches/tv.plex.player.desktop "Plex cache"
safe_clean ~/Library/Caches/com.netease.163music "NetEase Music cache"
safe_clean ~/Library/Caches/com.tencent.QQMusic/* "QQ Music cache"
safe_clean ~/Library/Caches/com.kugou.mac/* "Kugou Music cache"
safe_clean ~/Library/Caches/com.kuwo.mac/* "Kuwo Music cache"
}
# Video players.
clean_video_players() {
safe_clean ~/Library/Caches/com.colliderli.iina "IINA cache"
safe_clean ~/Library/Caches/org.videolan.vlc "VLC cache"
safe_clean ~/Library/Caches/io.mpv "MPV cache"
safe_clean ~/Library/Caches/com.iqiyi.player "iQIYI cache"
safe_clean ~/Library/Caches/com.tencent.tenvideo "Tencent Video cache"
safe_clean ~/Library/Caches/tv.danmaku.bili/* "Bilibili cache"
safe_clean ~/Library/Caches/com.douyu.*/* "Douyu cache"
safe_clean ~/Library/Caches/com.huya.*/* "Huya cache"
}
# Download managers.
clean_download_managers() {
safe_clean ~/Library/Caches/net.xmac.aria2gui "Aria2 cache"
safe_clean ~/Library/Caches/org.m0k.transmission "Transmission cache"
safe_clean ~/Library/Caches/com.qbittorrent.qBittorrent "qBittorrent cache"
safe_clean ~/Library/Caches/com.downie.Downie-* "Downie cache"
safe_clean ~/Library/Caches/com.folx.*/* "Folx cache"
safe_clean ~/Library/Caches/com.charlessoft.pacifist/* "Pacifist cache"
}
# Gaming platforms.
clean_gaming_platforms() {
safe_clean ~/Library/Caches/com.valvesoftware.steam/* "Steam cache"
safe_clean ~/Library/Application\ Support/Steam/htmlcache/* "Steam web cache"
safe_clean ~/Library/Application\ Support/Steam/appcache/* "Steam app cache"
safe_clean ~/Library/Application\ Support/Steam/depotcache/* "Steam depot cache"
safe_clean ~/Library/Application\ Support/Steam/steamapps/shadercache/* "Steam shader cache"
safe_clean ~/Library/Application\ Support/Steam/logs/* "Steam logs"
safe_clean ~/Library/Caches/com.epicgames.EpicGamesLauncher/* "Epic Games cache"
safe_clean ~/Library/Caches/com.blizzard.Battle.net/* "Battle.net cache"
safe_clean ~/Library/Application\ Support/Battle.net/Cache/* "Battle.net app cache"
safe_clean ~/Library/Caches/com.ea.*/* "EA Origin cache"
safe_clean ~/Library/Caches/com.gog.galaxy/* "GOG Galaxy cache"
safe_clean ~/Library/Caches/com.riotgames.*/* "Riot Games cache"
safe_clean ~/Library/Application\ Support/minecraft/logs/* "Minecraft logs"
safe_clean ~/Library/Application\ Support/minecraft/crash-reports/* "Minecraft crash reports"
safe_clean ~/Library/Application\ Support/minecraft/webcache/* "Minecraft web cache"
safe_clean ~/Library/Application\ Support/minecraft/webcache2/* "Minecraft web cache 2"
safe_clean ~/.lunarclient/game-cache/* "Lunar Client game cache"
safe_clean ~/.lunarclient/launcher-cache/* "Lunar Client launcher cache"
safe_clean ~/.lunarclient/logs/* "Lunar Client logs"
safe_clean ~/.lunarclient/offline/*/logs/* "Lunar Client offline logs"
safe_clean ~/.lunarclient/offline/files/*/logs/* "Lunar Client offline file logs"
}
# Translation/dictionary apps.
clean_translation_apps() {
safe_clean ~/Library/Caches/com.youdao.YoudaoDict "Youdao Dictionary cache"
safe_clean ~/Library/Caches/com.eudic.* "Eudict cache"
safe_clean ~/Library/Caches/com.bob-build.Bob "Bob Translation cache"
}
# Screenshot/recording tools.
clean_screenshot_tools() {
safe_clean ~/Library/Caches/com.cleanshot.* "CleanShot cache"
safe_clean ~/Library/Caches/com.reincubate.camo "Camo cache"
safe_clean ~/Library/Caches/com.xnipapp.xnip "Xnip cache"
}
# Email clients.
clean_email_clients() {
safe_clean ~/Library/Caches/com.readdle.smartemail-Mac "Spark cache"
safe_clean ~/Library/Caches/com.airmail.* "Airmail cache"
}
# Task management apps.
clean_task_apps() {
safe_clean ~/Library/Caches/com.todoist.mac.Todoist "Todoist cache"
safe_clean ~/Library/Caches/com.any.do.* "Any.do cache"
}
# Shell/terminal utilities.
clean_shell_utils() {
safe_clean ~/.zcompdump* "Zsh completion cache"
safe_clean ~/.lesshst "less history"
safe_clean ~/.viminfo.tmp "Vim temporary files"
safe_clean ~/.wget-hsts "wget HSTS cache"
safe_clean ~/.cacher/logs/* "Cacher logs"
safe_clean ~/.kite/logs/* "Kite logs"
}
# Input methods and system utilities.
clean_system_utils() {
safe_clean ~/Library/Caches/com.runjuu.Input-Source-Pro/* "Input Source Pro cache"
safe_clean ~/Library/Caches/macos-wakatime.WakaTime/* "WakaTime cache"
}
# Note-taking apps.
clean_note_apps() {
safe_clean ~/Library/Caches/notion.id/* "Notion cache"
safe_clean ~/Library/Caches/md.obsidian/* "Obsidian cache"
safe_clean ~/Library/Caches/com.logseq.*/* "Logseq cache"
safe_clean ~/Library/Caches/com.bear-writer.*/* "Bear cache"
safe_clean ~/Library/Caches/com.evernote.*/* "Evernote cache"
safe_clean ~/Library/Caches/com.yinxiang.*/* "Yinxiang Note cache"
}
# Launchers and automation tools.
clean_launcher_apps() {
safe_clean ~/Library/Caches/com.runningwithcrayons.Alfred/* "Alfred cache"
safe_clean ~/Library/Caches/cx.c3.theunarchiver/* "The Unarchiver cache"
}
# Remote desktop tools.
clean_remote_desktop() {
safe_clean ~/Library/Caches/com.teamviewer.*/* "TeamViewer cache"
safe_clean ~/Library/Caches/com.anydesk.*/* "AnyDesk cache"
safe_clean ~/Library/Caches/com.todesk.*/* "ToDesk cache"
safe_clean ~/Library/Caches/com.sunlogin.*/* "Sunlogin cache"
}
# Main entry for GUI app cleanup.
clean_user_gui_applications() {
stop_section_spinner
clean_xcode_tools
clean_code_editors
clean_communication_apps
clean_dingtalk
clean_ai_apps
clean_design_tools
clean_video_tools
clean_3d_tools
clean_productivity_apps
clean_media_players
clean_video_players
clean_download_managers
clean_gaming_platforms
clean_translation_apps
clean_screenshot_tools
clean_email_clients
clean_task_apps
clean_shell_utils
clean_system_utils
clean_note_apps
clean_launcher_apps
clean_remote_desktop
}

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