feat: add Atlas native app UX overhaul
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.
|
||||