293 lines
15 KiB
Swift
293 lines
15 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|