Add advanced monitoring and release infrastructure
Build and test / macos (push) Canceled after 0s
Signed release / release (push) Canceled after 0s

This commit is contained in:
2026-08-15 18:23:35 -04:00
parent a67bb5fdf3
commit 0da94715be
38 changed files with 4219 additions and 406 deletions
@@ -0,0 +1,29 @@
import Foundation
public enum PuterHelperConstants {
public static let machServiceName = "dev.soconnor.puter.helper"
public static let daemonPlistName = "dev.soconnor.puter.helper.plist"
public static let protocolVersion = 1
}
public enum PuterFanTargetValidator {
public static let validFanIDs = 0...15
public static let validRPM = 500...20_000
public static func validate(_ targets: [NSNumber: NSNumber]) -> [(fan: Int, rpm: Int)]? {
let values = targets.compactMap { key, value -> (fan: Int, rpm: Int)? in
let fan = key.intValue
let rpm = value.intValue
guard validFanIDs.contains(fan), validRPM.contains(rpm) else { return nil }
return (fan, rpm)
}
guard values.count == targets.count, !values.isEmpty else { return nil }
return values.sorted { $0.fan < $1.fan }
}
}
@objc public protocol PuterPrivilegedHelperProtocol {
func version(withReply reply: @escaping (Int) -> Void)
func setFanTargets(_ targets: [NSNumber: NSNumber], withReply reply: @escaping (Bool, String?) -> Void)
func restoreAutomaticFanControl(withReply reply: @escaping (Bool, String?) -> Void)
}
+114
View File
@@ -0,0 +1,114 @@
import Foundation
import PuterHelperProtocol
import Security
private final class HelperService: NSObject, PuterPrivilegedHelperProtocol {
func version(withReply reply: @escaping (Int) -> Void) {
reply(PuterHelperConstants.protocolVersion)
}
func setFanTargets(_ targets: [NSNumber: NSNumber], withReply reply: @escaping (Bool, String?) -> Void) {
guard let validated = PuterFanTargetValidator.validate(targets) else {
reply(false, "Fan targets failed validation.")
return
}
do {
for (fan, rpm) in validated {
try runSMC(["fan", "\(fan)", "-m", "1"])
try runSMC(["fan", "\(fan)", "-v", "\(rpm)"])
}
reply(true, nil)
} catch { reply(false, error.localizedDescription) }
}
func restoreAutomaticFanControl(withReply reply: @escaping (Bool, String?) -> Void) {
do {
try runSMC(["reset"])
reply(true, nil)
} catch { reply(false, error.localizedDescription) }
}
private func runSMC(_ arguments: [String]) throws {
let helperURL = URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL
let toolURL = helperURL.deletingLastPathComponent().appendingPathComponent("smc")
guard FileManager.default.isExecutableFile(atPath: toolURL.path) else {
throw HelperError.smcUnavailable
}
let process = Process()
let errorPipe = Pipe()
process.executableURL = toolURL
process.arguments = arguments
process.standardOutput = FileHandle.nullDevice
process.standardError = errorPipe
try process.run()
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw HelperError.commandFailed(String(decoding: errorData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines))
}
}
}
private enum HelperError: LocalizedError {
case smcUnavailable
case commandFailed(String)
var errorDescription: String? {
switch self {
case .smcUnavailable: "The bundled SMC backend is unavailable."
case .commandFailed(let detail): detail.isEmpty ? "The SMC command failed." : detail
}
}
}
private final class HelperDelegate: NSObject, NSXPCListenerDelegate {
private let service = HelperService()
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
guard ClientCodeValidator.isTrusted(pid: connection.processIdentifier) else { return false }
connection.exportedInterface = NSXPCInterface(with: PuterPrivilegedHelperProtocol.self)
connection.exportedObject = service
connection.resume()
return true
}
}
private enum ClientCodeValidator {
static func isTrusted(pid: pid_t) -> Bool {
var clientCode: SecCode?
let attributes = [kSecGuestAttributePid: NSNumber(value: pid)] as CFDictionary
guard SecCodeCopyGuestWithAttributes(nil, attributes, [], &clientCode) == errSecSuccess,
let clientCode,
let clientInfo = signingInfo(for: clientCode),
clientInfo.identifier == "dev.soconnor.puter" else { return false }
var helperCode: SecCode?
guard SecCodeCopySelf([], &helperCode) == errSecSuccess,
let helperCode,
let helperInfo = signingInfo(for: helperCode),
let clientTeam = clientInfo.team,
let helperTeam = helperInfo.team,
clientTeam == helperTeam else { return false }
var requirement: SecRequirement?
let text = "anchor apple generic and identifier \"dev.soconnor.puter\""
guard SecRequirementCreateWithString(text as CFString, [], &requirement) == errSecSuccess,
let requirement else { return false }
return SecCodeCheckValidity(clientCode, [], requirement) == errSecSuccess
}
private static func signingInfo(for code: SecCode) -> (identifier: String, team: String?)? {
var staticCode: SecStaticCode?
guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, let staticCode else { return nil }
var information: CFDictionary?
guard SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess,
let values = information as? [CFString: Any],
let identifier = values[kSecCodeInfoIdentifier] as? String else { return nil }
return (identifier, values[kSecCodeInfoTeamIdentifier] as? String)
}
}
private let delegate = HelperDelegate()
private let listener = NSXPCListener(machServiceName: PuterHelperConstants.machServiceName)
listener.delegate = delegate
listener.resume()
RunLoop.current.run()
+126
View File
@@ -74,11 +74,21 @@ struct NewTaskView: View {
struct SettingsView: View {
@Environment(SystemMonitor.self) private var monitor
@EnvironmentObject private var updates: UpdateController
@AppStorage("alwaysOnTop") private var alwaysOnTop = false
@AppStorage("defaultStartPage") private var defaultStartPage: TaskSection = .processes
@AppStorage("diagnosticReportDuration") private var diagnosticDuration = 5
@AppStorage("diagnosticAskEveryTime") private var diagnosticAskEveryTime = true
@AppStorage("diagnosticReportDirectory") private var diagnosticDirectory = ""
@AppStorage("resourceAlertsEnabled") private var resourceAlertsEnabled = false
@AppStorage("resourceAlertCPUThreshold") private var alertCPUThreshold = 90.0
@AppStorage("resourceAlertMemoryThreshold") private var alertMemoryThreshold = 85.0
@AppStorage("resourceAlertDiskThreshold") private var alertDiskThreshold = 90.0
@AppStorage("resourceAlertSustainedSamples") private var alertSustainedSamples = 3
@AppStorage("resourceAlertCooldownSeconds") private var alertCooldownSeconds = 300.0
@AppStorage("showMenuBarMonitor") private var showMenuBarMonitor = true
@AppStorage("menuBarMetric") private var menuBarMetric: MenuBarMetric = .cpu
@StateObject private var privilegedHelper = PrivilegedHelperManager()
var body: some View {
@Bindable var monitor = monitor
@@ -108,6 +118,111 @@ struct SettingsView: View {
LabeledContent("Memory units", value: "Automatic")
}
Section("Menu bar") {
Toggle("Show puter in the menu bar", isOn: $showMenuBarMonitor)
Picker("Displayed value", selection: $menuBarMetric) {
ForEach(MenuBarMetric.allCases) { Text($0.rawValue).tag($0) }
}
Text("The menu bar uses the shared adaptive sampler and slows to the background cadence when puter is inactive.")
.font(.caption).foregroundStyle(.secondary)
}
Section("Fan control helper") {
LabeledContent("Status", value: privilegedHelper.state.title)
Text(privilegedHelper.state.detail)
.font(.caption)
.foregroundStyle(.secondary)
HStack {
switch privilegedHelper.state {
case .enabled:
Button("Disable Helper", role: .destructive) { privilegedHelper.disable() }
case .requiresApproval:
Button("Open Login Items Settings") { privilegedHelper.openApprovalSettings() }
Button("Check Again") { privilegedHelper.refresh() }
case .unavailable, .requiresSignedInstallation:
EmptyView()
case .disabled, .failed:
Button("Enable Helper") { privilegedHelper.enable() }
}
if privilegedHelper.isWorking { ProgressView().controlSize(.small) }
}
Text("The helper accepts only validated fan targets and reset commands from a same-team signed copy of puter. It cannot run arbitrary commands.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Software updates") {
if updates.isConfigured {
Toggle("Check for updates automatically", isOn: Binding(
get: { updates.automaticallyChecksForUpdates },
set: { updates.automaticallyChecksForUpdates = $0 }
))
Toggle("Download and install updates automatically", isOn: Binding(
get: { updates.automaticallyDownloadsUpdates },
set: { updates.automaticallyDownloadsUpdates = $0 }
))
Button("Check for Updates…") { updates.checkForUpdates() }
.disabled(!updates.canCheckForUpdates)
} else {
LabeledContent("Status", value: "Not configured in this build")
Text("Release builds enable secure Sparkle updates when the appcast URL and EdDSA public key are supplied during packaging.")
.font(.caption).foregroundStyle(.secondary)
}
}
Section("Resource alerts") {
Toggle("Notify when resources stay above a threshold", isOn: $resourceAlertsEnabled)
.onChange(of: resourceAlertsEnabled) { _, enabled in
guard enabled else { return }
Task {
if !(await monitor.requestAlertAuthorization()) {
resourceAlertsEnabled = false
monitor.errorMessage = "Notifications are disabled for puter. Enable them in System Settings to use resource alerts."
}
}
}
LabeledContent("CPU") {
thresholdControl(value: $alertCPUThreshold)
}
LabeledContent("Memory") {
thresholdControl(value: $alertMemoryThreshold)
}
LabeledContent("Disk activity") {
thresholdControl(value: $alertDiskThreshold)
}
Picker("Sustained for", selection: $alertSustainedSamples) {
Text("1 sample").tag(1)
Text("3 samples").tag(3)
Text("5 samples").tag(5)
Text("10 samples").tag(10)
}
Picker("Alert cooldown", selection: $alertCooldownSeconds) {
Text("1 minute").tag(60.0)
Text("5 minutes").tag(300.0)
Text("15 minutes").tag(900.0)
Text("1 hour").tag(3600.0)
}
Text("Serious and critical thermal pressure also trigger alerts. Thresholds require consecutive telemetry samples and respect the cooldown.")
.font(.caption)
.foregroundStyle(.secondary)
}
if !monitor.recentAlerts.isEmpty {
Section("Recent alerts") {
ForEach(monitor.recentAlerts.prefix(6)) { event in
LabeledContent {
Text(event.timestamp, style: .relative).foregroundStyle(.secondary)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(event.resource).font(.callout.weight(.medium))
Text(event.message).font(.caption).foregroundStyle(.secondary)
}
}
}
Button("Clear alert history", role: .destructive) { monitor.clearAlerts() }
}
}
Section("Diagnostic reports") {
Picker("Sample duration", selection: $diagnosticDuration) {
Text("1 second").tag(1)
@@ -137,6 +252,7 @@ struct SettingsView: View {
}
.task {
NSApp.keyWindow?.level = alwaysOnTop ? .floating : .normal
privilegedHelper.refresh()
}
}
@@ -153,6 +269,16 @@ struct SettingsView: View {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Development"
}
private func thresholdControl(value: Binding<Double>) -> some View {
HStack(spacing: 10) {
Slider(value: value, in: 50...100, step: 5)
.frame(width: 180)
Text(Formatters.percent(value.wrappedValue))
.monospacedDigit()
.frame(width: 46, alignment: .trailing)
}
}
private func chooseDiagnosticDirectory() {
let panel = NSOpenPanel()
panel.title = "Choose diagnostic report folder"
+179 -102
View File
@@ -11,7 +11,6 @@ struct ContentView: View {
@State private var searchText = ""
@State private var showingNewTask = false
@State private var detailSelection: Int32?
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@AppStorage("sidebarCompact") private var sidebarCompact = false
init() {
@@ -20,59 +19,25 @@ struct ContentView: View {
}
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
Sidebar(selection: $selection, isCompact: sidebarCompact)
.navigationSplitViewColumnWidth(
min: sidebarCompact ? 68 : 190,
ideal: sidebarCompact ? 68 : 210,
max: sidebarCompact ? 68 : 250
)
NavigationSplitView(columnVisibility: .constant(.all)) {
Sidebar(selection: $selection, compact: sidebarCompact)
.navigationSplitViewColumnWidth(
min: sidebarCompact ? 68 : 190,
ideal: sidebarCompact ? 68 : 210,
max: sidebarCompact ? 68 : 260
)
} detail: {
Group {
switch selection ?? .processes {
case .processes:
ProcessesView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Search processes by name, PID, or user")
case .performance:
PerformanceView()
case .history:
AppHistoryView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Search app history")
case .startup:
StartupAppsView()
case .users:
UsersView { process in
detailSelection = process.pid
selection = .details
}
case .details:
DetailsView(searchText: searchText, selection: $detailSelection)
.searchable(text: $searchText, placement: .toolbar, prompt: "Search details by name, PID, or user")
case .services:
ServicesView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Search services by name, PID, or identifier")
case .hardware:
HardwareView()
case .settings:
SettingsView()
}
}
.toolbar {
TaskToolbarContent(showingNewTask: $showingNewTask)
}
detailView
.toolbar(removing: .sidebarToggle)
}
.navigationSplitViewStyle(.balanced)
.toolbar(removing: .sidebarToggle)
.navigationTitle("")
.background(WindowToolbarCleaner())
.onChange(of: selection) { _, section in
searchText = ""
monitor.setActiveSection(section ?? .processes)
}
.navigationTitle("puter")
.animation(.snappy(duration: 0.22), value: sidebarCompact)
.onChange(of: selection) { _, _ in searchText = "" }
.onReceive(NotificationCenter.default.publisher(for: .runNewTask)) { _ in showingNewTask = true }
.sheet(isPresented: $showingNewTask) {
NewTaskView(isPresented: $showingNewTask)
@@ -86,11 +51,120 @@ struct ContentView: View {
Text(monitor.errorMessage ?? "")
}
.onAppear {
monitor.setActiveSection(selection ?? .processes)
DispatchQueue.main.async {
NSApp.keyWindow?.level = UserDefaults.standard.bool(forKey: "alwaysOnTop") ? .floating : .normal
}
}
}
@ViewBuilder
private var detailView: some View {
Group {
switch selection ?? .processes {
case .processes:
ProcessesView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Search processes by name, PID, or user")
case .performance:
PerformanceView()
case .history:
AppHistoryView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Search app history")
case .startup:
StartupAppsView()
case .users:
UsersView { process in
detailSelection = process.pid
selection = .details
}
case .details:
DetailsView(searchText: searchText, selection: $detailSelection)
.searchable(text: $searchText, placement: .toolbar, prompt: "Search details by name, PID, or user")
case .services:
ServicesView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Search services by name, PID, or identifier")
case .energy:
EnergyView()
case .diagnostics:
DiagnosticsView()
case .hardware:
HardwareView()
case .settings:
SettingsView()
}
}
.toolbar {
TaskToolbarContent(showingNewTask: $showingNewTask)
}
}
}
private struct WindowToolbarCleaner: NSViewRepresentable {
func makeNSView(context: Context) -> CleanerView {
CleanerView()
}
func updateNSView(_ nsView: CleanerView, context: Context) {
nsView.removeAutomaticSidebarToggle()
nsView.installCenteredBrand()
}
final class CleanerView: NSView {
private weak var installedTitlebar: NSView?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
removeAutomaticSidebarToggle()
installCenteredBrand()
DispatchQueue.main.async { [weak self] in
self?.removeAutomaticSidebarToggle()
self?.installCenteredBrand()
}
}
func installCenteredBrand() {
guard installedTitlebar == nil,
let closeButton = window?.standardWindowButton(.closeButton),
let titlebar = closeButton.superview else { return }
let brand = PassthroughHostingView(rootView: PuterBrand(compactTitlebar: true))
brand.translatesAutoresizingMaskIntoConstraints = false
titlebar.addSubview(brand)
NSLayoutConstraint.activate([
brand.centerXAnchor.constraint(equalTo: titlebar.centerXAnchor),
brand.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor)
])
installedTitlebar = titlebar
}
func removeAutomaticSidebarToggle() {
guard let toolbar = window?.toolbar,
let index = toolbar.items.firstIndex(where: {
$0.itemIdentifier == .toggleSidebar
|| $0.action == NSSelectorFromString("toggleSidebar:")
|| $0.label == "Hide Sidebar"
|| $0.label == "Show Sidebar"
|| $0.toolTip == "Hide Sidebar"
|| $0.toolTip == "Show Sidebar"
|| $0.view?.accessibilityLabel() == "Hide Sidebar"
|| $0.view?.accessibilityLabel() == "Show Sidebar"
}) else { return }
toolbar.removeItem(at: index)
}
}
}
private final class PassthroughHostingView<Content: View>: NSHostingView<Content> {
override func hitTest(_ point: NSPoint) -> NSView? { nil }
}
private struct TaskToolbarContent: ToolbarContent {
@@ -100,6 +174,16 @@ private struct TaskToolbarContent: ToolbarContent {
var body: some ToolbarContent {
@Bindable var monitor = monitor
ToolbarItem(placement: .navigation) {
Button {
sidebarCompact.toggle()
} label: {
Image(systemName: "sidebar.left")
}
.help(sidebarCompact ? "Expand Sidebar" : "Compact Sidebar")
.accessibilityLabel(sidebarCompact ? "Expand Sidebar" : "Compact Sidebar")
}
ToolbarItemGroup(placement: .primaryAction) {
Button {
showingNewTask = true
@@ -117,78 +201,71 @@ private struct TaskToolbarContent: ToolbarContent {
Menu {
UpdateSpeedPicker(selection: $monitor.updateSpeed)
Divider()
Toggle("Compact Sidebar", isOn: $sidebarCompact)
} label: {
Image(systemName: "ellipsis")
}
.menuStyle(.borderlessButton)
}
}
}
private struct PuterBrand: View {
let compactTitlebar: Bool
var body: some View {
HStack(spacing: 9) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.scaledToFit()
.frame(width: compactTitlebar ? 21 : 30, height: compactTitlebar ? 21 : 30)
Text("puter")
.font(compactTitlebar ? .headline : .title2.weight(.semibold))
}
.padding(.horizontal, compactTitlebar ? 8 : 14)
.frame(height: compactTitlebar ? 32 : 52)
.fixedSize()
.accessibilityElement(children: .combine)
.accessibilityLabel("puter")
.accessibilityAddTraits(.isHeader)
}
}
private struct Sidebar: View {
@Binding var selection: TaskSection?
let isCompact: Bool
let compact: Bool
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 4) {
ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in
Button {
selection = section
} label: {
sidebarLabel(section.rawValue, icon: section.icon)
.padding(.horizontal, isCompact ? 0 : 8)
.frame(minHeight: 40)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity)
.background(selection == section ? Color.accentColor.opacity(0.18) : .clear)
.clipShape(RoundedRectangle(cornerRadius: 7))
.help(section.rawValue)
.accessibilityLabel(section.rawValue)
.accessibilityAddTraits(selection == section ? .isSelected : [])
}
List(selection: $selection) {
Section {
ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in
sidebarRow(section)
}
.padding(.horizontal, isCompact ? 8 : 10)
.padding(.vertical, 8)
}
Divider().padding(.horizontal, isCompact ? 12 : 16)
Button {
selection = .settings
} label: {
sidebarLabel("Settings", icon: "gearshape")
.padding(.horizontal, isCompact ? 0 : 8)
.frame(minHeight: 40)
.contentShape(Rectangle())
Section {
sidebarRow(.settings)
}
.buttonStyle(.plain)
.font(.callout)
.frame(maxWidth: .infinity)
.background(selection == .settings ? Color.accentColor.opacity(0.18) : .clear)
.clipShape(RoundedRectangle(cornerRadius: 7))
.padding(.horizontal, isCompact ? 8 : 10)
.padding(.vertical, 8)
.foregroundStyle(selection == .settings ? Color.primary : Color.secondary)
.help("Settings")
.accessibilityLabel("Settings")
}
.listStyle(.sidebar)
.scrollIndicators(compact ? .hidden : .automatic)
.accessibilityLabel("Navigation")
}
private func sidebarLabel(_ title: String, icon: String) -> some View {
private func sidebarRow(_ section: TaskSection) -> some View {
HStack(spacing: 10) {
Image(systemName: icon)
Image(systemName: section.icon)
.frame(width: 22, height: 20)
if !isCompact {
Text(title).lineLimit(1)
if !compact {
Text(section.rawValue)
.lineLimit(1)
Spacer(minLength: 0)
}
}
.frame(maxWidth: .infinity, alignment: isCompact ? .center : .leading)
.frame(maxWidth: .infinity, minHeight: compact ? 28 : nil, alignment: compact ? .center : .leading)
.contentShape(Rectangle())
.tag(section)
.help(section.rawValue)
.accessibilityLabel(section.rawValue)
.listRowInsets(compact ? EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) : nil)
}
}
+393
View File
@@ -0,0 +1,393 @@
import AppKit
import Foundation
import SwiftUI
import UniformTypeIdentifiers
struct DiagnosticCapture: Identifiable, Codable, Hashable, Sendable {
let id: UUID
var name: String
let createdAt: Date
let system: DiagnosticSystemMetrics
let processes: [DiagnosticProcessMetrics]
let disks: [DiagnosticDiskMetrics]
}
struct DiagnosticSystemMetrics: Codable, Hashable, Sendable {
let cpuPercent: Double
let memoryUsedBytes: UInt64
let memoryTotalBytes: UInt64
let memoryPressure: String
let compressedBytes: UInt64
let swapUsedBytes: UInt64
let diskReadBytesPerSecond: Double
let diskWriteBytesPerSecond: Double
let diskActivePercent: Double
let gpuPercent: Double
let networkReceiveBytesPerSecond: Double
let networkSendBytesPerSecond: Double
let systemPowerWatts: Double
let thermalState: String
let processCount: Int
let threadCount: Int
let uptimeSeconds: Double
}
struct DiagnosticProcessMetrics: Identifiable, Codable, Hashable, Sendable {
let pid: Int32
let name: String
let executablePath: String
let user: String
let cpuPercent: Double
let residentBytes: UInt64
let diskBytesPerSecond: Double
let networkBytesPerSecond: Double
var id: Int32 { pid }
}
struct DiagnosticDiskMetrics: Identifiable, Codable, Hashable, Sendable {
let id: String
let name: String
let smartStatus: String
let remainingLifePercent: Double?
let temperatureCelsius: Double?
let mediaErrors: UInt64?
let unsafeShutdowns: UInt64?
}
@MainActor
enum DiagnosticCaptureStore {
static var directory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("puter/Diagnostics", isDirectory: true)
}
static func load() -> [DiagnosticCapture] {
guard let urls = try? FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return urls.filter { $0.pathExtension == "json" }.compactMap {
guard let data = try? Data(contentsOf: $0) else { return nil }
return try? decoder.decode(DiagnosticCapture.self, from: data)
}.sorted { $0.createdAt > $1.createdAt }
}
static func save(_ capture: DiagnosticCapture) throws {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(capture).write(to: url(for: capture.id), options: .atomic)
}
static func delete(_ capture: DiagnosticCapture) throws {
try FileManager.default.removeItem(at: url(for: capture.id))
}
static func url(for id: UUID) -> URL { directory.appendingPathComponent("\(id.uuidString).json") }
}
struct DiagnosticsView: View {
@Environment(SystemMonitor.self) private var monitor
@State private var captures = DiagnosticCaptureStore.load()
@State private var selectedIDs: Set<UUID> = []
@State private var pendingDeletion: DiagnosticCapture?
private var selectedCaptures: [DiagnosticCapture] {
captures.filter { selectedIDs.contains($0.id) }.sorted { $0.createdAt < $1.createdAt }
}
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Diagnostics", subtitle: "Capture system state and compare changes over time") {
HStack(spacing: 10) {
Button("Capture now", systemImage: "camera.metering.center.weighted") { captureNow() }
.buttonStyle(.borderedProminent)
Button("Open folder", systemImage: "folder") { openFolder() }
}
}
ResourceSummaryBar(snapshot: monitor.snapshot)
HSplitView {
captureList.frame(minWidth: 310, idealWidth: 360, maxWidth: 440)
comparison.frame(minWidth: 430, maxWidth: .infinity, maxHeight: .infinity)
}
}
.confirmationDialog(
"Delete diagnostic capture?",
isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } })
) {
Button("Delete", role: .destructive) { deletePendingCapture() }
Button("Cancel", role: .cancel) { pendingDeletion = nil }
} message: {
Text("This removes the saved capture from puter's Diagnostics folder.")
}
}
private var captureList: some View {
VStack(spacing: 0) {
HStack {
Text("Saved captures").font(.headline)
Spacer()
Text("Select two").font(.caption).foregroundStyle(.secondary)
}
.padding(14)
Divider()
if captures.isEmpty {
VStack(spacing: 10) {
Image(systemName: "waveform.badge.plus")
.font(.system(size: 30))
.foregroundStyle(.tertiary)
Text("No captures").font(.headline)
Text("Capture current system state to establish a baseline.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 260)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(20)
} else {
List(captures) { capture in
HStack(spacing: 10) {
Toggle("Compare \(capture.name)", isOn: selectionBinding(capture.id)).labelsHidden().toggleStyle(.checkbox)
VStack(alignment: .leading, spacing: 3) {
Text(capture.name).font(.callout.weight(.medium)).lineLimit(1)
Text(capture.createdAt.formatted(date: .abbreviated, time: .standard))
.font(.caption).foregroundStyle(.secondary)
Text("CPU \(Formatters.percent(capture.system.cpuPercent)) • Memory \(Formatters.percent(memoryPercent(capture.system)))")
.font(.caption2).foregroundStyle(.secondary)
}
Spacer()
Menu {
Button("Export…") { export(capture) }
Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting([DiagnosticCaptureStore.url(for: capture.id)]) }
Divider()
Button("Delete", role: .destructive) { pendingDeletion = capture }
} label: { Image(systemName: "ellipsis.circle") }
.menuStyle(.borderlessButton)
}
.padding(.vertical, 4)
}
.listStyle(.inset)
}
}
}
@ViewBuilder
private var comparison: some View {
if selectedCaptures.count == 2 {
comparisonContent(from: selectedCaptures[0], to: selectedCaptures[1])
} else if selectedCaptures.count == 1 {
captureDetail(selectedCaptures[0])
} else {
ContentUnavailableView(
"Select captures to inspect",
systemImage: "arrow.left.and.right",
description: Text("Select one capture for details or two to compare deltas.")
)
}
}
private func captureDetail(_ capture: DiagnosticCapture) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
Text(capture.name).font(.title2.weight(.semibold))
metricGrid(capture.system)
topProcesses(capture.processes)
diskHealth(capture.disks)
}.padding(20)
}
}
private func comparisonContent(from baseline: DiagnosticCapture, to current: DiagnosticCapture) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
HStack {
VStack(alignment: .leading) {
Text("Comparison").font(.title2.weight(.semibold))
Text("\(baseline.name)\(current.name)").foregroundStyle(.secondary)
}
Spacer()
Text(Formatters.duration(current.createdAt.timeIntervalSince(baseline.createdAt)))
.font(.callout.monospacedDigit()).foregroundStyle(.secondary)
}
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 12)], spacing: 12) {
deltaCard("CPU", baseline.system.cpuPercent, current.system.cpuPercent, suffix: " pp", lowerIsBetter: true)
deltaBytes("Memory used", baseline.system.memoryUsedBytes, current.system.memoryUsedBytes, lowerIsBetter: true)
deltaCard("Disk activity", baseline.system.diskActivePercent, current.system.diskActivePercent, suffix: " pp", lowerIsBetter: true)
deltaRate("Disk throughput", baseline.system.diskReadBytesPerSecond + baseline.system.diskWriteBytesPerSecond, current.system.diskReadBytesPerSecond + current.system.diskWriteBytesPerSecond)
deltaCard("GPU", baseline.system.gpuPercent, current.system.gpuPercent, suffix: " pp", lowerIsBetter: true)
deltaRate("Network", baseline.system.networkReceiveBytesPerSecond + baseline.system.networkSendBytesPerSecond, current.system.networkReceiveBytesPerSecond + current.system.networkSendBytesPerSecond)
deltaCard("System power", baseline.system.systemPowerWatts, current.system.systemPowerWatts, suffix: " W", lowerIsBetter: true)
deltaCard("Processes", Double(baseline.system.processCount), Double(current.system.processCount), suffix: "", lowerIsBetter: true)
}
Text("Current top processes").font(.headline)
topProcesses(current.processes)
}.padding(20)
}
}
private func metricGrid(_ metrics: DiagnosticSystemMetrics) -> some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) {
valueCard("CPU", Formatters.percent(metrics.cpuPercent))
valueCard("Memory", Formatters.percent(memoryPercent(metrics)))
valueCard("Pressure", metrics.memoryPressure)
valueCard("Disk", Formatters.percent(metrics.diskActivePercent))
valueCard("GPU", Formatters.percent(metrics.gpuPercent))
valueCard("Power", metrics.systemPowerWatts > 0 ? String(format: "%.1f W", metrics.systemPowerWatts) : "Not reported")
valueCard("Thermals", metrics.thermalState)
valueCard("Processes", "\(metrics.processCount)")
}
}
private func topProcesses(_ processes: [DiagnosticProcessMetrics]) -> some View {
VStack(spacing: 0) {
ForEach(processes.prefix(12)) { process in
HStack {
ProcessIcon(name: process.name, path: process.executablePath, size: 20)
Text(process.name).lineLimit(1)
Spacer()
Text(Formatters.percent(process.cpuPercent)).monospacedDigit().frame(width: 60, alignment: .trailing)
Text(Formatters.bytes.string(fromByteCount: Int64(clamping: process.residentBytes))).monospacedDigit().frame(width: 90, alignment: .trailing)
}.font(.caption).padding(.vertical, 5)
if process.id != processes.prefix(12).last?.id { Divider() }
}
}
.padding(12)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
}
@ViewBuilder
private func diskHealth(_ disks: [DiagnosticDiskMetrics]) -> some View {
if !disks.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Physical storage health").font(.headline)
ForEach(disks) { disk in
HStack {
Label(disk.name, systemImage: "internaldrive")
Spacer()
Text(disk.smartStatus).foregroundStyle(disk.smartStatus == "Verified" ? .green : .orange)
if let life = disk.remainingLifePercent { Text("\(Formatters.percent(life)) life").monospacedDigit() }
}.font(.callout).padding(.vertical, 4)
}
}
}
}
private func selectionBinding(_ id: UUID) -> Binding<Bool> {
Binding(get: { selectedIDs.contains(id) }, set: { selected in
if selected {
if selectedIDs.count == 2, let oldest = selectedCaptures.first { selectedIDs.remove(oldest.id) }
selectedIDs.insert(id)
} else { selectedIDs.remove(id) }
})
}
private func captureNow() {
let snapshot = monitor.snapshot
let metrics = DiagnosticSystemMetrics(
cpuPercent: snapshot.cpuPercent, memoryUsedBytes: snapshot.memoryUsed,
memoryTotalBytes: snapshot.memoryTotal, memoryPressure: snapshot.memoryPressure.rawValue,
compressedBytes: snapshot.memoryCompressed, swapUsedBytes: snapshot.swapUsed,
diskReadBytesPerSecond: snapshot.diskReadRate, diskWriteBytesPerSecond: snapshot.diskWriteRate,
diskActivePercent: snapshot.diskActivePercent, gpuPercent: snapshot.gpuPercent,
networkReceiveBytesPerSecond: snapshot.networkReceiveRate, networkSendBytesPerSecond: snapshot.networkSendRate,
systemPowerWatts: monitor.hardware.battery.systemPowerWatts, thermalState: monitor.hardware.thermalState,
processCount: snapshot.processCount, threadCount: snapshot.threadCount, uptimeSeconds: snapshot.uptime
)
let processes = monitor.processes.sorted {
($0.cpu + $0.memoryPercent) > ($1.cpu + $1.memoryPercent)
}.prefix(30).map { process in
let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
return DiagnosticProcessMetrics(
pid: process.pid, name: process.displayName, executablePath: process.executablePath,
user: process.user, cpuPercent: process.cpu, residentBytes: process.residentBytes,
diskBytesPerSecond: activity.diskTotal, networkBytesPerSecond: activity.networkTotal
)
}
let disks = monitor.hardware.physicalDisks.map {
DiagnosticDiskMetrics(id: $0.id, name: $0.name, smartStatus: $0.smartStatus,
remainingLifePercent: $0.remainingLifePercent, temperatureCelsius: $0.temperatureCelsius,
mediaErrors: $0.mediaErrors, unsafeShutdowns: $0.unsafeShutdowns)
}
let now = Date()
let capture = DiagnosticCapture(
id: UUID(), name: "Capture \(now.formatted(date: .abbreviated, time: .shortened))",
createdAt: now, system: metrics, processes: processes, disks: disks
)
do {
try DiagnosticCaptureStore.save(capture)
captures.insert(capture, at: 0)
selectedIDs = [capture.id]
} catch { monitor.errorMessage = "Could not save diagnostic capture: \(error.localizedDescription)" }
}
private func deletePendingCapture() {
guard let capture = pendingDeletion else { return }
do {
try DiagnosticCaptureStore.delete(capture)
captures.removeAll { $0.id == capture.id }
selectedIDs.remove(capture.id)
} catch { monitor.errorMessage = "Could not delete diagnostic capture: \(error.localizedDescription)" }
pendingDeletion = nil
}
private func export(_ capture: DiagnosticCapture) {
let panel = NSSavePanel()
panel.allowedContentTypes = [.json]
panel.nameFieldStringValue = "puter-diagnostic-\(capture.id.uuidString.prefix(8)).json"
panel.begin { response in
guard response == .OK, let destination = panel.url else { return }
do { try Data(contentsOf: DiagnosticCaptureStore.url(for: capture.id)).write(to: destination, options: .atomic) }
catch { monitor.errorMessage = "Could not export diagnostic capture: \(error.localizedDescription)" }
}
}
private func openFolder() {
try? FileManager.default.createDirectory(at: DiagnosticCaptureStore.directory, withIntermediateDirectories: true)
NSWorkspace.shared.open(DiagnosticCaptureStore.directory)
}
private func memoryPercent(_ metrics: DiagnosticSystemMetrics) -> Double {
metrics.memoryTotalBytes > 0 ? Double(metrics.memoryUsedBytes) / Double(metrics.memoryTotalBytes) * 100 : 0
}
private func valueCard(_ title: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 4) { Text(title).font(.caption).foregroundStyle(.secondary); Text(value).font(.title3.monospacedDigit()) }
.frame(maxWidth: .infinity, alignment: .leading).padding(12)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
}
private func deltaCard(_ title: String, _ old: Double, _ new: Double, suffix: String, lowerIsBetter: Bool) -> some View {
let delta = new - old
let favorable = lowerIsBetter ? delta <= 0 : delta >= 0
return VStack(alignment: .leading, spacing: 4) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(String(format: "%+.1f%@", delta, suffix)).font(.title3.monospacedDigit()).foregroundStyle(delta == 0 ? Color.secondary : (favorable ? Color.green : Color.orange))
Text(String(format: "%.1f → %.1f", old, new)).font(.caption2).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, alignment: .leading).padding(12)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
}
private func deltaBytes(_ title: String, _ old: UInt64, _ new: UInt64, lowerIsBetter: Bool) -> some View {
let delta = Int64(clamping: new) - Int64(clamping: old)
let text = (delta >= 0 ? "+" : "") + Formatters.bytes.string(fromByteCount: abs(delta))
return VStack(alignment: .leading, spacing: 4) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(text).font(.title3.monospacedDigit()).foregroundStyle(delta == 0 ? Color.secondary : ((lowerIsBetter ? delta < 0 : delta > 0) ? Color.green : Color.orange))
Text("\(Formatters.bytes.string(fromByteCount: Int64(clamping: old)))\(Formatters.bytes.string(fromByteCount: Int64(clamping: new)))").font(.caption2).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, alignment: .leading).padding(12)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
}
private func deltaRate(_ title: String, _ old: Double, _ new: Double) -> some View {
let delta = new - old
return VStack(alignment: .leading, spacing: 4) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text((delta >= 0 ? "+" : "") + Formatters.rate(abs(delta))).font(.title3.monospacedDigit()).foregroundStyle(delta == 0 ? Color.secondary : Color.orange)
Text("\(Formatters.rate(old))\(Formatters.rate(new))").font(.caption2).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, alignment: .leading).padding(12)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 9))
}
}
+210
View File
@@ -0,0 +1,210 @@
import AppKit
import SwiftUI
struct EnergyView: View {
@Environment(SystemMonitor.self) private var monitor
private var power: PowerManagementSnapshot { monitor.hardware.powerManagement }
private var sleepBlockers: [SleepAssertionSnapshot] {
power.assertions.filter(\.preventsSleep)
}
private var rankedProcesses: [(process: ProcessRecord, score: Double)] {
monitor.processes.map { ($0, energyScore(for: $0)) }
.filter { $0.1 > 0.05 }
.sorted { $0.1 > $1.1 }
.prefix(12)
.map { $0 }
}
var body: some View {
VStack(spacing: 0) {
PageHeader(
title: "Energy & Sleep",
subtitle: "Live energy estimates, power policy, and apps preventing sleep"
) {
Button("Battery Settings", systemImage: "gear") {
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension")!)
}
UpdateStatus()
}
ResourceSummaryBar(snapshot: monitor.snapshot)
ScrollView {
VStack(alignment: .leading, spacing: 18) {
summary
HStack(alignment: .top, spacing: 16) {
processSection
sleepSection
}
policySection
Label(
"Energy impact is an estimate based on sampled CPU, disk, and network activity. macOS does not expose Activity Monitor's proprietary per-process Energy Impact value.",
systemImage: "info.circle"
)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(20)
}
}
}
private var summary: some View {
HStack(spacing: 12) {
summaryCard("Power source", power.source, "powerplug", .orange)
summaryCard("Power mode", power.mode, "gauge.with.dots.needle.50percent", .green)
summaryCard("System power", watts(monitor.hardware.battery.systemPowerWatts), "bolt.fill", .yellow)
summaryCard("Sleep blockers", "\(sleepBlockers.count)", "moon.zzz", sleepBlockers.isEmpty ? .green : .orange)
}
}
private var processSection: some View {
GroupBox("Estimated energy impact") {
VStack(spacing: 0) {
HStack {
Text("Process")
Spacer()
Text("Impact")
}
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.bottom, 6)
if rankedProcesses.isEmpty {
ContentUnavailableView("No active energy users", systemImage: "leaf", description: Text("No process activity was measured in this sample."))
.frame(minHeight: 220)
} else {
ForEach(rankedProcesses, id: \.process.pid) { item in
HStack(spacing: 8) {
ProcessIcon(name: item.process.displayName, path: item.process.executablePath, size: 22)
VStack(alignment: .leading, spacing: 2) {
Text(item.process.displayName).lineLimit(1)
Text("CPU \(Formatters.percent(item.process.cpu))")
.font(.caption2).foregroundStyle(.secondary)
}
Spacer()
Text(impactLabel(item.score))
.font(.caption.weight(.medium))
.foregroundStyle(impactColor(item.score))
.frame(width: 68, alignment: .trailing)
Text(String(format: "%.1f", item.score))
.monospacedDigit()
.frame(width: 44, alignment: .trailing)
}
.font(.callout)
.padding(.vertical, 5)
if item.process.pid != rankedProcesses.last?.process.pid { Divider() }
}
}
}
.padding(8)
}
.frame(maxWidth: .infinity)
}
private var sleepSection: some View {
GroupBox("Preventing sleep") {
VStack(spacing: 0) {
if sleepBlockers.isEmpty {
ContentUnavailableView("Nothing is preventing sleep", systemImage: "moon.zzz", description: Text("No active sleep assertions were reported by macOS."))
.frame(minHeight: 220)
} else {
ForEach(sleepBlockers) { assertion in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(assertion.processName).font(.callout.weight(.medium)).lineLimit(1)
Spacer()
Text(assertion.duration).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
Text(assertion.reason).font(.caption).foregroundStyle(.secondary).lineLimit(2)
Text("PID \(assertion.pid)\(friendlyAssertionType(assertion.type))")
.font(.caption2).foregroundStyle(.tertiary)
}
.padding(.vertical, 7)
if assertion.id != sleepBlockers.last?.id { Divider() }
}
}
}
.padding(8)
}
.frame(maxWidth: .infinity)
}
private var policySection: some View {
GroupBox("Active power policy") {
HStack(spacing: 28) {
policyValue("System sleep", minuteText(power.systemSleepMinutes), "powersleep")
policyValue("Display sleep", minuteText(power.displaySleepMinutes), "display")
policyValue("Power Nap", power.powerNapEnabled ? "On" : "Off", "clock.arrow.circlepath")
policyValue("Wake for network", power.wakeOnNetworkEnabled ? "On" : "Off", "network")
Spacer()
}
.padding(10)
}
}
private func summaryCard(_ title: String, _ value: String, _ icon: String, _ color: Color) -> some View {
HStack(spacing: 10) {
Image(systemName: icon).font(.title2).foregroundStyle(color).frame(width: 30)
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(value).font(.headline).lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(12)
.frame(maxWidth: .infinity)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.48))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(.secondary.opacity(0.14)) }
}
private func policyValue(_ title: String, _ value: String, _ icon: String) -> some View {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(value).font(.callout.weight(.medium))
}
} icon: { Image(systemName: icon).foregroundStyle(.green) }
}
private func energyScore(for process: ProcessRecord) -> Double {
let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
let diskMB = activity.diskTotal / 1_048_576
let networkMB = activity.networkTotal / 1_048_576
return min(100, process.cpu + diskMB * 3.5 + networkMB * 1.5)
}
private func impactLabel(_ score: Double) -> String {
switch score {
case 30...: "Very high"
case 15..<30: "High"
case 5..<15: "Medium"
case 1..<5: "Low"
default: "Very low"
}
}
private func impactColor(_ score: Double) -> Color {
switch score {
case 30...: .red
case 15..<30: .orange
case 5..<15: .yellow
default: .green
}
}
private func minuteText(_ value: Int?) -> String {
guard let value else { return "Not reported" }
return value == 0 ? "Never" : "\(value) min"
}
private func friendlyAssertionType(_ value: String) -> String {
value.replacingOccurrences(of: "PreventUserIdle", with: "Prevent ")
.replacingOccurrences(of: "NoIdleSleepAssertion", with: "Prevent idle sleep")
.replacingOccurrences(of: "SystemSleep", with: "system sleep")
.replacingOccurrences(of: "DisplaySleep", with: "display sleep")
}
private func watts(_ value: Double) -> String {
value > 0 ? String(format: "%.1f W", value) : "Not reported"
}
}
+11 -30
View File
@@ -2,12 +2,21 @@ import Foundation
enum FanControlError: LocalizedError {
case backendUnavailable
case helperNotEnabled
case connectionFailed
case timedOut
case commandFailed(String)
var errorDescription: String? {
switch self {
case .backendUnavailable:
"The SMC control backend is unavailable on this Mac."
case .helperNotEnabled:
"Enable puter's privileged helper in Settings before changing fan control."
case .connectionFailed:
"puter could not connect to its privileged helper."
case .timedOut:
"The privileged helper did not respond in time."
case .commandFailed(let message):
message.isEmpty ? "The fan command did not complete." : message
}
@@ -29,39 +38,11 @@ enum FanControlService {
static func setManualTargets(_ targets: [Int: Double]) throws {
guard isAvailable else { throw FanControlError.backendUnavailable }
let commands = targets.sorted { $0.key < $1.key }.flatMap { id, speed in
let safeID = max(0, id)
let safeSpeed = max(0, Int(speed.rounded()))
return ["\(quotedTool) fan \(safeID) -m 1", "\(quotedTool) fan \(safeID) -v \(safeSpeed)"]
}
try runPrivileged(commands.joined(separator: " && "))
try PrivilegedHelperClient.setFanTargets(targets)
}
static func restoreAutomatic() throws {
guard isAvailable else { throw FanControlError.backendUnavailable }
try runPrivileged("\(quotedTool) reset")
}
private static var quotedTool: String {
"'" + toolPath.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private static func runPrivileged(_ shellCommand: String) throws {
let escaped = shellCommand
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let script = "do shell script \"\(escaped)\" with administrator privileges"
let process = Process()
let errorPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", script]
process.standardOutput = FileHandle.nullDevice
process.standardError = errorPipe
try process.run()
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw FanControlError.commandFailed(String(decoding: errorData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines))
}
try PrivilegedHelperClient.restoreAutomaticFanControl()
}
}
+56
View File
@@ -33,6 +33,7 @@ struct HardwareView: View {
if battery.isPresent { batterySection }
if let adapter = battery.adapter { adapterSection(adapter) }
powerSection
if !monitor.hardware.physicalDisks.isEmpty { storageSection }
portsSection
coolingSection
topConsumersSection
@@ -151,6 +152,7 @@ struct HardwareView: View {
metric("Negotiated voltage", String(format: "%.1f V", adapter.negotiatedVoltage))
metric("Current limit", String(format: "%.2f A", adapter.negotiatedCurrent))
metric("Negotiated ceiling", watts(adapter.negotiatedWatts))
metric("Adapter input", watts(battery.adapterInputWatts))
metric("System draw", watts(battery.systemPowerWatts))
Spacer()
}
@@ -213,6 +215,60 @@ struct HardwareView: View {
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private var storageSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Physical storage").font(.headline)
ForEach(monitor.hardware.physicalDisks) { disk in
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .firstTextBaseline) {
Label(disk.name, systemImage: disk.isSolidState ? "internaldrive.fill" : "internaldrive")
.font(.headline)
Text(disk.id).font(.caption.monospaced()).foregroundStyle(.secondary)
Spacer()
Label(disk.smartStatus, systemImage: disk.smartStatus == "Verified" ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.callout.weight(.medium))
.foregroundStyle(disk.smartStatus == "Verified" ? .green : .orange)
}
HStack(spacing: 28) {
metric("Capacity", Formatters.bytes.string(fromByteCount: Int64(clamping: disk.capacity)))
metric("Connection", disk.protocolName)
metric("Media", disk.isSolidState ? "Solid-state" : "Rotational")
metric("Location", disk.isInternal ? "Internal" : (disk.isRemovable ? "Removable" : "External"))
if let remaining = disk.remainingLifePercent { metric("Estimated life", Formatters.percent(remaining)) }
Spacer()
}
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 135), alignment: .leading)], alignment: .leading, spacing: 12) {
diskMetric("Temperature", disk.temperatureCelsius.map { String(format: "%.1f °C", $0) })
diskMetric("Available spare", disk.availableSparePercent.map(Formatters.percent))
diskMetric("TRIM", disk.trimEnabled.map { $0 ? "Enabled" : "Disabled" })
diskMetric("Power-on hours", disk.powerOnHours.map(String.init))
diskMetric("Power cycles", disk.powerCycles.map(String.init))
diskMetric("Unsafe shutdowns", disk.unsafeShutdowns.map(String.init))
diskMetric("Media errors", disk.mediaErrors.map(String.init))
diskMetric("Lifetime read", disk.bytesRead.map { Formatters.bytes.string(fromByteCount: Int64(clamping: $0)) })
diskMetric("Lifetime written", disk.bytesWritten.map { Formatters.bytes.string(fromByteCount: Int64(clamping: $0)) })
}
Text("Wear and lifetime counters are shown only when the drive exposes them through macOS SMART data.")
.font(.caption).foregroundStyle(.secondary)
}
.padding(14)
.background(Color.secondary.opacity(0.045), in: RoundedRectangle(cornerRadius: 9))
.overlay { RoundedRectangle(cornerRadius: 9).stroke(Color.secondary.opacity(0.12)) }
}
}
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private func diskMetric(_ title: String, _ value: String?) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(value ?? "Not reported").font(.callout.monospacedDigit()).lineLimit(1)
}
}
private func portRow(_ port: HardwarePortSnapshot) -> some View {
VStack(alignment: .leading, spacing: 9) {
HStack {
+96
View File
@@ -0,0 +1,96 @@
import AppKit
import SwiftUI
struct MenuBarMonitorView: View {
@Environment(SystemMonitor.self) private var monitor
@Environment(\.openWindow) private var openWindow
private var topProcesses: [ProcessRecord] {
monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(5).map { $0 }
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Image(nsImage: NSApp.applicationIconImage).resizable().scaledToFit().frame(width: 30, height: 30)
VStack(alignment: .leading, spacing: 2) {
Text("puter").font(.headline)
if let updated = monitor.lastUpdated {
Text("Updated \(updated, style: .relative)").font(.caption).foregroundStyle(.secondary)
} else { Text("Collecting…").font(.caption).foregroundStyle(.secondary) }
}
Spacer()
Circle().fill(monitor.isPaused ? .orange : .green).frame(width: 8, height: 8)
}
HStack(spacing: 8) {
metric("CPU", Formatters.percent(monitor.snapshot.cpuPercent), .blue)
metric("Memory", Formatters.percent(monitor.snapshot.memoryPercent), pressureColor)
}
HStack(spacing: 8) {
metric("Network", Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate), .teal)
metric("Power", powerText, .orange)
}
HStack {
Label("Memory pressure", systemImage: "memorychip")
Spacer()
Text(monitor.snapshot.memoryPressure.rawValue).foregroundStyle(pressureColor)
}
.font(.caption.weight(.medium))
Divider()
Text("Top CPU processes").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
if topProcesses.isEmpty {
Text("No process data yet").font(.caption).foregroundStyle(.secondary)
} else {
ForEach(topProcesses) { process in
HStack(spacing: 8) {
ProcessIcon(name: process.displayName, path: process.executablePath, size: 18)
Text(process.displayName).font(.caption).lineLimit(1)
Spacer()
Text(Formatters.percent(process.cpu)).font(.caption.monospacedDigit())
}
}
}
Divider()
HStack {
Button(monitor.isPaused ? "Resume" : "Pause") { monitor.isPaused.toggle() }
Button("Refresh") { monitor.refresh() }
Spacer()
Button("Open puter") {
openWindow(id: "main")
NSApp.activate()
}
.buttonStyle(.borderedProminent)
}
.controlSize(.small)
}
.padding(14)
.frame(width: 320)
}
private func metric(_ title: String, _ value: String, _ color: Color) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.caption2).foregroundStyle(.secondary)
Text(value).font(.callout.weight(.semibold).monospacedDigit()).lineLimit(1).minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(9)
.background(color.opacity(0.09), in: RoundedRectangle(cornerRadius: 8))
}
private var pressureColor: Color {
switch monitor.snapshot.memoryPressure {
case .normal: .green
case .warning: .orange
case .critical: .red
}
}
private var powerText: String {
let watts = monitor.hardware.battery.systemPowerWatts
return watts > 0 ? String(format: "%.1f W", watts) : "Not reported"
}
}
+141
View File
@@ -9,11 +9,22 @@ enum TaskSection: String, CaseIterable, Identifiable {
case users = "Users"
case details = "Details"
case services = "Services"
case energy = "Energy & Sleep"
case diagnostics = "Diagnostics"
case hardware = "Hardware"
case settings = "Settings"
var id: String { rawValue }
var usesLiveProcessInventory: Bool {
switch self {
case .processes, .performance, .history, .users, .details, .energy, .diagnostics:
true
case .startup, .services, .hardware, .settings:
false
}
}
var icon: String {
switch self {
case .processes: "square.stack.3d.up"
@@ -23,6 +34,8 @@ enum TaskSection: String, CaseIterable, Identifiable {
case .users: "person.2"
case .details: "list.bullet.rectangle"
case .services: "gearshape.2"
case .energy: "leaf"
case .diagnostics: "waveform.badge.magnifyingglass"
case .hardware: "bolt.horizontal.circle"
case .settings: "gearshape"
}
@@ -66,6 +79,8 @@ struct BatterySnapshot: Hashable, Sendable {
var serial = ""
var adapter: PowerAdapterSnapshot?
var systemPowerWatts = 0.0
var sensorBatteryPowerWatts = 0.0
var adapterInputWatts = 0.0
var healthPercent: Double {
guard designCapacityMAh > 0 else { return 0 }
@@ -73,6 +88,7 @@ struct BatterySnapshot: Hashable, Sendable {
}
var batteryPowerWatts: Double {
if sensorBatteryPowerWatts > 0 { return sensorBatteryPowerWatts }
let watts = abs(voltageVolts * currentAmps)
return watts.isFinite && watts <= 500 ? watts : 0
}
@@ -110,11 +126,59 @@ struct FanSnapshot: Identifiable, Hashable, Sendable {
var isAutomatic: Bool { mode.localizedCaseInsensitiveContains("automatic") }
}
struct SleepAssertionSnapshot: Identifiable, Hashable, Sendable {
let id: String
let pid: Int32
let processName: String
let type: String
let reason: String
let duration: String
var preventsSleep: Bool {
type.localizedCaseInsensitiveContains("sleep") || type == "ExternalMedia"
}
}
struct PowerManagementSnapshot: Hashable, Sendable {
var source = "Unknown"
var mode = "Automatic"
var systemSleepMinutes: Int?
var displaySleepMinutes: Int?
var powerNapEnabled = false
var wakeOnNetworkEnabled = false
var assertions: [SleepAssertionSnapshot] = []
}
struct PhysicalDiskSnapshot: Identifiable, Hashable, Sendable {
let id: String
let name: String
let protocolName: String
let capacity: UInt64
let isInternal: Bool
let isRemovable: Bool
let isSolidState: Bool
let smartStatus: String
let trimEnabled: Bool?
let temperatureCelsius: Double?
let percentageUsed: Double?
let availableSparePercent: Double?
let powerOnHours: UInt64?
let powerCycles: UInt64?
let unsafeShutdowns: UInt64?
let mediaErrors: UInt64?
let bytesRead: UInt64?
let bytesWritten: UInt64?
var remainingLifePercent: Double? { percentageUsed.map { max(0, 100 - $0) } }
}
struct HardwareSnapshot: Hashable, Sendable {
var battery = BatterySnapshot()
var ports: [HardwarePortSnapshot] = []
var fans: [FanSnapshot] = []
var thermalState = "Nominal"
var powerManagement = PowerManagementSnapshot()
var physicalDisks: [PhysicalDiskSnapshot] = []
var capturedAt = Date.distantPast
}
@@ -187,6 +251,14 @@ enum UpdateSpeed: String, CaseIterable, Identifiable {
}
}
enum MenuBarMetric: String, CaseIterable, Identifiable {
case cpu = "CPU"
case memory = "Memory"
case network = "Network"
case power = "Power"
var id: String { rawValue }
}
struct ProcessRecord: Identifiable, Hashable, Sendable {
let pid: Int32
let parentPID: Int32
@@ -324,6 +396,7 @@ struct SystemSnapshot {
var memoryType = "Unified"
var memorySpeed = "Not reported by macOS"
var memoryManufacturer = "Apple unified memory"
var memoryPressure = MemoryPressureLevel.normal
var diskFree: UInt64 = 0
var diskTotal: UInt64 = 0
var diskReadRate = 0.0
@@ -359,6 +432,74 @@ struct SystemSnapshot {
var diskThroughput: Double { diskReadRate + diskWriteRate }
}
enum MemoryPressureLevel: String, Hashable, Sendable {
case normal = "Normal"
case warning = "Warning"
case critical = "Critical"
}
struct TelemetryRecordingSample: Codable, Hashable, Sendable {
let timestamp: Date
let cpuPercent: Double
let memoryUsedBytes: UInt64
let memoryTotalBytes: UInt64
let memoryPressure: String
let diskReadBytesPerSecond: Double
let diskWriteBytesPerSecond: Double
let gpuPercent: Double
let networkReceiveBytesPerSecond: Double
let networkSendBytesPerSecond: Double
let systemPowerWatts: Double
let thermalState: String
let processCount: Int
}
struct ResourceAlertEvent: Identifiable, Codable, Hashable, Sendable {
let id: UUID
let timestamp: Date
let resource: String
let value: Double
let threshold: Double
let message: String
}
struct ProcessSecuritySnapshot: Hashable, Sendable {
var signatureStatus = "Inspecting…"
var signingIdentifier = "Not reported"
var teamIdentifier = "Not reported"
var authority = "Not reported"
var hardenedRuntime = false
var appSandbox = false
var debuggerAllowed = false
var entitlementCount = 0
var codeDirectoryHash = "Not reported"
var gatekeeperStatus = "Not assessed"
var quarantineStatus = "Not quarantined"
var validationDetail = ""
}
enum ResourceAlertKind: String, CaseIterable, Sendable {
case cpu = "CPU"
case memory = "Memory"
case disk = "Disk activity"
case thermal = "Thermal pressure"
}
enum ResourceAlertPolicy {
static func shouldFire(
value: Double,
threshold: Double,
consecutiveSamples: Int,
requiredSamples: Int,
lastFired: Date?,
now: Date,
cooldown: TimeInterval
) -> Bool {
guard value >= threshold, consecutiveSamples >= max(1, requiredSamples) else { return false }
return lastFired.map { now.timeIntervalSince($0) >= max(0, cooldown) } ?? true
}
}
@MainActor
enum Formatters {
static let bytes: ByteCountFormatter = {
+71 -1
View File
@@ -38,6 +38,25 @@ struct PerformanceView: View {
VStack(spacing: 0) {
PageHeader(title: "Performance") {
HStack(spacing: 12) {
Button {
monitor.isRecording ? monitor.stopRecording() : monitor.startRecording()
} label: {
Label(monitor.isRecording ? "Stop recording" : "Record", systemImage: monitor.isRecording ? "stop.circle.fill" : "record.circle")
}
.tint(monitor.isRecording ? .red : nil)
if !monitor.recordingSamples.isEmpty {
Menu("Recording", systemImage: "waveform.path.ecg.rectangle") {
Text("\(monitor.recordingSamples.count) samples")
if let started = monitor.recordingStartDate {
Text("Started \(started, style: .relative)")
}
Divider()
Button("Export JSON…") { SessionRecordingExporter.exportJSON(monitor: monitor) }
Button("Export CSV…") { SessionRecordingExporter.exportCSV(monitor: monitor) }
Divider()
Button("Clear recording", role: .destructive) { monitor.clearRecording() }
}
}
Button("Export", systemImage: "square.and.arrow.up") {
SystemReportExporter.export(monitor: monitor, selectedResource: selectedResourceName)
}
@@ -48,7 +67,7 @@ struct PerformanceView: View {
ScrollView {
VStack(spacing: 10) {
metricCard(.cpu, value: monitor.snapshot.cpuPercent, detail: "\(ProcessInfo.processInfo.activeProcessorCount) logical processors")
metricCard(.memory, value: monitor.snapshot.memoryPercent, detail: "\(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))) of \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal)))")
metricCard(.memory, value: monitor.snapshot.memoryPercent, detail: "\(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))) of \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal)))\(monitor.snapshot.memoryPressure.rawValue) pressure")
metricCard(
.disk,
value: monitor.snapshot.diskActivePercent,
@@ -250,6 +269,7 @@ struct PerformanceView: View {
color: .purple,
value: monitor.snapshot.memoryPercent,
history: monitor.memoryHistory,
advisory: memoryAdvisory,
stats: [
("In use", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))),
("Available", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal - monitor.snapshot.memoryUsed))),
@@ -258,6 +278,7 @@ struct PerformanceView: View {
("Wired", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryWired))),
("Compressed", Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryCompressed))),
("Swap", "\(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.swapUsed))) / \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.swapTotal)))"),
("Memory pressure", monitor.snapshot.memoryPressure.rawValue),
("Type", monitor.snapshot.memoryType),
("Frequency / speed", monitor.snapshot.memorySpeed),
("Manufacturer", monitor.snapshot.memoryManufacturer)
@@ -302,6 +323,32 @@ struct PerformanceView: View {
}
return String(decoding: buffer.prefix { $0 != 0 }.map(UInt8.init(bitPattern:)), as: UTF8.self)
}
private var memoryAdvisory: PerformanceAdvisory {
switch monitor.snapshot.memoryPressure {
case .normal:
PerformanceAdvisory(
icon: "checkmark.circle.fill",
title: "Memory pressure is normal",
message: "macOS uses otherwise-idle RAM for cache. A high allocation percentage alone does not mean memory is exhausted.",
color: .green
)
case .warning:
PerformanceAdvisory(
icon: "exclamationmark.triangle.fill",
title: "Memory pressure is elevated",
message: "Compression or swap demand is increasing. Closing memory-heavy apps may improve responsiveness.",
color: .orange
)
case .critical:
PerformanceAdvisory(
icon: "exclamationmark.octagon.fill",
title: "Memory pressure is critical",
message: "The kernel reports severe memory contention. Save work and close memory-heavy apps.",
color: .red
)
}
}
}
private struct RemovableDiskDetail: View {
@@ -843,6 +890,7 @@ private struct PerformanceDetail: View {
let color: Color
let value: Double
let history: [MetricSample]
var advisory: PerformanceAdvisory? = nil
let stats: [(String, String)]
var chartData: [MetricSample] {
@@ -862,6 +910,21 @@ private struct PerformanceDetail: View {
Text(Formatters.percent(value))
.font(.largeTitle.weight(.light).monospacedDigit())
}
if let advisory {
Label {
VStack(alignment: .leading, spacing: 3) {
Text(advisory.title).font(.callout.weight(.semibold))
Text(advisory.message).font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: advisory.icon).foregroundStyle(advisory.color)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(advisory.color.opacity(0.09))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(advisory.color.opacity(0.2)) }
}
VStack(alignment: .leading, spacing: 8) {
Text("% Utilization").font(.caption).foregroundStyle(.secondary)
Chart(chartData) { sample in
@@ -892,3 +955,10 @@ private struct PerformanceDetail: View {
}
}
}
private struct PerformanceAdvisory {
let icon: String
let title: String
let message: String
let color: Color
}
+81
View File
@@ -0,0 +1,81 @@
import Foundation
enum PhysicalDiskScanner {
static func capture() -> [PhysicalDiskSnapshot] {
let listData = run("/usr/sbin/diskutil", ["list", "-plist", "physical"])
guard let list = try? PropertyListSerialization.propertyList(from: listData, format: nil) as? [String: Any] else { return [] }
let identifiers = (list["WholeDisks"] as? [String])
?? (list["AllDisksAndPartitions"] as? [[String: Any]])?.compactMap { $0["DeviceIdentifier"] as? String }
?? []
let trimByDisk = storageTrimSupport()
return identifiers.compactMap { identifier in
let data = run("/usr/sbin/diskutil", ["info", "-plist", identifier])
guard let info = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { return nil }
let smart = info["SMARTDeviceSpecificKeysMayVaryNotGuaranteed"] as? [String: Any] ?? [:]
func number(_ key: String) -> UInt64? { (smart[key] as? NSNumber)?.uint64Value }
func dataBytes(_ key: String) -> UInt64? {
guard let units = number(key) else { return nil }
let result = units.multipliedReportingOverflow(by: 512_000)
return result.overflow ? nil : result.partialValue
}
let temperature = number("TEMPERATURE").map {
let raw = Double($0)
return raw > 200 ? raw - 273.15 : raw
}
return PhysicalDiskSnapshot(
id: identifier,
name: info["MediaName"] as? String ?? identifier,
protocolName: info["BusProtocol"] as? String ?? "Not reported",
capacity: (info["TotalSize"] as? NSNumber)?.uint64Value ?? 0,
isInternal: info["Internal"] as? Bool ?? false,
isRemovable: info["Removable"] as? Bool ?? false,
isSolidState: info["SolidState"] as? Bool ?? false,
smartStatus: info["SMARTStatus"] as? String ?? "Not supported",
trimEnabled: trimByDisk[identifier],
temperatureCelsius: temperature,
percentageUsed: number("PERCENTAGE_USED").map { Double($0) },
availableSparePercent: number("AVAILABLE_SPARE").map { Double($0) },
powerOnHours: number("POWER_ON_HOURS_0"),
powerCycles: number("POWER_CYCLES_0"),
unsafeShutdowns: number("UNSAFE_SHUTDOWNS_0"),
mediaErrors: number("MEDIA_ERRORS_0"),
bytesRead: dataBytes("DATA_UNITS_READ_0"),
bytesWritten: dataBytes("DATA_UNITS_WRITTEN_0")
)
}
.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }
}
private static func storageTrimSupport() -> [String: Bool] {
let data = run("/usr/sbin/system_profiler", ["SPNVMeDataType", "SPSerialATADataType", "-json", "-detailLevel", "mini"])
guard let root = try? JSONSerialization.jsonObject(with: data) else { return [:] }
var result: [String: Bool] = [:]
func visit(_ value: Any) {
if let dictionary = value as? [String: Any] {
if let identifier = dictionary["bsd_name"] as? String,
let trim = (dictionary["spnvme_trim_support"] as? String) ?? (dictionary["spsata_trim_support"] as? String) {
result[identifier] = trim.localizedCaseInsensitiveCompare("yes") == .orderedSame
}
dictionary.values.forEach(visit)
} else if let array = value as? [Any] { array.forEach(visit) }
}
visit(root)
return result
}
private static func run(_ executable: String, _ arguments: [String]) -> Data {
let process = Process()
let output = Pipe()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
process.standardOutput = output
process.standardError = FileHandle.nullDevice
do {
try process.run()
let data = output.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return process.terminationStatus == 0 ? data : Data()
} catch { return Data() }
}
}
+143
View File
@@ -0,0 +1,143 @@
import AppKit
import Foundation
import PuterHelperProtocol
import ServiceManagement
@MainActor
final class PrivilegedHelperManager: ObservableObject {
enum State: Equatable {
case enabled
case disabled
case requiresApproval
case unavailable
case requiresSignedInstallation
case failed(String)
var title: String {
switch self {
case .enabled: "Enabled"
case .disabled: "Not enabled"
case .requiresApproval: "Approval required"
case .unavailable: "Unavailable in this build"
case .requiresSignedInstallation: "Signed installation required"
case .failed: "Error"
}
}
var detail: String {
switch self {
case .enabled: "Fan changes use puter's signed, command-limited helper."
case .disabled: "Enable only if you want puter to change fan targets. Monitoring never requires it."
case .requiresApproval: "Allow puter under Login Items in System Settings, then return here."
case .unavailable: "The helper service is not included in this app bundle."
case .requiresSignedInstallation: "The helper is included, but macOS enables launch daemons only from a properly signed installed app."
case .failed(let message): message
}
}
}
@Published private(set) var state: State = .disabled
@Published private(set) var isWorking = false
private var service: SMAppService {
SMAppService.daemon(plistName: PuterHelperConstants.daemonPlistName)
}
init() { refresh() }
func refresh() {
switch service.status {
case .enabled: state = .enabled
case .notRegistered: state = .disabled
case .requiresApproval: state = .requiresApproval
case .notFound:
state = helperIsBundled ? .requiresSignedInstallation : .unavailable
@unknown default: state = .unavailable
}
}
private var helperIsBundled: Bool {
let contents = Bundle.main.bundleURL.appendingPathComponent("Contents")
return FileManager.default.fileExists(
atPath: contents.appendingPathComponent("Library/LaunchDaemons/\(PuterHelperConstants.daemonPlistName)").path
) && FileManager.default.isExecutableFile(
atPath: contents.appendingPathComponent("Resources/puter-helper").path
)
}
func enable() {
isWorking = true
defer { isWorking = false }
do {
try service.register()
refresh()
} catch {
state = .failed(error.localizedDescription)
}
}
func disable() {
isWorking = true
service.unregister { [weak self] error in
Task { @MainActor in
guard let self else { return }
self.isWorking = false
if let error { self.state = .failed(error.localizedDescription) }
else { self.refresh() }
}
}
}
func openApprovalSettings() {
SMAppService.openSystemSettingsLoginItems()
}
}
enum PrivilegedHelperClient {
private final class ReplyBox: @unchecked Sendable {
private let lock = NSLock()
private var result: Result<Void, Error>?
func set(_ value: Result<Void, Error>) { lock.withLock { result = value } }
func get() -> Result<Void, Error>? { lock.withLock { result } }
}
static func setFanTargets(_ targets: [Int: Double]) throws {
let boxed = Dictionary(uniqueKeysWithValues: targets.map { key, value in
(NSNumber(value: key), NSNumber(value: Int(value.rounded())))
})
try perform { proxy, reply in proxy.setFanTargets(boxed, withReply: reply) }
}
static func restoreAutomaticFanControl() throws {
try perform { proxy, reply in proxy.restoreAutomaticFanControl(withReply: reply) }
}
private static func perform(
_ operation: (PuterPrivilegedHelperProtocol, @escaping (Bool, String?) -> Void) -> Void
) throws {
guard SMAppService.daemon(plistName: PuterHelperConstants.daemonPlistName).status == .enabled else {
throw FanControlError.helperNotEnabled
}
let connection = NSXPCConnection(machServiceName: PuterHelperConstants.machServiceName, options: .privileged)
connection.remoteObjectInterface = NSXPCInterface(with: PuterPrivilegedHelperProtocol.self)
connection.resume()
defer { connection.invalidate() }
let semaphore = DispatchSemaphore(value: 0)
let box = ReplyBox()
let errorHandler: (Error) -> Void = { error in
box.set(.failure(error))
semaphore.signal()
}
guard let proxy = connection.remoteObjectProxyWithErrorHandler(errorHandler) as? PuterPrivilegedHelperProtocol else {
throw FanControlError.connectionFailed
}
operation(proxy) { success, message in
box.set(success ? .success(()) : .failure(FanControlError.commandFailed(message ?? "")))
semaphore.signal()
}
guard semaphore.wait(timeout: .now() + 12) == .success else { throw FanControlError.timedOut }
try box.get()?.get()
}
}
+78 -18
View File
@@ -197,6 +197,7 @@ struct ProcessPropertiesView: View {
@Environment(\.dismiss) private var dismiss
@Environment(SystemMonitor.self) private var monitor
let process: ProcessRecord
@State private var security: ProcessSecuritySnapshot?
private var activity: ProcessActivityRate { monitor.processActivity[process.pid] ?? ProcessActivityRate() }
@@ -216,24 +217,33 @@ struct ProcessPropertiesView: View {
Divider()
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) {
property("Executable", process.executablePath)
property("User", process.user)
property("Parent PID", "\(process.parentPID)")
property("State", process.state)
property("CPU", Formatters.percent(process.cpu))
property("CPU time", Formatters.duration(process.cpuTime))
property("Memory", Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))
property("Threads", "\(process.threadCount)")
property("Open handles", "\(process.openFileCount)")
property("Architecture", process.architecture)
property("Priority", "\(process.priority) (nice \(process.nice))")
property("Disk read", Formatters.rate(activity.diskRead))
property("Disk write", Formatters.rate(activity.diskWrite))
property("Network", Formatters.rate(activity.networkTotal))
property("Elapsed time", process.elapsed)
TabView {
ScrollView {
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) {
property("Executable", process.executablePath)
property("User", process.user)
property("Parent PID", "\(process.parentPID)")
property("State", process.state)
property("CPU", Formatters.percent(process.cpu))
property("CPU time", Formatters.duration(process.cpuTime))
property("Memory", Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))
property("Threads", "\(process.threadCount)")
property("Open handles", "\(process.openFileCount)")
property("Architecture", process.architecture)
property("Priority", "\(process.priority) (nice \(process.nice))")
property("Disk read", Formatters.rate(activity.diskRead))
property("Disk write", Formatters.rate(activity.diskWrite))
property("Network", Formatters.rate(activity.networkTotal))
property("Elapsed time", process.elapsed)
}
.padding(20)
}
.tabItem { Label("Process", systemImage: "list.bullet.rectangle") }
securityContent
.tabItem { Label("Security", systemImage: "checkmark.shield") }
}
.padding(20)
.padding(.horizontal, 12)
Divider()
HStack {
@@ -246,7 +256,57 @@ struct ProcessPropertiesView: View {
}
.padding(16)
}
.frame(width: 520)
.frame(width: 680, height: 650)
.task(id: process.executablePath) {
security = await Task.detached(priority: .utility) {
ProcessSecurityInspector.inspect(executablePath: process.executablePath)
}.value
}
}
@ViewBuilder
private var securityContent: some View {
if let security {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
Label {
VStack(alignment: .leading, spacing: 3) {
Text("Code signature: \(security.signatureStatus)").font(.headline)
if !security.validationDetail.isEmpty {
Text(security.validationDetail).font(.caption).foregroundStyle(.secondary)
}
}
} icon: {
Image(systemName: security.signatureStatus == "Valid" ? "checkmark.shield.fill" : "exclamationmark.shield.fill")
.font(.title2)
.foregroundStyle(security.signatureStatus == "Valid" ? .green : .orange)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background((security.signatureStatus == "Valid" ? Color.green : Color.orange).opacity(0.09))
.clipShape(RoundedRectangle(cornerRadius: 10))
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 13) {
property("Identifier", security.signingIdentifier)
property("Team ID", security.teamIdentifier)
property("Authority", security.authority)
property("Gatekeeper", security.gatekeeperStatus)
property("Quarantine", security.quarantineStatus)
property("Hardened runtime", security.hardenedRuntime ? "Enabled" : "Not enabled")
property("App Sandbox", security.appSandbox ? "Enabled" : "Not enabled")
property("Debug entitlement", security.debuggerAllowed ? "Allowed" : "Not allowed")
property("Entitlements", "\(security.entitlementCount)")
property("CDHash", security.codeDirectoryHash)
}
Label("Security details are inspected on demand using Security.framework and Gatekeeper. They are not part of the recurring telemetry sampler.", systemImage: "info.circle")
.font(.caption).foregroundStyle(.secondary)
}
.padding(20)
}
} else {
ProgressView("Inspecting code signature and Gatekeeper status…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func property(_ name: String, _ value: String) -> some View {
+162
View File
@@ -0,0 +1,162 @@
import SwiftUI
struct ProcessPreviewPane: View {
@Environment(SystemMonitor.self) private var monitor
let process: ProcessRecord?
let onClose: () -> Void
var onShowDetails: ((ProcessRecord) -> Void)?
var onShowProperties: ((ProcessRecord) -> Void)?
var onEndTask: ((ProcessRecord) -> Void)?
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Process preview")
.font(.headline)
Spacer()
Button(action: onClose) {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
.help("Close preview")
.accessibilityLabel("Close process preview")
}
.padding(.horizontal, 16)
.frame(height: 46)
Divider()
if let process {
processContent(process)
} else {
ContentUnavailableView(
"No process selected",
systemImage: "sidebar.right",
description: Text("Select a process to see its live information here.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.background(.background)
.accessibilityElement(children: .contain)
.accessibilityLabel("Process preview")
}
private func processContent(_ process: ProcessRecord) -> some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 12) {
ProcessIcon(name: process.displayName, path: process.executablePath)
.scaleEffect(1.45)
.frame(width: 48, height: 48)
VStack(alignment: .leading, spacing: 3) {
Text(process.displayName)
.font(.title2.weight(.semibold))
.lineLimit(2)
Text("PID \(process.pid)\(process.user)")
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
Spacer(minLength: 0)
}
informationGroup("Resources", systemImage: "gauge.with.dots.needle.67percent") {
infoRow("CPU", Formatters.percent(process.cpu))
infoRow("Memory", Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))
infoRow("Memory share", Formatters.percent(process.memoryPercent))
infoRow("Disk read", Formatters.rate(activity(for: process).diskRead))
infoRow("Disk write", Formatters.rate(activity(for: process).diskWrite))
infoRow("Network", Formatters.rate(activity(for: process).networkTotal))
}
informationGroup("Process", systemImage: "cpu") {
infoRow("Status", readableState(process.state))
infoRow("CPU time", Formatters.duration(process.cpuTime))
infoRow("Elapsed", process.elapsed)
infoRow("Threads", "\(process.threadCount)")
infoRow("Open handles", "\(process.openFileCount)")
infoRow("Architecture", process.architecture)
infoRow("Parent PID", "\(process.parentPID)")
infoRow("Priority", "\(process.priority) (nice \(process.nice))")
}
informationGroup("Executable", systemImage: "terminal") {
Text(process.executablePath)
.font(.callout)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(16)
}
if onShowDetails != nil || onShowProperties != nil || onEndTask != nil {
Divider()
HStack(spacing: 8) {
if let onEndTask {
Button("End task", role: .destructive) { onEndTask(process) }
}
Spacer()
if let onShowDetails {
Button("Details") { onShowDetails(process) }
}
if let onShowProperties {
Button("Properties") { onShowProperties(process) }
.buttonStyle(.borderedProminent)
}
}
.padding(12)
}
}
}
private func informationGroup<Content: View>(
_ title: String,
systemImage: String,
@ViewBuilder content: () -> Content
) -> some View {
GroupBox {
VStack(alignment: .leading, spacing: 10) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
} label: {
Label(title, systemImage: systemImage)
.font(.callout.weight(.semibold))
}
}
private func infoRow(_ label: String, _ value: String) -> some View {
ViewThatFits(in: .horizontal) {
LabeledContent(label) {
Text(value)
.monospacedDigit()
.textSelection(.enabled)
.multilineTextAlignment(.trailing)
}
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.monospacedDigit()
.textSelection(.enabled)
}
}
}
private func activity(for process: ProcessRecord) -> ProcessActivityRate {
monitor.processActivity[process.pid] ?? ProcessActivityRate()
}
private func readableState(_ state: String) -> String {
if state.hasPrefix("R") { return "Running" }
if state.hasPrefix("S") { return "Sleeping" }
if state.hasPrefix("Z") { return "Zombie" }
if state.hasPrefix("T") { return "Stopped" }
return state
}
}
@@ -0,0 +1,105 @@
import Foundation
import Security
enum ProcessSecurityInspector {
static func inspect(executablePath: String) -> ProcessSecuritySnapshot {
guard executablePath.hasPrefix("/"), FileManager.default.fileExists(atPath: executablePath) else {
return ProcessSecuritySnapshot(signatureStatus: "Executable unavailable", validationDetail: "The process did not expose a readable executable path.")
}
var snapshot = ProcessSecuritySnapshot()
var staticCode: SecStaticCode?
let createStatus = SecStaticCodeCreateWithPath(URL(fileURLWithPath: executablePath) as CFURL, [], &staticCode)
guard createStatus == errSecSuccess, let staticCode else {
snapshot.signatureStatus = createStatus == errSecCSUnsigned ? "Unsigned" : "Inspection unavailable"
snapshot.validationDetail = SecCopyErrorMessageString(createStatus, nil) as String? ?? "Security.framework could not inspect this executable."
return snapshot
}
var validationError: Unmanaged<CFError>?
let validationStatus = SecStaticCodeCheckValidityWithErrors(
staticCode,
SecCSFlags(rawValue: UInt32(kSecCSStrictValidate)),
nil,
&validationError
)
if validationStatus == errSecSuccess {
snapshot.signatureStatus = "Valid"
} else if validationStatus == errSecCSUnsigned {
snapshot.signatureStatus = "Unsigned"
} else {
snapshot.signatureStatus = "Invalid"
}
if let error = validationError?.takeRetainedValue() {
snapshot.validationDetail = CFErrorCopyDescription(error) as String
}
var information: CFDictionary?
if SecCodeCopySigningInformation(
staticCode,
SecCSFlags(rawValue: UInt32(kSecCSSigningInformation)),
&information
) == errSecSuccess,
let info = information as? [CFString: Any] {
snapshot.signingIdentifier = info[kSecCodeInfoIdentifier] as? String ?? "Not reported"
snapshot.teamIdentifier = info[kSecCodeInfoTeamIdentifier] as? String ?? "Not reported"
let flags = (info[kSecCodeInfoFlags] as? NSNumber)?.uint32Value ?? 0
snapshot.hardenedRuntime = flags & 0x0001_0000 != 0
if let entitlements = info[kSecCodeInfoEntitlementsDict] as? [String: Any] {
snapshot.entitlementCount = entitlements.count
snapshot.appSandbox = entitlements["com.apple.security.app-sandbox"] as? Bool ?? false
snapshot.debuggerAllowed = entitlements["com.apple.security.get-task-allow"] as? Bool ?? false
}
if let certificates = info[kSecCodeInfoCertificates] as? [SecCertificate] {
snapshot.authority = certificates.compactMap {
SecCertificateCopySubjectSummary($0) as String?
}.first ?? "Ad hoc"
} else if snapshot.signatureStatus == "Valid" {
snapshot.authority = "Ad hoc"
}
if let unique = info[kSecCodeInfoUnique] as? Data {
snapshot.codeDirectoryHash = unique.map { String(format: "%02x", $0) }.joined()
}
}
let assessmentPath = enclosingApplicationPath(for: executablePath) ?? executablePath
let assessment = run("/usr/sbin/spctl", arguments: ["--assess", "--type", "execute", "--verbose=2", assessmentPath])
let assessmentText = (assessment.output + "\n" + assessment.error).trimmingCharacters(in: .whitespacesAndNewlines)
if assessment.status == 0 {
let origin = assessmentText.components(separatedBy: "origin=").dropFirst().first?.components(separatedBy: "\n").first
snapshot.gatekeeperStatus = origin.map { "Accepted • \($0)" } ?? "Accepted"
} else {
snapshot.gatekeeperStatus = assessmentText.isEmpty ? "Not accepted" : assessmentText.components(separatedBy: "\n").first ?? "Not accepted"
}
let quarantine = run("/usr/bin/xattr", arguments: ["-p", "com.apple.quarantine", assessmentPath])
snapshot.quarantineStatus = quarantine.status == 0 ? "Quarantined" : "Not quarantined"
return snapshot
}
private static func enclosingApplicationPath(for path: String) -> String? {
guard let range = path.range(of: ".app/", options: .caseInsensitive) else {
return path.lowercased().hasSuffix(".app") ? path : nil
}
return String(path[..<range.lowerBound]) + ".app"
}
private static func run(_ executable: String, arguments: [String]) -> (output: String, error: String, status: Int32) {
let process = Process()
let output = Pipe()
let error = Pipe()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
process.standardOutput = output
process.standardError = error
do {
try process.run()
let outputData = output.fileHandleForReading.readDataToEndOfFile()
let errorData = error.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return (String(decoding: outputData, as: UTF8.self), String(decoding: errorData, as: UTF8.self), process.terminationStatus)
} catch {
return ("", error.localizedDescription, -1)
}
}
}
+122 -41
View File
@@ -1,7 +1,7 @@
import AppKit
import SwiftUI
private enum ProcessSort: String, CaseIterable {
enum ProcessSort: String, CaseIterable {
case name = "Name"
case cpu = "CPU"
case memory = "Memory"
@@ -10,6 +10,27 @@ private enum ProcessSort: String, CaseIterable {
case pid = "PID"
}
struct ProcessSortCycleState: Equatable {
let field: ProcessSort
let ascending: Bool
let grouped: Bool
static func next(currentField: ProcessSort, ascending: Bool, grouped: Bool, clicked: ProcessSort) -> Self {
if grouped || currentField != clicked {
return .init(field: clicked, ascending: false, grouped: false)
}
if !ascending {
return .init(field: clicked, ascending: true, grouped: false)
}
return .init(field: clicked, ascending: true, grouped: true)
}
func accessibilityValue(for header: ProcessSort) -> String {
guard !grouped, field == header else { return "Not sorted; grouped by category" }
return ascending ? "Sorted ascending" : "Sorted descending"
}
}
private enum ResourceDisplayMode: String, CaseIterable {
case values = "Values"
case percentages = "Percentages"
@@ -76,9 +97,20 @@ struct ProcessesView: View {
@State private var terminationRequest: TerminationRequest?
@State private var groupTerminationRequest: ProcessGroup?
@State private var inspectedProcess: ProcessRecord?
@AppStorage("processPreviewPaneVisible") private var previewPaneVisible = false
private var grouped: [ProcessGroup] {
ProcessGrouping.groups(from: monitor.processes, searchText: searchText).sorted { lhs, rhs in
let groups = ProcessGrouping.groups(from: monitor.processes, searchText: searchText)
if groupByType {
return groups.sorted {
let leftCategory = ProcessCategory.allCases.firstIndex(of: $0.category) ?? 0
let rightCategory = ProcessCategory.allCases.firstIndex(of: $1.category) ?? 0
if leftCategory != rightCategory { return leftCategory < rightCategory }
let comparison = $0.name.localizedCaseInsensitiveCompare($1.name)
return comparison == .orderedSame ? $0.primary.pid < $1.primary.pid : comparison == .orderedAscending
}
}
return groups.sorted { lhs, rhs in
let ordering: ComparisonResult
switch sort {
case .name:
@@ -108,12 +140,78 @@ struct ProcessesView: View {
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Processes", subtitle: "\(monitor.processes.count) running processes") {
if monitor.isPaused {
Label("Paused", systemImage: "pause.fill")
.foregroundStyle(.orange)
HStack(spacing: 10) {
if monitor.isPaused {
Label("Paused", systemImage: "pause.fill")
.foregroundStyle(.orange)
}
Button {
previewPaneVisible.toggle()
} label: {
Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right")
}
.buttonStyle(.bordered)
.help(previewPaneVisible ? "Hide process preview" : "Show process preview")
}
}
ResourceSummaryBar(snapshot: monitor.snapshot)
processList
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.inspector(isPresented: $previewPaneVisible) {
ProcessPreviewPane(
process: previewProcess,
onClose: { previewPaneVisible = false },
onShowDetails: onShowDetails,
onShowProperties: { inspectedProcess = $0 },
onEndTask: { process in
if let group = grouped.first(where: { $0.id == selectedGroupID }) {
groupTerminationRequest = group
} else {
terminationRequest = .init(process: process, kind: .normal)
}
}
)
.inspectorColumnWidth(min: 250, ideal: 320, max: 480)
}
.confirmationDialog(
"\(terminationRequest?.kind.title ?? "End task"): \(terminationRequest?.process.displayName ?? "this task")?",
isPresented: Binding(get: { terminationRequest != nil }, set: { if !$0 { terminationRequest = nil } })
) {
Button(terminationRequest?.kind.title ?? "End task", role: .destructive) {
if let request = terminationRequest {
switch request.kind {
case .normal: monitor.terminate(request.process)
case .force: monitor.terminate(request.process, force: true)
case .tree: monitor.terminateTree(request.process)
}
}
terminationRequest = nil
}
Button("Cancel", role: .cancel) { terminationRequest = nil }
} message: {
Text(terminationRequest?.kind == .tree ? "This will end the process and all of its child processes. Unsaved data may be lost." : "Unsaved data in this process may be lost.")
}
.confirmationDialog(
"End task: \(groupTerminationRequest?.name ?? "this application")?",
isPresented: Binding(get: { groupTerminationRequest != nil }, set: { if !$0 { groupTerminationRequest = nil } })
) {
Button("End task", role: .destructive) {
if let groupTerminationRequest { monitor.terminateGroup(groupTerminationRequest.processes) }
groupTerminationRequest = nil
}
Button("Cancel", role: .cancel) { groupTerminationRequest = nil }
} message: {
Text("This will end all \(groupTerminationRequest?.processes.count ?? 0) processes in the application group. Unsaved data may be lost.")
}
.sheet(item: $inspectedProcess) { process in
ProcessPropertiesView(process: process)
}
.onAppear(perform: restoreColumnWidths)
}
private var processList: some View {
VStack(spacing: 0) {
ScrollView(.horizontal) {
VStack(spacing: 0) {
ProcessTableHeader(
@@ -187,40 +285,16 @@ struct ProcessesView: View {
}
.padding(12)
}
.confirmationDialog(
"\(terminationRequest?.kind.title ?? "End task"): \(terminationRequest?.process.displayName ?? "this task")?",
isPresented: Binding(get: { terminationRequest != nil }, set: { if !$0 { terminationRequest = nil } })
) {
Button(terminationRequest?.kind.title ?? "End task", role: .destructive) {
if let request = terminationRequest {
switch request.kind {
case .normal: monitor.terminate(request.process)
case .force: monitor.terminate(request.process, force: true)
case .tree: monitor.terminateTree(request.process)
}
}
terminationRequest = nil
}
Button("Cancel", role: .cancel) { terminationRequest = nil }
} message: {
Text(terminationRequest?.kind == .tree ? "This will end the process and all of its child processes. Unsaved data may be lost." : "Unsaved data in this process may be lost.")
}
private var previewProcess: ProcessRecord? {
if let selectedPID {
return monitor.processes.first { $0.pid == selectedPID }
}
.confirmationDialog(
"End task: \(groupTerminationRequest?.name ?? "this application")?",
isPresented: Binding(get: { groupTerminationRequest != nil }, set: { if !$0 { groupTerminationRequest = nil } })
) {
Button("End task", role: .destructive) {
if let groupTerminationRequest { monitor.terminateGroup(groupTerminationRequest.processes) }
groupTerminationRequest = nil
}
Button("Cancel", role: .cancel) { groupTerminationRequest = nil }
} message: {
Text("This will end all \(groupTerminationRequest?.processes.count ?? 0) processes in the application group. Unsaved data may be lost.")
if let selectedGroupID {
return grouped.first { $0.id == selectedGroupID }?.primary
}
.sheet(item: $inspectedProcess) { process in
ProcessPropertiesView(process: process)
}
.onAppear(perform: restoreColumnWidths)
return nil
}
private var processTableWidth: CGFloat {
@@ -530,7 +604,7 @@ private struct ProcessTableHeader: View {
HStack(spacing: 0) {
Text(title).lineLimit(1)
Spacer(minLength: 4)
if sort == field {
if !groupByType && sort == field {
Color.clear.frame(width: 6, height: 0)
Image(systemName: ascending ? "chevron.up" : "chevron.down")
.frame(width: 10)
@@ -540,12 +614,19 @@ private struct ProcessTableHeader: View {
.frame(width: columnWidths[column] ?? column.defaultWidth)
.contentShape(Rectangle())
.onTapGesture {
groupByType = false
if sort == field { ascending.toggle() } else { sort = field; ascending = false }
let next = ProcessSortCycleState.next(
currentField: sort, ascending: ascending, grouped: groupByType, clicked: field
)
sort = next.field
ascending = next.ascending
groupByType = next.grouped
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(title)
.accessibilityValue(sort == field ? (ascending ? "Sorted ascending" : "Sorted descending") : "Not sorted")
.accessibilityValue(
ProcessSortCycleState(field: sort, ascending: ascending, grouped: groupByType)
.accessibilityValue(for: field)
)
}
}
+48 -2
View File
@@ -1,20 +1,44 @@
import SwiftUI
import UserNotifications
final class PuterAppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
UNUserNotificationCenter.current().delegate = self
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound])
}
}
@main
struct PuterApp: App {
@NSApplicationDelegateAdaptor(PuterAppDelegate.self) private var appDelegate
@State private var monitor = SystemMonitor()
@StateObject private var updates = UpdateController()
@AppStorage("sidebarCompact") private var sidebarCompact = false
@AppStorage("processPreviewPaneVisible") private var processPreviewPaneVisible = false
@AppStorage("showMenuBarMonitor") private var showMenuBarMonitor = true
@AppStorage("menuBarMetric") private var menuBarMetric: MenuBarMetric = .cpu
var body: some Scene {
WindowGroup("puter") {
WindowGroup("puter", id: "main") {
ContentView()
.environment(monitor)
.environmentObject(updates)
.frame(minWidth: 820, minHeight: 560)
.task { monitor.start() }
}
.defaultSize(width: 1180, height: 760)
.windowStyle(.hiddenTitleBar)
.commands {
CommandGroup(after: .appInfo) {
Button("Check for Updates…") { updates.checkForUpdates() }
.disabled(!updates.canCheckForUpdates)
}
CommandGroup(replacing: .newItem) {
Button("Run New Task…") {
NotificationCenter.default.post(name: .runNewTask, object: nil)
@@ -32,7 +56,29 @@ struct PuterApp: App {
.keyboardShortcut("p", modifiers: .command)
Divider()
Toggle("Compact Sidebar", isOn: $sidebarCompact)
Toggle("Process Preview", isOn: $processPreviewPaneVisible)
.keyboardShortcut("i", modifiers: [.command, .option])
}
}
MenuBarExtra(isInserted: $showMenuBarMonitor) {
MenuBarMonitorView()
.environment(monitor)
.environmentObject(updates)
} label: {
Label(menuBarValue, systemImage: "gauge.with.dots.needle.50percent")
}
.menuBarExtraStyle(.window)
}
private var menuBarValue: String {
switch menuBarMetric {
case .cpu: Formatters.percent(monitor.snapshot.cpuPercent)
case .memory: Formatters.percent(monitor.snapshot.memoryPercent)
case .network: Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate)
case .power:
monitor.hardware.battery.systemPowerWatts > 0
? String(format: "%.0f W", monitor.hardware.battery.systemPowerWatts) : "— W"
}
}
}
+180 -74
View File
@@ -175,32 +175,45 @@ struct StartupAppsView: View {
@Environment(SystemMonitor.self) private var monitor
@State private var items: [StartupItem] = []
@State private var isLoading = true
@State private var includesLoginItems = false
@State private var inspectedItem: StartupItem?
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Startup apps", subtitle: "Login items and launch agents with estimated live impact") {
PageHeader(title: "Startup apps", subtitle: includesLoginItems
? "Login items and launch agents with estimated live impact"
: "Launch agents with optional macOS login-item inventory") {
HStack {
Button("Refresh", systemImage: "arrow.clockwise") { refreshItems() }
.disabled(isLoading)
Button("Open Login Items") {
openLoginItemsSettings()
Menu("Login Items", systemImage: "person.crop.circle.badge.checkmark") {
Button(includesLoginItems ? "Reload Login Items" : "Include Login Items") {
refreshItems(includeLoginItems: true)
}
.disabled(isLoading)
.help("Read macOSs system-wide Background Task Management registry using the built-in sfltool diagnostic")
Divider()
Button("Open Login Items Settings") {
openLoginItemsSettings()
}
}
}
}
if isLoading && items.isEmpty {
VStack(spacing: 12) {
ProgressView()
Text("Scanning login items…").foregroundStyle(.secondary)
Text("Scanning startup items…").foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityElement(children: .combine)
.accessibilityLabel("Scanning login items")
.accessibilityLabel("Scanning startup items")
} else if items.isEmpty {
EmptyState(
icon: "rectangle.stack.badge.play",
title: "No startup items found",
message: "Other login items can be managed in System Settings."
message: includesLoginItems
? "No login items or launch agents were found."
: "No launch agents were found. Choose Include Login Items or manage them in System Settings."
)
} else {
Table(items) {
@@ -233,7 +246,7 @@ struct StartupAppsView: View {
.sheet(item: $inspectedItem) { item in
StartupItemPropertiesView(item: item, impact: startupImpact(for: item))
}
.task { refreshItems() }
.task { refreshItems(includeLoginItems: false) }
}
private func openLoginItemsSettings() {
@@ -242,11 +255,15 @@ struct StartupAppsView: View {
}
}
private func refreshItems() {
private func refreshItems(includeLoginItems requestedValue: Bool? = nil) {
guard !isLoading || items.isEmpty else { return }
let shouldIncludeLoginItems = requestedValue ?? includesLoginItems
includesLoginItems = shouldIncludeLoginItems
isLoading = true
Task {
items = await Task.detached(priority: .utility) { StartupScanner.scan() }.value
items = await Task.detached(priority: .utility) {
StartupScanner.scan(includeLoginItems: shouldIncludeLoginItems)
}.value
isLoading = false
}
}
@@ -605,13 +622,86 @@ private struct StartupItem: Identifiable, Sendable {
var enabled: Bool
}
struct BackgroundTaskLoginItem: Equatable, Sendable {
let name: String
let developer: String
let bundleIdentifier: String
let path: String
let enabled: Bool
}
enum BackgroundTaskRegistryParser {
static func parse(_ output: String) -> [BackgroundTaskLoginItem] {
var records: [[String]] = []
var current: [String] = []
for line in output.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("#"), trimmed.dropFirst().prefix(while: \Character.isNumber).isEmpty == false {
if !current.isEmpty { records.append(current) }
current = []
} else if !current.isEmpty || trimmed.hasPrefix("Name:") {
current.append(trimmed)
}
}
if !current.isEmpty { records.append(current) }
var byPath: [String: BackgroundTaskLoginItem] = [:]
for lines in records {
let type = value("Type", in: lines)
guard type.hasPrefix("app "),
let rawURL = optionalValue("URL", in: lines),
rawURL.hasPrefix("file://"),
let url = URL(string: rawURL), url.isFileURL else { continue }
let path = url.standardizedFileURL.path
guard path.hasPrefix("/"), !path.isEmpty else { continue }
let disposition = value("Disposition", in: lines)
let name = optionalValue("Name", in: lines)
?? url.deletingPathExtension().lastPathComponent
let identifier = optionalValue("Bundle Identifier", in: lines)
?? Bundle(url: url)?.bundleIdentifier
?? path
let developer = optionalValue("Developer Name", in: lines) ?? "Unknown"
let enabled = disposition.contains("enabled")
&& disposition.contains("allowed")
&& !disposition.contains("disallowed")
let item = BackgroundTaskLoginItem(
name: name, developer: developer, bundleIdentifier: identifier,
path: path, enabled: enabled
)
if let existing = byPath[path] {
byPath[path] = BackgroundTaskLoginItem(
name: existing.name,
developer: existing.developer == "Unknown" ? item.developer : existing.developer,
bundleIdentifier: existing.bundleIdentifier,
path: path,
enabled: existing.enabled || item.enabled
)
} else {
byPath[path] = item
}
}
return byPath.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
private static func value(_ key: String, in lines: [String]) -> String {
optionalValue(key, in: lines) ?? ""
}
private static func optionalValue(_ key: String, in lines: [String]) -> String? {
let prefix = "\(key):"
guard let line = lines.first(where: { $0.hasPrefix(prefix) }) else { return nil }
let value = line.dropFirst(prefix.count).trimmingCharacters(in: .whitespaces)
return value.isEmpty || value == "(null)" ? nil : value
}
}
private enum StartupScanner {
static func scan() -> [StartupItem] {
static func scan(includeLoginItems: Bool) -> [StartupItem] {
let home = FileManager.default.homeDirectoryForCurrentUser.path
let directories = ["\(home)/Library/LaunchAgents", "/Library/LaunchAgents"]
let disabled = disabledServices()
let agents = directories.flatMap { scanDirectory($0, disabled: disabled) }
let loginItems = sessionLoginItems()
let loginItems = includeLoginItems ? sessionLoginItems() : []
return (loginItems + agents).sorted {
if $0.kind != $1.kind { return $0.kind == .loginItem }
return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
@@ -642,42 +732,33 @@ private enum StartupScanner {
}
private static func sessionLoginItems() -> [StartupItem] {
guard let unmanagedList = LSSharedFileListCreate(
nil,
"com.apple.LSSharedFileList.SessionLoginItems" as CFString,
nil
) else { return [] }
let list = unmanagedList.takeRetainedValue()
guard let unmanagedSnapshot = LSSharedFileListCopySnapshot(list, nil) else { return [] }
let snapshot = unmanagedSnapshot.takeRetainedValue()
var seen: Set<String> = []
var items: [StartupItem] = []
let flags = UInt32(kLSSharedFileListNoUserInteraction | kLSSharedFileListDoNotMountVolumes)
for index in 0..<CFArrayGetCount(snapshot) {
let raw = CFArrayGetValueAtIndex(snapshot, index)
let item = unsafeBitCast(raw, to: LSSharedFileListItem.self)
guard let unmanagedURL = LSSharedFileListItemCopyResolvedURL(item, flags, nil) else { continue }
let url = unmanagedURL.takeRetainedValue() as URL
let path = url.standardizedFileURL.path
guard seen.insert(path).inserted else { continue }
let bundle = Bundle(url: url)
let identifier = bundle?.bundleIdentifier ?? path
let name = (bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
?? (bundle?.object(forInfoDictionaryKey: "CFBundleName") as? String)
?? url.deletingPathExtension().lastPathComponent
items.append(StartupItem(
id: "login:\(path)",
name: name,
publisher: publisherName(label: identifier, command: path),
sourcePath: path,
executablePath: path,
serviceIdentifier: identifier,
kind: .loginItem,
isDirectlyManageable: false,
enabled: true
))
}
return items
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/sfltool")
process.arguments = ["dumpbtm"]
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
let output = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
process.waitUntilExit()
guard process.terminationStatus == 0 else { return [] }
return BackgroundTaskRegistryParser.parse(output).map { item in
StartupItem(
id: "login:\(item.path)",
name: item.name,
publisher: item.developer == "Unknown"
? publisherName(label: item.bundleIdentifier, command: item.path)
: item.developer,
sourcePath: item.path,
executablePath: item.path,
serviceIdentifier: item.bundleIdentifier,
kind: .loginItem,
isDirectlyManageable: false,
enabled: item.enabled
)
}
} catch { return [] }
}
private static func disabledServices() -> Set<String> {
@@ -791,6 +872,7 @@ struct DetailsView: View {
@State private var columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) })
@AppStorage("detailSortColumn") private var sortColumn: DetailColumn = .pid
@AppStorage("detailSortAscending") private var ascending = true
@AppStorage("processPreviewPaneVisible") private var previewPaneVisible = false
private var visibleColumns: [DetailColumn] {
let stored = Set(visibleColumnStorage.split(separator: ",").map(String.init))
@@ -800,46 +882,70 @@ struct DetailsView: View {
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Details", subtitle: "Technical information for running processes") {
Button("Properties", systemImage: "info.circle") {
inspectedProcess = selectedProcess
}
.disabled(selectedProcess == nil)
Menu("Columns", systemImage: "rectangle.split.3x1") {
ForEach(DetailColumn.allCases) { column in
Toggle(column.title, isOn: columnBinding(column))
.disabled(column == .name || column == .pid)
HStack(spacing: 10) {
Button {
previewPaneVisible.toggle()
} label: {
Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right")
}
Divider()
Button("Reset columns") {
visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) })
persistColumnWidths()
.buttonStyle(.bordered)
Button("Properties", systemImage: "info.circle") {
inspectedProcess = selectedProcess
}
.disabled(selectedProcess == nil)
Menu("Columns", systemImage: "rectangle.split.3x1") {
ForEach(DetailColumn.allCases) { column in
Toggle(column.title, isOn: columnBinding(column))
.disabled(column == .name || column == .pid)
}
Divider()
Button("Reset columns") {
visibleColumnStorage = DetailColumn.allCases.filter(\.defaultVisible).map(\.rawValue).joined(separator: ",")
columnWidths = Dictionary(uniqueKeysWithValues: DetailColumn.allCases.map { ($0, $0.defaultWidth) })
persistColumnWidths()
}
}
}
}
ResourceSummaryBar(snapshot: monitor.snapshot)
GeometryReader { proxy in
ScrollView([.horizontal, .vertical]) {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section {
ForEach(Array(filtered.enumerated()), id: \.element.id) { index, process in
detailRow(process, index: index)
}
} header: {
detailHeader
}
}
.frame(width: max(tableWidth, proxy.size.width), alignment: .leading)
}
VStack(spacing: 0) {
detailsTable
selectionBar
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
selectionBar
}
.inspector(isPresented: $previewPaneVisible) {
ProcessPreviewPane(
process: selectedProcess,
onClose: { previewPaneVisible = false },
onShowProperties: { inspectedProcess = $0 },
onEndTask: { terminationRequest = .init(process: $0, kind: .normal) }
)
.inspectorColumnWidth(min: 250, ideal: 320, max: 480)
}
.onAppear(perform: restoreColumnWidths)
.terminationConfirmation($terminationRequest)
.sheet(item: $inspectedProcess) { ProcessPropertiesView(process: $0) }
}
private var detailsTable: some View {
GeometryReader { proxy in
ScrollView([.horizontal, .vertical]) {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section {
ForEach(Array(filtered.enumerated()), id: \.element.id) { index, process in
detailRow(process, index: index)
}
} header: {
detailHeader
}
}
.frame(width: max(tableWidth, proxy.size.width), alignment: .leading)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var filtered: [ProcessRecord] {
monitor.processes.filter {
searchText.isEmpty || $0.displayName.localizedCaseInsensitiveContains(searchText)
@@ -0,0 +1,90 @@
import AppKit
import Foundation
import UniformTypeIdentifiers
@MainActor
enum SessionRecordingExporter {
static func exportJSON(monitor: SystemMonitor) {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
do {
let document = RecordingDocument(
startedAt: monitor.recordingStartDate,
endedAt: monitor.recordingSamples.last?.timestamp,
samples: monitor.recordingSamples,
alerts: monitor.recentAlerts
)
try save(
data: encoder.encode(document),
name: "puter-session-\(filenameDate()).json",
type: .json,
message: "Export the recorded telemetry session and resource alerts as JSON.",
monitor: monitor
)
} catch {
monitor.errorMessage = "Could not encode the recording: \(error.localizedDescription)"
}
}
static func exportCSV(monitor: SystemMonitor) {
let header = "timestamp,cpu_percent,memory_used_bytes,memory_total_bytes,memory_pressure,disk_read_bytes_per_second,disk_write_bytes_per_second,gpu_percent,network_receive_bytes_per_second,network_send_bytes_per_second,system_power_watts,thermal_state,process_count"
let formatter = ISO8601DateFormatter()
let rows = monitor.recordingSamples.map { sample in
[
formatter.string(from: sample.timestamp),
number(sample.cpuPercent), String(sample.memoryUsedBytes), String(sample.memoryTotalBytes),
csv(sample.memoryPressure), number(sample.diskReadBytesPerSecond), number(sample.diskWriteBytesPerSecond),
number(sample.gpuPercent), number(sample.networkReceiveBytesPerSecond), number(sample.networkSendBytesPerSecond),
number(sample.systemPowerWatts), csv(sample.thermalState), String(sample.processCount)
].joined(separator: ",")
}
let data = Data(([header] + rows).joined(separator: "\n").utf8)
save(
data: data,
name: "puter-session-\(filenameDate()).csv",
type: .commaSeparatedText,
message: "Export each recorded telemetry sample as a CSV row.",
monitor: monitor
)
}
private static func save(data: Data, name: String, type: UTType, message: String, monitor: SystemMonitor) {
let panel = NSSavePanel()
panel.title = "Export telemetry recording"
panel.message = message
panel.prompt = "Export"
panel.allowedContentTypes = [type]
panel.canCreateDirectories = true
panel.nameFieldStringValue = name
panel.begin { response in
guard response == .OK, let url = panel.url else { return }
do {
try data.write(to: url, options: .atomic)
} catch {
monitor.errorMessage = "Could not export the recording: \(error.localizedDescription)"
}
}
}
private static func filenameDate() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
return formatter.string(from: Date())
}
private static func number(_ value: Double) -> String {
String(format: "%.4f", value.isFinite ? value : 0)
}
private static func csv(_ value: String) -> String {
"\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\""
}
private struct RecordingDocument: Codable {
let startedAt: Date?
let endedAt: Date?
let samples: [TelemetryRecordingSample]
let alerts: [ResourceAlertEvent]
}
}
File diff suppressed because it is too large Load Diff
+46 -1
View File
@@ -48,6 +48,7 @@ enum SystemReportExporter {
"memoryType": snapshot.memoryType,
"memorySpeed": snapshot.memorySpeed,
"memoryManufacturer": snapshot.memoryManufacturer,
"memoryPressure": snapshot.memoryPressure.rawValue,
"diskReadBytesPerSecond": snapshot.diskReadRate,
"diskWriteBytesPerSecond": snapshot.diskWriteRate,
"diskActivePercent": snapshot.diskActivePercent,
@@ -63,11 +64,29 @@ enum SystemReportExporter {
"power": [
"thermalState": monitor.hardware.thermalState,
"systemPowerWatts": battery.systemPowerWatts,
"adapterInputWatts": battery.adapterInputWatts,
"batteryPowerWatts": battery.batteryPowerWatts,
"externalPowerConnected": battery.externalPowerConnected,
"lowPowerMode": ProcessInfo.processInfo.isLowPowerModeEnabled
"lowPowerMode": ProcessInfo.processInfo.isLowPowerModeEnabled,
"source": monitor.hardware.powerManagement.source,
"mode": monitor.hardware.powerManagement.mode,
"systemSleepMinutes": monitor.hardware.powerManagement.systemSleepMinutes as Any,
"displaySleepMinutes": monitor.hardware.powerManagement.displaySleepMinutes as Any,
"powerNapEnabled": monitor.hardware.powerManagement.powerNapEnabled,
"wakeOnNetworkEnabled": monitor.hardware.powerManagement.wakeOnNetworkEnabled,
"sleepAssertions": monitor.hardware.powerManagement.assertions.map {
[
"pid": $0.pid,
"process": $0.processName,
"type": $0.type,
"reason": $0.reason,
"duration": $0.duration
]
}
],
"battery": batteryReport(battery),
"ports": monitor.hardware.ports.map(portReport),
"physicalDisks": monitor.hardware.physicalDisks.map(diskReport),
"topProcesses": topProcesses(monitor),
"topUsers": topUsers(monitor)
]
@@ -84,6 +103,8 @@ enum SystemReportExporter {
"designCycleCount": battery.designCycleCount,
"temperatureCelsius": battery.temperatureCelsius,
"voltageVolts": battery.voltageVolts,
"batteryPowerWatts": battery.batteryPowerWatts,
"adapterInputWatts": battery.adapterInputWatts,
"fullChargeCapacityMAh": battery.fullChargeCapacityMAh,
"designCapacityMAh": battery.designCapacityMAh
]
@@ -119,6 +140,30 @@ enum SystemReportExporter {
]
}
private static func diskReport(_ disk: PhysicalDiskSnapshot) -> [String: Any] {
[
"identifier": disk.id,
"name": disk.name,
"protocol": disk.protocolName,
"capacityBytes": disk.capacity,
"internal": disk.isInternal,
"removable": disk.isRemovable,
"solidState": disk.isSolidState,
"smartStatus": disk.smartStatus,
"trimEnabled": disk.trimEnabled as Any,
"temperatureCelsius": disk.temperatureCelsius as Any,
"percentageUsed": disk.percentageUsed as Any,
"remainingLifePercent": disk.remainingLifePercent as Any,
"availableSparePercent": disk.availableSparePercent as Any,
"powerOnHours": disk.powerOnHours as Any,
"powerCycles": disk.powerCycles as Any,
"unsafeShutdowns": disk.unsafeShutdowns as Any,
"mediaErrors": disk.mediaErrors as Any,
"bytesRead": disk.bytesRead as Any,
"bytesWritten": disk.bytesWritten as Any
]
}
private static func topProcesses(_ monitor: SystemMonitor) -> [[String: Any]] {
monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(20).map { process in
let activity = monitor.processActivity[process.pid] ?? ProcessActivityRate()
+37
View File
@@ -0,0 +1,37 @@
import Foundation
import Sparkle
@MainActor
final class UpdateController: ObservableObject {
let updaterController: SPUStandardUpdaterController?
init() {
let feed = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String
let publicKey = Bundle.main.object(forInfoDictionaryKey: "SUPublicEDKey") as? String
if let feed, !feed.isEmpty, let publicKey, !publicKey.isEmpty {
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
} else {
updaterController = nil
}
}
var isConfigured: Bool { updaterController != nil }
var canCheckForUpdates: Bool { updaterController?.updater.canCheckForUpdates ?? false }
var automaticallyChecksForUpdates: Bool {
get { updaterController?.updater.automaticallyChecksForUpdates ?? false }
set { updaterController?.updater.automaticallyChecksForUpdates = newValue }
}
var automaticallyDownloadsUpdates: Bool {
get { updaterController?.updater.automaticallyDownloadsUpdates ?? false }
set { updaterController?.updater.automaticallyDownloadsUpdates = newValue }
}
func checkForUpdates() {
updaterController?.checkForUpdates(nil)
objectWillChange.send()
}
}