feat(menubar): 支持显示 IP 归属地国旗和文字

This commit is contained in:
ysicing committed 2026-09-29 21:12:47 +08:00
1 parent 0387b0704d
commit dd099950b1
15 files changed
+181 -2

No files matched your search

+5
View File
@@ -298,6 +298,11 @@ the quota reader has no access to session-log content.
`/etc/openstats/maxmind.env` on the server); the app no longer downloads those files, they are kept
for other uses.
- **Per-process traffic** — cumulative bytes from `/usr/bin/nettop`, diffed between samples.
- **Menu bar IP location** — optional flag or localized region name after network speeds, disabled
by default. It reads the same `NetworkController.publicAddresses.countryCode` as network details,
including the selected IPv4 / IPv6 family and its fallback. It follows the existing cache and manual
refresh, adds no network requests or timers, and hides when public-IP lookup is disabled or no region
is available. Only the display preference is included in settings backups.
- **DNS** — `networksetup -setdnsservers` through the helper (protocol 3), which re-validates the
service name and every address; without the helper, a one-off administrator prompt runs the
same fixed command.
@@ -1611,3 +1611,7 @@ GitHub 项目 مشروع GitHub
新版本更新 تحديثات الإصدارات الجديدة
后台发现新版本时通知,每个版本仅提醒一次 الإشعار عند العثور على تحديث في الخلفية، مرة واحدة لكل إصدار
点击查看更新内容并安装。 انقر للاطلاع على المستجدات وتثبيت التحديث.
不显示 إخفاء
国旗 العلم
地区文字 اسم المنطقة
与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 يشارك نتيجة الموقع والتحديث مع تفاصيل الشبكة دون طلبات إضافية. يتطلب الاستعلام عن IP العام.
1 # Copyright (C) 2026 ysicing
1611 新版本更新
1612 后台发现新版本时通知,每个版本仅提醒一次
1613 点击查看更新内容并安装。
1614 不显示
1615 国旗
1616 地区文字
1617 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。
@@ -1611,3 +1611,7 @@ GitHub 项目 GitHub-Projekt
新版本更新 Neue Versionen
后台发现新版本时通知,每个版本仅提醒一次 Bei im Hintergrund gefundenen Updates einmal pro Version benachrichtigen
点击查看更新内容并安装。 Klicken, um Neuerungen anzusehen und das Update zu installieren.
不显示 Aus
国旗 Flagge
地区文字 Regionsname
与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 Nutzt dasselbe Standortergebnis und dieselbe Aktualisierung wie die Netzwerkdetails, ohne zusätzliche Anfragen. Benötigt die öffentliche IP-Abfrage.
1 # Copyright (C) 2026 ysicing
1611 新版本更新
1612 后台发现新版本时通知,每个版本仅提醒一次
1613 点击查看更新内容并安装。
1614 不显示
1615 国旗
1616 地区文字
1617 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。
@@ -1611,3 +1611,7 @@ GitHub 项目 Proyecto en GitHub
新版本更新 Nuevas versiones
后台发现新版本时通知,每个版本仅提醒一次 Notificar una vez por versión al detectar actualizaciones en segundo plano
点击查看更新内容并安装。 Haz clic para ver las novedades e instalar la actualización.
不显示 No mostrar
国旗 Bandera
地区文字 Nombre de región
与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 Comparte el resultado de ubicación y la actualización con los detalles de red, sin solicitudes adicionales. Requiere consultar la IP pública.
1 # Copyright (C) 2026 ysicing
1611 新版本更新
1612 后台发现新版本时通知,每个版本仅提醒一次
1613 点击查看更新内容并安装。
1614 不显示
1615 国旗
1616 地区文字
1617 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。
@@ -1611,3 +1611,7 @@ GitHub 项目 Projet GitHub
新版本更新 Nouvelles versions
后台发现新版本时通知,每个版本仅提醒一次 Notifier une fois par version lorsqu’une mise à jour est détectée en arrière-plan
点击查看更新内容并安装。 Cliquez pour voir les nouveautés et installer la mise à jour.
不显示 Masquer
国旗 Drapeau
地区文字 Nom de région
与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 Partage le résultat de localisation et l’actualisation des détails réseau, sans requête supplémentaire. Nécessite la recherche d’IP publique.
1 # Copyright (C) 2026 ysicing
1611 新版本更新
1612 后台发现新版本时通知,每个版本仅提醒一次
1613 点击查看更新内容并安装。
1614 不显示
1615 国旗
1616 地区文字
1617 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。
@@ -1611,3 +1611,7 @@ GitHub 项目 GitHub プロジェクト
新版本更新 新しいバージョン
后台发现新版本时通知,每个版本仅提醒一次 バックグラウンドで更新を検出したとき、バージョンごとに一度だけ通知
点击查看更新内容并安装。 クリックして更新内容を確認し、インストールします。
不显示 表示しない
国旗 国旗
地区文字 地域名
与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 ネットワーク詳細の IP 地域と照会結果・更新を共有します。追加通信は行いません。公開 IP の照会が必要です。
1 # Copyright (C) 2026 ysicing
1611 新版本更新
1612 后台发现新版本时通知,每个版本仅提醒一次
1613 点击查看更新内容并安装。
1614 不显示
1615 国旗
1616 地区文字
1617 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。
@@ -1611,3 +1611,7 @@ GitHub 项目 GitHub 프로젝트
新版本更新 새 버전 업데이트
后台发现新版本时通知,每个版本仅提醒一次 백그라운드에서 업데이트를 찾으면 버전마다 한 번만 알림
点击查看更新内容并安装。 클릭하여 업데이트 내용을 확인하고 설치하세요.
不显示 표시 안 함
国旗 국기
地区文字 지역 이름
与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 네트워크 상세 정보의 IP 위치 조회 결과와 새로고침을 공유합니다. 추가 통신은 하지 않으며 공인 IP 조회가 필요합니다.
1 # Copyright (C) 2026 ysicing
1611 新版本更新
1612 后台发现新版本时通知,每个版本仅提醒一次
1613 点击查看更新内容并安装。
1614 不显示
1615 国旗
1616 地区文字
1617 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。
@@ -1612,3 +1612,7 @@ GitHub 项目 GitHub 專案
新版本更新 新版本更新
后台发现新版本时通知,每个版本仅提醒一次 背景發現新版本時通知,每個版本僅提醒一次
点击查看更新内容并安装。 點擊查看更新內容並安裝。
不显示 不顯示
国旗 國旗
地区文字 地區文字
与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 與網路詳情中的 IP 歸屬地共用查詢結果和重新整理,不額外連網。需開啟公網 IP 查詢。
1 # Copyright (c) 2026 GiantAccel, LLC
1612 新版本更新
1613 后台发现新版本时通知,每个版本仅提醒一次
1614 点击查看更新内容并安装。
1615 不显示
1616 国旗
1617 地区文字
1618 与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。
@@ -1420,5 +1420,9 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
点击重新扫描以计算可清理空间 Click Rescan to calculate reclaimable space
按需扫描缓存、日志与项目产物,清理前逐项确认。 Scan caches, logs, and project artifacts on demand. Review items before cleaning.
“{}”只剩 {} 可用。请检查不再需要的文件。 “{}” has only {} available. Check files you no longer need.
不显示 Off
国旗 Flag
地区文字 Region name
与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 Shares the location result and refresh with network details; no extra requests. Requires public IP lookup.
"""#
}
@@ -308,6 +308,9 @@ public final class AppController: NSObject, NSApplicationDelegate {
_ = model.settings.menuBarLayout
_ = model.settings.menuBarStyle
_ = model.settings.networkStyle
_ = model.settings.networkLocationStyle
_ = model.settings.publicIPLookup
_ = model.network.publicAddresses?.countryCode
_ = model.settings.styleOverrides
_ = model.settings.colorizeHighLoad
_ = model.settings.useFahrenheit
@@ -39,6 +39,8 @@ struct MenuBarReading {
var diskHistory: [Double] = []
var upload: Double?
var download: Double?
var networkLocationStyle: NetworkLocationStyle = .off
var networkCountryCode: String?
var temperature: Double?
var fanRPM: Double?
var battery: Double?
@@ -69,6 +71,8 @@ struct MenuBarReading {
diskHistory = disk.map { Array(repeating: $0, count: 30) } ?? []
upload = store.network?.uploadBytesPerSecond
download = store.network?.downloadBytesPerSecond
networkLocationStyle = model.settings.publicIPLookup ? model.settings.networkLocationStyle : .off
networkCountryCode = model.network.publicAddresses?.countryCode
temperature = store.sensors?.temperature(.cpu)?.maximum
fanRPM = store.fastestFan?.current
battery = store.battery?.level
@@ -146,6 +150,8 @@ struct MenuBarReading {
&& (style != .history && style != .line || current.history == old.history)
case .network:
return upload == previous.upload && download == previous.download
&& networkLocationStyle == previous.networkLocationStyle
&& (networkLocationStyle == .off || networkCountryCode == previous.networkCountryCode)
case .temperature:
return temperature == previous.temperature
case .fan:
@@ -174,7 +180,10 @@ struct MenuBarReading {
case .memory: memory.map { tr("内存 \(Format.percent($0))") }
case .disk: disk.map { tr("磁盘已用 \(Format.percent($0))") }
case .network:
upload.flatMap { up in download.map { tr("上传 \(Format.menuBarRate(up)) · 下载 \(Format.menuBarRate($0))") } }
[upload.flatMap { up in download.map { tr("上传 \(Format.menuBarRate(up)) · 下载 \(Format.menuBarRate($0))") } },
networkLocationStyle != .off ? networkCountryCode.map {
tr("IP 归属地") + " · " + (L10n.locale.localizedString(forRegionCode: $0) ?? $0)
} : nil].compactMap { $0 }.joined(separator: "\n")
case .temperature: temperature.map { tr("CPU 温度 \(Format.temperature($0, fahrenheit: fahrenheit))") }
case .fan: fanRPM.map { tr("风扇 \(Format.rpm($0))") }
case .battery:
@@ -310,7 +319,9 @@ enum MenuBarRenderer {
let (value, history) = reading.percent(item)
return percentSegment(item: item, value: value, history: history, style: style, colorizeHighLoad: colorizeHighLoad)
case .network:
return networkSegment(upload: reading.upload, download: reading.download, style: networkStyle)
let speed = networkSegment(upload: reading.upload, download: reading.download, style: networkStyle)
guard reading.networkLocationStyle != .off, let code = reading.networkCountryCode else { return speed }
return combine([speed, networkLocationSegment(code: code, style: reading.networkLocationStyle)], gap: Metrics.innerGap * 2)
case .temperature:
let text = reading.temperature.map { celsius -> String in
let degrees = fahrenheit ? celsius * 9 / 5 + 32 : celsius
@@ -726,6 +737,31 @@ enum MenuBarRenderer {
// MARK: 网速
private static func networkLocationSegment(code: String, style: NetworkLocationStyle) -> Segment {
if style == .flag, let flag = FlagCache.shared.image(for: code) {
return Segment(width: 16, colored: true) { rect in
let frame = NSRect(x: rect.minX, y: rect.midY - 6, width: 16, height: 12)
NSGraphicsContext.saveGraphicsState()
NSBezierPath(roundedRect: frame, xRadius: 2, yRadius: 2).addClip()
flag.draw(in: frame)
NSGraphicsContext.restoreGraphicsState()
}
}
let name = L10n.locale.localizedString(forRegionCode: code) ?? code
let paragraph = NSMutableParagraphStyle()
paragraph.lineBreakMode = .byTruncatingTail
paragraph.baseWritingDirection = L10n.locale.language.languageCode?.identifier == "ar" ? .rightToLeft : .leftToRight
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: 11, weight: .medium),
.foregroundColor: NSColor.labelColor, .paragraphStyle: paragraph,
]
let size = (name as NSString).size(withAttributes: attributes)
return Segment(width: min(100, ceil(size.width))) { rect in
(name as NSString).draw(in: NSRect(x: rect.minX, y: rect.midY - size.height / 2,
width: rect.width, height: size.height), withAttributes: attributes)
}
}
private static func networkSegment(upload: Double?, download: Double?, style: NetworkMenuStyle) -> Segment {
let up = upload.map { Format.menuBarRate($0) } ?? "— KB/s"
let down = download.map { Format.menuBarRate($0) } ?? "— KB/s"
@@ -387,6 +387,20 @@ struct MenuBarSettings: View {
}
if moduleEnabled, settings.isEnabled(item) {
ItemStyleRow(item: item)
if item == .network {
SettingRow(title: tr("IP 归属地"),
subtitle: tr("与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。")) {
Picker(tr("IP 归属地"), selection: $settings.networkLocationStyle) {
ForEach(NetworkLocationStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.labelsHidden()
.fixedSize()
.disabled(!settings.publicIPLookup)
}
.padding(.leading, DS.Size.iconStandalone + DS.Space.s3)
}
if !item.popoverSections.isEmpty { PopoverSectionPicker(item: item) }
}
}
@@ -152,6 +152,20 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable {
}
}
/// 网络速度旁的可选出口地区显示。
public enum NetworkLocationStyle: String, CaseIterable, Identifiable, Sendable {
case off, flag, text
public var id: String { rawValue }
var title: String {
switch self {
case .off: tr("不显示")
case .flag: tr("国旗")
case .text: tr("地区文字")
}
}
}
/// 菜单栏布局
public enum MenuBarLayout: String, CaseIterable, Identifiable, Sendable {
/// 每个指标一个图标,点击弹出该项详情
@@ -507,6 +521,9 @@ public final class AppSettings {
public var networkStyle: NetworkMenuStyle {
didSet { defaults.set(networkStyle.rawValue, forKey: Keys.networkStyle) }
}
public var networkLocationStyle: NetworkLocationStyle {
didSet { defaults.set(networkLocationStyle.rawValue, forKey: Keys.networkLocationStyle) }
}
/// 个别指标单独指定的风格;未指定的跟随 menuBarStyle
public var styleOverrides: [MenuBarItem: MenuBarStyle] {
didSet {
@@ -724,6 +741,7 @@ public final class AppSettings {
menuBarItems = Set(items ?? [.cpu, .memory, .network])
menuBarStyle = defaults.string(forKey: Keys.menuBarStyle).flatMap(MenuBarStyle.init(rawValue:)) ?? .stacked
networkStyle = defaults.string(forKey: Keys.networkStyle).flatMap(NetworkMenuStyle.init(rawValue:)) ?? .dots
networkLocationStyle = defaults.string(forKey: Keys.networkLocationStyle).flatMap(NetworkLocationStyle.init(rawValue:)) ?? .off
let storedOverrides = defaults.dictionary(forKey: Keys.styleOverrides) as? [String: String] ?? [:]
styleOverrides = Dictionary(uniqueKeysWithValues: storedOverrides.compactMap { key, value in
guard let item = MenuBarItem(rawValue: key), let style = MenuBarStyle(rawValue: value) else { return nil }
@@ -844,6 +862,7 @@ public final class AppSettings {
static let menuBarItems = "menuBarItems"
static let menuBarStyle = "menuBarStyle"
static let networkStyle = "networkStyle"
static let networkLocationStyle = "networkLocationStyle"
static let styleOverrides = "styleOverrides"
static let refreshSeconds = "refreshSeconds"
static let aiUsageEnabled = "aiUsageEnabled"
@@ -32,6 +32,7 @@ public struct SettingsDocument: Codable, Equatable, Sendable {
public var menuBarItems: [String]?
public var menuBarStyle: String?
public var networkStyle: String?
public var networkLocationStyle: String?
public var styleOverrides: [String: String]?
public var refreshSeconds: Int?
public var colorizeHighLoad: Bool?
@@ -83,6 +84,7 @@ extension AppSettings {
doc.menuBarItems = menuBarItems.map(\.rawValue).sorted()
doc.menuBarStyle = menuBarStyle.rawValue
doc.networkStyle = networkStyle.rawValue
doc.networkLocationStyle = networkLocationStyle.rawValue
doc.styleOverrides = Dictionary(uniqueKeysWithValues: styleOverrides.map { ($0.key.rawValue, $0.value.rawValue) })
doc.refreshSeconds = refreshSeconds
doc.colorizeHighLoad = colorizeHighLoad
@@ -144,6 +146,7 @@ extension AppSettings {
assign(\.menuBarItems, doc.menuBarItems.map { Set($0.compactMap(MenuBarItem.init(rawValue:))) })
assign(\.menuBarStyle, doc.menuBarStyle.flatMap(MenuBarStyle.init(rawValue:)))
assign(\.networkStyle, doc.networkStyle.flatMap(NetworkMenuStyle.init(rawValue:)))
assign(\.networkLocationStyle, doc.networkLocationStyle.flatMap(NetworkLocationStyle.init(rawValue:)))
assign(\.styleOverrides, doc.styleOverrides.map { overrides in
Dictionary(uniqueKeysWithValues: overrides.compactMap { key, value in
guard let item = MenuBarItem(rawValue: key), let style = MenuBarStyle(rawValue: value) else { return nil }
@@ -0,0 +1,67 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit
import Foundation
import Testing
@testable import XStatsUI
@MainActor
struct NetworkMenuLocationTests {
private func settings() -> AppSettings {
AppSettings(defaults: UserDefaults(suiteName: "NetworkMenuLocationTests.\(UUID())")!)
}
@Test func optionalPreferencePersistsAndRoundTrips() throws {
let defaults = UserDefaults(suiteName: "NetworkMenuLocationTests.\(UUID())")!
let original = AppSettings(defaults: defaults)
#expect(original.networkLocationStyle == .off)
original.networkLocationStyle = .flag
#expect(AppSettings(defaults: defaults).networkLocationStyle == .flag)
let data = try JSONEncoder().encode(original.exportDocument())
let restored = settings()
restored.apply(try JSONDecoder().decode(SettingsDocument.self, from: data))
#expect(restored.networkLocationStyle == .flag)
var invalid = SettingsDocument()
invalid.networkLocationStyle = "future-value"
restored.apply(invalid)
#expect(restored.networkLocationStyle == .flag)
#expect(try JSONDecoder().decode(SettingsDocument.self, from: Data("{}".utf8)).networkLocationStyle == nil)
}
@Test func locationInvalidatesOnlyTheNetworkImage() {
var first = MenuBarReading.sample
first.networkCountryCode = "US"
var second = first
second.networkCountryCode = "JP"
#expect(second.hasSameImage(as: first, item: .network, style: .stacked))
first.networkLocationStyle = .flag
second.networkLocationStyle = .flag
#expect(!second.hasSameImage(as: first, item: .network, style: .stacked))
#expect(second.hasSameImage(as: first, item: .cpu, style: .stacked))
second = first
second.networkLocationStyle = .text
#expect(!second.hasSameImage(as: first, item: .network, style: .stacked))
}
@Test func menuImagesSupportFlagsTextAndUnavailableLocation() {
var reading = MenuBarReading.sample
func image() -> NSImage {
MenuBarRenderer.image(reading: reading, items: [.network], style: { _ in .stacked },
networkStyle: .dots, colorizeHighLoad: false, fahrenheit: false)
}
let base = image().size.width
reading.networkLocationStyle = .flag
#expect(image().size.width == base)
reading.networkCountryCode = "US"
let flag = image()
#expect(flag.size.width > base && !flag.isTemplate)
reading.networkLocationStyle = .text
#expect(image().size.width > base)
for dark in [false, true] {
#expect(MenuBarRenderer.preview(flag, dark: dark).tiffRepresentation != nil)
#expect(MenuBarRenderer.preview(image(), dark: dark).tiffRepresentation != nil)
}
}
}