feat: added a new United widget that visualizes: CPU, GPU, RAM, and disk utilization

This commit is contained in:
Serhiy Mytrovtsiy
2025-07-25 19:03:28 +02:00
parent 35e6141021
commit 3a59807044
8 changed files with 161 additions and 2 deletions

View File

@@ -117,6 +117,7 @@ open class Module {
self.menuBar = MenuBar(moduleName: self.config.name)
self.available = self.isAvailable()
self.enabled = Store.shared.bool(key: "\(self.config.name)_state", defaultValue: self.config.defaultState)
self.userDefaults?.set(self.enabled, forKey: "\(self.config.name)_state")
if !self.available {
debug("Module is not available", log: self.log)
@@ -195,6 +196,7 @@ open class Module {
self.enabled = true
Store.shared.set(key: "\(self.config.name)_state", value: true)
self.userDefaults?.set(true, forKey: "\(self.config.name)_state")
self.readers.forEach { (reader: Reader_p) in
reader.initStoreValues(title: self.config.name)
reader.start()
@@ -211,6 +213,7 @@ open class Module {
self.enabled = false
if !self.pauseState { // omit saving the disable state when toggle by pause, need for resume state restoration
Store.shared.set(key: "\(self.config.name)_state", value: false)
self.userDefaults?.set(false, forKey: "\(self.config.name)_state")
}
self.readers.forEach{ $0.stop() }
self.menuBar.disable()

View File

@@ -11,7 +11,7 @@ import Kit
import WidgetKit
public struct CPU_Load: Codable, RemoteType {
var totalUsage: Double = 0
public var totalUsage: Double = 0
var usagePerCore: [Double] = []
var usageECores: Double? = nil
var usagePCores: Double? = nil
@@ -226,6 +226,7 @@ public class CPU: Module {
guard let blobData = try? JSONEncoder().encode(value) else { return }
self.userDefaults?.set(blobData, forKey: "CPU@LoadReader")
WidgetCenter.shared.reloadTimelines(ofKind: CPU_entry.kind)
WidgetCenter.shared.reloadTimelines(ofKind: "UnitedWidget")
}
}
}

View File

@@ -51,7 +51,7 @@ public struct drive: Codable {
var activity: stats = stats()
var smart: smart_t? = nil
var percentage: Double {
public var percentage: Double {
let total = self.size
let free = self.free
var usedSpace = total - free
@@ -333,6 +333,7 @@ public class Disk: Module {
guard let blobData = try? JSONEncoder().encode(d) else { return }
self.userDefaults?.set(blobData, forKey: "Disk@CapacityReader")
WidgetCenter.shared.reloadTimelines(ofKind: Disk_entry.kind)
WidgetCenter.shared.reloadTimelines(ofKind: "UnitedWidget")
}
}

View File

@@ -195,6 +195,7 @@ public class GPU: Module {
guard let blobData = try? JSONEncoder().encode(selectedGPU) else { return }
self.userDefaults?.set(blobData, forKey: "GPU@InfoReader")
WidgetCenter.shared.reloadTimelines(ofKind: GPU_entry.kind)
WidgetCenter.shared.reloadTimelines(ofKind: "UnitedWidget")
}
}
}

View File

@@ -236,6 +236,7 @@ public class RAM: Module {
guard let blobData = try? JSONEncoder().encode(value) else { return }
self.userDefaults?.set(blobData, forKey: "RAM@UsageReader")
WidgetCenter.shared.reloadTimelines(ofKind: RAM_entry.kind)
WidgetCenter.shared.reloadTimelines(ofKind: "UnitedWidget")
}
}
}

View File

@@ -82,6 +82,7 @@
5CFE493D2926513E000F2856 /* eu.exelban.Stats.SMC.Helper in Copy Files */ = {isa = PBXBuildFile; fileRef = 5CFE492729264DF1000F2856 /* eu.exelban.Stats.SMC.Helper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
5CFE494229265418000F2856 /* uninstall.sh in Resources */ = {isa = PBXBuildFile; fileRef = 5CFE494129265418000F2856 /* uninstall.sh */; };
5CFE494429265421000F2856 /* changelog.py in Resources */ = {isa = PBXBuildFile; fileRef = 5CFE494329265421000F2856 /* changelog.py */; };
5EC1B2E52E2FEAFB007042A6 /* UnitedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EC1B2E42E2FEAFB007042A6 /* UnitedWidget.swift */; };
5EE8037F29C36BDD0063D37D /* portal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EE8037E29C36BDD0063D37D /* portal.swift */; };
9A045EB72594F8D100ED58F2 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A045EB62594F8D100ED58F2 /* Dashboard.swift */; };
9A11AAD6266FD77F000C1C05 /* Bluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A11AACF266FD77F000C1C05 /* Bluetooth.framework */; };
@@ -570,6 +571,7 @@
5CFE493B292650F8000F2856 /* Launchd.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Launchd.plist; sourceTree = "<group>"; };
5CFE494129265418000F2856 /* uninstall.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = uninstall.sh; sourceTree = "<group>"; };
5CFE494329265421000F2856 /* changelog.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = changelog.py; sourceTree = "<group>"; };
5EC1B2E42E2FEAFB007042A6 /* UnitedWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitedWidget.swift; sourceTree = "<group>"; };
5EE8037E29C36BDD0063D37D /* portal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = portal.swift; sourceTree = "<group>"; };
62BA5F74254810C8009D0AC2 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
63A07F97275018DF00352C46 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -923,6 +925,7 @@
children = (
5C3068752C3351D800B05EFA /* Supporting Files */,
5CE7E7A32C318C33006BC92C /* widgets.swift */,
5EC1B2E42E2FEAFB007042A6 /* UnitedWidget.swift */,
);
path = Widgets;
sourceTree = "<group>";
@@ -2030,6 +2033,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5EC1B2E52E2FEAFB007042A6 /* UnitedWidget.swift in Sources */,
5CE7E7A42C318C33006BC92C /* widgets.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

147
Widgets/UnitedWidget.swift Normal file
View File

@@ -0,0 +1,147 @@
//
// UnitedWidget.swift
// WidgetsExtension
//
// Created by Serhiy Mytrovtsiy on 22/07/2025.
// Copyright © 2025 Serhiy Mytrovtsiy. All rights reserved.
//
import SwiftUI
import WidgetKit
import CPU
import GPU
import RAM
import Disk
public struct Value {
public var value: Double = 0
public var color: Color = Color(nsColor: .controlAccentColor)
}
public struct United_entry: TimelineEntry {
public static let kind = "UnitedWidget"
public static var snapshot: United_entry = United_entry()
public var date: Date {
Calendar.current.date(byAdding: .second, value: 5, to: Date())!
}
public var cpu: Value? = nil
public var gpu: Value? = nil
public var ram: Value? = nil
public var disk: Value? = nil
}
@available(macOS 11.0, *)
public struct Provider: TimelineProvider {
public typealias Entry = United_entry
private let userDefaults: UserDefaults? = UserDefaults(suiteName: "\(Bundle.main.object(forInfoDictionaryKey: "TeamId") as! String).eu.exelban.Stats.widgets")
public func placeholder(in context: Context) -> United_entry {
United_entry()
}
public func getSnapshot(in context: Context, completion: @escaping (United_entry) -> Void) {
completion(United_entry.snapshot)
}
public func getTimeline(in context: Context, completion: @escaping (Timeline<United_entry>) -> Void) {
var entry = United_entry()
if let raw = userDefaults?.data(forKey: "CPU@LoadReader"), let value = try? JSONDecoder().decode(CPU_Load.self, from: raw) {
entry.cpu = Value(value: value.totalUsage)
}
if let raw = userDefaults?.bool(forKey: "CPU_state"), !raw {
entry.cpu = nil
}
if let raw = userDefaults?.data(forKey: "GPU@InfoReader"), let value = try? JSONDecoder().decode(GPU_Info.self, from: raw) {
entry.gpu = Value(value: value.utilization ?? 0)
}
if let raw = userDefaults?.bool(forKey: "GPU_state"), !raw {
entry.gpu = nil
}
if let raw = userDefaults?.data(forKey: "RAM@UsageReader"), let value = try? JSONDecoder().decode(RAM_Usage.self, from: raw) {
entry.ram = Value(value: value.usage)
}
if let raw = userDefaults?.bool(forKey: "RAM_state"), !raw {
entry.ram = nil
}
if let raw = userDefaults?.data(forKey: "Disk@CapacityReader"), let value = try? JSONDecoder().decode(drive.self, from: raw) {
entry.disk = Value(value: value.percentage)
}
if let raw = userDefaults?.bool(forKey: "Disk_state"), !raw {
entry.disk = nil
}
let entries: [United_entry] = [entry]
completion(Timeline(entries: entries, policy: .atEnd))
}
}
@available(macOS 14.0, *)
public struct UnitedWidget: Widget {
public init() {}
let columns = [GridItem(.flexible()), GridItem(.flexible())]
public var body: some WidgetConfiguration {
StaticConfiguration(kind: United_entry.kind, provider: Provider()) { entry in
let values: [(String, Double, Color)] = [
entry.cpu.map { ("CPU", $0.value, $0.color) },
entry.gpu.map { ("GPU", $0.value, $0.color) },
entry.ram.map { ("RAM", $0.value, $0.color) },
entry.disk.map { ("Disk", $0.value, $0.color) }
].compactMap { $0 }
VStack {
if values.isEmpty {
Text("No data available")
} else {
LazyVGrid(columns: columns, alignment: .leading, spacing: 12) {
ForEach(values.indices, id: \.self) { index in
let item = values[index]
CircularGaugeView(title: item.0, progress: item.1, color: item.2)
}
ForEach(values.count..<4, id: \.self) { _ in
Color.clear
.frame(width: 60, height: 60)
}
}
}
}
.containerBackground(for: .widget) {
Color.clear
}
}
.configurationDisplayName("United widget")
.description("Displays CPU/GPU/RAM/Disk stats")
.supportedFamilies([.systemSmall])
}
}
struct CircularGaugeView: View {
var title: String
var progress: Double
var color: Color
var body: some View {
ZStack {
Circle().stroke(Color.gray.opacity(0.2), lineWidth: 6)
Circle()
.trim(from: 0, to: self.progress)
.stroke(self.color, style: StrokeStyle(lineWidth: 6, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.3), value: self.progress)
VStack(spacing: 0) {
Text(self.title).font(.system(size: 10))
Text("\(Int(self.progress * 100))%").font(.system(size: 12))
}
}
.frame(width: 60, height: 60)
}
}

View File

@@ -25,5 +25,6 @@ struct WidgetsBundle: WidgetBundle {
RAMWidget()
DiskWidget()
NetworkWidget()
UnitedWidget()
}
}