Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

254 lines
8.0 KiB
Swift

import Foundation
import OSLog
import SwiftUI
import MacToolsPluginKit
public final class EmptyTrashPluginFactory: NSObject, MacToolsPluginBundleFactory {
public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider {
EmptyTrashPluginProvider(context: context)
}
}
@MainActor
private struct EmptyTrashPluginProvider: PluginProvider {
let context: PluginRuntimeContext
func makePlugins() -> [any MacToolsPlugin] {
[EmptyTrashPlugin(localization: PluginLocalization(bundle: context.resourceBundle))]
}
}
@MainActor
final class EmptyTrashPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginPanelSurfaceLifecycleHandling {
let metadata: PluginMetadata
let primaryPanelDescriptor: PluginPrimaryPanelDescriptor
var onStateChange: (() -> Void)?
var requestPermissionGuidance: ((String) -> Void)?
var shortcutBindingResolver: ((String) -> ShortcutBinding?)?
private let localization: PluginLocalization
private let countItems: @Sendable () async -> Int
private let countRefreshDelay: Duration
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "EmptyTrashPlugin")
private var itemCount: Int = 0
private var isEmptying = false
private var lastErrorMessage: String?
private var isPrimaryPanelVisible = false
private var countRefreshTask: Task<Void, Never>?
init(
localization: PluginLocalization = PluginLocalization(bundle: .main),
countItems: @escaping @Sendable () async -> Int = EmptyTrashPlugin.fetchTrashItemCount,
countRefreshDelay: Duration = .milliseconds(150)
) {
self.localization = localization
self.countItems = countItems
self.countRefreshDelay = countRefreshDelay
self.metadata = PluginMetadata(
id: "empty-trash",
title: localization.string("metadata.title", defaultValue: "清空废纸篓"),
iconName: "trash",
iconTint: Color(nsColor: .systemGray),
order: 93,
defaultDescription: localization.string(
"metadata.description",
defaultValue: "清空废纸篓中的所有项目"
)
)
self.primaryPanelDescriptor = PluginPrimaryPanelDescriptor(
controlStyle: .button,
menuActionBehavior: .keepPresented,
buttonTitleProvider: { localization.string("panel.button.empty", defaultValue: "清空") }
)
}
var primaryPanelState: PluginPanelState {
PluginPanelState(
subtitle: subtitle,
isOn: false,
isExpanded: false,
isEnabled: !isEmptying && itemCount > 0,
isVisible: true,
detail: nil,
errorMessage: lastErrorMessage
)
}
var permissionRequirements: [PluginPermissionRequirement] { [] }
var settingsSections: [PluginSettingsSection] { [] }
var shortcutDefinitions: [PluginShortcutDefinition] { [] }
func refresh() {
scheduleCountRefreshIfVisible()
}
func deactivate(reason _: PluginDeactivationReason) {
countRefreshTask?.cancel()
countRefreshTask = nil
isPrimaryPanelVisible = false
}
func panelSurfaceDidBecomeVisible(_ surface: PluginPanelSurface) {
guard surface == .primary else {
return
}
isPrimaryPanelVisible = true
scheduleCountRefresh()
}
func panelSurfaceDidBecomeHidden(_ surface: PluginPanelSurface) {
guard surface == .primary else {
return
}
isPrimaryPanelVisible = false
countRefreshTask?.cancel()
countRefreshTask = nil
}
func handleAction(_ action: PluginPanelAction) {
switch action {
case let .invokeAction(controlID):
if controlID == "execute" {
emptyTrash()
}
default:
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 scheduleCountRefresh() {
countRefreshTask?.cancel()
let delay = countRefreshDelay
countRefreshTask = Task { @MainActor [weak self, delay] in
do {
try await Task.sleep(for: delay)
} catch {
return
}
guard let self, !Task.isCancelled else {
return
}
let count = await self.countItems()
guard !Task.isCancelled else {
return
}
if self.itemCount != count {
self.itemCount = count
self.onStateChange?()
}
self.countRefreshTask = nil
}
}
private func scheduleCountRefreshIfVisible() {
guard isPrimaryPanelVisible else {
return
}
scheduleCountRefresh()
}
private var subtitle: String {
if isEmptying {
return localization.string("panel.subtitle.emptying", defaultValue: "清空中...")
}
if itemCount == 0 {
return localization.string("panel.subtitle.empty", defaultValue: "废纸篓为空")
}
return localization.format("panel.subtitle.countFormat", defaultValue: "%d 个项目", itemCount)
}
@MainActor
private func emptyTrash() {
guard !isEmptying, itemCount > 0 else { return }
isEmptying = true
lastErrorMessage = nil
onStateChange?()
Task {
do {
try await self.emptyTrashViaAppleScript()
await MainActor.run {
self.isEmptying = false
self.itemCount = 0
self.onStateChange?()
self.scheduleCountRefreshIfVisible()
}
} catch {
await MainActor.run {
self.isEmptying = false
self.lastErrorMessage = error.localizedDescription
self.onStateChange?()
self.scheduleCountRefreshIfVisible()
self.logger.error("Empty trash failed: \(error)")
}
}
}
}
// MARK: - AppleScript helpers
private static func fetchTrashItemCount() async -> Int {
let script = "tell application \"Finder\" to count items of trash"
return await Task.detached(priority: .userInitiated) {
runOsascriptStandalone(script).flatMap { Int($0) } ?? 0
}.value
}
private func emptyTrashViaAppleScript() async throws {
let script = "tell application \"Finder\" to empty trash"
let errorMessage = localization.string(
"error.emptyFailed",
defaultValue: "清空废纸篓失败,请检查“自动操作”权限"
)
try await Task.detached(priority: .userInitiated) {
if runOsascriptStandalone(script) == nil {
throw NSError(
domain: "EmptyTrashPlugin",
code: 1,
userInfo: [NSLocalizedDescriptionKey: errorMessage]
)
}
}.value
}
}
private func runOsascriptStandalone(_ script: String) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", script]
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
do {
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
return nil
}
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
} catch {
return nil
}
}