Files

293 lines
15 KiB
Swift
Raw Permalink 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 Localization
import Metrics
import SwiftUI
import Updates
#if canImport(FoundationModels)
import FoundationModels
#endif
/// 本机信息:机型、芯片、内存、图形、存储、显示器、电池与标识
struct SystemInfoPage: View {
@Environment(AppModel.self) private var model
@Environment(\.isSnapshot) private var isSnapshot
@State private var revealSerial = false
@State private var installationID: String?
@State private var copiedInstallationID = 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: tr("\(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: tr("已运行 ") + Format.uptime(since: boot))
}
}
}
Spacer(minLength: 0)
}
}
WeightedRow(weights: [1, 1]) {
InfoCard(icon: "cpu", title: tr("处理器")) {
InfoRow(label: tr("芯片"), text: topology.brand)
InfoRow(label: tr("核心"), text: tr("\(topology.logicalCores) 核"))
ForEach(topology.clusters) { cluster in
InfoRow(label: cluster.name, text: tr("\(cluster.coreIndices.count) 核"))
}
}
InfoCard(icon: "memorychip", title: tr("内存与图形")) {
InfoRow(label: tr("内存"), text: Format.bytes(ProcessInfo.processInfo.physicalMemory))
InfoRow(label: tr("图形"), text: store.gpu?.name ?? topology.brand)
InfoRow(label: tr("图形核心"), text: store.gpu?.coreCount.map { tr("\($0) 核") } ?? "—")
InfoRow(label: tr("内存类型"), text: tr("统一内存(CPU 与 GPU 共享)"))
}
}
WeightedRow(weights: [1, 1]) {
InfoCard(icon: "internaldrive", title: tr("存储")) {
if let disk = store.disk {
InfoRow(label: tr("卷"), text: disk.volumeName)
InfoRow(label: tr("容量"), text: Format.bytes(disk.total, base: .decimal))
InfoRow(label: tr("可用"), text: Format.bytes(disk.available, base: .decimal))
ProgressTrack(fraction: disk.usedFraction,
color: disk.usedFraction > 0.9 ? DS.Palette.warning : DS.Palette.primary)
} else {
Text(tr("正在读取…")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
InfoCard(icon: "battery.75", title: tr("电池")) {
if let battery = store.battery {
InfoRow(label: tr("电量"), text: Format.percent(battery.level))
InfoRow(label: tr("健康度"), text: battery.health.map { Format.percent($0) } ?? "—")
InfoRow(label: tr("循环次数"), text: battery.cycleCount.map(String.init) ?? "—")
InfoRow(label: tr("电源"), text: battery.isPluggedIn ? tr("电源适配器\(battery.adapterWatts.map { " · \($0)W" } ?? "")") : tr("电池供电"))
// macOS 26 起系统自带充电上限;新款机型已不开放第三方写入 SMC 充电控制键,不另做一套
if ProcessInfo.processInfo.isOperatingSystemAtLeast(OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0)) {
HStack(spacing: DS.Space.s2) {
Text(tr("充电上限可在系统设置中设为 80%–100%,长期接电源时有助于延缓电池老化"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
Button(tr("电池设置")) {
if let url = URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension") {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.fixedSize()
}
}
} else {
Text(tr("这台 Mac 没有电池")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
BluetoothCard()
DisplayDetails()
InfoCard(icon: "number", title: tr("标识")) {
InfoRow(label: tr("机型标识符")) { CopyableText(text: system.modelIdentifier.isEmpty ? "—" : system.modelIdentifier) }
InfoRow(label: tr("Apple 智能"), text: AppleIntelligenceCompatibility
.evaluate(chip: topology.brand, osVersion: ProcessInfo.processInfo.operatingSystemVersion)
.localizedDescription)
.help(tr("仅显示本机模型当前是否可用;设备兼容不代表当前可用。"))
InfoRow(label: tr("系统版号")) { CopyableText(text: system.osBuild.isEmpty ? "—" : system.osBuild) }
InfoRow(label: tr("序列号")) {
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 ? tr("隐藏序列号") : tr("显示序列号")) {
revealSerial.toggle()
}
}
}
}
InfoRow(label: tr("安装标识")) {
if isSnapshot {
Image(systemName: "doc.on.doc")
.foregroundStyle(DS.Palette.textSecondary)
.accessibilityHidden(true)
} else if let installationID {
MiniIconButton(systemName: copiedInstallationID ? "checkmark" : "doc.on.doc",
help: copiedInstallationID ? tr("已拷贝") : tr("复制安装标识")) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(installationID, forType: .string)
copiedInstallationID = true
Task {
try? await Task.sleep(for: .seconds(1.2))
copiedInstallationID = false
}
}
} else {
Text(verbatim: "—")
}
}
if let boot = system.bootDate {
InfoRow(label: tr("启动于"), text: boot.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))
}
}
}
.task {
guard !isSnapshot else { return }
installationID = try? InstallationIdentity().hashedID()
}
}
/// “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 = tr(" \(details[index].dropLast("-inch".count)) 英寸")
details.remove(at: index)
}
return details.isEmpty ? base + size : tr("\(base)\(size)(\(details.joined(separator: tr(","))))")
}
private func chipName(_ brand: String) -> String {
brand.hasPrefix("Apple ") ? String(brand.dropFirst("Apple ".count)) : brand
}
}
/// “支持”表示本机模型已就绪,不再把仅满足设备和系统门槛当作当前可用。
enum AppleIntelligenceCompatibility: Equatable {
case unsupportedHardware
case requiresSystemUpdate
case unknown
case unavailable
case supported
static func evaluate(chip: String, osVersion: OperatingSystemVersion) -> Self {
#if canImport(FoundationModels)
if #available(macOS 26.0, *) {
return evaluate(chip: chip, osVersion: osVersion,
modelIsAvailable: SystemLanguageModel.default.isAvailable)
}
#endif
return evaluate(chip: chip, osVersion: osVersion, modelIsAvailable: nil)
}
static func evaluate(chip: String, osVersion: OperatingSystemVersion, modelIsAvailable: Bool?) -> Self {
guard chip.hasPrefix("Apple M") || chip == "Apple A18 Pro" else { return .unsupportedHardware }
guard osVersion.majorVersion > 15 || (osVersion.majorVersion == 15 && osVersion.minorVersion >= 1) else {
return .requiresSystemUpdate
}
guard let modelIsAvailable else { return .unknown }
return modelIsAvailable ? .supported : .unavailable
}
var localizedDescription: String {
switch self {
case .unsupportedHardware: tr("设备不支持")
case .requiresSystemUpdate: tr("需要 macOS 15.1 或更新版本")
case .unknown: tr("无法检测")
case .unavailable: tr("不可用")
case .supported: tr("支持")
}
}
}
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: Identifiable, Equatable, Sendable {
let target: DisplayTarget
var id: UInt32 { target.id }
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(tr("\(Int(inches.rounded())) 英寸"))
}
if let native = nativeResolution(id) { parts.append("\(native.width)×\(native.height)") }
let points = screen.frame.size
parts.append(tr("显示为 \(Int(points.width))×\(Int(points.height))"))
let refresh = CGDisplayCopyDisplayMode(id)?.refreshRate ?? 0
if refresh > 0 { parts.append("\(Int(refresh.rounded()))Hz") }
else if screen.maximumFramesPerSecond > 0 { parts.append("≤\(screen.maximumFramesPerSecond)Hz") }
return DisplayInfo(target: DisplayTarget(id: id, identity: NativeDisplayDDC.identity(for: id) ?? "unavailable-\(id)"),
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)
}
}
/// 蓝牙设备的当前或最近电量;页面打开期间由 BluetoothController 每分钟刷新
private struct BluetoothCard: View {
@Environment(AppModel.self) private var model
var body: some View {
InfoCard(icon: "dot.radiowaves.left.and.right", title: tr("蓝牙设备")) {
BluetoothDeviceList(devices: model.bluetooth.devices)
}
}
}