feat: add Atlas native app UX overhaul
23
.github/workflows/atlas-acceptance.yml
vendored
Normal 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
@@ -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
@@ -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`
|
||||||
279
Apps/AtlasApp/Sources/AtlasApp/AppShellView.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 686 B |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Apps/AtlasApp/Sources/AtlasApp/AtlasApp.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
87
Apps/AtlasApp/Sources/AtlasApp/AtlasAppCommands.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
576
Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
106
Apps/AtlasApp/Sources/AtlasApp/TaskCenterView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
297
Apps/AtlasApp/Tests/AtlasAppTests/AtlasAppModelTests.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
85
Apps/AtlasAppUITests/AtlasAppUITests.swift
Normal 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
@@ -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
@@ -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.
|
||||||
21
Docs/ADR/ADR-001-Worker-and-Helper-Boundary.md
Normal 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
|
||||||
21
Docs/ADR/ADR-002-Protocol-and-Adapters.md
Normal 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
|
||||||
@@ -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.
|
||||||
29
Docs/ADR/ADR-004-Helper-Executable-and-Native-Packaging.md
Normal 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.
|
||||||
29
Docs/ADR/ADR-005-Localization-and-App-Language-Preference.md
Normal 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.
|
||||||
33
Docs/ADR/ADR-006-Fail-Closed-Execution-Capability.md
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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" overline(line 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
@@ -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`
|
||||||
125
Docs/Execution/Beta-Acceptance-Checklist.md
Normal 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 | |
|
||||||
95
Docs/Execution/Beta-Gate-Review.md
Normal 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.
|
||||||
69
Docs/Execution/Current-Status-2026-03-07.md
Normal 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.
|
||||||
146
Docs/Execution/Execution-Chain-Audit-2026-03-09.md
Normal 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 Atlas’s 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.
|
||||||
58
Docs/Execution/MVP-Acceptance-Matrix.md
Normal 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.
|
||||||
196
Docs/Execution/Manual-Test-SOP.md
Normal 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`
|
||||||
36
Docs/Execution/Polish-Week-01.md
Normal 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
|
||||||
70
Docs/Execution/Release-Signing.md
Normal 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.
|
||||||
142
Docs/Execution/Smart-Clean-Execution-Coverage-2026-03-09.md
Normal 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 Atlas’s 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
|
||||||
142
Docs/Execution/Smart-Clean-Manual-Verification-2026-03-09.md
Normal 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`
|
||||||
102
Docs/Execution/Smart-Clean-QA-Checklist-2026-03-09.md
Normal 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
|
||||||
361
Docs/Execution/UI-Audit-2026-03-08.md
Normal 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”
|
||||||
92
Docs/Execution/UI-Automation-Blocker.md
Normal 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.
|
||||||
213
Docs/Execution/UI-Copy-Walkthrough-2026-03-09.md
Normal 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
@@ -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
@@ -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
@@ -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
|
||||||
43
Docs/HELP_CENTER_OUTLINE.md
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`
|
||||||
21
Docs/Sequence/execute-flow.md
Normal 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.
|
||||||
19
Docs/Sequence/restore-flow.md
Normal 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.
|
||||||
21
Docs/Sequence/scan-flow.md
Normal 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.
|
||||||
24
Docs/THIRD_PARTY_NOTICES.md
Normal 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
@@ -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.
|
||||||
24
Docs/Templates/ADR_TEMPLATE.md
Normal 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
|
||||||
23
Docs/Templates/AGENT_HANDOFF_TEMPLATE.md
Normal 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?
|
||||||
43
Docs/Templates/EPIC_TEMPLATE.md
Normal 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
|
||||||
32
Docs/Templates/GATE_REVIEW_TEMPLATE.md
Normal 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
|
||||||
56
Docs/Templates/ISSUE_TEMPLATE.md
Normal 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
@@ -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`.
|
||||||
14
Helpers/AtlasPrivilegedHelper/README.md
Normal 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)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: ", "))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
10
MenuBar/AtlasMenuBar/README.md
Normal 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
@@ -0,0 +1,3 @@
|
|||||||
|
# MenuBar
|
||||||
|
|
||||||
|
This directory contains planned menu-bar targets and helpers.
|
||||||
19
Packages/AtlasApplication/README.md
Normal 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.
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Packages/AtlasCoreAdapters/README.md
Normal 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.
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 "$@"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 "$@"
|
||||||
@@ -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 "$@"
|
||||||
@@ -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
|
||||||
@@ -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 "$@"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||