菜单栏合并为一个图标时弹出完整面板:两列指标卡片、电池、高占用进程、摄像头与麦克风状态、防休眠与清理入口和累计清理记录;系统信息标签始终排成一行
This commit is contained in:
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)。需要时可以在废纸篓里放回。"),
|
||||
|
||||
Reference in new issue
Block a user