diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05466f0..087d8a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@
- 历史(主窗口监控分组):每分钟把 CPU 平均与峰值、内存与内存压力、网络上下行、GPU、CPU 最高温度和整机功耗写入本机 SQLite 数据库,保留 7 天;可回看最近 1 小时 / 24 小时 / 7 天,睡眠等没有记录的时段线条断开,内存压力严重的时段标红,鼠标移到图上查看那一刻的数值;可关闭记录或清除历史,数据不上传。
- 蓝牙设备电量:本机信息页列出已连接的键盘、鼠标、触控板和耳机的电量(AirPods 分左耳、右耳、充电盒),低于 20% 标红;通知里新增“蓝牙设备电量低”(低于 15%)。
- 全局快捷键(设置 · 通用):可为显示 / 隐藏主窗口、打开进程页、开关防休眠、释放内存录制快捷键,在任何应用中都能用,不需要辅助功能权限;被其他应用占用时提示更换。
+- 桌面小组件“系统概览”:小号显示 CPU、内存圆环与磁盘用量,中号显示 CPU、内存、磁盘、电池四个圆环和磁盘可用空间;在自己的沙盒进程里读取数据,主应用没开也能显示,刷新间隔由系统决定(几分钟一次)。
#### 调整
diff --git a/Scripts/release.sh b/Scripts/release.sh
index 1ae549b..6ff2a8f 100755
--- a/Scripts/release.sh
+++ b/Scripts/release.sh
@@ -40,7 +40,7 @@ echo "版本 ${VERSION} · 签名身份:${SIGN_ID}"
make build CONFIG=Release SIGN_ID="$SIGN_ID"
codesign --verify --deep --strict --verbose=2 "$APP"
-for binary in "$APP" "$APP/Contents/MacOS/OpenStatsHelper"; do
+for binary in "$APP" "$APP/Contents/MacOS/OpenStatsHelper" "$APP/Contents/PlugIns/OpenStatsWidget.appex"; do
details="$(codesign -dvv "$binary" 2>&1)"
echo "$details" | grep -q "TeamIdentifier=${TEAM_ID}" \
|| { echo "error: $binary 未使用团队 ${TEAM_ID} 签名" >&2; exit 1; }
diff --git a/Widget/Info.plist b/Widget/Info.plist
new file mode 100644
index 0000000..ca6d52b
--- /dev/null
+++ b/Widget/Info.plist
@@ -0,0 +1,31 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ OpenStats
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSMinimumSystemVersion
+ $(MACOSX_DEPLOYMENT_TARGET)
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/Widget/OpenStatsWidget.entitlements b/Widget/OpenStatsWidget.entitlements
new file mode 100644
index 0000000..852fa1a
--- /dev/null
+++ b/Widget/OpenStatsWidget.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+
+
diff --git a/Widget/OpenStatsWidget.swift b/Widget/OpenStatsWidget.swift
new file mode 100644
index 0000000..16c7c23
--- /dev/null
+++ b/Widget/OpenStatsWidget.swift
@@ -0,0 +1,213 @@
+import Metrics
+import SwiftUI
+import WidgetKit
+
+// 桌面小组件:在自己的沙盒进程里直接读取 CPU、内存、磁盘与电池,不依赖主应用是否运行。
+// 刷新频率由系统决定(通常每 5 到 15 分钟),适合“看一眼”,实时数据仍以菜单栏为准。
+
+struct SystemEntry: TimelineEntry {
+ let date: Date
+ let cpu: Double?
+ let memory: Double?
+ let memoryPressure: MemoryPressure?
+ let disk: Double?
+ let diskFree: UInt64?
+ let battery: Double?
+ let charging: Bool
+
+ static let placeholder = SystemEntry(date: .now, cpu: 0.18, memory: 0.62, memoryPressure: .normal,
+ disk: 0.41, diskFree: 1_200_000_000_000, battery: 0.86, charging: false)
+}
+
+struct SystemProvider: TimelineProvider {
+ func placeholder(in context: Context) -> SystemEntry { .placeholder }
+
+ func getSnapshot(in context: Context, completion: @escaping (SystemEntry) -> Void) {
+ completion(context.isPreview ? .placeholder : Self.read())
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ let entry = Self.read()
+ completion(Timeline(entries: [entry], policy: .after(entry.date.addingTimeInterval(5 * 60))))
+ }
+
+ /// CPU 占用需要两次采样,间隔半秒
+ static func read() -> SystemEntry {
+ var sampler = CPUSampler()
+ _ = sampler.sample()
+ Thread.sleep(forTimeInterval: 0.5)
+ let cpu = sampler.sample()
+ let memory = MemorySampler().sample()
+ let disk = DiskSampler.sample()
+ let battery = BatterySampler.sample()
+ return SystemEntry(date: .now, cpu: cpu?.total, memory: memory?.usedFraction, memoryPressure: memory?.pressure,
+ disk: disk?.usedFraction, diskFree: disk?.available,
+ battery: battery?.level, charging: battery?.isCharging ?? false)
+ }
+}
+
+// MARK: - 视图
+
+private enum Palette {
+ static let primary = Color(light: 0x2563EB, dark: 0x3B82F6)
+ static let success = Color(light: 0x16A34A, dark: 0x22C55E)
+ static let warning = Color(light: 0xD97706, dark: 0xF59E0B)
+ static let error = Color(light: 0xDC2626, dark: 0xF87171)
+ static let textPrimary = Color(light: 0x111827, dark: 0xF3F4F6)
+ static let textSecondary = Color(light: 0x6B7280, dark: 0x9CA3AF)
+ static let track = Color.primary.opacity(0.08)
+ static let background = Color(light: 0xFFFFFF, dark: 0x0B0B0D)
+
+ static func load(_ fraction: Double) -> Color {
+ fraction >= 0.85 ? error : fraction >= 0.6 ? warning : primary
+ }
+}
+
+private extension Color {
+ init(light: UInt32, dark: UInt32) {
+ self.init(nsColor: NSColor(name: nil) { appearance in
+ let hex = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light
+ return NSColor(srgbRed: CGFloat(hex >> 16 & 0xFF) / 255, green: CGFloat(hex >> 8 & 0xFF) / 255,
+ blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
+ })
+ }
+}
+
+private func percent(_ value: Double?) -> String {
+ value.map { "\(Int(($0 * 100).rounded()))%" } ?? "—"
+}
+
+private struct Gauge: View {
+ let title: String
+ let value: Double?
+ var color: Color?
+ var size: CGFloat = 56
+
+ var body: some View {
+ VStack(spacing: 6) {
+ ZStack {
+ Circle().stroke(Palette.track, lineWidth: 6)
+ Circle()
+ .trim(from: 0, to: min(1, max(0, value ?? 0)))
+ .stroke(color ?? Palette.load(value ?? 0), style: StrokeStyle(lineWidth: 6, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ Text(percent(value))
+ .font(.system(size: 13, weight: .semibold))
+ .monospacedDigit()
+ .foregroundStyle(Palette.textPrimary)
+ }
+ .frame(width: size, height: size)
+ Text(title)
+ .font(.system(size: 12))
+ .foregroundStyle(Palette.textSecondary)
+ }
+ }
+}
+
+private struct BarRow: View {
+ let title: String
+ let value: Double?
+ let detail: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(title).font(.system(size: 12)).foregroundStyle(Palette.textSecondary)
+ Spacer()
+ Text(detail).font(.system(size: 12, weight: .medium)).monospacedDigit().foregroundStyle(Palette.textPrimary)
+ }
+ GeometryReader { proxy in
+ ZStack(alignment: .leading) {
+ Capsule().fill(Palette.track)
+ Capsule().fill(Palette.load(value ?? 0)).frame(width: proxy.size.width * min(1, max(0, value ?? 0)))
+ }
+ }
+ .frame(height: 6)
+ }
+ }
+}
+
+struct SystemWidgetView: View {
+ @Environment(\.widgetFamily) private var family
+ let entry: SystemEntry
+
+ var body: some View {
+ Group {
+ switch family {
+ case .systemMedium: medium
+ default: small
+ }
+ }
+ .containerBackground(Palette.background, for: .widget)
+ }
+
+ private var header: some View {
+ HStack(spacing: 4) {
+ Image(systemName: "waveform.path.ecg").font(.system(size: 12, weight: .semibold)).foregroundStyle(Palette.primary)
+ Text("OpenStats").font(.system(size: 12, weight: .semibold)).foregroundStyle(Palette.textPrimary)
+ Spacer()
+ Text(entry.date, style: .time).font(.system(size: 12)).foregroundStyle(Palette.textSecondary)
+ }
+ }
+
+ private var small: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ header
+ HStack {
+ Gauge(title: "CPU", value: entry.cpu, size: 48)
+ Spacer()
+ Gauge(title: "内存", value: entry.memory, color: pressureColor, size: 48)
+ }
+ BarRow(title: "磁盘", value: entry.disk, detail: percent(entry.disk))
+ }
+ }
+
+ private var medium: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ header
+ HStack {
+ Gauge(title: "CPU", value: entry.cpu)
+ Spacer()
+ Gauge(title: "内存", value: entry.memory, color: pressureColor)
+ Spacer()
+ Gauge(title: "磁盘", value: entry.disk)
+ if let battery = entry.battery {
+ Spacer()
+ Gauge(title: entry.charging ? "充电中" : "电池", value: battery,
+ color: battery <= 0.2 ? Palette.error : Palette.success)
+ }
+ }
+ if let free = entry.diskFree {
+ Text("磁盘可用 \(ByteCountFormatter.string(fromByteCount: Int64(free), countStyle: .file))")
+ .font(.system(size: 12))
+ .foregroundStyle(Palette.textSecondary)
+ }
+ }
+ }
+
+ private var pressureColor: Color {
+ switch entry.memoryPressure {
+ case .critical: Palette.error
+ case .warning: Palette.warning
+ default: Palette.primary
+ }
+ }
+}
+
+struct SystemWidget: Widget {
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: "com.openstats.widget.system", provider: SystemProvider()) { entry in
+ SystemWidgetView(entry: entry)
+ }
+ .configurationDisplayName("系统概览")
+ .description("CPU、内存、磁盘与电池。每隔几分钟刷新一次。")
+ .supportedFamilies([.systemSmall, .systemMedium])
+ }
+}
+
+@main
+struct OpenStatsWidgets: WidgetBundle {
+ var body: some Widget {
+ SystemWidget()
+ }
+}
diff --git a/project.yml b/project.yml
index 3c40d79..00908bd 100644
--- a/project.yml
+++ b/project.yml
@@ -61,6 +61,38 @@ targets:
embed: true
copy:
destination: executables
+ - target: OpenStatsWidget
+ embed: true
+
+ # 桌面小组件(沙盒扩展),嵌入 OpenStats.app/Contents/PlugIns
+ OpenStatsWidget:
+ type: app-extension
+ platform: macOS
+ sources:
+ - path: Widget
+ excludes:
+ - "*.entitlements"
+ info:
+ path: Widget/Info.plist
+ properties:
+ CFBundleDisplayName: OpenStats
+ CFBundleShortVersionString: $(MARKETING_VERSION)
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
+ LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.widgetkit-extension
+ entitlements:
+ path: Widget/OpenStatsWidget.entitlements
+ properties:
+ com.apple.security.app-sandbox: true
+ settings:
+ base:
+ PRODUCT_NAME: OpenStatsWidget
+ PRODUCT_BUNDLE_IDENTIFIER: com.openstats.app.widget
+ SKIP_INSTALL: YES
+ dependencies:
+ - package: OpenStatsKit
+ product: Metrics
OpenStatsHelper:
type: tool