- 电池按九种风格画在菜单栏,充电中带闪电,电量低于 20% 按“高负载时着色”变红;点击弹出电池详情,主窗口同名页面复用同一视图,历史页多了电池电量曲线 - 历史库加 battery 列(旧库首次打开时 ALTER TABLE 迁移),HistoryRecorder 单独查最近 24 小时电量供弹窗使用 - BluetoothController 按需轮询蓝牙设备电量:弹窗 / 页面开着每分钟,菜单栏需要时每 5 分钟;本机信息页的蓝牙卡片改用它 - 没有电池的 Mac 上电池项目改为显示电量最低的蓝牙设备;「设置 · 菜单栏」新增“蓝牙设备电量低时提示”开关(同步文档一并带上) - README、架构说明、更新日志与译文同步
141 lines
7.6 KiB
Swift
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)
|
|
}
|
|
}
|