新增本机信息页:机型、系统、芯片、内存与图形、存储、电池、显示器与标识,序列号默认遮住

This commit is contained in:
gentpan committed 2026-09-13 06:31:46 +05:00
1 parent 63c37b0e22
commit 3243570d53
10 files changed
+238 -14

No files matched your search

+1
View File
@@ -21,6 +21,7 @@
- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。
- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
- 本机信息页:机型图与名称、系统版本与版号、芯片与各类核心、内存与图形、存储用量、电池健康与循环次数、每台显示器的尺寸 / 原生分辨率 / 显示分辨率 / 刷新率、机型标识符、序列号(默认遮住,点眼睛图标显示,截图时始终遮住)与启动时间。
- 进程管理器(主窗口“进程”页):包括 root 与其他用户的系统进程(通过系统自带的 ps 读取,不需要辅助工具);列出 CPU、CPU 时间、内存、线程、唤醒次数、磁盘读写与用户,点列标题排序;可搜索名称、PID 或用户,筛选全部 / 我的 / 系统,切换按进程或按应用合并;选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。
- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
@@ -107,8 +107,17 @@ public enum SystemInfoReader {
let boot = sysctlbyname("kern.boottime", &bootTime, &size, nil, 0) == 0
? Date(timeIntervalSince1970: TimeInterval(bootTime.tv_sec)) : nil
return SystemInfo(modelName: modelName,
osVersion: "macOS \(version.majorVersion).\(version.minorVersion)\(patch)",
bootDate: boot)
var info = SystemInfo(modelName: modelName,
osVersion: "macOS \(version.majorVersion).\(version.minorVersion)\(patch)",
bootDate: boot)
info.modelIdentifier = Sysctl.string("hw.model") ?? ""
info.osBuild = Sysctl.string("kern.osversion") ?? ""
let platform = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice"))
if platform != 0 {
info.serialNumber = IORegistryEntryCreateCFProperty(platform, kIOPlatformSerialNumberKey as CFString, kCFAllocatorDefault, 0)?
.takeRetainedValue() as? String
IOObjectRelease(platform)
}
return info
}
}
@@ -173,6 +173,11 @@ public struct SystemInfo: Sendable, Equatable {
public var modelName: String
public var osVersion: String
public var bootDate: Date?
/// 例如 “Mac17,6”
public var modelIdentifier: String = ""
/// 系统版号,例如 “26A428”
public var osBuild: String = ""
public var serialNumber: String?
}
public struct MetricsSnapshot: Sendable {
@@ -24,6 +24,7 @@ public struct MainWindowView: View {
Group {
switch model.settings.panelTab {
case .overview: OverviewPage()
case .system: SystemInfoPage()
case .cpu: DetailPage { CPUPopover() }
case .gpu: DetailPage { GPUPopover() }
case .memory: DetailPage { MemoryPopover() }
@@ -0,0 +1,204 @@
import AppKit
import Metrics
import SwiftUI
/// 本机信息:机型、芯片、内存、图形、存储、显示器、电池与标识
struct SystemInfoPage: View {
@Environment(AppModel.self) private var model
@Environment(\.isSnapshot) private var isSnapshot
@State private var revealSerial = false
var body: some View {
let store = model.store
let system = store.system
let topology = store.topology
PageScroll {
Card(padding: DS.Space.s4, spacing: DS.Space.s3) {
HStack(spacing: DS.Space.s4) {
Image(nsImage: NSImage(named: NSImage.computerName) ?? NSApp.applicationIconImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: DS.Space.s16 + DS.Space.s8, height: DS.Space.s16 + DS.Space.s8)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: DS.Space.s2) {
Text(verbatim: localizedModel(system.modelName))
.dsFont(.xl, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: "\(system.osVersion)(\(system.osBuild))")
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
FlowLayout(spacing: DS.Space.s2) {
Chip(text: chipName(topology.brand), icon: "apple.logo")
Chip(text: Format.bytes(ProcessInfo.processInfo.physicalMemory).replacingOccurrences(of: ".0 ", with: " "))
if let disk = store.disk {
Chip(text: Format.bytes(disk.total, base: .decimal).replacingOccurrences(of: ".0 ", with: " "))
}
if let boot = system.bootDate {
Chip(text: "已运行 " + Format.uptime(since: boot))
}
}
}
Spacer(minLength: 0)
}
}
WeightedRow(weights: [1, 1]) {
InfoCard(icon: "cpu", title: "处理器") {
InfoRow(label: "芯片", text: topology.brand)
InfoRow(label: "核心", text: "\(topology.logicalCores) 核")
ForEach(topology.clusters) { cluster in
InfoRow(label: cluster.name, text: "\(cluster.coreIndices.count) 核")
}
}
InfoCard(icon: "memorychip", title: "内存与图形") {
InfoRow(label: "内存", text: Format.bytes(ProcessInfo.processInfo.physicalMemory))
InfoRow(label: "图形", text: store.gpu?.name ?? topology.brand)
InfoRow(label: "图形核心", text: store.gpu?.coreCount.map { "\($0) 核" } ?? "—")
InfoRow(label: "内存类型", text: "统一内存(CPU 与 GPU 共享)")
}
}
WeightedRow(weights: [1, 1]) {
InfoCard(icon: "internaldrive", title: "存储") {
if let disk = store.disk {
InfoRow(label: "卷", text: disk.volumeName)
InfoRow(label: "容量", text: Format.bytes(disk.total, base: .decimal))
InfoRow(label: "可用", text: Format.bytes(disk.available, base: .decimal))
ProgressTrack(fraction: disk.usedFraction,
color: disk.usedFraction > 0.9 ? DS.Palette.warning : DS.Palette.primary)
} else {
Text("正在读取…").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
InfoCard(icon: "battery.75", title: "电池") {
if let battery = store.battery {
InfoRow(label: "电量", text: Format.percent(battery.level))
InfoRow(label: "健康度", text: battery.health.map { Format.percent($0) } ?? "—")
InfoRow(label: "循环次数", text: battery.cycleCount.map(String.init) ?? "—")
InfoRow(label: "电源", text: battery.isPluggedIn ? "电源适配器\(battery.adapterWatts.map { " · \($0)W" } ?? "")" : "电池供电")
} else {
Text("这台 Mac 没有电池").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
InfoCard(icon: "display.2", title: "显示器") {
let displays = DisplayInfo.all()
if displays.isEmpty {
Text("没有检测到显示器").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(Array(displays.enumerated()), id: \.offset) { index, display in
if index > 0 { HairlineDivider() }
HStack(spacing: DS.Space.s3) {
Image(systemName: display.isBuiltIn ? "laptopcomputer" : "display")
.font(.system(size: DS.TextSize.base.rawValue))
.foregroundStyle(DS.Palette.textSecondary)
.frame(width: DS.Size.iconStandalone)
VStack(alignment: .leading, spacing: 0) {
Text(verbatim: display.name).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: display.summary).dsFont(.xs).monospacedDigit().foregroundStyle(DS.Palette.textSecondary)
}
Spacer(minLength: 0)
if display.isMain { Chip(text: "主显示器", tone: .primary) }
}
}
}
InfoCard(icon: "number", title: "标识") {
InfoRow(label: "机型标识符") { CopyableText(text: system.modelIdentifier.isEmpty ? "—" : system.modelIdentifier) }
InfoRow(label: "系统版号") { CopyableText(text: system.osBuild.isEmpty ? "—" : system.osBuild) }
InfoRow(label: "序列号") {
HStack(spacing: DS.Space.s1) {
if revealSerial, !isSnapshot, let serial = system.serialNumber {
CopyableText(text: serial)
} else {
Text(verbatim: system.serialNumber.map { String(repeating: "•", count: min(12, $0.count)) } ?? "—")
}
if system.serialNumber != nil, !isSnapshot {
MiniIconButton(systemName: revealSerial ? "eye.slash" : "eye", help: revealSerial ? "隐藏序列号" : "显示序列号") {
revealSerial.toggle()
}
}
}
}
if let boot = system.bootDate {
InfoRow(label: "启动于", text: boot.formatted(date: .abbreviated, time: .shortened))
}
}
}
}
/// “MacBook Pro (16-inch, M5 Max)” → “MacBook Pro 16 英寸(M5 Max)”
private func localizedModel(_ name: String) -> String {
guard let open = name.firstIndex(of: "("), name.hasSuffix(")") else { return name }
let base = name[..<open].trimmingCharacters(in: .whitespaces)
var details = name[name.index(after: open)..<name.index(before: name.endIndex)]
.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
var size = ""
if let index = details.firstIndex(where: { $0.hasSuffix("-inch") }) {
size = " \(details[index].dropLast("-inch".count)) 英寸"
details.remove(at: index)
}
return details.isEmpty ? base + size : "\(base)\(size)(\(details.joined(separator: ",")))"
}
private func chipName(_ brand: String) -> String {
brand.hasPrefix("Apple ") ? String(brand.dropFirst("Apple ".count)) : brand
}
}
private struct InfoCard<Content: View>: View {
let icon: String
let title: String
@ViewBuilder var content: Content
var body: some View {
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s1) {
Image(systemName: icon).font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text(title).dsFont(.xs, weight: .semibold)
}
.foregroundStyle(DS.Palette.textSecondary)
content
}
}
}
/// 显示器信息:名称、尺寸、原生分辨率与最高刷新率
struct DisplayInfo {
let name: String
let isBuiltIn: Bool
let isMain: Bool
let summary: String
@MainActor
static func all() -> [DisplayInfo] {
NSScreen.screens.map { screen in
let id = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
var parts: [String] = []
let millimeters = CGDisplayScreenSize(id)
if millimeters.width > 0 {
let inches = (millimeters.width * millimeters.width + millimeters.height * millimeters.height).squareRoot() / 25.4
parts.append("\(Int(inches.rounded())) 英寸")
}
if let native = nativeResolution(id) { parts.append("\(native.width)×\(native.height)") }
let points = screen.frame.size
parts.append("显示为 \(Int(points.width))×\(Int(points.height))")
if screen.maximumFramesPerSecond > 0 { parts.append("\(screen.maximumFramesPerSecond)Hz") }
return DisplayInfo(name: screen.localizedName, isBuiltIn: CGDisplayIsBuiltin(id) != 0,
isMain: CGDisplayIsMain(id) != 0, summary: parts.joined(separator: " · "))
}
}
/// 面板的原生分辨率:优先取标记为原生的显示模式;缩放模式的像素可能比面板还多(如 6400×3600)
private static func nativeResolution(_ id: CGDirectDisplayID) -> (width: Int, height: Int)? {
let options = [kCGDisplayShowDuplicateLowResolutionModes: true] as CFDictionary
guard let modes = CGDisplayCopyAllDisplayModes(id, options) as? [CGDisplayMode], !modes.isEmpty else { return nil }
let nativeFlag: UInt32 = 0x0200_0000 // kDisplayModeNativeFlag
let native = modes.filter { $0.ioFlags & nativeFlag != 0 }
guard let best = (native.isEmpty ? modes : native)
.max(by: { $0.pixelWidth * $0.pixelHeight < $1.pixelWidth * $1.pixelHeight }) else { return nil }
return (best.pixelWidth, best.pixelHeight)
}
}
@@ -55,9 +55,9 @@ public final class AppModel {
demand.memory = true
demand.network = true
let showing = { (page: PanelTab, item: MenuBarItem) in (window && tab == page) || popover == item }
demand.gpu = (window && tab == .overview) || menu.contains(.gpu) || showing(.gpu, .gpu)
demand.disk = window && (tab == .overview || tab == .cleaner)
demand.battery = (window && (tab == .overview || tab == .keepAwake)) || keepAwake.lidClosedActive
demand.gpu = (window && [.overview, .system].contains(tab)) || menu.contains(.gpu) || showing(.gpu, .gpu)
demand.disk = window && [.overview, .system, .cleaner].contains(tab)
demand.battery = (window && [.overview, .system, .keepAwake].contains(tab)) || keepAwake.lidClosedActive
demand.processes = (window && [.processes, .overview].contains(tab)) || showing(.cpu, .cpu) || showing(.memory, .memory)
demand.systemProcesses = window && tab == .processes
@@ -219,16 +219,17 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable {
/// 主窗口侧边栏的页面
public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
case overview, cpu, gpu, memory, network, thermal, processes, keepAwake, cleaner
case overview, system, cpu, gpu, memory, network, thermal, processes, keepAwake, cleaner
public var id: String { rawValue }
static let monitors: [PanelTab] = [.overview, .cpu, .gpu, .memory, .network, .thermal]
static let monitors: [PanelTab] = [.overview, .system, .cpu, .gpu, .memory, .network, .thermal]
static let tools: [PanelTab] = [.processes, .keepAwake, .cleaner]
var title: String {
switch self {
case .overview: "仪表盘"
case .system: "本机信息"
case .cpu: "CPU"
case .gpu: "GPU"
case .memory: "内存"
@@ -243,6 +244,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
var symbol: String {
switch self {
case .overview: "square.grid.2x2"
case .system: "laptopcomputer"
case .cpu: "cpu"
case .gpu: "square.3.layers.3d"
case .memory: "memorychip"
+3 -2
View File
@@ -46,10 +46,10 @@ Developed and tested on Apple Silicon. The interface is in Simplified Chinese.
<!-- changelog:start -->
<!-- Generated from CHANGELOG.md by Scripts/sync_changelog.py. Do not edit by hand. -->
Not released yet · **43** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
Not released yet · **44** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
<details open>
<summary><b>2026-09-13</b> · Unreleased · 28 added · 9 style · 6 fixed</summary>
<summary><b>2026-09-13</b> · Unreleased · 29 added · 9 style · 6 fixed</summary>
**Added**
@@ -65,6 +65,7 @@ Not released yet · **43** changes in development · [full changelog](CHANGELOG.
- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。
- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
- 本机信息页:机型图与名称、系统版本与版号、芯片与各类核心、内存与图形、存储用量、电池健康与循环次数、每台显示器的尺寸 / 原生分辨率 / 显示分辨率 / 刷新率、机型标识符、序列号(默认遮住,点眼睛图标显示,截图时始终遮住)与启动时间。
- 进程管理器(主窗口“进程”页):包括 root 与其他用户的系统进程(通过系统自带的 ps 读取,不需要辅助工具);列出 CPU、CPU 时间、内存、线程、唤醒次数、磁盘读写与用户,点列标题排序;可搜索名称、PID 或用户,筛选全部 / 我的 / 系统,切换按进程或按应用合并;选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。
- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
+3 -2
View File
@@ -44,10 +44,10 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
<!-- changelog:start -->
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
尚未发布正式版本 · 开发中 **43** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
尚未发布正式版本 · 开发中 **44** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
<details open>
<summary><b>2026-09-13</b> · 未发布 · 新增 28 · 样式 9 · 修复 6</summary>
<summary><b>2026-09-13</b> · 未发布 · 新增 29 · 样式 9 · 修复 6</summary>
**新增**
@@ -63,6 +63,7 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。
- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
- 本机信息页:机型图与名称、系统版本与版号、芯片与各类核心、内存与图形、存储用量、电池健康与循环次数、每台显示器的尺寸 / 原生分辨率 / 显示分辨率 / 刷新率、机型标识符、序列号(默认遮住,点眼睛图标显示,截图时始终遮住)与启动时间。
- 进程管理器(主窗口“进程”页):包括 root 与其他用户的系统进程(通过系统自带的 ps 读取,不需要辅助工具);列出 CPU、CPU 时间、内存、线程、唤醒次数、磁盘读写与用户,点列标题排序;可搜索名称、PID 或用户,筛选全部 / 我的 / 系统,切换按进程或按应用合并;选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。
- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。
- 仪表盘顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
+2 -2
View File
@@ -347,7 +347,7 @@
<div class="log">
<article class="log__day">
<div class="log__head"><time datetime="2026-09-13">2026-09-13</time><span class="log__tag">未发布</span></div>
<p class="log__counts">新增 28 · 样式 9 · 修复 6</p>
<p class="log__counts">新增 29 · 样式 9 · 修复 6</p>
<ul class="log__list">
<li><span class="log__kind">新增</span>菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。</li>
<li><span class="log__kind">新增</span>菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。</li>
@@ -358,7 +358,7 @@
<li><span class="log__kind">新增</span>网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。</li>
<li><span class="log__kind">新增</span>DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。</li>
</ul>
<a class="log__more" href="https://github.com/gentpan/OpenStats/blob/main/CHANGELOG.md">还有 35 项 →</a>
<a class="log__more" href="https://github.com/gentpan/OpenStats/blob/main/CHANGELOG.md">还有 36 项 →</a>
</article>
</div>
<!-- changelog:end -->