661 lines
28 KiB
Swift
661 lines
28 KiB
Swift
import AppKit
|
|
import Localization
|
|
import Metrics
|
|
import SwiftUI
|
|
|
|
/// 进程管理器:包括系统进程,可搜索、筛选、按列排序、按应用合并,选中后查看详情或结束进程
|
|
struct ProcessesPage: View {
|
|
enum Scope: String, CaseIterable {
|
|
case all, mine, system
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .all: tr("全部")
|
|
case .mine: tr("我的")
|
|
case .system: tr("系统")
|
|
}
|
|
}
|
|
}
|
|
|
|
enum Grouping: String, CaseIterable {
|
|
case processes, apps
|
|
|
|
var title: String { self == .processes ? tr("按进程") : tr("按应用") }
|
|
}
|
|
|
|
@Environment(AppModel.self) private var model
|
|
@Environment(\.isSnapshot) private var isSnapshot
|
|
@State private var search = ""
|
|
@State private var scope: Scope = .all
|
|
@State private var grouping: Grouping = .processes
|
|
@State private var sortColumn: ProcessColumn = .cpu
|
|
@State private var ascending = false
|
|
@State private var selection: String?
|
|
@State private var pendingQuit: ProcessRowModel?
|
|
@State private var actionError: String?
|
|
@State private var tableWidth: CGFloat = DS.Size.panelWidth
|
|
|
|
var body: some View {
|
|
let rows = visibleRows()
|
|
let selected = rows.first { $0.id == selection }
|
|
|
|
VStack(alignment: .leading, spacing: DS.Space.s3) {
|
|
if model.explainer.subject != nil {
|
|
ProcessExplanationCard()
|
|
}
|
|
toolbar
|
|
|
|
let columns = ProcessColumn.visible(forWidth: tableWidth)
|
|
Card(padding: 0, spacing: 0) {
|
|
ProcessHeader(columns: columns, sortColumn: sortColumn, ascending: ascending) { column in
|
|
if column == sortColumn { ascending.toggle() } else { sortColumn = column; ascending = column.ascendingByDefault }
|
|
}
|
|
HairlineDivider()
|
|
if rows.isEmpty {
|
|
Text(model.store.processes.isEmpty ? tr("正在读取进程…") : tr("没有匹配的进程"))
|
|
.dsFont(.sm)
|
|
.foregroundStyle(DS.Palette.textSecondary)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(DS.Space.s6)
|
|
} else if isSnapshot {
|
|
ForEach(rows.prefix(16)) { row in
|
|
ProcessTableRow(row: row, columns: columns, isSelected: false, sortColumn: sortColumn)
|
|
}
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(rows) { row in
|
|
ProcessTableRow(row: row, columns: columns, isSelected: row.id == selection, sortColumn: sortColumn)
|
|
.onTapGesture { selection = row.id }
|
|
.contextMenu { menu(for: row) }
|
|
}
|
|
}
|
|
.overlayScrollers()
|
|
}
|
|
.frame(maxHeight: .infinity)
|
|
}
|
|
}
|
|
.frame(maxHeight: isSnapshot ? nil : .infinity)
|
|
.background(GeometryReader { proxy in
|
|
Color.clear.preference(key: TableWidthKey.self, value: proxy.size.width)
|
|
})
|
|
.onPreferenceChange(TableWidthKey.self) { tableWidth = $0 }
|
|
|
|
if let selected {
|
|
ProcessInspector(row: selected, error: actionError,
|
|
onQuit: { pendingQuit = selected },
|
|
onExplain: { model.explainProcess(.init(selected.representative)) })
|
|
}
|
|
summary(count: rows.count)
|
|
}
|
|
// 顶部不留边距:主窗口顶栏已经在按钮上下留了对称的空
|
|
.padding([.horizontal, .bottom], DS.Space.s3)
|
|
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
|
|
.confirmationDialog(pendingQuit.map { tr("结束“\($0.title)”?") } ?? "", isPresented: Binding(get: { pendingQuit != nil }, set: { if !$0 { pendingQuit = nil } }),
|
|
titleVisibility: .visible, presenting: pendingQuit) { row in
|
|
Button(tr("退出")) { quit(row, force: false) }
|
|
Button(tr("强制退出"), role: .destructive) { quit(row, force: true) }
|
|
Button(tr("取消"), role: .cancel) {}
|
|
} message: { row in
|
|
Text(row.count > 1
|
|
? tr("会结束这个应用的 \(row.count) 个进程,未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。")
|
|
: tr("未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。"))
|
|
}
|
|
}
|
|
|
|
// MARK: 工具栏与汇总
|
|
|
|
private var toolbar: some View {
|
|
HStack(spacing: DS.Space.s3) {
|
|
HStack(spacing: DS.Space.s2) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
|
|
.foregroundStyle(DS.Palette.textTertiary)
|
|
TextField(tr("搜索名称、PID 或用户"), text: $search)
|
|
.textFieldStyle(.plain)
|
|
.dsFont(.sm)
|
|
if !search.isEmpty {
|
|
MiniIconButton(systemName: "xmark.circle.fill", help: tr("清除搜索")) { search = "" }
|
|
}
|
|
}
|
|
.padding(.horizontal, DS.Space.s2)
|
|
.frame(height: DS.Size.controlHeight)
|
|
.frame(maxWidth: DS.Size.sidebarWidth + DS.Space.s16)
|
|
.background(DS.Palette.elevated, in: RoundedRectangle(cornerRadius: DS.Radius.md))
|
|
.overlay(RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.neutral300, lineWidth: DS.Size.stroke))
|
|
|
|
SegmentedControl(selection: $scope, options: Scope.allCases.map { ($0, $0.title) })
|
|
.frame(width: DS.Size.sidebarWidth)
|
|
SegmentedControl(selection: $grouping, options: Grouping.allCases.map { ($0, $0.title) })
|
|
.frame(width: DS.Size.sidebarWidth - DS.Space.s8)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
|
|
private func summary(count: Int) -> some View {
|
|
let cpu = model.store.cpu
|
|
let counts = model.store.systemCounts
|
|
return HStack(spacing: DS.Space.s4) {
|
|
if let cpu {
|
|
LegendItem(color: DS.Palette.primary, label: tr("用户"), value: Format.percent(cpu.user))
|
|
LegendItem(color: DS.Palette.secondary, label: tr("系统"), value: Format.percent(cpu.system))
|
|
LegendItem(color: DS.Palette.track, label: tr("空闲"), value: Format.percent(max(0, 1 - cpu.total)))
|
|
}
|
|
Spacer(minLength: DS.Space.s2)
|
|
if let counts {
|
|
Text(verbatim: tr("进程 \(counts.processes.formatted()) · 线程 \(counts.threads.formatted())"))
|
|
.dsFont(.xs)
|
|
.monospacedDigit()
|
|
.foregroundStyle(DS.Palette.textSecondary)
|
|
}
|
|
Text(verbatim: tr("显示 \(count) 项")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
|
}
|
|
.padding(.horizontal, DS.Space.s1)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func menu(for row: ProcessRowModel) -> some View {
|
|
if ProcessExplainer.isSupported {
|
|
Button(tr("用 Apple 智能解释")) { model.explainProcess(.init(row.representative)) }
|
|
Divider()
|
|
}
|
|
if let path = row.bundlePath ?? row.representative.executablePath {
|
|
Button(tr("在访达中显示")) { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) }
|
|
}
|
|
if let pid = row.pid {
|
|
Button(tr("拷贝 PID")) {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(String(pid), forType: .string)
|
|
}
|
|
}
|
|
Divider()
|
|
Button(row.count > 1 ? tr("退出应用…") : tr("结束进程…")) { selection = row.id; pendingQuit = row }
|
|
.disabled(row.quitBlockedReason != nil)
|
|
}
|
|
|
|
// MARK: 数据
|
|
|
|
private func visibleRows() -> [ProcessRowModel] {
|
|
let me = getuid()
|
|
let filtered = model.store.processes.filter { process in
|
|
switch scope {
|
|
case .all: true
|
|
case .mine: process.uid == me
|
|
case .system: process.uid != me
|
|
}
|
|
}
|
|
var rows = grouping == .processes
|
|
? filtered.map(ProcessRowModel.init(process:))
|
|
: ProcessRowModel.grouped(filtered)
|
|
let query = search.trimmingCharacters(in: .whitespaces)
|
|
if !query.isEmpty {
|
|
rows = rows.filter { $0.matches(query) }
|
|
}
|
|
return rows.sorted { lhs, rhs in
|
|
let ordered = sortColumn.isOrdered(lhs, rhs)
|
|
return ascending ? ordered : sortColumn.isOrdered(rhs, lhs)
|
|
}
|
|
}
|
|
|
|
private func quit(_ row: ProcessRowModel, force: Bool) {
|
|
actionError = ProcessActions.terminate(row, force: force)
|
|
if actionError == nil { selection = nil }
|
|
}
|
|
}
|
|
|
|
// MARK: - 行模型
|
|
|
|
struct ProcessRowModel: Identifiable {
|
|
let id: String
|
|
let title: String
|
|
let subtitle: String
|
|
let bundlePath: String?
|
|
let cpu: Double
|
|
let cpuTime: Double
|
|
let memory: UInt64
|
|
let threads: Int?
|
|
let wakeups: Double?
|
|
let disk: (read: Double, write: Double)?
|
|
let user: String
|
|
let pid: Int32?
|
|
let count: Int
|
|
let isOwned: Bool
|
|
let representative: ProcessUsage
|
|
let processes: [ProcessUsage]
|
|
|
|
@MainActor
|
|
init(process: ProcessUsage) {
|
|
id = "pid-\(process.pid)"
|
|
title = process.displayName
|
|
subtitle = [process.helperRole, "PID \(process.pid)"].compactMap { $0 }.joined(separator: " · ")
|
|
bundlePath = process.appBundlePath
|
|
cpu = process.cpu
|
|
cpuTime = process.cpuTime
|
|
memory = process.memory
|
|
threads = process.threads
|
|
wakeups = process.idleWakeups
|
|
disk = process.diskRead.map { ($0, process.diskWrite ?? 0) }
|
|
user = process.userName
|
|
pid = process.pid
|
|
count = 1
|
|
isOwned = process.isOwned
|
|
representative = process
|
|
processes = [process]
|
|
}
|
|
|
|
private init(group: [ProcessUsage], key: String, title: String) {
|
|
let representative = group.max { $0.memory < $1.memory } ?? group[0]
|
|
id = "app-\(key)"
|
|
self.title = title
|
|
subtitle = group.count > 1 ? tr("\(group.count) 个进程") : "PID \(representative.pid)"
|
|
bundlePath = representative.appBundlePath
|
|
cpu = group.reduce(0) { $0 + $1.cpu }
|
|
cpuTime = group.reduce(0) { $0 + $1.cpuTime }
|
|
memory = group.reduce(0) { $0 + $1.memory }
|
|
let threadCounts = group.compactMap(\.threads)
|
|
threads = threadCounts.isEmpty ? nil : threadCounts.reduce(0, +)
|
|
let wakeupCounts = group.compactMap(\.idleWakeups)
|
|
wakeups = wakeupCounts.isEmpty ? nil : wakeupCounts.reduce(0, +)
|
|
let reads = group.compactMap(\.diskRead)
|
|
disk = reads.isEmpty ? nil : (reads.reduce(0, +), group.compactMap(\.diskWrite).reduce(0, +))
|
|
user = representative.userName
|
|
pid = group.count == 1 ? representative.pid : nil
|
|
count = group.count
|
|
isOwned = group.allSatisfy(\.isOwned)
|
|
self.representative = representative
|
|
processes = group
|
|
}
|
|
|
|
/// 同一应用的主进程与辅助进程合并;不属于应用的进程各自一行
|
|
@MainActor
|
|
static func grouped(_ processes: [ProcessUsage]) -> [ProcessRowModel] {
|
|
var buckets: [String: [ProcessUsage]] = [:]
|
|
for process in processes {
|
|
buckets[process.appBundlePath ?? "pid-\(process.pid)", default: []].append(process)
|
|
}
|
|
return buckets.map { key, group in
|
|
if let bundle = group[0].appBundlePath {
|
|
return ProcessRowModel(group: group, key: key, title: AppNameCache.shared.name(forBundle: bundle))
|
|
}
|
|
return ProcessRowModel(process: group[0])
|
|
}
|
|
}
|
|
|
|
func matches(_ query: String) -> Bool {
|
|
title.localizedCaseInsensitiveContains(query) || user.localizedCaseInsensitiveContains(query)
|
|
|| processes.contains { $0.name.localizedCaseInsensitiveContains(query) || String($0.pid) == query }
|
|
}
|
|
|
|
/// 不能从这里结束的原因;nil 表示可以结束
|
|
var quitBlockedReason: String? {
|
|
if !isOwned { return tr("系统或其他用户的进程,OpenStats 不提供结束") }
|
|
if processes.contains(where: { $0.pid == getpid() }) { return tr("请从菜单退出 OpenStats") }
|
|
if processes.contains(where: { ProcessActions.protectedNames.contains($0.name) }) { return tr("结束它会注销当前用户,已禁止") }
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private struct TableWidthKey: PreferenceKey {
|
|
static let defaultValue: CGFloat = DS.Size.panelWidth
|
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
|
|
}
|
|
|
|
enum ProcessColumn: CaseIterable {
|
|
case name, cpu, cpuTime, memory, threads, wakeups, disk, user
|
|
|
|
/// 窗口变窄时先隐藏 CPU 时间与唤醒,再窄只保留 CPU、内存与用户,保证名称列至少有 200pt
|
|
static func visible(forWidth width: CGFloat) -> [ProcessColumn] {
|
|
let tiers: [[ProcessColumn]] = [allCases, [.name, .cpu, .memory, .threads, .disk, .user], [.name, .cpu, .memory, .user]]
|
|
return tiers.first { tier in
|
|
let fixed = tier.compactMap(\.width).reduce(0, +) + DS.Space.s2 * CGFloat(tier.count - 1) + DS.Space.s4 * 2
|
|
return width - fixed >= DS.Space.s16 * 3 + DS.Space.s1 * 2
|
|
} ?? tiers[2]
|
|
}
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .name: tr("进程")
|
|
case .cpu: "CPU"
|
|
case .cpuTime: tr("CPU 时间")
|
|
case .memory: tr("内存")
|
|
case .threads: tr("线程")
|
|
case .wakeups: tr("唤醒")
|
|
case .disk: tr("磁盘")
|
|
case .user: tr("用户")
|
|
}
|
|
}
|
|
|
|
var width: CGFloat? {
|
|
switch self {
|
|
case .name: nil
|
|
case .cpu, .memory, .user: DS.Size.valueColumn
|
|
case .cpuTime, .disk: DS.Size.valueColumn + DS.Space.s4
|
|
case .threads, .wakeups: DS.Size.valueColumn
|
|
}
|
|
}
|
|
|
|
var help: String {
|
|
switch self {
|
|
case .cpu: tr("以单核满载为 100%")
|
|
case .cpuTime: tr("进程启动以来累计占用的 CPU 时间")
|
|
case .memory: tr("自己的进程为实际占用内存,系统进程为常驻内存")
|
|
case .threads: tr("线程数(只能读取自己的进程)")
|
|
case .wakeups: tr("每秒让 CPU 从空闲中唤醒的次数,越高越耗电")
|
|
case .disk: tr("每秒读写磁盘的字节数")
|
|
default: ""
|
|
}
|
|
}
|
|
|
|
var ascendingByDefault: Bool { self == .name || self == .user }
|
|
|
|
func isOrdered(_ lhs: ProcessRowModel, _ rhs: ProcessRowModel) -> Bool {
|
|
switch self {
|
|
case .name: lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending
|
|
case .cpu: lhs.cpu < rhs.cpu
|
|
case .cpuTime: lhs.cpuTime < rhs.cpuTime
|
|
case .memory: lhs.memory < rhs.memory
|
|
case .threads: (lhs.threads ?? -1) < (rhs.threads ?? -1)
|
|
case .wakeups: (lhs.wakeups ?? -1) < (rhs.wakeups ?? -1)
|
|
case .disk: (lhs.disk.map { $0.read + $0.write } ?? -1) < (rhs.disk.map { $0.read + $0.write } ?? -1)
|
|
case .user: lhs.user.localizedStandardCompare(rhs.user) == .orderedAscending
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 表格
|
|
|
|
private struct ProcessHeader: View {
|
|
let columns: [ProcessColumn]
|
|
let sortColumn: ProcessColumn
|
|
let ascending: Bool
|
|
let onSort: (ProcessColumn) -> Void
|
|
|
|
var body: some View {
|
|
HStack(spacing: DS.Space.s2) {
|
|
ForEach(columns, id: \.self) { column in
|
|
Button { onSort(column) } label: {
|
|
HStack(spacing: DS.Space.s1 / 2) {
|
|
if column != .name { Spacer(minLength: 0) }
|
|
Text(column.title).lineLimit(1)
|
|
if column == sortColumn {
|
|
Image(systemName: ascending ? "chevron.up" : "chevron.down")
|
|
.font(.system(size: DS.TextSize.xs.rawValue - DS.Space.s1, weight: .bold))
|
|
}
|
|
if column == .name { Spacer(minLength: 0) }
|
|
}
|
|
.foregroundStyle(column == sortColumn ? DS.Palette.primary : DS.Palette.textTertiary)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(column.help)
|
|
.frame(width: column.width)
|
|
.frame(maxWidth: column.width == nil ? .infinity : nil)
|
|
.padding(.leading, column == .name ? DS.Size.iconStandalone + DS.Space.s3 : 0)
|
|
}
|
|
}
|
|
.dsFont(.xs, weight: .medium)
|
|
.padding(.horizontal, DS.Space.s4)
|
|
.frame(height: DS.Size.controlHeight)
|
|
}
|
|
}
|
|
|
|
private struct ProcessTableRow: View {
|
|
let row: ProcessRowModel
|
|
let columns: [ProcessColumn]
|
|
let isSelected: Bool
|
|
let sortColumn: ProcessColumn
|
|
@State private var hovering = false
|
|
|
|
var body: some View {
|
|
HStack(spacing: DS.Space.s2) {
|
|
HStack(spacing: DS.Space.s3) {
|
|
AppIconCache.shared.image(bundlePath: row.bundlePath)
|
|
.resizable()
|
|
.frame(width: DS.Size.iconStandalone, height: DS.Size.iconStandalone)
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(verbatim: row.title).dsFont(.sm).foregroundStyle(DS.Palette.textPrimary).lineLimit(1)
|
|
Text(verbatim: row.subtitle).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).lineLimit(1)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
ForEach(columns.filter { $0 != .name }, id: \.self) { column in
|
|
cell(column, text(for: column))
|
|
}
|
|
}
|
|
.monospacedDigit()
|
|
.padding(.horizontal, DS.Space.s4)
|
|
.padding(.vertical, DS.Space.s1 + DS.Space.s1 / 2)
|
|
.background(isSelected ? DS.Palette.primary.opacity(0.14) : hovering ? DS.Palette.surfaceHover : .clear)
|
|
.contentShape(Rectangle())
|
|
.onHover { hovering = $0 }
|
|
}
|
|
|
|
private func text(for column: ProcessColumn) -> String {
|
|
switch column {
|
|
case .name: row.title
|
|
case .cpu: row.cpu < 0.0005 ? "0%" : "\((row.cpu * 100).formatted(.number.precision(.fractionLength(1))))%"
|
|
case .cpuTime: Format.cpuTime(row.cpuTime)
|
|
case .memory: Format.bytes(row.memory)
|
|
case .threads: row.threads.map(String.init) ?? "—"
|
|
case .wakeups: row.wakeups.map { String(Int($0.rounded())) } ?? "—"
|
|
case .disk: row.disk.map { Format.menuBarRate($0.read + $0.write) } ?? "—"
|
|
case .user: row.user
|
|
}
|
|
}
|
|
|
|
private func cell(_ column: ProcessColumn, _ text: String) -> some View {
|
|
Text(verbatim: text)
|
|
.dsFont(.xs, weight: column == sortColumn ? .semibold : .regular)
|
|
.foregroundStyle(column == sortColumn ? DS.Palette.textPrimary : DS.Palette.textSecondary)
|
|
.lineLimit(1)
|
|
.frame(width: column.width, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
/// 选中进程后的详情条:路径、PID、用户、线程、磁盘读写,以及解释与结束
|
|
private struct ProcessInspector: View {
|
|
let row: ProcessRowModel
|
|
let error: String?
|
|
let onQuit: () -> Void
|
|
let onExplain: () -> Void
|
|
|
|
var body: some View {
|
|
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
|
|
HStack(spacing: DS.Space.s3) {
|
|
AppIconCache.shared.image(bundlePath: row.bundlePath)
|
|
.resizable()
|
|
.frame(width: DS.Size.controlHeight, height: DS.Size.controlHeight)
|
|
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
|
|
Text(verbatim: row.title).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary).lineLimit(1)
|
|
Text(verbatim: row.bundlePath ?? row.representative.executablePath ?? tr("路径不可读"))
|
|
.dsFont(.xs)
|
|
.foregroundStyle(DS.Palette.textTertiary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.textSelection(.enabled)
|
|
Text(verbatim: details)
|
|
.dsFont(.xs)
|
|
.monospacedDigit()
|
|
.foregroundStyle(DS.Palette.textSecondary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer(minLength: DS.Space.s3)
|
|
if ProcessExplainer.isSupported {
|
|
Button { onExplain() } label: { Label(tr("Apple 智能解释"), systemImage: "sparkles") }
|
|
.buttonStyle(DSButtonStyle(kind: .secondary))
|
|
}
|
|
if let path = row.bundlePath ?? row.representative.executablePath {
|
|
IconButton(systemName: "folder", help: tr("在访达中显示")) {
|
|
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
|
|
}
|
|
}
|
|
Button(row.count > 1 ? tr("退出应用…") : tr("结束进程…")) { onQuit() }
|
|
.buttonStyle(DSButtonStyle(kind: .secondary))
|
|
.disabled(row.quitBlockedReason != nil)
|
|
.help(row.quitBlockedReason ?? "")
|
|
}
|
|
if let error {
|
|
Text(error).dsFont(.xs).foregroundStyle(DS.Palette.error)
|
|
} else if let reason = row.quitBlockedReason {
|
|
Text(reason).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
|
}
|
|
}
|
|
.frame(maxHeight: nil)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
private var details: String {
|
|
var parts = [row.pid.map { "PID \($0)" } ?? tr("\(row.count) 个进程"), tr("用户 \(row.user)")]
|
|
if let threads = row.threads { parts.append(tr("线程 \(threads)")) }
|
|
parts.append(tr("CPU 时间 \(Format.cpuTime(row.cpuTime))"))
|
|
if let disk = row.disk { parts.append(tr("读 \(Format.menuBarRate(disk.read)) · 写 \(Format.menuBarRate(disk.write))")) }
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
}
|
|
|
|
// MARK: - 结束进程
|
|
|
|
enum ProcessActions {
|
|
/// 结束会注销当前用户或导致系统异常的进程
|
|
static let protectedNames: Set<String> = ["loginwindow", "WindowServer", "launchd", "kernel_task"]
|
|
|
|
/// 返回错误描述,nil 表示已发出结束请求
|
|
@MainActor
|
|
static func terminate(_ row: ProcessRowModel, force: Bool) -> String? {
|
|
if let reason = row.quitBlockedReason { return reason }
|
|
// 应用优先按应用退出:辅助进程会随主进程一起结束,也给应用保存的机会
|
|
if let bundle = row.bundlePath,
|
|
let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleURL?.path == bundle }) {
|
|
let sent = force ? app.forceTerminate() : app.terminate()
|
|
return sent ? nil : tr("应用拒绝退出,可以试试强制退出")
|
|
}
|
|
for process in row.processes where kill(process.pid, force ? SIGKILL : SIGTERM) != 0 {
|
|
return tr("结束 \(process.name) 失败:\(String(cString: strerror(errno)))")
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// 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(tr("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: tr("重新生成")) { explainer.explain(subject) }
|
|
}
|
|
MiniIconButton(systemName: "xmark", help: tr("关闭")) { 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(tr("正在本机生成…")).dsFont(.sm).foregroundStyle(DS.Palette.textSecondary)
|
|
}
|
|
} else {
|
|
Text(explainer.text)
|
|
.dsFont(.sm)
|
|
.foregroundStyle(DS.Palette.textPrimary)
|
|
.textSelection(.enabled)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
Text(tr("由 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
|
|
}
|
|
}
|