445 lines
16 KiB
Swift
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
|
|
]
|
|
)
|
|
}
|
|
}
|