菜单栏合并为一个图标时弹出完整面板:两列指标卡片、电池、高占用进程、摄像头与麦克风状态、防休眠与清理入口和累计清理记录;系统信息标签始终排成一行

This commit is contained in:
gentpan committed 2026-10-01 16:51:41 +05:00
1 parent 85e22c3a04
commit 8ff769d590
14 files changed
+341 -269

No files matched your search

@@ -467,12 +467,14 @@ npm 缓存 npm Cache
左侧风扇 Left Fan
左耳 Left
已{}“{}” {} “{}”
已优化 Optimized
已使用 Developer ID 签名(团队 {}),辅助工具只接受同一团队签名的 OpenStats。 Signed with Developer ID (team {}); the helper only accepts OpenStats signed by the same team.
已停用 Off
已充满 Charged
已删除 {} 个快照,腾出的空间稍后会反映在“可清除”里 Deleted {} snapshots; the freed space will show up under purgeable shortly
已到设定时间,防休眠已关闭 Time's up; Keep Awake is off
已加载 Loaded
已卸载 Uninstalled
已取消 Cancelled
已取消登录 Sign-in cancelled
已启用 Enabled
@@ -485,6 +487,7 @@ npm 缓存 npm Cache
已拷贝 Copied
已推出“{}” Ejected “{}”
已是最新版本 Up to date
已清理 Cleaned
已用 Used
已用 {} · {} {} used · {}
已用内存占比 Memory used
@@ -566,7 +569,7 @@ npm 缓存 npm Cache
截图已输出到 {} Snapshots saved to {}
所属应用:{}({}) App: {} ({})
所有内容先移到废纸篓。 Everything goes to the Trash first.
所有指标合成一个图标,点击弹出状态总览,可切到各项详情 All metrics in one icon; click for a status overview, with tabs for each metric's details
所有指标合成一个图标,点击弹出完整面板:指标、进程与常用工具集中在一页 One icon for all metrics; click for a full panel with metrics, processes and tools on one page
所有检测目标都连不上。请检查网络连接,或者代理软件是否正常运行。 None of the test targets could be reached. Check your network connection and whether your proxy app is running properly.
所有用户 All Users
所有用户与系统级的启动项需要管理员权限,请在系统设置的登录项中管理 All-users and system items need admin rights; manage them in Login Items in System Settings
@@ -618,6 +621,9 @@ npm 缓存 npm Cache
推断 Inferred
搜索名称、PID 或用户 Search name, PID or user
搜索应用 Search apps
摄像头、麦克风使用中 Camera and mic in use
摄像头使用中 Camera in use
摄像头未在使用 Camera not in use
撤销 Undo
收起 Collapse
改回开机后累计 Back to since-boot totals
@@ -683,6 +689,7 @@ npm 缓存 npm Cache
未使用代理 No Proxy in Use
未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。 Unsaved work may be lost. Force Quit ends it immediately without letting it save.
未发现 None found
未在使用 Not in use
未安装 Not Installed
未完成的下载 Incomplete Downloads
未开启,Mac 按系统设置休眠 Off; your Mac sleeps as usual
@@ -820,6 +827,7 @@ npm 缓存 npm Cache
清理中… Cleaning…
清理可回收的缓存内存(需要管理员授权或辅助工具) Frees reclaimable cached memory (needs admin approval or the helper)
清理所选 {} Clean Selected {}
清理记录 Cleanup history
清空废纸篓 Empty Trash
清除 Clear
清除… Clear…
@@ -842,7 +850,6 @@ npm 缓存 npm Cache
物理地址 Hardware address
状态 Status
状态圆点 Status Dot
状态总览 Status Overview
状态正常 Healthy
状态良好 Healthy
现在 Now
@@ -995,7 +1002,6 @@ npm 缓存 npm Cache
菜单栏图标 Menu Bar Icons
菜单栏布局 Menu Bar Layout
菜单栏里的图标可以调整顺序:按住 ⌘ 键拖动任意一个,松开后位置会一直保留。新开启的项目由系统安排位置,可能离其他图标较远,拖一下就能挪到一起。 You can reorder the icons in the menu bar: hold ⌘ and drag any of them, and the position sticks. macOS picks where a newly enabled item first appears, sometimes away from the others; just drag it over.
菜单栏里还没有开启任何项目。 No items are turned on in the menu bar yet.
菜单栏项目 Menu Bar Items
菜单栏风格 Menu bar style
蓝牙设备 Bluetooth Devices
@@ -1088,7 +1094,6 @@ npm 缓存 npm Cache
退出应用… Quit App…
退出登录 Sign out
适中 Moderate
选择显示项目 Choose Items
通用 General
通电时间 Power-on hours
通电次数 Power cycles
@@ -1142,6 +1147,8 @@ npm 缓存 npm Cache
高负载 Heavy load
高负载时着色 Color High Load
高风险 High risk
麦克风使用中 Microphone in use
麦克风未在使用 Microphone not in use
默认只在菜单栏运行;打开后,主窗口开着时图标出现在程序坞与 ⌘Tab 中 OpenStats lives in the menu bar by default. Turn this on to show its icon in the Dock and ⌘Tab while the main window is open
({}) ({})
, ,
@@ -129,11 +129,12 @@ public enum Format {
return hours > 0 ? String(format: "%d:%02d:%02d", hours, minutes, secs) : String(format: "%d:%02d", minutes, secs)
}
public static func uptime(since date: Date, now: Date = Date()) -> String {
/// `compact` 只保留最大的单位:“1 天 14 小时” → “1 天”
public static func uptime(since date: Date, now: Date = Date(), compact: Bool = false) -> String {
let minutes = max(0, Int(now.timeIntervalSince(date) / 60))
let days = minutes / (60 * 24)
let hours = (minutes / 60) % 24
if days > 0 { return hours > 0 ? tr("\(days) 天 \(hours) 小时") : tr("\(days) 天") }
if days > 0 { return hours > 0 && !compact ? tr("\(days) 天 \(hours) 小时") : tr("\(days) 天") }
if hours > 0 { return tr("\(hours) 小时") }
return tr("\(minutes) 分钟")
}
@@ -0,0 +1,81 @@
import CoreAudio
import CoreMediaIO
import Foundation
/// 摄像头与麦克风是否正被某个进程使用。只读设备的“正在运行”属性,不打开设备,也不需要隐私授权
public struct MediaUsage: Sendable, Equatable {
public var camera = false
public var microphone = false
public init(camera: Bool = false, microphone: Bool = false) {
self.camera = camera
self.microphone = microphone
}
}
public enum MediaUsageSampler {
public static func sample() -> MediaUsage {
MediaUsage(camera: cameraInUse(), microphone: microphoneInUse())
}
// MARK: 麦克风(Core Audio)
private static func microphoneInUse() -> Bool {
audioDevices().contains { device in
hasInputStreams(device) && audioFlag(device, kAudioDevicePropertyDeviceIsRunningSomewhere)
}
}
private static func audioDevices() -> [AudioObjectID] {
var address = AudioObjectPropertyAddress(mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
let system = AudioObjectID(kAudioObjectSystemObject)
guard AudioObjectGetPropertyDataSize(system, &address, 0, nil, &size) == noErr, size > 0 else { return [] }
var devices = [AudioObjectID](repeating: 0, count: Int(size) / MemoryLayout<AudioObjectID>.size)
guard AudioObjectGetPropertyData(system, &address, 0, nil, &size, &devices) == noErr else { return [] }
return devices
}
private static func hasInputStreams(_ device: AudioObjectID) -> Bool {
var address = AudioObjectPropertyAddress(mSelector: kAudioDevicePropertyStreams,
mScope: kAudioObjectPropertyScopeInput,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
return AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr && size > 0
}
private static func audioFlag(_ device: AudioObjectID, _ selector: AudioObjectPropertySelector) -> Bool {
var address = AudioObjectPropertyAddress(mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var value: UInt32 = 0
var size = UInt32(MemoryLayout<UInt32>.size)
return AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) == noErr && value != 0
}
// MARK: 摄像头(Core Media IO)
private static func cameraInUse() -> Bool {
var address = CMIOObjectPropertyAddress(mSelector: CMIOObjectPropertySelector(kCMIOHardwarePropertyDevices),
mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal),
mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain))
let system = CMIOObjectID(kCMIOObjectSystemObject)
var size: UInt32 = 0
guard CMIOObjectGetPropertyDataSize(system, &address, 0, nil, &size) == noErr, size > 0 else { return false }
var devices = [CMIOObjectID](repeating: 0, count: Int(size) / MemoryLayout<CMIOObjectID>.size)
var used: UInt32 = 0
guard CMIOObjectGetPropertyData(system, &address, 0, nil, size, &used, &devices) == noErr else { return false }
return devices.contains { device in
var running = CMIOObjectPropertyAddress(mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere),
mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeWildcard),
mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementWildcard))
var value: UInt32 = 0
var valueUsed: UInt32 = 0
return CMIOObjectGetPropertyData(device, &running, 0, nil, UInt32(MemoryLayout<UInt32>.size), &valueUsed, &value) == noErr
&& value != 0
}
}
}
@@ -2,7 +2,7 @@ import AppKit
import Localization
import SwiftUI
/// 菜单栏图标:每项独立时一个指标一个图标,点击弹出该项详情;合并时只有一个图标,点击弹出状态总览,
/// 菜单栏图标:每项独立时一个指标一个图标,点击弹出该项详情;合并时只有一个图标,点击弹出完整面板,
/// 总览顶部的标签可以切到各项的详情
@MainActor
final class MenuBarController: NSObject {
@@ -143,7 +143,7 @@ final class MenuBarController: NSObject {
private func makeCombinedPanel() -> StatusPanel {
let model = self.model
let panel = StatusPanel(width: DS.Size.popoverWidth, minHeight: DS.Size.panelMinHeight / 2,
let panel = StatusPanel(width: DS.Size.dashboardPopoverWidth, minHeight: DS.Size.panelMinHeight / 2,
content: { CombinedPopoverView().environment(model) },
measuring: { CombinedPopoverView().environment(model).environment(\.isSnapshot, true) })
panel.onVisibilityChange = { [weak self] visible in
@@ -166,6 +166,8 @@ extension DS {
static let panelWidth: CGFloat = 640
/// 菜单栏单项详情弹窗的宽度
static let popoverWidth: CGFloat = 320
/// 菜单栏合并为一个图标时的面板:总览是两列卡片,比单项详情宽一些
static let dashboardPopoverWidth: CGFloat = 360
static let panelMinHeight: CGFloat = 320
static let panelGap: CGFloat = 4
/// 窗口顶栏高度:标准工具栏的标题栏(52pt),红绿灯、页面标题与右上角 32pt 的按钮在同一条水平线上,按钮上下各留 10pt
@@ -101,7 +101,8 @@ struct HealthReport {
}
}
private struct HealthHeader: View {
struct HealthHeader<Accessory: View>: View {
@ViewBuilder var accessory: Accessory
@Environment(AppModel.self) private var model
var body: some View {
@@ -120,21 +121,34 @@ private struct HealthHeader: View {
Text(report.summary)
.dsFont(.base, weight: .medium)
.foregroundStyle(DS.Palette.textSecondary)
Spacer()
.lineLimit(1)
Spacer(minLength: DS.Space.s2)
accessory
}
FlowLayout(spacing: DS.Space.s2) {
Chip(text: chipName(store.topology.brand), icon: "apple.logo")
Chip(text: Format.bytes(ProcessInfo.processInfo.physicalMemory).replacingOccurrences(of: ".0 ", with: " "))
Chip(text: store.system.osVersion)
if let boot = store.system.bootDate {
Chip(text: tr("已运行 ") + Format.uptime(since: boot))
}
Chip(text: shortModel(store.system.modelName))
// 始终一行:宽度不够时先去掉机型,再把运行时间缩成最大单位,最后去掉运行时间
ViewThatFits(in: .horizontal) {
chips(model: true, uptime: true, compactUptime: false)
chips(model: false, uptime: true, compactUptime: false)
chips(model: false, uptime: true, compactUptime: true)
chips(model: false, uptime: false, compactUptime: true)
}
}
.padding(.horizontal, DS.Space.s1)
}
private func chips(model showsModel: Bool, uptime showsUptime: Bool, compactUptime: Bool) -> some View {
let store = model.store
return HStack(spacing: DS.Space.s2) {
Chip(text: chipName(store.topology.brand), icon: "apple.logo")
Chip(text: Format.bytes(ProcessInfo.processInfo.physicalMemory).replacingOccurrences(of: ".0 ", with: " "))
Chip(text: store.system.osVersion)
if showsUptime, let boot = store.system.bootDate {
Chip(text: tr("已运行 ") + Format.uptime(since: boot, compact: compactUptime))
}
if showsModel { Chip(text: shortModel(store.system.modelName)) }
}
}
/// “Apple M5 Max” → “M5 Max”,前面配 Apple 标志
private func chipName(_ brand: String) -> String {
brand.hasPrefix("Apple ") ? String(brand.dropFirst("Apple ".count)) : brand
@@ -152,6 +166,10 @@ private struct HealthHeader: View {
}
}
extension HealthHeader where Accessory == EmptyView {
init() { self.init { EmptyView() } }
}
// MARK: - 指标卡片
func loadLevel(_ value: Double) -> String {
@@ -163,7 +181,7 @@ private func splitRate(_ text: String) -> (String, String) {
return (parts.first ?? text, parts.count > 1 ? parts[1] : "")
}
private struct CPUTile: View {
struct CPUTile: View {
@Environment(AppModel.self) private var model
var body: some View {
@@ -183,7 +201,7 @@ private struct CPUTile: View {
}
}
private struct GPUTile: View {
struct GPUTile: View {
@Environment(AppModel.self) private var model
var body: some View {
@@ -202,7 +220,7 @@ private struct GPUTile: View {
}
}
private struct MemoryTile: View {
struct MemoryTile: View {
@Environment(AppModel.self) private var model
var body: some View {
@@ -227,7 +245,7 @@ private struct MemoryTile: View {
}
}
private struct DiskTile: View {
struct DiskTile: View {
@Environment(AppModel.self) private var model
var body: some View {
@@ -249,7 +267,7 @@ private struct DiskTile: View {
}
}
private struct NetworkTile: View {
struct NetworkTile: View {
@Environment(AppModel.self) private var model
var body: some View {
@@ -278,7 +296,7 @@ private struct NetworkTile: View {
}
}
private struct FanTile: View {
struct FanTile: View {
@Environment(AppModel.self) private var model
var body: some View {
@@ -344,7 +362,7 @@ private struct CoreLoadCard: View {
// MARK: - 电池
private struct BatteryCard: View {
struct BatteryCard: View {
@Environment(AppModel.self) private var model
var body: some View {
@@ -405,7 +423,7 @@ private struct BatteryCard: View {
// MARK: - 高占用进程
private struct TopProcessesCard: View {
struct TopProcessesCard: View {
@Environment(AppModel.self) private var model
@Environment(\.isSnapshot) private var isSnapshot
private static let rowCount = 5
@@ -3,32 +3,46 @@ import Metrics
import SMC
import SwiftUI
/// 菜单栏合并为一个图标时点击弹出的面板:顶部一排标签切换“总览”与菜单栏里开启的各项。
/// 总览每项一行(名称、状态、走势、主数值),点一行或点标签看该项的完整详情,与每项独立时的弹窗相同
/// 菜单栏合并为一个图标时点击弹出的面板:顶部一排标签切换“总览”与这台 Mac 上的各项。
/// 总览把所有指标、高占用进程与常用工具集中在一页;点标签看某一项的完整详情,与每项独立时的弹窗相同
struct CombinedPopoverView: View {
@Environment(AppModel.self) private var model
var body: some View {
let items = model.settings.orderedMenuBarItems
// 标签对应的项目在设置里被关掉后回到总览
let tab = model.combinedPopoverTab.flatMap { items.contains($0) ? $0 : nil }
let items = tabItems
// 风扇与温度共用一个详情页;标签对应的项目在这台 Mac 上没有时回到总览
let requested = model.combinedPopoverTab == .fan ? .temperature : model.combinedPopoverTab
let tab = requested.flatMap { items.contains($0) ? $0 : nil }
PopoverFrame {
VStack(spacing: DS.Space.s2) {
if !items.isEmpty {
PopoverTabStrip(items: items, selection: tab) { model.combinedPopoverTab = $0 }
}
PopoverFrame(width: DS.Size.dashboardPopoverWidth) {
VStack(spacing: DS.Space.s3) {
PopoverTabStrip(items: items, selection: tab) { model.combinedPopoverTab = $0 }
if let tab {
PopoverHeader(item: tab)
} else {
OverviewPopoverHeader()
HealthHeader {
let page = PanelTab.overview
MiniIconButton(systemName: page.symbol, help: tr("在主窗口打开“\(page.title)”")) { model.openMainWindow(page) }
MiniIconButton(systemName: "gearshape", help: tr("设置")) { model.openSettings() }
}
}
}
} content: {
if let tab {
PopoverDetail(item: tab).id(tab)
} else {
OverviewPopover(items: items) { model.combinedPopoverTab = $0 }
DashboardPopover()
}
}
}
/// 标签里列出全部项目(与菜单栏里开了哪些无关);风扇并在“温度”里,没有电池的 Mac 不显示电池
private var tabItems: [MenuBarItem] {
MenuBarItem.allCases.filter { item in
switch item {
case .fan: false
case .battery: model.store.battery != nil
default: true
}
}
}
@@ -83,240 +97,131 @@ private struct PopoverTabStrip: View {
/// 总览的图标;主窗口“仪表盘”已经用了方格图标,这里换成仪表盘指针以示区别
private let overviewSymbol = "gauge.with.dots.needle.50percent"
private struct OverviewPopoverHeader: View {
@Environment(AppModel.self) private var model
var body: some View {
HStack(spacing: DS.Space.s2) {
Image(systemName: overviewSymbol)
.font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold))
.foregroundStyle(DS.Palette.textSecondary)
.frame(width: DS.Size.iconStandalone)
Text(tr("状态总览"))
.dsFont(.base, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
Spacer(minLength: DS.Space.s2)
let page = PanelTab.overview
MiniIconButton(systemName: page.symbol, help: tr("在主窗口打开“\(page.title)”")) { model.openMainWindow(page) }
MiniIconButton(systemName: "gearshape", help: tr("设置")) { model.openSettings() }
}
}
}
// MARK: - 总览
private struct OverviewPopover: View {
let items: [MenuBarItem]
let open: (MenuBarItem) -> Void
/// 总览:两列指标卡片(与主窗口仪表盘共用),下面是电池、高占用进程与常用工具
private struct DashboardPopover: View {
@Environment(AppModel.self) private var model
var body: some View {
if items.isEmpty {
Card {
Text(tr("菜单栏里还没有开启任何项目。"))
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
Button(tr("选择显示项目")) { model.openMainWindow(.settingsMenuBar) }
.buttonStyle(DSButtonStyle(kind: .secondary))
VStack(spacing: DS.Space.s3) {
WeightedRow {
CPUTile()
GPUTile()
}
} else {
ForEach(items) { item in
OverviewRow(item: item) { open(item) }
WeightedRow {
MemoryTile()
DiskTile()
}
WeightedRow {
NetworkTile()
FanTile()
}
if model.store.battery != nil { BatteryCard() }
TopProcessesCard()
ToolsCard()
}
.padding(.top, DS.Space.s1)
.padding(.bottom, DS.Space.s2)
// 总览用实色卡片排版,不用弹窗详情的分隔线样式
.environment(\.isPopover, false)
}
}
/// 总览里的一行:左边名称与一句状态,中间是走势,右边是主数值;点一下进入这一项的详情
private struct OverviewRow: View {
let item: MenuBarItem
let open: () -> Void
/// 常用工具:摄像头与麦克风占用、防休眠与清理入口,以及累计清理记录
private struct ToolsCard: View {
@Environment(AppModel.self) private var model
@State private var media = MediaUsage()
var body: some View {
let keepAwake = model.keepAwake
let tally = model.cleanupTally
Card(padding: DS.Space.s3, spacing: DS.Space.s3) {
HStack(spacing: DS.Space.s2) {
Image(systemName: media.camera ? "video.fill" : "video")
.foregroundStyle(media.camera ? DS.Palette.success : DS.Palette.textSecondary)
.help(media.camera ? tr("摄像头使用中") : tr("摄像头未在使用"))
Image(systemName: media.microphone ? "mic.fill" : "mic")
.foregroundStyle(media.microphone ? DS.Palette.warning : DS.Palette.textSecondary)
.help(media.microphone ? tr("麦克风使用中") : tr("麦克风未在使用"))
Text(mediaText)
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
Spacer(minLength: DS.Space.s2)
ToolButton(icon: "cup.and.saucer", title: tr("防休眠"), isOn: keepAwake.isActive) {
Task { await keepAwake.setActive(!keepAwake.isActive) }
}
ToolButton(icon: "sparkles", title: tr("清理"), isOn: false) {
model.openMainWindow(.cleaner)
}
}
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
HairlineDivider()
HStack(spacing: DS.Space.s1) {
Image(systemName: "clock.arrow.circlepath").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text(tr("清理记录")).dsFont(.xs, weight: .semibold)
}
.foregroundStyle(DS.Palette.textSecondary)
HStack(alignment: .top, spacing: DS.Space.s3) {
stat(Format.bytes(tally.freedBytes, base: .decimal), label: tr("已清理"))
stat("\(tally.uninstalledApps)", label: tr("已卸载"))
stat("\(tally.optimizations)", label: tr("已优化"))
}
}
.task {
// 读设备状态很快,面板打开期间每 2 秒刷新一次
while !Task.isCancelled {
media = MediaUsageSampler.sample()
try? await Task.sleep(for: .seconds(2))
}
}
}
private var mediaText: String {
switch (media.camera, media.microphone) {
case (true, true): tr("摄像头、麦克风使用中")
case (true, false): tr("摄像头使用中")
case (false, true): tr("麦克风使用中")
case (false, false): tr("未在使用")
}
}
private func stat(_ value: String, label: String) -> some View {
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text(verbatim: value)
.dsFont(.lg, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.8)
Text(label).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// 工具卡片里的文字按钮:图标加名称,开启时用品牌色
private struct ToolButton: View {
let icon: String
let title: String
let isOn: Bool
let action: () -> Void
@State private var hovering = false
var body: some View {
let reading = OverviewReading(item: item, model: model)
Card {
Button(action: open) {
HStack(spacing: DS.Space.s2) {
Image(systemName: item.symbol)
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
.foregroundStyle(hovering ? DS.Palette.primary : DS.Palette.textSecondary)
.frame(width: DS.Size.iconStandalone)
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text(item.popoverTitle)
.dsFont(.sm, weight: .semibold)
.foregroundStyle(hovering ? DS.Palette.primary : DS.Palette.textPrimary)
Text(verbatim: reading.detail)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.monospacedDigit()
}
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
OverviewChart(chart: reading.chart)
.frame(width: DS.Space.s12, height: DS.Space.s6)
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1 / 2) {
Text(verbatim: reading.value)
.dsFont(.base, weight: .semibold)
.foregroundStyle(reading.tone == .neutral ? DS.Palette.textPrimary : reading.tone.color)
if let unit = reading.unit {
Text(verbatim: unit)
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textSecondary)
}
}
.monospacedDigit()
.lineLimit(1)
.frame(minWidth: DS.Space.s12, alignment: .trailing)
Image(systemName: "chevron.right")
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
.foregroundStyle(hovering ? DS.Palette.primary : DS.Palette.textTertiary)
}
Button(action: action) {
Label(title, systemImage: icon)
.dsFont(.xs, weight: .semibold)
.foregroundStyle(isOn || hovering ? DS.Palette.primary : DS.Palette.textSecondary)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
.help(tr("查看\(item.popoverTitle)详情"))
}
}
}
private struct OverviewChart: View {
let chart: OverviewReading.Chart
var body: some View {
switch chart {
case .bars(let values):
BarHistoryChart(values: values, capacity: 20, height: DS.Space.s6)
case .line(let values, let color):
LineHistoryChart(values: values, capacity: 30, color: color, height: DS.Space.s6)
case .rates(let upload, let download):
DualLineChart(upload: upload, download: download, capacity: 30, height: DS.Space.s6)
case .level(let fraction, let color):
ProgressTrack(fraction: fraction, color: color, height: DS.Space.s2)
.frame(maxHeight: .infinity)
case .none:
Color.clear
}
}
}
/// 每一项在总览里显示的内容
private struct OverviewReading {
enum Chart {
case bars([Double])
case line([Double], Color)
case rates(upload: [Double], download: [Double])
case level(Double, Color)
case none
}
var value = "—"
var unit: String?
var detail = ""
var tone = Tone.neutral
var chart = Chart.none
@MainActor
init(item: MenuBarItem, model: AppModel) {
let store = model.store
let fahrenheit = model.settings.useFahrenheit
let temperature = { (group: TemperatureGroup) in
store.sensors?.temperature(group).map { Format.temperature($0.maximum, fahrenheit: fahrenheit) }
}
switch item {
case .cpu:
chart = .bars(store.cpuTotal.elements)
if let total = store.cpu?.total {
setPercent(total)
tone = Self.loadTone(total)
detail = [loadLevel(total), temperature(.cpu)].compactMap { $0 }.joined(separator: " · ")
}
case .memory:
chart = .line(store.memoryHistory.elements, DS.Palette.primary)
if let memory = store.memory {
setPercent(memory.usedFraction)
tone = memory.pressure == .critical ? .error : memory.pressure == .warning ? .warning : .neutral
// 压力正常时不占地方,偏高才写出来(数值同时变色)
let usage = "\(Format.bytes(memory.used)) / \(Format.bytes(memory.total))"
detail = memory.pressure == .normal ? usage : tr("压力 \(memory.pressure.title)") + " · " + usage
}
case .network:
chart = .rates(upload: store.uploadHistory.elements, download: store.downloadHistory.elements)
if let rate = store.network {
let total = Format.menuBarRate(rate.uploadBytesPerSecond + rate.downloadBytesPerSecond)
let parts = total.split(separator: " ", maxSplits: 1).map(String.init)
value = parts.first ?? total
unit = parts.count > 1 ? parts[1] : nil
detail = "↑ \(Format.menuBarRate(rate.uploadBytesPerSecond)) ↓ \(Format.menuBarRate(rate.downloadBytesPerSecond))"
}
case .gpu:
chart = .line(store.gpuHistory.elements, DS.Palette.secondary)
if let gpu = store.gpu {
setPercent(gpu.utilization)
tone = Self.loadTone(gpu.utilization)
detail = [loadLevel(gpu.utilization), temperature(.gpu)].compactMap { $0 }.joined(separator: " · ")
}
case .disk:
if let disk = store.disk {
setPercent(disk.usedFraction)
chart = .level(disk.usedFraction, disk.usedFraction > 0.9 ? DS.Palette.warning : DS.Palette.primary)
detail = tr("可用 \(Format.bytes(disk.available, base: .decimal))")
}
case .temperature:
if let cpu = store.sensors?.temperature(.cpu) {
value = Format.temperature(cpu.maximum, fahrenheit: fahrenheit)
tone = Tone.forTemperature(cpu.maximum) == .primary ? .neutral : Tone.forTemperature(cpu.maximum)
chart = .level(cpu.maximum / 100, Tone.forTemperature(cpu.maximum).color)
// 状态行写 CPU 以外温度最高的一组
let next = (store.sensors?.temperatures ?? [])
.filter { $0.group != .cpu }
.max { $0.maximum < $1.maximum }
detail = next.map { "\($0.group.title) \(Format.temperature($0.maximum, fahrenheit: fahrenheit))" }
?? tr("CPU 核心最高温度")
}
case .fan:
let fans = store.sensors?.fans ?? []
if let fastest = store.fastestFan {
value = Int(fastest.current).formatted()
unit = "RPM"
let average = fans.map { $0.maximum > 0 ? $0.current / $0.maximum : 0 }.reduce(0, +) / Double(max(1, fans.count))
chart = .level(average, DS.Palette.primary)
}
detail = fanStatusText(fans: fans, mode: model.fans.mode)
case .battery:
if let battery = store.battery {
setPercent(battery.level)
tone = battery.level <= 0.2 && !battery.isCharging ? .error : .neutral
chart = .level(battery.level, battery.level <= 0.2 ? DS.Palette.error : battery.isCharging ? DS.Palette.primary : DS.Palette.success)
// 充电中的时间是充满所需,其余是还能用多久,状态已经说明了是哪一种
detail = [battery.stateText, battery.minutesRemaining.map { Format.duration(minutes: $0) }]
.compactMap { $0 }.joined(separator: " · ")
} else if let lowest = model.bluetooth.lowest {
// 没有电池的 Mac:与菜单栏一致,显示电量最低的蓝牙设备
value = "\(lowest.percent)"
unit = "%"
tone = lowest.percent <= MenuBarReading.lowBluetoothPercent ? .error : .neutral
chart = .level(Double(lowest.percent) / 100, tone == .error ? DS.Palette.error : DS.Palette.success)
detail = lowest.label.isEmpty ? lowest.device.name : "\(lowest.device.name) · \(lowest.label)"
} else {
detail = tr("没有电池")
}
}
}
private mutating func setPercent(_ fraction: Double) {
value = "\(Int((fraction * 100).rounded()))"
unit = "%"
}
private static func loadTone(_ value: Double) -> Tone {
value >= 0.85 ? .error : value >= 0.6 ? .warning : .neutral
.buttonStyle(.plain)
.onHover { hovering = $0 }
.accessibilityAddTraits(isOn ? .isSelected : [])
}
}
@@ -18,6 +18,7 @@ struct PopoverRootView: View {
/// 菜单栏弹窗的外框:顶部标题栏固定,下面的内容可滚动
struct PopoverFrame<Header: View, Content: View>: View {
var width: CGFloat = DS.Size.popoverWidth
@ViewBuilder var header: Header
@ViewBuilder var content: Content
@Environment(\.isSnapshot) private var isSnapshot
@@ -32,7 +33,7 @@ struct PopoverFrame<Header: View, Content: View>: View {
.frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
.environment(\.isPopover, true)
}
.frame(width: DS.Size.popoverWidth)
.frame(width: width)
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
.fixedSize(horizontal: false, vertical: isSnapshot)
.background(DS.Palette.background, in: RoundedRectangle(cornerRadius: DS.Radius.xl))
@@ -287,7 +287,7 @@ struct MenuBarSettings: View {
SettingRow(title: tr("菜单栏图标"),
subtitle: settings.menuBarLayout == .separate
? tr("每个指标一个图标,点击弹出该项详情")
: tr("所有指标合成一个图标,点击弹出状态总览,可切到各项详情")) {
: tr("所有指标合成一个图标,点击弹出完整面板:指标、进程与常用工具集中在一页")) {
SegmentedControl(selection: $settings.menuBarLayout,
options: MenuBarLayout.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.sidebarWidth + DS.Space.s6)
@@ -16,6 +16,7 @@ public final class AppModel {
public let keepAwake: KeepAwakeController
public let cleaner: CleanerController
public let maintenance: MaintenanceController
public let cleanupTally: CleanupTally
public let network: NetworkController
public let egress = EgressController()
public let speedTest: SpeedTestController
@@ -37,7 +38,7 @@ public final class AppModel {
/// 当前打开的菜单栏详情弹窗;合并模式的弹窗切到某一项时也是这一项
public var openPopover: MenuBarItem?
/// 合并模式下点击菜单栏图标弹出的面板正在显示。
/// 面板切到某一项的详情时 openPopover 就是这一项,按那一项的弹窗加采数据;总览只用菜单栏本来就在采的指标
/// 面板切到某一项的详情时 openPopover 就是这一项,按那一项的弹窗加采数据;总览与主窗口仪表盘采同样的数据
public var isCombinedPopoverOpen = false {
didSet {
guard isCombinedPopoverOpen != oldValue else { return }
@@ -69,8 +70,11 @@ public final class AppModel {
self.helper = helper
fans = FanController(helper: helper, store: store, settings: settings)
keepAwake = KeepAwakeController(helper: helper, settings: settings)
cleaner = CleanerController(settings: settings)
maintenance = MaintenanceController(helper: helper)
let cleanupTally = CleanupTally()
self.cleanupTally = cleanupTally
cleaner = CleanerController(settings: settings, tally: cleanupTally)
maintenance = MaintenanceController(helper: helper, tally: cleanupTally)
uninstaller.tally = cleanupTally
network = NetworkController(settings: settings)
updates = UpdateController(settings: settings)
alerts = AlertController(settings: settings)
@@ -88,6 +92,8 @@ public final class AppModel {
let popover = openPopover
let menu = settings.menuBarItems
let thermalPopover = popover == .temperature || popover == .fan
// 主窗口仪表盘或合并面板的总览
let overview = (window && tab == .overview) || (isCombinedPopoverOpen && combinedPopoverTab == nil)
// 进程页要读全系统进程(启动 ps),每 2 秒刷新一次足够,也更省电
demand.interval = window && tab == .processes && popover == nil ? .seconds(2)
@@ -95,17 +101,17 @@ public final class AppModel {
demand.memory = true
demand.network = true
let showing = { (page: PanelTab, item: MenuBarItem) in (window && tab == page) || popover == item }
demand.gpu = (window && [.overview, .system].contains(tab)) || menu.contains(.gpu) || showing(.gpu, .gpu)
demand.disk = (window && [.overview, .system, .cleaner, .disk].contains(tab)) || menu.contains(.disk) || popover == .disk
demand.gpu = overview || (window && tab == .system) || menu.contains(.gpu) || showing(.gpu, .gpu)
demand.disk = overview || (window && [.system, .cleaner, .disk].contains(tab)) || menu.contains(.disk) || popover == .disk
demand.diskDetail = showing(.disk, .disk)
demand.battery = (window && [.overview, .system, .keepAwake].contains(tab)) || keepAwake.lidClosedActive
demand.battery = overview || (window && [.system, .keepAwake].contains(tab)) || keepAwake.lidClosedActive
|| menu.contains(.battery) || showing(.battery, .battery)
demand.processes = (window && [.processes, .overview, .disk].contains(tab)) || showing(.cpu, .cpu) || showing(.memory, .memory)
demand.processes = overview || (window && [.processes, .disk].contains(tab)) || showing(.cpu, .cpu) || showing(.memory, .memory)
|| popover == .disk
demand.systemProcesses = window && tab == .processes
var groups = Set<TemperatureGroup>()
if window && tab == .overview { groups.formUnion([.cpu, .gpu]) }
if overview { groups.formUnion([.cpu, .gpu]) }
if (window && tab == .thermal) || thermalPopover { groups.formUnion(TemperatureGroup.allCases) }
if menu.contains(.temperature) || fans.mode != .automatic || showing(.cpu, .cpu) { groups.insert(.cpu) }
if showing(.gpu, .gpu) { groups.insert(.gpu) }
@@ -114,7 +120,7 @@ public final class AppModel {
demand.temperatures = groups
demand.power = (window && tab == .thermal) || thermalPopover || showing(.battery, .battery)
demand.cpuFrequency = showing(.cpu, .cpu)
demand.fans = (window && (tab == .thermal || tab == .overview)) || menu.contains(.fan)
demand.fans = overview || (window && tab == .thermal) || menu.contains(.fan)
|| fans.mode != .automatic || thermalPopover
return demand
}
@@ -18,11 +18,13 @@ public final class CleanerController {
public var isConfirming = false
@ObservationIgnored private let settings: AppSettings
@ObservationIgnored private let tally: CleanupTally
@ObservationIgnored private let rules = RuleCatalog.rules()
@ObservationIgnored private var task: Task<Void, Never>?
init(settings: AppSettings) {
init(settings: AppSettings, tally: CleanupTally) {
self.settings = settings
self.tally = tally
selection = Set(rules.filter(\.selectedByDefault).map(\.id))
}
@@ -62,6 +64,7 @@ public final class CleanerController {
task = Task {
let result = await CleanEngine.clean(scans, selected: selection, preferTrash: preferTrash, environment: environment)
report = result
tally.recordClean(bytes: result.freedBytes)
phase = .finished
// 清理后重新计算剩余可清理空间
let rescanned = await CleanEngine.scan(rules, environment: Self.environment())
@@ -0,0 +1,43 @@
import Foundation
import Observation
/// 累计清理记录:清理释放的空间、卸载的应用数、执行过的系统优化次数(刷新 DNS、释放内存),显示在菜单栏总览面板
@MainActor
@Observable
public final class CleanupTally {
public private(set) var freedBytes: UInt64
public private(set) var uninstalledApps: Int
public private(set) var optimizations: Int
@ObservationIgnored private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
freedBytes = UInt64(max(0, defaults.double(forKey: Keys.freedBytes)))
uninstalledApps = defaults.integer(forKey: Keys.uninstalledApps)
optimizations = defaults.integer(forKey: Keys.optimizations)
}
func recordClean(bytes: UInt64) {
guard bytes > 0 else { return }
freedBytes += bytes
defaults.set(Double(freedBytes), forKey: Keys.freedBytes)
}
func recordUninstall(bytes: UInt64) {
uninstalledApps += 1
defaults.set(uninstalledApps, forKey: Keys.uninstalledApps)
recordClean(bytes: bytes)
}
func recordOptimization() {
optimizations += 1
defaults.set(optimizations, forKey: Keys.optimizations)
}
private enum Keys {
static let freedBytes = "cleanupFreedBytes"
static let uninstalledApps = "cleanupUninstalledApps"
static let optimizations = "cleanupOptimizations"
}
}
@@ -19,9 +19,11 @@ public final class MaintenanceController {
public private(set) var dnsOutcome: Outcome?
@ObservationIgnored private let helper: HelperClient
@ObservationIgnored private let tally: CleanupTally
init(helper: HelperClient) {
init(helper: HelperClient, tally: CleanupTally) {
self.helper = helper
self.tally = tally
}
func run(_ command: MaintenanceCommand) async {
@@ -41,6 +43,7 @@ public final class MaintenanceController {
outcomes[command] = Outcome(text: error, isError: error != Self.cancelled)
return
}
tally.recordOptimization()
switch command {
case .flushDNS:
outcomes[command] = Outcome(text: tr("DNS 缓存已刷新"), isError: false)
@@ -18,6 +18,7 @@ public final class UninstallerController {
public private(set) var isRemoving = false
public private(set) var outcome: (text: String, isError: Bool)?
var chosen: Set<String> = []
@ObservationIgnored var tally: CleanupTally?
/// 有应用启动或退出时变化,让“正在运行”的提示跟着刷新
private var runningRevision = 0
@ObservationIgnored private var observers: [NSObjectProtocol] = []
@@ -122,6 +123,7 @@ public final class UninstallerController {
}
// 应用已经进了废纸篓,程序坞里的图标只会变成问号,一并移除
let dock = Self.removeDockTile(for: app.url)
self.tally?.recordUninstall(bytes: freed)
Log.app.notice("卸载 \(app.bundleIdentifier, privacy: .public),移到废纸篓 \(movedCount) 项")
let partial = movedCount < urls.count ? tr(",\(urls.count - movedCount) 项未能移动") : ""
self.outcome = (tr("已将 \(app.name) 与 \(movedCount - 1) 项残留移到废纸篓,约 \(Format.bytes(freed, base: .decimal))\(dock ? tr(",已从程序坞移除") : "")\(partial)。需要时可以在废纸篓里放回。"),