feat: add Atlas native app UX overhaul

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

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

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

43
Apps/Package.swift Normal file
View File

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

10
Apps/README.md Normal file
View File

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