This commit is contained in:
@@ -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 + "/"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user