Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift
T
gentpan 51b954983b 菜单栏新增“电池”项目:电量、24 小时曲线、功耗、健康度与蓝牙设备电量
- 电池按九种风格画在菜单栏,充电中带闪电,电量低于 20% 按“高负载时着色”变红;点击弹出电池详情,主窗口同名页面复用同一视图,历史页多了电池电量曲线
- 历史库加 battery 列(旧库首次打开时 ALTER TABLE 迁移),HistoryRecorder 单独查最近 24 小时电量供弹窗使用
- BluetoothController 按需轮询蓝牙设备电量:弹窗 / 页面开着每分钟,菜单栏需要时每 5 分钟;本机信息页的蓝牙卡片改用它
- 没有电池的 Mac 上电池项目改为显示电量最低的蓝牙设备;「设置 · 菜单栏」新增“蓝牙设备电量低时提示”开关(同步文档一并带上)
- README、架构说明、更新日志与译文同步
2026-09-15 03:58:30 +05:00

141 lines
7.6 KiB
Swift

import AppKit
import Localization
import Metrics
import SMC
import SwiftUI
import Updates
/// `OpenStats --snapshot <目录>`:用本机实时数据渲染各页面 PNG,用于设计走查
@MainActor
enum SnapshotRenderer {
static func run(outputDirectory: URL) async {
try? FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
let defaults = UserDefaults(suiteName: "OpenStats.snapshot") ?? .standard
let settings = AppSettings(defaults: defaults)
settings.menuBarItems = [.cpu, .gpu, .memory, .network, .disk, .temperature, .battery]
settings.probeSeconds = 1
// 历史页用示例数据:最近 24 小时每分钟一条,中间留一段“睡眠”空档
let historyURL = FileManager.default.temporaryDirectory.appendingPathComponent("openstats-snapshot-history.sqlite")
try? FileManager.default.removeItem(at: historyURL)
if let database = try? HistoryDatabase(url: historyURL) {
let now = Int(Date().timeIntervalSince1970) / 60 * 60
for index in 0..<(24 * 60) where !(600..<780).contains(index) {
let t = Double(index)
let wave = (sin(t / 90) + 1) / 2
await database.insert(HistoryRecord(minute: now - (24 * 60 - index) * 60,
cpu: 0.08 + wave * 0.3, cpuMax: min(1, 0.2 + wave * 0.6),
memory: 0.55 + wave * 0.2, pressure: index % 400 == 0 ? 4 : 1,
download: 200_000 + wave * 3_000_000, upload: 40_000 + wave * 400_000,
gpu: index % 3 == 0 ? wave * 0.4 : nil, temperature: 45 + wave * 30,
power: index > 1200 ? 20 + wave * 25 : nil))
}
}
let model = AppModel(settings: settings, historyURL: historyURL)
model.isMainWindowVisible = true
model.network.setVisibility(inMenuBar: true, detailVisible: true)
// 采集约 12 秒的真实数据,让历史曲线有内容
var demand = MetricsDemand()
demand.interval = .milliseconds(250)
demand.memory = true
demand.network = true
demand.gpu = true
demand.disk = true
demand.battery = true
demand.processes = true
demand.temperatures = Set(TemperatureGroup.allCases)
demand.fans = true
demand.power = true
demand.cpuFrequency = true
demand.diskDetail = true
await model.hub.update(demand)
await model.hub.start { snapshot in model.handle(snapshot) }
try? await Task.sleep(for: .seconds(12))
await model.hub.stop()
model.network.setVisibility(inMenuBar: false, detailVisible: false)
model.network.maskForSnapshot()
// 清理页展示真实扫描结果(只读,不删除任何文件)
model.cleaner.scan()
while model.cleaner.isBusy { try? await Task.sleep(for: .milliseconds(200)) }
model.history.load(.day)
model.startupItems.refresh()
model.uninstaller.loadApps()
try? await Task.sleep(for: .seconds(2))
model.updates.showPreview(UpdateRelease(
version: "0.3.0", build: "3", date: "2026-09-20", minimumSystem: "14.0",
url: URL(string: "https://getopenstats.com/download/OpenStats-0.3.0.zip")!, sha256: String(repeating: "0", count: 64),
size: 9_600_000, dmg: nil,
notes: [tr("在线升级:发现新版本时显示更新摘要,一键安装并自动重启"), tr("连接探测只在需要时运行,更省电"), tr("进程页刷新频率调整为每 2 秒")],
changelog: URL(string: "https://getopenstats.com/#changelog")))
for (appearanceName, suffix) in [(NSAppearance.Name.aqua, "light"), (.darkAqua, "dark")] {
guard let appearance = NSAppearance(named: appearanceName) else { continue }
NSApp.appearance = appearance
for tab in PanelTab.allCases {
settings.panelTab = tab
write(MainWindowView(), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("window-\(tab.rawValue)-\(suffix).png"))
}
write(UpdatePromptView {}, model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("update-prompt-\(suffix).png"))
for item in MenuBarItem.allCases where item != .fan {
write(PopoverRootView(item: item), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("popover-\(item.rawValue)-\(suffix).png"))
}
}
let menuBar = MenuBarRenderer.image(for: model)
for dark in [false, true] {
let preview = MenuBarRenderer.preview(menuBar, dark: dark)
// 垫上菜单栏底色,便于查看
let padded = NSImage(size: NSSize(width: preview.size.width + DS.Space.s4, height: preview.size.height), flipped: false) { rect in
NSColor(hex: dark ? 0x1F2937 : 0xE5E7EB).setFill()
rect.fill()
preview.draw(in: NSRect(x: DS.Space.s2, y: 0, width: preview.size.width, height: preview.size.height))
return true
}
writePNG(padded, scale: 2, to: outputDirectory.appendingPathComponent("menubar-\(dark ? "dark" : "light").png"))
}
print(tr("截图已输出到 \(outputDirectory.path)"))
}
/// 用真实的 NSHostingView 放进离屏窗口截图,渲染路径与 App 内一致
private static func write<V: View>(_ view: V, model: AppModel, appearance: NSAppearance, to url: URL) {
let hosting = NSHostingView(rootView: view
.environment(model)
.environment(\.isSnapshot, true))
hosting.appearance = appearance
let size = hosting.fittingSize
hosting.frame = CGRect(origin: .zero, size: size)
let window = NSWindow(contentRect: CGRect(origin: CGPoint(x: -10_000, y: -10_000), size: size),
styleMask: .borderless, backing: .buffered, defer: false)
window.appearance = appearance
window.backgroundColor = .dsBackground
window.isReleasedWhenClosed = false
window.contentView = hosting
window.orderFrontRegardless()
hosting.layoutSubtreeIfNeeded()
RunLoop.main.run(until: Date().addingTimeInterval(0.3))
defer { window.orderOut(nil) }
guard let rep = hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds) else { return }
hosting.cacheDisplay(in: hosting.bounds, to: rep)
try? rep.representation(using: .png, properties: [:])?.write(to: url)
}
private static func writePNG(_ image: NSImage, scale: CGFloat, to url: URL) {
let size = NSSize(width: image.size.width * scale, height: image.size.height * scale)
guard let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0) else { return }
bitmap.size = image.size
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap)
image.draw(in: NSRect(origin: .zero, size: image.size))
NSGraphicsContext.restoreGraphicsState()
try? bitmap.representation(using: .png, properties: [:])?.write(to: url)
}
}