diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 0e90a4fd3..cc55ce846 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -155,6 +155,9 @@ B1F0A0022E60000100000001 /* BrightnessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0A0012E60000100000001 /* BrightnessManager.swift */; }; B1FEB4992C7686630066EBBC /* PanGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1FEB4982C7686630066EBBC /* PanGesture.swift */; }; F38DE6482D8243E7008B5C6D /* BatteryActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */; }; + E5A00004AAAA000000000004 /* SystemMonitorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5A00001AAAA000000000001 /* SystemMonitorManager.swift */; }; + E5A00005AAAA000000000005 /* SystemMonitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5A00002AAAA000000000002 /* SystemMonitorView.swift */; }; + E5A00006AAAA000000000006 /* SystemMonitorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5A00003AAAA000000000003 /* SystemMonitorSettingsView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -333,6 +336,9 @@ B1F0A0012E60000100000001 /* BrightnessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessManager.swift; sourceTree = ""; }; B1FEB4982C7686630066EBBC /* PanGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanGesture.swift; sourceTree = ""; }; F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActivityManager.swift; sourceTree = ""; }; + E5A00001AAAA000000000001 /* SystemMonitorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitorManager.swift; sourceTree = ""; }; + E5A00002AAAA000000000002 /* SystemMonitorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitorView.swift; sourceTree = ""; }; + E5A00003AAAA000000000003 /* SystemMonitorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitorSettingsView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -530,6 +536,7 @@ 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */, 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */, 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */, + E5A00003AAAA000000000003 /* SystemMonitorSettingsView.swift */, ); path = Views; sourceTree = ""; @@ -584,6 +591,7 @@ 149E0B982C737D26006418B1 /* Webcam */, B18654312C6F45AE000B926A /* Live activities */, B18654302C6F4590000B926A /* Settings */, + E5A00007AAAA000000000001 /* SystemMonitor */, B186542F2C6F455E000B926A /* Notch */, 9A0887332C7AFF7E00C160EA /* Tabs */, B186542E2C6F453B000B926A /* Music */, @@ -605,10 +613,19 @@ 147163992C5D35FF0068B555 /* MusicManager.swift */, 149E0B962C737D00006418B1 /* WebcamManager.swift */, 14C08BB52C8DE42D000F8AA0 /* CalendarManager.swift */, + E5A00001AAAA000000000001 /* SystemMonitorManager.swift */, ); path = managers; sourceTree = ""; }; + E5A00007AAAA000000000001 /* SystemMonitor */ = { + isa = PBXGroup; + children = ( + E5A00002AAAA000000000002 /* SystemMonitorView.swift */, + ); + path = SystemMonitor; + sourceTree = ""; + }; 149E0B982C737D26006418B1 /* Webcam */ = { isa = PBXGroup; children = ( @@ -1108,6 +1125,9 @@ 1100290C2E847E2800035A57 /* NSItemProvider+LoadHelpers.swift in Sources */, 11CFC6632E09918400748C80 /* MusicControllerSelectionView.swift in Sources */, B1F0A0022E60000100000001 /* BrightnessManager.swift in Sources */, + E5A00004AAAA000000000004 /* SystemMonitorManager.swift in Sources */, + E5A00005AAAA000000000005 /* SystemMonitorView.swift in Sources */, + E5A00006AAAA000000000006 /* SystemMonitorSettingsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 8717ddd53..f2d236a39 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -170,13 +170,13 @@ struct ContentView: View { } } .onReceive(NotificationCenter.default.publisher(for: .sharingDidFinish)) { _ in - if vm.notchState == .open && !isHovering && !vm.isBatteryPopoverActive { + if vm.notchState == .open && !isHovering && !vm.isBatteryPopoverActive && !vm.isSystemMonitorPopoverActive { hoverTask?.cancel() hoverTask = Task { try? await Task.sleep(for: .milliseconds(100)) guard !Task.isCancelled else { return } await MainActor.run { - if self.vm.notchState == .open && !self.isHovering && !self.vm.isBatteryPopoverActive && !SharingStateManager.shared.preventNotchClose { + if self.vm.notchState == .open && !self.isHovering && !self.vm.isBatteryPopoverActive && !self.vm.isSystemMonitorPopoverActive && !SharingStateManager.shared.preventNotchClose { self.vm.close() } } @@ -204,6 +204,20 @@ struct ContentView: View { } } } + .onChange(of: vm.isSystemMonitorPopoverActive) { + if !vm.isSystemMonitorPopoverActive && !isHovering && vm.notchState == .open && !SharingStateManager.shared.preventNotchClose { + hoverTask?.cancel() + hoverTask = Task { + try? await Task.sleep(for: .milliseconds(100)) + guard !Task.isCancelled else { return } + await MainActor.run { + if !self.vm.isSystemMonitorPopoverActive && !self.isHovering && self.vm.notchState == .open && !SharingStateManager.shared.preventNotchClose { + self.vm.close() + } + } + } + } + } .sensoryFeedback(.alignment, trigger: haptics) .contextMenu { Button("Settings") { @@ -583,7 +597,7 @@ struct ContentView: View { self.isHovering = false } - if self.vm.notchState == .open && !self.vm.isBatteryPopoverActive && !SharingStateManager.shared.preventNotchClose { + if self.vm.notchState == .open && !self.vm.isBatteryPopoverActive && !self.vm.isSystemMonitorPopoverActive && !SharingStateManager.shared.preventNotchClose { self.vm.close() } } diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index 2a380e21b..e8ccbaabf 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -1206,6 +1206,10 @@ }, "Accessibility Access Required" : { + }, + "Activity Monitor" : { + "comment" : "A button label that opens the Activity Monitor.", + "isCommentAutoGenerated" : true }, "Add" : { "extractionState" : "stale", @@ -5429,6 +5433,14 @@ } } }, + "CPU Usage" : { + "comment" : "A label displayed next to the CPU usage value in the System Monitor settings view.", + "isCommentAutoGenerated" : true + }, + "Current Stats" : { + "comment" : "A section header for the current CPU and memory usage statistics.", + "isCommentAutoGenerated" : true + }, "Currently selected: %@" : { "localizations" : { "ar" : { @@ -6432,6 +6444,10 @@ } } }, + "Display CPU and memory usage indicators in the notch header area, next to the battery indicator." : { + "comment" : "A description of the feature that allows users to see CPU and memory usage indicators in the notch header area.", + "isCommentAutoGenerated" : true + }, "Download" : { "localizations" : { "ar" : { @@ -11963,6 +11979,10 @@ } } }, + "Memory Used" : { + "comment" : "A label describing the amount of memory currently used by the device.", + "isCommentAutoGenerated" : true + }, "Mic" : { }, @@ -18083,6 +18103,10 @@ } } }, + "Show system monitor" : { + "comment" : "A toggle label that allows the user to enable or disable the system monitor.", + "isCommentAutoGenerated" : true + }, "Slider color" : { "localizations" : { "ar" : { @@ -18739,6 +18763,10 @@ } } }, + "Statistics update every 3 seconds." : { + "comment" : "A footer label for the System Monitor settings view, explaining that the statistics are updated every 3 seconds.", + "isCommentAutoGenerated" : true + }, "Stopped" : { "extractionState" : "stale", "localizations" : { @@ -19040,6 +19068,10 @@ } } }, + "System Monitor" : { + "comment" : "The title of a settings page related to monitoring the system.", + "isCommentAutoGenerated" : true + }, "Time to Full Charge: %lld min" : { "localizations" : { "ar" : { @@ -21158,5 +21190,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/boringNotch/components/Notch/BoringHeader.swift b/boringNotch/components/Notch/BoringHeader.swift index 9f9da3248..3723807db 100644 --- a/boringNotch/components/Notch/BoringHeader.swift +++ b/boringNotch/components/Notch/BoringHeader.swift @@ -47,6 +47,9 @@ struct BoringHeader: View { ) .transition(.scale(scale: 0.8).combined(with: .opacity)) } else { + if Defaults[.showSystemMonitor] { + SystemMonitorView() + } if Defaults[.showMirror] { Button(action: { vm.toggleCameraPreview() diff --git a/boringNotch/components/Settings/SettingsView.swift b/boringNotch/components/Settings/SettingsView.swift index a1a174886..0ec7031bf 100644 --- a/boringNotch/components/Settings/SettingsView.swift +++ b/boringNotch/components/Settings/SettingsView.swift @@ -40,6 +40,9 @@ struct SettingsView: View { NavigationLink(value: "Battery") { Label("Battery", systemImage: "battery.100.bolt") } + NavigationLink(value: "SystemMonitor") { + Label("System Monitor", systemImage: "gauge.with.dots.needle.33percent") + } NavigationLink(value: "Shelf") { Label("Shelf", systemImage: "books.vertical") } @@ -72,6 +75,8 @@ struct SettingsView: View { OSDSettings() case "Battery": Charge() + case "SystemMonitor": + SystemMonitorSettings() case "Shelf": Shelf() case "Shortcuts": diff --git a/boringNotch/components/Settings/Views/SystemMonitorSettingsView.swift b/boringNotch/components/Settings/Views/SystemMonitorSettingsView.swift new file mode 100644 index 000000000..878f9defa --- /dev/null +++ b/boringNotch/components/Settings/Views/SystemMonitorSettingsView.swift @@ -0,0 +1,54 @@ +// +// SystemMonitorSettingsView.swift +// boringNotch +// +// Created by Zaky Syihab Hatmoko on 05/03/2026. +// + +import Defaults +import SwiftUI + +struct SystemMonitorSettings: View { + @ObservedObject var monitor = SystemMonitorManager.shared + + var body: some View { + Form { + Section { + Defaults.Toggle(key: .showSystemMonitor) { + Text("Show system monitor") + } + } header: { + Text("General") + } footer: { + Text("Display CPU and memory usage indicators in the notch header area, next to the battery indicator.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if Defaults[.showSystemMonitor] { + Section { + HStack { + Text("CPU Usage") + Spacer() + Text(String(format: "%.1f%%", monitor.cpuUsage)) + .foregroundStyle(.secondary) + } + HStack { + Text("Memory Used") + Spacer() + Text(String(format: "%.1f / %.0f GB", monitor.memoryUsed, monitor.memoryTotal)) + .foregroundStyle(.secondary) + } + } header: { + Text("Current Stats") + } footer: { + Text("Statistics update every 3 seconds.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .accentColor(.effectiveAccent) + .navigationTitle("System Monitor") + } +} diff --git a/boringNotch/components/SystemMonitor/SystemMonitorView.swift b/boringNotch/components/SystemMonitor/SystemMonitorView.swift new file mode 100644 index 000000000..1693cf991 --- /dev/null +++ b/boringNotch/components/SystemMonitor/SystemMonitorView.swift @@ -0,0 +1,191 @@ +// +// SystemMonitorView.swift +// boringNotch +// +// Created by Zaky Syihab Hatmoko on 05/03/2026. +// + +import SwiftUI + +// MARK: - Color Coding + +/// Returns a color based on usage level and thresholds. +/// - Parameters: +/// - value: Usage percentage (0-100). +/// - thresholds: A tuple of (warning, critical) thresholds. +private func colorForUsage(_ value: Double, thresholds: (Double, Double)) -> Color { + switch value { + case ..? + + var body: some View { + Button(action: { + withAnimation { + showPopover.toggle() + } + }) { + HStack(spacing: 10) { + GaugeRingView(progress: monitor.cpuUsage, color: monitor.cpuColor, iconName: "cpu") + GaugeRingView(progress: monitor.memoryUsagePercent, color: monitor.memoryColor, iconName: "memorychip") + } + } + .buttonStyle(ScaleButtonStyle()) + .popover(isPresented: $showPopover, arrowEdge: .bottom) { + SystemMonitorMenuView(onDismiss: { showPopover = false }) + .onHover { hovering in + isHoveringPopover = hovering + if hovering { + hideTask?.cancel() + hideTask = nil + } else { + scheduleHideIfNeeded() + } + } + } + .onChange(of: showPopover) { + vm.isSystemMonitorPopoverActive = showPopover + } + .onDisappear { + hideTask?.cancel() + hideTask = nil + } + } + + // MARK: - Hover Persistence + + private func scheduleHideIfNeeded() { + guard !isHoveringPopover else { return } + hideTask?.cancel() + hideTask = Task { + try? await Task.sleep(for: .milliseconds(350)) + guard !Task.isCancelled else { return } + await MainActor.run { withAnimation { showPopover = false } } + } + } +} + +// MARK: - Popover Menu + +/// Detailed system monitor popover shown when clicking the gauge rings. +struct SystemMonitorMenuView: View { + @ObservedObject var monitor = SystemMonitorManager.shared + var onDismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("System Monitor") + .font(.headline) + .fontWeight(.semibold) + Spacer() + } + + VStack(alignment: .leading, spacing: 8) { + statRow(label: "CPU Usage", icon: "cpu", value: "\(Int(monitor.cpuUsage))%", color: monitor.cpuColor) + statRow(label: "Memory Usage", icon: "memorychip", value: "\(Int(monitor.memoryUsagePercent))%", color: monitor.memoryColor) + + HStack { + Text("Memory Used") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + Text(String(format: "%.1f / %.0f GB", monitor.memoryUsed, monitor.memoryTotal)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 8) + + Divider().background(Color.white) + + Button(action: openActivityMonitor) { + Label("Activity Monitor", systemImage: "gauge.with.dots.needle.33percent") + } + .frame(maxWidth: .infinity) + .buttonStyle(.plain) + .padding(.vertical, 8) + } + .padding() + .frame(width: 280) + .foregroundColor(.white) + } + + private func statRow(label: String, icon: String, value: String, color: Color) -> some View { + HStack { + Label(label, systemImage: icon) + .font(.subheadline) + Spacer() + Text(value) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(color) + } + } + + private func openActivityMonitor() { + NSWorkspace.shared.open( + URL(fileURLWithPath: "/System/Applications/Utilities/Activity Monitor.app") + ) + onDismiss() + } +} + +#Preview { + SystemMonitorView() + .environmentObject(BoringViewModel()) + .padding() + .background(Color.black) +} diff --git a/boringNotch/managers/SystemMonitorManager.swift b/boringNotch/managers/SystemMonitorManager.swift new file mode 100644 index 000000000..ac65e82f7 --- /dev/null +++ b/boringNotch/managers/SystemMonitorManager.swift @@ -0,0 +1,174 @@ +// +// SystemMonitorManager.swift +// boringNotch +// +// Created by Zaky Syihab Hatmoko on 05/03/2026. +// + +import Combine +import Defaults +import Foundation + +/// Manages polling of system CPU and memory usage statistics. +/// Uses Mach kernel APIs for accurate, low-overhead measurements. +class SystemMonitorManager: ObservableObject { + + static let shared = SystemMonitorManager() + + // MARK: - Published Properties + + /// Current CPU usage as a percentage (0-100) + @Published private(set) var cpuUsage: Double = 0.0 + + /// Current memory used in GB + @Published private(set) var memoryUsed: Double = 0.0 + + /// Total physical memory in GB + let memoryTotal: Double + + /// Memory usage as a percentage (0-100) + var memoryUsagePercent: Double { + guard memoryTotal > 0 else { return 0 } + return (memoryUsed / memoryTotal) * 100.0 + } + + // MARK: - Private Properties + + private var timer: Timer? + private var defaultsObservation: Defaults.Observation? + + /// Previous CPU tick counts for delta calculation + private var previousCPUInfo: host_cpu_load_info? + + /// Cached Mach host port to avoid leaking send rights on every poll. + private let hostPort: mach_port_t + + private let pollingInterval: TimeInterval = 3.0 + + // MARK: - Init + + private init() { + self.hostPort = mach_host_self() + self.memoryTotal = Double(ProcessInfo.processInfo.physicalMemory) / (1024 * 1024 * 1024) + + // Take an initial CPU snapshot so the first real reading has a baseline + previousCPUInfo = fetchCPULoadInfo() + + // Observe the setting toggle to start/stop polling + defaultsObservation = Defaults.observe(.showSystemMonitor) { [weak self] change in + DispatchQueue.main.async { + if change.newValue { + self?.startPolling() + } else { + self?.stopPolling() + } + } + } + + // Start polling immediately if the setting is already on + if Defaults[.showSystemMonitor] { + startPolling() + } + } + + // MARK: - Polling + + private func startPolling() { + guard timer == nil else { return } + + // Perform an initial update immediately + updateStats() + + timer = Timer.scheduledTimer(withTimeInterval: pollingInterval, repeats: true) { [weak self] _ in + self?.updateStats() + } + } + + private func stopPolling() { + timer?.invalidate() + timer = nil + } + + private func updateStats() { + let cpu = measureCPUUsage() + let mem = measureMemoryUsed() + + DispatchQueue.main.async { [weak self] in + self?.cpuUsage = cpu + self?.memoryUsed = mem + } + } + + // MARK: - CPU Measurement + + /// Fetches the current aggregate CPU load info from the kernel. + private func fetchCPULoadInfo() -> host_cpu_load_info? { + var size = mach_msg_type_number_t( + MemoryLayout.stride / MemoryLayout.stride + ) + let hostInfo = host_cpu_load_info_t.allocate(capacity: 1) + defer { hostInfo.deallocate() } + + let result = hostInfo.withMemoryRebound(to: integer_t.self, capacity: Int(size)) { ptr in + host_statistics(hostPort, HOST_CPU_LOAD_INFO, ptr, &size) + } + + guard result == KERN_SUCCESS else { return nil } + return hostInfo.pointee + } + + /// Calculates CPU usage percentage based on tick delta since last poll. + private func measureCPUUsage() -> Double { + guard let current = fetchCPULoadInfo() else { return cpuUsage } + + defer { previousCPUInfo = current } + + guard let previous = previousCPUInfo else { return 0 } + + let userDelta = Double(current.cpu_ticks.0 - previous.cpu_ticks.0) // CPU_STATE_USER + let systemDelta = Double(current.cpu_ticks.1 - previous.cpu_ticks.1) // CPU_STATE_SYSTEM + let idleDelta = Double(current.cpu_ticks.2 - previous.cpu_ticks.2) // CPU_STATE_IDLE + let niceDelta = Double(current.cpu_ticks.3 - previous.cpu_ticks.3) // CPU_STATE_NICE + + let totalTicks = userDelta + systemDelta + idleDelta + niceDelta + guard totalTicks > 0 else { return 0 } + + let usedTicks = userDelta + systemDelta + niceDelta + return (usedTicks / totalTicks) * 100.0 + } + + // MARK: - Memory Measurement + + /// Measures current memory usage using Mach VM statistics. + /// Returns memory used in GB (active + wired + compressed). + private func measureMemoryUsed() -> Double { + var stats = vm_statistics64() + var count = mach_msg_type_number_t( + MemoryLayout.stride / MemoryLayout.stride + ) + + let result = withUnsafeMutablePointer(to: &stats) { ptr in + ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { intPtr in + host_statistics64(hostPort, HOST_VM_INFO64, intPtr, &count) + } + } + + guard result == KERN_SUCCESS else { return memoryUsed } + + let pageSize = Double(vm_kernel_page_size) + let active = Double(stats.active_count) * pageSize + let wired = Double(stats.wire_count) * pageSize + let compressed = Double(stats.compressor_page_count) * pageSize + + let usedBytes = active + wired + compressed + return usedBytes / (1024 * 1024 * 1024) + } + + // MARK: - Cleanup + + deinit { + stopPolling() + defaultsObservation?.invalidate() + mach_port_deallocate(mach_task_self_, hostPort) + } +} diff --git a/boringNotch/models/BoringViewModel.swift b/boringNotch/models/BoringViewModel.swift index 6da144393..2909c75b4 100644 --- a/boringNotch/models/BoringViewModel.swift +++ b/boringNotch/models/BoringViewModel.swift @@ -30,6 +30,7 @@ class BoringViewModel: NSObject, ObservableObject { @Published var edgeAutoOpenActive: Bool = false @Published var isHoveringCalendar: Bool = false @Published var isBatteryPopoverActive: Bool = false + @Published var isSystemMonitorPopoverActive: Bool = false @Published var screenUUID: String? @@ -211,6 +212,7 @@ class BoringViewModel: NSObject, ObservableObject { self.closedNotchSize = self.notchSize self.notchState = .closed self.isBatteryPopoverActive = false + self.isSystemMonitorPopoverActive = false if self.coordinator.shouldShowSneakPeek(on: self.screenUUID) { self.coordinator.toggleSneakPeek(status: false, type: .music, targetScreenUUID: self.screenUUID) } diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 0747d328d..a3e44d4bc 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -229,6 +229,9 @@ extension Defaults.Keys { static let osdBrightnessSource = Key("osdBrightnessSource", default: .builtin) static let osdVolumeSource = Key("osdVolumeSource", default: .builtin) + // MARK: System Monitor + static let showSystemMonitor = Key("showSystemMonitor", default: false) + // MARK: Shelf static let boringShelf = Key("boringShelf", default: true) static let openShelfByDefault = Key("openShelfByDefault", default: true)