✨ Initial: FloatingMonitor — macOS 悬浮系统监控
- 实时 CPU/GPU/内存/Swap 监控 + Top 进程 - 5 种计量器样式(圆环/条状/纯文本/扇形/紧凑) - 半透明悬浮窗 + 菜单栏图标控制 - 独立设置窗口,每模块可指定行号+列位布局 - 窗口宽高/透明度/字体全局可调
This commit is contained in:
commit
4ab9a7b28c
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.build/
|
||||||
|
dmg_build/
|
||||||
|
*.dmg
|
||||||
|
.DS_Store
|
||||||
16
Package.swift
Normal file
16
Package.swift
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// swift-tools-version:5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "FloatingMonitor",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v14)
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "FloatingMonitor",
|
||||||
|
path: "Sources/FloatingMonitor",
|
||||||
|
resources: []
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
120
Sources/FloatingMonitor/App/AppDelegate.swift
Normal file
120
Sources/FloatingMonitor/App/AppDelegate.swift
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
// MARK: - 通知名
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let showSettingsPanel = Notification.Name("FloatingMonitor.ShowSettings")
|
||||||
|
static let resizeMonitorWindow = Notification.Name("FloatingMonitor.ResizeWindow")
|
||||||
|
static let refreshIntervalChanged = Notification.Name("FloatingMonitor.RefreshIntervalChanged")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AppDelegate
|
||||||
|
|
||||||
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
private var windowController: FloatingWindowController?
|
||||||
|
private var settingsWindowController: SettingsWindowController?
|
||||||
|
private var statusItem: NSStatusItem?
|
||||||
|
private var statusMenu: NSMenu!
|
||||||
|
|
||||||
|
let systemMonitor = SystemMonitor()
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
|
||||||
|
windowController = FloatingWindowController()
|
||||||
|
let contentView = ContentView(monitor: systemMonitor, windowController: windowController)
|
||||||
|
windowController?.embed(rootView: contentView)
|
||||||
|
|
||||||
|
let cfg = Configuration.shared
|
||||||
|
windowController?.resize(width: cfg.windowWidth, height: cfg.windowHeight)
|
||||||
|
|
||||||
|
systemMonitor.interval = cfg.refreshInterval
|
||||||
|
systemMonitor.topN = cfg.processCount
|
||||||
|
systemMonitor.start()
|
||||||
|
|
||||||
|
setupStatusItem()
|
||||||
|
observeResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 菜单栏
|
||||||
|
|
||||||
|
private func setupStatusItem() {
|
||||||
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||||
|
guard let button = statusItem?.button else { return }
|
||||||
|
|
||||||
|
if let image = NSImage(systemSymbolName: "gauge.with.dots.needle.33percent",
|
||||||
|
accessibilityDescription: "Monitor") {
|
||||||
|
image.isTemplate = true
|
||||||
|
button.image = image
|
||||||
|
}
|
||||||
|
button.toolTip = "Monitor"
|
||||||
|
|
||||||
|
statusMenu = NSMenu()
|
||||||
|
|
||||||
|
let toggleItem = NSMenuItem(title: "显示 Monitor", action: #selector(menuToggleWindow), keyEquivalent: "")
|
||||||
|
toggleItem.target = self
|
||||||
|
statusMenu.addItem(toggleItem)
|
||||||
|
statusMenu.addItem(.separator())
|
||||||
|
|
||||||
|
let settingsItem = NSMenuItem(title: "显示设置…", action: #selector(menuOpenSettings), keyEquivalent: ",")
|
||||||
|
settingsItem.keyEquivalentModifierMask = .command
|
||||||
|
settingsItem.target = self
|
||||||
|
statusMenu.addItem(settingsItem)
|
||||||
|
statusMenu.addItem(.separator())
|
||||||
|
|
||||||
|
let quitItem = NSMenuItem(title: "退出 Monitor", action: #selector(menuQuit), keyEquivalent: "q")
|
||||||
|
quitItem.keyEquivalentModifierMask = .command
|
||||||
|
quitItem.target = self
|
||||||
|
statusMenu.addItem(quitItem)
|
||||||
|
|
||||||
|
statusItem?.menu = statusMenu
|
||||||
|
|
||||||
|
button.target = self
|
||||||
|
button.action = #selector(statusItemClicked)
|
||||||
|
button.sendAction(on: [.leftMouseDown, .rightMouseDown])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func statusItemClicked() {
|
||||||
|
guard let event = NSApp.currentEvent else { return }
|
||||||
|
if event.type == .rightMouseDown {
|
||||||
|
updateToggleMenuItemTitle()
|
||||||
|
} else {
|
||||||
|
toggleWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func menuToggleWindow() { toggleWindow() }
|
||||||
|
@objc private func toggleWindow() { windowController?.toggleVisible() }
|
||||||
|
@objc private func menuOpenSettings() { openSettingsWindow() }
|
||||||
|
@objc private func menuQuit() { NSApp.terminate(nil) }
|
||||||
|
|
||||||
|
private func updateToggleMenuItemTitle() {
|
||||||
|
let visible = windowController?.isVisible ?? false
|
||||||
|
statusMenu.item(at: 0)?.title = visible ? "隐藏 Monitor" : "显示 Monitor"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 设置窗口
|
||||||
|
|
||||||
|
private func openSettingsWindow() {
|
||||||
|
if settingsWindowController == nil {
|
||||||
|
settingsWindowController = SettingsWindowController(monitor: systemMonitor)
|
||||||
|
}
|
||||||
|
settingsWindowController?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 监听
|
||||||
|
|
||||||
|
private func observeResize() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: .resizeMonitorWindow, object: nil, queue: .main
|
||||||
|
) { [weak self] notification in
|
||||||
|
guard let size = notification.object as? NSSize else { return }
|
||||||
|
self?.windowController?.resize(width: size.width, height: size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||||
|
windowController?.show()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
27
Sources/FloatingMonitor/App/FloatingWindow.swift
Normal file
27
Sources/FloatingMonitor/App/FloatingWindow.swift
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// 半透明悬浮窗口
|
||||||
|
final class FloatingWindow: NSWindow {
|
||||||
|
override var canBecomeKey: Bool { true }
|
||||||
|
override var canBecomeMain: Bool { false }
|
||||||
|
|
||||||
|
override init(
|
||||||
|
contentRect: NSRect = NSRect(x: 0, y: 0, width: 250, height: 300),
|
||||||
|
styleMask style: NSWindow.StyleMask = [.borderless, .resizable],
|
||||||
|
backing backingStoreType: NSWindow.BackingStoreType = .buffered,
|
||||||
|
defer flag: Bool = false
|
||||||
|
) {
|
||||||
|
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
|
||||||
|
|
||||||
|
self.level = .floating
|
||||||
|
self.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
||||||
|
|
||||||
|
self.isOpaque = false
|
||||||
|
self.backgroundColor = .clear
|
||||||
|
self.hasShadow = true
|
||||||
|
self.isMovableByWindowBackground = true
|
||||||
|
self.minSize = NSSize(width: 240, height: 200)
|
||||||
|
self.titlebarAppearsTransparent = true
|
||||||
|
self.titleVisibility = .hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
116
Sources/FloatingMonitor/App/FloatingWindowController.swift
Normal file
116
Sources/FloatingMonitor/App/FloatingWindowController.swift
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 悬浮窗口控制器:管理毛玻璃效果 + SwiftUI 视图嵌入
|
||||||
|
final class FloatingWindowController: NSWindowController {
|
||||||
|
private var hostingView: NSHostingView<ContentView>?
|
||||||
|
private var visualEffect: NSVisualEffectView!
|
||||||
|
|
||||||
|
convenience init() {
|
||||||
|
let window = FloatingWindow()
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
self.init(window: window)
|
||||||
|
setupVisualEffect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupVisualEffect() {
|
||||||
|
guard let window = window else { return }
|
||||||
|
|
||||||
|
let ve = NSVisualEffectView()
|
||||||
|
ve.wantsLayer = true
|
||||||
|
ve.material = .hudWindow
|
||||||
|
ve.blendingMode = .behindWindow
|
||||||
|
ve.state = .active
|
||||||
|
ve.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
ve.layer?.cornerRadius = 16
|
||||||
|
ve.layer?.masksToBounds = true
|
||||||
|
ve.layer?.borderWidth = 0
|
||||||
|
ve.layer?.borderColor = NSColor.clear.cgColor
|
||||||
|
ve.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.35).cgColor
|
||||||
|
|
||||||
|
self.visualEffect = ve
|
||||||
|
|
||||||
|
guard let contentView = window.contentView else { return }
|
||||||
|
contentView.addSubview(ve, positioned: .below, relativeTo: contentView.subviews.first)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
ve.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
ve.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
ve.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||||
|
ve.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func embed(rootView: ContentView) {
|
||||||
|
guard let window = window, let contentView = window.contentView else { return }
|
||||||
|
|
||||||
|
let hv = NSHostingView(rootView: rootView)
|
||||||
|
hv.wantsLayer = true
|
||||||
|
hv.layer?.cornerRadius = 16
|
||||||
|
hv.layer?.masksToBounds = true
|
||||||
|
hv.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
contentView.addSubview(hv)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
hv.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
hv.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
hv.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||||
|
hv.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
self.hostingView = hv
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 显隐
|
||||||
|
|
||||||
|
func toggleVisible() {
|
||||||
|
guard let window = window else { return }
|
||||||
|
if window.isVisible {
|
||||||
|
window.orderOut(nil)
|
||||||
|
} else {
|
||||||
|
positionNearMenuBar()
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func show() {
|
||||||
|
guard let window = window else { return }
|
||||||
|
if !window.isVisible {
|
||||||
|
positionNearMenuBar()
|
||||||
|
}
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hide() {
|
||||||
|
window?.orderOut(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isVisible: Bool {
|
||||||
|
window?.isVisible ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyOpacity(_ value: Double) {
|
||||||
|
window?.alphaValue = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func resize(width: CGFloat, height: CGFloat) {
|
||||||
|
guard let window = window else { return }
|
||||||
|
var frame = window.frame
|
||||||
|
frame.size.width = width
|
||||||
|
frame.size.height = height
|
||||||
|
window.setFrame(frame, display: true, animate: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func positionNearMenuBar() {
|
||||||
|
guard let window = window, let screen = NSScreen.main else { return }
|
||||||
|
let screenFrame = screen.visibleFrame
|
||||||
|
window.setFrameOrigin(NSPoint(
|
||||||
|
x: screenFrame.maxX - window.frame.width - 24,
|
||||||
|
y: screenFrame.maxY - window.frame.height - 24
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Sources/FloatingMonitor/App/SettingsWindowController.swift
Normal file
41
Sources/FloatingMonitor/App/SettingsWindowController.swift
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 独立的设置窗口控制器(参考语音智能体项目 SettingsWindow 模式)
|
||||||
|
final class SettingsWindowController: NSWindowController {
|
||||||
|
|
||||||
|
private let monitor: SystemMonitor
|
||||||
|
|
||||||
|
init(monitor: SystemMonitor) {
|
||||||
|
self.monitor = monitor
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 340, height: 640),
|
||||||
|
styleMask: [.titled, .closable, .resizable, .fullSizeContentView],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.title = "Monitor 设置"
|
||||||
|
window.titlebarAppearsTransparent = true
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.minSize = NSSize(width: 300, height: 480)
|
||||||
|
super.init(window: window)
|
||||||
|
|
||||||
|
// 嵌入 SwiftUI 视图
|
||||||
|
let settingsView = SettingsView(monitor: monitor)
|
||||||
|
let hostingView = NSHostingView(rootView: settingsView)
|
||||||
|
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
window.contentView = hostingView
|
||||||
|
|
||||||
|
window.center()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func show() {
|
||||||
|
window?.center()
|
||||||
|
window?.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
174
Sources/FloatingMonitor/Configuration.swift
Normal file
174
Sources/FloatingMonitor/Configuration.swift
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 单个指标的展示样式
|
||||||
|
enum GaugeDisplayStyle: String, CaseIterable, Identifiable, Codable {
|
||||||
|
case bar // 条状百分比
|
||||||
|
case ring // 圆环进度
|
||||||
|
case text // 纯文本百分比
|
||||||
|
case pie // 扇形小圆盘
|
||||||
|
case compact // 紧凑横条 + 文字
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .bar: return "条状百分比"
|
||||||
|
case .ring: return "圆环进度"
|
||||||
|
case .text: return "纯文本"
|
||||||
|
case .pie: return "扇形圆盘"
|
||||||
|
case .compact: return "紧凑横条"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconName: String {
|
||||||
|
switch self {
|
||||||
|
case .bar: return "rectangle.split.1x2"
|
||||||
|
case .ring: return "circle.dotted"
|
||||||
|
case .text: return "textformat"
|
||||||
|
case .pie: return "chart.pie"
|
||||||
|
case .compact: return "rectangle.compress.vertical"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模块在窗口中的排列位置
|
||||||
|
enum ModulePosition: String, CaseIterable, Identifiable, Codable {
|
||||||
|
case left // 左列
|
||||||
|
case right // 右列
|
||||||
|
case full // 全宽(独占一行)
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .left: return "左"
|
||||||
|
case .right: return "右"
|
||||||
|
case .full: return "全宽"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 配置管理者:所有用户偏好通过 UserDefaults 持久化
|
||||||
|
final class Configuration: ObservableObject {
|
||||||
|
static let shared = Configuration()
|
||||||
|
|
||||||
|
@Published var cpuStyle: GaugeDisplayStyle {
|
||||||
|
didSet { save("cpuStyle", cpuStyle.rawValue) }
|
||||||
|
}
|
||||||
|
@Published var gpuStyle: GaugeDisplayStyle {
|
||||||
|
didSet { save("gpuStyle", gpuStyle.rawValue) }
|
||||||
|
}
|
||||||
|
@Published var memoryStyle: GaugeDisplayStyle {
|
||||||
|
didSet { save("memoryStyle", memoryStyle.rawValue) }
|
||||||
|
}
|
||||||
|
@Published var swapStyle: GaugeDisplayStyle {
|
||||||
|
didSet { save("swapStyle", swapStyle.rawValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var showCPU: Bool {
|
||||||
|
didSet { save("showCPU", showCPU) }
|
||||||
|
}
|
||||||
|
@Published var showGPU: Bool {
|
||||||
|
didSet { save("showGPU", showGPU) }
|
||||||
|
}
|
||||||
|
@Published var showMemory: Bool {
|
||||||
|
didSet { save("showMemory", showMemory) }
|
||||||
|
}
|
||||||
|
@Published var showSwap: Bool {
|
||||||
|
didSet { save("showSwap", showSwap) }
|
||||||
|
}
|
||||||
|
@Published var showTopProcesses: Bool {
|
||||||
|
didSet { save("showTopProcesses", showTopProcesses) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var processCount: Int {
|
||||||
|
didSet { save("processCount", processCount) }
|
||||||
|
}
|
||||||
|
@Published var refreshInterval: Double {
|
||||||
|
didSet { save("refreshInterval", refreshInterval) }
|
||||||
|
}
|
||||||
|
@Published var opacity: Double {
|
||||||
|
didSet { save("opacity", opacity) }
|
||||||
|
}
|
||||||
|
@Published var fontSize: Double {
|
||||||
|
didSet { save("fontSize", fontSize) }
|
||||||
|
}
|
||||||
|
@Published var windowWidth: Double {
|
||||||
|
didSet { save("windowWidth", windowWidth) }
|
||||||
|
}
|
||||||
|
@Published var windowHeight: Double {
|
||||||
|
didSet { save("windowHeight", windowHeight) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var cpuPos: ModulePosition {
|
||||||
|
didSet { save("cpuPos", cpuPos.rawValue) }
|
||||||
|
}
|
||||||
|
@Published var gpuPos: ModulePosition {
|
||||||
|
didSet { save("gpuPos", gpuPos.rawValue) }
|
||||||
|
}
|
||||||
|
@Published var memoryPos: ModulePosition {
|
||||||
|
didSet { save("memoryPos", memoryPos.rawValue) }
|
||||||
|
}
|
||||||
|
@Published var swapPos: ModulePosition {
|
||||||
|
didSet { save("swapPos", swapPos.rawValue) }
|
||||||
|
}
|
||||||
|
@Published var processesPos: ModulePosition {
|
||||||
|
didSet { save("processesPos", processesPos.rawValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 行号:决定模块在垂直方向上的排列顺序(1-10)
|
||||||
|
@Published var cpuRow: Int {
|
||||||
|
didSet { save("cpuRow", cpuRow) }
|
||||||
|
}
|
||||||
|
@Published var gpuRow: Int {
|
||||||
|
didSet { save("gpuRow", gpuRow) }
|
||||||
|
}
|
||||||
|
@Published var memoryRow: Int {
|
||||||
|
didSet { save("memoryRow", memoryRow) }
|
||||||
|
}
|
||||||
|
@Published var swapRow: Int {
|
||||||
|
didSet { save("swapRow", swapRow) }
|
||||||
|
}
|
||||||
|
@Published var processesRow: Int {
|
||||||
|
didSet { save("processesRow", processesRow) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
cpuStyle = GaugeDisplayStyle(rawValue: defaults.string(forKey: "cpuStyle") ?? "ring") ?? .ring
|
||||||
|
gpuStyle = GaugeDisplayStyle(rawValue: defaults.string(forKey: "gpuStyle") ?? "ring") ?? .ring
|
||||||
|
memoryStyle = GaugeDisplayStyle(rawValue: defaults.string(forKey: "memoryStyle") ?? "bar") ?? .bar
|
||||||
|
swapStyle = GaugeDisplayStyle(rawValue: defaults.string(forKey: "swapStyle") ?? "bar") ?? .bar
|
||||||
|
|
||||||
|
showCPU = defaults.object(forKey: "showCPU") as? Bool ?? true
|
||||||
|
showGPU = defaults.object(forKey: "showGPU") as? Bool ?? true
|
||||||
|
showMemory = defaults.object(forKey: "showMemory") as? Bool ?? true
|
||||||
|
showSwap = defaults.object(forKey: "showSwap") as? Bool ?? true
|
||||||
|
showTopProcesses = defaults.object(forKey: "showTopProcesses") as? Bool ?? true
|
||||||
|
|
||||||
|
processCount = defaults.object(forKey: "processCount") as? Int ?? 5
|
||||||
|
refreshInterval = defaults.object(forKey: "refreshInterval") as? Double ?? 1.5
|
||||||
|
opacity = defaults.object(forKey: "opacity") as? Double ?? 1.0
|
||||||
|
fontSize = defaults.object(forKey: "fontSize") as? Double ?? 12
|
||||||
|
windowWidth = defaults.object(forKey: "windowWidth") as? Double ?? 250
|
||||||
|
windowHeight = defaults.object(forKey: "windowHeight") as? Double ?? 300
|
||||||
|
|
||||||
|
cpuPos = ModulePosition(rawValue: defaults.string(forKey: "cpuPos") ?? "full") ?? .full
|
||||||
|
gpuPos = ModulePosition(rawValue: defaults.string(forKey: "gpuPos") ?? "full") ?? .full
|
||||||
|
memoryPos = ModulePosition(rawValue: defaults.string(forKey: "memoryPos") ?? "full") ?? .full
|
||||||
|
swapPos = ModulePosition(rawValue: defaults.string(forKey: "swapPos") ?? "full") ?? .full
|
||||||
|
processesPos = ModulePosition(rawValue: defaults.string(forKey: "processesPos") ?? "full") ?? .full
|
||||||
|
|
||||||
|
cpuRow = defaults.object(forKey: "cpuRow") as? Int ?? 1
|
||||||
|
gpuRow = defaults.object(forKey: "gpuRow") as? Int ?? 2
|
||||||
|
memoryRow = defaults.object(forKey: "memoryRow") as? Int ?? 3
|
||||||
|
swapRow = defaults.object(forKey: "swapRow") as? Int ?? 4
|
||||||
|
processesRow = defaults.object(forKey: "processesRow") as? Int ?? 5
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save(_ key: String, _ value: Any) {
|
||||||
|
UserDefaults.standard.set(value, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
227
Sources/FloatingMonitor/ContentView.swift
Normal file
227
Sources/FloatingMonitor/ContentView.swift
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@ObservedObject var monitor: SystemMonitor
|
||||||
|
@ObservedObject private var config = Configuration.shared
|
||||||
|
|
||||||
|
weak var windowController: FloatingWindowController?
|
||||||
|
|
||||||
|
init(monitor: SystemMonitor, windowController: FloatingWindowController? = nil) {
|
||||||
|
self.monitor = monitor
|
||||||
|
self.windowController = windowController
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// ── 顶部固定区域:图标 + Monitor + 时间 + 关闭按钮 ──
|
||||||
|
headerBar
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color.white.opacity(0.1))
|
||||||
|
|
||||||
|
// ── 可滚动内容 ──
|
||||||
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
ForEach(Array(moduleRows.enumerated()), id: \.offset) { _, row in
|
||||||
|
row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 240, idealWidth: 280, maxWidth: 400,
|
||||||
|
minHeight: 200, idealHeight: 480, maxHeight: 700)
|
||||||
|
.onAppear {
|
||||||
|
monitor.interval = config.refreshInterval
|
||||||
|
monitor.topN = config.processCount
|
||||||
|
applyOpacity()
|
||||||
|
}
|
||||||
|
.onChange(of: config.processCount) { _, new in
|
||||||
|
monitor.topN = new
|
||||||
|
}
|
||||||
|
.onChange(of: config.opacity) { _, _ in
|
||||||
|
applyOpacity()
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .refreshIntervalChanged)) { notification in
|
||||||
|
if let interval = notification.object as? Double {
|
||||||
|
monitor.interval = interval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 顶部栏(精简版)
|
||||||
|
|
||||||
|
private var headerBar: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "gauge.with.dots.needle.33percent")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
|
||||||
|
Text("Monitor")
|
||||||
|
.font(.system(size: config.fontSize, weight: .semibold))
|
||||||
|
.foregroundColor(.primary.opacity(0.85))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(timeString)
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary.opacity(0.5))
|
||||||
|
|
||||||
|
Button(action: { windowController?.hide() }) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundColor(.secondary.opacity(0.5))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("隐藏窗口(点击菜单栏图标重新打开)")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 各指标
|
||||||
|
|
||||||
|
private var cpuGauge: some View {
|
||||||
|
StyledGauge(
|
||||||
|
title: "CPU", icon: "cpu",
|
||||||
|
value: monitor.info.cpuUsage, unit: "%",
|
||||||
|
detail: "\(monitor.info.cpuCores) 核心 · \(monitor.info.cpuModel)",
|
||||||
|
style: config.cpuStyle, fontSize: config.fontSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gpuGauge: some View {
|
||||||
|
StyledGauge(
|
||||||
|
title: "GPU", icon: "display",
|
||||||
|
value: monitor.info.gpuUsage ?? 0, unit: "%",
|
||||||
|
detail: monitor.info.gpuModel + (monitor.info.gpuUsage == nil ? " (N/A)" : ""),
|
||||||
|
style: config.gpuStyle, fontSize: config.fontSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var memoryGauge: some View {
|
||||||
|
StyledGauge(
|
||||||
|
title: "内存", icon: "memorychip",
|
||||||
|
value: monitor.info.memoryUsagePercent, unit: "%",
|
||||||
|
detail: formatBytes(monitor.info.memoryUsed) + " / " + formatBytes(monitor.info.memoryTotal),
|
||||||
|
style: config.memoryStyle, fontSize: config.fontSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var swapGauge: some View {
|
||||||
|
StyledGauge(
|
||||||
|
title: "Swap", icon: "arrow.left.arrow.right",
|
||||||
|
value: monitor.info.swapUsagePercent, unit: "%",
|
||||||
|
detail: formatBytes(monitor.info.swapUsed) + " / " + formatBytes(monitor.info.swapTotal),
|
||||||
|
style: config.swapStyle, fontSize: config.fontSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var processesSection: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProcessListView(
|
||||||
|
processes: monitor.info.topCPUProcesses,
|
||||||
|
title: "CPU 消耗 Top", icon: "bolt.horizontal",
|
||||||
|
isMemory: false, fontSize: config.fontSize
|
||||||
|
)
|
||||||
|
ProcessListView(
|
||||||
|
processes: monitor.info.topMemoryProcesses,
|
||||||
|
title: "内存消耗 Top", icon: "tray.full",
|
||||||
|
isMemory: true, fontSize: config.fontSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 布局引擎(左/右/全宽排列)
|
||||||
|
|
||||||
|
/// 根据行号 + 列位生成布局
|
||||||
|
private var moduleRows: [AnyView] {
|
||||||
|
struct Item {
|
||||||
|
let name: String
|
||||||
|
let row: Int
|
||||||
|
let position: ModulePosition
|
||||||
|
let view: AnyView
|
||||||
|
}
|
||||||
|
|
||||||
|
var items: [Item] = []
|
||||||
|
if config.showCPU {
|
||||||
|
items.append(Item(name: "CPU", row: config.cpuRow, position: config.cpuPos,
|
||||||
|
view: AnyView(cpuGauge)))
|
||||||
|
}
|
||||||
|
if config.showGPU {
|
||||||
|
items.append(Item(name: "GPU", row: config.gpuRow, position: config.gpuPos,
|
||||||
|
view: AnyView(gpuGauge.opacity(monitor.info.gpuUsage == nil ? 0.5 : 1.0))))
|
||||||
|
}
|
||||||
|
if config.showMemory {
|
||||||
|
items.append(Item(name: "内存", row: config.memoryRow, position: config.memoryPos,
|
||||||
|
view: AnyView(memoryGauge)))
|
||||||
|
}
|
||||||
|
if config.showSwap {
|
||||||
|
items.append(Item(name: "Swap", row: config.swapRow, position: config.swapPos,
|
||||||
|
view: AnyView(swapGauge)))
|
||||||
|
}
|
||||||
|
if config.showTopProcesses {
|
||||||
|
items.append(Item(name: "进程", row: config.processesRow, position: config.processesPos,
|
||||||
|
view: AnyView(processesSection)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按行号排序
|
||||||
|
items.sort { $0.row < $1.row }
|
||||||
|
|
||||||
|
// 同行 left+right 配对,full 独占
|
||||||
|
var rows: [AnyView] = []
|
||||||
|
var i = 0
|
||||||
|
while i < items.count {
|
||||||
|
let item = items[i]
|
||||||
|
switch item.position {
|
||||||
|
case .full:
|
||||||
|
rows.append(item.view)
|
||||||
|
i += 1
|
||||||
|
case .left:
|
||||||
|
// 找同行下一个 right
|
||||||
|
if let j = (i+1..<items.count).first(where: { items[$0].row == item.row && items[$0].position == .right }) {
|
||||||
|
rows.append(AnyView(HStack(alignment: .top, spacing: 12) {
|
||||||
|
item.view
|
||||||
|
items[j].view
|
||||||
|
}))
|
||||||
|
items.remove(at: j)
|
||||||
|
} else {
|
||||||
|
rows.append(item.view)
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
case .right:
|
||||||
|
rows.append(item.view)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 工具
|
||||||
|
|
||||||
|
private var timeString: String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "HH:mm:ss"
|
||||||
|
return f.string(from: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatBytes(_ bytes: UInt64) -> String {
|
||||||
|
if bytes >= 1_073_741_824 {
|
||||||
|
return String(format: "%.1f GB", Double(bytes) / 1_073_741_824)
|
||||||
|
} else if bytes >= 1_048_576 {
|
||||||
|
return String(format: "%.0f MB", Double(bytes) / 1_048_576)
|
||||||
|
} else if bytes >= 1_024 {
|
||||||
|
return String(format: "%.0f KB", Double(bytes) / 1_024)
|
||||||
|
} else {
|
||||||
|
return "\(bytes) B"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyOpacity() {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
windowController?.applyOpacity(config.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
335
Sources/FloatingMonitor/SystemMonitor.swift
Normal file
335
Sources/FloatingMonitor/SystemMonitor.swift
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
import Foundation
|
||||||
|
import IOKit
|
||||||
|
import Darwin
|
||||||
|
|
||||||
|
// MARK: - 系统信息数据结构
|
||||||
|
|
||||||
|
struct SystemInfo {
|
||||||
|
var cpuUsage: Double // 0-100
|
||||||
|
var cpuCores: Int
|
||||||
|
var cpuModel: String
|
||||||
|
|
||||||
|
var gpuUsage: Double? // 0-100 (部分 Mac 可能为 nil)
|
||||||
|
var gpuModel: String
|
||||||
|
|
||||||
|
var memoryTotal: UInt64 // bytes
|
||||||
|
var memoryUsed: UInt64
|
||||||
|
var memoryUsagePercent: Double
|
||||||
|
|
||||||
|
var swapTotal: UInt64
|
||||||
|
var swapUsed: UInt64
|
||||||
|
var swapUsagePercent: Double
|
||||||
|
var swapIns: UInt64
|
||||||
|
var swapOuts: UInt64
|
||||||
|
|
||||||
|
var topCPUProcesses: [ProcessInfo]
|
||||||
|
var topMemoryProcesses: [ProcessInfo]
|
||||||
|
|
||||||
|
struct ProcessInfo: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let pid: Int32
|
||||||
|
let name: String
|
||||||
|
let cpuPercent: Double
|
||||||
|
let memoryBytes: UInt64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 系统数据采集引擎
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SystemMonitor: ObservableObject {
|
||||||
|
@Published var info = SystemInfo(
|
||||||
|
cpuUsage: 0, cpuCores: 0, cpuModel: "",
|
||||||
|
gpuUsage: nil, gpuModel: "",
|
||||||
|
memoryTotal: 0, memoryUsed: 0, memoryUsagePercent: 0,
|
||||||
|
swapTotal: 0, swapUsed: 0, swapUsagePercent: 0,
|
||||||
|
swapIns: 0, swapOuts: 0,
|
||||||
|
topCPUProcesses: [], topMemoryProcesses: []
|
||||||
|
)
|
||||||
|
|
||||||
|
private var timer: Timer?
|
||||||
|
private var previousCPU: host_cpu_load_info?
|
||||||
|
|
||||||
|
// 采集频率
|
||||||
|
var interval: TimeInterval = 1.5 {
|
||||||
|
didSet { restartTimer() }
|
||||||
|
}
|
||||||
|
var topN: Int = 5
|
||||||
|
|
||||||
|
nonisolated init() {}
|
||||||
|
|
||||||
|
// MARK: - 启动 / 停止
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
fetch() // 立即拉一次
|
||||||
|
restartTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartTimer() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.fetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 允许 timer 在 tracking 模式下也触发(防止拖拽窗口时卡顿不更新)
|
||||||
|
if let t = timer {
|
||||||
|
RunLoop.current.add(t, forMode: .common)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 数据采集
|
||||||
|
|
||||||
|
func fetch() {
|
||||||
|
let cpu = fetchCPU()
|
||||||
|
let gpu = fetchGPU()
|
||||||
|
let mem = fetchMemory()
|
||||||
|
let swap = fetchSwap()
|
||||||
|
let procs = fetchTopProcesses(count: topN)
|
||||||
|
|
||||||
|
info = SystemInfo(
|
||||||
|
cpuUsage: cpu.usage,
|
||||||
|
cpuCores: cpu.cores,
|
||||||
|
cpuModel: cpu.model,
|
||||||
|
gpuUsage: gpu.usage,
|
||||||
|
gpuModel: gpu.model,
|
||||||
|
memoryTotal: mem.total,
|
||||||
|
memoryUsed: mem.used,
|
||||||
|
memoryUsagePercent: mem.percent,
|
||||||
|
swapTotal: swap.total,
|
||||||
|
swapUsed: swap.used,
|
||||||
|
swapUsagePercent: swap.percent,
|
||||||
|
swapIns: swap.ins,
|
||||||
|
swapOuts: swap.outs,
|
||||||
|
topCPUProcesses: procs.cpu,
|
||||||
|
topMemoryProcesses: procs.mem
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CPU
|
||||||
|
|
||||||
|
private func fetchCPU() -> (usage: Double, cores: Int, model: String) {
|
||||||
|
var size = mach_msg_type_number_t(MemoryLayout<host_cpu_load_info>.stride / MemoryLayout<integer_t>.stride)
|
||||||
|
var info = host_cpu_load_info()
|
||||||
|
let result = withUnsafeMutablePointer(to: &info) {
|
||||||
|
$0.withMemoryRebound(to: integer_t.self, capacity: Int(size)) {
|
||||||
|
host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard result == KERN_SUCCESS else {
|
||||||
|
return (0, ProcessInfo.processInfo.activeProcessorCount, "Unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
let cores = ProcessInfo.processInfo.activeProcessorCount
|
||||||
|
|
||||||
|
// 获取 CPU 型号
|
||||||
|
var model = "Apple Silicon"
|
||||||
|
var sysctlSize = 0
|
||||||
|
sysctlbyname("machdep.cpu.brand_string", nil, &sysctlSize, nil, 0)
|
||||||
|
if sysctlSize > 0 {
|
||||||
|
var brand = [CChar](repeating: 0, count: sysctlSize)
|
||||||
|
sysctlbyname("machdep.cpu.brand_string", &brand, &sysctlSize, nil, 0)
|
||||||
|
model = String(cString: brand)
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = Double(info.cpu_ticks.0)
|
||||||
|
let system = Double(info.cpu_ticks.1)
|
||||||
|
let idle = Double(info.cpu_ticks.2)
|
||||||
|
let nice = Double(info.cpu_ticks.3)
|
||||||
|
|
||||||
|
var usage: Double = 0
|
||||||
|
if let prev = previousCPU {
|
||||||
|
let prevUser = Double(prev.cpu_ticks.0)
|
||||||
|
let prevSystem = Double(prev.cpu_ticks.1)
|
||||||
|
let prevIdle = Double(prev.cpu_ticks.2)
|
||||||
|
let prevNice = Double(prev.cpu_ticks.3)
|
||||||
|
|
||||||
|
let totalTicks = (user - prevUser) + (system - prevSystem) + (idle - prevIdle) + (nice - prevNice)
|
||||||
|
let usedTicks = (user - prevUser) + (system - prevSystem) + (nice - prevNice)
|
||||||
|
|
||||||
|
if totalTicks > 0 {
|
||||||
|
usage = (usedTicks / totalTicks) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousCPU = info
|
||||||
|
return (min(usage, 100), cores, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - GPU (通过 IOKit)
|
||||||
|
|
||||||
|
private func fetchGPU() -> (usage: Double?, model: String) {
|
||||||
|
var model = "Unknown GPU"
|
||||||
|
var usage: Double? = nil
|
||||||
|
|
||||||
|
// 查找 GPU 节点
|
||||||
|
var iterator: io_iterator_t = 0
|
||||||
|
let matchDict = IOServiceMatching("IOAccelerator")
|
||||||
|
let result = IOServiceGetMatchingServices(kIOMainPortDefault, matchDict, &iterator)
|
||||||
|
|
||||||
|
guard result == KERN_SUCCESS else { return (nil, "Unknown GPU") }
|
||||||
|
|
||||||
|
var gpuEntry = IOIteratorNext(iterator)
|
||||||
|
defer { IOObjectRelease(iterator) }
|
||||||
|
|
||||||
|
while gpuEntry != 0 {
|
||||||
|
// GPU 型号
|
||||||
|
if let modelCF = IORegistryEntryCreateCFProperty(gpuEntry, "model" as CFString, kCFAllocatorDefault, 0) {
|
||||||
|
if let modelStr = (modelCF.takeUnretainedValue() as? String) ?? (modelCF.takeUnretainedValue() as? Data).flatMap({ String(data: $0, encoding: .utf8) }) {
|
||||||
|
model = modelStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试读取性能统计
|
||||||
|
if let perfCF = IORegistryEntryCreateCFProperty(gpuEntry, "PerformanceStatistics" as CFString, kCFAllocatorDefault, 0) {
|
||||||
|
let perfDict = perfCF.takeUnretainedValue() as? [String: Any]
|
||||||
|
// Apple Silicon 常见 key
|
||||||
|
if let util = perfDict?["GPU Core Utilization"] as? Double {
|
||||||
|
usage = util * 100
|
||||||
|
} else if let util = perfDict?["gpuCoreUtilization"] as? Double {
|
||||||
|
usage = util * 100
|
||||||
|
} else if let util = perfDict?["Device Utilization %"] as? Int {
|
||||||
|
usage = Double(util)
|
||||||
|
} else if let util = perfDict?["GPU Activity(%)"] as? Int {
|
||||||
|
usage = Double(util)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IOObjectRelease(gpuEntry)
|
||||||
|
gpuEntry = IOIteratorNext(iterator)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有从 IOKit 拿到型号,用 sysctl
|
||||||
|
if model == "Unknown GPU" {
|
||||||
|
var size = 0
|
||||||
|
sysctlbyname("hw.model", nil, &size, nil, 0)
|
||||||
|
if size > 0 {
|
||||||
|
var hwModel = [CChar](repeating: 0, count: size)
|
||||||
|
sysctlbyname("hw.model", &hwModel, &size, nil, 0)
|
||||||
|
let hw = String(cString: hwModel)
|
||||||
|
if hw.hasPrefix("Mac") {
|
||||||
|
// Apple Silicon 通常显示为 "Apple M1/M2/M3..."
|
||||||
|
// 通过 IO 获取到的 model 更准确
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (usage, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 内存
|
||||||
|
|
||||||
|
private func fetchMemory() -> (total: UInt64, used: UInt64, percent: Double) {
|
||||||
|
let total = ProcessInfo.processInfo.physicalMemory
|
||||||
|
|
||||||
|
var pageSize: vm_size_t = 0
|
||||||
|
host_page_size(mach_host_self(), &pageSize)
|
||||||
|
|
||||||
|
var vmStat = vm_statistics64()
|
||||||
|
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64>.stride / MemoryLayout<integer_t>.stride)
|
||||||
|
let result = withUnsafeMutablePointer(to: &vmStat) {
|
||||||
|
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
||||||
|
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard result == KERN_SUCCESS else { return (total, 0, 0) }
|
||||||
|
|
||||||
|
let activePages = UInt64(vmStat.active_count)
|
||||||
|
let wiredPages = UInt64(vmStat.wire_count)
|
||||||
|
let compressedPages = UInt64(vmStat.compressor_page_count)
|
||||||
|
|
||||||
|
let pageSizeU64 = UInt64(pageSize)
|
||||||
|
let usedRaw = (activePages + wiredPages + compressedPages) * pageSizeU64
|
||||||
|
let percent = Double(usedRaw) / Double(total) * 100
|
||||||
|
|
||||||
|
return (total, usedRaw, min(percent, 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Swap
|
||||||
|
|
||||||
|
private func fetchSwap() -> (total: UInt64, used: UInt64, percent: Double, ins: UInt64, outs: UInt64) {
|
||||||
|
var xsw = xsw_usage()
|
||||||
|
var size = MemoryLayout<xsw_usage>.size
|
||||||
|
let result = sysctlbyname("vm.swapusage", &xsw, &size, nil, 0)
|
||||||
|
|
||||||
|
if result == 0 {
|
||||||
|
return (xsw.xsu_total, xsw.xsu_used, xsw.xsu_total > 0 ? Double(xsw.xsu_used) / Double(xsw.xsu_total) * 100 : 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (0, 0, 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 进程列表
|
||||||
|
|
||||||
|
private func fetchTopProcesses(count: Int) -> (cpu: [SystemInfo.ProcessInfo], mem: [SystemInfo.ProcessInfo]) {
|
||||||
|
// 使用 ps 命令获取进程 CPU / 内存排行
|
||||||
|
// ps -eo pid,%cpu,rss,comm -r | head -n 30
|
||||||
|
let cpuProcs = executePS(sortByCPU: true, count: count)
|
||||||
|
let memProcs = executePS(sortByCPU: false, count: count)
|
||||||
|
return (cpuProcs, memProcs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func executePS(sortByCPU: Bool, count: Int) -> [SystemInfo.ProcessInfo] {
|
||||||
|
let sortFlag = sortByCPU ? "-r" : "-m"
|
||||||
|
let command = "/bin/ps -eo pid,%cpu,rss,comm \(sortFlag) 2>/dev/null | head -n \(count + 1)"
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
process.launchPath = "/bin/bash"
|
||||||
|
process.arguments = ["-c", command]
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = Pipe()
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
guard let output = String(data: data, encoding: .utf8) else { return [] }
|
||||||
|
|
||||||
|
var results: [SystemInfo.ProcessInfo] = []
|
||||||
|
let lines = output.components(separatedBy: "\n").dropFirst() // 去掉表头
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty else { continue }
|
||||||
|
|
||||||
|
// 格式: PID %CPU RSS COMMAND
|
||||||
|
var components = trimmed.components(separatedBy: .whitespaces)
|
||||||
|
// 过滤空字符串
|
||||||
|
components = components.filter { !$0.isEmpty }
|
||||||
|
guard components.count >= 3 else { continue }
|
||||||
|
|
||||||
|
let pidStr = components[0]
|
||||||
|
let cpuStr = components[1]
|
||||||
|
let rssStr = components[2]
|
||||||
|
let comm = components.dropFirst(3).joined(separator: " ")
|
||||||
|
|
||||||
|
guard let pid = Int32(pidStr),
|
||||||
|
let cpu = Double(cpuStr),
|
||||||
|
let rss = UInt64(rssStr) else { continue }
|
||||||
|
|
||||||
|
let rssBytes = rss * 1024 // RSS 单位是 KB
|
||||||
|
|
||||||
|
results.append(SystemInfo.ProcessInfo(
|
||||||
|
pid: pid,
|
||||||
|
name: comm,
|
||||||
|
cpuPercent: cpu,
|
||||||
|
memoryBytes: rssBytes
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
217
Sources/FloatingMonitor/Views/GaugeView.swift
Normal file
217
Sources/FloatingMonitor/Views/GaugeView.swift
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 通用计量器:根据 GaugeDisplayStyle 动态渲染
|
||||||
|
|
||||||
|
struct StyledGauge: View {
|
||||||
|
let title: String
|
||||||
|
let icon: String
|
||||||
|
let value: Double // 0-100
|
||||||
|
let unit: String // "%" / "GB"
|
||||||
|
let detail: String // 副标题(如 "3.2 / 16 GB")
|
||||||
|
let style: GaugeDisplayStyle
|
||||||
|
let fontSize: Double
|
||||||
|
|
||||||
|
// 主题色(根据负载渐变)
|
||||||
|
private var gaugeColor: Color {
|
||||||
|
if value < 40 { return .green }
|
||||||
|
if value < 70 { return .yellow }
|
||||||
|
if value < 85 { return .orange }
|
||||||
|
return .red
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
// 标题行
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: fontSize))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: fontSize, weight: .medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据样式渲染
|
||||||
|
switch style {
|
||||||
|
case .bar:
|
||||||
|
barView
|
||||||
|
case .ring:
|
||||||
|
ringView
|
||||||
|
case .text:
|
||||||
|
textView
|
||||||
|
case .pie:
|
||||||
|
pieView
|
||||||
|
case .compact:
|
||||||
|
compactView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 条状百分比
|
||||||
|
|
||||||
|
private var barView: some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.white.opacity(0.12))
|
||||||
|
.frame(height: 8)
|
||||||
|
Capsule()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [gaugeColor.opacity(0.8), gaugeColor],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: max(geo.size.width * CGFloat(value / 100), 4), height: 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 8)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(String(format: "%.1f%@", value, unit))
|
||||||
|
.font(.system(size: fontSize + 2, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
Text(detail)
|
||||||
|
.font(.system(size: fontSize - 2))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 圆环进度
|
||||||
|
|
||||||
|
private var ringView: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white.opacity(0.12), lineWidth: 5)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(value / 100))
|
||||||
|
.stroke(
|
||||||
|
AngularGradient(
|
||||||
|
colors: [gaugeColor.opacity(0.7), gaugeColor, gaugeColor.opacity(0.9)],
|
||||||
|
center: .center
|
||||||
|
),
|
||||||
|
style: StrokeStyle(lineWidth: 5, lineCap: .round)
|
||||||
|
)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
|
||||||
|
Text(String(format: "%.0f", value))
|
||||||
|
.font(.system(size: fontSize - 1, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(String(format: "%.1f%@", value, unit))
|
||||||
|
.font(.system(size: fontSize + 2, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text(detail)
|
||||||
|
.font(.system(size: fontSize - 2))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 纯文本
|
||||||
|
|
||||||
|
private var textView: some View {
|
||||||
|
HStack {
|
||||||
|
Text(String(format: "%.1f%@", value, unit))
|
||||||
|
.font(.system(size: fontSize + 6, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(gaugeColor)
|
||||||
|
Spacer()
|
||||||
|
Text(detail)
|
||||||
|
.font(.system(size: fontSize - 2))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 扇形圆盘
|
||||||
|
|
||||||
|
private var pieView: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.white.opacity(0.05))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
PieSlice(endAngle: .degrees(360 * value / 100))
|
||||||
|
.fill(gaugeColor.opacity(0.85))
|
||||||
|
.frame(width: 42, height: 42)
|
||||||
|
.clipShape(Circle())
|
||||||
|
|
||||||
|
Text(String(format: "%.0f", value))
|
||||||
|
.font(.system(size: fontSize - 2, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(String(format: "%.1f%@", value, unit))
|
||||||
|
.font(.system(size: fontSize + 2, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text(detail)
|
||||||
|
.font(.system(size: fontSize - 2))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 紧凑横条
|
||||||
|
|
||||||
|
private var compactView: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color.white.opacity(0.1))
|
||||||
|
.frame(height: 14)
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(gaugeColor)
|
||||||
|
.frame(width: max(geo.size.width * CGFloat(value / 100), 3), height: 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 14)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(String(format: "%.1f%@", value, unit))
|
||||||
|
.font(.system(size: fontSize, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
Text(detail)
|
||||||
|
.font(.system(size: fontSize - 2))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 扇形 Shape
|
||||||
|
|
||||||
|
struct PieSlice: Shape {
|
||||||
|
var endAngle: Angle
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
let center = CGPoint(x: rect.midX, y: rect.midY)
|
||||||
|
let radius = min(rect.width, rect.height) / 2
|
||||||
|
|
||||||
|
path.move(to: center)
|
||||||
|
path.addArc(center: center, radius: radius,
|
||||||
|
startAngle: .degrees(-90),
|
||||||
|
endAngle: endAngle - .degrees(90),
|
||||||
|
clockwise: false)
|
||||||
|
path.closeSubpath()
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
101
Sources/FloatingMonitor/Views/ProcessListView.swift
Normal file
101
Sources/FloatingMonitor/Views/ProcessListView.swift
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ProcessListView: View {
|
||||||
|
let processes: [SystemInfo.ProcessInfo]
|
||||||
|
let title: String
|
||||||
|
let icon: String
|
||||||
|
let isMemory: Bool // true = 显示内存,false = 显示 CPU%
|
||||||
|
let fontSize: Double
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
// 标题
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: fontSize))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: fontSize, weight: .medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表头
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text("进程")
|
||||||
|
.font(.system(size: fontSize - 2))
|
||||||
|
.foregroundColor(.secondary.opacity(0.6))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text(isMemory ? "内存" : "CPU")
|
||||||
|
.font(.system(size: fontSize - 2))
|
||||||
|
.foregroundColor(.secondary.opacity(0.6))
|
||||||
|
.frame(width: 52, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color.white.opacity(0.1))
|
||||||
|
|
||||||
|
// 进程列表
|
||||||
|
ForEach(processes.prefix(5)) { proc in
|
||||||
|
processRow(proc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processRow(_ proc: SystemInfo.ProcessInfo) -> some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
// 进程名
|
||||||
|
Text(proc.name.components(separatedBy: "/").last ?? proc.name)
|
||||||
|
.font(.system(size: fontSize - 1, design: .monospaced))
|
||||||
|
.foregroundColor(.primary.opacity(0.85))
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 数值
|
||||||
|
if isMemory {
|
||||||
|
Text(formatBytes(proc.memoryBytes))
|
||||||
|
.font(.system(size: fontSize - 1, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(memoryColor(proc.memoryBytes))
|
||||||
|
.frame(width: 52, alignment: .trailing)
|
||||||
|
} else {
|
||||||
|
Text(String(format: "%.1f%%", proc.cpuPercent))
|
||||||
|
.font(.system(size: fontSize - 1, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(cpuColor(proc.cpuPercent))
|
||||||
|
.frame(width: 52, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatBytes(_ bytes: UInt64) -> String {
|
||||||
|
if bytes >= 1_073_741_824 {
|
||||||
|
String(format: "%.1f GB", Double(bytes) / 1_073_741_824)
|
||||||
|
} else if bytes >= 1_048_576 {
|
||||||
|
String(format: "%.0f MB", Double(bytes) / 1_048_576)
|
||||||
|
} else if bytes >= 1_024 {
|
||||||
|
String(format: "%.0f KB", Double(bytes) / 1_024)
|
||||||
|
} else {
|
||||||
|
"\(bytes) B"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cpuColor(_ value: Double) -> Color {
|
||||||
|
if value < 20 { return .green.opacity(0.8) }
|
||||||
|
if value < 50 { return .yellow.opacity(0.8) }
|
||||||
|
if value < 80 { return .orange.opacity(0.8) }
|
||||||
|
return .red.opacity(0.8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func memoryColor(_ bytes: UInt64) -> Color {
|
||||||
|
let mb = Double(bytes) / 1_048_576
|
||||||
|
if mb < 200 { return .green.opacity(0.8) }
|
||||||
|
if mb < 500 { return .yellow.opacity(0.8) }
|
||||||
|
if mb < 1000 { return .orange.opacity(0.8) }
|
||||||
|
return .red.opacity(0.8)
|
||||||
|
}
|
||||||
|
}
|
||||||
296
Sources/FloatingMonitor/Views/SettingsView.swift
Normal file
296
Sources/FloatingMonitor/Views/SettingsView.swift
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@ObservedObject var config = Configuration.shared
|
||||||
|
@ObservedObject var monitor: SystemMonitor
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// 标题栏
|
||||||
|
HStack {
|
||||||
|
Text("显示设置")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
Spacer()
|
||||||
|
Button(action: { closeWindow() }) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
|
||||||
|
Divider().background(Color.white.opacity(0.1))
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// 显示/隐藏开关组
|
||||||
|
toggleGroup
|
||||||
|
|
||||||
|
Divider().background(Color.white.opacity(0.08))
|
||||||
|
|
||||||
|
// 各指标样式
|
||||||
|
styleGroup
|
||||||
|
|
||||||
|
Divider().background(Color.white.opacity(0.08))
|
||||||
|
|
||||||
|
// 进程设置
|
||||||
|
processGroup
|
||||||
|
|
||||||
|
Divider().background(Color.white.opacity(0.08))
|
||||||
|
|
||||||
|
// 其他设置
|
||||||
|
otherGroup
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部关闭
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("完成") { closeWindow() }
|
||||||
|
.keyboardShortcut(.return)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
.frame(width: 340, height: 640)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 显示/隐藏
|
||||||
|
|
||||||
|
private var toggleGroup: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("显示内容")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
|
||||||
|
toggleRow("CPU", binding: $config.showCPU, icon: "cpu")
|
||||||
|
toggleRow("GPU", binding: $config.showGPU, icon: "display")
|
||||||
|
toggleRow("内存", binding: $config.showMemory, icon: "memorychip")
|
||||||
|
toggleRow("Swap", binding: $config.showSwap, icon: "arrow.left.arrow.right")
|
||||||
|
toggleRow("进程列表", binding: $config.showTopProcesses, icon: "list.bullet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleRow(_ label: String, binding: Binding<Bool>, icon: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.frame(width: 18)
|
||||||
|
.foregroundColor(binding.wrappedValue ? .accentColor : .secondary.opacity(0.5))
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Spacer()
|
||||||
|
Toggle("", isOn: binding)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.scaleEffect(0.7)
|
||||||
|
.frame(width: 38)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 样式选择
|
||||||
|
|
||||||
|
private var styleGroup: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("样式 · 行号 · 列位")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
|
||||||
|
styleRow("CPU", style: $config.cpuStyle, row: $config.cpuRow, pos: $config.cpuPos, icon: "cpu")
|
||||||
|
styleRow("GPU", style: $config.gpuStyle, row: $config.gpuRow, pos: $config.gpuPos, icon: "display")
|
||||||
|
styleRow("内存", style: $config.memoryStyle, row: $config.memoryRow, pos: $config.memoryPos, icon: "memorychip")
|
||||||
|
styleRow("Swap", style: $config.swapStyle, row: $config.swapRow, pos: $config.swapPos, icon: "arrow.left.arrow.right")
|
||||||
|
styleRow("进程", style: .constant(.compact), row: $config.processesRow, pos: $config.processesPos,
|
||||||
|
icon: "list.bullet", styleDisabled: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func styleRow(_ label: String, style: Binding<GaugeDisplayStyle>,
|
||||||
|
row: Binding<Int>, pos: Binding<ModulePosition>, icon: String,
|
||||||
|
styleDisabled: Bool = false) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.frame(width: 14)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.frame(width: 26, alignment: .leading)
|
||||||
|
|
||||||
|
// 行号 stepper
|
||||||
|
Stepper("", value: row, in: 1...10)
|
||||||
|
.labelsHidden()
|
||||||
|
.scaleEffect(0.7)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text("\(row.wrappedValue)")
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 14, alignment: .center)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if styleDisabled {
|
||||||
|
Text("—")
|
||||||
|
.foregroundColor(.secondary.opacity(0.4))
|
||||||
|
.frame(width: 64)
|
||||||
|
} else {
|
||||||
|
Picker("", selection: style) {
|
||||||
|
ForEach(GaugeDisplayStyle.allCases) { s in
|
||||||
|
Text(s.displayName).tag(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.frame(width: 76)
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("", selection: pos) {
|
||||||
|
ForEach(ModulePosition.allCases) { p in
|
||||||
|
Text(p.displayName).tag(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: 90)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 进程
|
||||||
|
|
||||||
|
private var processGroup: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("进程列表")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("显示数量")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: $config.processCount) {
|
||||||
|
ForEach([3, 5, 8, 10], id: \.self) { n in
|
||||||
|
Text("\(n) 个").tag(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: 160)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 其他
|
||||||
|
|
||||||
|
private var otherGroup: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("其他")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("刷新间隔")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: Binding<Double>(
|
||||||
|
get: { config.refreshInterval },
|
||||||
|
set: { newValue in
|
||||||
|
config.refreshInterval = newValue
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .refreshIntervalChanged, object: newValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
Text("0.5s").tag(0.5)
|
||||||
|
Text("1.0s").tag(1.0)
|
||||||
|
Text("1.5s").tag(1.5)
|
||||||
|
Text("2.0s").tag(2.0)
|
||||||
|
Text("3.0s").tag(3.0)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("透明度")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.frame(width: 52, alignment: .leading)
|
||||||
|
Slider(value: $config.opacity, in: 0.5...1.0, step: 0.05) {
|
||||||
|
Text("")
|
||||||
|
} onEditingChanged: { editing in
|
||||||
|
if !editing { updateWindowOpacity() }
|
||||||
|
}
|
||||||
|
.frame(width: 130)
|
||||||
|
Text(String(format: "%.0f%%", config.opacity * 100))
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 38, alignment: .trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("字体大小")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.frame(width: 52, alignment: .leading)
|
||||||
|
Slider(value: $config.fontSize, in: 9...16, step: 1) {
|
||||||
|
Text("")
|
||||||
|
}
|
||||||
|
.frame(width: 130)
|
||||||
|
Text(String(format: "%.0f", config.fontSize))
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 38, alignment: .trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("窗口宽度")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.frame(width: 52, alignment: .leading)
|
||||||
|
Slider(value: $config.windowWidth, in: 240...500) {
|
||||||
|
Text("")
|
||||||
|
} onEditingChanged: { editing in
|
||||||
|
if !editing { applyWindowSize() }
|
||||||
|
}
|
||||||
|
.frame(width: 130)
|
||||||
|
Text(String(format: "%.0f", config.windowWidth))
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 38, alignment: .trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("窗口高度")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.frame(width: 52, alignment: .leading)
|
||||||
|
Slider(value: $config.windowHeight, in: 200...700) {
|
||||||
|
Text("")
|
||||||
|
} onEditingChanged: { editing in
|
||||||
|
if !editing { applyWindowSize() }
|
||||||
|
}
|
||||||
|
.frame(width: 130)
|
||||||
|
Text(String(format: "%.0f", config.windowHeight))
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 38, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyWindowSize() {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .resizeMonitorWindow,
|
||||||
|
object: NSSize(width: config.windowWidth, height: config.windowHeight)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateWindowOpacity() {
|
||||||
|
if let window = NSApplication.shared.windows.first(where: { $0 is FloatingWindow }) {
|
||||||
|
window.alphaValue = config.opacity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeWindow() {
|
||||||
|
NSApp.keyWindow?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Sources/FloatingMonitor/main.swift
Normal file
9
Sources/FloatingMonitor/main.swift
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
// ── FloatingMonitor 入口 ──
|
||||||
|
|
||||||
|
let app = NSApplication.shared
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
app.delegate = delegate
|
||||||
|
app.setActivationPolicy(.accessory)
|
||||||
|
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
|
||||||
Loading…
Reference in New Issue
Block a user