diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 72fbb7d..f3d1ae4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 172ee43..835a177 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -1611,3 +1611,7 @@ GitHub 项目 مشروع GitHub 新版本更新 تحديثات الإصدارات الجديدة 后台发现新版本时通知,每个版本仅提醒一次 الإشعار عند العثور على تحديث في الخلفية، مرة واحدة لكل إصدار 点击查看更新内容并安装。 انقر للاطلاع على المستجدات وتثبيت التحديث. +不显示 إخفاء +国旗 العلم +地区文字 اسم المنطقة +与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 يشارك نتيجة الموقع والتحديث مع تفاصيل الشبكة دون طلبات إضافية. يتطلب الاستعلام عن IP العام. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index d2e0413..103acdc 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -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. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 7239980..89ac8d2 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -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. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 3317d4d..6a08e00 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -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. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 6f50d08..837235e 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -1611,3 +1611,7 @@ GitHub 项目 GitHub プロジェクト 新版本更新 新しいバージョン 后台发现新版本时通知,每个版本仅提醒一次 バックグラウンドで更新を検出したとき、バージョンごとに一度だけ通知 点击查看更新内容并安装。 クリックして更新内容を確認し、インストールします。 +不显示 表示しない +国旗 国旗 +地区文字 地域名 +与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 ネットワーク詳細の IP 地域と照会結果・更新を共有します。追加通信は行いません。公開 IP の照会が必要です。 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index d5d4d67..959e65c 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -1611,3 +1611,7 @@ GitHub 项目 GitHub 프로젝트 新版本更新 새 버전 업데이트 后台发现新版本时通知,每个版本仅提醒一次 백그라운드에서 업데이트를 찾으면 버전마다 한 번만 알림 点击查看更新内容并安装。 클릭하여 업데이트 내용을 확인하고 설치하세요. +不显示 표시 안 함 +国旗 국기 +地区文字 지역 이름 +与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 네트워크 상세 정보의 IP 위치 조회 결과와 새로고침을 공유합니다. 추가 통신은 하지 않으며 공인 IP 조회가 필요합니다. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index f982d8b..54308b7 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -1612,3 +1612,7 @@ GitHub 项目 GitHub 專案 新版本更新 新版本更新 后台发现新版本时通知,每个版本仅提醒一次 背景發現新版本時通知,每個版本僅提醒一次 点击查看更新内容并安装。 點擊查看更新內容並安裝。 +不显示 不顯示 +国旗 國旗 +地区文字 地區文字 +与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。 與網路詳情中的 IP 歸屬地共用查詢結果和重新整理,不額外連網。需開啟公網 IP 查詢。 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index 2cc141e..1bf120c 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -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. """# } diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index afa37ea..bacde97 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -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 diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift index 9bc94af..ef8ad7b 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift @@ -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" diff --git a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift index 2a7b046..6304e41 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift @@ -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) } } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift index 651e121..93faaea 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift @@ -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" diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift index 72cc7d9..6474aa3 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift @@ -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 } diff --git a/Packages/XStatsKit/Tests/XStatsUITests/NetworkMenuLocationTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/NetworkMenuLocationTests.swift new file mode 100644 index 0000000..df69a91 --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/NetworkMenuLocationTests.swift @@ -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) + } + } + +}