新增偏好设置功能,并把原本主菜单的2个功能移到偏好设置中,新增HTTP代理功能

This commit is contained in:
ZhangLei
2025-10-31 14:54:44 +08:00
parent 1bd09df761
commit 5969b7ae39
6 changed files with 720 additions and 101 deletions

View File

@@ -22,7 +22,16 @@ class AppSettings: ObservableObject {
@Published var selectedSymbol: CryptoSymbol = .btc
///
@Published var launchAtLogin: Bool = false
// MARK: -
///
@Published var proxyEnabled: Bool = false
///
@Published var proxyHost: String = ""
///
@Published var proxyPort: Int = 8080
// MARK: - Private Properties
private let defaults = UserDefaults.standard
@@ -30,6 +39,12 @@ class AppSettings: ObservableObject {
private let selectedSymbolKey = "SelectedCryptoSymbol"
private let launchAtLoginKey = "LaunchAtLogin"
// MARK: -
private let proxyEnabledKey = "ProxyEnabled"
private let proxyHostKey = "ProxyHost"
private let proxyPortKey = "ProxyPort"
// MARK: - Initialization
init() {
@@ -108,11 +123,17 @@ class AppSettings: ObservableObject {
//
launchAtLogin = defaults.bool(forKey: launchAtLoginKey)
//
proxyEnabled = defaults.bool(forKey: proxyEnabledKey)
proxyHost = defaults.string(forKey: proxyHostKey) ?? ""
proxyPort = defaults.integer(forKey: proxyPortKey)
if proxyPort == 0 { proxyPort = 8080 } //
//
checkAndSyncLaunchAtLoginStatus()
#if DEBUG
print("🔧 [AppSettings] 配置加载完成 - 刷新间隔: \(refreshInterval.displayText), 币种: \(selectedSymbol.displayName), 开机自启动: \(launchAtLogin)")
print("🔧 [AppSettings] 配置加载完成 - 刷新间隔: \(refreshInterval.displayText), 币种: \(selectedSymbol.displayName), 开机自启动: \(launchAtLogin), 代理: \(proxyEnabled ? "\(proxyHost):\(proxyPort)" : "未启用")")
#endif
}
@@ -130,8 +151,16 @@ class AppSettings: ObservableObject {
saveRefreshInterval(.thirtySeconds)
saveSelectedSymbol(.btc)
//
proxyEnabled = false
proxyHost = ""
proxyPort = 8080
defaults.set(false, forKey: proxyEnabledKey)
defaults.set("", forKey: proxyHostKey)
defaults.set(8080, forKey: proxyPortKey)
#if DEBUG
print("🔧 [AppSettings] 重置完成 - 刷新间隔: \(refreshInterval.displayText), 币种: \(selectedSymbol.displayName)")
print("🔧 [AppSettings] 重置完成 - 刷新间隔: \(refreshInterval.displayText), 币种: \(selectedSymbol.displayName), 代理: 已重置")
#endif
//
@@ -159,6 +188,76 @@ class AppSettings: ObservableObject {
defaults.set(symbol.rawValue, forKey: selectedSymbolKey)
}
// MARK: -
///
/// - Parameters:
/// - enabled:
/// - host:
/// - port:
func saveProxySettings(enabled: Bool, host: String, port: Int) {
proxyEnabled = enabled
proxyHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
proxyPort = port
// UserDefaults
defaults.set(enabled, forKey: proxyEnabledKey)
defaults.set(proxyHost, forKey: proxyHostKey)
defaults.set(port, forKey: proxyPortKey)
#if DEBUG
if enabled {
print("🔧 [AppSettings] 保存代理设置: \(proxyHost):\(proxyPort)")
} else {
print("🔧 [AppSettings] 保存代理设置: 已禁用")
}
#endif
}
///
/// - Returns:
func validateProxySettings() -> (isValid: Bool, errorMessage: String?) {
guard proxyEnabled else {
return (true, nil) //
}
let trimmedHost = proxyHost.trimmingCharacters(in: .whitespacesAndNewlines)
//
if trimmedHost.isEmpty {
return (false, "代理服务器地址不能为空")
}
// IP
if !isValidHost(trimmedHost) {
return (false, "代理服务器地址格式不正确")
}
//
if proxyPort < 1 || proxyPort > 65535 {
return (false, "代理端口必须在 1-65535 范围内")
}
return (true, nil)
}
///
/// - Parameter host:
/// - Returns:
private func isValidHost(_ host: String) -> Bool {
// IP
if host.matches(pattern: #"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"#) {
return true
}
//
if host.matches(pattern: #"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$"#) {
return true
}
return false
}
// MARK: -
///
@@ -236,3 +335,19 @@ class AppSettings: ObservableObject {
return actualStatus == .enabled
}
}
// MARK: - String Extension for Regex Matching
extension String {
///
/// - Parameter pattern:
/// - Returns:
func matches(pattern: String) -> Bool {
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else {
return false
}
let range = NSRange(location: 0, length: self.utf16.count)
return regex.firstMatch(in: self, options: [], range: range) != nil
}
}

View File

@@ -21,10 +21,13 @@ class BTCMenuBarApp: NSObject, ObservableObject {
//
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)
self.priceManager = PriceManager(initialSymbol: settings.selectedSymbol, appSettings: settings)
super.init()
setupMenuBar()
setupConfigurationObservers()
@@ -236,48 +239,17 @@ class BTCMenuBarApp: NSObject, ObservableObject {
refreshItem.target = self
refreshItem.isEnabled = !priceManager.isFetching
menu.addItem(refreshItem)
//
let refreshSettingsItem = NSMenuItem(title: "刷新设置", action: nil, keyEquivalent: "")
if let settingsImage = NSImage(systemSymbolName: "timer", accessibilityDescription: "刷新设置") {
settingsImage.size = NSSize(width: 16, height: 16)
refreshSettingsItem.image = settingsImage
}
let refreshSettingsMenu = NSMenu()
let currentInterval = priceManager.getCurrentRefreshInterval()
//
for interval in RefreshInterval.allCases {
let isCurrent = (interval == currentInterval)
let item = NSMenuItem(
title: interval.displayTextWithMark(isCurrent: isCurrent),
action: #selector(selectRefreshInterval(_:)),
keyEquivalent: ""
)
item.target = self
item.representedObject = interval
item.isEnabled = !isCurrent //
refreshSettingsMenu.addItem(item)
}
refreshSettingsItem.submenu = refreshSettingsMenu
menu.addItem(refreshSettingsItem)
menu.addItem(NSMenuItem.separator())
//
let launchAtLoginTitle = appSettings.launchAtLogin ? "✓ 开机启动" : "开机启动"
let launchAtLoginItem = NSMenuItem(title: launchAtLoginTitle, action: #selector(toggleLaunchAtLogin), keyEquivalent: "")
if let powerImage = NSImage(systemSymbolName: "tv", accessibilityDescription: "开机启动") {
powerImage.size = NSSize(width: 16, height: 16)
launchAtLoginItem.image = powerImage
// 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
}
launchAtLoginItem.target = self
menu.addItem(launchAtLoginItem)
menu.addItem(NSMenuItem.separator())
preferencesItem.target = self
menu.addItem(preferencesItem)
#if DEBUG
// Debug
@@ -392,19 +364,10 @@ class BTCMenuBarApp: NSObject, ObservableObject {
}
}
//
@objc private func selectRefreshInterval(_ sender: NSMenuItem) {
guard let interval = sender.representedObject as? RefreshInterval else {
return
}
// UserDefaults
appSettings.saveRefreshInterval(interval)
//
priceManager.updateRefreshInterval(interval)
print("✅ 刷新间隔已更新为: \(interval.displayText)")
//
@objc private func showPreferences() {
print("⚙️ [BTCMenuBarApp] 用户打开偏好设置")
preferencesWindowManager.showPreferencesWindow()
}
//
@@ -474,48 +437,7 @@ class BTCMenuBarApp: NSObject, ObservableObject {
}
//
@objc private func toggleLaunchAtLogin() {
let newState = !appSettings.launchAtLogin
// macOS
if #available(macOS 13.0, *) {
//
let alert = NSAlert()
alert.messageText = newState ? "启用开机自启动" : "禁用开机自启动"
alert.informativeText = newState ?
"应用将在系统启动时自动运行,您也可以随时在系统偏好设置中更改此选项。" :
"应用将不再在系统启动时自动运行。"
alert.alertStyle = .informational
alert.addButton(withTitle: "确定")
alert.addButton(withTitle: "取消")
let response = alert.runModal()
if response == .alertFirstButtonReturn {
//
appSettings.toggleLoginItem(enabled: newState)
//
let resultAlert = NSAlert()
resultAlert.messageText = newState ? "开机自启动已启用" : "开机自启动已禁用"
resultAlert.informativeText = newState ?
"Bitcoin Monitoring 将在下次系统启动时自动运行。" :
"Bitcoin Monitoring 不会在系统启动时自动运行。"
resultAlert.alertStyle = .informational
resultAlert.addButton(withTitle: "确定")
resultAlert.runModal()
}
} else {
//
let alert = NSAlert()
alert.messageText = "系统版本不支持"
alert.informativeText = "开机自启动功能需要 macOS 13.0 (Ventura) 或更高版本。"
alert.alertStyle = .warning
alert.addButton(withTitle: "确定")
alert.runModal()
}
}
// 退
@objc private func quitApp() {
NSApplication.shared.terminate(nil)

View File

@@ -0,0 +1,132 @@
//
// PreferencesWindowManager.swift
// Bitcoin Monitoring
//
// Created by Mark on 2025/10/31.
//
import SwiftUI
/**
*
*
*/
class PreferencesWindowManager: ObservableObject {
private var preferencesWindow: NSWindow?
private var appSettings: AppSettings
init(appSettings: AppSettings) {
self.appSettings = appSettings
}
/**
*
*/
func showPreferencesWindow() {
//
if let existingWindow = preferencesWindow {
existingWindow.makeKeyAndOrderFront(nil)
return
}
//
let preferencesView = PreferencesWindowView(
appSettings: appSettings,
onClose: { [weak self] in
self?.closePreferencesWindow()
}
)
let hostingView = NSHostingView(rootView: preferencesView)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 620),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "偏好设置"
window.contentViewController = NSViewController()
window.contentViewController?.view = hostingView
//
window.layoutIfNeeded()
//
centerWindowInScreen(window)
window.isReleasedWhenClosed = false
window.titlebarAppearsTransparent = false
window.titleVisibility = .visible
//
window.level = .floating
//
self.preferencesWindow = window
//
window.makeKeyAndOrderFront(nil)
print("✅ 已显示偏好设置窗口")
}
/**
*
*/
private func closePreferencesWindow() {
preferencesWindow?.close()
preferencesWindow = nil
print("✅ 已关闭偏好设置窗口")
}
/**
*
* - Parameter window:
*/
private func centerWindowInScreen(_ window: NSWindow) {
guard let screen = NSScreen.main else {
// 使 center()
window.center()
return
}
// 使 center()
window.center()
//
let currentFrame = window.frame
let screenFrame = screen.visibleFrame
//
let idealCenterY = screenFrame.origin.y + (screenFrame.height - currentFrame.height) / 2
// YY
if abs(currentFrame.origin.y - idealCenterY) > 1 {
var adjustedFrame = currentFrame
adjustedFrame.origin.y = idealCenterY
window.setFrame(adjustedFrame, display: false)
print("✅ 偏好设置窗口位置已调整到垂直居中")
} else {
print("✅ 偏好设置窗口已经在垂直居中位置")
}
}
/**
*
* - Returns:
*/
func isWindowVisible() -> Bool {
return preferencesWindow?.isVisible ?? false
}
/**
*
*
*/
func closeWindow() {
closePreferencesWindow()
}
}

View File

@@ -0,0 +1,345 @@
//
// PreferencesWindowView.swift
// Bitcoin Monitoring
//
// Created by Mark on 2025/10/31.
//
import SwiftUI
/**
*
* 使 SwiftUI
*/
struct PreferencesWindowView: View {
//
let onClose: () -> Void
//
@ObservedObject var appSettings: AppSettings
//
@State private var tempRefreshInterval: RefreshInterval
@State private var tempProxyEnabled: Bool
@State private var tempProxyHost: String
@State private var tempProxyPort: String
@State private var tempLaunchAtLogin: Bool
//
@State private var showingValidationError = false
@State private var validationErrorMessage = ""
//
@State private var isSaving = false
init(appSettings: AppSettings, onClose: @escaping () -> Void) {
self.appSettings = appSettings
self.onClose = onClose
//
self._tempRefreshInterval = State(initialValue: appSettings.refreshInterval)
self._tempProxyEnabled = State(initialValue: appSettings.proxyEnabled)
self._tempProxyHost = State(initialValue: appSettings.proxyHost)
self._tempProxyPort = State(initialValue: String(appSettings.proxyPort))
self._tempLaunchAtLogin = State(initialValue: appSettings.launchAtLogin)
}
var body: some View {
VStack(spacing: 0) {
//
ScrollView {
VStack(spacing: 24) {
//
SettingsGroupView(title: "刷新设置", icon: "timer") {
VStack(alignment: .leading, spacing: 12) {
Text("选择价格刷新间隔")
.font(.subheadline)
.foregroundColor(.secondary)
HStack(spacing: 12) {
ForEach(RefreshInterval.allCases, id: \.self) { interval in
IntervalSelectionButton(
interval: interval,
isSelected: tempRefreshInterval == interval,
onSelect: { tempRefreshInterval = interval }
)
}
}
}
}
//
SettingsGroupView(title: "启动设置", icon: "power") {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("开机自动启动")
.font(.subheadline)
.foregroundColor(.primary)
Text("应用将在系统启动时自动运行")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Toggle("", isOn: $tempLaunchAtLogin)
.labelsHidden()
}
}
}
//
SettingsGroupView(title: "代理设置", icon: "network") {
VStack(alignment: .leading, spacing: 16) {
//
HStack {
Text("启用HTTP代理")
.font(.subheadline)
.foregroundColor(.primary)
Spacer()
Toggle("", isOn: $tempProxyEnabled)
.labelsHidden()
}
if tempProxyEnabled {
//
VStack(alignment: .leading, spacing: 12) {
Text("代理服务器配置")
.font(.caption)
.foregroundColor(.secondary)
HStack(spacing: 12) {
//
VStack(alignment: .leading, spacing: 4) {
Text("服务器地址")
.font(.caption)
.foregroundColor(.secondary)
TextField("例如: proxy.example.com", text: $tempProxyHost)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(maxWidth: .infinity)
}
//
VStack(alignment: .leading, spacing: 4) {
Text("端口")
.font(.caption)
.foregroundColor(.secondary)
TextField("8080", text: $tempProxyPort)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(width: 80)
}
}
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
.animation(.easeInOut(duration: 0.2), value: tempProxyEnabled)
}
}
}
Spacer(minLength: 20)
}
.padding(24)
}
Divider()
//
HStack {
Spacer()
//
Button("取消") {
onClose()
}
.keyboardShortcut(.escape)
//
Button(action: saveSettings) {
HStack {
if isSaving {
ProgressView()
.scaleEffect(0.6)
.frame(width: 12, height: 12)
}
Text("保存")
}
.frame(minWidth: 80)
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(isSaving)
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
.frame(width: 480, height: 520)
.alert("配置验证", isPresented: $showingValidationError) {
Button("确定", role: .cancel) { }
} message: {
Text(validationErrorMessage)
}
}
/**
*
*/
private func saveSettings() {
print("🔧 [Preferences] 用户点击了保存按钮")
//
if tempProxyEnabled {
let validation = validateProxyInput()
if !validation.isValid {
validationErrorMessage = validation.errorMessage ?? "配置验证失败"
showingValidationError = true
return
}
}
isSaving = true
//
appSettings.saveRefreshInterval(tempRefreshInterval)
print("✅ [Preferences] 已保存刷新间隔: \(tempRefreshInterval.displayText)")
//
if tempLaunchAtLogin != appSettings.launchAtLogin {
appSettings.toggleLoginItem(enabled: tempLaunchAtLogin)
print("✅ [Preferences] 已设置开机自启动: \(tempLaunchAtLogin)")
}
//
let port = Int(tempProxyPort) ?? 8080
appSettings.saveProxySettings(
enabled: tempProxyEnabled,
host: tempProxyHost,
port: port
)
if tempProxyEnabled {
print("✅ [Preferences] 已保存代理设置: \(tempProxyHost):\(port)")
} else {
print("✅ [Preferences] 已禁用代理设置")
}
//
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isSaving = false
onClose()
}
}
/**
*
* - Returns:
*/
private func validateProxyInput() -> (isValid: Bool, errorMessage: String?) {
let trimmedHost = tempProxyHost.trimmingCharacters(in: .whitespacesAndNewlines)
//
if trimmedHost.isEmpty {
return (false, "代理服务器地址不能为空")
}
//
guard let port = Int(tempProxyPort), port > 0, port <= 65535 else {
return (false, "代理端口必须在 1-65535 范围内")
}
return (true, nil)
}
}
/**
*
*/
struct SettingsGroupView<Content: View>: View {
let title: String
let icon: String
let content: Content
init(title: String, icon: String, @ViewBuilder content: () -> Content) {
self.title = title
self.icon = icon
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
//
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 16))
.foregroundColor(.blue)
.frame(width: 20)
Text(title)
.font(.headline)
.fontWeight(.semibold)
Spacer()
}
//
VStack(alignment: .leading, spacing: 0) {
content
}
}
.padding(16)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(NSColor.separatorColor), lineWidth: 1)
)
}
}
/**
*
*/
struct IntervalSelectionButton: View {
let interval: RefreshInterval
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 14))
.foregroundColor(isSelected ? .blue : .secondary)
Text(interval.displayText)
.font(.system(size: 13))
.fontWeight(isSelected ? .medium : .regular)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isSelected ? Color.blue.opacity(0.1) : Color.clear)
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(isSelected ? Color.blue : Color(NSColor.separatorColor), lineWidth: 1)
)
}
.buttonStyle(PlainButtonStyle())
}
}
#Preview {
PreferencesWindowView(
appSettings: AppSettings(),
onClose: {}
)
}

View File

@@ -16,12 +16,13 @@ class PriceManager: ObservableObject {
@Published var lastError: PriceError?
@Published var selectedSymbol: CryptoSymbol
private let priceService = PriceService()
private let priceService: PriceService
private var timer: Timer?
private var currentRefreshInterval: TimeInterval = RefreshInterval.thirtySeconds.rawValue //
init(initialSymbol: CryptoSymbol = .btc) {
init(initialSymbol: CryptoSymbol = .btc, appSettings: AppSettings) {
selectedSymbol = initialSymbol
self.priceService = PriceService(appSettings: appSettings)
startPriceUpdates()
}

View File

@@ -10,7 +10,18 @@ import Foundation
// API
class PriceService: ObservableObject {
private let baseURL = "https://api.binance.com/api/v3/ticker/price"
private let session = URLSession.shared
private let session: URLSession
private let appSettings: AppSettings
@MainActor
init(appSettings: AppSettings) {
self.appSettings = appSettings
self.session = Self.createURLSession(
proxyEnabled: appSettings.proxyEnabled,
proxyHost: appSettings.proxyHost,
proxyPort: appSettings.proxyPort
)
}
//
func fetchPrice(for symbol: CryptoSymbol) async throws -> Double {
@@ -42,6 +53,99 @@ class PriceService: ObservableObject {
return price
}
// MARK: -
/**
* URLSession
* - Parameters:
* - proxyEnabled:
* - proxyHost:
* - proxyPort:
* - Returns: URLSession
*/
private static func createURLSession(proxyEnabled: Bool, proxyHost: String, proxyPort: Int) -> URLSession {
let configuration = URLSessionConfiguration.default
//
if proxyEnabled {
let proxyDict = createProxyDictionary(
host: proxyHost,
port: proxyPort
)
configuration.connectionProxyDictionary = proxyDict
#if DEBUG
print("🌐 [PriceService] 已配置代理: \(proxyHost):\(proxyPort)")
#endif
}
//
configuration.timeoutIntervalForRequest = 15.0
configuration.timeoutIntervalForResource = 30.0
return URLSession(configuration: configuration)
}
/**
*
* - Parameters:
* - host:
* - port:
* - Returns:
*/
private static func createProxyDictionary(host: String, port: Int) -> [AnyHashable: Any] {
return [
kCFNetworkProxiesHTTPEnable: 1,
kCFNetworkProxiesHTTPProxy: host,
kCFNetworkProxiesHTTPPort: port,
kCFNetworkProxiesHTTPSEnable: 1,
kCFNetworkProxiesHTTPSProxy: host,
kCFNetworkProxiesHTTPSPort: port
]
}
/**
*
*/
func updateNetworkConfiguration() {
// URLSessionsession
//
#if DEBUG
print("🔄 [PriceService] 网络配置已更新")
#endif
}
/**
*
* - Returns:
*/
func testProxyConnection() async -> Bool {
let proxyEnabled = await MainActor.run {
return appSettings.proxyEnabled
}
guard proxyEnabled else {
#if DEBUG
print("🌐 [PriceService] 代理未启用,无需测试连接")
#endif
return true
}
do {
//
_ = try await fetchPrice(for: .btc)
#if DEBUG
print("✅ [PriceService] 代理连接测试成功")
#endif
return true
} catch {
#if DEBUG
print("❌ [PriceService] 代理连接测试失败: \(error.localizedDescription)")
#endif
return false
}
}
}
//