IP 归属地改用本地 MaxMind GeoLite2:自研 .mmdb 读取器、服务器定时同步、应用校验下载与导入
- Metrics:MaxMindDatabase 按 MaxMind DB 2.0 规范解析搜索树与数据段(内存映射,无第三方依赖),GeoLookup 取国家、城市与 ASN - 服务器:server/geoip 下的更新脚本与 systemd 定时器,License Key 只放在服务器 /etc/openstats/maxmind.env,生成 manifest.json - 应用:GeoDatabaseController 每 3 天检查清单,下载后校验 sha256 与库类型再替换;支持从文件导入、可选城市库 - 有本地库时只向外查询公网 IP,归属地与 ASN 本机查询;网络详情标明数据来源;官网部署排除 /geoip/ - 新增读取器单元测试;更新隐私说明、第三方声明与架构文档
This commit is contained in:
1 parent
3243570d53
commit
0342074489
21 files changed
+827
-19
No files matched your search
+7
-2
@@ -120,8 +120,13 @@ is open and back to accessory when all are closed.
|
||||
sequence number and a random payload token because the kernel rewrites the identifier.
|
||||
- **Interface and addresses** — `SCDynamicStore` / `SCPreferences` for the primary and physical
|
||||
service, `getifaddrs` for addresses, CoreWLAN for signal and rate.
|
||||
- **Public IP** — ipinfo.io for address, region and ASN; Cloudflare trace and ipify for IPv6 and
|
||||
fallback. Country codes are validated before being used as flag file names.
|
||||
- **Public IP** — Cloudflare trace (ipify as fallback) for the address only. Region and ASN come from
|
||||
MaxMind GeoLite2 databases read by `MaxMindDatabase`, a memory-mapped reader of the MaxMind DB
|
||||
format (search tree + data section decoder, no dependencies). `server/geoip/` holds the systemd
|
||||
timer that syncs GeoLite2 onto getopenstats.com with the MaxMind key kept in `/etc/openstats` on the
|
||||
server; the app fetches `geoip/manifest.json`, downloads changed files, verifies sha256 and the
|
||||
database type, then swaps them in. Without a local database it falls back to ipinfo.io. Country
|
||||
codes are validated before being used as flag file names.
|
||||
- **Per-process traffic** — cumulative bytes from `/usr/bin/nettop`, diffed between samples.
|
||||
- **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
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。
|
||||
- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。
|
||||
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
|
||||
- IP 归属地改为本地 MaxMind GeoLite2 数据库查询:自己实现 .mmdb 读取器(不依赖第三方库),国家、城市与 ASN 完全离线查;数据库由官网服务器每周二、周五用 MaxMind 账号同步,应用每 3 天检查清单、下载并按 sha256 校验后替换,也可以从文件导入;设置中可选是否包含城市库(约 60 MB);数据库就绪前暂用 ipinfo.io 在线查询,网络详情里标明数据来源。
|
||||
- 本机信息页:机型图与名称、系统版本与版号、芯片与各类核心、内存与图形、存储用量、电池健康与循环次数、每台显示器的尺寸 / 原生分辨率 / 显示分辨率 / 刷新率、机型标识符、序列号(默认遮住,点眼睛图标显示,截图时始终遮住)与启动时间。
|
||||
- 进程管理器(主窗口“进程”页):包括 root 与其他用户的系统进程(通过系统自带的 ps 读取,不需要辅助工具);列出 CPU、CPU 时间、内存、线程、唤醒次数、磁盘读写与用户,点列标题排序;可搜索名称、PID 或用户,筛选全部 / 我的 / 系统,切换按进程或按应用合并;选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。
|
||||
- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
/// MaxMind DB(.mmdb)读取器,按格式规范 2.0 实现:二叉搜索树定位记录,再解码数据段。
|
||||
/// 文件以内存映射方式打开,查询不需要把整个库读进内存。
|
||||
public final class MaxMindDatabase: @unchecked Sendable {
|
||||
public enum Error: Swift.Error, Equatable {
|
||||
case unreadable
|
||||
case missingMetadata
|
||||
case invalidMetadata(String)
|
||||
case corrupt
|
||||
}
|
||||
|
||||
public let databaseType: String
|
||||
/// 数据生成时间
|
||||
public let buildDate: Date
|
||||
public let ipVersion: Int
|
||||
|
||||
private let data: Data
|
||||
private let nodeCount: Int
|
||||
private let recordSize: Int
|
||||
private let treeSize: Int
|
||||
private let dataSectionStart: Int
|
||||
private let ipv4Start: Int
|
||||
|
||||
private static let metadataMarker = Data([0xAB, 0xCD, 0xEF] + Array("MaxMind.com".utf8))
|
||||
|
||||
public convenience init(url: URL) throws {
|
||||
guard let data = try? Data(contentsOf: url, options: .alwaysMapped) else { throw Error.unreadable }
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
public init(data: Data) throws {
|
||||
self.data = data
|
||||
// 元数据在文件末尾 128 KB 以内,取最后一次出现的标记
|
||||
let searchStart = max(0, data.count - 128 * 1024)
|
||||
guard let marker = data.range(of: Self.metadataMarker, options: .backwards, in: searchStart..<data.count) else {
|
||||
throw Error.missingMetadata
|
||||
}
|
||||
var decoder = Decoder(data: data, pointerBase: marker.upperBound)
|
||||
guard case .map(let metadata)? = try? decoder.decode(at: marker.upperBound).value else {
|
||||
throw Error.invalidMetadata("元数据不是字典")
|
||||
}
|
||||
guard let nodes = metadata["node_count"]?.integer, let record = metadata["record_size"]?.integer,
|
||||
[24, 28, 32].contains(record), let version = metadata["ip_version"]?.integer else {
|
||||
throw Error.invalidMetadata("缺少 node_count / record_size / ip_version")
|
||||
}
|
||||
nodeCount = nodes
|
||||
recordSize = record
|
||||
ipVersion = version
|
||||
databaseType = metadata["database_type"]?.string ?? ""
|
||||
buildDate = Date(timeIntervalSince1970: TimeInterval(metadata["build_epoch"]?.integer ?? 0))
|
||||
treeSize = (record * 2 / 8) * nodes
|
||||
dataSectionStart = treeSize + 16
|
||||
guard dataSectionStart <= data.count else { throw Error.corrupt }
|
||||
|
||||
// IPv6 库里 IPv4 地址位于 ::/96 之下,预先走完前 96 个 0 位
|
||||
var node = 0
|
||||
if version == 6 {
|
||||
for _ in 0..<96 where node < nodes {
|
||||
node = Self.readRecord(data: data, node: node, bit: 0, recordSize: record)
|
||||
}
|
||||
}
|
||||
ipv4Start = node
|
||||
}
|
||||
|
||||
/// 查询 IP,返回该地址对应的记录;不在库中时返回 nil
|
||||
public func lookup(_ address: String) -> Value? {
|
||||
guard let bytes = Self.addressBytes(address) else { return nil }
|
||||
if bytes.count == 16, ipVersion == 4 { return nil }
|
||||
|
||||
var node = bytes.count == 4 ? ipv4Start : 0
|
||||
let bitCount = bytes.count * 8
|
||||
for index in 0..<bitCount {
|
||||
guard node < nodeCount else { break }
|
||||
let bit = Int((bytes[index >> 3] >> (7 - UInt8(index % 8))) & 1)
|
||||
node = Self.readRecord(data: data, node: node, bit: bit, recordSize: recordSize)
|
||||
}
|
||||
guard node > nodeCount else { return nil } // 等于 nodeCount 表示没有数据
|
||||
let offset = node - nodeCount + treeSize
|
||||
guard offset < data.count else { return nil }
|
||||
var decoder = Decoder(data: data, pointerBase: dataSectionStart)
|
||||
return try? decoder.decode(at: offset).value
|
||||
}
|
||||
|
||||
// MARK: 搜索树
|
||||
|
||||
private static func readRecord(data: Data, node: Int, bit: Int, recordSize: Int) -> Int {
|
||||
data.withUnsafeBytes { raw -> Int in
|
||||
let bytes = raw.bindMemory(to: UInt8.self)
|
||||
func byte(_ offset: Int) -> Int { Int(bytes[offset]) }
|
||||
switch recordSize {
|
||||
case 24:
|
||||
let base = node * 6 + bit * 3
|
||||
return byte(base) << 16 | byte(base + 1) << 8 | byte(base + 2)
|
||||
case 28:
|
||||
let base = node * 7
|
||||
if bit == 0 {
|
||||
return (byte(base + 3) & 0xF0) << 20 | byte(base) << 16 | byte(base + 1) << 8 | byte(base + 2)
|
||||
}
|
||||
return (byte(base + 3) & 0x0F) << 24 | byte(base + 4) << 16 | byte(base + 5) << 8 | byte(base + 6)
|
||||
default:
|
||||
let base = node * 8 + bit * 4
|
||||
return byte(base) << 24 | byte(base + 1) << 16 | byte(base + 2) << 8 | byte(base + 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func addressBytes(_ address: String) -> [UInt8]? {
|
||||
var v4 = in_addr()
|
||||
if inet_pton(AF_INET, address, &v4) == 1 {
|
||||
return withUnsafeBytes(of: &v4.s_addr) { Array($0) }
|
||||
}
|
||||
var v6 = in6_addr()
|
||||
if inet_pton(AF_INET6, address, &v6) == 1 {
|
||||
return withUnsafeBytes(of: &v6) { Array($0) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: 数据段
|
||||
|
||||
public indirect enum Value: Sendable, Equatable {
|
||||
case string(String)
|
||||
case double(Double)
|
||||
case bytes(Data)
|
||||
case unsigned(UInt64)
|
||||
case signed(Int64)
|
||||
case map([String: Value])
|
||||
case array([Value])
|
||||
case boolean(Bool)
|
||||
|
||||
public subscript(key: String) -> Value? {
|
||||
if case .map(let map) = self { return map[key] }
|
||||
return nil
|
||||
}
|
||||
|
||||
public var string: String? {
|
||||
if case .string(let value) = self { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
public var integer: Int? {
|
||||
switch self {
|
||||
case .unsigned(let value): Int(exactly: value)
|
||||
case .signed(let value): Int(exactly: value)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Decoder {
|
||||
let data: Data
|
||||
let pointerBase: Int
|
||||
|
||||
mutating func decode(at offset: Int) throws -> (value: Value, next: Int) {
|
||||
var position = offset
|
||||
let control = try byte(&position)
|
||||
var type = Int(control >> 5)
|
||||
|
||||
if type == 1 { // 指针:跳到数据段里的另一处解码,解码完回到指针之后
|
||||
let size = Int((control >> 3) & 0x3)
|
||||
let high = Int(control & 0x7)
|
||||
let raw = try bytes(&position, count: size + 1)
|
||||
var pointer: Int
|
||||
switch size {
|
||||
case 0: pointer = high << 8 | raw[0]
|
||||
case 1: pointer = (high << 16 | raw[0] << 8 | raw[1]) + 2048
|
||||
case 2: pointer = (high << 24 | raw[0] << 16 | raw[1] << 8 | raw[2]) + 526_336
|
||||
default: pointer = raw[0] << 24 | raw[1] << 16 | raw[2] << 8 | raw[3]
|
||||
}
|
||||
pointer += pointerBase
|
||||
let resolved = try decode(at: pointer)
|
||||
return (resolved.value, position)
|
||||
}
|
||||
if type == 0 { // 扩展类型
|
||||
type = 7 + Int(try byte(&position))
|
||||
}
|
||||
|
||||
var size = Int(control & 0x1F)
|
||||
if size >= 29 {
|
||||
let extra = try bytes(&position, count: size - 28)
|
||||
switch size {
|
||||
case 29: size = 29 + extra[0]
|
||||
case 30: size = 285 + (extra[0] << 8 | extra[1])
|
||||
default: size = 65_821 + (extra[0] << 16 | extra[1] << 8 | extra[2])
|
||||
}
|
||||
}
|
||||
|
||||
switch type {
|
||||
case 2:
|
||||
let raw = try slice(&position, count: size)
|
||||
return (.string(String(decoding: raw, as: UTF8.self)), position)
|
||||
case 3:
|
||||
guard size == 8 else { throw Error.corrupt }
|
||||
let raw = try slice(&position, count: 8)
|
||||
return (.double(Double(bitPattern: UInt64(bigEndianBytes: raw))), position)
|
||||
case 4:
|
||||
return (.bytes(try slice(&position, count: size)), position)
|
||||
case 5, 6, 9, 10:
|
||||
let raw = try slice(&position, count: size)
|
||||
// uint128 超出 64 位的部分在地理库里用不到,只保留低 64 位
|
||||
return (.unsigned(UInt64(bigEndianBytes: raw.suffix(8))), position)
|
||||
case 7:
|
||||
var map: [String: Value] = [:]
|
||||
map.reserveCapacity(size)
|
||||
for _ in 0..<size {
|
||||
let key = try decode(at: position)
|
||||
guard case .string(let name) = key.value else { throw Error.corrupt }
|
||||
let value = try decode(at: key.next)
|
||||
map[name] = value.value
|
||||
position = value.next
|
||||
}
|
||||
return (.map(map), position)
|
||||
case 8:
|
||||
let raw = try slice(&position, count: size)
|
||||
let unsigned = UInt32(truncatingIfNeeded: UInt64(bigEndianBytes: raw))
|
||||
return (.signed(Int64(Int32(bitPattern: unsigned))), position)
|
||||
case 11:
|
||||
var array: [Value] = []
|
||||
array.reserveCapacity(size)
|
||||
for _ in 0..<size {
|
||||
let element = try decode(at: position)
|
||||
array.append(element.value)
|
||||
position = element.next
|
||||
}
|
||||
return (.array(array), position)
|
||||
case 14:
|
||||
return (.boolean(size != 0), position)
|
||||
case 15:
|
||||
guard size == 4 else { throw Error.corrupt }
|
||||
let raw = try slice(&position, count: 4)
|
||||
return (.double(Double(Float(bitPattern: UInt32(truncatingIfNeeded: UInt64(bigEndianBytes: raw))))), position)
|
||||
default:
|
||||
throw Error.corrupt
|
||||
}
|
||||
}
|
||||
|
||||
private func byte(_ position: inout Int) throws -> UInt8 {
|
||||
guard position < data.count else { throw Error.corrupt }
|
||||
defer { position += 1 }
|
||||
return data[data.startIndex + position]
|
||||
}
|
||||
|
||||
private func bytes(_ position: inout Int, count: Int) throws -> [Int] {
|
||||
try slice(&position, count: count).map(Int.init)
|
||||
}
|
||||
|
||||
private func slice(_ position: inout Int, count: Int) throws -> Data {
|
||||
guard count >= 0, position + count <= data.count else { throw Error.corrupt }
|
||||
defer { position += count }
|
||||
return data.subdata(in: (data.startIndex + position)..<(data.startIndex + position + count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UInt64 {
|
||||
init<Bytes: Collection>(bigEndianBytes bytes: Bytes) where Bytes.Element == UInt8 {
|
||||
self = bytes.reduce(0) { $0 << 8 | UInt64($1) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 地理信息
|
||||
|
||||
/// 从 GeoLite2 City / Country 与 ASN 库里取出界面需要的字段
|
||||
public struct GeoLocation: Sendable, Equatable {
|
||||
public var countryCode: String?
|
||||
public var countryName: String?
|
||||
public var city: String?
|
||||
public var asn: String?
|
||||
public var organization: String?
|
||||
}
|
||||
|
||||
public enum GeoLookup {
|
||||
/// 名称优先取简体中文,没有时用英文
|
||||
public static func locate(_ address: String, location: MaxMindDatabase?, asn: MaxMindDatabase?) -> GeoLocation? {
|
||||
var result = GeoLocation()
|
||||
if let record = location?.lookup(address) {
|
||||
let country = record["country"] ?? record["registered_country"]
|
||||
result.countryCode = country?["iso_code"]?.string
|
||||
result.countryName = name(country)
|
||||
result.city = name(record["city"])
|
||||
}
|
||||
if let record = asn?.lookup(address) {
|
||||
result.asn = record["autonomous_system_number"]?.integer.map { "AS\($0)" }
|
||||
result.organization = record["autonomous_system_organization"]?.string
|
||||
}
|
||||
return result == GeoLocation() ? nil : result
|
||||
}
|
||||
|
||||
private static func name(_ value: MaxMindDatabase.Value?) -> String? {
|
||||
guard let names = value?["names"] else { return nil }
|
||||
return names["zh-CN"]?.string ?? names["en"]?.string
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,14 @@ public struct PublicAddresses: Sendable, Equatable {
|
||||
public var asn: String?
|
||||
/// 网络所属组织(英文),例如 “CHINANET-BACKBONE”
|
||||
public var organization: String?
|
||||
public var source: Source = .online
|
||||
|
||||
public enum Source: Sendable, Equatable {
|
||||
/// 本机的 MaxMind GeoLite2 数据库
|
||||
case localDatabase
|
||||
/// ipinfo.io 在线查询
|
||||
case online
|
||||
}
|
||||
|
||||
public init(ipv4: String? = nil, ipv6: String? = nil, countryCode: String? = nil,
|
||||
city: String? = nil, asn: String? = nil, organization: String? = nil) {
|
||||
@@ -35,7 +43,14 @@ public enum PublicAddressLookup {
|
||||
return URLSession(configuration: configuration)
|
||||
}()
|
||||
|
||||
public static func fetch() async -> PublicAddresses {
|
||||
/// `includeGeo` 为假时只查询公网 IP(Cloudflare trace / ipify),不请求任何归属地服务
|
||||
public static func fetch(includeGeo: Bool = true) async -> PublicAddresses {
|
||||
guard includeGeo else {
|
||||
async let v4 = lookup(trace: "https://1.1.1.1/cdn-cgi/trace", fallback: "https://api.ipify.org")
|
||||
async let v6 = lookup(trace: "https://[2606:4700:4700::1111]/cdn-cgi/trace", fallback: "https://api6.ipify.org")
|
||||
let (ipv4, ipv6) = await (v4, v6)
|
||||
return PublicAddresses(ipv4: ipv4.ip, ipv6: ipv6.ip, countryCode: ipv4.country ?? ipv6.country)
|
||||
}
|
||||
async let geoResult = geo()
|
||||
async let v6Result = lookup(trace: "https://[2606:4700:4700::1111]/cdn-cgi/trace", fallback: "https://api6.ipify.org")
|
||||
var result = await geoResult ?? PublicAddresses()
|
||||
|
||||
@@ -40,6 +40,7 @@ public final class AppController: NSObject, NSApplicationDelegate, NSWindowDeleg
|
||||
observeProbeSettings()
|
||||
applyAppearance()
|
||||
model.network.updateProbing(networkShown: model.networkShown)
|
||||
Task { await model.geo.updateIfNeeded() }
|
||||
|
||||
// 开发调试:--show-panel [cpu|memory|network|gpu|temperature|fan] 启动后展开并固定弹窗;--show-window 打开主窗口
|
||||
let arguments = CommandLine.arguments
|
||||
|
||||
@@ -214,6 +214,9 @@ private struct AddressSection: View {
|
||||
if let organization = publicAddresses?.organization {
|
||||
InfoRow(label: "网络运营方") { Text(verbatim: organization).lineLimit(1).truncationMode(.middle) }
|
||||
}
|
||||
if let publicAddresses, publicAddresses.countryCode != nil || publicAddresses.asn != nil {
|
||||
InfoRow(label: "数据来源", text: publicAddresses.source == .localDatabase ? "本地 GeoLite2(MaxMind)" : "ipinfo.io 在线查询")
|
||||
}
|
||||
} else {
|
||||
InfoRow(label: "公网 IP", text: "查询已关闭")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AppKit
|
||||
import HelperShared
|
||||
import Metrics
|
||||
import SwiftUI
|
||||
|
||||
enum SettingsSection: String, CaseIterable, Identifiable {
|
||||
@@ -450,16 +451,100 @@ private struct NetworkSettings: View {
|
||||
|
||||
SettingsGroup(caption: "公网 IP") {
|
||||
GroupRow(showsDivider: false) {
|
||||
SettingRow(title: "查询公网 IP", subtitle: "打开网络详情时向 ipinfo.io 查询公网地址、归属地与 ASN,IPv6 与回退使用 Cloudflare / ipify;10 分钟内不重复请求") {
|
||||
SettingRow(title: "查询公网 IP",
|
||||
subtitle: model.geo.isAvailable
|
||||
? "打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址;归属地与 ASN 在本机数据库里查,10 分钟内不重复请求"
|
||||
: "打开网络详情时查询公网地址;本地数据库还没准备好时,归属地与 ASN 暂由 ipinfo.io 在线查询") {
|
||||
DSToggle(isOn: $settings.publicIPLookup, label: "查询公网 IP")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GeoDatabaseSettings()
|
||||
|
||||
InfoBanner(icon: "lock.shield", text: "修改 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: "IP 归属地数据库") {
|
||||
GroupRow(showsDivider: false) {
|
||||
SettingRow(title: geo.isAvailable ? "本地 MaxMind GeoLite2" : "尚未下载",
|
||||
subtitle: geo.isAvailable
|
||||
? "国家、城市与 ASN 在本机查询,不经过任何在线服务"
|
||||
: "下载后归属地查询完全离线;数据库由 OpenStats 官网每周同步 MaxMind 的最新版本") {
|
||||
StatusBadge(text: geo.isAvailable ? "离线查询" : "在线查询", 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: "版本 \(database.build) · \(Format.bytes(UInt64(database.size), base: .decimal))") {
|
||||
Text(database.edition).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
GroupRow {
|
||||
SettingRow(title: "包含城市数据", subtitle: "显示城市名称,数据库约 60 MB;关闭时只下载国家库(约 9 MB)") {
|
||||
DSToggle(isOn: $settings.geoIncludeCity, label: "包含城市数据")
|
||||
}
|
||||
}
|
||||
GroupRow {
|
||||
SettingRow(title: "自动更新", subtitle: geo.lastChecked.map { "每 3 天检查一次 · 上次检查 \($0.formatted(date: .abbreviated, time: .shortened))" } ?? "每 3 天检查一次") {
|
||||
DSToggle(isOn: $settings.geoAutoUpdate, label: "自动更新")
|
||||
}
|
||||
}
|
||||
GroupRow {
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
Button(geo.isUpdating ? "正在更新…" : geo.isAvailable ? "立即检查更新" : "下载数据库") {
|
||||
Task { await geo.update() }
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.disabled(geo.isUpdating)
|
||||
Button("从文件导入…") { importFile() }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
if geo.isAvailable {
|
||||
Button("在访达中显示") { 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 "城市库" }
|
||||
if edition.hasSuffix("-Country") { return "国家库" }
|
||||
if edition.hasSuffix("-ASN") { return "ASN 库" }
|
||||
return edition
|
||||
}
|
||||
|
||||
private func importFile() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.init(filenameExtension: "mmdb") ?? .data]
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.message = "选择 GeoLite2 City / Country / ASN 的 .mmdb 文件"
|
||||
guard panel.runModal() == .OK else { return }
|
||||
for url in panel.urls { model.geo.importDatabase(from: url) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 散热与防休眠
|
||||
|
||||
private struct ThermalSettings: View {
|
||||
|
||||
@@ -16,6 +16,7 @@ public final class AppModel {
|
||||
public let cleaner: CleanerController
|
||||
public let maintenance: MaintenanceController
|
||||
public let network: NetworkController
|
||||
public let geo: GeoDatabaseController
|
||||
public let explainer = ProcessExplainer()
|
||||
@ObservationIgnored public let hub = MetricsHub()
|
||||
|
||||
@@ -39,7 +40,8 @@ public final class AppModel {
|
||||
keepAwake = KeepAwakeController(helper: helper, settings: settings)
|
||||
cleaner = CleanerController(settings: settings)
|
||||
maintenance = MaintenanceController(helper: helper)
|
||||
network = NetworkController(settings: settings)
|
||||
geo = GeoDatabaseController(settings: settings)
|
||||
network = NetworkController(settings: settings, geo: geo)
|
||||
}
|
||||
|
||||
/// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项
|
||||
|
||||
@@ -344,6 +344,14 @@ public final class AppSettings {
|
||||
public var publicIPLookup: Bool {
|
||||
didSet { defaults.set(publicIPLookup, forKey: Keys.publicIPLookup) }
|
||||
}
|
||||
/// 本地归属地库包含城市数据(GeoLite2-City,体积约为国家库的 7 倍)
|
||||
public var geoIncludeCity: Bool {
|
||||
didSet { defaults.set(geoIncludeCity, forKey: Keys.geoIncludeCity) }
|
||||
}
|
||||
/// 定期从官网检查归属地数据库更新
|
||||
public var geoAutoUpdate: Bool {
|
||||
didSet { defaults.set(geoAutoUpdate, forKey: Keys.geoAutoUpdate) }
|
||||
}
|
||||
/// 可再生的缓存也先移到废纸篓(可恢复,但不会立即释放空间)
|
||||
public var cleanPrefersTrash: Bool {
|
||||
didSet { defaults.set(cleanPrefersTrash, forKey: Keys.cleanPrefersTrash) }
|
||||
@@ -383,6 +391,8 @@ public final class AppSettings {
|
||||
? defaults.integer(forKey: Keys.probeSeconds) : 2
|
||||
probeTarget = defaults.string(forKey: Keys.probeTarget).flatMap(ProbeTarget.init(rawValue:)) ?? .cloudflare
|
||||
publicIPLookup = defaults.object(forKey: Keys.publicIPLookup) as? Bool ?? true
|
||||
geoIncludeCity = defaults.bool(forKey: Keys.geoIncludeCity)
|
||||
geoAutoUpdate = defaults.object(forKey: Keys.geoAutoUpdate) as? Bool ?? true
|
||||
}
|
||||
|
||||
/// 按固定顺序返回已启用的菜单栏项目
|
||||
@@ -430,5 +440,7 @@ public final class AppSettings {
|
||||
static let probeSeconds = "probeSeconds"
|
||||
static let probeTarget = "probeTarget"
|
||||
static let publicIPLookup = "publicIPLookup"
|
||||
static let geoIncludeCity = "geoIncludeCity"
|
||||
static let geoAutoUpdate = "geoAutoUpdate"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Metrics
|
||||
import Observation
|
||||
|
||||
/// 本地 IP 归属地数据库(MaxMind GeoLite2):从官网下载或手动导入,校验后放在应用支持目录,查询完全离线。
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class GeoDatabaseController {
|
||||
public struct Installed: Codable, Equatable {
|
||||
let edition: String
|
||||
let build: String
|
||||
let sha256: String
|
||||
let size: Int64
|
||||
}
|
||||
|
||||
public private(set) var installed: [String: Installed] = [:]
|
||||
public private(set) var isUpdating = false
|
||||
public private(set) var message: (text: String, isError: Bool)?
|
||||
public private(set) var lastChecked: Date?
|
||||
|
||||
@ObservationIgnored private let settings: AppSettings
|
||||
@ObservationIgnored private var locationDatabase: MaxMindDatabase?
|
||||
@ObservationIgnored private var asnDatabase: MaxMindDatabase?
|
||||
|
||||
static let manifestURL = URL(string: "https://getopenstats.com/geoip/manifest.json")!
|
||||
static let attribution = "本产品包含 MaxMind 创建的 GeoLite2 数据,可从 https://www.maxmind.com 获取。"
|
||||
private static let checkInterval: TimeInterval = 3 * 24 * 3600
|
||||
private static let lastCheckedKey = "geoDatabaseLastChecked"
|
||||
|
||||
let directory: URL = {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
return base.appendingPathComponent("OpenStats/GeoIP", isDirectory: true)
|
||||
}()
|
||||
private var indexURL: URL { directory.appendingPathComponent("installed.json") }
|
||||
|
||||
init(settings: AppSettings) {
|
||||
self.settings = settings
|
||||
lastChecked = UserDefaults.standard.object(forKey: Self.lastCheckedKey) as? Date
|
||||
reload()
|
||||
}
|
||||
|
||||
/// 至少有国家 / 城市库或 ASN 库之一
|
||||
var isAvailable: Bool { locationDatabase != nil || asnDatabase != nil }
|
||||
|
||||
func locate(_ address: String) -> GeoLocation? {
|
||||
GeoLookup.locate(address, location: locationDatabase, asn: asnDatabase)
|
||||
}
|
||||
|
||||
// MARK: 更新
|
||||
|
||||
func updateIfNeeded() async {
|
||||
guard settings.geoAutoUpdate else { return }
|
||||
if let lastChecked, Date().timeIntervalSince(lastChecked) < Self.checkInterval, isAvailable { return }
|
||||
await update()
|
||||
}
|
||||
|
||||
func update() async {
|
||||
guard !isUpdating else { return }
|
||||
isUpdating = true
|
||||
message = nil
|
||||
defer { isUpdating = false }
|
||||
|
||||
let wanted = ["GeoLite2-ASN", settings.geoIncludeCity ? "GeoLite2-City" : "GeoLite2-Country"]
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(from: Self.manifestURL)
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||
throw Failure("服务器上还没有数据库,稍后再试")
|
||||
}
|
||||
let manifest = try JSONDecoder().decode(Manifest.self, from: data)
|
||||
var changed = 0
|
||||
for edition in wanted {
|
||||
guard let entry = manifest.databases.first(where: { $0.edition == edition }) else {
|
||||
throw Failure("服务器清单里没有 \(edition)")
|
||||
}
|
||||
if installed[edition]?.sha256 == entry.sha256 { continue }
|
||||
try await download(entry)
|
||||
changed += 1
|
||||
}
|
||||
// 切换城市 / 国家库后删掉不再需要的那个
|
||||
for edition in installed.keys where !wanted.contains(edition) && edition.hasPrefix("GeoLite2-") {
|
||||
try? FileManager.default.removeItem(at: directory.appendingPathComponent("\(edition).mmdb"))
|
||||
installed[edition] = nil
|
||||
}
|
||||
try saveIndex()
|
||||
reload()
|
||||
lastChecked = Date()
|
||||
UserDefaults.standard.set(lastChecked, forKey: Self.lastCheckedKey)
|
||||
message = (changed > 0 ? "已更新 \(changed) 个数据库" : "已经是最新", false)
|
||||
} catch let failure as Failure {
|
||||
message = (failure.text, true)
|
||||
} catch {
|
||||
message = ("更新失败:\(error.localizedDescription)", true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载到临时文件,sha256 与清单一致、且能按库类型打开后才替换
|
||||
private func download(_ entry: Manifest.Entry) async throws {
|
||||
guard let url = URL(string: entry.file, relativeTo: Self.manifestURL) else { throw Failure("清单里的文件地址无效") }
|
||||
let (temporary, response) = try await URLSession.shared.download(from: url)
|
||||
defer { try? FileManager.default.removeItem(at: temporary) }
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw Failure("下载 \(entry.edition) 失败") }
|
||||
let digest = try await Task.detached { try Self.sha256(of: temporary) }.value
|
||||
guard digest == entry.sha256 else { throw Failure("\(entry.edition) 校验不一致,已丢弃") }
|
||||
let database = try MaxMindDatabase(url: temporary)
|
||||
guard database.databaseType == entry.edition else { throw Failure("\(entry.edition) 文件类型不对") }
|
||||
try install(temporary, edition: entry.edition, build: entry.build, sha256: digest, size: entry.size)
|
||||
}
|
||||
|
||||
/// 手动导入 .mmdb:按文件里记录的库类型识别是城市、国家还是 ASN 库
|
||||
func importDatabase(from url: URL) {
|
||||
do {
|
||||
let database = try MaxMindDatabase(url: url)
|
||||
let type = database.databaseType
|
||||
guard type.hasSuffix("-City") || type.hasSuffix("-Country") || type.hasSuffix("-ASN") else {
|
||||
throw Failure("不是城市、国家或 ASN 数据库(\(type))")
|
||||
}
|
||||
let digest = try Self.sha256(of: url)
|
||||
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map(Int64.init) ?? 0
|
||||
let build = database.buildDate.formatted(.iso8601.year().month().day().dateSeparator(.omitted))
|
||||
try install(url, edition: type, build: build, sha256: digest, size: size, copy: true)
|
||||
try saveIndex()
|
||||
reload()
|
||||
message = ("已导入 \(type)", false)
|
||||
} catch let failure as Failure {
|
||||
message = (failure.text, true)
|
||||
} catch {
|
||||
message = ("导入失败:\(error.localizedDescription)", true)
|
||||
}
|
||||
}
|
||||
|
||||
private func install(_ source: URL, edition: String, build: String, sha256: String, size: Int64, copy: Bool = false) throws {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let destination = directory.appendingPathComponent("\(edition).mmdb")
|
||||
let staging = directory.appendingPathComponent(".\(edition).mmdb.new")
|
||||
try? FileManager.default.removeItem(at: staging)
|
||||
if copy {
|
||||
try FileManager.default.copyItem(at: source, to: staging)
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: source, to: staging)
|
||||
}
|
||||
_ = try FileManager.default.replaceItemAt(destination, withItemAt: staging)
|
||||
installed[edition] = Installed(edition: edition, build: build, sha256: sha256, size: size)
|
||||
}
|
||||
|
||||
// MARK: 加载
|
||||
|
||||
private func reload() {
|
||||
if let data = try? Data(contentsOf: indexURL),
|
||||
let index = try? JSONDecoder().decode([String: Installed].self, from: data) {
|
||||
installed = index.filter { FileManager.default.fileExists(atPath: directory.appendingPathComponent("\($0.key).mmdb").path) }
|
||||
}
|
||||
func open(_ suffix: String) -> MaxMindDatabase? {
|
||||
installed.keys.sorted().filter { $0.hasSuffix(suffix) }
|
||||
.lazy.compactMap { try? MaxMindDatabase(url: self.directory.appendingPathComponent("\($0).mmdb")) }.first
|
||||
}
|
||||
locationDatabase = open("-City") ?? open("-Country")
|
||||
asnDatabase = open("-ASN")
|
||||
}
|
||||
|
||||
private func saveIndex() throws {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try JSONEncoder().encode(installed).write(to: indexURL, options: .atomic)
|
||||
}
|
||||
|
||||
nonisolated static func sha256(of url: URL) throws -> String {
|
||||
let handle = try FileHandle(forReadingFrom: url)
|
||||
defer { try? handle.close() }
|
||||
var hasher = SHA256()
|
||||
while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
|
||||
hasher.update(data: chunk)
|
||||
}
|
||||
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private struct Manifest: Decodable {
|
||||
struct Entry: Decodable {
|
||||
let edition: String
|
||||
let file: String
|
||||
let build: String
|
||||
let sha256: String
|
||||
let size: Int64
|
||||
}
|
||||
let databases: [Entry]
|
||||
}
|
||||
|
||||
private struct Failure: Error {
|
||||
let text: String
|
||||
init(_ text: String) { self.text = text }
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,11 @@ public final class NetworkController {
|
||||
@ObservationIgnored private var isPaused = false
|
||||
@ObservationIgnored private var wantsDetail = false
|
||||
|
||||
init(settings: AppSettings) {
|
||||
@ObservationIgnored private let geo: GeoDatabaseController
|
||||
|
||||
init(settings: AppSettings, geo: GeoDatabaseController) {
|
||||
self.settings = settings
|
||||
self.geo = geo
|
||||
}
|
||||
|
||||
// MARK: 统计
|
||||
@@ -133,7 +136,16 @@ public final class NetworkController {
|
||||
isLookingUpPublic = true
|
||||
let localIPv4 = details?.physical?.ipv4 ?? []
|
||||
Task {
|
||||
let result = await PublicAddressLookup.fetch()
|
||||
// 有本地 GeoLite2 时只向外查询公网 IP,归属地与 ASN 在本机查
|
||||
let local = geo.isAvailable
|
||||
var result = await PublicAddressLookup.fetch(includeGeo: !local)
|
||||
if local, let address = result.ipv4 ?? result.ipv6, let location = geo.locate(address) {
|
||||
result.countryCode = location.countryCode ?? result.countryCode
|
||||
result.city = location.city
|
||||
result.asn = location.asn
|
||||
result.organization = location.organization
|
||||
result.source = .localDatabase
|
||||
}
|
||||
publicAddresses = result
|
||||
lastPublicLookup = (Date(), localIPv4)
|
||||
isLookingUpPublic = false
|
||||
|
||||
@@ -224,3 +224,76 @@ struct LiveSamplerTests {
|
||||
#expect(counts.threads >= counts.processes)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct MaxMindDatabaseTests {
|
||||
/// 按 MaxMind DB 格式拼一个最小的 IPv4 库:只有一个节点,0.0.0.0/1 指向数据,128.0.0.0/1 没有数据
|
||||
private func makeDatabase() -> Data {
|
||||
func string(_ text: String) -> [UInt8] {
|
||||
let bytes = Array(text.utf8)
|
||||
// 长度 29 及以上时,控制字节写 29,后面跟一个字节存“长度 - 29”
|
||||
let header: [UInt8] = bytes.count < 29 ? [UInt8(2 << 5) | UInt8(bytes.count)] : [UInt8(2 << 5) | 29, UInt8(bytes.count - 29)]
|
||||
return header + bytes
|
||||
}
|
||||
func map(_ pairs: [(String, [UInt8])]) -> [UInt8] {
|
||||
[UInt8(7 << 5) | UInt8(pairs.count)] + pairs.flatMap { string($0.0) + $0.1 }
|
||||
}
|
||||
func uint32(_ value: UInt32) -> [UInt8] {
|
||||
[UInt8(6 << 5) | 4] + withUnsafeBytes(of: value.bigEndian) { Array($0) }
|
||||
}
|
||||
func uint16(_ value: UInt16) -> [UInt8] {
|
||||
[UInt8(5 << 5) | 2] + withUnsafeBytes(of: value.bigEndian) { Array($0) }
|
||||
}
|
||||
|
||||
// 数据段开头先放一个字符串,记录里用指针(类型 1,偏移 0)引用它
|
||||
let sharedName = string("中国")
|
||||
let pointerToName: [UInt8] = [0x20, 0x00]
|
||||
let record = map([
|
||||
("country", map([("iso_code", string("CN")), ("names", map([("zh-CN", pointerToName), ("en", string("China"))]))])),
|
||||
("autonomous_system_number", uint32(4134)),
|
||||
("autonomous_system_organization", string("CHINANET")),
|
||||
])
|
||||
let dataSection = sharedName + record
|
||||
let nodeCount = 1
|
||||
let recordValue = nodeCount + 16 + sharedName.count // 数据段中记录的偏移 + 节点数 + 16
|
||||
let tree: [UInt8] = [UInt8(recordValue >> 16), UInt8(recordValue >> 8 & 0xFF), UInt8(recordValue & 0xFF), 0, 0, 1]
|
||||
let metadata = map([
|
||||
("node_count", uint32(UInt32(nodeCount))),
|
||||
("record_size", uint16(24)),
|
||||
("ip_version", uint16(4)),
|
||||
("database_type", string("Test-City")),
|
||||
("build_epoch", uint32(1_789_000_000)),
|
||||
])
|
||||
let marker: [UInt8] = [0xAB, 0xCD, 0xEF] + Array("MaxMind.com".utf8)
|
||||
return Data(tree + [UInt8](repeating: 0, count: 16) + dataSection + marker + metadata)
|
||||
}
|
||||
|
||||
@Test func readsMetadata() throws {
|
||||
let database = try MaxMindDatabase(data: makeDatabase())
|
||||
#expect(database.databaseType == "Test-City")
|
||||
#expect(database.ipVersion == 4)
|
||||
#expect(database.buildDate == Date(timeIntervalSince1970: 1_789_000_000))
|
||||
}
|
||||
|
||||
@Test func looksUpAddressesThroughTheTree() throws {
|
||||
let database = try MaxMindDatabase(data: makeDatabase())
|
||||
let record = try #require(database.lookup("1.2.3.4"))
|
||||
#expect(record["country"]?["iso_code"]?.string == "CN")
|
||||
#expect(record["autonomous_system_number"]?.integer == 4134)
|
||||
#expect(database.lookup("200.1.1.1") == nil)
|
||||
#expect(database.lookup("not an ip") == nil)
|
||||
#expect(database.lookup("2001:db8::1") == nil) // IPv4 库不含 IPv6
|
||||
}
|
||||
|
||||
@Test func followsPointersAndPrefersChineseNames() throws {
|
||||
let database = try MaxMindDatabase(data: makeDatabase())
|
||||
let location = try #require(GeoLookup.locate("8.8.8.8", location: database, asn: database))
|
||||
#expect(location.countryName == "中国")
|
||||
#expect(location.countryCode == "CN")
|
||||
#expect(location.asn == "AS4134")
|
||||
#expect(location.organization == "CHINANET")
|
||||
}
|
||||
|
||||
@Test func rejectsFilesWithoutMetadata() {
|
||||
#expect(throws: MaxMindDatabase.Error.missingMetadata) { try MaxMindDatabase(data: Data(repeating: 0, count: 64)) }
|
||||
}
|
||||
}
|
||||
@@ -46,10 +46,10 @@ Developed and tested on Apple Silicon. The interface is in Simplified Chinese.
|
||||
<!-- changelog:start -->
|
||||
<!-- Generated from CHANGELOG.md by Scripts/sync_changelog.py. Do not edit by hand. -->
|
||||
|
||||
Not released yet · **44** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
|
||||
Not released yet · **45** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
|
||||
|
||||
<details open>
|
||||
<summary><b>2026-09-13</b> · Unreleased · 29 added · 9 style · 6 fixed</summary>
|
||||
<summary><b>2026-09-13</b> · Unreleased · 30 added · 9 style · 6 fixed</summary>
|
||||
|
||||
**Added**
|
||||
|
||||
@@ -65,6 +65,7 @@ Not released yet · **44** changes in development · [full changelog](CHANGELOG.
|
||||
- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。
|
||||
- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。
|
||||
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
|
||||
- IP 归属地改为本地 MaxMind GeoLite2 数据库查询:自己实现 .mmdb 读取器(不依赖第三方库),国家、城市与 ASN 完全离线查;数据库由官网服务器每周二、周五用 MaxMind 账号同步,应用每 3 天检查清单、下载并按 sha256 校验后替换,也可以从文件导入;设置中可选是否包含城市库(约 60 MB);数据库就绪前暂用 ipinfo.io 在线查询,网络详情里标明数据来源。
|
||||
- 本机信息页:机型图与名称、系统版本与版号、芯片与各类核心、内存与图形、存储用量、电池健康与循环次数、每台显示器的尺寸 / 原生分辨率 / 显示分辨率 / 刷新率、机型标识符、序列号(默认遮住,点眼睛图标显示,截图时始终遮住)与启动时间。
|
||||
- 进程管理器(主窗口“进程”页):包括 root 与其他用户的系统进程(通过系统自带的 ps 读取,不需要辅助工具);列出 CPU、CPU 时间、内存、线程、唤醒次数、磁盘读写与用户,点列标题排序;可搜索名称、PID 或用户,筛选全部 / 我的 / 系统,切换按进程或按应用合并;选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。
|
||||
- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。
|
||||
@@ -222,8 +223,10 @@ There is no analytics and no telemetry.
|
||||
OpenStats only touches the network for two optional features, both of which can be turned off in
|
||||
Settings → Network:
|
||||
|
||||
- **Public IP**: when you open network details, one request to `ipinfo.io` for your public address,
|
||||
region and ASN (IPv6 and fallback via Cloudflare `1.1.1.1` / ipify), cached for 10 minutes.
|
||||
- **Public IP**: when you open network details, one request to Cloudflare `1.1.1.1` (ipify as fallback)
|
||||
for your public address, cached for 10 minutes. Region and ASN are looked up on the Mac in a MaxMind
|
||||
GeoLite2 database downloaded from `getopenstats.com` and verified by sha256; until it is downloaded,
|
||||
`ipinfo.io` answers instead.
|
||||
- **Connection probe**: an ICMP ping to the target you pick (Cloudflare, Google, Alibaba Cloud,
|
||||
Tencent or your router) every 1, 2 or 5 seconds, only while the network item is in the menu bar
|
||||
or network details are open.
|
||||
|
||||
+4
-3
@@ -44,10 +44,10 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
|
||||
<!-- changelog:start -->
|
||||
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
|
||||
|
||||
尚未发布正式版本 · 开发中 **44** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
|
||||
尚未发布正式版本 · 开发中 **45** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
|
||||
|
||||
<details open>
|
||||
<summary><b>2026-09-13</b> · 未发布 · 新增 29 · 样式 9 · 修复 6</summary>
|
||||
<summary><b>2026-09-13</b> · 未发布 · 新增 30 · 样式 9 · 修复 6</summary>
|
||||
|
||||
**新增**
|
||||
|
||||
@@ -63,6 +63,7 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
|
||||
- CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。
|
||||
- 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。
|
||||
- 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。
|
||||
- IP 归属地改为本地 MaxMind GeoLite2 数据库查询:自己实现 .mmdb 读取器(不依赖第三方库),国家、城市与 ASN 完全离线查;数据库由官网服务器每周二、周五用 MaxMind 账号同步,应用每 3 天检查清单、下载并按 sha256 校验后替换,也可以从文件导入;设置中可选是否包含城市库(约 60 MB);数据库就绪前暂用 ipinfo.io 在线查询,网络详情里标明数据来源。
|
||||
- 本机信息页:机型图与名称、系统版本与版号、芯片与各类核心、内存与图形、存储用量、电池健康与循环次数、每台显示器的尺寸 / 原生分辨率 / 显示分辨率 / 刷新率、机型标识符、序列号(默认遮住,点眼睛图标显示,截图时始终遮住)与启动时间。
|
||||
- 进程管理器(主窗口“进程”页):包括 root 与其他用户的系统进程(通过系统自带的 ps 读取,不需要辅助工具);列出 CPU、CPU 时间、内存、线程、唤醒次数、磁盘读写与用户,点列标题排序;可搜索名称、PID 或用户,筛选全部 / 我的 / 系统,切换按进程或按应用合并;选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。
|
||||
- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。
|
||||
@@ -201,7 +202,7 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
|
||||
|
||||
只有两项可选功能会联网,都可以在“设置 → 网络”中关闭:
|
||||
|
||||
- **公网 IP**:打开网络详情时向 `ipinfo.io` 请求一次公网地址、归属地与 ASN(IPv6 与回退使用 Cloudflare `1.1.1.1` / ipify),10 分钟内不重复请求。
|
||||
- **公网 IP**:打开网络详情时向 Cloudflare `1.1.1.1`(回退 ipify)请求一次公网地址,10 分钟内不重复请求。归属地与 ASN 在本机的 MaxMind GeoLite2 数据库里查询;数据库从 `getopenstats.com` 下载并按 sha256 校验,下载前才临时改用 `ipinfo.io` 在线查询。
|
||||
- **连接探测**:每 1、2 或 5 秒向你选择的目标(Cloudflare、Google、阿里云、腾讯或路由器)发送一次 ICMP ping,只在菜单栏显示网络项或打开网络详情时运行。
|
||||
|
||||
用 Apple 智能解释进程完全在本机完成,进程信息不会离开这台 Mac。
|
||||
|
||||
@@ -24,7 +24,8 @@ echo "内容指纹 v=$STAMP"
|
||||
|
||||
# 目录归部署用户所有,Caddy 只需读取
|
||||
"${SSH[@]}" "$HOST" "sudo mkdir -p $ROOT && sudo chown -R \$(id -un):\$(id -gn) $ROOT"
|
||||
rsync -az --delete -e "${SSH[*]}" web/ "$HOST:$ROOT/"
|
||||
# geoip/ 由服务器上的定时任务生成,不在 web/ 里——排除掉,否则 --delete 会把数据库删了
|
||||
rsync -az --delete --exclude /geoip/ -e "${SSH[*]}" web/ "$HOST:$ROOT/"
|
||||
"${SSH[@]}" "$HOST" "sudo chmod -R a+rX $ROOT"
|
||||
echo "已同步到 $HOST:$ROOT"
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 第三方声明
|
||||
|
||||
## MaxMind GeoLite2
|
||||
|
||||
IP 归属地与 ASN 查询使用 MaxMind 的 GeoLite2 数据库(不随源码分发,由官网服务器定期同步后供应用下载)。
|
||||
|
||||
This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com.
|
||||
|
||||
`Packages/OpenStatsKit/Sources/Metrics/MaxMindDatabase.swift` 按 MaxMind DB 文件格式规范 2.0 自行实现,未使用第三方库。
|
||||
|
||||
## flag-icons
|
||||
|
||||
`Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/` 中的国旗 SVG 来自
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# 把 GeoLite2 更新脚本与定时任务装到服务器(不包含 License Key)。
|
||||
# ./server/geoip/install.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
HOST="${SITE_HOST:-debian@15.204.80.137}"
|
||||
KEY="${SITE_KEY:-$HOME/.ssh/gentpan.pem}"
|
||||
SSH=(ssh -i "$KEY" -o BatchMode=yes "$HOST")
|
||||
|
||||
scp -i "$KEY" -o BatchMode=yes openstats-geoip-update.sh openstats-geoip.service openstats-geoip.timer "$HOST:/tmp/"
|
||||
"${SSH[@]}" 'set -e
|
||||
sudo install -m 755 /tmp/openstats-geoip-update.sh /usr/local/bin/openstats-geoip-update
|
||||
sudo install -m 644 /tmp/openstats-geoip.service /tmp/openstats-geoip.timer /etc/systemd/system/
|
||||
sudo install -d -m 755 /var/www/getopenstats.com/geoip
|
||||
sudo install -d -m 700 /etc/openstats
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now openstats-geoip.timer
|
||||
rm -f /tmp/openstats-geoip-update.sh /tmp/openstats-geoip.service /tmp/openstats-geoip.timer
|
||||
systemctl list-timers openstats-geoip.timer --no-pager | head -3
|
||||
if sudo test -f /etc/openstats/maxmind.env; then echo "已找到 License Key 配置"; else echo "还没有 /etc/openstats/maxmind.env"; fi'
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# 在服务器上定时下载 MaxMind GeoLite2 数据库,放到官网 /geoip/ 下供 OpenStats 更新。
|
||||
#
|
||||
# 账号与 License Key 只放在 /etc/openstats/maxmind.env(root 所有,权限 600),
|
||||
# 不进仓库、不进应用。文件内容:
|
||||
# MAXMIND_ACCOUNT_ID=你的账号
|
||||
# MAXMIND_LICENSE_KEY=你的 Key
|
||||
#
|
||||
# 每个库先下载 tar.gz 与官方 sha256 校验,解出 .mmdb 后原子替换,最后重写 manifest.json。
|
||||
set -euo pipefail
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/etc/openstats/maxmind.env}"
|
||||
OUT="${OUT:-/var/www/getopenstats.com/geoip}"
|
||||
EDITIONS=(${EDITIONS:-GeoLite2-ASN GeoLite2-Country GeoLite2-City})
|
||||
|
||||
[ -r "$ENV_FILE" ] || { echo "缺少 $ENV_FILE" >&2; exit 1; }
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
: "${MAXMIND_ACCOUNT_ID:?}" "${MAXMIND_LICENSE_KEY:?}"
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
mkdir -p "$OUT"
|
||||
|
||||
fetch() {
|
||||
# 凭据通过 curl 配置从标准输入传入,不出现在进程参数里
|
||||
printf 'user = "%s:%s"\n' "$MAXMIND_ACCOUNT_ID" "$MAXMIND_LICENSE_KEY" \
|
||||
| curl --config - -fsSL --retry 3 -o "$2" "https://download.maxmind.com/geoip/databases/$1/download?suffix=$3"
|
||||
}
|
||||
|
||||
entries=()
|
||||
for edition in "${EDITIONS[@]}"; do
|
||||
fetch "$edition" "$WORK/$edition.tar.gz" "tar.gz"
|
||||
fetch "$edition" "$WORK/$edition.tar.gz.sha256" "tar.gz.sha256"
|
||||
expected="$(cut -d' ' -f1 "$WORK/$edition.tar.gz.sha256")"
|
||||
actual="$(sha256sum "$WORK/$edition.tar.gz" | cut -d' ' -f1)"
|
||||
[ "$expected" = "$actual" ] || { echo "$edition 校验失败" >&2; exit 1; }
|
||||
|
||||
tar -xzf "$WORK/$edition.tar.gz" -C "$WORK"
|
||||
folder="$(find "$WORK" -maxdepth 1 -type d -name "${edition}_*" | sort | tail -1)"
|
||||
build="${folder##*_}" # 目录名形如 GeoLite2-ASN_20260912
|
||||
install -m 644 "$folder/$edition.mmdb" "$OUT/.$edition.mmdb.new"
|
||||
mv -f "$OUT/.$edition.mmdb.new" "$OUT/$edition.mmdb"
|
||||
|
||||
sha="$(sha256sum "$OUT/$edition.mmdb" | cut -d' ' -f1)"
|
||||
size="$(stat -c %s "$OUT/$edition.mmdb")"
|
||||
entries+=("{\"edition\":\"$edition\",\"file\":\"$edition.mmdb\",\"build\":\"$build\",\"sha256\":\"$sha\",\"size\":$size}")
|
||||
echo "$edition $build $size 字节"
|
||||
done
|
||||
|
||||
joined="$(IFS=,; echo "${entries[*]}")"
|
||||
printf '{"generated":"%s","attribution":"This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com.","databases":[%s]}\n' \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$joined" > "$OUT/.manifest.json.new"
|
||||
mv -f "$OUT/.manifest.json.new" "$OUT/manifest.json"
|
||||
chmod 644 "$OUT/manifest.json"
|
||||
echo "manifest.json 已更新"
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=下载 MaxMind GeoLite2 数据库供 OpenStats 更新
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/openstats-geoip-update
|
||||
# 只需要写官网的 geoip 目录
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/www/getopenstats.com/geoip /tmp
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=每周二、周五更新 GeoLite2(MaxMind 在这两天发布新数据)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=Tue,Fri *-*-* 08:00:00 UTC
|
||||
RandomizedDelaySec=30m
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
+5
-4
@@ -315,7 +315,8 @@
|
||||
<li><b>指标来源</b><span>内核接口(host_processor_info、host_statistics64、sysctl)、IOKit 与 SMC</span></li>
|
||||
<li><b>账号</b><span>不需要,也没有</span></li>
|
||||
<li><b>统计上报</b><span>没有</span></li>
|
||||
<li><b>公网 IP</b><span>打开网络详情时向 ipinfo.io 查询一次地址、归属地与 ASN,10 分钟内不重复,可关闭</span></li>
|
||||
<li><b>公网 IP</b><span>打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可关闭</span></li>
|
||||
<li><b>归属地</b><span>国家、城市与 ASN 在本机的 MaxMind GeoLite2 数据库里查,数据库从本站下载并校验</span></li>
|
||||
<li><b>连接探测</b><span>按设定间隔 ping 你选择的目标,只在显示网络项时运行,可关闭</span></li>
|
||||
<li><b>偏好设置</b><span>保存在本机,不含个人信息</span></li>
|
||||
<li><b>源代码</b><span><a href="https://github.com/gentpan/OpenStats">MIT 开源,全部公开</a></span></li>
|
||||
@@ -347,7 +348,7 @@
|
||||
<div class="log">
|
||||
<article class="log__day">
|
||||
<div class="log__head"><time datetime="2026-09-13">2026-09-13</time><span class="log__tag">未发布</span></div>
|
||||
<p class="log__counts">新增 29 · 样式 9 · 修复 6</p>
|
||||
<p class="log__counts">新增 30 · 样式 9 · 修复 6</p>
|
||||
<ul class="log__list">
|
||||
<li><span class="log__kind">新增</span>菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。</li>
|
||||
<li><span class="log__kind">新增</span>菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。</li>
|
||||
@@ -358,7 +359,7 @@
|
||||
<li><span class="log__kind">新增</span>网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。</li>
|
||||
<li><span class="log__kind">新增</span>DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。</li>
|
||||
</ul>
|
||||
<a class="log__more" href="https://github.com/gentpan/OpenStats/blob/main/CHANGELOG.md">还有 36 项 →</a>
|
||||
<a class="log__more" href="https://github.com/gentpan/OpenStats/blob/main/CHANGELOG.md">还有 37 项 →</a>
|
||||
</article>
|
||||
</div>
|
||||
<!-- changelog:end -->
|
||||
@@ -379,7 +380,7 @@
|
||||
</details>
|
||||
<details class="faq">
|
||||
<summary>会联网吗?</summary>
|
||||
<p>只有两项可关闭的功能会:打开网络详情时向 ipinfo.io 查询一次公网地址、归属地与 ASN(10 分钟内不重复);连接探测按你设定的间隔 ping 你选择的目标。没有账号,没有统计上报。</p>
|
||||
<p>只有几项可关闭的功能会:打开网络详情时向 Cloudflare 查询一次公网地址(10 分钟内不重复);连接探测按你设定的间隔 ping 你选择的目标;每 3 天从本站检查 IP 归属地数据库的更新。归属地与 ASN 在本机查询。没有账号,没有统计上报。</p>
|
||||
</details>
|
||||
<details class="faq">
|
||||
<summary>为什么不上 App Store?</summary>
|
||||
|
||||
Reference in new issue
Block a user