From 8ff769d59089f295ffb55317f488f58827db5011 Mon Sep 17 00:00:00 2001 From: gentpan Date: Thu, 1 Oct 2026 16:51:41 +0500 Subject: [PATCH] =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=A0=8F=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E4=B8=BA=E4=B8=80=E4=B8=AA=E5=9B=BE=E6=A0=87=E6=97=B6=E5=BC=B9?= =?UTF-8?q?=E5=87=BA=E5=AE=8C=E6=95=B4=E9=9D=A2=E6=9D=BF=EF=BC=9A=E4=B8=A4?= =?UTF-8?q?=E5=88=97=E6=8C=87=E6=A0=87=E5=8D=A1=E7=89=87=E3=80=81=E7=94=B5?= =?UTF-8?q?=E6=B1=A0=E3=80=81=E9=AB=98=E5=8D=A0=E7=94=A8=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E3=80=81=E6=91=84=E5=83=8F=E5=A4=B4=E4=B8=8E=E9=BA=A6=E5=85=8B?= =?UTF-8?q?=E9=A3=8E=E7=8A=B6=E6=80=81=E3=80=81=E9=98=B2=E4=BC=91=E7=9C=A0?= =?UTF-8?q?=E4=B8=8E=E6=B8=85=E7=90=86=E5=85=A5=E5=8F=A3=E5=92=8C=E7=B4=AF?= =?UTF-8?q?=E8=AE=A1=E6=B8=85=E7=90=86=E8=AE=B0=E5=BD=95=EF=BC=9B=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=BF=A1=E6=81=AF=E6=A0=87=E7=AD=BE=E5=A7=8B=E7=BB=88?= =?UTF-8?q?=E6=8E=92=E6=88=90=E4=B8=80=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Localization/Translations.swift | 15 +- .../Sources/Metrics/Formatting.swift | 5 +- .../Sources/Metrics/MediaUsageSampler.swift | 81 ++++ .../AppKit/MenuBarController.swift | 4 +- .../Sources/OpenStatsUI/Design/Tokens.swift | 2 + .../OpenStatsUI/Panel/OverviewPage.swift | 54 ++- .../OpenStatsUI/Popover/CombinedPopover.swift | 365 +++++++----------- .../OpenStatsUI/Popover/PopoverRootView.swift | 3 +- .../OpenStatsUI/Settings/SettingsPages.swift | 2 +- .../Sources/OpenStatsUI/State/AppModel.swift | 24 +- .../OpenStatsUI/State/CleanerController.swift | 5 +- .../OpenStatsUI/State/CleanupTally.swift | 43 +++ .../State/MaintenanceController.swift | 5 +- .../State/UninstallerController.swift | 2 + 14 files changed, 341 insertions(+), 269 deletions(-) create mode 100644 Packages/OpenStatsKit/Sources/Metrics/MediaUsageSampler.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/State/CleanupTally.swift diff --git a/Packages/OpenStatsKit/Sources/Localization/Translations.swift b/Packages/OpenStatsKit/Sources/Localization/Translations.swift index 2d7eaec..cbd5976 100644 --- a/Packages/OpenStatsKit/Sources/Localization/Translations.swift +++ b/Packages/OpenStatsKit/Sources/Localization/Translations.swift @@ -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 ({}) ({}) , , diff --git a/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift b/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift index 70f4a1e..aa853c3 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift @@ -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) 分钟") } diff --git a/Packages/OpenStatsKit/Sources/Metrics/MediaUsageSampler.swift b/Packages/OpenStatsKit/Sources/Metrics/MediaUsageSampler.swift new file mode 100644 index 0000000..f775ab2 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/MediaUsageSampler.swift @@ -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.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.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.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.size), &valueUsed, &value) == noErr + && value != 0 + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift index 0564292..6ca0a8e 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift @@ -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 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift index c6f5150..9e5b2dc 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift @@ -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 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift index 0b95a64..ed0880d 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift @@ -101,7 +101,8 @@ struct HealthReport { } } -private struct HealthHeader: View { +struct HealthHeader: 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 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/CombinedPopover.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/CombinedPopover.swift index 94404b8..d0dd064 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/CombinedPopover.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/CombinedPopover.swift @@ -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 : []) } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift index 75aad24..2ff011f 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift @@ -18,6 +18,7 @@ struct PopoverRootView: View { /// 菜单栏弹窗的外框:顶部标题栏固定,下面的内容可滚动 struct PopoverFrame: 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: 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)) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift index 9c8aadb..f90ef07 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift @@ -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) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index b3a2837..f08474d 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -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() - 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 } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/CleanerController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/CleanerController.swift index 1807326..9485e07 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/CleanerController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/CleanerController.swift @@ -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? - 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()) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/CleanupTally.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/CleanupTally.swift new file mode 100644 index 0000000..46f73c3 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/CleanupTally.swift @@ -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" + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift index a8b0a34..806e846 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift @@ -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) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift index 8997c7a..f71b407 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift @@ -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 = [] + @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)。需要时可以在废纸篓里放回。"),