全局快捷键

This commit is contained in:
gentpan committed 2026-09-13 08:51:09 +05:00
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")
}
}