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