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

445 lines
16 KiB
Swift

import AppKit
import Foundation
import OSLog
import SwiftUI
import UniformTypeIdentifiers
import MacToolsPluginKit
// MARK: - Factory
public final class FixDamagedAppPluginFactory: NSObject, MacToolsPluginBundleFactory {
public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider {
FixDamagedAppPluginProvider(context: context)
}
}
@MainActor
private struct FixDamagedAppPluginProvider: PluginProvider {
let context: PluginRuntimeContext
func makePlugins() -> [any MacToolsPlugin] {
[FixDamagedAppPlugin(context: context)]
}
}
// MARK: - Plugin
@MainActor
final class FixDamagedAppPlugin: MacToolsPlugin, PluginPrimaryPanel, DropZoneAnchorProviding {
// MARK: Metadata
let metadata: PluginMetadata
// MARK: State
private enum FixState: Equatable {
case idle
case running
case success(appName: String)
case failure(message: String)
}
private var selectedApp: URL?
private var fixState: FixState = .idle
// MARK: Drag Detection State
private let storage: PluginStorage
private var mouseDownMonitor: Any?
private var dragMonitor: Any?
private var mouseUpMonitor: Any?
private var dropZonePanel: FixDamagedAppDropZonePanel?
private var isDragPanelShowing = false
private var isMouseButtonDown = false
/// Drag pasteboard change count captured at mouseDown to identify a new drag session.
private var dragSessionPasteboardChangeCount: Int = Int.min
private let localization: PluginLocalization
// MARK: DropZoneAnchorProviding
var anchorRectProvider: (() -> NSRect?)?
var isDragDetectionEnabled: Bool {
storage.bool(forKey: StorageKey.isDragDetectionEnabled)
}
// MARK: Storage Keys
private enum StorageKey {
static let isDragDetectionEnabled = "drag-detection-enabled"
}
// MARK: MacToolsPlugin
var onStateChange: (() -> Void)?
var requestPermissionGuidance: ((String) -> Void)?
var shortcutBindingResolver: ((String) -> ShortcutBinding?)?
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools",
category: "FixDamagedAppPlugin"
)
var permissionRequirements: [PluginPermissionRequirement] { [] }
var settingsSections: [PluginSettingsSection] { [] }
var shortcutDefinitions: [PluginShortcutDefinition] { [] }
// MARK: Init
init(context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "fix-damaged-app")) {
let localization = PluginLocalization(bundle: context.resourceBundle)
self.localization = localization
self.storage = context.storage
self.metadata = PluginMetadata(
id: "fix-damaged-app",
title: localization.string("metadata.title", defaultValue: "修复损坏应用"),
iconName: "wrench.and.screwdriver.fill",
iconTint: Color(nsColor: .systemOrange),
order: 94,
defaultDescription: localization.string(
"metadata.description",
defaultValue: "移除隔离属性,解决「已损坏」或「不受信任」提示"
)
)
self.primaryPanelDescriptor = PluginPrimaryPanelDescriptor(
controlStyle: .button,
menuActionBehavior: .dismissBeforeHandling,
buttonTitleProvider: { localization.string("panel.button.choose", defaultValue: "选择") }
)
}
// MARK: Lifecycle
func activate(context: PluginRuntimeContext) {
updateDragMonitoring()
}
func deactivate(reason: PluginDeactivationReason) {
stopDragMonitoring()
hideDropZonePanel()
}
func refresh() {}
// MARK: Configuration
var configuration: PluginConfiguration? {
PluginConfiguration(description: metadata.defaultDescription) { [weak self] _ in
guard let self else { return AnyView(EmptyView()) }
return AnyView(
FixDamagedAppSettingsView(
isDragDetectionEnabled: self.isDragDetectionEnabled,
localization: self.localization,
onToggle: { [weak self] isOn in
self?.setDragDetectionEnabled(isOn)
}
)
)
}
}
func setDragDetectionEnabled(_ enabled: Bool) {
storage.set(enabled, forKey: StorageKey.isDragDetectionEnabled)
updateDragMonitoring()
onStateChange?()
}
// MARK: PluginPrimaryPanel
let primaryPanelDescriptor: PluginPrimaryPanelDescriptor
var primaryPanelState: PluginPanelState {
PluginPanelState(
subtitle: primarySubtitle,
isOn: false,
isExpanded: false,
isEnabled: fixState != .running,
isVisible: true,
detail: nil,
errorMessage: primaryError
)
}
func handleAction(_ action: PluginPanelAction) {
switch action {
case .invokeAction(let controlID):
handleControlAction(controlID: controlID)
default:
break
}
}
func permissionState(for permissionID: String) -> PluginPermissionState {
PluginPermissionState(isGranted: true, footnote: nil)
}
// MARK: Private
private func handleControlAction(controlID: String) {
switch controlID {
case "execute":
chooseApp()
default:
break
}
}
private var primarySubtitle: String {
switch fixState {
case .idle:
return selectedApp.map { $0.deletingPathExtension().lastPathComponent }
?? localization.string("panel.subtitle.chooseApp", defaultValue: "选择 .app 文件以修复")
case .running:
return localization.string("panel.subtitle.running", defaultValue: "修复中…")
case .success(let name):
return localization.format("panel.subtitle.successFormat", defaultValue: "已修复:%@", name)
case .failure:
return selectedApp.map { $0.deletingPathExtension().lastPathComponent }
?? localization.string("panel.subtitle.chooseApp", defaultValue: "选择 .app 文件以修复")
}
}
private var primaryError: String? {
guard case .failure(let message) = fixState else { return nil }
return message
}
private func chooseApp() {
let panel = NSOpenPanel()
panel.title = localization.string("openPanel.title", defaultValue: "选择要修复的应用")
panel.message = localization.string("openPanel.message", defaultValue: "选择显示「已损坏」或「不受信任」的应用")
panel.canChooseFiles = true
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
if let appBundleType = UTType("com.apple.application-bundle") {
panel.allowedContentTypes = [appBundleType]
}
panel.directoryURL = URL(fileURLWithPath: "/Applications")
let response = panel.runModal()
guard response == .OK, let url = panel.url else { return }
selectedApp = url
fixState = .idle
onStateChange?()
performFix()
}
private func performFix() {
guard let appURL = selectedApp, fixState != .running else { return }
let appPath = appURL.path
let appName = appURL.deletingPathExtension().lastPathComponent
fixState = .running
onStateChange?()
Task {
do {
try await self.removeQuarantine(appPath: appPath)
await MainActor.run {
self.fixState = .success(appName: appName)
self.onStateChange?()
}
} catch {
let message = error.localizedDescription
self.logger.error("Quarantine removal failed for '\(appPath)': \(message)")
await MainActor.run {
self.fixState = .failure(message: message)
self.onStateChange?()
}
}
}
}
private func removeQuarantine(appPath: String) async throws {
let localization = localization
try await Task.detached(priority: .userInitiated) {
try runQuarantineRemoval(appPath: appPath, localization: localization)
}.value
}
// MARK: Private - Drag Monitoring
private func updateDragMonitoring() {
stopDragMonitoring()
guard isDragDetectionEnabled else { return }
startDragMonitoring()
}
private func startDragMonitoring() {
// Global NSEvent monitors fire on the main thread. Run synchronously with
// `MainActor.assumeIsolated` so multiple events cannot pass the `isDragPanelShowing`
// check while queued through async Tasks.
mouseDownMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] _ in
MainActor.assumeIsolated {
self?.isMouseButtonDown = true
// Capture the current drag pasteboard version; only a later changeCount indicates
// that a real drag started.
self?.dragSessionPasteboardChangeCount = NSPasteboard(name: .drag).changeCount
}
}
dragMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDragged]) { [weak self] _ in
MainActor.assumeIsolated {
self?.handleGlobalDrag()
}
}
mouseUpMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseUp]) { [weak self] _ in
MainActor.assumeIsolated {
self?.isMouseButtonDown = false
self?.handleGlobalMouseUp()
}
}
}
private func stopDragMonitoring() {
if let monitor = mouseDownMonitor {
NSEvent.removeMonitor(monitor)
mouseDownMonitor = nil
}
if let monitor = dragMonitor {
NSEvent.removeMonitor(monitor)
dragMonitor = nil
}
if let monitor = mouseUpMonitor {
NSEvent.removeMonitor(monitor)
mouseUpMonitor = nil
}
isMouseButtonDown = false
}
private func handleGlobalDrag() {
guard !isDragPanelShowing, isMouseButtonDown else { return }
let pb = NSPasteboard(name: .drag)
// React only after the drag pasteboard changes during this mouse-down cycle. This prevents
// stale drag pasteboard data from showing the panel on normal clicks, such as opening Finder.
guard pb.changeCount != dragSessionPasteboardChangeCount else { return }
guard
let urls = pb.readObjects(
forClasses: [NSURL.self],
options: [.urlReadingFileURLsOnly: true]
) as? [URL],
urls.contains(where: { $0.pathExtension.lowercased() == "app" })
else { return }
showDropZonePanel()
}
private func handleGlobalMouseUp() {
guard isDragPanelShowing else { return }
// If the pointer is inside the panel, the file was dropped into the window and the drop
// pipeline owns dismissal. The global mouseUp arrives before SwiftUI `onDrop`, so it cannot
// rely on `isDropPending` having been set yet.
if let panel = dropZonePanel, panel.frame.contains(NSEvent.mouseLocation) { return }
dropZonePanel?.dismissIfIdle()
}
private func showDropZonePanel() {
isDragPanelShowing = true
let vm = DropZoneViewModel(
localization: localization,
onComplete: { [weak self] appName, succeeded, errorMessage in
guard let self else { return }
if succeeded {
self.fixState = .success(appName: appName)
} else {
self.fixState = .failure(
message: errorMessage
?? self.localization.string("error.fixFailed", defaultValue: "修复失败")
)
}
self.onStateChange?()
},
onDismiss: { [weak self] in
self?.hideDropZonePanel()
}
)
let panel = FixDamagedAppDropZonePanel(viewModel: vm, localization: localization)
positionDropZonePanel(panel)
panel.makeKeyAndOrderFront(nil)
dropZonePanel = panel
}
private func hideDropZonePanel() {
dropZonePanel?.orderOut(nil)
dropZonePanel = nil
isDragPanelShowing = false
}
private func positionDropZonePanel(_ panel: NSPanel) {
let panelSize = panel.frame.size
if let anchorRect = anchorRectProvider?() {
let screenMaxX = NSScreen.main?.frame.maxX ?? 1440
let rawX = anchorRect.midX - panelSize.width / 2
let x = max(8, min(rawX, screenMaxX - panelSize.width - 8))
let y = anchorRect.minY - panelSize.height - 4
panel.setFrameOrigin(NSPoint(x: x, y: y))
return
}
guard let screen = NSScreen.main else { return }
let menuBarThickness = NSStatusBar.system.thickness
let x = screen.frame.midX - panelSize.width / 2
let y = screen.frame.maxY - menuBarThickness - panelSize.height - 12
panel.setFrameOrigin(NSPoint(x: x, y: y))
}
}
// MARK: - Quarantine Removal (nonisolated helper)
func runQuarantineRemoval(appPath: String) throws {
try runQuarantineRemoval(appPath: appPath, localization: PluginLocalization(bundle: .main))
}
func runQuarantineRemoval(appPath: String, localization: PluginLocalization) throws {
// Reject paths containing double-quote to prevent AppleScript string literal injection
guard !appPath.contains("\"") else {
throw NSError(
domain: "FixDamagedAppPlugin",
code: 1,
userInfo: [
NSLocalizedDescriptionKey: localization.string(
"error.unsupportedPathCharacters",
defaultValue: "应用路径包含不支持的字符(双引号)"
)
]
)
}
// Use AppleScript's `quoted form of` to safely quote the path in the shell command
let script = """
set appPath to "\(appPath)"
do shell script "xattr -r -d com.apple.quarantine " & quoted form of appPath with administrator privileges
"""
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
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
let errMsg = String(data: errData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if errMsg.contains("-128") || errMsg.contains("User canceled") {
throw NSError(
domain: "FixDamagedAppPlugin",
code: -128,
userInfo: [
NSLocalizedDescriptionKey: localization.string(
"error.userCancelledAuthorization",
defaultValue: "用户取消了授权"
)
]
)
}
throw NSError(
domain: "FixDamagedAppPlugin",
code: Int(process.terminationStatus),
userInfo: [
NSLocalizedDescriptionKey: errMsg.isEmpty
? localization.string("error.fixFailedUnknown", defaultValue: "修复失败(未知错误)")
: errMsg
]
)
}
}