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