feat(menubar): 支持显示 IP 归属地国旗和文字
This commit is contained in:
1 parent
0387b0704d
commit
dd099950b1
15 files changed
+181
-2
No files matched your search
@@ -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 العام.
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
@@ -1611,3 +1611,7 @@ GitHub 项目 GitHub プロジェクト
|
||||
新版本更新 新しいバージョン
|
||||
后台发现新版本时通知,每个版本仅提醒一次 バックグラウンドで更新を検出したとき、バージョンごとに一度だけ通知
|
||||
点击查看更新内容并安装。 クリックして更新内容を確認し、インストールします。
|
||||
不显示 表示しない
|
||||
国旗 国旗
|
||||
地区文字 地域名
|
||||
与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 ネットワーク詳細の IP 地域と照会結果・更新を共有します。追加通信は行いません。公開 IP の照会が必要です。
|
||||
|
@@ -1611,3 +1611,7 @@ GitHub 项目 GitHub 프로젝트
|
||||
新版本更新 새 버전 업데이트
|
||||
后台发现新版本时通知,每个版本仅提醒一次 백그라운드에서 업데이트를 찾으면 버전마다 한 번만 알림
|
||||
点击查看更新内容并安装。 클릭하여 업데이트 내용을 확인하고 설치하세요.
|
||||
不显示 표시 안 함
|
||||
国旗 국기
|
||||
地区文字 지역 이름
|
||||
与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 네트워크 상세 정보의 IP 위치 조회 결과와 새로고침을 공유합니다. 추가 통신은 하지 않으며 공인 IP 조회가 필요합니다.
|
||||
|
@@ -1612,3 +1612,7 @@ GitHub 项目 GitHub 專案
|
||||
新版本更新 新版本更新
|
||||
后台发现新版本时通知,每个版本仅提醒一次 背景發現新版本時通知,每個版本僅提醒一次
|
||||
点击查看更新内容并安装。 點擊查看更新內容並安裝。
|
||||
不显示 不顯示
|
||||
国旗 國旗
|
||||
地区文字 地區文字
|
||||
与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 與網路詳情中的 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user