Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

1132 lines
40 KiB
Swift

import AppKit
import SwiftUI
import MacToolsPluginKit
public final class SystemStatusPluginFactory: NSObject, MacToolsPluginBundleFactory {
public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider {
SystemStatusPluginProvider(context: context)
}
}
@MainActor
private struct SystemStatusPluginProvider: PluginProvider {
let context: PluginRuntimeContext
func makePlugins() -> [any MacToolsPlugin] {
[SystemStatusPlugin(
storage: context.storage,
supportDirectory: context.supportDirectory,
localization: PluginLocalization(bundle: context.resourceBundle)
)]
}
}
@MainActor
final class SystemStatusPlugin: MacToolsPlugin, PluginComponentPanel, PluginPanelSurfaceLifecycleHandling, PluginFeatureVisibilityLifecycleHandling, PluginConfigurationPresenting {
let metadata: PluginMetadata
var descriptor: PluginComponentDescriptor {
PluginComponentDescriptor(
span: PluginComponentSpan(
width: 4,
height: PluginComponentPanelLayoutMetrics.default.heightSpan(
fittingContentHeight: SystemStatusComponentLayout.contentHeight(
for: settingsController.configuration.visiblePanelMetricKinds
)
)
)!
)
}
private let viewModel: SystemStatusViewModel
private let settingsController: SystemStatusSettingsController
private let menuBarMetricsController: SystemStatusMenuBarMetricsController
private let localization: PluginLocalization
private var isFeatureVisible = true
init(
viewModel: SystemStatusViewModel? = nil,
settingsController: SystemStatusSettingsController? = nil,
storage: PluginStorage? = nil,
supportDirectory: URL? = nil,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) {
let resolvedViewModel = viewModel
?? SystemStatusViewModel(
sampler: SystemStatusSampler(localization: localization),
historyStore: SystemStatusHistoryStore(
fileURL: SystemStatusHistoryStore.defaultFileURL(supportDirectory: supportDirectory)
)
)
let resolvedSettingsController = settingsController
?? SystemStatusSettingsController(
store: SystemStatusPluginStorageConfigurationStore(
storage: storage ?? UserDefaultsPluginStorage(pluginID: "system-status")
)
)
self.viewModel = resolvedViewModel
self.settingsController = resolvedSettingsController
self.localization = localization
self.metadata = PluginMetadata(
id: "system-status",
title: localization.string("metadata.title", defaultValue: "系统状态"),
iconName: "gauge.with.dots.needle.67percent",
iconTint: Color(nsColor: .systemTeal),
order: 10,
defaultDescription: localization.string("metadata.description", defaultValue: "实时查看系统状态")
)
self.menuBarMetricsController = SystemStatusMenuBarMetricsController(
viewModel: resolvedViewModel,
settingsController: resolvedSettingsController
)
resolvedSettingsController.onConfigurationChange = { [weak self] in
self?.onStateChange?()
}
}
var onStateChange: (() -> Void)?
var requestPermissionGuidance: ((String) -> Void)?
var shortcutBindingResolver: ((String) -> ShortcutBinding?)?
var requestConfigurationPresentation: (() -> Void)? {
didSet {
menuBarMetricsController.requestConfigurationPresentation = requestConfigurationPresentation
}
}
var componentPanelState: PluginComponentState {
PluginComponentState(
subtitle: metadata.defaultDescription,
isActive: false,
isEnabled: true,
isVisible: true,
errorMessage: nil
)
}
var permissionRequirements: [PluginPermissionRequirement] { [] }
var settingsSections: [PluginSettingsSection] { [] }
var shortcutDefinitions: [PluginShortcutDefinition] { [] }
var configuration: PluginConfiguration? {
PluginConfiguration(description: metadata.defaultDescription) { [settingsController, localization] _ in
SystemStatusSettingsView(
controller: settingsController,
localization: localization
)
}
}
func makeView(context: PluginComponentContext) -> AnyView {
AnyView(
SystemStatusComponentView(
viewModel: viewModel,
settingsController: settingsController,
localization: localization
)
)
}
func refresh() {
guard isFeatureVisible else {
return
}
viewModel.startBackground()
menuBarMetricsController.activate()
}
func activate(context: PluginRuntimeContext) {
isFeatureVisible = true
refresh()
}
func deactivate(reason: PluginDeactivationReason) {
isFeatureVisible = false
menuBarMetricsController.stop()
viewModel.stop()
}
func featureVisibilityDidChange(_ isVisible: Bool) {
guard isFeatureVisible != isVisible else {
return
}
isFeatureVisible = isVisible
if isVisible {
refresh()
} else {
menuBarMetricsController.stop()
viewModel.stop()
}
}
func panelSurfaceDidBecomeVisible(_ surface: PluginPanelSurface) {
guard surface == .component else {
return
}
viewModel.startForeground()
}
func panelSurfaceDidBecomeHidden(_ surface: PluginPanelSurface) {
guard surface == .component else {
return
}
viewModel.returnToBackground()
}
func permissionState(for permissionID: String) -> PluginPermissionState {
PluginPermissionState(isGranted: true, footnote: nil)
}
func handlePermissionAction(id: String) {}
func handleSettingsAction(id: String) {}
func handleShortcutAction(id: String) {}
}
@MainActor
struct SystemStatusSamplingSchedule: Sendable {
let backgroundFastInterval: Duration
let menuBarFastInterval: Duration
let foregroundFastInterval: Duration
let backgroundSlowInterval: TimeInterval
let menuBarSlowInterval: TimeInterval
let foregroundSlowInterval: TimeInterval
let backgroundProcessInterval: TimeInterval
let foregroundProcessInterval: TimeInterval
let backgroundHistoryInterval: TimeInterval
let foregroundHistoryInterval: TimeInterval
static let production = SystemStatusSamplingSchedule(
backgroundFastInterval: .seconds(30),
menuBarFastInterval: .seconds(3),
foregroundFastInterval: .seconds(3),
backgroundSlowInterval: 300,
menuBarSlowInterval: 3,
foregroundSlowInterval: 15,
backgroundProcessInterval: 300,
foregroundProcessInterval: 15,
backgroundHistoryInterval: 300,
foregroundHistoryInterval: 60
)
}
@MainActor
final class SystemStatusViewModel: ObservableObject {
@Published private(set) var snapshot = SystemStatusSnapshot.empty
private enum SamplingMode: Equatable {
case background
case menuBar(requiresSlowSampling: Bool)
case foreground
func fastInterval(schedule: SystemStatusSamplingSchedule) -> Duration {
switch self {
case .background:
return schedule.backgroundFastInterval
case .menuBar:
return schedule.menuBarFastInterval
case .foreground:
return schedule.foregroundFastInterval
}
}
func slowInterval(schedule: SystemStatusSamplingSchedule) -> TimeInterval {
switch self {
case .background:
return schedule.backgroundSlowInterval
case let .menuBar(requiresSlowSampling):
return requiresSlowSampling ? schedule.menuBarSlowInterval : schedule.backgroundSlowInterval
case .foreground:
return schedule.foregroundSlowInterval
}
}
func processInterval(schedule: SystemStatusSamplingSchedule) -> TimeInterval {
switch self {
case .background, .menuBar:
return schedule.backgroundProcessInterval
case .foreground:
return schedule.foregroundProcessInterval
}
}
func historyInterval(schedule: SystemStatusSamplingSchedule) -> TimeInterval {
switch self {
case .background, .menuBar:
return schedule.backgroundHistoryInterval
case .foreground:
return schedule.foregroundHistoryInterval
}
}
}
private let sampler: any SystemStatusSampling
private let historyStore: any SystemStatusHistoryStoring
private let schedule: SystemStatusSamplingSchedule
private var samplingTask: Task<Void, Never>?
private var mode: SamplingMode = .background
private var menuBarMode: SamplingMode?
private var lastSlowDate: Date?
private var lastProcessDate: Date?
private var lastHistoryDate: Date?
private var displayHistory: [SystemStatusHistoryPoint] = []
private var didLoadHistory = false
private var lastDisplayHistoryPublishDate: Date?
private static let displayHistoryRetention: TimeInterval = 30 * 60
private static let maximumDisplayHistoryCount = 1_800
private static let foregroundDisplayHistoryInterval: TimeInterval = 2
private static let backgroundDisplayHistoryInterval: TimeInterval = 30
init(
sampler: any SystemStatusSampling = SystemStatusSampler(),
historyStore: (any SystemStatusHistoryStoring)? = nil,
schedule: SystemStatusSamplingSchedule = .production
) {
self.sampler = sampler
self.historyStore = historyStore ?? SystemStatusHistoryStore(
fileURL: SystemStatusHistoryStore.defaultFileURL(supportDirectory: nil)
)
self.schedule = schedule
}
func start() {
startForeground()
}
func startForeground() {
let previousMode = mode
mode = .foreground
if previousMode != .foreground, samplingTask != nil {
restartSamplingLoop()
return
}
startSamplingIfNeeded()
}
func startMenuBar(requiresSlowSampling: Bool) {
menuBarMode = .menuBar(requiresSlowSampling: requiresSlowSampling)
guard mode != .foreground else {
startSamplingIfNeeded()
return
}
let previousMode = mode
mode = menuBarMode ?? .background
if previousMode != mode, samplingTask != nil {
restartSamplingLoop()
return
}
startSamplingIfNeeded()
}
func stopMenuBar() {
menuBarMode = nil
guard mode != .foreground else {
return
}
mode = .background
}
func startBackground() {
startSamplingIfNeeded()
}
func returnToBackground() {
mode = menuBarMode ?? .background
}
func refreshSnapshotNow(referenceDate: Date = Date()) async {
await collectFast(referenceDate: referenceDate, mode: .foreground, forcePublishHistory: true)
let slowSample = await sampler.collectSlow()
guard !Task.isCancelled else { return }
var slowSnapshot = snapshot
slowSnapshot.disk = slowSnapshot.disk.replacingCapacity(from: slowSample.disk)
slowSnapshot.battery = slowSample.battery
slowSnapshot.gpu = slowSample.gpu
slowSnapshot.hardware = slowSample.hardware
publishSnapshotIfChanged(slowSnapshot)
let processes = await sampler.collectTopProcesses(limit: 3)
guard !Task.isCancelled else { return }
var processSnapshot = snapshot
processSnapshot.topProcesses = await Self.resolveApplicationNames(for: processes)
publishSnapshotIfChanged(processSnapshot)
let point = SystemStatusHistoryPoint(timestamp: referenceDate.timeIntervalSince1970, snapshot: snapshot)
appendDisplayHistoryPoint(point, referenceDate: referenceDate)
publishDisplayHistory(referenceDate: referenceDate, force: true)
_ = await historyStore.append(point, referenceDate: referenceDate)
guard !Task.isCancelled else { return }
}
func stop() {
samplingTask?.cancel()
samplingTask = nil
mode = .background
menuBarMode = nil
}
private func startSamplingIfNeeded() {
guard samplingTask == nil else {
return
}
samplingTask = Task { @MainActor [weak self] in
await self?.loadHistory()
await self?.runSamplingLoop()
}
}
private func restartSamplingLoop() {
samplingTask?.cancel()
samplingTask = nil
startSamplingIfNeeded()
}
private func loadHistory() async {
guard !didLoadHistory else {
return
}
let referenceDate = Date()
displayHistory = Self.prunedDisplayHistory(
await historyStore.load(referenceDate: referenceDate),
referenceDate: referenceDate
)
publishDisplayHistory(referenceDate: referenceDate, force: true)
didLoadHistory = true
}
private func runSamplingLoop() async {
while !Task.isCancelled {
let currentMode = mode
let now = Date()
await collectFast(referenceDate: now, mode: currentMode)
guard !Task.isCancelled else { return }
await collectSlowIfNeeded(referenceDate: now, mode: currentMode)
guard !Task.isCancelled else { return }
await collectProcessesIfNeeded(referenceDate: now, mode: currentMode)
guard !Task.isCancelled else { return }
await persistHistoryIfNeeded(referenceDate: now, mode: currentMode)
guard !Task.isCancelled else { return }
do {
try await Task.sleep(for: currentMode.fastInterval(schedule: schedule))
} catch {
return
}
}
}
private func collectFast(
referenceDate: Date,
mode: SamplingMode,
forcePublishHistory: Bool = false
) async {
let sample = await sampler.collectFast(referenceDate: referenceDate)
guard !Task.isCancelled else { return }
var updatedSnapshot = snapshot
updatedSnapshot.cpu = sample.cpu
updatedSnapshot.memory = sample.memory
updatedSnapshot.network = sample.network
updatedSnapshot.disk = updatedSnapshot.disk.replacingActivity(from: sample.disk)
appendDisplayHistoryPoint(
SystemStatusHistoryPoint(timestamp: referenceDate.timeIntervalSince1970, snapshot: updatedSnapshot),
referenceDate: referenceDate
)
if shouldPublishDisplayHistory(referenceDate: referenceDate, mode: mode) || forcePublishHistory {
updatedSnapshot.history = displayHistory
lastDisplayHistoryPublishDate = referenceDate
} else {
updatedSnapshot.history = snapshot.history
}
publishSnapshotIfChanged(updatedSnapshot)
}
private func collectSlowIfNeeded(referenceDate: Date, mode: SamplingMode) async {
guard shouldRun(lastDate: lastSlowDate, referenceDate: referenceDate, interval: mode.slowInterval(schedule: schedule)) else {
return
}
lastSlowDate = referenceDate
let sample = await sampler.collectSlow()
guard !Task.isCancelled else { return }
var updatedSnapshot = snapshot
updatedSnapshot.disk = updatedSnapshot.disk.replacingCapacity(from: sample.disk)
updatedSnapshot.battery = sample.battery
updatedSnapshot.gpu = sample.gpu
updatedSnapshot.hardware = sample.hardware
publishSnapshotIfChanged(updatedSnapshot)
}
private func collectProcessesIfNeeded(referenceDate: Date, mode: SamplingMode) async {
guard shouldRun(lastDate: lastProcessDate, referenceDate: referenceDate, interval: mode.processInterval(schedule: schedule)) else {
return
}
lastProcessDate = referenceDate
let processes = await sampler.collectTopProcesses(limit: 3)
guard !Task.isCancelled else { return }
var updatedSnapshot = snapshot
updatedSnapshot.topProcesses = await Self.resolveApplicationNames(for: processes)
publishSnapshotIfChanged(updatedSnapshot)
}
private func persistHistoryIfNeeded(referenceDate: Date, mode: SamplingMode) async {
guard shouldRun(lastDate: lastHistoryDate, referenceDate: referenceDate, interval: mode.historyInterval(schedule: schedule)) else {
return
}
lastHistoryDate = referenceDate
let point = SystemStatusHistoryPoint(timestamp: referenceDate.timeIntervalSince1970, snapshot: snapshot)
appendDisplayHistoryPoint(point, referenceDate: referenceDate)
publishDisplayHistory(referenceDate: referenceDate, force: true)
_ = await historyStore.append(point, referenceDate: referenceDate)
guard !Task.isCancelled else { return }
}
private func shouldRun(lastDate: Date?, referenceDate: Date, interval: TimeInterval) -> Bool {
guard let lastDate else {
return true
}
return referenceDate.timeIntervalSince(lastDate) >= interval
}
private func appendDisplayHistoryPoint(_ point: SystemStatusHistoryPoint, referenceDate: Date) {
if let lastIndex = displayHistory.indices.last,
displayHistory[lastIndex].timestamp == point.timestamp {
displayHistory[lastIndex] = point
displayHistory = Self.prunedSortedDisplayHistory(displayHistory, referenceDate: referenceDate)
return
}
if let lastTimestamp = displayHistory.last?.timestamp,
point.timestamp < lastTimestamp {
displayHistory.append(point)
displayHistory = Self.prunedDisplayHistory(displayHistory, referenceDate: referenceDate)
return
}
displayHistory.append(point)
displayHistory = Self.prunedSortedDisplayHistory(displayHistory, referenceDate: referenceDate)
}
private func shouldPublishDisplayHistory(referenceDate: Date, mode: SamplingMode) -> Bool {
let interval: TimeInterval
switch mode {
case .foreground:
interval = Self.foregroundDisplayHistoryInterval
case .background, .menuBar:
interval = Self.backgroundDisplayHistoryInterval
}
return shouldRun(lastDate: lastDisplayHistoryPublishDate, referenceDate: referenceDate, interval: interval)
}
private func publishDisplayHistory(referenceDate: Date, force: Bool = false) {
guard force || shouldPublishDisplayHistory(referenceDate: referenceDate, mode: mode) else {
return
}
var updatedSnapshot = snapshot
updatedSnapshot.history = displayHistory
lastDisplayHistoryPublishDate = referenceDate
publishSnapshotIfChanged(updatedSnapshot)
}
private func publishSnapshotIfChanged(_ updatedSnapshot: SystemStatusSnapshot) {
guard updatedSnapshot != snapshot else {
return
}
snapshot = updatedSnapshot
}
static func prunedSortedDisplayHistory(
_ points: [SystemStatusHistoryPoint],
referenceDate: Date
) -> [SystemStatusHistoryPoint] {
guard !points.isEmpty else {
return []
}
let cutoff = referenceDate.timeIntervalSince1970 - displayHistoryRetention
let upperBound = referenceDate.timeIntervalSince1970 + 60
var startIndex = points.startIndex
while startIndex < points.endIndex, points[startIndex].timestamp < cutoff {
startIndex = points.index(after: startIndex)
}
var endIndex = points.endIndex
var currentIndex = startIndex
while currentIndex < points.endIndex {
if points[currentIndex].timestamp > upperBound {
endIndex = currentIndex
break
}
currentIndex = points.index(after: currentIndex)
}
let retainedCount = points.distance(from: startIndex, to: endIndex)
if retainedCount > maximumDisplayHistoryCount {
startIndex = points.index(endIndex, offsetBy: -maximumDisplayHistoryCount)
}
guard startIndex < endIndex else {
return []
}
return Array(points[startIndex..<endIndex])
}
static func prunedDisplayHistory(
_ points: [SystemStatusHistoryPoint],
referenceDate: Date
) -> [SystemStatusHistoryPoint] {
let cutoff = referenceDate.timeIntervalSince1970 - displayHistoryRetention
let recentPoints = points
.filter { $0.timestamp >= cutoff && $0.timestamp <= referenceDate.timeIntervalSince1970 + 60 }
.sorted { $0.timestamp < $1.timestamp }
guard recentPoints.count > maximumDisplayHistoryCount else {
return recentPoints
}
return Array(recentPoints.suffix(maximumDisplayHistoryCount))
}
private static func resolveApplicationNames(for processes: [SystemStatusTopProcess]) async -> [SystemStatusTopProcess] {
processes.map { process in
guard
let application = NSRunningApplication(processIdentifier: pid_t(process.pid)),
let localizedName = application.localizedName,
!localizedName.isEmpty
else {
return process
}
return process.replacingDisplayName(localizedName)
}
}
}
struct SystemStatusComponentView: View {
private enum Layout {
static let spacing = SystemStatusComponentLayout.cardSpacing
}
@ObservedObject var viewModel: SystemStatusViewModel
@ObservedObject var settingsController: SystemStatusSettingsController
let localization: PluginLocalization
var body: some View {
SystemStatusDashboardView(
snapshot: viewModel.snapshot,
visibleKinds: settingsController.configuration.visiblePanelMetricKinds,
localization: localization
)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
private var compactCPUCard: some View {
let cpu = viewModel.snapshot.cpu
return SystemStatusCompactMetricCard(
title: SystemStatusMetricKind.cpu.title(localization: localization),
percentText: cpu.isCollecting ? "--" : SystemStatusFormatter.percent(cpu.usage),
detailLines: [
localization.format(
"metric.temperatureFormat",
defaultValue: "温度 %@",
SystemStatusFormatter.temperature(cpu.temperatureCelsius)
),
localization.format(
"metric.powerFormat",
defaultValue: "功率 %@",
SystemStatusFormatter.power(cpu.systemPowerWatts)
)
],
progress: cpu.usage
)
}
private var compactMemoryCard: some View {
let memory = viewModel.snapshot.memory
return SystemStatusCompactMetricCard(
title: SystemStatusMetricKind.memory.title(localization: localization),
percentText: SystemStatusFormatter.percent(memory.usage),
detailLines: [
localization.format(
"metric.usedFormat",
defaultValue: "已用 %@",
SystemStatusFormatter.bytes(memory.usedBytes)
),
localization.format(
"metric.totalFormat",
defaultValue: "总量 %@",
SystemStatusFormatter.bytes(memory.totalBytes)
)
],
progress: memory.usage
)
}
private var compactDiskCard: some View {
let disk = viewModel.snapshot.disk
return SystemStatusCompactMetricCard(
title: SystemStatusMetricKind.disk.title(localization: localization),
percentText: SystemStatusFormatter.percent(disk.usage),
detailLines: [
localization.format(
"metric.usedFormat",
defaultValue: "已用 %@",
SystemStatusFormatter.bytes(disk.usedBytes)
),
localization.format(
"metric.totalFormat",
defaultValue: "总量 %@",
SystemStatusFormatter.bytes(disk.totalBytes)
)
],
progress: disk.usage
)
}
private var compactBatteryCard: some View {
let battery = viewModel.snapshot.battery
return SystemStatusCompactMetricCard(
title: SystemStatusMetricKind.battery.title(localization: localization),
percentText: battery.isAvailable ? SystemStatusFormatter.percent(battery.level) : "--",
detailLines: [
localization.format(
"metric.temperatureFormat",
defaultValue: "温度 %@",
SystemStatusFormatter.temperature(battery.temperatureCelsius)
),
batteryHealthText(for: battery)
],
progress: battery.level,
centerSubtext: batteryCircleStatusText(for: battery),
centerHelpText: batteryShortText(for: battery)
)
}
private var networkCard: some View {
let network = viewModel.snapshot.network
return SystemStatusWideInfoCard(
title: SystemStatusMetricKind.network.title(localization: localization),
iconName: "wifi",
tint: Color(nsColor: .systemCyan),
headerLeadingPadding: 4
) {
VStack(alignment: .leading, spacing: 3) {
VStack(alignment: .leading, spacing: 2) {
SystemStatusNetworkSpeedRow(
iconName: "arrow.down",
value: SystemStatusFormatter.speed(network.downloadBytesPerSecond),
tint: Color(nsColor: .systemBlue)
)
SystemStatusNetworkSpeedRow(
iconName: "arrow.up",
value: SystemStatusFormatter.speed(network.uploadBytesPerSecond),
tint: Color(nsColor: .systemGreen)
)
}
VStack(alignment: .leading, spacing: 1) {
SystemStatusKeyValueLine(
label: localization.string("network.publicIP", defaultValue: "公网"),
value: network.publicIPAddress
?? localization.string("network.publicIP.collecting", defaultValue: "获取中"),
copyValue: network.publicIPAddress,
localization: localization
)
SystemStatusKeyValueLine(
label: localization.string("network.localIP", defaultValue: "内网"),
value: network.ipAddress ?? "—",
copyValue: network.ipAddress,
localization: localization
)
}
}
}
}
private var topProcessesCard: some View {
SystemStatusTopProcessesCard(
processes: Array(viewModel.snapshot.topProcesses.prefix(3)),
localization: localization
)
}
private func batteryShortText(for battery: SystemStatusBatterySnapshot) -> String {
guard battery.isAvailable else {
return battery.state.title(localization: localization)
}
if battery.state == .charged {
return localization.string("battery.state.charged", defaultValue: "已充满")
}
if battery.state == .charging || battery.state == .acPower {
return battery.state.title(localization: localization)
}
return SystemStatusFormatter.timeRemaining(minutes: battery.timeRemainingMinutes, localization: localization)
}
private func batteryHealthText(for battery: SystemStatusBatterySnapshot) -> String {
guard let healthPercent = battery.healthPercent else {
return localization.string("battery.healthUnavailable", defaultValue: "健康度 —")
}
return localization.format("battery.healthFormat", defaultValue: "健康度 %d%%", healthPercent)
}
private func batteryCircleStatusText(for battery: SystemStatusBatterySnapshot) -> String? {
guard battery.isAvailable else {
return nil
}
switch battery.state {
case .charging, .charged, .acPower, .unplugged:
return battery.state.title(localization: localization)
case .unavailable, .unknown:
return nil
}
}
}
private enum SystemStatusCircleStyle {
static let tint = Color(nsColor: .systemBlue)
}
private struct SystemStatusCompactMetricCard: View {
let title: String
let percentText: String
let detailLines: [String]
let progress: Double?
var centerSubtext: String? = nil
var centerHelpText: String? = nil
var body: some View {
VStack(spacing: 0) {
ZStack {
SystemStatusCircularProgress(value: progress, tint: SystemStatusCircleStyle.tint)
.frame(width: 58, height: 58)
VStack(spacing: centerSubtext == nil ? 1 : 0) {
Text(title)
.font(.system(size: centerSubtext == nil ? 9 : 8, weight: .semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
Text(percentText)
.font(.system(size: centerSubtext == nil ? 12 : 11, weight: .bold, design: .rounded))
.foregroundStyle(.primary)
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.7)
if let centerSubtext {
Text(centerSubtext)
.font(.system(size: 6.8, weight: .semibold, design: .rounded))
.foregroundStyle(SystemStatusCircleStyle.tint)
.lineLimit(1)
.minimumScaleFactor(0.55)
}
}
.padding(.horizontal, 5)
.help(centerHelpText ?? "")
}
Spacer(minLength: 5)
VStack(spacing: 1) {
ForEach(Array(detailLines.prefix(2).enumerated()), id: \.offset) { _, line in
Text(line)
.font(.system(size: 7.5, weight: .medium, design: .rounded))
.foregroundStyle(.tertiary)
.lineLimit(1)
.minimumScaleFactor(0.55)
.monospacedDigit()
.frame(maxWidth: .infinity, alignment: .center)
}
}
.help(detailLines.joined(separator: "\n"))
}
.padding(SystemStatusComponentLayout.cardContentPadding)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(SystemStatusCardBackground(cornerRadius: SystemStatusComponentLayout.cardCornerRadius))
}
}
private struct SystemStatusWideInfoCard<Content: View>: View {
let title: String
let iconName: String
let tint: Color
var headerLeadingPadding: CGFloat = 0
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Image(systemName: iconName)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 14, height: 14)
Text(title)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.leading, headerLeadingPadding)
content
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
.padding(SystemStatusComponentLayout.cardContentPadding)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(SystemStatusCardBackground(cornerRadius: SystemStatusComponentLayout.cardCornerRadius))
}
}
private enum SystemStatusNetworkRowLayout {
static let leadingColumnWidth: CGFloat = 22
static let columnSpacing: CGFloat = 5
}
private struct SystemStatusNetworkSpeedRow: View {
let iconName: String
let value: String
let tint: Color
var body: some View {
HStack(spacing: SystemStatusNetworkRowLayout.columnSpacing) {
Image(systemName: iconName)
.font(.system(size: 9, weight: .bold))
.foregroundStyle(tint)
.frame(width: SystemStatusNetworkRowLayout.leadingColumnWidth, alignment: .center)
Text(value)
.font(.system(size: 10, weight: .semibold, design: .rounded))
.foregroundStyle(tint)
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity, alignment: .leading)
.layoutPriority(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct SystemStatusKeyValueLine: View {
let label: String
let value: String
let copyValue: String?
let localization: PluginLocalization
@State private var isHovering = false
var body: some View {
HStack(spacing: SystemStatusNetworkRowLayout.columnSpacing) {
Text(label)
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.tertiary)
.frame(width: SystemStatusNetworkRowLayout.leadingColumnWidth, alignment: .center)
Text(value)
.font(.system(size: 9, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.monospacedDigit()
.help(value)
.layoutPriority(1)
if canCopy {
Button(action: copyToPasteboard) {
Image(systemName: "doc.on.doc")
.font(.system(size: 8, weight: .semibold))
.foregroundStyle(.secondary)
.frame(width: 12, height: 12)
}
.buttonStyle(.plain)
.opacity(isHovering ? 1 : 0)
.disabled(!isHovering)
.help(localization.format("network.copyIPHelpFormat", defaultValue: "复制%@ IP", label))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.onHover { isHovering = $0 }
}
private var canCopy: Bool {
guard let copyValue else {
return false
}
return !copyValue.isEmpty
}
private func copyToPasteboard() {
guard let copyValue, !copyValue.isEmpty else {
return
}
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(copyValue, forType: .string)
}
}
private struct SystemStatusTopProcessesCard: View {
let processes: [SystemStatusTopProcess]
let localization: PluginLocalization
var body: some View {
VStack(alignment: .leading, spacing: 6) {
header
if processes.isEmpty {
Text(localization.string("topProcesses.collecting", defaultValue: "采集中…"))
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
} else {
VStack(spacing: 4) {
ForEach(processes) { process in
SystemStatusProcessRow(process: process)
}
}
.frame(maxHeight: .infinity, alignment: .center)
}
}
.padding(SystemStatusComponentLayout.cardContentPadding)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(SystemStatusCardBackground(cornerRadius: SystemStatusComponentLayout.cardCornerRadius))
}
private var header: some View {
HStack(spacing: 6) {
Image(systemName: "list.bullet.rectangle")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Color(nsColor: .systemPink))
.frame(width: 14, height: 14)
Text(SystemStatusMetricKind.topProcesses.title(localization: localization))
.font(.system(size: 10.5, weight: .semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 2)
SystemStatusProcessMetricHeader()
.layoutPriority(1)
}
}
}
private struct SystemStatusProcessMetricHeader: View {
var body: some View {
HStack(spacing: SystemStatusProcessRow.metricColumnSpacing) {
Text("CPU")
.frame(width: SystemStatusProcessRow.cpuColumnWidth, alignment: .trailing)
Text("MEM")
.frame(width: SystemStatusProcessRow.memoryColumnWidth, alignment: .trailing)
}
.font(.system(size: 7.5, weight: .bold, design: .rounded))
.foregroundStyle(.tertiary)
.monospacedDigit()
.fixedSize(horizontal: true, vertical: false)
}
}
private struct SystemStatusProcessRow: View {
static let cpuColumnWidth: CGFloat = 28
static let memoryColumnWidth: CGFloat = 26
static let metricColumnSpacing: CGFloat = 1
let process: SystemStatusTopProcess
var body: some View {
HStack(spacing: 4) {
Text(process.displayName)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
.help(process.displayName)
Spacer(minLength: 1)
HStack(spacing: Self.metricColumnSpacing) {
metricText(SystemStatusFormatter.wholePercent(process.cpuPercent, fractionDigits: 0))
.frame(width: Self.cpuColumnWidth, alignment: .trailing)
metricText(SystemStatusFormatter.wholePercent(process.memoryPercent, fractionDigits: 0))
.frame(width: Self.memoryColumnWidth, alignment: .trailing)
}
.layoutPriority(1)
}
.frame(maxWidth: .infinity)
}
private func metricText(_ value: String) -> some View {
Text(value)
.font(.system(size: 8.5, weight: .semibold, design: .rounded))
.foregroundStyle(.secondary)
.monospacedDigit()
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
}
private struct SystemStatusCircularProgress: View {
let value: Double?
let tint: Color
private var clampedValue: Double {
min(max(value ?? 0, 0), 1)
}
var body: some View {
ZStack {
Circle()
.stroke(Color.primary.opacity(0.08), lineWidth: 4)
Circle()
.trim(from: 0, to: clampedValue)
.stroke(
tint.opacity(value == nil ? 0.22 : 0.86),
style: StrokeStyle(lineWidth: 4, lineCap: .round)
)
.rotationEffect(.degrees(-90))
}
}
}
private struct SystemStatusCardBackground: View {
let cornerRadius: CGFloat
var body: some View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color.primary.opacity(0.045))
}
}