diff --git a/CHANGELOG.md b/CHANGELOG.md index 944a426..05466f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - 启动项(主窗口工具分组):列出当前用户、所有用户的 LaunchAgents 和系统 LaunchDaemons,显示所属应用、可执行文件、是否登录时运行 / 保持运行,以及运行中(PID)、已加载、已停用状态;当前用户的启动项可以直接停用或重新启用(写入 launchd 停用记录并卸载,不删除文件),其余只读并提供登录项设置入口。 - 历史(主窗口监控分组):每分钟把 CPU 平均与峰值、内存与内存压力、网络上下行、GPU、CPU 最高温度和整机功耗写入本机 SQLite 数据库,保留 7 天;可回看最近 1 小时 / 24 小时 / 7 天,睡眠等没有记录的时段线条断开,内存压力严重的时段标红,鼠标移到图上查看那一刻的数值;可关闭记录或清除历史,数据不上传。 - 蓝牙设备电量:本机信息页列出已连接的键盘、鼠标、触控板和耳机的电量(AirPods 分左耳、右耳、充电盒),低于 20% 标红;通知里新增“蓝牙设备电量低”(低于 15%)。 +- 全局快捷键(设置 · 通用):可为显示 / 隐藏主窗口、打开进程页、开关防休眠、释放内存录制快捷键,在任何应用中都能用,不需要辅助功能权限;被其他应用占用时提示更换。 #### 调整 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index 3201927..ad803f2 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -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 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/HotKeyCenter.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/HotKeyCenter.swift new file mode 100644 index 0000000..3238f10 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/HotKeyCenter.swift @@ -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 = [] + + 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.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) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainWindowController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainWindowController.swift index 0803cfb..185af72 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainWindowController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MainWindowController.swift @@ -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() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift index 20a7e6a..1b2bacc 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift @@ -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 { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index f5dae56..33131a5 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -27,6 +27,8 @@ public final class AppModel { @ObservationIgnored public let hub = MetricsHub() public var isMainWindowVisible = false + /// 被其他应用占用、没能注册的快捷键 + public var hotKeyConflicts: Set = [] /// 当前打开的菜单栏详情弹窗 public var openPopover: MenuBarItem? public private(set) var launchAtLoginEnabled = SMAppService.mainApp.status == .enabled diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index 64979db..8e427de 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -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" } diff --git a/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift b/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift index e231d88..6ce8b00 100644 --- a/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift +++ b/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift @@ -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") + } +}