Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift
T
gentpan 51b954983b 菜单栏新增“电池”项目:电量、24 小时曲线、功耗、健康度与蓝牙设备电量
- 电池按九种风格画在菜单栏,充电中带闪电,电量低于 20% 按“高负载时着色”变红;点击弹出电池详情,主窗口同名页面复用同一视图,历史页多了电池电量曲线
- 历史库加 battery 列(旧库首次打开时 ALTER TABLE 迁移),HistoryRecorder 单独查最近 24 小时电量供弹窗使用
- BluetoothController 按需轮询蓝牙设备电量:弹窗 / 页面开着每分钟,菜单栏需要时每 5 分钟;本机信息页的蓝牙卡片改用它
- 没有电池的 Mac 上电池项目改为显示电量最低的蓝牙设备;「设置 · 菜单栏」新增“蓝牙设备电量低时提示”开关(同步文档一并带上)
- README、架构说明、更新日志与译文同步
2026-09-15 03:58:30 +05:00

869 lines
38 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.
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))
}
}
}
}
}