- 电池按九种风格画在菜单栏,充电中带闪电,电量低于 20% 按“高负载时着色”变红;点击弹出电池详情,主窗口同名页面复用同一视图,历史页多了电池电量曲线 - 历史库加 battery 列(旧库首次打开时 ALTER TABLE 迁移),HistoryRecorder 单独查最近 24 小时电量供弹窗使用 - BluetoothController 按需轮询蓝牙设备电量:弹窗 / 页面开着每分钟,菜单栏需要时每 5 分钟;本机信息页的蓝牙卡片改用它 - 没有电池的 Mac 上电池项目改为显示电量最低的蓝牙设备;「设置 · 菜单栏」新增“蓝牙设备电量低时提示”开关(同步文档一并带上) - README、架构说明、更新日志与译文同步
869 lines
38 KiB
Swift
869 lines
38 KiB
Swift
import AppKit
|
||
import HelperShared
|
||
import Localization
|
||
import Metrics
|
||
import SwiftUI
|
||
|
||
/// 主窗口里的设置页:与其他页面同样的滚动容器,打开时刷新登录项与辅助工具状态
|
||
struct SettingsTabPage<Content: View>: View {
|
||
@Environment(AppModel.self) private var model
|
||
@ViewBuilder var content: Content
|
||
|
||
var body: some View {
|
||
PageScroll { content }
|
||
.onAppear {
|
||
model.refreshLaunchAtLogin()
|
||
model.helper.refreshStatus()
|
||
}
|
||
}
|
||
}
|
||
|
||
struct SettingsGroup<Content: View>: View {
|
||
var caption: String?
|
||
@ViewBuilder var content: Content
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: DS.Space.s2) {
|
||
if let caption {
|
||
Text(caption).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
|
||
}
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
content
|
||
}
|
||
.background(DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.lg))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 分组内的一行,自带内边距与分隔线
|
||
struct GroupRow<Content: View>: View {
|
||
var showsDivider = true
|
||
@ViewBuilder var content: Content
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
if showsDivider { HairlineDivider() }
|
||
content
|
||
.padding(.horizontal, DS.Space.s4)
|
||
.padding(.vertical, DS.Space.s3)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 通用
|
||
|
||
struct GeneralSettings: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
@Bindable var settings = model.settings
|
||
|
||
SettingsGroup {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: tr("外观"), subtitle: tr("主窗口与弹窗的配色;菜单栏始终跟随系统")) {
|
||
SegmentedControl(selection: $settings.appearance,
|
||
options: AppearanceMode.allCases.map { ($0, $0.title) })
|
||
.frame(width: DS.Size.sidebarWidth + DS.Space.s12)
|
||
}
|
||
}
|
||
GroupRow {
|
||
SettingRow(title: tr("登录时启动"), subtitle: model.launchAtLoginError ?? tr("开机后自动在菜单栏显示 OpenStats")) {
|
||
DSToggle(isOn: Binding(get: { model.launchAtLoginEnabled },
|
||
set: { model.setLaunchAtLogin($0) }),
|
||
label: tr("登录时启动"))
|
||
}
|
||
}
|
||
GroupRow {
|
||
SettingRow(title: tr("刷新频率"), subtitle: tr("只显示菜单栏时的采样间隔;打开弹窗或主窗口时为 1 秒(进程页 2 秒)")) {
|
||
SegmentedControl(selection: $settings.refreshSeconds,
|
||
options: AppSettings.refreshOptions.map { ($0, tr("\($0) 秒")) })
|
||
.frame(width: DS.Size.sidebarWidth + DS.Space.s12)
|
||
}
|
||
}
|
||
GroupRow {
|
||
SettingRow(title: "语言 / Language", subtitle: tr("立即切换;显示器、应用名称等由系统提供的文字在下次启动时切换")) {
|
||
SegmentedControl(selection: $settings.language,
|
||
options: AppLanguage.allCases.map { ($0, $0.title) })
|
||
.frame(width: DS.Size.sidebarWidth + DS.Space.s12)
|
||
}
|
||
}
|
||
GroupRow {
|
||
SettingRow(title: tr("温度单位")) {
|
||
SegmentedControl(selection: $settings.useFahrenheit, options: [(false, "°C"), (true, "°F")])
|
||
.frame(width: DS.Size.sidebarWidth / 2 + DS.Space.s6)
|
||
}
|
||
}
|
||
}
|
||
|
||
HotKeySettings()
|
||
}
|
||
}
|
||
|
||
// MARK: - 通知
|
||
|
||
struct NotificationSettings: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
@Bindable var settings = model.settings
|
||
let alerts = model.alerts
|
||
|
||
if alerts.authorization == .denied {
|
||
InfoBanner(icon: "bell.slash", text: tr("OpenStats 的通知被关闭了,打开的提醒不会显示。"), tone: .warning) {
|
||
Button(tr("打开通知设置")) { alerts.openNotificationSettings() }
|
||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||
}
|
||
}
|
||
|
||
SettingsGroup(caption: tr("发生这些状况时发送系统通知,点通知打开对应页面")) {
|
||
ForEach(Array(AlertKind.allCases.enumerated()), id: \.element) { index, kind in
|
||
GroupRow(showsDivider: index > 0) {
|
||
SettingRow(title: kind.title, subtitle: kind.detail, icon: kind.symbol) {
|
||
DSToggle(isOn: Binding(get: { settings.enabledAlerts.contains(kind) },
|
||
set: { on in
|
||
if on { settings.enabledAlerts.insert(kind) } else { settings.enabledAlerts.remove(kind) }
|
||
}),
|
||
label: kind.title)
|
||
}
|
||
}
|
||
if kind == .cpuTemperature, settings.enabledAlerts.contains(.cpuTemperature) {
|
||
GroupRow {
|
||
SettingRow(title: tr("过热温度")) {
|
||
SegmentedControl(selection: $settings.alertCPUTemperature,
|
||
options: AppSettings.alertTemperatureOptions.map {
|
||
($0, Format.temperature(Double($0), fahrenheit: settings.useFahrenheit))
|
||
})
|
||
.frame(width: DS.Size.sidebarWidth + DS.Space.s12)
|
||
}
|
||
}
|
||
}
|
||
if kind == .cpuLoad, settings.enabledAlerts.contains(.cpuLoad) {
|
||
GroupRow {
|
||
SettingRow(title: tr("占用高于")) {
|
||
SegmentedControl(selection: $settings.alertCPULoad,
|
||
options: AppSettings.alertLoadOptions.map { ($0, "\($0)%") })
|
||
.frame(width: DS.Size.sidebarWidth + DS.Space.s6)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
SettingsGroup {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: tr("发送测试通知"), subtitle: alerts.authorization == .allowed ? tr("确认通知能正常显示") : tr("首次发送时系统会询问是否允许通知")) {
|
||
Button(tr("发送")) { alerts.sendTest() }
|
||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||
}
|
||
}
|
||
}
|
||
.onAppear { alerts.refreshAuthorization() }
|
||
}
|
||
}
|
||
|
||
// MARK: - 诊断
|
||
|
||
struct DiagnosticsSettings: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
let diagnostics = model.diagnostics
|
||
|
||
SettingsGroup(caption: tr("反馈问题")) {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: tr("导出诊断信息"),
|
||
subtitle: tr("打包版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志和崩溃报告,反馈问题时附上;会去掉序列号、IP 与硬件地址")) {
|
||
Button(diagnostics.phase == .collecting ? tr("正在导出…") : tr("导出…")) { diagnostics.export(model: model) }
|
||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||
.disabled(diagnostics.phase == .collecting)
|
||
}
|
||
}
|
||
switch diagnostics.phase {
|
||
case .finished(let url):
|
||
GroupRow {
|
||
InfoBanner(icon: "checkmark.circle.fill", text: tr("已导出 \(url.lastPathComponent)"), tone: .success) {
|
||
Button(tr("在访达中显示")) { diagnostics.reveal() }
|
||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||
}
|
||
}
|
||
case .failed(let message):
|
||
GroupRow { InfoBanner(icon: "exclamationmark.triangle.fill", text: message, tone: .error) }
|
||
default:
|
||
EmptyView()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 快捷键
|
||
|
||
struct HotKeySettings: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
let settings = model.settings
|
||
SettingsGroup(caption: tr("全局快捷键(在任何应用中都能使用,需要包含 ⌘、⌥ 或 ⌃)")) {
|
||
ForEach(Array(HotKeyAction.allCases.enumerated()), id: \.element) { index, action in
|
||
GroupRow(showsDivider: index > 0) {
|
||
SettingRow(title: action.title,
|
||
subtitle: model.hotKeyConflicts.contains(action) ? tr("这个快捷键已被其他应用或系统占用,请换一个") : nil) {
|
||
ShortcutRecorder(hotKey: Binding(get: { settings.hotKeys[action] },
|
||
set: { settings.hotKeys[action] = $0 }))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 点一下开始录制,按下组合键完成;Esc 取消,⌫ 清除
|
||
struct ShortcutRecorder: View {
|
||
@Binding var hotKey: HotKey?
|
||
@State private var recording = false
|
||
@State private var monitor: Any?
|
||
|
||
var body: some View {
|
||
HStack(spacing: DS.Space.s1) {
|
||
Button {
|
||
recording ? stop() : start()
|
||
} label: {
|
||
Text(verbatim: recording ? tr("按下快捷键…") : hotKey?.display ?? tr("录制快捷键"))
|
||
.dsFont(.sm, weight: hotKey == nil || recording ? .regular : .semibold)
|
||
.monospacedDigit()
|
||
.frame(minWidth: DS.Space.s16 + DS.Space.s12)
|
||
}
|
||
.buttonStyle(DSButtonStyle(kind: recording ? .primary : .secondary))
|
||
if hotKey != nil && !recording {
|
||
MiniIconButton(systemName: "xmark.circle.fill", help: tr("清除快捷键")) { hotKey = nil }
|
||
}
|
||
}
|
||
.onDisappear { stop() }
|
||
}
|
||
|
||
private func start() {
|
||
recording = true
|
||
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
|
||
switch Int(event.keyCode) {
|
||
case 53: // Esc
|
||
stop()
|
||
case 51 where event.modifierFlags.intersection([.command, .option, .control]).isEmpty: // Delete
|
||
hotKey = nil
|
||
stop()
|
||
default:
|
||
let candidate = HotKey(keyCode: event.keyCode, modifiers: event.modifierFlags,
|
||
key: event.charactersIgnoringModifiers ?? "")
|
||
guard candidate.isValid else { NSSound.beep(); return nil }
|
||
hotKey = candidate
|
||
stop()
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private func stop() {
|
||
recording = false
|
||
if let monitor { NSEvent.removeMonitor(monitor) }
|
||
monitor = nil
|
||
}
|
||
}
|
||
|
||
// MARK: - 菜单栏
|
||
|
||
struct MenuBarSettings: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
@Bindable var settings = model.settings
|
||
|
||
MenuBarPreview()
|
||
|
||
SettingsGroup(caption: tr("布局")) {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: tr("菜单栏图标"),
|
||
subtitle: settings.menuBarLayout == .separate
|
||
? tr("每个指标一个图标,点击弹出该项详情")
|
||
: tr("所有指标合成一个图标,点击打开主窗口")) {
|
||
SegmentedControl(selection: $settings.menuBarLayout,
|
||
options: MenuBarLayout.allCases.map { ($0, $0.title) })
|
||
.frame(width: DS.Size.sidebarWidth + DS.Space.s6)
|
||
}
|
||
}
|
||
}
|
||
|
||
SettingsGroup(caption: tr("整体风格")) {
|
||
LazyVGrid(columns: [GridItem(.flexible(), spacing: DS.Space.s3), GridItem(.flexible(), spacing: DS.Space.s3)],
|
||
spacing: DS.Space.s3) {
|
||
ForEach(MenuBarStyle.allCases) { style in
|
||
StyleCard(style: style, isSelected: settings.menuBarStyle == style) {
|
||
settings.menuBarStyle = style
|
||
}
|
||
}
|
||
}
|
||
.padding(DS.Space.s3)
|
||
HairlineDivider()
|
||
Text(tr("整体风格套用到所有项目。想让某个项目不一样,在下面“显示项目”里给它单独选一种,比如 CPU 用圆环、风扇用数字。"))
|
||
.dsFont(.xs)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
.padding(.horizontal, DS.Space.s4)
|
||
.padding(.vertical, DS.Space.s3)
|
||
}
|
||
|
||
SettingsGroup(caption: tr("显示项目")) {
|
||
GroupRow(showsDivider: false) {
|
||
InfoBanner(icon: "hand.draw",
|
||
text: tr("菜单栏里的图标可以调整顺序:按住 ⌘ 键拖动任意一个,松开后位置会一直保留。新开启的项目由系统安排位置,可能离其他图标较远,拖一下就能挪到一起。"))
|
||
}
|
||
ForEach(Array(MenuBarItem.allCases.enumerated()), id: \.element) { index, item in
|
||
GroupRow {
|
||
VStack(alignment: .leading, spacing: DS.Space.s3) {
|
||
SettingRow(title: item.title, subtitle: itemSubtitle(item), icon: item.symbol) {
|
||
DSToggle(isOn: Binding(get: { settings.isEnabled(item) },
|
||
set: { settings.setEnabled(item, $0) }),
|
||
label: item.title)
|
||
}
|
||
if settings.isEnabled(item) {
|
||
ItemStyleRow(item: item)
|
||
if settings.menuBarLayout == .separate {
|
||
PopoverSectionPicker(item: item)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
SettingsGroup(caption: tr("其他")) {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: tr("高负载时着色"), subtitle: tr("占用超过 85%(电池电量低于 20%)时数值与图形显示为红色")) {
|
||
DSToggle(isOn: $settings.colorizeHighLoad, label: tr("高负载时着色"))
|
||
}
|
||
}
|
||
GroupRow {
|
||
SettingRow(title: tr("蓝牙设备电量低时提示"),
|
||
subtitle: tr("键盘、鼠标、耳机等低于 20% 时,在菜单栏的电池项目旁显示该设备的图标与电量;需要开启电池项目")) {
|
||
DSToggle(isOn: $settings.bluetoothLowBatteryInMenuBar, label: tr("蓝牙设备电量低时提示"))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 没有电池的 Mac 上,电池项目改为显示蓝牙设备电量
|
||
private func itemSubtitle(_ item: MenuBarItem) -> String {
|
||
item == .battery && model.store.battery == nil
|
||
? tr("这台 Mac 没有电池:菜单栏显示电量最低的蓝牙设备,弹窗只列蓝牙设备")
|
||
: item.subtitle
|
||
}
|
||
|
||
}
|
||
|
||
/// 单个项目自己的菜单栏风格:网速有自己的三种样式;其他项目默认跟随整体,也可以单独选一种。
|
||
/// 旁边用实时数据画出这一项现在在菜单栏里的样子,改了立刻能看到
|
||
private struct ItemStyleRow: View {
|
||
@Environment(AppModel.self) private var model
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
let item: MenuBarItem
|
||
|
||
var body: some View {
|
||
@Bindable var settings = model.settings
|
||
HStack(spacing: DS.Space.s3) {
|
||
Text(tr("菜单栏风格"))
|
||
.dsFont(.xs)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
.fixedSize()
|
||
if item == .network {
|
||
SegmentedControl(selection: $settings.networkStyle,
|
||
options: NetworkMenuStyle.allCases.map { ($0, $0.title) })
|
||
.frame(width: DS.Size.sidebarWidth + DS.Space.s6)
|
||
} else {
|
||
Picker(tr("菜单栏风格"), selection: Binding(get: { settings.styleOverrides[item] },
|
||
set: { settings.setStyleOverride($0, for: item) })) {
|
||
Text(tr("跟随整体(\(settings.menuBarStyle.title))")).tag(MenuBarStyle?.none)
|
||
Divider()
|
||
ForEach(MenuBarStyle.options(for: item)) { style in
|
||
Text(style.title).tag(MenuBarStyle?.some(style))
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
.labelsHidden()
|
||
.fixedSize()
|
||
}
|
||
preview
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(.leading, DS.Size.iconStandalone + DS.Space.s3)
|
||
}
|
||
|
||
/// 这一项按当前风格、实时读数画出来的样子,底色跟随浅色 / 深色外观
|
||
private var preview: some View {
|
||
let settings = model.settings
|
||
let image = MenuBarRenderer.image(reading: MenuBarReading(model: model), items: [item],
|
||
style: { settings.style(for: $0) }, networkStyle: settings.networkStyle,
|
||
colorizeHighLoad: settings.colorizeHighLoad, fahrenheit: settings.useFahrenheit)
|
||
return Image(nsImage: MenuBarRenderer.preview(image, dark: colorScheme == .dark))
|
||
.padding(.horizontal, DS.Space.s3)
|
||
.frame(height: DS.Size.segmentHeight + DS.Space.s1)
|
||
.background(DS.Palette.track, in: RoundedRectangle(cornerRadius: DS.Radius.sm))
|
||
.accessibilityLabel(tr("该项目在菜单栏里的实时效果"))
|
||
}
|
||
}
|
||
|
||
/// 选择该项详情弹窗里显示哪些区块
|
||
private struct PopoverSectionPicker: View {
|
||
@Environment(AppModel.self) private var model
|
||
let item: MenuBarItem
|
||
|
||
var body: some View {
|
||
let settings = model.settings
|
||
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s3) {
|
||
Text(tr("弹窗显示"))
|
||
.dsFont(.xs)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
.fixedSize()
|
||
FlowLayout(spacing: DS.Space.s1) {
|
||
ForEach(item.popoverSections) { section in
|
||
let visible = settings.isVisible(section)
|
||
SectionToggleChip(title: section.title, isOn: visible) {
|
||
settings.setVisible(section, !visible)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.leading, DS.Size.iconStandalone + DS.Space.s3)
|
||
}
|
||
}
|
||
|
||
private struct SectionToggleChip: View {
|
||
let title: String
|
||
let isOn: Bool
|
||
let action: () -> Void
|
||
@State private var hovering = false
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
HStack(spacing: DS.Space.s1) {
|
||
Image(systemName: isOn ? "checkmark" : "plus")
|
||
.font(.system(size: DS.TextSize.xs.rawValue - DS.Space.s1 / 2, weight: .bold))
|
||
Text(title).dsFont(.xs, weight: .medium)
|
||
}
|
||
.foregroundStyle(isOn ? DS.Palette.primary : DS.Palette.textSecondary)
|
||
.padding(.horizontal, DS.Space.s2)
|
||
.frame(height: DS.Size.segmentHeight)
|
||
.background(isOn ? DS.Palette.primary.opacity(0.12) : hovering ? DS.Palette.surfaceHover : .clear,
|
||
in: RoundedRectangle(cornerRadius: DS.Radius.sm))
|
||
.overlay(RoundedRectangle(cornerRadius: DS.Radius.sm)
|
||
.strokeBorder(isOn ? DS.Palette.primary.opacity(0.4) : DS.Palette.border, lineWidth: DS.Size.stroke))
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.onHover { hovering = $0 }
|
||
.accessibilityAddTraits(isOn ? .isSelected : [])
|
||
}
|
||
}
|
||
|
||
private struct StyleCard: View {
|
||
@Environment(AppModel.self) private var model
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
let style: MenuBarStyle
|
||
let isSelected: Bool
|
||
let action: () -> Void
|
||
@State private var hovering = false
|
||
|
||
var body: some View {
|
||
let sample = MenuBarRenderer.image(reading: .sample, items: [.cpu, .memory, .gpu],
|
||
style: { _ in style }, networkStyle: model.settings.networkStyle,
|
||
colorizeHighLoad: false, fahrenheit: model.settings.useFahrenheit)
|
||
Button(action: action) {
|
||
VStack(alignment: .leading, spacing: DS.Space.s2) {
|
||
// 预览按原尺寸显示,超出卡片宽度时等比缩小,保证各卡片对齐
|
||
Image(nsImage: MenuBarRenderer.preview(sample, dark: colorScheme == .dark))
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fit)
|
||
.frame(maxWidth: sample.size.width, maxHeight: sample.size.height)
|
||
.padding(.horizontal, DS.Space.s2)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: DS.Size.controlHeight + DS.Space.s2)
|
||
.background(DS.Palette.track, in: RoundedRectangle(cornerRadius: DS.Radius.sm))
|
||
|
||
HStack(spacing: DS.Space.s1) {
|
||
Text(style.title).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
|
||
Spacer()
|
||
if isSelected {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.font(.system(size: DS.TextSize.sm.rawValue))
|
||
.foregroundStyle(DS.Palette.primary)
|
||
}
|
||
}
|
||
Text(style.detail)
|
||
.dsFont(.xs)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
.lineLimit(2)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
.frame(maxWidth: .infinity, minHeight: DS.TextSize.xs.rawValue * 3, alignment: .topLeading)
|
||
}
|
||
.padding(DS.Space.s2)
|
||
.background(hovering && !isSelected ? DS.Palette.surfaceHover : Color.clear,
|
||
in: RoundedRectangle(cornerRadius: DS.Radius.md))
|
||
.overlay(RoundedRectangle(cornerRadius: DS.Radius.md)
|
||
.strokeBorder(isSelected ? DS.Palette.primary : DS.Palette.border,
|
||
lineWidth: isSelected ? DS.Size.chartLine : DS.Size.stroke))
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.onHover { hovering = $0 }
|
||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||
}
|
||
}
|
||
|
||
private struct MenuBarPreview: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
let image = MenuBarRenderer.image(for: model)
|
||
VStack(alignment: .leading, spacing: DS.Space.s2) {
|
||
Text(tr("当前效果(实时数据)")).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
|
||
HStack(spacing: DS.Space.s3) {
|
||
preview(image: image, dark: false)
|
||
preview(image: image, dark: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func preview(image: NSImage, dark: Bool) -> some View {
|
||
HStack {
|
||
Spacer()
|
||
Image(nsImage: MenuBarRenderer.preview(image, dark: dark))
|
||
Spacer()
|
||
}
|
||
.frame(height: DS.Size.controlHeight + DS.Space.s2)
|
||
.background(dark ? Color(nsColor: NSColor(hex: 0x1F2937)) : Color(nsColor: NSColor(hex: 0xE5E7EB)),
|
||
in: RoundedRectangle(cornerRadius: DS.Radius.md))
|
||
.accessibilityLabel(dark ? tr("深色菜单栏预览") : tr("浅色菜单栏预览"))
|
||
}
|
||
}
|
||
|
||
// MARK: - 网络
|
||
|
||
struct NetworkSettings: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
@Bindable var settings = model.settings
|
||
|
||
SettingsGroup(caption: tr("连接探测")) {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: tr("定时探测网络"), subtitle: tr("用 ping 测量延迟与丢包,在网络详情里以格子显示")) {
|
||
DSToggle(isOn: $settings.probeEnabled, label: tr("定时探测网络"))
|
||
}
|
||
}
|
||
GroupRow {
|
||
SettingRow(title: tr("探测间隔"), subtitle: tr("打开网络详情时使用")) {
|
||
SegmentedControl(selection: $settings.probeSeconds,
|
||
options: AppSettings.probeOptions.map { ($0, tr("\($0) 秒")) })
|
||
.frame(width: DS.Size.sidebarWidth)
|
||
}
|
||
}
|
||
.disabled(!settings.probeEnabled)
|
||
GroupRow {
|
||
SettingRow(title: tr("探测目标"), subtitle: tr("国内网络建议选阿里云或腾讯;选路由器只检测本地连接")) {
|
||
Picker(tr("探测目标"), selection: $settings.probeTarget) {
|
||
ForEach(ProbeTarget.allCases) { target in
|
||
Text(target.title).tag(target)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
.labelsHidden()
|
||
.fixedSize()
|
||
}
|
||
}
|
||
.disabled(!settings.probeEnabled)
|
||
GroupRow {
|
||
SettingRow(title: tr("后台低频探测"),
|
||
subtitle: tr("详情关闭时,菜单栏显示网络项期间每 \(NetworkController.backgroundProbeSeconds) 秒探测一次,打开详情就能看到最近的连接情况;关闭后只在打开详情时探测,更省电")) {
|
||
DSToggle(isOn: $settings.probeInBackground, label: tr("后台低频探测"))
|
||
}
|
||
}
|
||
.disabled(!settings.probeEnabled)
|
||
}
|
||
|
||
SettingsGroup(caption: tr("公网 IP")) {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: tr("查询公网 IP"),
|
||
subtitle: tr("打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址,10 分钟内不重复请求")) {
|
||
DSToggle(isOn: $settings.publicIPLookup, label: tr("查询公网 IP"))
|
||
}
|
||
}
|
||
GroupRow {
|
||
SettingRow(title: tr("归属地数据源"), subtitle: settings.geoSource.detail) {
|
||
Picker(tr("归属地数据源"), selection: $settings.geoSource) {
|
||
ForEach(GeoSource.allCases) { source in
|
||
Text(source.title).tag(source)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
.labelsHidden()
|
||
.fixedSize()
|
||
.disabled(!settings.publicIPLookup)
|
||
}
|
||
}
|
||
}
|
||
|
||
GeoDatabaseSettings()
|
||
|
||
InfoBanner(icon: "lock.shield", text: tr("修改 DNS 需要管理员权限:已安装辅助工具时直接修改,否则每次弹出系统授权框。"), tone: .neutral)
|
||
}
|
||
}
|
||
|
||
/// IP 归属地数据库(MaxMind GeoLite2)
|
||
private struct GeoDatabaseSettings: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
@Bindable var settings = model.settings
|
||
let geo = model.geo
|
||
|
||
SettingsGroup(caption: tr("IP 归属地数据库")) {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: geo.isAvailable ? tr("本地 MaxMind GeoLite2") : tr("尚未下载"),
|
||
subtitle: geo.isAvailable
|
||
? tr("国家、城市与 ASN 在本机查询,不经过任何在线服务")
|
||
: tr("下载后归属地查询完全离线;数据库由 OpenStats 官网每周同步 MaxMind 的最新版本")) {
|
||
StatusBadge(text: geo.isAvailable ? tr("离线查询") : tr("在线查询"), tone: geo.isAvailable ? .success : .neutral)
|
||
}
|
||
}
|
||
ForEach(geo.installed.values.sorted { $0.edition < $1.edition }, id: \GeoDatabaseController.Installed.edition) { database in
|
||
GroupRow {
|
||
SettingRow(title: title(database.edition), subtitle: tr("版本 \(database.build) · \(Format.bytes(UInt64(database.size), base: .decimal))")) {
|
||
Text(database.edition).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||
}
|
||
}
|
||
}
|
||
GroupRow {
|
||
SettingRow(title: tr("包含城市数据"), subtitle: tr("显示城市名称,数据库约 60 MB;关闭时只下载国家库(约 9 MB)")) {
|
||
DSToggle(isOn: $settings.geoIncludeCity, label: tr("包含城市数据"))
|
||
}
|
||
}
|
||
GroupRow {
|
||
SettingRow(title: tr("自动更新"), subtitle: geo.lastChecked.map { tr("每 3 天检查一次 · 上次检查 \($0.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))") } ?? tr("每 3 天检查一次")) {
|
||
DSToggle(isOn: $settings.geoAutoUpdate, label: tr("自动更新"))
|
||
}
|
||
}
|
||
GroupRow {
|
||
HStack(spacing: DS.Space.s2) {
|
||
Button(geo.isUpdating ? tr("正在更新…") : geo.isAvailable ? tr("立即检查更新") : tr("下载数据库")) {
|
||
Task { await geo.update() }
|
||
}
|
||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||
.disabled(geo.isUpdating)
|
||
Button(tr("从文件导入…")) { importFile() }
|
||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||
if geo.isAvailable {
|
||
Button(tr("在访达中显示")) { NSWorkspace.shared.activateFileViewerSelecting([geo.directory]) }
|
||
.buttonStyle(DSButtonStyle(kind: .ghost))
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
if let message = geo.message {
|
||
GroupRow {
|
||
Text(message.text).dsFont(.xs).foregroundStyle(message.isError ? DS.Palette.error : DS.Palette.success)
|
||
}
|
||
}
|
||
}
|
||
|
||
Text(GeoDatabaseController.attribution)
|
||
.dsFont(.xs)
|
||
.foregroundStyle(DS.Palette.textTertiary)
|
||
}
|
||
|
||
private func title(_ edition: String) -> String {
|
||
if edition.hasSuffix("-City") { return tr("城市库") }
|
||
if edition.hasSuffix("-Country") { return tr("国家库") }
|
||
if edition.hasSuffix("-ASN") { return tr("ASN 库") }
|
||
return edition
|
||
}
|
||
|
||
private func importFile() {
|
||
let panel = NSOpenPanel()
|
||
panel.allowedContentTypes = [.init(filenameExtension: "mmdb") ?? .data]
|
||
panel.allowsMultipleSelection = true
|
||
panel.message = tr("选择 GeoLite2 City / Country / ASN 的 .mmdb 文件")
|
||
guard panel.runModal() == .OK else { return }
|
||
for url in panel.urls { model.geo.importDatabase(from: url) }
|
||
}
|
||
}
|
||
|
||
// MARK: - 散热与防休眠
|
||
|
||
/// 放在“温度与风扇”页底部
|
||
struct FanSafetySettings: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
@Bindable var settings = model.settings
|
||
|
||
SettingsGroup(caption: tr("风扇设置")) {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: tr("安全温度"), subtitle: tr("自定义转速时,CPU 达到该温度自动恢复系统控制")) {
|
||
SegmentedControl(selection: $settings.fanSafetyTemperature,
|
||
options: AppSettings.fanSafetyOptions.map { ($0, "\($0)°C") })
|
||
.frame(width: DS.Size.sidebarWidth + DS.Space.s12)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 放在“防休眠”页底部
|
||
struct LidBatterySettings: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
@Bindable var settings = model.settings
|
||
|
||
SettingsGroup(caption: tr("合盖运行设置")) {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: tr("电量下限"), subtitle: tr("使用电池且电量低于该值时,自动关闭合盖运行")) {
|
||
SegmentedControl(selection: $settings.lidModeBatteryFloor,
|
||
options: AppSettings.batteryFloorOptions.map { ($0, "\($0)%") })
|
||
.frame(width: DS.Size.sidebarWidth + DS.Space.s12)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 辅助工具
|
||
|
||
struct HelperSettings: View {
|
||
@Environment(AppModel.self) private var model
|
||
|
||
var body: some View {
|
||
let helper = model.helper
|
||
|
||
SettingsGroup {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: tr("OpenStats 辅助工具"), subtitle: tr("以系统权限运行的后台服务,只接受本应用的请求"), icon: "lock.shield") {
|
||
StatusBadge(text: helper.status.title, tone: tone(helper.status))
|
||
}
|
||
}
|
||
GroupRow {
|
||
VStack(alignment: .leading, spacing: DS.Space.s2) {
|
||
Text(tr("它只做这几件事")).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
|
||
CapabilityLine(text: tr("设置风扇目标转速,或恢复系统自动控制"))
|
||
CapabilityLine(text: tr("开启 / 关闭“合盖不睡眠”(等同 pmset disablesleep)"))
|
||
CapabilityLine(text: tr("刷新 DNS 缓存、释放内存、为网络服务设置 DNS 服务器"))
|
||
CapabilityLine(text: tr("应用退出或断开连接时,自动恢复风扇与睡眠设置"))
|
||
}
|
||
}
|
||
GroupRow {
|
||
HStack(spacing: DS.Space.s2) {
|
||
switch helper.status {
|
||
case .notInstalled:
|
||
Button(tr("安装辅助工具")) { helper.install() }
|
||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||
case .requiresApproval:
|
||
Button(tr("打开登录项设置")) { helper.openLoginItemsSettings() }
|
||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||
Button(tr("卸载")) { Task { await helper.uninstall() } }
|
||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||
case .enabled:
|
||
Button(tr("卸载辅助工具")) { Task { await helper.uninstall() } }
|
||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||
case .unavailable:
|
||
EmptyView()
|
||
}
|
||
Button(tr("刷新状态")) { helper.refreshStatus() }
|
||
.buttonStyle(DSButtonStyle(kind: .ghost))
|
||
Spacer()
|
||
}
|
||
.disabled(helper.isWorking)
|
||
}
|
||
}
|
||
|
||
if case .unavailable(let reason) = helper.status {
|
||
InfoBanner(icon: "exclamationmark.triangle.fill", text: reason, tone: .error)
|
||
}
|
||
if helper.isReady && helper.isOutdated {
|
||
InfoBanner(icon: "arrow.triangle.2.circlepath", text: HelperClient.outdatedMessage, tone: .warning) {
|
||
Button(tr("重新安装")) { Task { await helper.reinstall() } }
|
||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||
.disabled(helper.isWorking)
|
||
}
|
||
}
|
||
if let error = helper.lastError {
|
||
InfoBanner(icon: "exclamationmark.triangle.fill", text: error, tone: .error)
|
||
}
|
||
if let team = CodeSigningInfo.currentTeamIdentifier() {
|
||
InfoBanner(icon: "checkmark.seal", text: tr("已使用 Developer ID 签名(团队 \(team)),辅助工具只接受同一团队签名的 OpenStats。"), tone: .success)
|
||
} else {
|
||
InfoBanner(icon: "info.circle", text: tr("当前为临时签名的开发构建,辅助工具只能校验应用标识。使用 Developer ID 证书构建后会自动启用团队校验。"), tone: .neutral)
|
||
}
|
||
}
|
||
|
||
private func tone(_ status: HelperClient.Status) -> Tone {
|
||
switch status {
|
||
case .enabled: .success
|
||
case .requiresApproval: .warning
|
||
case .notInstalled: .neutral
|
||
case .unavailable: .error
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct CapabilityLine: View {
|
||
let text: String
|
||
|
||
var body: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) {
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: DS.TextSize.xs.rawValue, weight: .bold))
|
||
.foregroundStyle(DS.Palette.success)
|
||
Text(text).dsFont(.sm).foregroundStyle(DS.Palette.textSecondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 关于
|
||
|
||
struct AboutSettings: View {
|
||
var body: some View {
|
||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? tr("开发版")
|
||
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
|
||
|
||
SettingsGroup {
|
||
GroupRow(showsDivider: false) {
|
||
HStack(spacing: DS.Space.s4) {
|
||
AppGlyph(size: DS.Space.s12)
|
||
VStack(alignment: .leading, spacing: DS.Space.s1) {
|
||
Text("OpenStats").dsFont(.lg, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
|
||
Text(verbatim: tr("版本 \(version)\(build.map { " (\($0))" } ?? "")"))
|
||
.dsFont(.sm)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
GroupRow {
|
||
Text(tr("轻量的 macOS 菜单栏系统监控:CPU、GPU、内存、网络、温度、风扇、防休眠与清理。"))
|
||
.dsFont(.sm)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
}
|
||
}
|
||
|
||
UpdateSettings()
|
||
DiagnosticsSettings()
|
||
|
||
SettingsGroup(caption: tr("致谢")) {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: "exelban/stats", subtitle: tr("SMC 通信与 Apple Silicon 风扇解锁流程移植自该项目 · MIT License")) {
|
||
Button(tr("查看")) {
|
||
if let url = URL(string: "https://github.com/exelban/stats") { NSWorkspace.shared.open(url) }
|
||
}
|
||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|