Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ProcessesPage.swift
T
gentpan 23e4a8b8e0 设置并入主窗口:侧边栏新增设置分组,网络、风扇、合盖设置放到对应页面
- 侧边栏“设置”分组:通用、菜单栏、辅助工具、关于;导航较多时侧边栏可滚动
- 网络设置(连接探测、公网 IP、IP 归属地数据库)放在网络页底部,风扇安全温度放在温度与风扇页,合盖电量下限放在防休眠页
- 弹窗齿轮、菜单栏右键“设置…”与 ⌘, 打开主窗口的通用设置;删除独立设置窗口,偏好设置不变
- 去掉不再使用的设置窗口尺寸 token,侧边栏宽度改名为 sidebarWidth;截图渲染改为按页面输出
2026-09-13 07:04:14 +05:00

659 lines
28 KiB
Swift

import AppKit
import Metrics
import SwiftUI
/// 进程管理器:包括系统进程,可搜索、筛选、按列排序、按应用合并,选中后查看详情或结束进程
struct ProcessesPage: View {
enum Scope: String, CaseIterable {
case all, mine, system
var title: String {
switch self {
case .all: "全部"
case .mine: "我的"
case .system: "系统"
}
}
}
enum Grouping: String, CaseIterable {
case processes, apps
var title: String { self == .processes ? "按进程" : "按应用" }
}
@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 ? "正在读取进程…" : "没有匹配的进程")
.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(DS.Space.s3)
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
.confirmationDialog(pendingQuit.map { "结束“\($0.title)”?" } ?? "", isPresented: Binding(get: { pendingQuit != nil }, set: { if !$0 { pendingQuit = nil } }),
titleVisibility: .visible, presenting: pendingQuit) { row in
Button("退出") { quit(row, force: false) }
Button("强制退出", role: .destructive) { quit(row, force: true) }
Button("取消", role: .cancel) {}
} message: { row in
Text(row.count > 1
? "会结束这个应用的 \(row.count) 个进程,未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。"
: "未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。")
}
}
// 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("搜索名称、PID 或用户", text: $search)
.textFieldStyle(.plain)
.dsFont(.sm)
if !search.isEmpty {
MiniIconButton(systemName: "xmark.circle.fill", help: "清除搜索") { 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: "用户", value: Format.percent(cpu.user))
LegendItem(color: DS.Palette.secondary, label: "系统", value: Format.percent(cpu.system))
LegendItem(color: DS.Palette.track, label: "空闲", value: Format.percent(max(0, 1 - cpu.total)))
}
Spacer(minLength: DS.Space.s2)
if let counts {
Text(verbatim: "进程 \(counts.processes.formatted()) · 线程 \(counts.threads.formatted())")
.dsFont(.xs)
.monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary)
}
Text(verbatim: "显示 \(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("用 Apple 智能解释") { model.explainProcess(.init(row.representative)) }
Divider()
}
if let path = row.bundlePath ?? row.representative.executablePath {
Button("在访达中显示") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) }
}
if let pid = row.pid {
Button("拷贝 PID") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(String(pid), forType: .string)
}
}
Divider()
Button(row.count > 1 ? "退出应用…" : "结束进程…") { 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 ? "\(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 "系统或其他用户的进程,OpenStats 不提供结束" }
if processes.contains(where: { $0.pid == getpid() }) { return "请从菜单退出 OpenStats" }
if processes.contains(where: { ProcessActions.protectedNames.contains($0.name) }) { return "结束它会注销当前用户,已禁止" }
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: "进程"
case .cpu: "CPU"
case .cpuTime: "CPU 时间"
case .memory: "内存"
case .threads: "线程"
case .wakeups: "唤醒"
case .disk: "磁盘"
case .user: "用户"
}
}
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.labelColumn
}
}
var help: String {
switch self {
case .cpu: "以单核满载为 100%"
case .cpuTime: "进程启动以来累计占用的 CPU 时间"
case .memory: "自己的进程为实际占用内存,系统进程为常驻内存"
case .threads: "线程数(只能读取自己的进程)"
case .wakeups: "每秒让 CPU 从空闲中唤醒的次数,越高越耗电"
case .disk: "每秒读写磁盘的字节数"
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 ?? "路径不可读")
.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("Apple 智能解释", systemImage: "sparkles") }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
if let path = row.bundlePath ?? row.representative.executablePath {
IconButton(systemName: "folder", help: "在访达中显示") {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
}
}
Button(row.count > 1 ? "退出应用…" : "结束进程…") { 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)" } ?? "\(row.count) 个进程", "用户 \(row.user)"]
if let threads = row.threads { parts.append("线程 \(threads)") }
parts.append("CPU 时间 \(Format.cpuTime(row.cpuTime))")
if let disk = row.disk { parts.append("读 \(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 : "应用拒绝退出,可以试试强制退出"
}
for process in row.processes where kill(process.pid, force ? SIGKILL : SIGTERM) != 0 {
return "结束 \(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("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
}
}