381 lines
12 KiB
Swift
381 lines
12 KiB
Swift
import AppKit
|
|
import Carbon
|
|
import Foundation
|
|
import OSLog
|
|
import SwiftUI
|
|
import MacToolsPluginKit
|
|
|
|
public final class PhysicalCleanModePluginFactory: NSObject, MacToolsPluginBundleFactory {
|
|
public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider {
|
|
PhysicalCleanModePluginProvider(context: context)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private struct PhysicalCleanModePluginProvider: PluginProvider {
|
|
let context: PluginRuntimeContext
|
|
|
|
func makePlugins() -> [any MacToolsPlugin] {
|
|
[PhysicalCleanModePlugin(context: context)]
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class PhysicalCleanModePlugin: MacToolsPlugin, PluginPrimaryPanel, AccessibilityPermissionRefreshing {
|
|
private enum StorageKey {
|
|
static let legacyEnabledState = "feature.cleanModeEnabled"
|
|
}
|
|
|
|
private enum ActionID {
|
|
static let exitPhysicalCleanMode = "exitPhysicalCleanMode"
|
|
}
|
|
|
|
private enum PermissionID {
|
|
static let accessibility = "accessibility"
|
|
}
|
|
|
|
private enum ShortcutID {
|
|
static let exitPhysicalCleanMode = "exit-physical-clean-mode"
|
|
}
|
|
|
|
private enum ShortcutSettingsGroupID {
|
|
static let exitPhysicalCleanMode = "physical-clean-mode.exit"
|
|
}
|
|
|
|
let metadata: PluginMetadata
|
|
|
|
let primaryPanelDescriptor = PluginPrimaryPanelDescriptor(
|
|
controlStyle: .switch,
|
|
menuActionBehavior: .dismissBeforeHandling
|
|
)
|
|
|
|
var onStateChange: (() -> Void)?
|
|
var requestPermissionGuidance: ((String) -> Void)?
|
|
var shortcutBindingResolver: ((String) -> ShortcutBinding?)?
|
|
|
|
private let storage: PluginStorage
|
|
private let localization: PluginLocalization
|
|
private let logger = PhysicalCleanModeLog.plugin
|
|
private var isAccessibilityGranted: Bool
|
|
private var lastErrorKey: String?
|
|
private var lastErrorMessage: String?
|
|
private var session: PhysicalCleanModeSession?
|
|
|
|
init(
|
|
context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "physical-clean-mode"),
|
|
userDefaults: UserDefaults? = nil
|
|
) {
|
|
let localization = PluginLocalization(bundle: context.resourceBundle)
|
|
self.localization = localization
|
|
self.storage = userDefaults.map {
|
|
UserDefaultsPluginStorage(pluginID: context.pluginID, userDefaults: $0)
|
|
} ?? context.storage
|
|
self.metadata = PluginMetadata(
|
|
id: "physical-clean-mode",
|
|
title: localization.string("metadata.title", defaultValue: "清洁模式"),
|
|
iconName: "sparkles",
|
|
iconTint: Color(nsColor: .systemCyan),
|
|
order: 100,
|
|
defaultDescription: localization.string(
|
|
"metadata.description",
|
|
defaultValue: "屏幕全黑并临时禁用键盘输入"
|
|
)
|
|
)
|
|
self.isAccessibilityGranted = AccessibilityCheck.isTrusted()
|
|
|
|
storage.migrateValueIfNeeded(
|
|
fromLegacyKey: StorageKey.legacyEnabledState,
|
|
to: StorageKey.legacyEnabledState
|
|
)
|
|
storage.removeObject(forKey: StorageKey.legacyEnabledState)
|
|
}
|
|
|
|
var primaryPanelState: PluginPanelState {
|
|
PluginPanelState(
|
|
subtitle: panelSubtitle,
|
|
isOn: session != nil,
|
|
isExpanded: false,
|
|
isEnabled: true,
|
|
isVisible: true,
|
|
detail: nil,
|
|
errorMessage: lastErrorMessage
|
|
)
|
|
}
|
|
|
|
var permissionRequirements: [PluginPermissionRequirement] {
|
|
[
|
|
PluginPermissionRequirement(
|
|
id: PermissionID.accessibility,
|
|
kind: .accessibility,
|
|
title: localization.string("permission.accessibility.title", defaultValue: "辅助功能授权"),
|
|
description: localization.string(
|
|
"permission.accessibility.description",
|
|
defaultValue: "辅助功能权限是清洁模式运行所需的必要权限。"
|
|
)
|
|
)
|
|
]
|
|
}
|
|
|
|
var settingsSections: [PluginSettingsSection] { [] }
|
|
|
|
var shortcutDefinitions: [PluginShortcutDefinition] {
|
|
[
|
|
PluginShortcutDefinition(
|
|
id: ShortcutID.exitPhysicalCleanMode,
|
|
title: localization.string("shortcut.exit.title", defaultValue: "退出清洁模式"),
|
|
description: localization.string(
|
|
"shortcut.exit.description",
|
|
defaultValue: "清洁模式启用时用于恢复输入和关闭黑屏覆盖的快捷键。"
|
|
),
|
|
actionID: ActionID.exitPhysicalCleanMode,
|
|
scope: .whilePluginActive,
|
|
defaultBinding: ShortcutBinding(
|
|
keyCode: UInt16(kVK_Escape),
|
|
modifiers: [.control, .command]
|
|
),
|
|
isRequired: true,
|
|
settingsGroupID: ShortcutSettingsGroupID.exitPhysicalCleanMode,
|
|
settingsGroupTitle: localization.string("shortcut.exit.settingsGroupTitle", defaultValue: "退出快捷键"),
|
|
settingsGroupDescription: localization.string(
|
|
"shortcut.exit.settingsGroupDescription",
|
|
defaultValue: "清洁模式启用时恢复输入并关闭黑屏覆盖。"
|
|
),
|
|
settingsControlTitle: localization.string("shortcut.exit.settingsControlTitle", defaultValue: "退出"),
|
|
settingsControlSystemImage: "keyboard"
|
|
)
|
|
]
|
|
}
|
|
|
|
func refresh() {
|
|
let previousAccessState = isAccessibilityGranted
|
|
isAccessibilityGranted = AccessibilityCheck.isTrusted()
|
|
|
|
if isAccessibilityGranted {
|
|
clearErrorIfKey("error.accessibilityRequired")
|
|
} else if session != nil {
|
|
session?.requestEmergencyExit(message: localization.string(
|
|
"error.accessibilityRevokedEmergencyExit",
|
|
defaultValue: "辅助功能授权已失效,已自动退出清洁模式。"
|
|
))
|
|
}
|
|
|
|
if previousAccessState != isAccessibilityGranted {
|
|
notifyChange()
|
|
}
|
|
}
|
|
|
|
func handleAction(_ action: PluginPanelAction) {
|
|
switch action {
|
|
case let .setSwitch(isEnabled):
|
|
if PhysicalCleanModeLog.isVerboseLoggingEnabled {
|
|
logger.debug("panel action setSwitch isEnabled=\(isEnabled, privacy: .public)")
|
|
}
|
|
setPhysicalCleanModeEnabled(isEnabled)
|
|
case .setDisclosureExpanded,
|
|
.setSelection,
|
|
.setNavigationSelection,
|
|
.clearNavigationSelection,
|
|
.setDate,
|
|
.setSlider,
|
|
.invokeAction:
|
|
break
|
|
}
|
|
}
|
|
|
|
func permissionState(for permissionID: String) -> PluginPermissionState {
|
|
switch permissionID {
|
|
case PermissionID.accessibility:
|
|
return PluginPermissionState(
|
|
isGranted: isAccessibilityGranted,
|
|
footnote: lastErrorMessage
|
|
)
|
|
default:
|
|
return PluginPermissionState(isGranted: true, footnote: nil)
|
|
}
|
|
}
|
|
|
|
func handlePermissionAction(id: String) {
|
|
guard id == PermissionID.accessibility else {
|
|
return
|
|
}
|
|
|
|
if isAccessibilityGranted {
|
|
refresh()
|
|
} else {
|
|
requestAccessibilityPermission(showSettingsGuidance: false)
|
|
}
|
|
}
|
|
|
|
func handleSettingsAction(id: String) {}
|
|
|
|
func handleShortcutAction(id: String) {
|
|
guard id == ActionID.exitPhysicalCleanMode else {
|
|
return
|
|
}
|
|
|
|
session?.requestStop(reason: .userRequested)
|
|
}
|
|
|
|
private var panelSubtitle: String {
|
|
if let session {
|
|
return localization.format(
|
|
"panel.subtitle.enabledExitFormat",
|
|
defaultValue: "已启用,使用 %@ 退出",
|
|
ShortcutFormatter.displayString(for: session.exitBinding)
|
|
)
|
|
}
|
|
|
|
if isAccessibilityGranted {
|
|
return metadata.defaultDescription
|
|
}
|
|
|
|
return localization.string("panel.subtitle.needsAccessibility", defaultValue: "启用前需要辅助功能授权")
|
|
}
|
|
|
|
private func requestAccessibilityPermission(showSettingsGuidance: Bool) {
|
|
if PhysicalCleanModeLog.isVerboseLoggingEnabled {
|
|
logger.debug("request accessibility permission showSettingsGuidance=\(showSettingsGuidance, privacy: .public)")
|
|
}
|
|
isAccessibilityGranted = AccessibilityCheck.requestTrust(prompt: true)
|
|
|
|
if isAccessibilityGranted {
|
|
clearError()
|
|
} else {
|
|
logger.notice("accessibility permission is required before entering physical clean mode")
|
|
setError("error.accessibilityRequired")
|
|
|
|
if showSettingsGuidance {
|
|
requestPermissionGuidance?(PermissionID.accessibility)
|
|
}
|
|
}
|
|
|
|
notifyChange()
|
|
}
|
|
|
|
private func setPhysicalCleanModeEnabled(_ isEnabled: Bool) {
|
|
guard isEnabled else {
|
|
clearError()
|
|
if let session {
|
|
session.requestStop(reason: .userRequested)
|
|
} else {
|
|
notifyChange()
|
|
}
|
|
return
|
|
}
|
|
|
|
enablePhysicalCleanModeIfPossible()
|
|
}
|
|
|
|
private func enablePhysicalCleanModeIfPossible() {
|
|
isAccessibilityGranted = AccessibilityCheck.isTrusted()
|
|
|
|
guard isAccessibilityGranted else {
|
|
requestAccessibilityPermission(showSettingsGuidance: true)
|
|
return
|
|
}
|
|
|
|
guard let exitBinding = shortcutBindingResolver?(ShortcutID.exitPhysicalCleanMode), exitBinding.isValid else {
|
|
logger.error("enable aborted because exit shortcut is missing or invalid")
|
|
setError("error.invalidExitShortcut")
|
|
notifyChange()
|
|
return
|
|
}
|
|
|
|
if session != nil {
|
|
return
|
|
}
|
|
|
|
let session = PhysicalCleanModeSession(
|
|
exitBinding: exitBinding,
|
|
onEnd: { [weak self] reason in
|
|
self?.handleSessionEnd(reason)
|
|
},
|
|
localization: localization
|
|
)
|
|
|
|
do {
|
|
self.session = session
|
|
if PhysicalCleanModeLog.isVerboseLoggingEnabled {
|
|
logger.debug("starting physical clean mode session exitBinding=\(ShortcutFormatter.displayString(for: exitBinding), privacy: .public)")
|
|
}
|
|
try session.start()
|
|
|
|
guard self.session === session else {
|
|
logger.error("session ended during startup sequence before completion")
|
|
return
|
|
}
|
|
|
|
clearError()
|
|
notifyChange()
|
|
} catch {
|
|
if self.session === session {
|
|
self.session = nil
|
|
}
|
|
logger.error("physical clean mode session start failed: \(error.localizedDescription, privacy: .public)")
|
|
lastErrorMessage = error.localizedDescription
|
|
notifyChange()
|
|
}
|
|
}
|
|
|
|
private func handleSessionEnd(_ reason: PhysicalCleanModeSession.EndReason) {
|
|
switch reason {
|
|
case .userRequested:
|
|
break
|
|
case .emergency:
|
|
logger.error("physical clean mode session ended unexpectedly reason=\(String(describing: reason), privacy: .public)")
|
|
}
|
|
|
|
session = nil
|
|
|
|
switch reason {
|
|
case .userRequested:
|
|
clearError()
|
|
case let .emergency(message):
|
|
setRawError(message)
|
|
}
|
|
|
|
notifyChange()
|
|
}
|
|
|
|
private func clearError() {
|
|
lastErrorKey = nil
|
|
lastErrorMessage = nil
|
|
}
|
|
|
|
private func clearErrorIfKey(_ key: String) {
|
|
guard lastErrorKey == key else { return }
|
|
clearError()
|
|
}
|
|
|
|
private func setError(_ key: String) {
|
|
lastErrorKey = key
|
|
lastErrorMessage = localizedErrorMessage(for: key)
|
|
}
|
|
|
|
private func setRawError(_ message: String) {
|
|
lastErrorKey = nil
|
|
lastErrorMessage = message
|
|
}
|
|
|
|
private func localizedErrorMessage(for key: String) -> String {
|
|
switch key {
|
|
case "error.accessibilityRequired":
|
|
return localization.string(key, defaultValue: "清洁模式需要辅助功能权限,请先前往设置完成授权。")
|
|
case "error.invalidExitShortcut":
|
|
return localization.string(key, defaultValue: "请先在功能中设置有效的退出快捷键。")
|
|
default:
|
|
return localization.string(key, defaultValue: "清洁模式不可用。")
|
|
}
|
|
}
|
|
|
|
private func notifyChange() {
|
|
onStateChange?()
|
|
}
|
|
|
|
// MARK: - AccessibilityPermissionRefreshing
|
|
|
|
func refreshAccessibilityPermission() {
|
|
refresh()
|
|
}
|
|
}
|