Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SystemInfoPage.swift
T

288 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" } ?? "")" : "电池供电")
// macOS 26 起系统自带充电上限;新款机型已不开放第三方写入 SMC 充电控制键,不另做一套
if ProcessInfo.processInfo.isOperatingSystemAtLeast(OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0)) {
HStack(spacing: DS.Space.s2) {
Text("充电上限可在系统设置中设为 80%–100%,长期接电源时有助于延缓电池老化")
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
Button("电池设置") {
if let url = URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension") {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
} else {
Text("这台 Mac 没有电池").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
BluetoothCard()
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)
}
}
/// 已连接蓝牙设备的电量,页面打开期间每分钟刷新
private struct BluetoothCard: View {
@State private var devices: [BluetoothDevice]?
var body: some View {
InfoCard(icon: "dot.radiowaves.left.and.right", title: "蓝牙设备") {
if let devices {
if devices.isEmpty {
Text("没有已连接的蓝牙设备").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(devices) { device in
HStack(spacing: DS.Space.s2) {
Image(systemName: symbol(device.kind))
.font(.system(size: DS.TextSize.sm.rawValue))
.foregroundStyle(DS.Palette.textSecondary)
.frame(width: DS.Size.iconStandalone)
Text(verbatim: device.name)
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Spacer(minLength: DS.Space.s2)
if device.batteries.isEmpty {
Text("不提供电量").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(device.batteries, id: \.label) { battery in
HStack(spacing: DS.Space.s1) {
if device.batteries.count > 1 {
Text(verbatim: battery.label).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ProgressTrack(fraction: Double(battery.percent) / 100,
color: battery.percent <= 20 ? DS.Palette.error : DS.Palette.success,
height: DS.Space.s1 + DS.Space.s1 / 2)
.frame(width: DS.Space.s8)
Text(verbatim: "\(battery.percent)%")
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
.monospacedDigit()
}
}
}
}
} else {
Text("正在读取…").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
.task {
while !Task.isCancelled {
devices = await Task.detached { BluetoothBatteryReader.read() }.value
try? await Task.sleep(for: .seconds(60))
}
}
}
private func symbol(_ kind: BluetoothDevice.Kind) -> String {
switch kind {
case .keyboard: "keyboard"
case .mouse: "computermouse"
case .trackpad: "rectangle.and.hand.point.up.left"
case .headphones: "headphones"
case .phone: "iphone"
case .other: "dot.radiowaves.left.and.right"
}
}
}