chore: import upstream snapshot with attribution
Build / Build and test (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:18:38 +08:00
commit 1d1286fadb
1468 changed files with 272754 additions and 0 deletions
@@ -0,0 +1,414 @@
import AppKit
import SwiftUI
import MacToolsPluginKit
// MARK: - Window
@MainActor
final class XcodeCleanConfirmWindow: NSPanel {
private let viewModel: XcodeCleanConfirmViewModel
private var onDismiss: (() -> Void)?
init(
candidates: [XcodeCleanCandidate],
localization: PluginLocalization = PluginLocalization(bundle: .main),
onConfirm: @escaping (Set<XcodeCleanCandidate.ID>) -> Void,
onCancel: @escaping () -> Void
) {
let size = NSSize(width: 480, height: 540)
self.viewModel = XcodeCleanConfirmViewModel(candidates: candidates, localization: localization)
super.init(
contentRect: NSRect(origin: .zero, size: size),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
isFloatingPanel = true
level = .floating
backgroundColor = .clear
isOpaque = false
hasShadow = true
isMovableByWindowBackground = true
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
let effectView = NSVisualEffectView(frame: NSRect(origin: .zero, size: size))
effectView.material = .sidebar
effectView.blendingMode = .behindWindow
effectView.state = .active
effectView.maskImage = Self.roundedMaskImage(size: size, cornerRadius: 16)
let rootView = XcodeCleanConfirmView(
viewModel: viewModel,
localization: localization,
onConfirm: { [weak self] selectedIDs in
onConfirm(selectedIDs)
self?.orderOut(nil)
self?.onDismiss?()
},
onCancel: { [weak self] in
onCancel()
self?.orderOut(nil)
self?.onDismiss?()
}
)
let hostingView = NSHostingView(rootView: rootView)
hostingView.frame = effectView.bounds
hostingView.autoresizingMask = [.width, .height]
effectView.addSubview(hostingView)
contentView = effectView
setContentSize(size)
}
func attachDismissHandler(_ handler: @escaping () -> Void) {
onDismiss = handler
}
override func cancelOperation(_ sender: Any?) {
orderOut(nil)
onDismiss?()
}
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }
private static func roundedMaskImage(size: NSSize, cornerRadius: CGFloat) -> NSImage {
let image = NSImage(size: size, flipped: false) { rect in
NSColor.black.setFill()
NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius).fill()
return true
}
image.capInsets = NSEdgeInsets(
top: cornerRadius, left: cornerRadius,
bottom: cornerRadius, right: cornerRadius
)
image.resizingMode = .stretch
return image
}
}
// MARK: - View Model
@MainActor
final class XcodeCleanConfirmViewModel: ObservableObject {
struct Section: Identifiable {
let id: XcodeCleanCategory
let title: String
let candidates: [XcodeCleanCandidate]
}
@Published private(set) var sections: [Section]
@Published private(set) var selectedIDs: Set<XcodeCleanCandidate.ID>
private let allCleanableIDs: Set<XcodeCleanCandidate.ID>
init(
candidates: [XcodeCleanCandidate],
localization: PluginLocalization = PluginLocalization(bundle: .main)
) {
let cleanable = candidates.filter { $0.safety.isCleanable }
self.allCleanableIDs = Set(cleanable.map(\.id))
self.selectedIDs = self.allCleanableIDs
var grouped: [XcodeCleanCategory: [XcodeCleanCandidate]] = [:]
for candidate in cleanable {
grouped[candidate.category, default: []].append(candidate)
}
self.sections = XcodeCleanCategory.allCases.compactMap { category in
guard let items = grouped[category], !items.isEmpty else { return nil }
return Section(
id: category,
title: category.title(localization: localization),
candidates: items.sorted { $0.sizeBytes > $1.sizeBytes }
)
}
}
var totalCount: Int { allCleanableIDs.count }
var selectedCount: Int { selectedIDs.count }
var selectedSizeBytes: Int64 {
sections.reduce(Int64(0)) { partial, section in
partial + section.candidates.reduce(Int64(0)) { sum, candidate in
selectedIDs.contains(candidate.id) ? sum + max(candidate.sizeBytes, 0) : sum
}
}
}
var allSelected: Bool {
selectedIDs == allCleanableIDs
}
func toggle(id: XcodeCleanCandidate.ID) {
if selectedIDs.contains(id) {
selectedIDs.remove(id)
} else {
selectedIDs.insert(id)
}
}
func setSection(_ category: XcodeCleanCategory, selected: Bool) {
guard let section = sections.first(where: { $0.id == category }) else { return }
for candidate in section.candidates {
if selected {
selectedIDs.insert(candidate.id)
} else {
selectedIDs.remove(candidate.id)
}
}
}
func sectionState(_ category: XcodeCleanCategory) -> SectionSelectionState {
guard let section = sections.first(where: { $0.id == category }) else { return .none }
let selected = section.candidates.filter { selectedIDs.contains($0.id) }.count
if selected == 0 { return .none }
if selected == section.candidates.count { return .all }
return .partial
}
func toggleAll() {
if allSelected {
selectedIDs.removeAll()
} else {
selectedIDs = allCleanableIDs
}
}
}
enum SectionSelectionState {
case none
case partial
case all
}
// MARK: - SwiftUI View
private struct XcodeCleanConfirmView: View {
@ObservedObject var viewModel: XcodeCleanConfirmViewModel
let localization: PluginLocalization
let onConfirm: (Set<XcodeCleanCandidate.ID>) -> Void
let onCancel: () -> Void
var body: some View {
VStack(spacing: 0) {
header
Divider().opacity(0.5)
candidateList
Divider().opacity(0.5)
footer
}
}
private var header: some View {
HStack(spacing: 10) {
Image(systemName: "trash")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Color(nsColor: .systemBlue))
VStack(alignment: .leading, spacing: 2) {
Text(localization.string("confirm.title", defaultValue: "确认清理 Xcode 缓存"))
.font(.system(size: 15, weight: .semibold))
Text(headerSubtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button(action: { viewModel.toggleAll() }) {
Text(viewModel.allSelected
? localization.string("confirm.action.deselectAll", defaultValue: "全不选")
: localization.string("confirm.action.selectAll", defaultValue: "全选")
)
.font(.system(size: 12))
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private var headerSubtitle: String {
if viewModel.totalCount == 0 {
return localization.string("confirm.noItems", defaultValue: "没有可清理项目")
}
return localization.format(
"confirm.subtitle.selected",
defaultValue: "已选 %d / %d 项 · %@",
viewModel.selectedCount,
viewModel.totalCount,
byteText(viewModel.selectedSizeBytes)
)
}
private var candidateList: some View {
ScrollView {
if viewModel.sections.isEmpty {
emptyState
} else {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(viewModel.sections) { section in
sectionHeader(for: section)
ForEach(section.candidates) { candidate in
candidateRow(candidate)
.padding(.leading, 22)
Divider().opacity(0.3)
.padding(.leading, 22)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
.frame(maxHeight: .infinity)
}
private var emptyState: some View {
VStack(spacing: 8) {
Spacer(minLength: 60)
Image(systemName: "checkmark.circle")
.font(.system(size: 32))
.foregroundStyle(.secondary)
Text(localization.string("confirm.noItems", defaultValue: "没有可清理项目"))
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer(minLength: 60)
}
.frame(maxWidth: .infinity)
}
private func sectionHeader(for section: XcodeCleanConfirmViewModel.Section) -> some View {
let state = viewModel.sectionState(section.id)
return HStack(spacing: 8) {
Button(action: {
viewModel.setSection(section.id, selected: state != .all)
}) {
Image(systemName: sectionToggleIcon(state))
.font(.system(size: 14, weight: .regular))
.foregroundStyle(state == .none ? Color.secondary : Color.accentColor)
}
.buttonStyle(.plain)
Text(section.title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
Text(itemCountText(section.candidates.count))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 8)
}
private func sectionToggleIcon(_ state: SectionSelectionState) -> String {
switch state {
case .all: return "checkmark.square.fill"
case .partial: return "minus.square.fill"
case .none: return "square"
}
}
private func candidateRow(_ candidate: XcodeCleanCandidate) -> some View {
Button(action: { viewModel.toggle(id: candidate.id) }) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: viewModel.selectedIDs.contains(candidate.id) ? "checkmark.square.fill" : "square")
.font(.system(size: 14))
.foregroundStyle(viewModel.selectedIDs.contains(candidate.id) ? Color.accentColor : Color.secondary)
.frame(width: 16, height: 16)
VStack(alignment: .leading, spacing: 2) {
Text((candidate.path as NSString).lastPathComponent)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.middle)
Text(candidate.path)
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.head)
}
Spacer(minLength: 8)
Text(byteText(candidate.sizeBytes))
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
.frame(minWidth: 64, alignment: .trailing)
}
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var footer: some View {
VStack(spacing: 6) {
Button(action: {
onConfirm(viewModel.selectedIDs)
}) {
Text(confirmTitle)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(
LinearGradient(
colors: [Color(nsColor: .systemBlue).opacity(0.85), Color(nsColor: .systemBlue)],
startPoint: .top,
endPoint: .bottom
)
)
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.white.opacity(0.18), lineWidth: 1)
)
.opacity(viewModel.selectedCount == 0 ? 0.4 : 1)
}
.buttonStyle(.plain)
.disabled(viewModel.selectedCount == 0)
Button(action: onCancel) {
Text(localization.string("confirm.action.cancel", defaultValue: "取消"))
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
}
private var confirmTitle: String {
if viewModel.selectedCount == 0 {
return localization.string("confirm.action.empty", defaultValue: "请选择要清理的项目")
}
return localization.format(
"confirm.action.confirm",
defaultValue: "清理 %d 项 · %@",
viewModel.selectedCount,
byteText(viewModel.selectedSizeBytes)
)
}
private func byteText(_ bytes: Int64) -> String {
XcodeCleanByteFormatter.string(fromByteCount: bytes)
}
private func itemCountText(_ count: Int) -> String {
localization.format("confirm.itemCount", defaultValue: "%d 项", count)
}
}
@@ -0,0 +1,499 @@
import Combine
import Foundation
import MacToolsPluginKit
enum XcodeCleanPhase: Equatable, Sendable {
case idle
case scanning
case scanned
case cleaning
case completed
}
struct XcodeCleanSnapshot: Equatable, Sendable {
let phase: XcodeCleanPhase
let selectedCategories: Set<XcodeCleanCategory>
let scanResult: XcodeCleanScanResult?
let executionResult: XcodeCleanExecutionResult?
let isResultStale: Bool
let isXcodeRunning: Bool
let errorMessage: String?
let scanLogEntries: [XcodeCleanScanLogEntry]
init(
phase: XcodeCleanPhase,
selectedCategories: Set<XcodeCleanCategory>,
scanResult: XcodeCleanScanResult?,
executionResult: XcodeCleanExecutionResult?,
isResultStale: Bool,
isXcodeRunning: Bool,
errorMessage: String?,
scanLogEntries: [XcodeCleanScanLogEntry] = []
) {
self.phase = phase
self.selectedCategories = selectedCategories
self.scanResult = scanResult
self.executionResult = executionResult
self.isResultStale = isResultStale
self.isXcodeRunning = isXcodeRunning
self.errorMessage = errorMessage
self.scanLogEntries = scanLogEntries
}
var isBusy: Bool {
phase == .scanning || phase == .cleaning
}
var canScan: Bool {
!isXcodeRunning && !isBusy && !selectedCategories.isEmpty
}
var canClean: Bool {
!isXcodeRunning
&& phase == .scanned
&& !isResultStale
&& scanResult?.cleanableCandidates.isEmpty == false
}
static let initial = XcodeCleanSnapshot(
phase: .idle,
selectedCategories: Set(XcodeCleanCategory.allCases),
scanResult: nil,
executionResult: nil,
isResultStale: false,
isXcodeRunning: false,
errorMessage: nil
)
}
@MainActor
protocol XcodeCleanControlling: AnyObject {
var onStateChange: (() -> Void)? { get set }
var snapshot: XcodeCleanSnapshot { get }
func setCategory(_ category: XcodeCleanCategory, isSelected: Bool)
func scan()
func cleanSelected(candidateIDs: Set<XcodeCleanCandidate.ID>)
func cancelCurrentOperation()
func updateXcodeRunningState(_ isRunning: Bool)
}
@MainActor
final class XcodeCleanController: ObservableObject, XcodeCleanControlling {
private static let scanLogFlushIntervalNanoseconds: UInt64 = 100_000_000
private static let maxLogEntries = 500
var onStateChange: (() -> Void)?
@Published private(set) var snapshot: XcodeCleanSnapshot {
didSet { onStateChange?() }
}
private let scanner: XcodeCleanScanning
private let executor: XcodeCleanExecuting
private let localization: PluginLocalization
private var currentTask: Task<Void, Never>?
private var currentOperationID: UUID?
private var scanLogFlushTask: Task<Void, Never>?
private var nextLogEntryID = 1
init(
scanner: XcodeCleanScanning = XcodeCleanScanner(),
executor: XcodeCleanExecuting = XcodeCleanExecutor(),
initialSnapshot: XcodeCleanSnapshot = .initial,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) {
self.scanner = scanner
self.executor = executor
self.localization = localization
snapshot = initialSnapshot
}
deinit {
currentTask?.cancel()
scanLogFlushTask?.cancel()
}
func setCategory(_ category: XcodeCleanCategory, isSelected: Bool) {
var next = snapshot.selectedCategories
if isSelected {
next.insert(category)
} else {
next.remove(category)
}
snapshot = XcodeCleanSnapshot(
phase: snapshot.phase,
selectedCategories: next,
scanResult: snapshot.scanResult,
executionResult: snapshot.executionResult,
isResultStale: isStale(scanResult: snapshot.scanResult, selectedCategories: next),
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: snapshot.errorMessage,
scanLogEntries: snapshot.scanLogEntries
)
}
func updateXcodeRunningState(_ isRunning: Bool) {
guard snapshot.isXcodeRunning != isRunning else { return }
if isRunning {
cancelTaskOnly()
snapshot = XcodeCleanSnapshot(
phase: .idle,
selectedCategories: snapshot.selectedCategories,
scanResult: nil,
executionResult: nil,
isResultStale: false,
isXcodeRunning: true,
errorMessage: nil,
scanLogEntries: appendLogs(
[
XcodeCleanScanLogMessage(
text: localization.string(
"scanLog.xcodeStarted",
defaultValue: "Xcode 已启动,操作已中断"
),
tone: .warning
)
],
to: snapshot.scanLogEntries
)
)
} else {
snapshot = XcodeCleanSnapshot(
phase: snapshot.phase,
selectedCategories: snapshot.selectedCategories,
scanResult: snapshot.scanResult,
executionResult: snapshot.executionResult,
isResultStale: snapshot.isResultStale,
isXcodeRunning: false,
errorMessage: snapshot.errorMessage,
scanLogEntries: snapshot.scanLogEntries
)
}
}
func scan() {
guard snapshot.canScan else { return }
cancelTaskOnly()
let categories = snapshot.selectedCategories
let operationID = UUID()
currentOperationID = operationID
nextLogEntryID = 1
let buffer = XcodeCleanScanLogBuffer()
let initialLogs = appendLogs(
[XcodeCleanScanLogMessage(
text: localization.format(
"scanLog.started",
defaultValue: "开始扫描:%@",
selectedCategoryTitles(categories)
),
tone: .info
)],
to: []
)
snapshot = XcodeCleanSnapshot(
phase: .scanning,
selectedCategories: categories,
scanResult: nil,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: initialLogs
)
startScanLogFlushLoop(operationID: operationID, buffer: buffer)
currentTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
let result = try await scanner.scan(categories: categories) { message in
await buffer.append(message)
}
guard isCurrentOperation(operationID) else { return }
await flushLogs(operationID: operationID, buffer: buffer)
snapshot = XcodeCleanSnapshot(
phase: .scanned,
selectedCategories: categories,
scanResult: result,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: snapshot.scanLogEntries
)
finishOperation(operationID)
} catch is CancellationError {
guard isCurrentOperation(operationID) else { return }
await flushLogs(operationID: operationID, buffer: buffer)
snapshot = XcodeCleanSnapshot(
phase: .idle,
selectedCategories: categories,
scanResult: nil,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: snapshot.scanLogEntries
)
finishOperation(operationID)
} catch {
guard isCurrentOperation(operationID) else { return }
await flushLogs(operationID: operationID, buffer: buffer)
let message = Self.userFacingMessage(for: error)
snapshot = XcodeCleanSnapshot(
phase: .idle,
selectedCategories: categories,
scanResult: nil,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: message,
scanLogEntries: appendLogs(
[
XcodeCleanScanLogMessage(
text: localization.format(
"scanLog.failed",
defaultValue: "扫描失败:%@",
message
),
tone: .error
)
],
to: snapshot.scanLogEntries
)
)
finishOperation(operationID)
}
}
}
func cleanSelected(candidateIDs: Set<XcodeCleanCandidate.ID>) {
guard snapshot.canClean, let scanResult = snapshot.scanResult else { return }
cancelTaskOnly()
let categories = snapshot.selectedCategories
let operationID = UUID()
currentOperationID = operationID
snapshot = XcodeCleanSnapshot(
phase: .cleaning,
selectedCategories: categories,
scanResult: scanResult,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: snapshot.scanLogEntries
)
currentTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
let executionResult = try await executor.clean(
candidates: scanResult.candidates,
selectedCandidateIDs: candidateIDs
)
guard isCurrentOperation(operationID) else { return }
snapshot = XcodeCleanSnapshot(
phase: .completed,
selectedCategories: categories,
scanResult: scanResult,
executionResult: executionResult,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: snapshot.scanLogEntries
)
finishOperation(operationID)
} catch is CancellationError {
guard isCurrentOperation(operationID) else { return }
snapshot = XcodeCleanSnapshot(
phase: .scanned,
selectedCategories: categories,
scanResult: scanResult,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: snapshot.scanLogEntries
)
finishOperation(operationID)
} catch {
guard isCurrentOperation(operationID) else { return }
snapshot = XcodeCleanSnapshot(
phase: .scanned,
selectedCategories: categories,
scanResult: scanResult,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: Self.userFacingMessage(for: error),
scanLogEntries: snapshot.scanLogEntries
)
finishOperation(operationID)
}
}
}
func cancelCurrentOperation() {
let phase = snapshot.phase
let categories = snapshot.selectedCategories
let scanResult = snapshot.scanResult
let isResultStale = snapshot.isResultStale
let logs = snapshot.scanLogEntries
cancelTaskOnly()
switch phase {
case .scanning:
snapshot = XcodeCleanSnapshot(
phase: .idle,
selectedCategories: categories,
scanResult: nil,
executionResult: nil,
isResultStale: false,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: appendLogs(
[
XcodeCleanScanLogMessage(
text: localization.string("scanLog.stopped", defaultValue: "扫描已停止"),
tone: .warning
)
],
to: logs
)
)
case .cleaning:
snapshot = XcodeCleanSnapshot(
phase: .scanned,
selectedCategories: categories,
scanResult: scanResult,
executionResult: nil,
isResultStale: isResultStale,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: nil,
scanLogEntries: logs
)
case .idle, .scanned, .completed:
break
}
}
// MARK: - Private
private func cancelTaskOnly() {
currentTask?.cancel()
currentTask = nil
currentOperationID = nil
scanLogFlushTask?.cancel()
scanLogFlushTask = nil
}
private func finishOperation(_ operationID: UUID) {
guard isCurrentOperation(operationID) else { return }
currentTask = nil
currentOperationID = nil
scanLogFlushTask?.cancel()
scanLogFlushTask = nil
}
private func startScanLogFlushLoop(operationID: UUID, buffer: XcodeCleanScanLogBuffer) {
scanLogFlushTask?.cancel()
scanLogFlushTask = Task { [weak self] in
while !Task.isCancelled {
do {
try await Task.sleep(nanoseconds: Self.scanLogFlushIntervalNanoseconds)
} catch {
break
}
await self?.flushLogs(operationID: operationID, buffer: buffer)
}
}
}
private func flushLogs(operationID: UUID, buffer: XcodeCleanScanLogBuffer) async {
let messages = await buffer.drain()
guard isCurrentOperation(operationID), !messages.isEmpty else { return }
snapshot = XcodeCleanSnapshot(
phase: snapshot.phase,
selectedCategories: snapshot.selectedCategories,
scanResult: snapshot.scanResult,
executionResult: snapshot.executionResult,
isResultStale: snapshot.isResultStale,
isXcodeRunning: snapshot.isXcodeRunning,
errorMessage: snapshot.errorMessage,
scanLogEntries: appendLogs(messages, to: snapshot.scanLogEntries)
)
}
private func appendLogs(
_ messages: [XcodeCleanScanLogMessage],
to existing: [XcodeCleanScanLogEntry]
) -> [XcodeCleanScanLogEntry] {
guard !messages.isEmpty else { return existing }
var entries = existing
entries.reserveCapacity(min(existing.count + messages.count, Self.maxLogEntries))
for message in messages {
let entry = XcodeCleanScanLogEntry(
id: nextLogEntryID,
text: message.text,
tone: message.tone
)
nextLogEntryID += 1
entries.append(entry)
}
if entries.count > Self.maxLogEntries {
entries.removeFirst(entries.count - Self.maxLogEntries)
}
return entries
}
private func isCurrentOperation(_ operationID: UUID) -> Bool {
currentOperationID == operationID
}
private func isStale(
scanResult: XcodeCleanScanResult?,
selectedCategories: Set<XcodeCleanCategory>
) -> Bool {
guard let scanResult else { return false }
return scanResult.categories != selectedCategories
}
private func selectedCategoryTitles(_ categories: Set<XcodeCleanCategory>) -> String {
XcodeCleanCategory.allCases
.filter { categories.contains($0) }
.map { $0.title(localization: localization) }
.joined(separator: "")
}
private static func userFacingMessage(for error: Error) -> String {
if let localized = error as? LocalizedError, let description = localized.errorDescription {
return description
}
return error.localizedDescription
}
}
private actor XcodeCleanScanLogBuffer {
private var messages: [XcodeCleanScanLogMessage] = []
func append(_ message: XcodeCleanScanLogMessage) {
messages.append(message)
}
func drain() -> [XcodeCleanScanLogMessage] {
defer { messages.removeAll(keepingCapacity: true) }
return messages
}
}
@@ -0,0 +1,398 @@
import SwiftUI
import MacToolsPluginKit
struct XcodeCleanDetailView: View {
@ObservedObject var controller: XcodeCleanController
private let localization: PluginLocalization
private let showsHeader: Bool
private let contentPadding: CGFloat
private let minimumContentHeight: CGFloat
init(
controller: XcodeCleanController,
localization: PluginLocalization = PluginLocalization(bundle: .main),
showsHeader: Bool = true,
contentPadding: CGFloat = 20,
minimumContentHeight: CGFloat = 420
) {
self.controller = controller
self.localization = localization
self.showsHeader = showsHeader
self.contentPadding = contentPadding
self.minimumContentHeight = minimumContentHeight
}
var body: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) {
if showsHeader {
header
}
if snapshot.isXcodeRunning {
xcodeRunningBanner
}
categoryControls
actionBar
scanLog
statusSummary
candidateList
}
.padding(contentPadding)
.frame(maxWidth: .infinity, minHeight: minimumContentHeight, alignment: .topLeading)
}
private var snapshot: XcodeCleanSnapshot { controller.snapshot }
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text(localization.string("detail.title", defaultValue: "Xcode 清理"))
.font(PluginSettingsTheme.Typography.pageTitle)
Text(snapshot.errorMessage ?? subtitleText)
.font(PluginSettingsTheme.Typography.pageDescription.weight(.medium))
.foregroundStyle(snapshot.errorMessage == nil ? Color.secondary : Color.red)
}
}
private var subtitleText: String {
if snapshot.isXcodeRunning {
return localization.string("detail.subtitle.xcodeRunning", defaultValue: "请先退出 Xcode,再进行扫描或清理")
}
switch snapshot.phase {
case .idle:
return localization.string("detail.subtitle.idle", defaultValue: "选择需要清理的分类,然后点击扫描")
case .scanning:
return localization.string("detail.subtitle.scanning", defaultValue: "正在扫描…")
case .scanned:
if snapshot.isResultStale {
return localization.string("detail.subtitle.stale", defaultValue: "勾选已更新,请重新扫描")
}
return snapshot.scanResult.map {
localization.format(
"detail.subtitle.scanned",
defaultValue: "已找到 %d 项可清理",
$0.cleanableCandidates.count
)
} ?? localization.string("detail.subtitle.scanComplete", defaultValue: "扫描完成")
case .cleaning:
return localization.string("detail.subtitle.cleaning", defaultValue: "正在清理…")
case .completed:
return snapshot.executionResult.map {
localization.format(
"detail.subtitle.completed",
defaultValue: "已释放 %@",
byteText($0.reclaimedBytes)
)
} ?? localization.string("detail.subtitle.cleanComplete", defaultValue: "清理完成")
}
}
private var xcodeRunningBanner: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.frame(width: 18)
VStack(alignment: .leading, spacing: 2) {
Text(localization.string("detail.xcodeRunning.title", defaultValue: "Xcode 当前正在运行"))
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
Text(localization.string(
"detail.xcodeRunning.description",
defaultValue: "为避免破坏正在进行的构建或索引,请先退出 Xcode 再使用此功能。"
))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal)
.padding(.vertical, PluginSettingsTheme.Spacing.rowVertical)
.pluginSettingsCardBackground(.recessed)
}
private var categoryControls: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
Label(localization.string("detail.categories.title", defaultValue: "清理分类"), systemImage: "square.grid.2x2")
.font(PluginSettingsTheme.Typography.sectionTitle)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
ForEach(XcodeCleanCategory.allCases) { category in
categoryRow(category)
}
}
.padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal)
.padding(.vertical, PluginSettingsTheme.Spacing.rowVertical)
.pluginSettingsCardBackground(.host)
}
}
private func categoryRow(_ category: XcodeCleanCategory) -> some View {
let isSelected = snapshot.selectedCategories.contains(category)
let summary = snapshot.scanResult?.summary(for: category)
let sizeText = summary.map { byteText($0.totalBytes) } ?? ""
return HStack(alignment: .firstTextBaseline, spacing: 10) {
Toggle(isOn: Binding(
get: { isSelected },
set: { controller.setCategory(category, isSelected: $0) }
)) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(category.title(localization: localization))
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
if category.risk == .medium {
Text(localization.string("detail.risk.medium", defaultValue: "谨慎"))
.font(PluginSettingsTheme.Typography.statusBadge)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Color.orange.opacity(0.15), in: Capsule())
.foregroundStyle(.orange)
}
}
Text(category.summary(localization: localization))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
}
}
.toggleStyle(.checkbox)
.disabled(snapshot.isBusy || snapshot.isXcodeRunning)
Spacer(minLength: 12)
Text(sizeText)
.font(PluginSettingsTheme.Typography.monospacedValue)
.foregroundStyle(.secondary)
.frame(minWidth: 72, alignment: .trailing)
}
.padding(.vertical, 4)
}
private var actionBar: some View {
HStack(spacing: 10) {
Button {
controller.scan()
} label: {
Label(localization.string("detail.action.scan", defaultValue: "扫描"), systemImage: "magnifyingglass")
}
.disabled(!snapshot.canScan)
Button {
controller.cleanSelected(candidateIDs: cleanableCandidateIDs)
} label: {
Label(localization.string("detail.action.clean", defaultValue: "清理"), systemImage: "trash")
}
.disabled(!snapshot.canClean)
if snapshot.isBusy {
Button {
controller.cancelCurrentOperation()
} label: {
Label(localization.string("detail.action.stop", defaultValue: "停止"), systemImage: "xmark.circle")
}
}
Spacer()
}
}
private var scanLog: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(localization.string("detail.scanLog.title", defaultValue: "扫描日志"))
.font(PluginSettingsTheme.Typography.sectionTitle)
.foregroundStyle(.secondary)
Spacer()
if snapshot.phase == .scanning {
ProgressView()
.controlSize(.small)
.scaleEffect(0.75)
}
}
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 6) {
if snapshot.scanLogEntries.isEmpty {
Text(localization.string("detail.scanLog.empty", defaultValue: "扫描后这里会显示实时进度"))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
ForEach(snapshot.scanLogEntries) { entry in
logRow(entry).id(entry.id)
}
}
}
.padding(10)
}
.frame(minHeight: 118, maxHeight: 160)
.pluginSettingsCardBackground(.recessed)
.onChange(of: snapshot.scanLogEntries.last?.id) {
guard let id = snapshot.scanLogEntries.last?.id else { return }
proxy.scrollTo(id, anchor: .bottom)
}
}
}
}
private func logRow(_ entry: XcodeCleanScanLogEntry) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: logIconName(entry.tone))
.font(PluginSettingsTheme.Typography.statusBadge)
.foregroundStyle(logColor(entry.tone))
.frame(width: 14, height: 14)
Text(entry.text)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.primary)
.textSelection(.enabled)
.lineLimit(3)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private var statusSummary: some View {
if let scanResult = snapshot.scanResult {
HStack(spacing: 16) {
summaryTile(
title: localization.string("detail.summary.cleanable", defaultValue: "可清理"),
value: itemCountText(scanResult.cleanableCandidates.count)
)
summaryTile(
title: localization.string("detail.summary.estimatedRelease", defaultValue: "预计释放"),
value: byteText(scanResult.cleanableSizeBytes)
)
summaryTile(
title: localization.string("detail.summary.protected", defaultValue: "已保护"),
value: itemCountText(scanResult.protectedCount)
)
}
}
if let executionResult = snapshot.executionResult {
HStack(spacing: 16) {
summaryTile(
title: localization.string("detail.summary.removed", defaultValue: "已删除"),
value: itemCountText(executionResult.removedCount)
)
summaryTile(
title: localization.string("detail.summary.skipped", defaultValue: "已跳过"),
value: itemCountText(executionResult.skippedCount)
)
summaryTile(
title: localization.string("detail.summary.failed", defaultValue: "失败"),
value: itemCountText(executionResult.failedCount)
)
summaryTile(
title: localization.string("detail.summary.released", defaultValue: "已释放"),
value: byteText(executionResult.reclaimedBytes)
)
}
}
}
private func summaryTile(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
Text(value)
.font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold))
}
.frame(minWidth: 96, alignment: .leading)
}
@ViewBuilder
private var candidateList: some View {
if let scanResult = snapshot.scanResult, !scanResult.candidates.isEmpty {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(scanResult.candidates) { candidate in
candidateRow(candidate)
Divider()
}
}
}
}
}
private func candidateRow(_ candidate: XcodeCleanCandidate) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: candidate.safety.isCleanable ? "checkmark.circle.fill" : "shield.fill")
.foregroundStyle(candidate.safety.isCleanable ? Color.green : Color.secondary)
.frame(width: 18)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline) {
Text(candidate.category.title(localization: localization))
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
Spacer(minLength: 12)
Text(byteText(candidate.sizeBytes))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
}
Text(candidate.path)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(2)
Text(safetyText(candidate.safety))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(candidate.safety.isCleanable ? Color.green : Color.secondary)
}
}
.padding(.vertical, 9)
}
// MARK: - Helpers
private var cleanableCandidateIDs: Set<XcodeCleanCandidate.ID> {
Set(snapshot.scanResult?.cleanableCandidates.map(\.id) ?? [])
}
private func safetyText(_ safety: XcodeCleanSafetyStatus) -> String {
switch safety {
case .allowed:
return localization.string("detail.safety.allowed", defaultValue: "允许清理")
case .outsideAllowedRoot:
return localization.string("detail.safety.outsideAllowedRoot", defaultValue: "路径越界保护")
case .xcodeRunning:
return localization.string("detail.safety.xcodeRunning", defaultValue: "Xcode 运行中")
case .missing:
return localization.string("detail.safety.missing", defaultValue: "路径已不存在")
}
}
private func itemCountText(_ count: Int) -> String {
localization.format("detail.itemCount", defaultValue: "%d 项", count)
}
private func byteText(_ bytes: Int64) -> String {
XcodeCleanByteFormatter.string(fromByteCount: bytes)
}
private func logIconName(_ tone: XcodeCleanScanLogTone) -> String {
switch tone {
case .info: return "circle"
case .success: return "checkmark.circle.fill"
case .warning: return "shield.fill"
case .error: return "xmark.octagon.fill"
}
}
private func logColor(_ tone: XcodeCleanScanLogTone) -> Color {
switch tone {
case .info: return .secondary
case .success: return .green
case .warning: return .orange
case .error: return .red
}
}
}
@@ -0,0 +1,91 @@
import Foundation
protocol XcodeCleanExecuting: Sendable {
func clean(
candidates: [XcodeCleanCandidate],
selectedCandidateIDs: Set<XcodeCleanCandidate.ID>
) async throws -> XcodeCleanExecutionResult
}
struct XcodeCleanExecutor: XcodeCleanExecuting {
let fileSystem: XcodeCleanFileSystemProviding
let allowedRoots: [String]
init(
fileSystem: XcodeCleanFileSystemProviding = LocalXcodeCleanFileSystem(),
allowedRoots: [String]? = nil
) {
self.fileSystem = fileSystem
self.allowedRoots = (allowedRoots ?? Self.defaultAllowedRoots()).map { Self.ensureTrailingSlash($0) }
}
func clean(
candidates: [XcodeCleanCandidate],
selectedCandidateIDs: Set<XcodeCleanCandidate.ID>
) async throws -> XcodeCleanExecutionResult {
var itemResults: [XcodeCleanExecutionItemResult] = []
for candidate in candidates where selectedCandidateIDs.contains(candidate.id) {
try Task.checkCancellation()
itemResults.append(clean(candidate))
}
return XcodeCleanExecutionResult(itemResults: itemResults)
}
private func clean(_ candidate: XcodeCleanCandidate) -> XcodeCleanExecutionItemResult {
guard candidate.safety.isCleanable else {
return XcodeCleanExecutionItemResult(
candidateID: candidate.id,
path: candidate.path,
outcome: .skipped(candidate.safety)
)
}
if !isPathAllowed(candidate.path) {
return XcodeCleanExecutionItemResult(
candidateID: candidate.id,
path: candidate.path,
outcome: .skipped(.outsideAllowedRoot)
)
}
guard fileSystem.itemExists(at: candidate.path) else {
return XcodeCleanExecutionItemResult(
candidateID: candidate.id,
path: candidate.path,
outcome: .skipped(.missing)
)
}
do {
try fileSystem.removeItem(at: candidate.path)
return XcodeCleanExecutionItemResult(
candidateID: candidate.id,
path: candidate.path,
outcome: .removed(reclaimedBytes: candidate.sizeBytes)
)
} catch {
return XcodeCleanExecutionItemResult(
candidateID: candidate.id,
path: candidate.path,
outcome: .failed(message: error.localizedDescription)
)
}
}
private func isPathAllowed(_ path: String) -> Bool {
allowedRoots.contains { path.hasPrefix($0) }
}
private static func defaultAllowedRoots() -> [String] {
let home = NSHomeDirectory()
return XcodeCleanRuleCatalog.allowedRootPrefixes.map { prefix in
prefix.hasPrefix("~/") ? home + String(prefix.dropFirst()) : prefix
}
}
private static func ensureTrailingSlash(_ path: String) -> String {
path.hasSuffix("/") ? path : path + "/"
}
}
@@ -0,0 +1,198 @@
import Foundation
import MacToolsPluginKit
enum XcodeCleanCategory: String, CaseIterable, Identifiable, Equatable, Sendable {
case derivedData
case deviceSupport
case archives
case simulatorCaches
case previews
case xcodeAppCaches
var id: String { rawValue }
var title: String {
title()
}
func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
switch self {
case .derivedData:
return localization.string("category.derivedData.title", defaultValue: "DerivedData")
case .deviceSupport:
return localization.string("category.deviceSupport.title", defaultValue: "设备支持文件")
case .archives:
return localization.string("category.archives.title", defaultValue: "Archives")
case .simulatorCaches:
return localization.string("category.simulatorCaches.title", defaultValue: "模拟器缓存")
case .previews:
return localization.string("category.previews.title", defaultValue: "预览缓存")
case .xcodeAppCaches:
return localization.string("category.xcodeAppCaches.title", defaultValue: "Xcode 应用缓存")
}
}
var summary: String {
summary()
}
func summary(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
switch self {
case .derivedData:
return localization.string("category.derivedData.summary", defaultValue: "构建中间产物、索引与日志")
case .deviceSupport:
return localization.string("category.deviceSupport.summary", defaultValue: "调试旧设备时下载的符号文件")
case .archives:
return localization.string("category.archives.summary", defaultValue: "包含已发布版本的归档与 dSYM,谨慎清理")
case .simulatorCaches:
return localization.string("category.simulatorCaches.summary", defaultValue: "模拟器运行时缓存,不影响已创建的模拟器设备")
case .previews:
return localization.string("category.previews.summary", defaultValue: "SwiftUI 预览的中间渲染缓存")
case .xcodeAppCaches:
return localization.string("category.xcodeAppCaches.summary", defaultValue: "Xcode 自身的会话与界面状态缓存")
}
}
var risk: XcodeCleanRisk {
switch self {
case .archives:
return .medium
default:
return .low
}
}
}
enum XcodeCleanRisk: Equatable, Sendable {
case low
case medium
}
enum XcodeCleanSafetyStatus: Equatable, Sendable {
case allowed
case outsideAllowedRoot
case xcodeRunning
case missing
var isCleanable: Bool {
if case .allowed = self { return true }
return false
}
}
enum XcodeCleanScanLogTone: Equatable, Sendable {
case info
case success
case warning
case error
}
struct XcodeCleanScanLogMessage: Equatable, Sendable {
let text: String
let tone: XcodeCleanScanLogTone
}
struct XcodeCleanScanLogEntry: Identifiable, Equatable, Sendable {
let id: Int
let text: String
let tone: XcodeCleanScanLogTone
}
struct XcodeCleanCandidate: Identifiable, Equatable, Sendable {
let id: String
let category: XcodeCleanCategory
let path: String
let sizeBytes: Int64
let safety: XcodeCleanSafetyStatus
}
struct XcodeCleanCategorySummary: Equatable, Sendable {
let category: XcodeCleanCategory
let candidateCount: Int
let totalBytes: Int64
}
struct XcodeCleanScanResult: Equatable, Sendable {
let categories: Set<XcodeCleanCategory>
let candidates: [XcodeCleanCandidate]
let scannedAt: Date
var cleanableCandidates: [XcodeCleanCandidate] {
candidates.filter { $0.safety.isCleanable }
}
var cleanableSizeBytes: Int64 {
cleanableCandidates.reduce(0) { $0 + max($1.sizeBytes, 0) }
}
var protectedCount: Int {
candidates.count - cleanableCandidates.count
}
func summary(for category: XcodeCleanCategory) -> XcodeCleanCategorySummary {
let scoped = candidates.filter { $0.category == category }
let totalBytes = scoped.reduce(Int64(0)) { $0 + max($1.sizeBytes, 0) }
return XcodeCleanCategorySummary(
category: category,
candidateCount: scoped.count,
totalBytes: totalBytes
)
}
}
struct XcodeCleanExecutionItemResult: Equatable, Sendable {
enum Outcome: Equatable, Sendable {
case removed(reclaimedBytes: Int64)
case skipped(XcodeCleanSafetyStatus)
case failed(message: String)
}
let candidateID: XcodeCleanCandidate.ID
let path: String
let outcome: Outcome
var reclaimedBytes: Int64 {
if case let .removed(reclaimedBytes) = outcome {
return max(reclaimedBytes, 0)
}
return 0
}
}
struct XcodeCleanExecutionResult: Equatable, Sendable {
let itemResults: [XcodeCleanExecutionItemResult]
var removedCount: Int {
itemResults.filter {
if case .removed = $0.outcome { return true }
return false
}.count
}
var skippedCount: Int {
itemResults.filter {
if case .skipped = $0.outcome { return true }
return false
}.count
}
var failedCount: Int {
itemResults.filter {
if case .failed = $0.outcome { return true }
return false
}.count
}
var reclaimedBytes: Int64 {
itemResults.reduce(0) { $0 + $1.reclaimedBytes }
}
}
enum XcodeCleanByteFormatter {
static func string(fromByteCount bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
formatter.allowsNonnumericFormatting = false
return formatter.string(fromByteCount: max(bytes, 0))
}
}
@@ -0,0 +1,371 @@
import AppKit
import Foundation
import SwiftUI
import MacToolsPluginKit
public final class XcodeCleanPluginFactory: NSObject, MacToolsPluginBundleFactory {
public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider {
XcodeCleanPluginProvider(context: context)
}
}
@MainActor
private struct XcodeCleanPluginProvider: PluginProvider {
let context: PluginRuntimeContext
func makePlugins() -> [any MacToolsPlugin] {
let localization = PluginLocalization(bundle: context.resourceBundle)
let monitor = XcodeCleanRunningMonitor()
let scanner = XcodeCleanScanner(localization: localization)
let executor = XcodeCleanExecutor()
let controller = XcodeCleanController(
scanner: scanner,
executor: executor,
localization: localization
)
return [XcodeCleanPlugin(
controller: controller,
runningMonitor: monitor,
localization: localization
)]
}
}
@MainActor
protocol XcodeCleanConfirmationPresenting: AnyObject {
var isPresenting: Bool { get }
func present(
candidates: [XcodeCleanCandidate],
anchorRect: NSRect?,
onConfirm: @escaping (Set<XcodeCleanCandidate.ID>) -> Void,
onCancel: @escaping () -> Void
)
func dismiss()
}
@MainActor
final class XcodeCleanConfirmWindowPresenter: XcodeCleanConfirmationPresenting {
private var window: XcodeCleanConfirmWindow?
private let localization: PluginLocalization
init(localization: PluginLocalization = PluginLocalization(bundle: .main)) {
self.localization = localization
}
var isPresenting: Bool { window?.isVisible == true }
func present(
candidates: [XcodeCleanCandidate],
anchorRect: NSRect?,
onConfirm: @escaping (Set<XcodeCleanCandidate.ID>) -> Void,
onCancel: @escaping () -> Void
) {
dismiss()
let window = XcodeCleanConfirmWindow(
candidates: candidates,
localization: localization,
onConfirm: onConfirm,
onCancel: onCancel
)
window.attachDismissHandler { [weak self] in
self?.window = nil
}
position(window: window, anchorRect: anchorRect)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
self.window = window
}
func dismiss() {
window?.orderOut(nil)
window = nil
}
private func position(window: NSWindow, anchorRect: NSRect?) {
let windowSize = window.frame.size
if let anchorRect {
let screenMaxX = NSScreen.main?.frame.maxX ?? 1440
let rawX = anchorRect.midX - windowSize.width / 2
let x = max(8, min(rawX, screenMaxX - windowSize.width - 8))
let y = anchorRect.minY - windowSize.height - 4
window.setFrameOrigin(NSPoint(x: x, y: y))
return
}
guard let screen = NSScreen.main else { return }
let menuBarThickness = NSStatusBar.system.thickness
let x = screen.frame.midX - windowSize.width / 2
let y = screen.frame.maxY - menuBarThickness - windowSize.height - 12
window.setFrameOrigin(NSPoint(x: x, y: y))
}
}
@MainActor
final class XcodeCleanPlugin: MacToolsPlugin, PluginPrimaryPanel, DropZoneAnchorProviding {
enum ControlID {
static let scan = "xcode-clean-scan"
static let clean = "xcode-clean-clean"
static let stop = "xcode-clean-stop"
}
let metadata: PluginMetadata
let primaryPanelDescriptor = PluginPrimaryPanelDescriptor(
controlStyle: .disclosure,
menuActionBehavior: .keepPresented
)
var anchorRectProvider: (() -> NSRect?)?
var onStateChange: (() -> Void)?
var requestPermissionGuidance: ((String) -> Void)?
var shortcutBindingResolver: ((String) -> ShortcutBinding?)?
let controller: XcodeCleanControlling
private let runningMonitor: XcodeCleanRunningMonitoring
private let confirmationPresenter: XcodeCleanConfirmationPresenting
private let localization: PluginLocalization
private var isExpanded = false
init(
controller: XcodeCleanControlling,
runningMonitor: XcodeCleanRunningMonitoring,
confirmationPresenter: XcodeCleanConfirmationPresenting? = nil,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) {
self.controller = controller
self.runningMonitor = runningMonitor
self.localization = localization
self.confirmationPresenter = confirmationPresenter ?? XcodeCleanConfirmWindowPresenter(localization: localization)
self.metadata = PluginMetadata(
id: "xcode-clean",
title: localization.string("metadata.title", defaultValue: "Xcode 清理"),
iconName: "hammer",
iconTint: Color(nsColor: .systemBlue),
order: 91,
defaultDescription: localization.string(
"metadata.description",
defaultValue: "分类清理 Xcode DerivedData、设备支持、归档与缓存"
)
)
self.controller.onStateChange = { [weak self] in
self?.onStateChange?()
}
self.runningMonitor.onStateChange = { [weak self] in
guard let self else { return }
self.controller.updateXcodeRunningState(self.runningMonitor.isXcodeRunning)
}
}
func activate(context: PluginRuntimeContext) {
runningMonitor.start()
controller.updateXcodeRunningState(runningMonitor.isXcodeRunning)
}
func deactivate(reason: PluginDeactivationReason) {
confirmationPresenter.dismiss()
controller.cancelCurrentOperation()
runningMonitor.stop()
}
func refresh() {
runningMonitor.refresh()
controller.updateXcodeRunningState(runningMonitor.isXcodeRunning)
}
var primaryPanelState: PluginPanelState {
let snapshot = controller.snapshot
return PluginPanelState(
subtitle: subtitle(for: snapshot),
isOn: snapshot.isBusy,
isExpanded: isExpanded,
isEnabled: true,
isVisible: true,
detail: isExpanded ? buildDetail(for: snapshot) : nil,
errorMessage: snapshot.errorMessage
)
}
var permissionRequirements: [PluginPermissionRequirement] { [] }
var settingsSections: [PluginSettingsSection] { [] }
var shortcutDefinitions: [PluginShortcutDefinition] { [] }
var configuration: PluginConfiguration? {
guard let controller = controller as? XcodeCleanController else { return nil }
let localization = localization
return PluginConfiguration(description: metadata.defaultDescription) { _ in
XcodeCleanDetailView(
controller: controller,
localization: localization,
showsHeader: false,
contentPadding: 0,
minimumContentHeight: 0
)
}
}
func handleAction(_ action: PluginPanelAction) {
switch action {
case let .setDisclosureExpanded(value):
isExpanded = value
onStateChange?()
case let .invokeAction(controlID):
handleInvoke(controlID: controlID)
case .setSwitch,
.setSelection,
.setNavigationSelection,
.clearNavigationSelection,
.setDate,
.setSlider:
break
}
}
func permissionState(for permissionID: String) -> PluginPermissionState {
PluginPermissionState(isGranted: true, footnote: nil)
}
func handlePermissionAction(id: String) {}
func handleSettingsAction(id: String) {}
func handleShortcutAction(id: String) {}
// MARK: - Private
private func handleInvoke(controlID: String) {
switch controlID {
case ControlID.scan:
controller.scan()
case ControlID.clean:
presentConfirmation()
case ControlID.stop:
controller.cancelCurrentOperation()
default:
break
}
}
private func presentConfirmation() {
guard let scanResult = controller.snapshot.scanResult else { return }
let candidates = scanResult.cleanableCandidates
guard !candidates.isEmpty else { return }
confirmationPresenter.present(
candidates: candidates,
anchorRect: anchorRectProvider?(),
onConfirm: { [weak self] selectedIDs in
self?.controller.cleanSelected(candidateIDs: selectedIDs)
},
onCancel: {}
)
}
private func buildDetail(for snapshot: XcodeCleanSnapshot) -> PluginPanelDetail {
var controls: [PluginPanelControl] = []
controls.append(
PluginPanelControl(
id: ControlID.scan,
kind: .actionRow,
options: [],
selectedOptionID: nil,
dateValue: nil,
minimumDate: nil,
displayedComponents: nil,
datePickerStyle: nil,
sectionTitle: nil,
actionTitle: snapshot.phase == .scanning
? localization.string("panel.action.scanning", defaultValue: "扫描中…")
: localization.string("panel.action.scan", defaultValue: "扫描"),
actionIconSystemName: "magnifyingglass",
isEnabled: snapshot.canScan
)
)
controls.append(
PluginPanelControl(
id: ControlID.clean,
kind: .actionRow,
options: [],
selectedOptionID: nil,
dateValue: nil,
minimumDate: nil,
displayedComponents: nil,
datePickerStyle: nil,
sectionTitle: nil,
actionTitle: snapshot.phase == .cleaning
? localization.string("panel.action.cleaning", defaultValue: "清理中…")
: localization.string("panel.action.clean", defaultValue: "清理"),
actionIconSystemName: "trash",
actionBehavior: .dismissBeforeHandling,
showsLeadingDivider: true,
isEnabled: snapshot.canClean
)
)
if snapshot.isBusy {
controls.append(
PluginPanelControl(
id: ControlID.stop,
kind: .actionRow,
options: [],
selectedOptionID: nil,
dateValue: nil,
minimumDate: nil,
displayedComponents: nil,
datePickerStyle: nil,
sectionTitle: nil,
actionTitle: localization.string("panel.action.stop", defaultValue: "停止"),
actionIconSystemName: "xmark.circle",
showsLeadingDivider: true,
isEnabled: true
)
)
}
return PluginPanelDetail(primaryControls: controls, secondaryPanel: nil)
}
private func subtitle(for snapshot: XcodeCleanSnapshot) -> String {
if snapshot.isXcodeRunning {
return localization.string("panel.subtitle.xcodeRunning", defaultValue: "请先退出 Xcode")
}
switch snapshot.phase {
case .idle:
return localization.string("panel.subtitle.idle", defaultValue: "等待扫描")
case .scanning:
return localization.string("panel.subtitle.scanning", defaultValue: "正在扫描…")
case .scanned:
if snapshot.isResultStale {
return localization.string("panel.subtitle.stale", defaultValue: "勾选已更新,请重新扫描")
}
if let result = snapshot.scanResult {
return localization.format(
"panel.subtitle.scanned",
defaultValue: "%d 项,%@",
result.cleanableCandidates.count,
byteText(result.cleanableSizeBytes)
)
}
return localization.string("panel.subtitle.scanComplete", defaultValue: "扫描完成")
case .cleaning:
return localization.string("panel.subtitle.cleaning", defaultValue: "正在清理…")
case .completed:
if let result = snapshot.executionResult {
return localization.format(
"panel.subtitle.completed",
defaultValue: "已释放 %@",
byteText(result.reclaimedBytes)
)
}
return localization.string("panel.subtitle.cleanComplete", defaultValue: "清理完成")
}
}
private func byteText(_ bytes: Int64) -> String {
XcodeCleanByteFormatter.string(fromByteCount: bytes)
}
}
@@ -0,0 +1,66 @@
import Foundation
struct XcodeCleanRule: Identifiable, Equatable, Sendable {
let id: String
let category: XcodeCleanCategory
let pathPatterns: [String]
init(id: String, category: XcodeCleanCategory, pathPatterns: [String]) {
self.id = id
self.category = category
self.pathPatterns = pathPatterns
}
}
struct XcodeCleanRuleCatalog: Equatable, Sendable {
let rules: [XcodeCleanRule]
static let allowedRootPrefixes: [String] = [
"~/Library/Developer/Xcode/",
"~/Library/Developer/CoreSimulator/Caches/",
"~/Library/Caches/com.apple.dt.Xcode/"
]
static let defaultCatalog = XcodeCleanRuleCatalog(rules: [
XcodeCleanRule(
id: "xcode-clean.derived-data",
category: .derivedData,
pathPatterns: ["~/Library/Developer/Xcode/DerivedData/*"]
),
XcodeCleanRule(
id: "xcode-clean.device-support",
category: .deviceSupport,
pathPatterns: [
"~/Library/Developer/Xcode/iOS DeviceSupport/*",
"~/Library/Developer/Xcode/tvOS DeviceSupport/*",
"~/Library/Developer/Xcode/watchOS DeviceSupport/*",
"~/Library/Developer/Xcode/visionOS DeviceSupport/*",
"~/Library/Developer/Xcode/macOS DeviceSupport/*"
]
),
XcodeCleanRule(
id: "xcode-clean.archives",
category: .archives,
pathPatterns: ["~/Library/Developer/Xcode/Archives/*"]
),
XcodeCleanRule(
id: "xcode-clean.simulator-caches",
category: .simulatorCaches,
pathPatterns: ["~/Library/Developer/CoreSimulator/Caches/*"]
),
XcodeCleanRule(
id: "xcode-clean.previews",
category: .previews,
pathPatterns: ["~/Library/Developer/Xcode/UserData/Previews/*"]
),
XcodeCleanRule(
id: "xcode-clean.xcode-app-caches",
category: .xcodeAppCaches,
pathPatterns: ["~/Library/Caches/com.apple.dt.Xcode/*"]
)
])
func rules(for category: XcodeCleanCategory) -> [XcodeCleanRule] {
rules.filter { $0.category == category }
}
}
@@ -0,0 +1,73 @@
import AppKit
import Foundation
@MainActor
protocol XcodeCleanRunningMonitoring: AnyObject {
var isXcodeRunning: Bool { get }
var onStateChange: (() -> Void)? { get set }
func start()
func stop()
func refresh()
}
@MainActor
final class XcodeCleanRunningMonitor: XcodeCleanRunningMonitoring {
nonisolated static let xcodeBundleIdentifier = "com.apple.dt.Xcode"
private(set) var isXcodeRunning: Bool = false
var onStateChange: (() -> Void)?
private var observers: [NSObjectProtocol] = []
private let workspace: NSWorkspace
init(workspace: NSWorkspace = .shared) {
self.workspace = workspace
}
func start() {
guard observers.isEmpty else { return }
let center = workspace.notificationCenter
let launched = center.addObserver(
forName: NSWorkspace.didLaunchApplicationNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
app.bundleIdentifier == Self.xcodeBundleIdentifier
else { return }
Task { @MainActor [weak self] in self?.refresh() }
}
let terminated = center.addObserver(
forName: NSWorkspace.didTerminateApplicationNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
app.bundleIdentifier == Self.xcodeBundleIdentifier
else { return }
Task { @MainActor [weak self] in self?.refresh() }
}
observers = [launched, terminated]
refresh()
}
func stop() {
for observer in observers {
workspace.notificationCenter.removeObserver(observer)
}
observers.removeAll()
}
func refresh() {
let nextValue = workspace.runningApplications.contains { app in
app.bundleIdentifier == Self.xcodeBundleIdentifier
}
guard nextValue != isXcodeRunning else { return }
isXcodeRunning = nextValue
onStateChange?()
}
}
@@ -0,0 +1,293 @@
import Darwin
import Foundation
import MacToolsPluginKit
typealias XcodeCleanScanProgressHandler = @Sendable (XcodeCleanScanLogMessage) async -> Void
protocol XcodeCleanScanning: Sendable {
func scan(
categories: Set<XcodeCleanCategory>,
progress: XcodeCleanScanProgressHandler
) async throws -> XcodeCleanScanResult
}
extension XcodeCleanScanning {
func scan(categories: Set<XcodeCleanCategory>) async throws -> XcodeCleanScanResult {
try await scan(categories: categories, progress: { _ in })
}
}
protocol XcodeCleanFileSystemProviding: Sendable {
func expandPathPattern(_ pattern: String) throws -> [String]
func sizeOfItem(at path: String) throws -> Int64
func itemExists(at path: String) -> Bool
func removeItem(at path: String) throws
}
struct LocalXcodeCleanFileSystem: XcodeCleanFileSystemProviding, @unchecked Sendable {
private let fileManager: FileManager
private let homeDirectory: String
init(
fileManager: FileManager = .default,
homeDirectory: String = NSHomeDirectory()
) {
self.fileManager = fileManager
self.homeDirectory = homeDirectory
}
func expandPathPattern(_ pattern: String) throws -> [String] {
let expanded = expandHome(in: pattern)
guard Self.containsGlob(expanded) else {
return itemExists(at: expanded) ? [expanded] : []
}
return try Self.globPaths(matching: expanded).sorted()
}
func sizeOfItem(at path: String) throws -> Int64 {
var isDir: ObjCBool = false
guard fileManager.fileExists(atPath: path, isDirectory: &isDir) else { return 0 }
if !isDir.boolValue {
let attributes = try fileManager.attributesOfItem(atPath: path)
return Int64((attributes[.size] as? NSNumber)?.int64Value ?? 0)
}
var total: Int64 = 0
let url = URL(fileURLWithPath: path)
guard let enumerator = fileManager.enumerator(
at: url,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey, .fileSizeKey],
options: [.skipsPackageDescendants]
) else {
return 0
}
for case let fileURL as URL in enumerator {
let values = try? fileURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey, .fileSizeKey])
if values?.isDirectory == true || values?.isSymbolicLink == true {
continue
}
total += Int64(values?.fileSize ?? 0)
}
return total
}
func itemExists(at path: String) -> Bool {
if fileManager.fileExists(atPath: path) {
return true
}
let attributes = try? fileManager.attributesOfItem(atPath: path)
return (attributes?[.type] as? FileAttributeType) == .typeSymbolicLink
}
func removeItem(at path: String) throws {
guard itemExists(at: path) else { return }
try fileManager.removeItem(atPath: path)
}
private func expandHome(in pattern: String) -> String {
if pattern == "~" {
return homeDirectory
}
if pattern.hasPrefix("~/") {
return homeDirectory + String(pattern.dropFirst())
}
return pattern
}
private static func containsGlob(_ pattern: String) -> Bool {
pattern.contains { "*?[".contains($0) }
}
private static func globPaths(matching pattern: String) throws -> [String] {
var result = glob_t()
defer { globfree(&result) }
let status = pattern.withCString { glob($0, 0, nil, &result) }
if status == GLOB_NOMATCH {
return []
}
guard status == 0 else {
throw XcodeCleanFileSystemError.globFailed(pattern: pattern, status: status)
}
guard let pathv = result.gl_pathv else { return [] }
return (0..<Int(result.gl_pathc)).compactMap { index in
guard let cString = pathv[index] else { return nil }
return String(cString: cString)
}
}
}
enum XcodeCleanFileSystemError: LocalizedError {
case globFailed(pattern: String, status: Int32)
var errorDescription: String? {
errorDescription()
}
func errorDescription(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
switch self {
case let .globFailed(pattern, status):
return localization.format(
"fileSystemError.globFailed",
defaultValue: "无法展开路径 %@(glob 状态 %d)",
pattern,
status
)
}
}
}
struct XcodeCleanScanner: XcodeCleanScanning {
let ruleCatalog: XcodeCleanRuleCatalog
let fileSystem: XcodeCleanFileSystemProviding
let allowedRoots: [String]
let now: @Sendable () -> Date
let localization: PluginLocalization
init(
ruleCatalog: XcodeCleanRuleCatalog = .defaultCatalog,
fileSystem: XcodeCleanFileSystemProviding = LocalXcodeCleanFileSystem(),
allowedRoots: [String]? = nil,
now: @escaping @Sendable () -> Date = Date.init,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) {
self.ruleCatalog = ruleCatalog
self.fileSystem = fileSystem
self.allowedRoots = (allowedRoots ?? Self.defaultAllowedRoots()).map { Self.ensureTrailingSlash($0) }
self.now = now
self.localization = localization
}
func scan(
categories: Set<XcodeCleanCategory>,
progress: XcodeCleanScanProgressHandler
) async throws -> XcodeCleanScanResult {
var candidates: [XcodeCleanCandidate] = []
for category in XcodeCleanCategory.allCases where categories.contains(category) {
try Task.checkCancellation()
await progress(
XcodeCleanScanLogMessage(
text: localization.format(
"scanLog.category",
defaultValue: "扫描分类:%@",
category.title(localization: localization)
),
tone: .info
)
)
for rule in ruleCatalog.rules(for: category) {
try Task.checkCancellation()
for pattern in rule.pathPatterns {
try Task.checkCancellation()
let matches = (try? fileSystem.expandPathPattern(pattern)) ?? []
await progress(
XcodeCleanScanLogMessage(
text: localization.format(
"scanLog.expanded",
defaultValue: "展开 %@ → %d 项",
pattern,
matches.count
),
tone: matches.isEmpty ? .info : .success
)
)
for path in matches {
try Task.checkCancellation()
let safety = safetyStatus(for: path)
let size = safety.isCleanable ? ((try? fileSystem.sizeOfItem(at: path)) ?? 0) : 0
let candidate = XcodeCleanCandidate(
id: "\(rule.id)::\(path)",
category: category,
path: path,
sizeBytes: size,
safety: safety
)
candidates.append(candidate)
await progress(logMessage(for: candidate))
}
}
}
}
let cleanable = candidates.filter { $0.safety.isCleanable }
await progress(
XcodeCleanScanLogMessage(
text: localization.format(
"scanLog.completed",
defaultValue: "扫描完成:%d 项,%d 项可清理",
candidates.count,
cleanable.count
),
tone: .success
)
)
return XcodeCleanScanResult(
categories: categories,
candidates: candidates,
scannedAt: now()
)
}
private func safetyStatus(for path: String) -> XcodeCleanSafetyStatus {
if !fileSystem.itemExists(at: path) {
return .missing
}
if !isPathAllowed(path) {
return .outsideAllowedRoot
}
return .allowed
}
private func isPathAllowed(_ path: String) -> Bool {
let normalized = path.hasSuffix("/") ? path : path
return allowedRoots.contains { normalized.hasPrefix($0) }
}
private func logMessage(for candidate: XcodeCleanCandidate) -> XcodeCleanScanLogMessage {
switch candidate.safety {
case .allowed:
return XcodeCleanScanLogMessage(
text: localization.format("scanLog.candidate.allowed", defaultValue: "可清理:%@", candidate.path),
tone: .success
)
case .outsideAllowedRoot:
return XcodeCleanScanLogMessage(
text: localization.format(
"scanLog.candidate.outsideAllowedRoot",
defaultValue: "越界拒绝:%@",
candidate.path
),
tone: .warning
)
case .xcodeRunning:
return XcodeCleanScanLogMessage(
text: localization.format("scanLog.candidate.xcodeRunning", defaultValue: "Xcode 运行中:%@", candidate.path),
tone: .warning
)
case .missing:
return XcodeCleanScanLogMessage(
text: localization.format("scanLog.candidate.missing", defaultValue: "已不存在:%@", candidate.path),
tone: .info
)
}
}
private static func defaultAllowedRoots() -> [String] {
let home = NSHomeDirectory()
return XcodeCleanRuleCatalog.allowedRootPrefixes.map { prefix in
prefix.hasPrefix("~/") ? home + String(prefix.dropFirst()) : prefix
}
}
private static func ensureTrailingSlash(_ path: String) -> String {
path.hasSuffix("/") ? path : path + "/"
}
}