From e19ea5377131290a00eabb9b77dd0cf67c492134 Mon Sep 17 00:00:00 2001 From: ysicing Date: Tue, 29 Sep 2026 22:13:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(processes):=20=E6=94=B9=E4=B8=BA=E5=8F=AF?= =?UTF-8?q?=E9=80=89=E6=A8=A1=E5=9D=97=E5=B9=B6=E6=94=AF=E6=8C=81=E6=B4=BB?= =?UTF-8?q?=E5=8A=A8=E7=9B=91=E8=A7=86=E5=99=A8=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 10 ++ .../Sources/Localization/Resources/ar.tsv | 4 + .../Sources/Localization/Resources/de.tsv | 4 + .../Sources/Localization/Resources/es.tsv | 4 + .../Sources/Localization/Resources/fr.tsv | 4 + .../Sources/Localization/Resources/ja.tsv | 4 + .../Sources/Localization/Resources/ko.tsv | 4 + .../Localization/Resources/zh-Hant.tsv | 4 + .../Sources/Localization/Translations.swift | 4 + .../XStatsUI/AppKit/AppController.swift | 7 +- .../XStatsUI/AppKit/HotKeyCenter.swift | 4 + .../XStatsUI/Panel/MainWindowView.swift | 13 +++ .../Sources/XStatsUI/Panel/OverviewPage.swift | 8 +- .../XStatsUI/Popover/MetricPopovers.swift | 2 +- .../XStatsUI/Settings/SettingsPages.swift | 18 ++- .../XStatsUI/Snapshot/SnapshotRenderer.swift | 1 + .../Sources/XStatsUI/State/AppModel.swift | 18 ++- .../Sources/XStatsUI/State/AppSettings.swift | 17 ++- .../XStatsUI/State/SettingsDocument.swift | 3 + .../XStatsUITests/ProcessFeatureTests.swift | 103 ++++++++++++++++++ .../Tests/XStatsUITests/XStatsUITests.swift | 1 + README.en.md | 1 + README.ja.md | 1 + README.ko.md | 1 + README.md | 1 + 25 files changed, 229 insertions(+), 12 deletions(-) create mode 100644 Packages/XStatsKit/Tests/XStatsUITests/ProcessFeatureTests.swift diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f3d1ae4..98986c6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,6 +13,16 @@ samples only that: CPU always; memory and network cheaply; GPU, disk, processes, fans only when a visible surface needs them. Disk is read at most every 30 s, battery every 10 s. The loop pauses on screen sleep, system sleep and session switch. +The full process manager is an optional module, disabled by default through `processesEnabled`. +Its sidebar entry and process-explanation shortcuts are available only when enabled. The process +hotkey remains registered: it opens XStats's process page when enabled, or macOS Activity Monitor +when disabled, without changing the module preference. Its settings label follows the destination; +the process-page header also provides a direct Activity Monitor button. +Disabling the module returns an open process page to General settings, cancels an explanation, +and removes full-system process sampling demand. Existing CPU, memory, disk and overview app-usage +lists retain their own visibility-based sampling. The preference is backed up; older documents without +it preserve the current setting. Saved process-page routes fall back to General settings when disabled. + The sandboxed WidgetKit extension samples CPU, memory, disk, and battery through `Metrics` independently, so it remains useful when the app is not running. AI, IP, and calendar widgets instead read a credential-free App Group snapshot. The main app diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 835a177..db55451 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -1615,3 +1615,7 @@ GitHub 项目 مشروع GitHub 国旗 العلم 地区文字 اسم المنطقة 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 يشارك نتيجة الموقع والتحديث مع تفاصيل الشبكة دون طلبات إضافية. يتطلب الاستعلام عن IP العام. +查看全部进程、搜索排序与结束进程,按需开启。 عرض جميع العمليات والبحث عنها وفرزها وإنهاؤها. فعّله عند الحاجة. +活动监视器 مراقب النشاط +打开系统活动监视器 فتح مراقب نشاط النظام +进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 يفتح صفحة عمليات XStats عند تفعيل الميزة، أو مراقب نشاط النظام عند إيقافها. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index 103acdc..1e456c7 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -1615,3 +1615,7 @@ GitHub 项目 GitHub-Projekt 国旗 Flagge 地区文字 Regionsname 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 Nutzt dasselbe Standortergebnis und dieselbe Aktualisierung wie die Netzwerkdetails, ohne zusätzliche Anfragen. Benötigt die öffentliche IP-Abfrage. +查看全部进程、搜索排序与结束进程,按需开启。 Alle Prozesse anzeigen, suchen, sortieren und beenden. Bei Bedarf aktivieren. +活动监视器 Aktivitätsanzeige +打开系统活动监视器 macOS-Aktivitätsanzeige öffnen +进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 Öffnet bei aktivierter Prozessfunktion die XStats-Prozessseite, andernfalls die macOS-Aktivitätsanzeige. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 89ac8d2..6738197 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -1615,3 +1615,7 @@ GitHub 项目 Proyecto en GitHub 国旗 Bandera 地区文字 Nombre de región 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 Comparte el resultado de ubicación y la actualización con los detalles de red, sin solicitudes adicionales. Requiere consultar la IP pública. +查看全部进程、搜索排序与结束进程,按需开启。 Consulta todos los procesos, busca, ordena y finaliza procesos. Actívalo cuando lo necesites. +活动监视器 Monitor de Actividad +打开系统活动监视器 Abrir el Monitor de Actividad de macOS +进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 Abre Procesos de XStats si la función está activada; si está desactivada, abre el Monitor de Actividad de macOS. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 6a08e00..9b46cd0 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -1615,3 +1615,7 @@ GitHub 项目 Projet GitHub 国旗 Drapeau 地区文字 Nom de région 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 Partage le résultat de localisation et l’actualisation des détails réseau, sans requête supplémentaire. Nécessite la recherche d’IP publique. +查看全部进程、搜索排序与结束进程,按需开启。 Affichez tous les processus, recherchez, triez et arrêtez-les. À activer selon vos besoins. +活动监视器 Moniteur d’activité +打开系统活动监视器 Ouvrir le Moniteur d’activité de macOS +进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 Ouvre les processus XStats si la fonction est activée, sinon le Moniteur d’activité de macOS. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 837235e..2088c13 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -1615,3 +1615,7 @@ GitHub 项目 GitHub プロジェクト 国旗 国旗 地区文字 地域名 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 ネットワーク詳細の IP 地域と照会結果・更新を共有します。追加通信は行いません。公開 IP の照会が必要です。 +查看全部进程、搜索排序与结束进程,按需开启。 全プロセスの表示、検索、並べ替え、終了ができます。必要なときに有効にしてください。 +活动监视器 アクティビティモニタ +打开系统活动监视器 システムのアクティビティモニタを開く +进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 プロセス機能がオンの場合は XStats のプロセスページを、オフの場合はシステムのアクティビティモニタを開きます。 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 959e65c..3da1c82 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -1615,3 +1615,7 @@ GitHub 项目 GitHub 프로젝트 国旗 국기 地区文字 지역 이름 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 네트워크 상세 정보의 IP 위치 조회 결과와 새로고침을 공유합니다. 추가 통신은 하지 않으며 공인 IP 조회가 필요합니다. +查看全部进程、搜索排序与结束进程,按需开启。 모든 프로세스를 보고 검색, 정렬하거나 종료합니다. 필요할 때 켜세요. +活动监视器 활성 상태 보기 +打开系统活动监视器 시스템 활성 상태 보기 열기 +进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 프로세스 기능이 켜져 있으면 XStats 프로세스 페이지를, 꺼져 있으면 시스템 활성 상태 보기를 엽니다. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 54308b7..05f2bc7 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -1616,3 +1616,7 @@ GitHub 项目 GitHub 專案 国旗 國旗 地区文字 地區文字 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 與網路詳情中的 IP 歸屬地共用查詢結果和重新整理,不額外連網。需開啟公網 IP 查詢。 +查看全部进程、搜索排序与结束进程,按需开启。 查看全部程序、搜尋排序與結束程序,按需啟用。 +活动监视器 活動監視器 +打开系统活动监视器 開啟系統活動監視器 +进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 程序功能開啟時開啟 XStats 程序頁,關閉時開啟系統活動監視器。 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index 1bf120c..88071c1 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -1424,5 +1424,9 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 国旗 Flag 地区文字 Region name 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 Shares the location result and refresh with network details; no extra requests. Requires public IP lookup. +查看全部进程、搜索排序与结束进程,按需开启。 View all processes, search, sort, and end processes. Enable when needed. +活动监视器 Activity Monitor +打开系统活动监视器 Open macOS Activity Monitor +进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 Opens XStats Processes when enabled, or macOS Activity Monitor when the feature is off. """# } diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index bacde97..b495251 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -186,7 +186,8 @@ public final class AppController: NSObject, NSApplicationDelegate { egressWindow.show() } // 开发调试:--explain-process <进程名> 打开进程页并用 Apple 智能解释该进程 - if let index = arguments.firstIndex(of: "--explain-process"), let name = arguments.dropFirst(index + 1).first { + if model.settings.processesEnabled, let index = arguments.firstIndex(of: "--explain-process"), + let name = arguments.dropFirst(index + 1).first { mainWindow.show(tab: .processes) DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in guard let self else { return } @@ -248,7 +249,7 @@ public final class AppController: NSObject, NSApplicationDelegate { if mainWindow.isVisible && NSApp.isActive { mainWindow.close() } else { mainWindow.show(tab: nil) } case .showProcesses: menuBar.dismissPopovers() - mainWindow.show(tab: .processes) + model.openProcessMonitor() case .toggleKeepAwake: Task { await model.keepAwake.setActive(!model.keepAwake.isActive) } case .purgeMemory: @@ -308,6 +309,7 @@ public final class AppController: NSObject, NSApplicationDelegate { _ = model.settings.menuBarLayout _ = model.settings.menuBarStyle _ = model.settings.networkStyle + _ = model.settings.processesEnabled _ = model.settings.networkLocationStyle _ = model.settings.publicIPLookup _ = model.network.publicAddresses?.countryCode @@ -328,6 +330,7 @@ public final class AppController: NSObject, NSApplicationDelegate { Task { @MainActor in guard let self else { return } self.applyLanguageIfChanged() + if !self.model.settings.processesEnabled { self.model.explainer.dismiss() } self.model.alerts.applySettings() await self.model.hub.update(self.model.demand) self.model.bluetooth.setDemand(self.model.bluetoothDemand) diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/HotKeyCenter.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/HotKeyCenter.swift index 36ad06e..bcedd6b 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/HotKeyCenter.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/HotKeyCenter.swift @@ -18,6 +18,10 @@ public enum HotKeyAction: String, CaseIterable, Identifiable, Codable, Sendable } } + func title(processesEnabled: Bool) -> String { + self == .showProcesses && !processesEnabled ? tr("打开系统活动监视器") : title + } + /// Carbon 热键编号,从 1 开始 var carbonID: UInt32 { UInt32(Self.allCases.firstIndex(of: self)! + 1) } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift index 8d99d5e..1e6dcb8 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift @@ -206,6 +206,7 @@ private struct MainSidebar: View { switch tab { case .rest: model.settings.restEnabled case .cleaner: model.settings.cleanerEnabled + case .processes: model.settings.processesEnabled default: true } }) @@ -341,6 +342,18 @@ private struct PageHeader: View { } } if tab == .memory { PurgeMemoryButton() } + if tab == .processes { + Button { + model.openActivityMonitor() + } label: { + Label(tr("活动监视器"), systemImage: "arrow.up.forward.app") + .lineLimit(1) + .fixedSize() + } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .help(tr("打开系统活动监视器")) + .accessibilityLabel(tr("打开系统活动监视器")) + } if tab == .cleaner { Button { model.openProjectPurgeWindow() } label: { HStack(spacing: DS.Space.s1) { diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/OverviewPage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/OverviewPage.swift index 1e40805..a664623 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/OverviewPage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/OverviewPage.swift @@ -475,11 +475,13 @@ private struct TopProcessesCard: View { NSPasteboard.general.clearContents() NSPasteboard.general.setString(String(process.pid), forType: .string) } - if ProcessExplainer.isSupported { + if model.settings.processesEnabled && ProcessExplainer.isSupported { Button(tr("用 Apple 智能解释")) { model.explainProcess(.init(process)) } } - Divider() - Button(tr("查看全部进程")) { model.settings.panelTab = .processes } + if model.settings.processesEnabled { + Divider() + Button(tr("查看全部进程")) { model.settings.panelTab = .processes } + } } label: { icon } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Popover/MetricPopovers.swift b/Packages/XStatsKit/Sources/XStatsUI/Popover/MetricPopovers.swift index 2534961..cbb97b6 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Popover/MetricPopovers.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Popover/MetricPopovers.swift @@ -602,7 +602,7 @@ private struct ExplainableRow: View { var body: some View { content .contentShape(Rectangle()) - .contextMenu(isSnapshot ? nil : ContextMenu { + .contextMenu(isSnapshot || !model.settings.processesEnabled ? nil : ContextMenu { Button(tr("用 Apple 智能解释")) { model.explainProcess(subject) } }) } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift index 6304e41..1c74b91 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift @@ -105,6 +105,13 @@ struct GeneralSettings: View { } SettingsGroup(caption: tr("可选功能")) { + GroupRow(showsDivider: false) { + SettingRow(title: tr("进程"), + subtitle: tr("查看全部进程、搜索排序与结束进程,按需开启。"), + icon: "list.bullet.rectangle") { + DSToggle(isOn: $settings.processesEnabled, label: tr("进程")) + } + } GroupRow(showsDivider: false) { SettingRow(title: tr("番茄钟与护眼休息"), subtitle: tr("专注计时、每日目标与多屏休息幕布。"), @@ -258,9 +265,16 @@ struct HotKeySettings: View { let settings = model.settings SettingsGroup(caption: tr("全局快捷键(在任何应用中都能使用,需要包含 ⌘、⌥ 或 ⌃)")) { ForEach(Array(HotKeyAction.allCases.enumerated()), id: \.element) { index, action in + let subtitle: String? = if model.hotKeyConflicts.contains(action) { + tr("这个快捷键已被其他应用或系统占用,请换一个") + } else if action == .showProcesses { + tr("进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。") + } else { + nil + } GroupRow(showsDivider: index > 0) { - SettingRow(title: action.title, - subtitle: model.hotKeyConflicts.contains(action) ? tr("这个快捷键已被其他应用或系统占用,请换一个") : nil) { + SettingRow(title: action.title(processesEnabled: settings.processesEnabled), + subtitle: subtitle) { ShortcutRecorder(hotKey: Binding(get: { settings.hotKeys[action] }, set: { settings.hotKeys[action] = $0 })) } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift index e3f15d7..edb3d40 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift @@ -29,6 +29,7 @@ enum SnapshotRenderer { settings.menuBarItems = [.cpu, .gpu, .memory, .network, .disk, .temperature, .battery, .aiUsage] settings.aiUsageEnabled = true settings.cleanerEnabled = true + settings.processesEnabled = true // 历史页用示例数据:最近 24 小时每分钟一条,中间留一段“睡眠”空档 let historyURL = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-snapshot-history.sqlite") try? FileManager.default.removeItem(at: historyURL) diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift index 16452af..0d59a7a 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift @@ -1,4 +1,5 @@ import AIUsage +import AppKit import Foundation import Metrics import Observation @@ -63,6 +64,9 @@ public final class AppModel { public private(set) var launchAtLoginError: String? @ObservationIgnored var openSettings: () -> Void = {} + @ObservationIgnored var openActivityMonitor: () -> Void = { + NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/Utilities/Activity Monitor.app")) + } @ObservationIgnored var openCalendar: () -> Void = {} @ObservationIgnored var restoreCalendarEntry: () -> Void = {} @ObservationIgnored var openAIUsageSettings: () -> Void = {} @@ -113,6 +117,7 @@ public final class AppModel { var demand = MetricsDemand() let tab = settings.panelTab let window = isMainWindowVisible + let processPage = settings.processesEnabled && window && tab == .processes let popover = openPopover let menu = settings.menuBarItems let thermalPopover = popover == .temperature || popover == .fan @@ -121,7 +126,7 @@ public final class AppModel { let liveWindow = window && [.overview, .system, .cpu, .gpu, .memory, .disk, .network, .thermal, .battery].contains(tab) // 进程页要读全系统进程(启动 ps),每 2 秒刷新一次足够,也更省电 - demand.interval = window && tab == .processes && popover == nil ? .seconds(2) + demand.interval = processPage && popover == nil ? .seconds(2) : liveWindow || popover != nil || isCombinedPopoverOpen ? .seconds(1) : .seconds(settings.refreshSeconds) demand.memory = true demand.network = true @@ -131,9 +136,9 @@ public final class AppModel { demand.diskDetail = showing(.disk, .disk) demand.battery = (window && [.overview, .system, .keepAwake].contains(tab)) || keepAwake.lidClosedActive || menu.contains(.battery) || showing(.battery, .battery) - demand.processes = (window && [.processes, .overview, .disk].contains(tab)) || showing(.cpu, .cpu) || showing(.memory, .memory) + demand.processes = processPage || (window && [.overview, .disk].contains(tab)) || showing(.cpu, .cpu) || showing(.memory, .memory) || popover == .disk - demand.systemProcesses = window && tab == .processes + demand.systemProcesses = processPage var groups = Set() if window && tab == .overview { groups.formUnion([.cpu, .gpu]) } @@ -206,8 +211,15 @@ public final class AppModel { } } + /// 进程快捷键始终可用;关闭内置模块时交给系统应用,不隐式开启模块。 + func openProcessMonitor() { + if settings.processesEnabled { openMainWindow(.processes) } + else { openActivityMonitor() } + } + /// 用 Apple 智能解释进程:结果显示在主窗口的进程页 func explainProcess(_ subject: ProcessExplainer.Subject) { + guard settings.processesEnabled else { return } settings.panelTab = .processes if !isMainWindowVisible { openMainWindow(.processes) } explainer.explain(subject) diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift index 93faaea..72f881b 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift @@ -541,6 +541,13 @@ public final class AppSettings { if !aiUsageEnabled && panelTab == .aiUsage { panelTab = .settingsGeneral } } } + /// 独立进程管理器默认关闭;其他监控页仍可按需读取应用用量。 + public var processesEnabled: Bool { + didSet { + defaults.set(processesEnabled, forKey: Keys.processesEnabled) + if !processesEnabled && panelTab == .processes { panelTab = .settingsGeneral } + } + } /// 清理工具默认关闭;扫描只在打开页面后按需执行。 public var cleanerEnabled: Bool { didSet { @@ -578,7 +585,11 @@ public final class AppSettings { didSet { defaults.set(fanSafetyTemperature, forKey: Keys.fanSafetyTemperature) } } public var panelTab: PanelTab { - didSet { defaults.set(panelTab.rawValue, forKey: Keys.panelTab) } + didSet { + // 统一拦截旧路由与外部打开请求,关闭的进程模块不能被其他入口重新打开。 + if panelTab == .processes && !processesEnabled { panelTab = .settingsGeneral } + defaults.set(panelTab.rawValue, forKey: Keys.panelTab) + } } public var appearance: AppearanceMode { didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) } @@ -753,6 +764,8 @@ public final class AppSettings { aiUsageEnabled = isAIUsageEnabled let isCleanerEnabled = defaults.bool(forKey: Keys.cleanerEnabled) cleanerEnabled = isCleanerEnabled + let isProcessesEnabled = defaults.bool(forKey: Keys.processesEnabled) + processesEnabled = isProcessesEnabled aiUsageSources = defaults.stringArray(forKey: Keys.aiUsageSources) .map { Set($0.compactMap(AIProviderID.init(rawValue:))) } ?? Set(AIProviderID.allCases) aiUsageShowsLocalUsage = defaults.object(forKey: Keys.aiUsageShowsLocalUsage) as? Bool ?? true @@ -772,6 +785,7 @@ public final class AppSettings { || (savedPanelTab == .aiUsage && !isAIUsageEnabled) || (savedPanelTab == .rest && !isRestEnabled) || (savedPanelTab == .cleaner && !isCleanerEnabled) + || (savedPanelTab == .processes && !isProcessesEnabled) ? .settingsGeneral : savedPanelTab appearance = defaults.string(forKey: Keys.appearance).flatMap(AppearanceMode.init(rawValue:)) ?? .system showDockIcon = defaults.bool(forKey: Keys.showDockIcon) @@ -867,6 +881,7 @@ public final class AppSettings { static let refreshSeconds = "refreshSeconds" static let aiUsageEnabled = "aiUsageEnabled" static let cleanerEnabled = "cleanerEnabled" + static let processesEnabled = "processesEnabled" static let aiUsageSources = "aiUsageSources" static let aiUsageShowsLocalUsage = "aiUsageShowsLocalUsage" static let aiUsageRefreshMinutes = "aiUsageRefreshMinutes" diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift index 6474aa3..0f8896a 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift @@ -59,6 +59,7 @@ public struct SettingsDocument: Codable, Equatable, Sendable { public var autoCheckUpdates: Bool? public var updateCheckSchedule: String? public var cleanerEnabled: Bool? + public var processesEnabled: Bool? public var cleanPrefersTrash: Bool? public init() {} @@ -111,6 +112,7 @@ extension AppSettings { doc.autoCheckUpdates = autoCheckUpdates doc.updateCheckSchedule = updateCheckSchedule.rawValue doc.cleanerEnabled = cleanerEnabled + doc.processesEnabled = processesEnabled doc.cleanPrefersTrash = cleanPrefersTrash return doc } @@ -184,6 +186,7 @@ extension AppSettings { assign(\.autoCheckUpdates, doc.autoCheckUpdates) } assign(\.cleanerEnabled, doc.cleanerEnabled) + assign(\.processesEnabled, doc.processesEnabled) assign(\.cleanPrefersTrash, doc.cleanPrefersTrash) } diff --git a/Packages/XStatsKit/Tests/XStatsUITests/ProcessFeatureTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/ProcessFeatureTests.swift new file mode 100644 index 0000000..455c2f1 --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/ProcessFeatureTests.swift @@ -0,0 +1,103 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AppKit +import Foundation +import Localization +import Testing +@testable import XStatsUI + +@MainActor +struct ProcessFeatureTests { + private func defaults() -> UserDefaults { + UserDefaults(suiteName: "ProcessFeatureTests.\(UUID())")! + } + + @Test func defaultsOffAndRejectsSavedOrDirectProcessRoutes() { + let defaults = defaults() + defaults.set("processes", forKey: "panelTab") + let settings = AppSettings(defaults: defaults) + #expect(!settings.processesEnabled) + #expect(settings.panelTab == .settingsGeneral) + settings.panelTab = .processes + #expect(settings.panelTab == .settingsGeneral) + #expect(defaults.string(forKey: "panelTab") == "settingsGeneral") + settings.processesEnabled = true + settings.panelTab = .processes + let restored = AppSettings(defaults: defaults) + #expect(restored.processesEnabled && restored.panelTab == .processes) + settings.processesEnabled = false + #expect(settings.panelTab == .settingsGeneral) + #expect(!AppSettings(defaults: defaults).processesEnabled) + } + + @Test func disablingProcessPageStopsSystemSamplingWithoutDisablingCPUDetails() { + let settings = AppSettings(defaults: defaults()) + let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: []) + model.isMainWindowVisible = true + settings.processesEnabled = true + settings.panelTab = .processes + #expect(model.demand.processes && model.demand.systemProcesses) + settings.processesEnabled = false + #expect(!model.demand.processes && !model.demand.systemProcesses) + model.openPopover = .cpu + #expect(model.demand.processes && !model.demand.systemProcesses) + model.openPopover = nil + settings.processesEnabled = true + settings.panelTab = .processes + model.isMainWindowVisible = false + #expect(!model.demand.processes && !model.demand.systemProcesses) + } + + @Test func shortcutKeepsItsBindingAndRoutesToTheEnabledDestination() { + let settings = AppSettings(defaults: defaults()) + let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: []) + var systemLaunches = 0 + var pages: [PanelTab?] = [] + model.openActivityMonitor = { systemLaunches += 1 } + model.openMainWindow = { pages.append($0) } + let key = HotKey(keyCode: 35, modifiers: [.command, .option], key: "p") + settings.hotKeys[.showProcesses] = key + let initialPage = settings.panelTab + model.openProcessMonitor() + #expect(systemLaunches == 1 && pages.isEmpty) + #expect(!settings.processesEnabled && settings.panelTab == initialPage) + #expect(HotKeyAction.showProcesses.title(processesEnabled: false) == tr("打开系统活动监视器")) + settings.processesEnabled = true + model.openProcessMonitor() + #expect(systemLaunches == 1 && pages == [.processes]) + #expect(HotKeyAction.showProcesses.title(processesEnabled: true) == tr("打开进程页")) + settings.processesEnabled = false + model.openProcessMonitor() + #expect(systemLaunches == 2 && pages == [.processes]) + #expect(settings.hotKeys[.showProcesses] == key) + } + + @Test func disabledFeatureDoesNotStartAnExplanationOrOpenItsPage() { + let settings = AppSettings(defaults: defaults()) + let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: []) + var opened = false + model.openMainWindow = { _ in opened = true } + model.explainProcess(.init(pid: 42, name: "fixture", displayName: "fixture", executablePath: nil, + appBundlePath: nil, cpu: nil, memory: nil, networkRate: nil)) + #expect(!opened && settings.panelTab != .processes) + #expect(model.explainer.subject == nil && model.explainer.phase == .idle) + } + + @Test func backupRoundTripsPreferenceAndOldDocumentsPreserveIt() throws { + let source = AppSettings(defaults: defaults()) + source.processesEnabled = true + let data = try JSONEncoder().encode(source.exportDocument()) + let decoded = try JSONDecoder().decode(SettingsDocument.self, from: data) + let target = AppSettings(defaults: defaults()) + target.apply(decoded) + #expect(target.processesEnabled) + target.apply(try JSONDecoder().decode(SettingsDocument.self, from: Data("{}".utf8))) + #expect(target.processesEnabled) + var disabled = SettingsDocument() + disabled.processesEnabled = false + target.panelTab = .processes + target.apply(disabled) + #expect(!target.processesEnabled && target.panelTab == .settingsGeneral) + } +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift index 19bc5e0..1d53453 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift @@ -451,6 +451,7 @@ private func isolatedDefaults() -> UserDefaults { @Test func processesPageRefreshesEveryTwoSeconds() { let model = model() + model.settings.processesEnabled = true model.isMainWindowVisible = true model.settings.panelTab = .processes #expect(model.demand.interval == .seconds(2)) diff --git a/README.en.md b/README.en.md index eafb3c8..5f5e928 100644 --- a/README.en.md +++ b/README.en.md @@ -55,6 +55,7 @@ brew install --cask xstats **Optional modules**, off by default: +- Processes: view all processes, search, sort, group by app, and end processes. - Menu bar calendar: Chinese lunar calendar, public holidays and make-up workdays, almanac, plus your calendar events and reminders. - Pomodoro and eye-rest breaks: a break screen on every display that you can skip, pause or shrink to a mini HUD at any time. - AI usage and quotas: local Codex / Claude Code token usage and subscription quota checks. diff --git a/README.ja.md b/README.ja.md index d72bbc8..9016a14 100644 --- a/README.ja.md +++ b/README.ja.md @@ -55,6 +55,7 @@ brew install --cask xstats **オプションモジュール**(初期状態はオフ): +- プロセス:全プロセスの表示、検索、並べ替え、アプリごとのグループ化、終了に対応します。 - メニューバーカレンダー:旧暦、中国の祝日と振替出勤日、暦注に加え、カレンダーの予定とリマインダーも表示します。 - ポモドーロと目の休憩:すべてのディスプレイに休憩画面を表示し、いつでもスキップ、一時停止、ミニ HUD への縮小ができます。 - AI の使用量と利用枠:Codex / Claude Code のローカルのトークン使用量とサブスクリプション利用枠を確認します。 diff --git a/README.ko.md b/README.ko.md index e7ef491..40d709c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -55,6 +55,7 @@ brew install --cask xstats **선택 모듈**(기본값 꺼짐): +- 프로세스: 모든 프로세스를 보고 검색, 정렬, 앱별 그룹화 및 종료를 할 수 있습니다. - 메뉴 막대 달력: 음력, 중국 공휴일과 대체 근무일, 역서에 더해 캘린더 일정과 미리 알림도 표시합니다. - 뽀모도로와 눈 휴식: 모든 디스플레이에 휴식 화면을 띄우며 언제든 건너뛰거나 일시 정지하거나 미니 HUD로 줄일 수 있습니다. - AI 사용량 및 한도: Codex / Claude Code의 로컬 토큰 사용량과 구독 한도를 확인합니다. diff --git a/README.md b/README.md index e454b5d..025a793 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ brew install --cask xstats **可选模块**,默认关闭,按需打开: +- 进程:查看全部进程,支持搜索、排序、按应用分组与结束进程。 - 菜单栏日历:农历、节假日与调休、黄历,可显示系统日程和提醒事项。 - 番茄钟与护眼休息:多屏休息幕布,可随时跳过、暂停或收起到迷你 HUD。 - AI 用量与额度:统计 Codex / Claude Code 本机 Token 用量,查询订阅额度。