205 lines
11 KiB
Swift
205 lines
11 KiB
Swift
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)
|
||
}
|
||
}
|