From 4ab9a7b28c92ecca3901d2340233b50890d1d18c Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Tue, 5 May 2026 21:26:44 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Initial:=20FloatingMonitor=20?= =?UTF-8?q?=E2=80=94=20macOS=20=E6=82=AC=E6=B5=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实时 CPU/GPU/内存/Swap 监控 + Top 进程 - 5 种计量器样式(圆环/条状/纯文本/扇形/紧凑) - 半透明悬浮窗 + 菜单栏图标控制 - 独立设置窗口,每模块可指定行号+列位布局 - 窗口宽高/透明度/字体全局可调 --- .gitignore | 4 + Package.swift | 16 + Sources/FloatingMonitor/App/AppDelegate.swift | 120 +++++++ .../FloatingMonitor/App/FloatingWindow.swift | 27 ++ .../App/FloatingWindowController.swift | 116 ++++++ .../App/SettingsWindowController.swift | 41 +++ Sources/FloatingMonitor/Configuration.swift | 174 +++++++++ Sources/FloatingMonitor/ContentView.swift | 227 ++++++++++++ Sources/FloatingMonitor/SystemMonitor.swift | 335 ++++++++++++++++++ Sources/FloatingMonitor/Views/GaugeView.swift | 217 ++++++++++++ .../Views/ProcessListView.swift | 101 ++++++ .../FloatingMonitor/Views/SettingsView.swift | 296 ++++++++++++++++ Sources/FloatingMonitor/main.swift | 9 + 13 files changed, 1683 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 Sources/FloatingMonitor/App/AppDelegate.swift create mode 100644 Sources/FloatingMonitor/App/FloatingWindow.swift create mode 100644 Sources/FloatingMonitor/App/FloatingWindowController.swift create mode 100644 Sources/FloatingMonitor/App/SettingsWindowController.swift create mode 100644 Sources/FloatingMonitor/Configuration.swift create mode 100644 Sources/FloatingMonitor/ContentView.swift create mode 100644 Sources/FloatingMonitor/SystemMonitor.swift create mode 100644 Sources/FloatingMonitor/Views/GaugeView.swift create mode 100644 Sources/FloatingMonitor/Views/ProcessListView.swift create mode 100644 Sources/FloatingMonitor/Views/SettingsView.swift create mode 100644 Sources/FloatingMonitor/main.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2bc85f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.build/ +dmg_build/ +*.dmg +.DS_Store diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..c0ae22b --- /dev/null +++ b/Package.swift @@ -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: [] + ) + ] +) diff --git a/Sources/FloatingMonitor/App/AppDelegate.swift b/Sources/FloatingMonitor/App/AppDelegate.swift new file mode 100644 index 0000000..0e2f4e5 --- /dev/null +++ b/Sources/FloatingMonitor/App/AppDelegate.swift @@ -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 + } +} diff --git a/Sources/FloatingMonitor/App/FloatingWindow.swift b/Sources/FloatingMonitor/App/FloatingWindow.swift new file mode 100644 index 0000000..85f57a0 --- /dev/null +++ b/Sources/FloatingMonitor/App/FloatingWindow.swift @@ -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 + } +} diff --git a/Sources/FloatingMonitor/App/FloatingWindowController.swift b/Sources/FloatingMonitor/App/FloatingWindowController.swift new file mode 100644 index 0000000..eb51f76 --- /dev/null +++ b/Sources/FloatingMonitor/App/FloatingWindowController.swift @@ -0,0 +1,116 @@ +import AppKit +import SwiftUI + +/// 悬浮窗口控制器:管理毛玻璃效果 + SwiftUI 视图嵌入 +final class FloatingWindowController: NSWindowController { + private var hostingView: NSHostingView? + 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 + )) + } +} diff --git a/Sources/FloatingMonitor/App/SettingsWindowController.swift b/Sources/FloatingMonitor/App/SettingsWindowController.swift new file mode 100644 index 0000000..efa587d --- /dev/null +++ b/Sources/FloatingMonitor/App/SettingsWindowController.swift @@ -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) + } +} diff --git a/Sources/FloatingMonitor/Configuration.swift b/Sources/FloatingMonitor/Configuration.swift new file mode 100644 index 0000000..0547cec --- /dev/null +++ b/Sources/FloatingMonitor/Configuration.swift @@ -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) + } +} diff --git a/Sources/FloatingMonitor/ContentView.swift b/Sources/FloatingMonitor/ContentView.swift new file mode 100644 index 0000000..2b0dae6 --- /dev/null +++ b/Sources/FloatingMonitor/ContentView.swift @@ -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.. 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) + } + } +} diff --git a/Sources/FloatingMonitor/SystemMonitor.swift b/Sources/FloatingMonitor/SystemMonitor.swift new file mode 100644 index 0000000..e8d9a50 --- /dev/null +++ b/Sources/FloatingMonitor/SystemMonitor.swift @@ -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.stride / MemoryLayout.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.stride / MemoryLayout.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.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 + } +} diff --git a/Sources/FloatingMonitor/Views/GaugeView.swift b/Sources/FloatingMonitor/Views/GaugeView.swift new file mode 100644 index 0000000..30516c5 --- /dev/null +++ b/Sources/FloatingMonitor/Views/GaugeView.swift @@ -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 + } +} diff --git a/Sources/FloatingMonitor/Views/ProcessListView.swift b/Sources/FloatingMonitor/Views/ProcessListView.swift new file mode 100644 index 0000000..400ffd5 --- /dev/null +++ b/Sources/FloatingMonitor/Views/ProcessListView.swift @@ -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) + } +} diff --git a/Sources/FloatingMonitor/Views/SettingsView.swift b/Sources/FloatingMonitor/Views/SettingsView.swift new file mode 100644 index 0000000..729ce19 --- /dev/null +++ b/Sources/FloatingMonitor/Views/SettingsView.swift @@ -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, 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, + row: Binding, pos: Binding, 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( + 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() + } +} diff --git a/Sources/FloatingMonitor/main.swift b/Sources/FloatingMonitor/main.swift new file mode 100644 index 0000000..8051d3c --- /dev/null +++ b/Sources/FloatingMonitor/main.swift @@ -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)