- CPU:温度 / 占用 / 负载三个圆环,负载历史面积图,核心按类型分色,各类核心平均占用与 1 / 5 / 15 分钟平均负载 - 内存:压力仪表与按 App / 联动 / 压缩分段的圆环,补充已用、可用与交换区 - 进程右键或进程页星形按钮调用系统自带的本机大模型解释进程,附带路径、所属应用与签名方,不联网 - 进程显示本地化应用名,详情弹窗进程列表 8 行;修复截图模式下短页面卡片被拉高 - 更新 README 截图与说明,更新日志同步到官网
268 lines
11 KiB
Swift
268 lines
11 KiB
Swift
import AppKit
|
|
import Metrics
|
|
import SwiftUI
|
|
|
|
struct ProcessesPage: View {
|
|
enum Sort: String, CaseIterable {
|
|
case cpu, memory
|
|
|
|
var title: String { self == .cpu ? "按 CPU" : "按内存" }
|
|
}
|
|
|
|
@Environment(AppModel.self) private var model
|
|
@State private var sort: Sort = .cpu
|
|
private let visibleCount = 15
|
|
|
|
var body: some View {
|
|
let processes = sorted(model.store.processes)
|
|
|
|
PageScroll {
|
|
if model.explainer.subject != nil {
|
|
ProcessExplanationCard()
|
|
}
|
|
HStack {
|
|
SegmentedControl(selection: $sort, options: Sort.allCases.map { ($0, $0.title) })
|
|
.frame(width: DS.Size.tileWidth)
|
|
Spacer()
|
|
Text(verbatim: "占用最高的 \(visibleCount) 个")
|
|
.dsFont(.xs)
|
|
.foregroundStyle(DS.Palette.textTertiary)
|
|
}
|
|
|
|
Card(padding: 0, spacing: 0) {
|
|
HStack(spacing: DS.Space.s3) {
|
|
Text("进程")
|
|
Spacer()
|
|
Text("CPU").frame(width: DS.Size.valueColumn, alignment: .trailing)
|
|
Text("内存").frame(width: DS.Size.valueColumn, alignment: .trailing)
|
|
}
|
|
.dsFont(.xs, weight: .medium)
|
|
.foregroundStyle(DS.Palette.textTertiary)
|
|
.padding(.horizontal, DS.Space.s4)
|
|
.padding(.vertical, DS.Space.s2)
|
|
|
|
if processes.isEmpty {
|
|
HairlineDivider()
|
|
Text("正在读取进程…")
|
|
.dsFont(.sm)
|
|
.foregroundStyle(DS.Palette.textSecondary)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(DS.Space.s6)
|
|
} else {
|
|
ForEach(processes.prefix(visibleCount)) { process in
|
|
HairlineDivider()
|
|
ProcessRow(process: process, emphasis: sort)
|
|
}
|
|
}
|
|
}
|
|
|
|
Text("仅列出当前用户有权限读取的进程,CPU 以单核满载为 100%。")
|
|
.dsFont(.xs)
|
|
.foregroundStyle(DS.Palette.textTertiary)
|
|
}
|
|
}
|
|
|
|
private func sorted(_ processes: [ProcessUsage]) -> [ProcessUsage] {
|
|
switch sort {
|
|
case .cpu: processes
|
|
case .memory: processes.sorted { $0.memory > $1.memory }
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ProcessRow: View {
|
|
let process: ProcessUsage
|
|
let emphasis: ProcessesPage.Sort
|
|
@State private var hovering = false
|
|
@Environment(\.isSnapshot) private var isSnapshot
|
|
@Environment(AppModel.self) private var model
|
|
|
|
var body: some View {
|
|
HStack(spacing: DS.Space.s3) {
|
|
AppIconCache.shared.image(for: process)
|
|
.resizable()
|
|
.frame(width: DS.Size.iconStandalone, height: DS.Size.iconStandalone)
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(title)
|
|
.dsFont(.sm)
|
|
.foregroundStyle(DS.Palette.textPrimary)
|
|
.lineLimit(1)
|
|
Text(subtitle)
|
|
.dsFont(.xs)
|
|
.foregroundStyle(DS.Palette.textTertiary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer(minLength: DS.Space.s2)
|
|
Text(cpuText)
|
|
.dsFont(.sm, weight: emphasis == .cpu ? .semibold : .regular)
|
|
.foregroundStyle(emphasis == .cpu ? DS.Palette.textPrimary : DS.Palette.textSecondary)
|
|
.frame(width: DS.Size.valueColumn, alignment: .trailing)
|
|
Text(Format.bytes(process.memory))
|
|
.dsFont(.sm, weight: emphasis == .memory ? .semibold : .regular)
|
|
.foregroundStyle(emphasis == .memory ? DS.Palette.textPrimary : DS.Palette.textSecondary)
|
|
.frame(width: DS.Size.valueColumn, alignment: .trailing)
|
|
if ProcessExplainer.isSupported {
|
|
MiniIconButton(systemName: "sparkles", help: "用 Apple 智能解释这个进程") {
|
|
model.explainProcess(.init(process))
|
|
}
|
|
.opacity(hovering || isSnapshot ? 1 : 0)
|
|
}
|
|
}
|
|
.monospacedDigit()
|
|
.padding(.horizontal, DS.Space.s4)
|
|
.padding(.vertical, DS.Space.s2)
|
|
.background(hovering ? DS.Palette.surfaceHover : .clear)
|
|
.onHover { hovering = $0 }
|
|
.contextMenu(isSnapshot ? nil : ContextMenu {
|
|
if let path = process.appBundlePath ?? process.executablePath {
|
|
Button("在访达中显示") {
|
|
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
|
|
}
|
|
}
|
|
Button("拷贝 PID") {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(String(process.pid), forType: .string)
|
|
}
|
|
if ProcessExplainer.isSupported {
|
|
Divider()
|
|
Button("用 Apple 智能解释") { model.explainProcess(.init(process)) }
|
|
}
|
|
})
|
|
}
|
|
|
|
private var title: String { process.displayName }
|
|
|
|
private var subtitle: String {
|
|
let pid = "PID \(process.pid)"
|
|
guard let role = process.helperRole else { return pid }
|
|
return "\(role) · \(pid)"
|
|
}
|
|
|
|
private var cpuText: String {
|
|
let percent = process.cpu * 100
|
|
return percent < 0.05 ? "0%" : "\(percent.formatted(.number.precision(.fractionLength(1))))%"
|
|
}
|
|
}
|
|
|
|
/// Apple 智能对某个进程的解释,流式显示
|
|
private struct ProcessExplanationCard: View {
|
|
@Environment(AppModel.self) private var model
|
|
|
|
var body: some View {
|
|
let explainer = model.explainer
|
|
if let subject = explainer.subject {
|
|
Card(padding: DS.Space.s4, spacing: DS.Space.s3) {
|
|
HStack(spacing: DS.Space.s2) {
|
|
Image(systemName: "sparkles")
|
|
.font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold))
|
|
.foregroundStyle(DS.Palette.primary)
|
|
Text("Apple 智能解释").dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
|
|
Text(verbatim: "\(subject.displayName) · PID \(subject.pid)")
|
|
.dsFont(.xs)
|
|
.foregroundStyle(DS.Palette.textSecondary)
|
|
.lineLimit(1)
|
|
Spacer(minLength: DS.Space.s2)
|
|
if explainer.phase == .done {
|
|
MiniIconButton(systemName: "arrow.clockwise", help: "重新生成") { explainer.explain(subject) }
|
|
}
|
|
MiniIconButton(systemName: "xmark", help: "关闭") { explainer.dismiss() }
|
|
}
|
|
|
|
switch explainer.phase {
|
|
case .failed(let message):
|
|
InfoBanner(icon: "exclamationmark.triangle.fill", text: message, tone: .warning)
|
|
default:
|
|
if explainer.text.isEmpty {
|
|
HStack(spacing: DS.Space.s2) {
|
|
ProgressView().controlSize(.small)
|
|
Text("正在本机生成…").dsFont(.sm).foregroundStyle(DS.Palette.textSecondary)
|
|
}
|
|
} else {
|
|
Text(explainer.text)
|
|
.dsFont(.sm)
|
|
.foregroundStyle(DS.Palette.textPrimary)
|
|
.textSelection(.enabled)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
Text("由 Apple 智能在这台 Mac 上生成,不联网;内容可能不准确,结束进程前请自行确认。")
|
|
.dsFont(.xs)
|
|
.foregroundStyle(DS.Palette.textTertiary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 进程图标缓存:只保留 40px 小位图,避免每次刷新解码系统图标的全部分辨率
|
|
@MainActor
|
|
final class AppIconCache {
|
|
static let shared = AppIconCache()
|
|
private static let pixelSize: CGFloat = DS.Size.iconStandalone * 2
|
|
private static let limit = 128
|
|
private var cache: [String: CGImage] = [:]
|
|
|
|
func image(for process: ProcessUsage) -> Image {
|
|
image(bundlePath: process.appBundlePath)
|
|
}
|
|
|
|
func image(bundlePath: String?) -> Image {
|
|
let key = bundlePath ?? "unix-executable"
|
|
if let cached = cache[key] { return Image(decorative: cached, scale: 2) }
|
|
|
|
let icon = bundlePath.map { NSWorkspace.shared.icon(forFile: $0) }
|
|
?? NSWorkspace.shared.icon(for: .unixExecutable)
|
|
var rect = NSRect(x: 0, y: 0, width: Self.pixelSize, height: Self.pixelSize)
|
|
guard let cgImage = icon.cgImage(forProposedRect: &rect, context: nil, hints: nil) else {
|
|
return Image(systemName: "app")
|
|
}
|
|
if cache.count >= Self.limit { cache.removeAll() }
|
|
cache[key] = cgImage
|
|
return Image(decorative: cgImage, scale: 2)
|
|
}
|
|
}
|
|
|
|
/// 应用的本地化显示名(“WeChat.app”显示为“微信”),按包路径缓存
|
|
final class AppNameCache: @unchecked Sendable {
|
|
static let shared = AppNameCache()
|
|
private let lock = NSLock()
|
|
private var names: [String: String] = [:]
|
|
|
|
func name(forBundle path: String) -> String {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
if let cached = names[path] { return cached }
|
|
var name = FileManager.default.displayName(atPath: path)
|
|
if name.hasSuffix(".app") { name = String(name.dropLast(4)) }
|
|
if names.count > 256 { names.removeAll() }
|
|
names[path] = name
|
|
return name
|
|
}
|
|
}
|
|
|
|
extension NetworkProcessUsage {
|
|
var localizedName: String {
|
|
appBundlePath.map { AppNameCache.shared.name(forBundle: $0) } ?? name
|
|
}
|
|
}
|
|
|
|
extension ProcessUsage {
|
|
private var appName: String? {
|
|
appBundlePath.map { URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent }
|
|
}
|
|
|
|
/// 应用本身及其辅助进程都显示为应用的本地化名称
|
|
var displayName: String {
|
|
guard let appName, let appBundlePath, name.hasPrefix(appName) else { return name }
|
|
return AppNameCache.shared.name(forBundle: appBundlePath)
|
|
}
|
|
|
|
/// 辅助进程的角色,例如 “Helper (Renderer)”
|
|
var helperRole: String? {
|
|
guard let appName, name.hasPrefix(appName), name != appName else { return nil }
|
|
let role = name.dropFirst(appName.count).trimmingCharacters(in: .whitespaces)
|
|
return role.isEmpty ? nil : role
|
|
}
|
|
}
|