全局快捷键
This commit is contained in:
1 parent
4f46446494
commit
0cd20c4c8a
8 files changed
+273
No files matched your search
@@ -9,6 +9,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
private var mainWindow: MainWindowController!
|
||||
private var updateWindow: UpdateWindowController!
|
||||
private var updateTimer: Timer?
|
||||
private let hotKeys = HotKeyCenter()
|
||||
private var workspaceObservers: [NSObjectProtocol] = []
|
||||
|
||||
public override init() {
|
||||
@@ -58,6 +59,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
self?.mainWindow.show(tab: tab)
|
||||
}
|
||||
model.alerts.start()
|
||||
hotKeys.onAction = { [weak self] action in self?.perform(action) }
|
||||
applyHotKeys()
|
||||
|
||||
// 开发调试:--show-panel [cpu|memory|network|gpu|temperature|fan] 启动后展开并固定弹窗;--show-window 打开主窗口
|
||||
let arguments = CommandLine.arguments
|
||||
@@ -112,6 +115,31 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
mainWindow.show(tab: .settingsAbout)
|
||||
}
|
||||
|
||||
private func applyHotKeys() {
|
||||
hotKeys.apply(model.settings.hotKeys)
|
||||
model.hotKeyConflicts = hotKeys.conflicts
|
||||
withObservationTracking {
|
||||
_ = model.settings.hotKeys
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in self?.applyHotKeys() }
|
||||
}
|
||||
}
|
||||
|
||||
private func perform(_ action: HotKeyAction) {
|
||||
Log.app.info("快捷键:\(action.rawValue, privacy: .public)")
|
||||
switch action {
|
||||
case .toggleMainWindow:
|
||||
if mainWindow.isVisible && NSApp.isActive { mainWindow.close() } else { mainWindow.show(tab: nil) }
|
||||
case .showProcesses:
|
||||
menuBar.dismissPopovers()
|
||||
mainWindow.show(tab: .processes)
|
||||
case .toggleKeepAwake:
|
||||
Task { await model.keepAwake.setActive(!model.keepAwake.isActive) }
|
||||
case .purgeMemory:
|
||||
Task { await model.maintenance.run(.purgeMemory) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 10 秒后检查一次,之后每小时看一眼是否已满一天
|
||||
private func startUpdateChecks() {
|
||||
model.updates.onPrompt = { [weak self] in
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import AppKit
|
||||
import Carbon.HIToolbox
|
||||
|
||||
/// 可以绑定全局快捷键的操作
|
||||
public enum HotKeyAction: String, CaseIterable, Identifiable, Codable, Sendable {
|
||||
case toggleMainWindow, showProcesses, toggleKeepAwake, purgeMemory
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .toggleMainWindow: "显示 / 隐藏主窗口"
|
||||
case .showProcesses: "打开进程页"
|
||||
case .toggleKeepAwake: "开关防休眠"
|
||||
case .purgeMemory: "释放内存"
|
||||
}
|
||||
}
|
||||
|
||||
/// Carbon 热键编号,从 1 开始
|
||||
var carbonID: UInt32 { UInt32(Self.allCases.firstIndex(of: self)! + 1) }
|
||||
}
|
||||
|
||||
/// 一个快捷键:虚拟键码加修饰键
|
||||
public struct HotKey: Codable, Equatable, Sendable {
|
||||
public var keyCode: UInt16
|
||||
/// NSEvent.ModifierFlags 的原始值,只保留 ⌃⌥⇧⌘
|
||||
public var modifiers: UInt
|
||||
/// 录制时按下的字符,用于显示
|
||||
public var key: String
|
||||
|
||||
public init(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, key: String) {
|
||||
self.keyCode = keyCode
|
||||
self.modifiers = modifiers.intersection([.control, .option, .shift, .command]).rawValue
|
||||
self.key = key
|
||||
}
|
||||
|
||||
var flags: NSEvent.ModifierFlags { NSEvent.ModifierFlags(rawValue: modifiers) }
|
||||
|
||||
/// 至少带一个 ⌃、⌥ 或 ⌘,避免抢走普通按键
|
||||
var isValid: Bool { !flags.intersection([.control, .option, .command]).isEmpty }
|
||||
|
||||
var display: String {
|
||||
var text = ""
|
||||
if flags.contains(.control) { text += "⌃" }
|
||||
if flags.contains(.option) { text += "⌥" }
|
||||
if flags.contains(.shift) { text += "⇧" }
|
||||
if flags.contains(.command) { text += "⌘" }
|
||||
return text + Self.keyName(keyCode: keyCode, fallback: key)
|
||||
}
|
||||
|
||||
var carbonModifiers: UInt32 {
|
||||
var result: UInt32 = 0
|
||||
if flags.contains(.control) { result |= UInt32(controlKey) }
|
||||
if flags.contains(.option) { result |= UInt32(optionKey) }
|
||||
if flags.contains(.shift) { result |= UInt32(shiftKey) }
|
||||
if flags.contains(.command) { result |= UInt32(cmdKey) }
|
||||
return result
|
||||
}
|
||||
|
||||
private static let functionKeys: [Int: Int] = [
|
||||
kVK_F1: 1, kVK_F2: 2, kVK_F3: 3, kVK_F4: 4, kVK_F5: 5, kVK_F6: 6,
|
||||
kVK_F7: 7, kVK_F8: 8, kVK_F9: 9, kVK_F10: 10, kVK_F11: 11, kVK_F12: 12,
|
||||
]
|
||||
|
||||
static func keyName(keyCode: UInt16, fallback: String) -> String {
|
||||
switch Int(keyCode) {
|
||||
case kVK_Space: "空格"
|
||||
case kVK_Return: "↩"
|
||||
case kVK_Tab: "⇥"
|
||||
case kVK_Delete: "⌫"
|
||||
case kVK_Escape: "⎋"
|
||||
case kVK_LeftArrow: "←"
|
||||
case kVK_RightArrow: "→"
|
||||
case kVK_UpArrow: "↑"
|
||||
case kVK_DownArrow: "↓"
|
||||
default:
|
||||
// F1–F12 的虚拟键码不连续,逐个对照
|
||||
functionKeys[Int(keyCode)].map { "F\($0)" } ?? fallback.uppercased()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 Carbon 注册系统级快捷键:不需要辅助功能权限,应用在后台也能响应
|
||||
@MainActor
|
||||
final class HotKeyCenter {
|
||||
private var references: [HotKeyAction: EventHotKeyRef] = [:]
|
||||
private var handler: EventHandlerRef?
|
||||
var onAction: (HotKeyAction) -> Void = { _ in }
|
||||
/// 注册失败(被其他应用占用)的操作
|
||||
private(set) var conflicts: Set<HotKeyAction> = []
|
||||
|
||||
private static weak var current: HotKeyCenter?
|
||||
|
||||
func apply(_ bindings: [HotKeyAction: HotKey]) {
|
||||
installHandlerIfNeeded()
|
||||
for reference in references.values { UnregisterEventHotKey(reference) }
|
||||
references = [:]
|
||||
conflicts = []
|
||||
for (action, hotKey) in bindings where hotKey.isValid {
|
||||
var reference: EventHotKeyRef?
|
||||
let id = EventHotKeyID(signature: Self.signature, id: action.carbonID)
|
||||
let status = RegisterEventHotKey(UInt32(hotKey.keyCode), hotKey.carbonModifiers, id, GetApplicationEventTarget(), 0, &reference)
|
||||
if status == noErr, let reference {
|
||||
references[action] = reference
|
||||
} else {
|
||||
conflicts.insert(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let signature: OSType = 0x4F505354 // "OPST"
|
||||
|
||||
private func installHandlerIfNeeded() {
|
||||
guard handler == nil else { return }
|
||||
Self.current = self
|
||||
var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
|
||||
InstallEventHandler(GetApplicationEventTarget(), { _, event, _ -> OSStatus in
|
||||
var id = EventHotKeyID()
|
||||
let status = GetEventParameter(event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
|
||||
nil, MemoryLayout<EventHotKeyID>.size, nil, &id)
|
||||
guard status == noErr, id.signature == HotKeyCenter.signature,
|
||||
let action = HotKeyAction.allCases.first(where: { $0.carbonID == id.id }) else { return OSStatus(eventNotHandledErr) }
|
||||
MainActor.assumeIsolated { HotKeyCenter.current?.onAction(action) }
|
||||
return noErr
|
||||
}, 1, &spec, nil, &handler)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ final class MainWindowController: NSObject, NSWindowDelegate {
|
||||
|
||||
var isVisible: Bool { window?.isVisible == true }
|
||||
|
||||
func close() {
|
||||
window?.performClose(nil)
|
||||
}
|
||||
|
||||
func show(tab: PanelTab?) {
|
||||
if let tab { model.settings.panelTab = tab }
|
||||
let window = self.window ?? makeWindow()
|
||||
|
||||
@@ -86,6 +86,8 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HotKeySettings()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +178,78 @@ struct DiagnosticsSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 快捷键
|
||||
|
||||
struct HotKeySettings: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let settings = model.settings
|
||||
SettingsGroup(caption: "全局快捷键(在任何应用中都能使用,需要包含 ⌘、⌥ 或 ⌃)") {
|
||||
ForEach(Array(HotKeyAction.allCases.enumerated()), id: \.element) { index, action in
|
||||
GroupRow(showsDivider: index > 0) {
|
||||
SettingRow(title: action.title,
|
||||
subtitle: model.hotKeyConflicts.contains(action) ? "这个快捷键已被其他应用或系统占用,请换一个" : nil) {
|
||||
ShortcutRecorder(hotKey: Binding(get: { settings.hotKeys[action] },
|
||||
set: { settings.hotKeys[action] = $0 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 点一下开始录制,按下组合键完成;Esc 取消,⌫ 清除
|
||||
struct ShortcutRecorder: View {
|
||||
@Binding var hotKey: HotKey?
|
||||
@State private var recording = false
|
||||
@State private var monitor: Any?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: DS.Space.s1) {
|
||||
Button {
|
||||
recording ? stop() : start()
|
||||
} label: {
|
||||
Text(verbatim: recording ? "按下快捷键…" : hotKey?.display ?? "录制快捷键")
|
||||
.dsFont(.sm, weight: hotKey == nil || recording ? .regular : .semibold)
|
||||
.monospacedDigit()
|
||||
.frame(minWidth: DS.Space.s16 + DS.Space.s12)
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: recording ? .primary : .secondary))
|
||||
if hotKey != nil && !recording {
|
||||
MiniIconButton(systemName: "xmark.circle.fill", help: "清除快捷键") { hotKey = nil }
|
||||
}
|
||||
}
|
||||
.onDisappear { stop() }
|
||||
}
|
||||
|
||||
private func start() {
|
||||
recording = true
|
||||
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
|
||||
switch Int(event.keyCode) {
|
||||
case 53: // Esc
|
||||
stop()
|
||||
case 51 where event.modifierFlags.intersection([.command, .option, .control]).isEmpty: // Delete
|
||||
hotKey = nil
|
||||
stop()
|
||||
default:
|
||||
let candidate = HotKey(keyCode: event.keyCode, modifiers: event.modifierFlags,
|
||||
key: event.charactersIgnoringModifiers ?? "")
|
||||
guard candidate.isValid else { NSSound.beep(); return nil }
|
||||
hotKey = candidate
|
||||
stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
recording = false
|
||||
if let monitor { NSEvent.removeMonitor(monitor) }
|
||||
monitor = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 菜单栏
|
||||
|
||||
struct MenuBarSettings: View {
|
||||
|
||||
@@ -27,6 +27,8 @@ public final class AppModel {
|
||||
@ObservationIgnored public let hub = MetricsHub()
|
||||
|
||||
public var isMainWindowVisible = false
|
||||
/// 被其他应用占用、没能注册的快捷键
|
||||
public var hotKeyConflicts: Set<HotKeyAction> = []
|
||||
/// 当前打开的菜单栏详情弹窗
|
||||
public var openPopover: MenuBarItem?
|
||||
public private(set) var launchAtLoginEnabled = SMAppService.mainApp.status == .enabled
|
||||
|
||||
@@ -390,6 +390,13 @@ public final class AppSettings {
|
||||
public var alertCPUTemperature: Int {
|
||||
didSet { defaults.set(alertCPUTemperature, forKey: Keys.alertCPUTemperature) }
|
||||
}
|
||||
/// 全局快捷键
|
||||
public var hotKeys: [HotKeyAction: HotKey] {
|
||||
didSet {
|
||||
let stored = Dictionary(uniqueKeysWithValues: hotKeys.map { ($0.key.rawValue, $0.value) })
|
||||
defaults.set(try? JSONEncoder().encode(stored), forKey: Keys.hotKeys)
|
||||
}
|
||||
}
|
||||
/// 每分钟把主要指标写入本机历史库
|
||||
public var historyEnabled: Bool {
|
||||
didSet { defaults.set(historyEnabled, forKey: Keys.historyEnabled) }
|
||||
@@ -443,6 +450,10 @@ public final class AppSettings {
|
||||
geoAutoUpdate = defaults.object(forKey: Keys.geoAutoUpdate) as? Bool ?? true
|
||||
autoCheckUpdates = defaults.object(forKey: Keys.autoCheckUpdates) as? Bool ?? true
|
||||
historyEnabled = defaults.object(forKey: Keys.historyEnabled) as? Bool ?? true
|
||||
let storedHotKeys = defaults.data(forKey: Keys.hotKeys).flatMap { try? JSONDecoder().decode([String: HotKey].self, from: $0) } ?? [:]
|
||||
hotKeys = Dictionary(uniqueKeysWithValues: storedHotKeys.compactMap { key, value in
|
||||
HotKeyAction(rawValue: key).map { ($0, value) }
|
||||
})
|
||||
enabledAlerts = Set(defaults.stringArray(forKey: Keys.enabledAlerts)?.compactMap(AlertKind.init(rawValue:)) ?? [])
|
||||
alertCPUTemperature = Self.alertTemperatureOptions.contains(defaults.integer(forKey: Keys.alertCPUTemperature))
|
||||
? defaults.integer(forKey: Keys.alertCPUTemperature) : 95
|
||||
@@ -498,6 +509,7 @@ public final class AppSettings {
|
||||
static let geoAutoUpdate = "geoAutoUpdate"
|
||||
static let autoCheckUpdates = "autoCheckUpdates"
|
||||
static let historyEnabled = "historyEnabled"
|
||||
static let hotKeys = "hotKeys"
|
||||
static let enabledAlerts = "enabledAlerts"
|
||||
static let alertCPUTemperature = "alertCPUTemperature"
|
||||
}
|
||||
|
||||
@@ -225,3 +225,28 @@ private func isolatedDefaults() -> UserDefaults {
|
||||
#expect(Set(AlertKind.allCases.map(\.tab)).isSubset(of: Set(PanelTab.allCases)))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct HotKeyTests {
|
||||
@Test func displaysAndValidates() {
|
||||
let hotKey = HotKey(keyCode: 1, modifiers: [.command, .option, .capsLock], key: "s")
|
||||
#expect(hotKey.display == "⌥⌘S")
|
||||
#expect(hotKey.isValid)
|
||||
#expect(!HotKey(keyCode: 1, modifiers: [.shift], key: "s").isValid)
|
||||
#expect(HotKey(keyCode: 49, modifiers: [.control], key: " ").display == "⌃空格")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func persistsBindings() {
|
||||
let defaults = isolatedDefaults()
|
||||
let settings = AppSettings(defaults: defaults)
|
||||
settings.hotKeys[.toggleKeepAwake] = HotKey(keyCode: 40, modifiers: [.command, .shift], key: "k")
|
||||
let reloaded = AppSettings(defaults: defaults)
|
||||
#expect(reloaded.hotKeys[.toggleKeepAwake]?.display == "⇧⌘K")
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct FunctionKeyTests {
|
||||
@Test func namesFunctionKeys() {
|
||||
#expect(HotKey(keyCode: 0x7A, modifiers: [.command], key: "\u{F704}").display == "⌘F1")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user