From 3a598070449d4c59d577c0d05541fbd0d2859be8 Mon Sep 17 00:00:00 2001 From: Serhiy Mytrovtsiy Date: Fri, 25 Jul 2025 19:03:28 +0200 Subject: [PATCH] feat: added a new United widget that visualizes: CPU, GPU, RAM, and disk utilization --- Kit/module/module.swift | 3 + Modules/CPU/main.swift | 3 +- Modules/Disk/main.swift | 3 +- Modules/GPU/main.swift | 1 + Modules/RAM/main.swift | 1 + Stats.xcodeproj/project.pbxproj | 4 + Widgets/UnitedWidget.swift | 147 ++++++++++++++++++++++++++++++++ Widgets/widgets.swift | 1 + 8 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 Widgets/UnitedWidget.swift diff --git a/Kit/module/module.swift b/Kit/module/module.swift index 13fa2111..aeee71fd 100644 --- a/Kit/module/module.swift +++ b/Kit/module/module.swift @@ -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() diff --git a/Modules/CPU/main.swift b/Modules/CPU/main.swift index b5c638e6..2180f187 100644 --- a/Modules/CPU/main.swift +++ b/Modules/CPU/main.swift @@ -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") } } } diff --git a/Modules/Disk/main.swift b/Modules/Disk/main.swift index a4ac05db..68209c3d 100644 --- a/Modules/Disk/main.swift +++ b/Modules/Disk/main.swift @@ -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") } } diff --git a/Modules/GPU/main.swift b/Modules/GPU/main.swift index b6d9fd6c..9cb099a9 100644 --- a/Modules/GPU/main.swift +++ b/Modules/GPU/main.swift @@ -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") } } } diff --git a/Modules/RAM/main.swift b/Modules/RAM/main.swift index b998afd4..f5df70de 100644 --- a/Modules/RAM/main.swift +++ b/Modules/RAM/main.swift @@ -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") } } } diff --git a/Stats.xcodeproj/project.pbxproj b/Stats.xcodeproj/project.pbxproj index 2385ec30..8d8a680f 100644 --- a/Stats.xcodeproj/project.pbxproj +++ b/Stats.xcodeproj/project.pbxproj @@ -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 = ""; }; 5CFE494129265418000F2856 /* uninstall.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = uninstall.sh; sourceTree = ""; }; 5CFE494329265421000F2856 /* changelog.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = changelog.py; sourceTree = ""; }; + 5EC1B2E42E2FEAFB007042A6 /* UnitedWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitedWidget.swift; sourceTree = ""; }; 5EE8037E29C36BDD0063D37D /* portal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = portal.swift; sourceTree = ""; }; 62BA5F74254810C8009D0AC2 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 63A07F97275018DF00352C46 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = ""; }; @@ -923,6 +925,7 @@ children = ( 5C3068752C3351D800B05EFA /* Supporting Files */, 5CE7E7A32C318C33006BC92C /* widgets.swift */, + 5EC1B2E42E2FEAFB007042A6 /* UnitedWidget.swift */, ); path = Widgets; sourceTree = ""; @@ -2030,6 +2033,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5EC1B2E52E2FEAFB007042A6 /* UnitedWidget.swift in Sources */, 5CE7E7A42C318C33006BC92C /* widgets.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Widgets/UnitedWidget.swift b/Widgets/UnitedWidget.swift new file mode 100644 index 00000000..63343c50 --- /dev/null +++ b/Widgets/UnitedWidget.swift @@ -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) -> 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) + } +} diff --git a/Widgets/widgets.swift b/Widgets/widgets.swift index 9b0cf439..28156712 100644 --- a/Widgets/widgets.swift +++ b/Widgets/widgets.swift @@ -25,5 +25,6 @@ struct WidgetsBundle: WidgetBundle { RAMWidget() DiskWidget() NetworkWidget() + UnitedWidget() } }