- 菜单栏可每项独立(点击弹出该项窄详情)或合并为一个(点击打开主窗口),详情区块可在设置中勾选 - 网络详情:镜像流量历史、ICMP 连接探测格子、接口与 Wi-Fi、本地与公网 IPv4 / IPv6、SVG 国旗归属地与 ASN、各进程流量 - DNS:刷新缓存,一键切换自动 / Cloudflare / Google / 腾讯 / 阿里云或手动填写;辅助工具协议升至 3,服务名与地址二次校验 - 主窗口:侧边栏切换仪表盘、各指标详情与工具页,宽高可调,卡片按比例伸缩 - 统一外观:白底蓝 / 黑底蓝,窗口、设置与弹窗共用底色,去掉玻璃效果 - 新增网络解析、DNS 校验与国旗代码校验的单元测试;更新 README、架构文档与隐私说明
179 lines
6.6 KiB
Swift
179 lines
6.6 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 {
|
|
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
|
|
|
|
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)
|
|
}
|
|
.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)
|
|
}
|
|
})
|
|
}
|
|
|
|
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))))%"
|
|
}
|
|
}
|
|
|
|
/// 进程图标缓存:只保留 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)
|
|
}
|
|
}
|
|
|
|
extension ProcessUsage {
|
|
private var appName: String? {
|
|
appBundlePath.map { URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent }
|
|
}
|
|
|
|
/// 应用的辅助进程显示为“主应用名”
|
|
var displayName: String {
|
|
guard let appName, name.hasPrefix(appName), name != appName else { return name }
|
|
return appName
|
|
}
|
|
|
|
/// 辅助进程的角色,例如 “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
|
|
}
|
|
}
|