Files
Bitcoin-Monitoring/test1/BTCMenuBarApp.swift

485 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// BTCMenuBarApp.swift
// Bitcoin Monitoring
//
// Created by Mark on 2025/10/28.
//
import SwiftUI
import AppKit
import Combine
// macOS
@MainActor
class BTCMenuBarApp: NSObject, ObservableObject {
private var statusItem: NSStatusItem?
private var popover: NSPopover?
private let appSettings: AppSettings
private let priceManager: PriceManager
private var cancellables = Set<AnyCancellable>()
//
private let aboutWindowManager = AboutWindowManager()
//
private lazy var preferencesWindowManager = PreferencesWindowManager(appSettings: appSettings)
override init() {
let settings = AppSettings()
self.appSettings = settings
self.priceManager = PriceManager(initialSymbol: settings.selectedSymbol, appSettings: settings)
super.init()
setupMenuBar()
setupConfigurationObservers()
}
//
private func setupConfigurationObservers() {
//
appSettings.$refreshInterval
.sink { [weak self] newInterval in
self?.priceManager.updateRefreshInterval(newInterval)
}
.store(in: &cancellables)
//
appSettings.$selectedSymbol
.sink { [weak self] newSymbol in
guard let self = self else { return }
self.priceManager.updateSymbol(newSymbol)
self.updateMenuBarTitle(price: self.priceManager.currentPrice)
}
.store(in: &cancellables)
//
appSettings.$proxyEnabled
.sink { [weak self] _ in
self?.updateProxyConfiguration()
}
.store(in: &cancellables)
//
appSettings.$proxyHost
.sink { [weak self] _ in
self?.updateProxyConfiguration()
}
.store(in: &cancellables)
//
appSettings.$proxyPort
.sink { [weak self] _ in
self?.updateProxyConfiguration()
}
.store(in: &cancellables)
}
//
private func setupMenuBar() {
//
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
guard let statusItem = statusItem else {
print("❌ 无法创建状态栏项目")
return
}
guard let button = statusItem.button else {
print("❌ 无法获取状态栏按钮")
return
}
//
updateMenuBarTitle(price: 0.0)
button.action = #selector(menuBarClicked)
button.target = self
//
priceManager.$currentPrice
.receive(on: DispatchQueue.main)
.sink { [weak self] price in
self?.updateMenuBarTitle(price: price)
}
.store(in: &cancellables)
// UI
priceManager.$selectedSymbol
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.updateMenuBarTitle(price: self.priceManager.currentPrice)
}
.store(in: &cancellables)
}
//
private func updateMenuBarTitle(price: Double) {
DispatchQueue.main.async {
guard let button = self.statusItem?.button else { return }
let symbol = self.priceManager.selectedSymbol
let symbolImage = self.symbolImage(for: symbol)
symbolImage?.size = NSSize(width: 16, height: 16)
//
button.image = symbolImage
//
if price == 0.0 {
if self.priceManager.isFetching {
button.title = " \(symbol.displayName) 更新中..."
} else if self.priceManager.lastError != nil {
button.title = " \(symbol.displayName) 错误"
} else {
button.title = " \(symbol.displayName) 加载中..."
}
} else {
button.title = " \(symbol.displayName) $\(self.formatPriceWithCommas(price))"
}
}
}
//
private func symbolImage(for symbol: CryptoSymbol) -> NSImage? {
if let image = NSImage(systemSymbolName: symbol.systemImageName, accessibilityDescription: symbol.displayName) {
return image
}
return NSImage(systemSymbolName: "bitcoinsign.circle.fill", accessibilityDescription: "Crypto")
}
//
private func formatPriceWithCommas(_ price: Double) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 4
formatter.groupingSeparator = ","
formatter.usesGroupingSeparator = true
return formatter.string(from: NSNumber(value: price)) ?? String(format: "%.4f", price)
}
//
private func updateProxyConfiguration() {
#if DEBUG
print("🔄 [BTCMenuBarApp] 检测到代理设置变化,正在更新网络配置...")
#endif
// PriceService
priceManager.updateNetworkConfiguration()
#if DEBUG
let proxyStatus = appSettings.proxyEnabled ? "已启用 (\(appSettings.proxyHost):\(appSettings.proxyPort))" : "已禁用"
print("✅ [BTCMenuBarApp] 代理配置更新完成: \(proxyStatus)")
#endif
}
//
@objc private func menuBarClicked() {
guard let button = statusItem?.button else {
print("❌ 无法获取状态栏按钮")
return
}
showMenu(from: button)
}
//
private func showMenu(from view: NSView) {
let menu = NSMenu()
//
//
var symbolMenuItems: [CryptoSymbol: NSMenuItem] = [:]
let currentSymbol = priceManager.selectedSymbol
for symbol in CryptoSymbol.allCases {
let isCurrent = (symbol == currentSymbol)
let placeholderTitle = isCurrent ? "\(symbol.displayName): 加载中..." : " \(symbol.displayName): 加载中..."
let item = NSMenuItem(title: placeholderTitle, action: #selector(self.selectOrCopySymbol(_:)), keyEquivalent: "")
item.target = self // target
if let icon = symbolImage(for: symbol) {
icon.size = NSSize(width: 16, height: 16)
item.image = icon
}
item.isEnabled = true //
item.representedObject = ["symbol": symbol, "price": 0.0]
menu.addItem(item)
symbolMenuItems[symbol] = item
}
//
Task { @MainActor in
let results = await self.priceManager.fetchAllPrices()
let currentSymbolAfter = self.priceManager.selectedSymbol
for symbol in CryptoSymbol.allCases {
guard let (priceOpt, errorOpt) = results[symbol], let menuItem = symbolMenuItems[symbol] else { continue }
let isCurrent = (symbol == currentSymbolAfter)
if let price = priceOpt {
let title = isCurrent ? "\(symbol.displayName): $\(self.formatPriceWithCommas(price))" : " \(symbol.displayName): $\(self.formatPriceWithCommas(price))"
menuItem.title = title
menuItem.isEnabled = true //
menuItem.target = self // target
menuItem.representedObject = ["symbol": symbol, "price": price]
} else if errorOpt != nil {
let title = isCurrent ? "\(symbol.displayName): 错误" : " \(symbol.displayName): 错误"
menuItem.title = title
//
menuItem.isEnabled = false //
menuItem.target = self // target
} else {
let title = isCurrent ? "\(symbol.displayName): 加载中..." : " \(symbol.displayName): 加载中..."
menuItem.title = title
menuItem.target = self // target
//
}
}
}
// 使
// let hintItem = NSMenuItem(title: "💡 Option+", action: nil, keyEquivalent: "")
// hintItem.isEnabled = false
// menu.addItem(hintItem)
menu.addItem(NSMenuItem.separator())
//
if let errorMessage = priceManager.errorMessage {
let errorItem = NSMenuItem(title: "错误: \(errorMessage)", action: nil, keyEquivalent: "")
if let errorImage = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "错误") {
errorImage.size = NSSize(width: 16, height: 16)
errorItem.image = errorImage
}
errorItem.isEnabled = false
menu.addItem(errorItem)
menu.addItem(NSMenuItem.separator())
}
//
let timeItem = NSMenuItem(title: "上次更新: \(getCurrentTime())", action: nil, keyEquivalent: "")
if let clockImage = NSImage(systemSymbolName: "clock", accessibilityDescription: "时间") {
clockImage.size = NSSize(width: 16, height: 16)
timeItem.image = clockImage
}
timeItem.isEnabled = false
menu.addItem(timeItem)
menu.addItem(NSMenuItem.separator())
//
let refreshTitle = priceManager.isFetching ? "刷新中..." : "刷新价格"
let refreshItem = NSMenuItem(title: refreshTitle, action: #selector(refreshPrice), keyEquivalent: "r")
if let refreshImage = NSImage(systemSymbolName: priceManager.isFetching ? "hourglass" : "arrow.clockwise", accessibilityDescription: "刷新") {
refreshImage.size = NSSize(width: 16, height: 16)
refreshItem.image = refreshImage
}
refreshItem.target = self
refreshItem.isEnabled = !priceManager.isFetching
menu.addItem(refreshItem)
menu.addItem(NSMenuItem.separator())
// Cmd+,
let preferencesItem = NSMenuItem(title: "偏好设置", action: #selector(showPreferences), keyEquivalent: ",")
if let preferencesImage = NSImage(systemSymbolName: "gear", accessibilityDescription: "偏好设置") {
preferencesImage.size = NSSize(width: 16, height: 16)
preferencesItem.image = preferencesImage
}
preferencesItem.target = self
menu.addItem(preferencesItem)
menu.addItem(NSMenuItem.separator())
#if DEBUG
// Debug
let resetItem = NSMenuItem(title: "重置设置", action: #selector(resetSettings), keyEquivalent: "")
if let resetImage = NSImage(systemSymbolName: "arrow.counterclockwise", accessibilityDescription: "重置设置") {
resetImage.size = NSSize(width: 16, height: 16)
resetItem.image = resetImage
}
resetItem.target = self
menu.addItem(resetItem)
menu.addItem(NSMenuItem.separator())
#endif
// GitHubGitHub
let checkUpdateItem = NSMenuItem(title: "GitHub", action: #selector(checkForUpdates), keyEquivalent: "")
if let updateImage = NSImage(systemSymbolName: "star.circle", accessibilityDescription: "GitHub") {
updateImage.size = NSSize(width: 16, height: 16)
checkUpdateItem.image = updateImage
}
checkUpdateItem.target = self
menu.addItem(checkUpdateItem)
//
let aboutItem = NSMenuItem(title: "关于", action: #selector(showAbout), keyEquivalent: "")
if let infoImage = NSImage(systemSymbolName: "info.circle", accessibilityDescription: "关于") {
infoImage.size = NSSize(width: 16, height: 16)
aboutItem.image = infoImage
}
aboutItem.target = self
menu.addItem(aboutItem)
// 退退
let quitItem = NSMenuItem(title: "退出", action: #selector(quitApp), keyEquivalent: "q")
if let quitImage = NSImage(systemSymbolName: "power", accessibilityDescription: "退出") {
quitImage.size = NSSize(width: 16, height: 16)
quitItem.image = quitImage
}
quitItem.target = self
menu.addItem(quitItem)
//
guard let statusItem = statusItem,
let button = statusItem.button else {
print("❌ 无法显示菜单 - 状态栏项目不可用")
return
}
statusItem.menu = menu
button.performClick(nil)
statusItem.menu = nil
}
//
private func getCurrentTime() -> String {
let formatter = DateFormatter()
formatter.timeStyle = .medium
return formatter.string(from: Date())
}
//
@objc private func refreshPrice() {
Task {
await priceManager.refreshPrice()
}
}
// Option
@objc private func selectOrCopySymbol(_ sender: NSMenuItem) {
guard let data = sender.representedObject as? [String: Any],
let symbol = data["symbol"] as? CryptoSymbol else {
print("❌ 无法获取菜单项数据")
return
}
// Option
let currentEvent = NSApp.currentEvent
let isOptionPressed = currentEvent?.modifierFlags.contains(.option) ?? false
if isOptionPressed {
//
let price = data["price"] as? Double ?? 0.0
//
if price == 0.0 {
Task { @MainActor in
print("🔄 价格未加载,正在获取 \(symbol.displayName) 价格...")
if let newPrice = await self.priceManager.fetchSinglePrice(for: symbol) {
let priceString = self.formatPriceWithCommas(newPrice)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString("$\(priceString)", forType: .string)
print("✅ 已复制 \(symbol.displayName) 价格到剪贴板: $\(priceString)")
} else {
print("❌ 无法获取 \(symbol.displayName) 价格")
}
}
} else {
let priceString = formatPriceWithCommas(price)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString("$\(priceString)", forType: .string)
print("✅ 已复制 \(symbol.displayName) 价格到剪贴板: $\(priceString)")
}
} else {
//
appSettings.saveSelectedSymbol(symbol)
print("✅ 币种已更新为: \(symbol.displayName)")
}
}
//
@objc private func showPreferences() {
print("⚙️ [BTCMenuBarApp] 用户打开偏好设置")
preferencesWindowManager.showPreferencesWindow()
}
//
@objc private func showAbout() {
let currentInterval = priceManager.getCurrentRefreshInterval()
let version = getAppVersion()
// 使 NSAlert
aboutWindowManager.showAboutWindow(
currentRefreshInterval: currentInterval.displayText,
appVersion: version,
appSettings: appSettings
)
}
// Debug
@objc private func resetSettings() {
#if DEBUG
let alert = NSAlert()
alert.messageText = "重置设置"
alert.informativeText = "确定要将所有设置重置为默认值吗?\n\n• 币种BTC\n• 刷新间隔30秒"
alert.alertStyle = .warning
alert.addButton(withTitle: "确定")
alert.addButton(withTitle: "取消")
let response = alert.runModal()
if response == .alertFirstButtonReturn {
//
appSettings.resetToDefaults()
//
let confirmAlert = NSAlert()
confirmAlert.messageText = "重置完成"
confirmAlert.informativeText = "所有设置已重置为默认值,应用将立即生效。"
confirmAlert.alertStyle = .informational
confirmAlert.addButton(withTitle: "确定")
confirmAlert.runModal()
print("🔧 [BTCMenuBarApp] 用户手动重置了所有设置")
}
#endif
}
// GitHub
@objc private func checkForUpdates() {
let githubURL = "https://github.com/jiayouzl/Bitcoin-Monitoring"
// URL
guard let url = URL(string: githubURL) else {
print("❌ 无效的URL: \(githubURL)")
return
}
// 使URL
NSWorkspace.shared.open(url)
print("✅ 已在浏览器中打开GitHub页面: \(githubURL)")
}
//
/// - Returns: ".."
private func getAppVersion() -> String {
guard let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else {
return "未知版本"
}
return version
}
// 退
@objc private func quitApp() {
NSApplication.shared.terminate(nil)
}
}