归属地只用 cleanip.io:移除 MaxMind GeoLite2 与其他在线数据源

- 删掉 MaxMind .mmdb 读取器、本地数据库控制器与「IP 归属地数据库」设置区块,去掉「归属地数据源」选择及 ipapi.is / DB-IP / ipinfo.io 解析;PublicAddressLookup 只向 cleanip.io 查询,结果按 IP 记一小时,收到 429 当天不再请求
- 设置同步文档不再带 geoSource / geoIncludeCity / geoAutoUpdate;公网 IP 缓存键升到第 4 版,旧数据源的缓存不再读取
- 网络详情:IP 地址区块标题不再放 cleanip.io 字标,只留在纯净度区块;纯净度色带改为直角
- 删除 server/geoip 的 GeoLite2 同步脚本;README、架构说明、第三方声明、官网与隐私页、译文同步,清掉 53 条不再使用的翻译
This commit is contained in:
gentpan committed 2026-09-15 04:24:42 +05:00
1 parent 51b954983b
commit b8db7eacae
29 files changed
+108 -1221

No files matched your search

+4 -6
View File
@@ -157,12 +157,10 @@ 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** — 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 ipapi.is (anonymous, called directly from each Mac; 30 lookups per client IP per day, so results are cached per IP for 24 h and a 429 stops further calls until the next UTC day). Country
- **Public IP** — Cloudflare trace (ipify as fallback) for the address only. Location, ASN, network
type, native / broadcast and the cleanliness score come from `cleanip.io`, queried directly from each
Mac by `PublicAddressLookup` (one request per address family; results are cached per IP for an hour
in memory and for a week on disk, and a 429 stops further calls until the next UTC day). 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
+2
View File
@@ -24,6 +24,8 @@
#### 调整
- 公网 IP 的归属地只用 cleanip.io:去掉「设置 · 网络」里的归属地数据源选择,ipapi.is、DB-IP、ipinfo.io 三个在线数据源与本地 MaxMind GeoLite2 数据库(含下载、导入、自动更新的整个“IP 归属地数据库”设置区块)一并移除;归属地、ASN、网络类型与纯净度都由这台 Mac 直接向 cleanip.io 查询,只发送公网地址。以前缓存的其他数据源结果不再读取,打开网络详情时会重新查一次。
- 网络详情的 IP 地址区块标题不再放 cleanip.io 字标,字标只留在 IP 纯净度区块里;纯净度色带改为直角,去掉两端的圆角。
- 「设置 · 菜单栏」每个显示项目下面多了一行“菜单栏风格”:给这一项单独选一种风格(默认跟随整体,菜单里标出整体现在是哪种),旁边是这一项按当前风格、实时读数画出的预览,改了立刻能看到。以前这个选择藏在开关旁一个没有说明的下拉里,不容易发现。温度、风扇只提供文字类的三种风格;原“风格”分组改名“整体风格”并加了说明。
- 菜单栏弹窗右上角的两个按钮:左边改为该指标自己的图标(网络是网络图标、磁盘是硬盘),点了直接进主窗口里这个指标的页面;右边的齿轮进总设置。
- 网络详情的 IP 地址区块精简:原生 / 广播、住宅 / 机房、运营商类型(ISP 等)改为 cleanip.io 样式的徽章,去掉反向解析、网络类型、住宅概率、数据来源四行;数据来源改为标题后的小标记,CleanIP.io 显示它的字标:与标题文字同高、不留上下空白,点了打开 cleanip.io。徽章样式作为通用组件收进设计系统。
@@ -25,7 +25,6 @@ extension Translations {
5 小时 5 hours
60 秒峰值 Peak
7 天 7 days
ASN 库 ASN database
Apple 智能暂时不可用。 Apple Intelligence is temporarily unavailable.
Apple 智能模型还在下载或准备中,请稍后再试。 The Apple Intelligence model is still downloading or preparing. Try again later.
Apple 智能没有给出回答:{} Apple Intelligence didn't answer: {}
@@ -59,7 +58,6 @@ DerivedData,下次编译时自动重建 DerivedData; rebuilt on the next build
GPU 占用 GPU Usage
Google(8.8.8.8) Google (8.8.8.8)
IP 地址 IP Addresses
IP 归属地数据库 IP Location Database
IP 类型 IP type
IP 纯净度 IP cleanliness
OpenStats {} 已发布 OpenStats {} is available
@@ -86,8 +84,6 @@ VPN / 代理 VPN / Proxy
VPN 隧道 VPN Tunnel
Xcode 归档 Xcode Archives
Xcode 编译缓存 Xcode Build Cache
ipapi.is 在线查询 ipapi.is online lookup
ipinfo.io 在线查询 ipinfo.io (online)
macOS 系统目录(系统组件) macOS system folder (system component)
npm 缓存 npm Cache
{} {} 英寸 {} {}-inch
@@ -102,13 +98,10 @@ npm 缓存 npm Cache
{} 个进程 {} processes
{} 分钟 {} min
{} 可用 {} · 共 {} {} {} free · {} total
{} 在线查询 {} online lookup
{} 天 {} d
{} 天 {} 小时 {} d {} h
{} 小时 {} h
{} 小时 {} 分钟 {} h {} min
{} 文件类型不对 {} has the wrong file type
{} 校验不一致,已丢弃 {} failed the checksum and was discarded
{} 核 {} cores
{} 核图形处理器 {}-core GPU
{} 正在运行 {} is running
@@ -151,17 +144,13 @@ npm 缓存 npm Cache
上次检查正常 Last check was fine
上次检查:{} Last checked: {}
下载 Download
下载 {} 失败 Failed to download {}
下载后归属地查询完全离线;数据库由 OpenStats 官网每周同步 MaxMind 的最新版本 Once downloaded, lookups are fully offline. The database is synced weekly from MaxMind by the OpenStats website.
下载失败:{} Download failed: {}
下载数据库 Download Database
下载目录中的 .dmg / .pkg / .xip / .iso,移到废纸篓 .dmg / .pkg / .xip / .iso files in Downloads; moved to Trash
不可用 Unavailable
不在允许清理的目录内 Not inside an allowed cleanup folder
不在可卸载的位置:{} Not in a location that can be uninstalled: {}
不提供电量 No battery info
不是团队 {} 签名的完整应用({}) Not a complete app signed by team {} ({})
不是城市、国家或 ASN 数据库({}) Not a City, Country or ASN database ({})
不是应用程序 Not an application
不是绝对路径 Not an absolute path
不能删除清理目录本身 Can't delete the cleanup folder itself
@@ -181,7 +170,6 @@ npm 缓存 npm Cache
仅系统不休眠 Keep system awake
介质错误 Media errors
从左侧选择应用,或把应用拖到这里 Choose an app on the left, or drop one here
从文件导入… Import from File…
从未 Never
代理 Proxy
以单核满载为 100% 100% = one full core
@@ -222,7 +210,6 @@ npm 缓存 npm Cache
健康 {} Health {}
健康度 Health
健康度是现在充满时的容量与出厂容量之比,低于 80% 时苹果建议更换电池 Health is today's full charge capacity versus the design capacity; Apple recommends a replacement below 80%
元数据不是字典 Metadata is not a dictionary
充电上限可在系统设置中设为 80%–100%,长期接电源时有助于延缓电池老化 Set a charge limit of 80%–100% in System Settings to slow battery aging when you stay plugged in
充电中 Charging
充电盒 Case
@@ -336,7 +323,6 @@ npm 缓存 npm Cache
只在菜单栏显示温度、开启过热通知或打开温度页面时记录 Recorded only when temperature is in the menu bar, overheating alerts are on, or a temperature page is open
只在菜单栏显示电池或打开电池页面时记录 Recorded only while Battery is in the menu bar or the Battery page is open
只显示菜单栏时的采样间隔;打开弹窗或主窗口时为 1 秒(进程页 2 秒) Sampling interval when only the menu bar is shown; 1 second with a popover or window open (2 seconds on Processes)
只有国家、省 / 州与城市;把公网 IP 发给 db-ip.com Country, state and city only; sends your public IP to db-ip.com
只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动。程序坞里的图标会一并移除 Only files named after the app's bundle ID, plus folders with the app's name in Application Support and Logs, are included. Keychains and data shared with other apps are never touched. The Dock icon is removed as well
可以恢复,但清空废纸篓前不会释放空间 Recoverable, but space isn't freed until the Trash is emptied
可执行文件:{} Executable: {}
@@ -364,7 +350,7 @@ npm 缓存 npm Cache
合盖运行需要修改系统睡眠设置,需安装辅助工具(管理员授权一次)。 Lid mode changes system sleep settings and needs the helper (one-time admin approval).
同步 Sync
同步失败 Sync failed
同步的内容:菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限。当前页面、辅助工具、历史记录与归属地库留在本机。 What syncs: menu bar items and styles, refresh rate, appearance and language, popover sections, connection probe, notifications, hotkeys, fan safety temperature and lid-mode battery floor. The current page, the helper, history and the GeoIP database stay on this Mac.
同步的内容:菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限。当前页面、辅助工具与历史记录留在本机。 What syncs: menu bar items and styles, refresh rate, appearance and language, popover sections, connection probe, notifications, hotkeys, fan safety temperature and lid-mode battery floor. The current page, the helper and history stay on this Mac.
后台低频探测 Background Probing
启动于 Booted
启动时和之后每天检查一次,发现新版本时显示更新摘要 Checks at launch and daily, and shows what's new when an update is found
@@ -378,8 +364,6 @@ npm 缓存 npm Cache
唤醒 Wakeups
团队 {} team {}
国内网络建议选阿里云或腾讯;选路由器只检测本地连接 In mainland China, Alibaba Cloud or Tencent work best; Router only checks the local link
国家、城市与 ASN 在本机查询,不经过任何在线服务 Country, city and ASN are looked up on this Mac without any online service
国家库 Country database
图形 Graphics
图形处理器 GPU
图形核心 GPU cores
@@ -388,15 +372,12 @@ npm 缓存 npm Cache
圆环表示当前占用比例 A ring shows current usage
在 cleanip.io 查看这个 IP 的完整报告 Open this IP's full report on cleanip.io
在主窗口打开“{}” Open “{}” in the main window
在本机的 MaxMind 数据库里查国家、城市与 ASN,不向任何服务发送 IP;数据库未就绪时暂用 CleanIP.io Looks up country, city and ASN in the local MaxMind database and sends your IP nowhere; uses CleanIP.io until the database is ready
在线升级 Online Updates
在线升级:发现新版本时显示更新摘要,一键安装并自动重启 Online updates: see what's new, install in one click and relaunch automatically
在线查询 Online lookup
在菜单栏显示 Show in Menu Bar
在菜单栏显示{} Show {} in menu bar
在访达中显示 Show in Finder
型号 Model
城市库 City database
处理器 Processor
处理器核心 CPU cores
备用空间 Available spare
@@ -418,7 +399,6 @@ npm 缓存 npm Cache
定时探测网络 Connectivity Probe
家目录 Home folder
容量 Capacity
导入失败:{} Import failed: {}
导出… Export…
导出失败:{} Export failed: {}
导出时间 Exported at
@@ -428,7 +408,6 @@ npm 缓存 npm Cache
将清理 {} 个项目,共 {}。{} {} items, {} total, will be cleaned. {}
将移到废纸篓 Will move to Trash
小标签在上、数值在下,最紧凑 Small label above the value; most compact
尚未下载 Not downloaded
尚未检查 Not checked yet
屏幕保持常亮 Keep display on
屏幕可按设置关闭,下载、编译等后台任务继续运行 The display can turn off as usual while downloads and builds keep running
@@ -448,7 +427,6 @@ npm 缓存 npm Cache
已取消 Cancelled
已取消登录 Sign-in cancelled
已启用 Enabled
已导入 {} Imported {}
已导出 {} Exported {}
已将 {} 与 {} 项残留移到废纸篓,约 {}{}{}。需要时可以在废纸篓里放回。 Moved {} and {} leftovers to the Trash, about {}{}{}. You can put them back from the Trash.
已开启同步 Sync is on
@@ -458,11 +436,9 @@ npm 缓存 npm Cache
已拷贝 Copied
已推出“{}” Ejected “{}”
已是最新版本 Up to date
已更新 {} 个数据库 Updated {} databases
已用 Used
已用 {} · {} {} used · {}
已用内存占比 Memory used
已经是最新 Already up to date
已记录的数据会从本机删除,无法恢复。 Recorded data will be deleted from this Mac and can't be recovered.
已运行 Uptime
已运行 Up
@@ -514,11 +490,6 @@ npm 缓存 npm Cache
强制刷新:忽略缓存,立即重新查询公网 IP 与归属地 Force refresh: ignore the cache and look up the public IP and location again now
强制退出 Force Quit
归属地 Location
归属地、ASN、网络类型、原生 / 广播、纯净度与风险评分,中文地名;把公网 IP 发给 cleanip.io Location, ASN, network type, native / broadcast, cleanliness and risk scores; sends your public IP to cleanip.io
归属地与 ASN;把公网 IP 发给 ipapi.is,匿名额度每天 30 次,同一 IP 的结果记住一天 Location and ASN; sends your public IP to ipapi.is. Anonymous quota is 30 a day, so a result is kept for a day
归属地与 ASN;把公网 IP 发给 ipinfo.io,匿名额度很小,容易被限流 Location and ASN; sends your public IP to ipinfo.io. The anonymous quota is tiny and easily exhausted
归属地数据库 Location database
归属地数据源 Location data source
当前 {} · 发布于 {} · {} · {} Current {} · Released {} · {} · {}
当前为临时签名的开发构建,辅助工具只能校验应用标识。使用 Developer ID 证书构建后会自动启用团队校验。 This is an ad-hoc signed development build, so the helper can only check the bundle ID. Team verification turns on automatically with a Developer ID build.
当前效果(实时数据) Preview (live data)
@@ -552,9 +523,7 @@ npm 缓存 npm Cache
打开 SMC 失败 ({}) Failed to open SMC ({})
打开登录项设置 Open Login Items Settings
打开网络详情时使用 Used while network details are open
打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址,10 分钟内不重复请求 Asks Cloudflare (1.1.1.1) or ipify for the public address once when network details open, at most every 10 minutes
打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址;归属地与 ASN 在本机数据库里查,10 分钟内不重复请求 When network details open, asks Cloudflare (1.1.1.1) or ipify for your public address; location and ASN come from the local database. At most once every 10 minutes.
打开网络详情时查询公网地址;本地数据库还没准备好时,归属地与 ASN 暂由 ipinfo.io 在线查询 Looks up your public address when network details open; until the local database is ready, location and ASN come from ipinfo.io
打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址,10 分钟内不重复请求;归属地、ASN、网络类型与纯净度由这台 Mac 直接向 cleanip.io 查询,只发送公网地址 When network details open, asks Cloudflare (1.1.1.1) or ipify for your public address, at most once every 10 minutes; location, ASN, network type and cleanliness are looked up by this Mac directly at cleanip.io, sending only the public address
打开进程页 Show Processes
打开通知设置 Open Notification Settings
托管 Hosting
@@ -594,8 +563,6 @@ npm 缓存 npm Cache
散热模式 Cooling
数据 Data
数据来源 Source
数据来源:cleanip.io Data source: cleanip.io
数据来源:cleanip.io,点击打开官网 Data source: cleanip.io. Click to open the site
整体风格 Overall style
整体风格套用到所有项目。想让某个项目不一样,在下面“显示项目”里给它单独选一种,比如 CPU 用圆环、风扇用数字。 The overall style applies to every item. To make one item different, pick a style for it under Items below, say a ring for CPU and plain numbers for the fan.
整机 System
@@ -625,12 +592,10 @@ npm 缓存 npm Cache
显示为 {}×{} Looks like {}×{}
显示名称:{} Display name: {}
显示器 Displays
显示城市名称,数据库约 60 MB;关闭时只下载国家库(约 9 MB) Shows city names; the database is about 60 MB. When off, only the country database (about 9 MB) is downloaded.
显示序列号 Show Serial Number
显示项目 Items
暂时没有拿到评分,稍后会再试 No score yet; it will try again later
更新内容 What's New
更新失败:{} Update failed: {}
更省电,负责后台和轻量任务 More efficient; handles background and light work
替换后未找到应用 App not found after replacement
替换应用失败:{} Failed to replace the app: {}
@@ -648,9 +613,7 @@ npm 缓存 npm Cache
最高 Max
最高 / 平均 Max / Avg
最高温度 Max temperature
服务器上还没有数据库,稍后再试 The server doesn't have the database yet. Try again later.
服务器在 getopenstats.com,不上传任何监控数据;随时可以删除。 The server is at getopenstats.com; no monitoring data is uploaded, and you can delete everything at any time.
服务器清单里没有 {} {} is not in the server manifest
服务器返回 {} Server returned {}
服务器返回的数据格式不正确 The server returned malformed data
未使用 Unused
@@ -667,12 +630,8 @@ npm 缓存 npm Cache
未签名(开发构建) Unsigned (development build)
未连接 Not connected
未连接网络 No network
本产品包含 MaxMind 创建的 GeoLite2 数据,可从 https://www.maxmind.com 获取。 This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com.
本地 GeoLite2 数据库 Local GeoLite2 database
本地 GeoLite2(MaxMind) Local GeoLite2 (MaxMind)
本地 IPv4 Local IPv4
本地 IPv6 Local IPv6
本地 MaxMind GeoLite2 Local MaxMind GeoLite2
本地快照 Local snapshots
本机信息 This Mac
机型 Model
@@ -722,7 +681,6 @@ npm 缓存 npm Cache
正在导出… Exporting…
正在扫描应用… Scanning apps…
正在收集走势… Collecting trend…
正在更新… Updating…
正在本机生成… Generating on this Mac…
正在查找残留… Finding leftovers…
正在查询… Looking up…
@@ -744,8 +702,6 @@ npm 缓存 npm Cache
正常 Normal
此刻各核心的占用;超过 60% 变橙色、85% 变红色 Current load of each core; orange above 60%, red above 85%
此设备没有可调节的风扇 This Mac has no adjustable fans
每 3 天检查一次 Checks every 3 days
每 3 天检查一次 · 上次检查 {} Checks every 3 days · last checked {}
每个应用按一个核心满载为 100% 计算,与活动监视器一致,所以各应用相加可以超过顶部的总占用 Each app is measured with one full core as 100%, like Activity Monitor, so the apps can add up to more than the total above
每个指标一个图标,点击弹出该项详情 One icon per metric; click for its details
每分钟把主要指标的平均值与峰值写入本机数据库,保留 7 天,不上传 Writes averages and peaks of key metrics to a local database every minute. Kept for 7 days, never uploaded.
@@ -773,7 +729,6 @@ npm 缓存 npm Cache
浏览器 Browsers
深色 Dark
深色菜单栏预览 Dark menu bar preview
清单里的文件地址无效 Invalid file URL in the manifest
清理 Cleanup
清理中… Cleaning…
清理可回收的缓存内存(需要管理员授权或辅助工具) Frees reclaimable cached memory (needs admin approval or the helper)
@@ -794,7 +749,6 @@ npm 缓存 npm Cache
点击拷贝 Click to copy
版本 Version
版本 {} Version {}
版本 {} · {} Version {} · {}
版本 {}{} Version {}{}
版本是 {},清单写的是 {} Version is {}, but the manifest says {}
版本清单格式不正确 Invalid version manifest
@@ -855,7 +809,6 @@ npm 缓存 npm Cache
磁盘清理 Disk Cleanup
磁盘空间不足 Low Disk Space
磁盘空间偏紧 Disk space is tight
离线查询 Offline lookup
移到废纸篓 Move to Trash
移到废纸篓? Move to the Trash?
移动网络 Mobile
@@ -869,7 +822,6 @@ npm 缓存 npm Cache
窗口状态 Saved State
立即切换;显示器、应用名称等由系统提供的文字在下次启动时切换 Applies right away; names supplied by macOS, such as displays and apps, switch at next launch
立即同步 Sync now
立即检查更新 Check Now
竖向电量条表示当前占用比例 A vertical meter shows current usage
等待批准 Waiting for Approval
等待选择 Waiting for your choice
@@ -893,7 +845,6 @@ npm 缓存 npm Cache
繁忙 Busy
纯净度 {} 分 Cleanliness score {}
纯净度看信誉、来路、邻居与网络类型四项;机房 IP 常见信誉高、来路和类型偏低。数据来自 cleanip.io Cleanliness weighs reputation, origin, neighbors and network type; data-center IPs often score high on reputation but low on origin and type. Data from cleanip.io
纯净度评分来自 CleanIP.io。在「设置 · 网络」把归属地数据源改为 CleanIP.io 后显示。 Cleanliness scores come from CleanIP.io. Choose it as the location data source in Settings › Network to show them.
线程 Threads
线程 {} Threads {}
线程数(只能读取自己的进程) Thread count (your processes only)
@@ -909,7 +860,6 @@ npm 缓存 npm Cache
缓存与日志直接删除,下载内容移到废纸篓。 Caches and logs are deleted; downloads go to the Trash.
缓存也先移到废纸篓 Move caches to Trash too
编辑 Edit
缺少 node_count / record_size / ip_version Missing node_count / record_size / ip_version
网络 Network
网络名称 Network name
网络已恢复 Network Restored
@@ -929,7 +879,6 @@ npm 缓存 npm Cache
腾讯(119.29.29.29) Tencent (119.29.29.29)
自 {} 起 Since {}
自动 Auto
自动更新 Auto Update
自动检查更新 Check Automatically
自动(由路由器分配) Automatic (from router)
自定义 Custom
@@ -1029,7 +978,6 @@ npm 缓存 npm Cache
退出应用… Quit App…
退出登录 Sign out
适中 Moderate
选择 GeoLite2 City / Country / ASN 的 .mmdb 文件 Choose a GeoLite2 City / Country / ASN .mmdb file
通用 General
通电时间 Power-on hours
通电次数 Power cycles
@@ -1,296 +0,0 @@
import Darwin
import Foundation
import Localization
/// 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(tr("元数据不是字典"))
}
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(tr("缺少 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 L10n.isEnglish ? names["en"]?.string : names["zh-CN"]?.string ?? names["en"]?.string
}
}
@@ -1,48 +1,6 @@
import Foundation
/// 在线归属地数据源。都由每台 Mac 直接查询自己的公网 IP,不经过我们的服务器
public enum GeoProvider: String, CaseIterable, Sendable, Codable {
/// cleanip.io:归属地、ASN、网络类型、原生 / 广播、纯净度与风险评分,中文地名
case cleanIP
/// ipapi.is 匿名接口:归属地与 ASN,按客户端 IP 每天 30 次
case ipapi
/// DB-IP 免费接口:只有国家、省 / 州与城市
case dbip
/// ipinfo.io 匿名接口:归属地与 ASN,匿名额度很小
case ipinfo
public var displayName: String {
switch self {
case .cleanIP: "CleanIP.io"
case .ipapi: "ipapi.is"
case .dbip: "DB-IP"
case .ipinfo: "ipinfo.io"
}
}
/// 同一个 IP 的结果保留多久
var cacheLifetime: TimeInterval {
self == .cleanIP ? 60 * 60 : 24 * 60 * 60
}
func url(for ip: String) -> URL? {
var components: URLComponents?
switch self {
case .cleanIP:
components = URLComponents(string: "https://cleanip.io/cli")
components?.queryItems = [URLQueryItem(name: "ip", value: ip), URLQueryItem(name: "json", value: "1")]
case .ipapi:
components = URLComponents(string: "https://api.ipapi.is/")
components?.queryItems = [URLQueryItem(name: "q", value: ip)]
case .dbip:
components = URLComponents(string: "https://api.db-ip.com/v2/free/\(ip)")
case .ipinfo:
components = URLComponents(string: "https://ipinfo.io/\(ip)/json")
}
return components?.url
}
}
/// 公网地址与 cleanip.io 给出的归属地、网络类型与纯净度
public struct PublicAddresses: Sendable, Equatable, Codable {
public var ipv4: String?
public var ipv6: String?
@@ -51,14 +9,13 @@ public struct PublicAddresses: Sendable, Equatable, Codable {
public var city: String?
/// 省 / 州,例如 “New York”
public var region: String?
/// 数据源给的是中文地名时,这里是英文,供英文界面使用
/// cleanip.io 给的是中文地名,这里是英文,供英文界面使用
public var cityEnglish: String?
public var regionEnglish: String?
/// 自治系统号,例如 “AS4134”
public var asn: String?
/// 网络所属组织(英文),例如 “CHINANET-BACKBONE”
public var organization: String?
/// 以下只有 CleanIP.io 提供
public var hostname: String?
/// 网络类型:Residential / Business / Hosting / Mobile …
public var networkType: String?
@@ -74,7 +31,6 @@ public struct PublicAddresses: Sendable, Equatable, Codable {
public var purity: Purity?
public var risk: Risk?
public var reportURL: URL?
public var source: Source = .online(.cleanIP)
public struct Purity: Sendable, Equatable, Codable {
public var score: Int
@@ -101,12 +57,6 @@ public struct PublicAddresses: Sendable, Equatable, Codable {
}
}
public enum Source: Sendable, Equatable, Codable {
/// 本机的 MaxMind GeoLite2 数据库
case localDatabase
case online(GeoProvider)
}
public init(ipv4: String? = nil, ipv6: String? = nil, countryCode: String? = nil,
city: String? = nil, region: String? = nil, asn: String? = nil, organization: String? = nil) {
self.ipv4 = ipv4
@@ -118,7 +68,7 @@ public struct PublicAddresses: Sendable, Equatable, Codable {
self.organization = organization
}
/// 把在线查到的归属地信息并进来;国家代码以 Cloudflare 给的为准,没有时才用数据源的
/// 把 cleanip.io 查到的信息并进来;国家代码以 Cloudflare 给的为准,没有时才用 cleanip.io 的
public mutating func apply(_ geo: PublicAddressLookup.GeoInfo) {
city = geo.city
region = geo.region
@@ -142,8 +92,8 @@ public struct PublicAddresses: Sendable, Equatable, Codable {
/// 查询公网 IP 与归属地。只在用户打开网络详情时请求,不携带任何本机信息。
/// 公网地址来自 Cloudflare trace(直连 IP,不依赖 DNS,同时给出国家代码),回退 ipify;
/// 归属地等信息按用户选择的数据源在线查询,由每台 Mac 自己直接查。这些接口按客户端 IP 限额,
/// 所以同一个公网 IP 的结果会记住一段时间,收到 429 后到当天(UTC)结束都不再请求
/// 归属地、ASN、网络类型与纯净度由每台 Mac 自己直接向 cleanip.io 查,不经过我们的服务器。
/// 接口按客户端 IP 限额,所以同一个公网 IP 的结果会记住一小时,收到 429 后到当天(UTC)结束都不再请求
public enum PublicAddressLookup {
private static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
@@ -158,24 +108,23 @@ public enum PublicAddressLookup {
private static let geoCache = GeoCache()
/// `includeGeo` 为假时只查询公网 IP,不请求任何归属地服务
public static func fetch(includeGeo: Bool = true, provider: GeoProvider = .cleanIP) async -> PublicAddresses {
/// `includeGeo` 为假时只查询公网 IP,不请求 cleanip.io
public static func fetch(includeGeo: Bool = true) async -> PublicAddresses {
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)
var result = PublicAddresses(ipv4: ipv4.ip, ipv6: ipv6.ip, countryCode: ipv4.country ?? ipv6.country)
result.source = .online(provider)
guard includeGeo, let ip = result.ipv4 ?? result.ipv6, let geo = await geoCache.lookup(ip, provider: provider) else { return result }
guard includeGeo, let ip = result.ipv4 ?? result.ipv6, let geo = await geoCache.lookup(ip) else { return result }
result.apply(geo)
return result
}
/// 只查一个已知地址的归属地,公网 IP 没变时用它补全,不必重新取地址
public static func geo(for ip: String, provider: GeoProvider) async -> GeoInfo? {
await geoCache.lookup(ip, provider: provider)
public static func geo(for ip: String) async -> GeoInfo? {
await geoCache.lookup(ip)
}
// MARK: 归属地
// MARK: cleanip.io
public struct GeoInfo: Sendable, Equatable {
public var countryCode: String?
@@ -197,17 +146,12 @@ public enum PublicAddressLookup {
public var reportURL: URL?
}
static func parse(_ data: Data, provider: GeoProvider) -> GeoInfo? {
switch provider {
case .cleanIP: parseCleanIP(data)
case .ipapi: parseGeo(data)
case .dbip: parseDBIP(data)
case .ipinfo: parseIpinfo(data)
}
static func cleanIPURL(for ip: String) -> URL? {
var components = URLComponents(string: "https://cleanip.io/cli")
components?.queryItems = [URLQueryItem(name: "ip", value: ip), URLQueryItem(name: "json", value: "1")]
return components?.url
}
// MARK: CleanIP.io
struct CleanIPResponse: Decodable {
struct Geo: Decodable {
let countryCode: String?
@@ -304,121 +248,24 @@ public enum PublicAddressLookup {
return info
}
// MARK: ipapi.is
/// ipapi.is 匿名接口的响应:`asn` 形如 “AS7018 AT&T Enterprises, LLC”,`country` 是英文全名,`company` 是地址持有方
struct GeoResponse: Decodable {
let ip: String?
let company: String?
let asn: String?
let city: String?
let region: String?
let country: String?
}
static func parseGeo(_ data: Data) -> GeoInfo? {
guard let response = try? JSONDecoder().decode(GeoResponse.self, from: data) else { return nil }
var info = GeoInfo()
info.countryCode = response.country.flatMap(nonEmpty).flatMap(countryCode(fromName:))
info.city = response.city.flatMap(nonEmpty)
info.region = response.region.flatMap(nonEmpty)
let (asn, asnOrganization) = splitASN(response.asn)
info.asn = asn
// 地址持有方通常比自治系统的注册名更能说明“是谁的网络”
info.organization = response.company.flatMap(nonEmpty) ?? asnOrganization
return info
}
// MARK: DB-IP
struct DBIPResponse: Decodable {
let countryCode: String?
let stateProv: String?
let city: String?
}
static func parseDBIP(_ data: Data) -> GeoInfo? {
guard let response = try? JSONDecoder().decode(DBIPResponse.self, from: data) else { return nil }
var info = GeoInfo()
info.countryCode = response.countryCode.flatMap(validCountryCode)
info.region = response.stateProv.flatMap(nonEmpty)
info.city = response.city.flatMap(nonEmpty)
return info
}
// MARK: ipinfo.io
struct IpinfoResponse: Decodable {
let country: String?
let region: String?
let city: String?
let org: String?
}
static func parseIpinfo(_ data: Data) -> GeoInfo? {
guard let response = try? JSONDecoder().decode(IpinfoResponse.self, from: data) else { return nil }
var info = GeoInfo()
info.countryCode = response.country.flatMap(validCountryCode)
info.region = response.region.flatMap(nonEmpty)
info.city = response.city.flatMap(nonEmpty)
let (asn, organization) = splitASN(response.org)
info.asn = asn
info.organization = organization
return info
}
/// “AS7018 AT&T Enterprises, LLC” → (“AS7018”, “AT&T Enterprises, LLC”);没有 AS 前缀时整段当组织名
static func splitASN(_ text: String?) -> (String?, String?) {
guard let text = text.flatMap(nonEmpty) else { return (nil, nil) }
let parts = text.split(separator: " ", maxSplits: 1).map(String.init)
if let first = parts.first, first.hasPrefix("AS"), Int(first.dropFirst(2)) != nil {
return (first, parts.count > 1 ? parts[1] : nil)
}
return (nil, text)
}
/// 英文国家名转两位代码:先查几个系统名称对不上的别名,再按系统的英文区域名反查
static func countryCode(fromName name: String) -> String? {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
if let alias = countryAliases[trimmed.lowercased()] { return alias }
let english = Locale(identifier: "en_US")
for region in Locale.Region.isoRegions where region.identifier.count == 2 {
if english.localizedString(forRegionCode: region.identifier)?.caseInsensitiveCompare(trimmed) == .orderedSame {
return validCountryCode(region.identifier)
}
}
return nil
}
private static let countryAliases: [String: String] = [
"china": "CN", "china mainland": "CN", "united states": "US", "usa": "US", "united kingdom": "GB", "uk": "GB",
"russia": "RU", "south korea": "KR", "north korea": "KP", "hong kong": "HK", "macau": "MO", "macao": "MO",
"taiwan": "TW", "vietnam": "VN", "viet nam": "VN", "turkey": "TR", "türkiye": "TR", "czech republic": "CZ",
"czechia": "CZ", "iran": "IR", "syria": "SY", "laos": "LA", "brunei": "BN", "bolivia": "BO", "venezuela": "VE",
"tanzania": "TZ", "moldova": "MD", "palestine": "PS", "the netherlands": "NL", "netherlands": "NL",
"ivory coast": "CI", "cape verde": "CV", "swaziland": "SZ", "myanmar": "MM", "burma": "MM", "congo": "CG",
"dr congo": "CD", "democratic republic of the congo": "CD", "micronesia": "FM", "vatican": "VA", "reunion": "RE",
"curacao": "CW", "saint martin": "MF", "sint maarten": "SX",
]
/// 按数据源与 IP 缓存;收到 429 后该数据源到当天(UTC)结束都不再请求
/// 按 IP 缓存一小时;收到 429 后到当天(UTC)结束都不再请求
actor GeoCache {
private static let lifetime: TimeInterval = 60 * 60
private var entries: [String: (info: GeoInfo?, date: Date)] = [:]
private var blockedUntil: [GeoProvider: Date] = [:]
private var blockedUntil: Date?
func lookup(_ ip: String, provider: GeoProvider) async -> GeoInfo? {
let key = "\(provider.rawValue)|\(ip)"
if let entry = entries[key], Date().timeIntervalSince(entry.date) < provider.cacheLifetime { return entry.info }
if let until = blockedUntil[provider], Date() < until { return nil }
guard let url = provider.url(for: ip) else { return nil }
func lookup(_ ip: String) async -> GeoInfo? {
if let entry = entries[ip], Date().timeIntervalSince(entry.date) < Self.lifetime { return entry.info }
if let blockedUntil, Date() < blockedUntil { return nil }
guard let url = PublicAddressLookup.cleanIPURL(for: ip) else { return nil }
let (data, status) = await PublicAddressLookup.getWithStatus(url)
if status == 429 {
blockedUntil[provider] = Self.nextUTCMidnight()
blockedUntil = Self.nextUTCMidnight()
return nil
}
guard status == 200, let data else { return nil }
let info = PublicAddressLookup.parse(data, provider: provider)
entries[key] = (info, Date())
let info = PublicAddressLookup.parseCleanIP(data)
entries[ip] = (info, Date())
return info
}
@@ -54,7 +54,6 @@ public final class AppController: NSObject, NSApplicationDelegate {
applyAppearance()
updateNetworkVisibility()
appliedLanguage = model.settings.language
Task { await model.geo.updateIfNeeded() }
startUpdateChecks()
model.alerts.openTab = { [weak self] tab in
self?.menuBar.dismissPopovers()
@@ -213,7 +212,6 @@ public final class AppController: NSObject, NSApplicationDelegate {
_ = model.settings.probeTarget
_ = model.settings.probeSeconds
_ = model.settings.publicIPLookup
_ = model.settings.geoSource
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }
@@ -537,7 +537,6 @@ struct ScoreBand: View {
}
}
.frame(height: height)
.clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm))
ZStack(alignment: .topLeading) {
// A 与 A+ 的分界太密,刻度上不标 95;弹窗那么窄时 85 也挤不下
ForEach([0] + bands.map(\.upper).filter { $0 != 95 && ($0 != 85 || width >= DS.Size.panelWidth / 2) }, id: \.self) { tick in
@@ -209,12 +209,7 @@ private struct AddressSection: View {
let publicAddresses = network.publicAddresses
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
// 标题、数据来源字标、IPv4 / IPv6 切换、刷新排在一行;320 宽放不下时字标依次缩小,卡片不会被撑宽
ViewThatFits(in: .horizontal) {
header(publicAddresses, logoHeight: DS.TextSize.sm.rawValue)
header(publicAddresses, logoHeight: DS.TextSize.xs.rawValue)
header(publicAddresses, logoHeight: DS.TextSize.xs.rawValue - DS.Space.s1 / 2)
}
header
InfoRow(label: tr("本地 IPv4")) { CopyableText(text: physical?.ipv4.first ?? "—") }
InfoRow(label: tr("本地 IPv6")) { CopyableText(text: physical?.ipv6.first ?? "—") }
InfoRow(label: tr("路由器")) { CopyableText(text: physical?.router ?? "—") }
@@ -271,8 +266,8 @@ private struct AddressSection: View {
return parts.joined(separator: " · ")
}
/// 标题行:IP 地址 · 数据来源 ……… [IPv4 | IPv6] ⟳
private func header(_ addresses: PublicAddresses?, logoHeight: CGFloat) -> some View {
/// 标题行:IP 地址 ……… [IPv4 | IPv6] ⟳;数据来源的字标只放在下面的纯净度区块里
private var header: some View {
let network = model.network
return HStack(spacing: DS.Space.s2) {
Text(PopoverSection.networkAddresses.title)
@@ -280,9 +275,6 @@ private struct AddressSection: View {
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.fixedSize()
if let source = sourceAccessory(addresses, logoHeight: logoHeight) {
source
}
Spacer(minLength: 0)
if model.settings.publicIPLookup {
HStack(spacing: DS.Space.s1) {
@@ -298,19 +290,6 @@ private struct AddressSection: View {
}
}
/// 标题后的数据来源:CleanIP.io 用它的字标(默认与标题文字同高,点了打开官网),其他来源写名字
private func sourceAccessory(_ addresses: PublicAddresses?, logoHeight: CGFloat) -> AnyView? {
guard let addresses, addresses.countryCode != nil || addresses.asn != nil else { return nil }
switch addresses.source {
case .online(.cleanIP):
return AnyView(CleanIPLink(height: logoHeight, help: tr("数据来源:cleanip.io,点击打开官网")))
case .online(let provider):
return AnyView(Text(verbatim: provider.displayName).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).fixedSize())
case .localDatabase:
return AnyView(Text("GeoLite2").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).fixedSize())
}
}
struct Badge: Equatable {
let icon: String
let text: String
@@ -347,13 +326,6 @@ private struct AddressSection: View {
return badges
}
static func sourceLabel(_ source: PublicAddresses.Source) -> String {
switch source {
case .localDatabase: tr("本地 GeoLite2(MaxMind)")
case .online(let provider): tr("\(provider.displayName) 在线查询")
}
}
static func networkTypeLabel(_ raw: String) -> String {
switch raw.lowercased() {
case "residential", "isp": tr("住宅宽带")
@@ -379,7 +351,7 @@ private struct AddressSection: View {
// MARK: - IP 纯净度
/// CleanIP.io 给出的纯净度评分与风险标记;其他数据源没有这一项
/// cleanip.io 给出的纯净度评分与风险标记
private struct PuritySection: View {
@Environment(AppModel.self) private var model
@@ -436,9 +408,6 @@ private struct PuritySection: View {
}
} else if !settings.publicIPLookup {
Text(tr("查询已关闭")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
} else if settings.geoSource != .cleanIP {
Text(tr("纯净度评分来自 CleanIP.io。在「设置 · 网络」把归属地数据源改为 CleanIP.io 后显示。"))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).fixedSize(horizontal: false, vertical: true)
} else if network.isLookingUpPublic {
Text(tr("正在查询…")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
} else {
@@ -527,27 +496,50 @@ private struct DNSSection: View {
InfoRow(label: tr("正在使用")) {
Text(verbatim: network.details.map { $0.dnsServers.isEmpty ? "—" : $0.dnsServers.joined(separator: "\n") } ?? "—")
}
InfoRow(label: tr("配置方式"), text: manual.isEmpty ? tr("自动(由路由器分配)") : matched?.title ?? tr("手动"))
// 配置方式直接是一个下拉菜单:选预设立即切换,选“手动”展开输入框;比六个并排的按钮省两行
if let physical {
let isCustom = !manual.isEmpty && matched == nil
HStack(spacing: DS.Space.s2) {
Text(tr("配置方式")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Spacer(minLength: DS.Space.s2)
if isCustom, !editingManual {
MiniIconButton(systemName: "pencil", help: tr("修改地址")) {
manualText = manual.joined(separator: ", ")
editingManual = true
}
}
Picker(tr("配置方式"), selection: Binding<DNSPreset?>(
get: { editingManual ? nil : manual.isEmpty ? .automatic : matched },
set: { choice in
if let choice {
editingManual = false
apply(choice.servers, service: physical.serviceName)
} else {
manualText = manual.joined(separator: ", ")
editingManual = true
}
})) {
ForEach(DNSPreset.allCases) { preset in
Text(preset.title).tag(DNSPreset?.some(preset))
}
Divider()
Text(tr("手动")).tag(DNSPreset?.none)
}
.pickerStyle(.menu)
.labelsHidden()
.controlSize(.small)
.fixedSize()
.disabled(maintenance.isApplyingDNS)
}
} else {
InfoRow(label: tr("配置方式"), text: manual.isEmpty ? tr("自动(由路由器分配)") : matched?.title ?? tr("手动"))
}
if let tunnel = network.details?.tunnel {
InfoBanner(icon: "info.circle", text: tr("流量经过 \(tunnel.name),系统 DNS 可能由它接管,修改后不一定生效。"), tone: .neutral)
}
if let physical {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: DS.Space.s1), count: 3), spacing: DS.Space.s1) {
ForEach(DNSPreset.allCases) { preset in
ChipButton(title: preset.title, isSelected: !editingManual && matched == preset) {
editingManual = false
apply(preset.servers, service: physical.serviceName)
}
}
ChipButton(title: tr("手动"), isSelected: editingManual || (!manual.isEmpty && matched == nil)) {
manualText = manual.joined(separator: ", ")
editingManual.toggle()
}
}
.disabled(maintenance.isApplyingDNS)
if editingManual {
HStack(spacing: DS.Space.s2) {
TextField(tr("例如 1.1.1.1, 8.8.8.8"), text: $manualText)
@@ -106,7 +106,7 @@ struct AccountSettings: View {
GroupRow { InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error) }
}
GroupRow {
Text(tr("同步的内容:菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限。当前页面、辅助工具、历史记录与归属地库留在本机。"))
Text(tr("同步的内容:菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限。当前页面、辅助工具与历史记录留在本机。"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
@@ -589,110 +589,16 @@ struct NetworkSettings: View {
SettingsGroup(caption: tr("公网 IP")) {
GroupRow(showsDivider: false) {
SettingRow(title: tr("查询公网 IP"),
subtitle: tr("打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址,10 分钟内不重复请求")) {
subtitle: tr("打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址,10 分钟内不重复请求;归属地、ASN、网络类型与纯净度由这台 Mac 直接向 cleanip.io 查询,只发送公网地址")) {
DSToggle(isOn: $settings.publicIPLookup, label: tr("查询公网 IP"))
}
}
GroupRow {
SettingRow(title: tr("归属地数据源"), subtitle: settings.geoSource.detail) {
Picker(tr("归属地数据源"), selection: $settings.geoSource) {
ForEach(GeoSource.allCases) { source in
Text(source.title).tag(source)
}
}
.pickerStyle(.menu)
.labelsHidden()
.fixedSize()
.disabled(!settings.publicIPLookup)
}
}
}
GeoDatabaseSettings()
InfoBanner(icon: "lock.shield", text: tr("修改 DNS 需要管理员权限:已安装辅助工具时直接修改,否则每次弹出系统授权框。"), tone: .neutral)
}
}
/// IP 归属地数据库(MaxMind GeoLite2)
private struct GeoDatabaseSettings: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var settings = model.settings
let geo = model.geo
SettingsGroup(caption: tr("IP 归属地数据库")) {
GroupRow(showsDivider: false) {
SettingRow(title: geo.isAvailable ? tr("本地 MaxMind GeoLite2") : tr("尚未下载"),
subtitle: geo.isAvailable
? tr("国家、城市与 ASN 在本机查询,不经过任何在线服务")
: tr("下载后归属地查询完全离线;数据库由 OpenStats 官网每周同步 MaxMind 的最新版本")) {
StatusBadge(text: geo.isAvailable ? tr("离线查询") : tr("在线查询"), tone: geo.isAvailable ? .success : .neutral)
}
}
ForEach(geo.installed.values.sorted { $0.edition < $1.edition }, id: \GeoDatabaseController.Installed.edition) { database in
GroupRow {
SettingRow(title: title(database.edition), subtitle: tr("版本 \(database.build) · \(Format.bytes(UInt64(database.size), base: .decimal))")) {
Text(database.edition).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
}
}
GroupRow {
SettingRow(title: tr("包含城市数据"), subtitle: tr("显示城市名称,数据库约 60 MB;关闭时只下载国家库(约 9 MB)")) {
DSToggle(isOn: $settings.geoIncludeCity, label: tr("包含城市数据"))
}
}
GroupRow {
SettingRow(title: tr("自动更新"), subtitle: geo.lastChecked.map { tr("每 3 天检查一次 · 上次检查 \($0.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))") } ?? tr("每 3 天检查一次")) {
DSToggle(isOn: $settings.geoAutoUpdate, label: tr("自动更新"))
}
}
GroupRow {
HStack(spacing: DS.Space.s2) {
Button(geo.isUpdating ? tr("正在更新…") : geo.isAvailable ? tr("立即检查更新") : tr("下载数据库")) {
Task { await geo.update() }
}
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(geo.isUpdating)
Button(tr("从文件导入…")) { importFile() }
.buttonStyle(DSButtonStyle(kind: .secondary))
if geo.isAvailable {
Button(tr("在访达中显示")) { NSWorkspace.shared.activateFileViewerSelecting([geo.directory]) }
.buttonStyle(DSButtonStyle(kind: .ghost))
}
Spacer()
}
}
if let message = geo.message {
GroupRow {
Text(message.text).dsFont(.xs).foregroundStyle(message.isError ? DS.Palette.error : DS.Palette.success)
}
}
}
Text(GeoDatabaseController.attribution)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
private func title(_ edition: String) -> String {
if edition.hasSuffix("-City") { return tr("城市库") }
if edition.hasSuffix("-Country") { return tr("国家库") }
if edition.hasSuffix("-ASN") { return tr("ASN 库") }
return edition
}
private func importFile() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.init(filenameExtension: "mmdb") ?? .data]
panel.allowsMultipleSelection = true
panel.message = tr("选择 GeoLite2 City / Country / ASN 的 .mmdb 文件")
guard panel.runModal() == .OK else { return }
for url in panel.urls { model.geo.importDatabase(from: url) }
}
}
// MARK: - 散热与防休眠
/// 放在“温度与风扇”页底部
@@ -17,7 +17,6 @@ public final class AppModel {
public let cleaner: CleanerController
public let maintenance: MaintenanceController
public let network: NetworkController
public let geo: GeoDatabaseController
public let explainer = ProcessExplainer()
public let updates: UpdateController
let diagnostics = DiagnosticsExporter()
@@ -52,8 +51,7 @@ public final class AppModel {
keepAwake = KeepAwakeController(helper: helper, settings: settings)
cleaner = CleanerController(settings: settings)
maintenance = MaintenanceController(helper: helper)
geo = GeoDatabaseController(settings: settings)
network = NetworkController(settings: settings, geo: geo)
network = NetworkController(settings: settings)
updates = UpdateController(settings: settings)
alerts = AlertController(settings: settings)
history = HistoryRecorder(settings: settings, databaseURL: historyURL)
@@ -185,44 +185,6 @@ public enum ProbeTarget: String, CaseIterable, Identifiable, Sendable {
}
}
/// 公网 IP 归属地的数据源
public enum GeoSource: String, CaseIterable, Identifiable, Sendable {
case cleanIP, ipapi, dbip, ipinfo, localDatabase
public var id: String { rawValue }
var title: String {
switch self {
case .cleanIP: "CleanIP.io"
case .ipapi: "ipapi.is"
case .dbip: "DB-IP"
case .ipinfo: "ipinfo.io"
case .localDatabase: tr("本地 GeoLite2 数据库")
}
}
var detail: String {
switch self {
case .cleanIP: tr("归属地、ASN、网络类型、原生 / 广播、纯净度与风险评分,中文地名;把公网 IP 发给 cleanip.io")
case .ipapi: tr("归属地与 ASN;把公网 IP 发给 ipapi.is,匿名额度每天 30 次,同一 IP 的结果记住一天")
case .dbip: tr("只有国家、省 / 州与城市;把公网 IP 发给 db-ip.com")
case .ipinfo: tr("归属地与 ASN;把公网 IP 发给 ipinfo.io,匿名额度很小,容易被限流")
case .localDatabase: tr("在本机的 MaxMind 数据库里查国家、城市与 ASN,不向任何服务发送 IP;数据库未就绪时暂用 CleanIP.io")
}
}
/// 对应的在线数据源;本地数据库没有
var provider: GeoProvider? {
switch self {
case .cleanIP: .cleanIP
case .ipapi: .ipapi
case .dbip: .dbip
case .ipinfo: .ipinfo
case .localDatabase: nil
}
}
}
/// 菜单栏风格:整体选一种套用到所有指标,个别指标可以单独指定(AppSettings.styleOverrides);风格内部的排版保持一致
public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable {
case stacked, inline, icon, ring, pie, history, line, meter, dot
@@ -479,22 +441,10 @@ public final class AppSettings {
public var probeTarget: ProbeTarget {
didSet { defaults.set(probeTarget.rawValue, forKey: Keys.probeTarget) }
}
/// 公网 IP 归属地的数据源
public var geoSource: GeoSource {
didSet { defaults.set(geoSource.rawValue, forKey: Keys.geoSource) }
}
/// 打开网络详情时查询公网 IP(会访问 Cloudflare / ipify)
/// 打开网络详情时查询公网 IP(会访问 Cloudflare / ipify),归属地与纯净度由 cleanip.io 提供
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 enabledAlerts: Set<AlertKind> {
didSet { defaults.set(enabledAlerts.map(\.rawValue).sorted(), forKey: Keys.enabledAlerts) }
@@ -579,10 +529,7 @@ public final class AppSettings {
probeSeconds = Self.probeOptions.contains(defaults.integer(forKey: Keys.probeSeconds))
? defaults.integer(forKey: Keys.probeSeconds) : 2
probeTarget = defaults.string(forKey: Keys.probeTarget).flatMap(ProbeTarget.init(rawValue:)) ?? .cloudflare
geoSource = defaults.string(forKey: Keys.geoSource).flatMap(GeoSource.init(rawValue:)) ?? .cleanIP
publicIPLookup = defaults.object(forKey: Keys.publicIPLookup) as? Bool ?? true
geoIncludeCity = defaults.bool(forKey: Keys.geoIncludeCity)
geoAutoUpdate = defaults.object(forKey: Keys.geoAutoUpdate) as? Bool ?? true
autoCheckUpdates = defaults.object(forKey: Keys.autoCheckUpdates) as? Bool ?? true
historyEnabled = defaults.object(forKey: Keys.historyEnabled) as? Bool ?? true
language = defaults.string(forKey: Keys.language).flatMap(AppLanguage.init(rawValue:)) ?? .system
@@ -645,10 +592,7 @@ public final class AppSettings {
static let probeSeconds = "probeSeconds"
static let probeInBackground = "probeInBackground"
static let probeTarget = "probeTarget"
static let geoSource = "geoSource"
static let publicIPLookup = "publicIPLookup"
static let geoIncludeCity = "geoIncludeCity"
static let geoAutoUpdate = "geoAutoUpdate"
static let autoCheckUpdates = "autoCheckUpdates"
static let historyEnabled = "historyEnabled"
static let hotKeys = "hotKeys"
@@ -103,8 +103,6 @@ final class DiagnosticsExporter {
row(tr("合盖运行"), model.keepAwake.lidClosedActive ? tr("开启") : tr("关闭"))
row(tr("登录时启动"), model.launchAtLoginEnabled ? tr("开启") : tr("关闭"))
row(tr("在线升级"), tr("上次检查 \(model.updates.lastChecked.map { ISO8601DateFormatter().string(from: $0) } ?? tr("从未")),最新 \(model.updates.release?.version ?? tr("当前版本"))"))
let geo = model.geo.installed.values.sorted { $0.edition < $1.edition }.map { "\($0.edition) \($0.build)" }
row(tr("归属地数据库"), geo.isEmpty ? tr("未安装") : geo.joined(separator: tr(",")))
section(tr("设置"))
row(tr("菜单栏项目"), settings.orderedMenuBarItems.map(\.rawValue).joined(separator: ", "))
@@ -1,193 +0,0 @@
import CryptoKit
import Foundation
import Localization
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 var attribution: String { tr("本产品包含 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(tr("服务器上还没有数据库,稍后再试"))
}
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(tr("服务器清单里没有 \(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 ? tr("已更新 \(changed) 个数据库") : tr("已经是最新"), false)
} catch let failure as Failure {
message = (failure.text, true)
} catch {
message = (tr("更新失败:\(error.localizedDescription)"), true)
Log.network.error("归属地数据库更新失败:\(error.localizedDescription, privacy: .public)")
}
}
/// 下载到临时文件,sha256 与清单一致、且能按库类型打开后才替换
private func download(_ entry: Manifest.Entry) async throws {
guard let url = URL(string: entry.file, relativeTo: Self.manifestURL) else { throw Failure(tr("清单里的文件地址无效")) }
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(tr("下载 \(entry.edition) 失败")) }
let digest = try await Task.detached { try Self.sha256(of: temporary) }.value
guard digest == entry.sha256 else { throw Failure(tr("\(entry.edition) 校验不一致,已丢弃")) }
let database = try MaxMindDatabase(url: temporary)
guard database.databaseType == entry.edition else { throw Failure(tr("\(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(tr("不是城市、国家或 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 = (tr("已导入 \(type)"), false)
} catch let failure as Failure {
message = (failure.text, true)
} catch {
message = (tr("导入失败:\(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 }
}
}
@@ -53,16 +53,14 @@ public final class NetworkController {
/// 详情关闭、只在菜单栏显示网速时的低频探测间隔
static let backgroundProbeSeconds = 10
@ObservationIgnored private let geo: GeoDatabaseController
/// 上次查到的公网 IP 信息:点开弹窗时先显示它,只有超过 7 天或公网 IP 变了才重新查归属地
private struct PublicCache: Codable {
var results: [String: PublicAddresses]
var geoDates: [String: Date]
var source: String
}
static let geoCacheLifetime: TimeInterval = 7 * 24 * 60 * 60
private static let cacheKey = "publicAddressCache3"
/// 第 4 版起只有 cleanip.io 一个数据源,旧版本缓存里可能是别的数据源的结果,不再读取
private static let cacheKey = "publicAddressCache4"
/// 用户按过“重置统计”后的累计流量基线:界面显示 当前累计 − 基线。
/// 随开机时间一起保存,重启后系统计数器归零、基线作废,自动回到开机后累计
@@ -80,12 +78,10 @@ public final class NetworkController {
return sysctlbyname("kern.boottime", &boot, &size, nil, 0) == 0 ? TimeInterval(boot.tv_sec) : nil
}()
init(settings: AppSettings, geo: GeoDatabaseController) {
init(settings: AppSettings) {
self.settings = settings
self.geo = geo
if let data = UserDefaults.standard.data(forKey: Self.cacheKey),
let cache = try? JSONDecoder().decode(PublicCache.self, from: data),
cache.source == settings.geoSource.rawValue, settings.publicIPLookup {
let cache = try? JSONDecoder().decode(PublicCache.self, from: data), settings.publicIPLookup {
publicResults = Dictionary(uniqueKeysWithValues: cache.results.compactMap { key, value in IPFamily(rawValue: key).map { ($0, value) } })
}
if let data = UserDefaults.standard.data(forKey: Self.trafficBaselineKey),
@@ -219,24 +215,21 @@ public final class NetworkController {
// MARK: 公网 IP
/// 先只取公网地址(Cloudflare,便宜且不限额),再按地址族各查一次归属地:地址没变、结果不满 7 天、
/// 数据源没换的那一族沿用缓存,否则重新查并写回缓存。`force` 为真时(用户点了刷新)两族都重查
/// 先只取公网地址(Cloudflare,便宜且不限额),再按地址族各向 cleanip.io 查一次:地址没变、结果不满 7 天
/// 的那一族沿用缓存,否则重新查并写回缓存。`force` 为真时(用户点了刷新)两族都重查
func lookUpPublicAddresses(force: Bool = false) {
guard settings.publicIPLookup, !isLookingUpPublic else { return }
// 有缓存可显示时不转圈,后台悄悄核对
isLookingUpPublic = publicResults.isEmpty || force
let localIPv4 = details?.physical?.ipv4 ?? []
Task {
let source = settings.geoSource
let provider = source.provider ?? .cleanIP
let useLocal = source == .localDatabase && geo.isAvailable
let base = await PublicAddressLookup.fetch(includeGeo: false, provider: provider)
let base = await PublicAddressLookup.fetch(includeGeo: false)
let cached = Self.loadCache()
let now = Date()
@MainActor func resolve(_ family: IPFamily, ip: String?) async -> PublicAddresses? {
guard let ip else { return nil }
if !force, let cached, cached.source == source.rawValue,
if !force, let cached,
let old = cached.results[family.rawValue], (family == .v4 ? old.ipv4 : old.ipv6) == ip,
let date = cached.geoDates[family.rawValue], now.timeIntervalSince(date) < Self.geoCacheLifetime {
var kept = old
@@ -245,17 +238,9 @@ public final class NetworkController {
return kept
}
var result = base
if !useLocal, let info = await PublicAddressLookup.geo(for: ip, provider: provider) {
if let info = await PublicAddressLookup.geo(for: ip) {
result.apply(info)
}
// 选了本地 GeoLite2,或在线查不到时,退回本地数据库
if useLocal || (result.asn == nil && result.city == nil && geo.isAvailable), let location = geo.locate(ip) {
result.countryCode = location.countryCode ?? result.countryCode
result.city = location.city
result.asn = location.asn
result.organization = location.organization
result.source = .localDatabase
}
guard result.asn != nil || result.city != nil || result.purity != nil else {
// 这次没查到,地址又没变,先继续用旧的
if let old = cached?.results[family.rawValue], (family == .v4 ? old.ipv4 : old.ipv6) == ip { return old }
@@ -270,14 +255,14 @@ public final class NetworkController {
if let v4 = await v4 { results[.v4] = v4 }
if let v6 = await v6 { results[.v6] = v6 }
var dates = cached?.source == source.rawValue ? cached?.geoDates ?? [:] : [:]
var dates = cached?.geoDates ?? [:]
for (family, result) in results {
let previous = cached?.results[family.rawValue]
let sameAsCached = previous?.asn == result.asn && previous?.purity == result.purity && previous?.city == result.city
if force || !sameAsCached || dates[family.rawValue] == nil { dates[family.rawValue] = now }
}
Self.saveCache(PublicCache(results: Dictionary(uniqueKeysWithValues: results.map { ($0.key.rawValue, $0.value) }),
geoDates: dates, source: source.rawValue))
geoDates: dates))
publicResults = results
if publicResults[publicFamily] == nil, let only = publicResults.keys.first { publicFamily = only }
lastPublicLookup = (Date(), localIPv4)
@@ -1,7 +1,7 @@
import Foundation
import Localization
/// 同步到云端的设置文档:只含偏好,不含本机状态(当前页签、辅助工具安装状态、历史库、归属地库)。
/// 同步到云端的设置文档:只含偏好,不含本机状态(当前页签、辅助工具安装状态、历史库)。
/// 枚举以原始值保存,字段全部可选:旧版本读到新字段会忽略,新版本读到旧文档不会解码失败;
/// 应用时逐项校验,不认识的值跳过
public struct SettingsDocument: Codable, Equatable, Sendable {
@@ -23,10 +23,7 @@ public struct SettingsDocument: Codable, Equatable, Sendable {
public var probeInBackground: Bool?
public var probeSeconds: Int?
public var probeTarget: String?
public var geoSource: String?
public var publicIPLookup: Bool?
public var geoIncludeCity: Bool?
public var geoAutoUpdate: Bool?
public var enabledAlerts: [String]?
public var alertCPUTemperature: Int?
public var alertCPULoad: Int?
@@ -60,10 +57,7 @@ extension AppSettings {
doc.probeInBackground = probeInBackground
doc.probeSeconds = probeSeconds
doc.probeTarget = probeTarget.rawValue
doc.geoSource = geoSource.rawValue
doc.publicIPLookup = publicIPLookup
doc.geoIncludeCity = geoIncludeCity
doc.geoAutoUpdate = geoAutoUpdate
doc.enabledAlerts = enabledAlerts.map(\.rawValue).sorted()
doc.alertCPUTemperature = alertCPUTemperature
doc.alertCPULoad = alertCPULoad
@@ -107,10 +101,7 @@ extension AppSettings {
assign(\.probeInBackground, doc.probeInBackground)
assign(\.probeSeconds, option(doc.probeSeconds, in: Self.probeOptions))
assign(\.probeTarget, doc.probeTarget.flatMap(ProbeTarget.init(rawValue:)))
assign(\.geoSource, doc.geoSource.flatMap(GeoSource.init(rawValue:)))
assign(\.publicIPLookup, doc.publicIPLookup)
assign(\.geoIncludeCity, doc.geoIncludeCity)
assign(\.geoAutoUpdate, doc.geoAutoUpdate)
assign(\.enabledAlerts, doc.enabledAlerts.map { Set($0.compactMap(AlertKind.init(rawValue:))) })
assign(\.alertCPUTemperature, option(doc.alertCPUTemperature, in: Self.alertTemperatureOptions))
assign(\.alertCPULoad, option(doc.alertCPULoad, in: Self.alertLoadOptions))
@@ -172,24 +172,6 @@ struct LiveSamplerTests {
}
@Suite struct GeoParsingTests {
@Test func parsesIpapiResponse() throws {
let json = #"{"ip":"32.5.140.2","is_bogon":false,"company":"AT&T Global Network Services, LLC","asn":"AS7018 AT&T Enterprises, LLC","city":"New York City","region":"New York","country":"United States","lat":40.7,"lon":-74.0,"timezone":"America/New_York"}"#
let info = try #require(PublicAddressLookup.parseGeo(Data(json.utf8)))
#expect(info.countryCode == "US")
#expect(info.city == "New York City")
#expect(info.region == "New York")
#expect(info.asn == "AS7018")
#expect(info.organization == "AT&T Global Network Services, LLC")
}
@Test func fallsBackToASNOrganizationAndRejectsBadCountry() throws {
let json = #"{"ip":"2606:4700:4700::1111","asn":"Cloudflare","country":"../../etc"}"#
let info = try #require(PublicAddressLookup.parseGeo(Data(json.utf8)))
#expect(info.countryCode == nil)
#expect(info.asn == nil)
#expect(info.organization == "Cloudflare")
}
@Test func parsesCleanIPResponse() throws {
let json = #"{"ok":true,"ip":"32.5.140.2","hostname":"host.example.net","geo":{"country":"美国","country_code":"US","country_en":"United States","region":"佛罗里达州","city":"玛丽湖"},"geo_sources":[{"source":"maxmind","country_code":"US"},{"source":"ipinfo","region":"Florida","city":"Lake Mary"}],"network":{"asn":7018,"asn_name":"AT&T Enterprises, LLC","isp":"AT&T Enterprises, LLC","network_type":"Residential","connection_type":"Cable/DSL","native_or_broadcast":"NATIVE"},"risk":{"risk_score":2,"risk_label":"Very Clean","is_vpn":false,"is_proxy":false,"is_datacenter":false,"is_abuser":true},"purity":{"score":98,"grade":"A+","confidence":100,"ip_type":"Residential IP","is_native":true,"residential_probability":98,"recommendation":"该 IP 整体较干净"}}"#
let info = try #require(PublicAddressLookup.parseCleanIP(Data(json.utf8)))
@@ -206,22 +188,6 @@ struct LiveSamplerTests {
#expect(PublicAddressLookup.parseCleanIP(Data(#"{"ok":false,"error":"bad"}"#.utf8)) == nil)
}
@Test func parsesDBIPAndIpinfo() throws {
let dbip = try #require(PublicAddressLookup.parseDBIP(Data(#"{"ipAddress":"32.5.140.2","countryCode":"US","stateProv":"New York","city":"New York"}"#.utf8)))
#expect(dbip.countryCode == "US" && dbip.region == "New York" && dbip.city == "New York" && dbip.asn == nil)
let ipinfo = try #require(PublicAddressLookup.parseIpinfo(Data(#"{"ip":"82.139.234.155","city":"Frankfurt am Main","region":"Hesse","country":"DE","org":"AS151338 POLONETWORK LIMITED"}"#.utf8)))
#expect(ipinfo.countryCode == "DE" && ipinfo.city == "Frankfurt am Main")
#expect(ipinfo.asn == "AS151338" && ipinfo.organization == "POLONETWORK LIMITED")
}
@Test func mapsCountryNames() {
#expect(PublicAddressLookup.countryCode(fromName: "Germany") == "DE")
#expect(PublicAddressLookup.countryCode(fromName: "China") == "CN")
#expect(PublicAddressLookup.countryCode(fromName: "Hong Kong") == "HK")
#expect(PublicAddressLookup.countryCode(fromName: "south korea") == "KR")
#expect(PublicAddressLookup.countryCode(fromName: "Atlantis") == nil)
}
@Test func rejectsUnsafeCountryCode() {
#expect(PublicAddressLookup.validCountryCode("cn") == "CN")
#expect(PublicAddressLookup.validCountryCode("../x") == nil)
@@ -257,76 +223,3 @@ 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)) }
}
}
+7 -5
View File
@@ -47,10 +47,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. -->
Latest release **0.2.0** (2026-09-13) · **50** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
Latest release **0.2.0** (2026-09-13) · **52** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
<details open>
<summary><b>2026-09-15</b> · Unreleased · 3 added · 2 added · 11 changed · 4 fixed · 1 added</summary>
<summary><b>2026-09-15</b> · Unreleased · 3 added · 2 added · 13 changed · 4 fixed · 1 added</summary>
**Added**
@@ -65,6 +65,8 @@ Latest release **0.2.0** (2026-09-13) · **50** changes in development · [full
**Changed**
- 公网 IP 的归属地只用 cleanip.io:去掉「设置 · 网络」里的归属地数据源选择,ipapi.is、DB-IP、ipinfo.io 三个在线数据源与本地 MaxMind GeoLite2 数据库(含下载、导入、自动更新的整个“IP 归属地数据库”设置区块)一并移除;归属地、ASN、网络类型与纯净度都由这台 Mac 直接向 cleanip.io 查询,只发送公网地址。以前缓存的其他数据源结果不再读取,打开网络详情时会重新查一次。
- 网络详情的 IP 地址区块标题不再放 cleanip.io 字标,字标只留在 IP 纯净度区块里;纯净度色带改为直角,去掉两端的圆角。
- 「设置 · 菜单栏」每个显示项目下面多了一行“菜单栏风格”:给这一项单独选一种风格(默认跟随整体,菜单里标出整体现在是哪种),旁边是这一项按当前风格、实时读数画出的预览,改了立刻能看到。以前这个选择藏在开关旁一个没有说明的下拉里,不容易发现。温度、风扇只提供文字类的三种风格;原“风格”分组改名“整体风格”并加了说明。
- 菜单栏弹窗右上角的两个按钮:左边改为该指标自己的图标(网络是网络图标、磁盘是硬盘),点了直接进主窗口里这个指标的页面;右边的齿轮进总设置。
- 网络详情的 IP 地址区块精简:原生 / 广播、住宅 / 机房、运营商类型(ISP 等)改为 cleanip.io 样式的徽章,去掉反向解析、网络类型、住宅概率、数据来源四行;数据来源改为标题后的小标记,CleanIP.io 显示它的字标:与标题文字同高、不留上下空白,点了打开 cleanip.io。徽章样式作为通用组件收进设计系统。
@@ -242,9 +244,9 @@ OpenStats only touches the network for two optional features, both of which can
Settings → Network:
- **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,
`ipapi.is` answers instead (anonymous, queried directly from each Mac, cached per IP for a day).
for your public address, cached for 10 minutes. Location, ASN, network type and the cleanliness score
come from `cleanip.io`, queried directly from each Mac with nothing but the public address and never
through our servers; a result is kept for a week unless the address changes or you refresh.
- **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.
+5 -3
View File
@@ -46,10 +46,10 @@ brew install --cask gentpan/tap/openstats
<!-- changelog:start -->
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
最新版本 **0.2.0**(2026-09-13) · 开发中 **50** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
最新版本 **0.2.0**(2026-09-13) · 开发中 **52** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
<details open>
<summary><b>2026-09-15</b> · 未发布 · 新增 3 · 新增 2 · 调整 11 · 修复 4 · 新增 1</summary>
<summary><b>2026-09-15</b> · 未发布 · 新增 3 · 新增 2 · 调整 13 · 修复 4 · 新增 1</summary>
**新增**
@@ -64,6 +64,8 @@ brew install --cask gentpan/tap/openstats
**调整**
- 公网 IP 的归属地只用 cleanip.io:去掉「设置 · 网络」里的归属地数据源选择,ipapi.is、DB-IP、ipinfo.io 三个在线数据源与本地 MaxMind GeoLite2 数据库(含下载、导入、自动更新的整个“IP 归属地数据库”设置区块)一并移除;归属地、ASN、网络类型与纯净度都由这台 Mac 直接向 cleanip.io 查询,只发送公网地址。以前缓存的其他数据源结果不再读取,打开网络详情时会重新查一次。
- 网络详情的 IP 地址区块标题不再放 cleanip.io 字标,字标只留在 IP 纯净度区块里;纯净度色带改为直角,去掉两端的圆角。
- 「设置 · 菜单栏」每个显示项目下面多了一行“菜单栏风格”:给这一项单独选一种风格(默认跟随整体,菜单里标出整体现在是哪种),旁边是这一项按当前风格、实时读数画出的预览,改了立刻能看到。以前这个选择藏在开关旁一个没有说明的下拉里,不容易发现。温度、风扇只提供文字类的三种风格;原“风格”分组改名“整体风格”并加了说明。
- 菜单栏弹窗右上角的两个按钮:左边改为该指标自己的图标(网络是网络图标、磁盘是硬盘),点了直接进主窗口里这个指标的页面;右边的齿轮进总设置。
- 网络详情的 IP 地址区块精简:原生 / 广播、住宅 / 机房、运营商类型(ISP 等)改为 cleanip.io 样式的徽章,去掉反向解析、网络类型、住宅概率、数据来源四行;数据来源改为标题后的小标记,CleanIP.io 显示它的字标:与标题文字同高、不留上下空白,点了打开 cleanip.io。徽章样式作为通用组件收进设计系统。
@@ -221,7 +223,7 @@ brew install --cask gentpan/tap/openstats
只有两项可选功能会联网,都可以在“设置 → 网络”中关闭:
- **公网 IP**:打开网络详情时向 Cloudflare `1.1.1.1`(回退 ipify)请求一次公网地址,10 分钟内不重复请求。归属地与 ASN 在本机的 MaxMind GeoLite2 数据库里查询;数据库从 `getopenstats.com` 下载并按 sha256 校验,下载前才临时改用 `ipapi.is` 的匿名接口在线查询:由每台 Mac 自己直接查、不经过我们的服务器,同一个公网 IP 的结果记住一天。
- **公网 IP**:打开网络详情时向 Cloudflare `1.1.1.1`(回退 ipify)请求一次公网地址,10 分钟内不重复请求。归属地、ASN、网络类型与纯净度评分由这台 Mac 直接向 `cleanip.io` 查询,只发送公网地址、不经过我们的服务器;地址没变、不满 7 天就沿用上次结果,点刷新才重查。
- **连接探测**:每 1、2 或 5 秒向你选择的目标(Cloudflare、Google、阿里云、腾讯或路由器)发送一次 ICMP ping,只在菜单栏显示网络项或打开网络详情时运行。
用 Apple 智能解释进程完全在本机完成,进程信息不会离开这台 Mac。
+2 -2
View File
@@ -24,8 +24,8 @@ echo "内容指纹 v=$STAMP"
# 目录归部署用户所有,Caddy 只需读取
"${SSH[@]}" "$HOST" "sudo mkdir -p $ROOT && sudo chown -R \$(id -un):\$(id -gn) $ROOT"
# geoip/ 由服务器上的定时任务生成,download/ 是 publish_release.sh 上传的安装包,都不在 web/ 里——
# 排除掉,否则 --delete 会把它们删了
# download/ 是 publish_release.sh 上传的安装包,geoip/ 是以前 GeoLite2 定时任务留下的目录(应用已不再下载),
# 都不在 web/ 里——排除掉,否则 --delete 会把它们删了
rsync -az --delete --exclude /geoip/ --exclude /download/ -e "${SSH[*]}" web/ "$HOST:$ROOT/"
"${SSH[@]}" "$HOST" "sudo chmod -R a+rX $ROOT"
echo "已同步到 $HOST:$ROOT"
-8
View File
@@ -1,13 +1,5 @@
# 第三方声明
## 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
`Assets/flags-svg/` 中的国旗 SVG(应用内为 `Scripts/render_flags.sh` 渲染的 PNG)来自
+1 -1
View File
@@ -13,7 +13,7 @@ settings:
SWIFT_VERSION: "6.0"
MARKETING_VERSION: "0.2.0"
# 构建号四位数字,每次 make build 加一;营销版本只在发布时用 Scripts/version.sh patch / minor 修改
CURRENT_PROJECT_VERSION: "0048"
CURRENT_PROJECT_VERSION: "0049"
MACOSX_DEPLOYMENT_TARGET: "14.0"
# 本地开发使用 ad-hoc 签名;发布时改为 Developer ID(见 docs/PLAN.md)
CODE_SIGN_STYLE: Manual
-20
View File
@@ -1,20 +0,0 @@
#!/bin/bash
# 把 MaxMind 账号与 License Key 写到服务器的 /etc/openstats/maxmind.env,然后立刻同步一次 GeoLite2 数据库。
# 值在本机终端里输入(Key 不回显),只经 ssh 传到服务器,不进仓库、不进日志。
#
# ./server/geoip/configure.sh
set -euo pipefail
HOST="${SITE_HOST:-debian@51.38.126.148}"
KEY="${SITE_KEY:-$HOME/.ssh/gentpan.pem}"
SSH=(ssh -i "$KEY" -o BatchMode=yes "$HOST")
read -r -p "MaxMind Account ID: " ACCOUNT
read -r -s -p "MaxMind License Key: " LICENSE; echo
[ -n "$ACCOUNT" ] && [ -n "$LICENSE" ] || { echo "两项都要填" >&2; exit 1; }
case "$ACCOUNT$LICENSE" in *[!A-Za-z0-9_]*) echo "账号或 Key 含有意外字符" >&2; exit 1 ;; esac
# 通过标准输入写入,命令行参数里不出现 Key
printf 'MAXMIND_ACCOUNT_ID=%s\nMAXMIND_LICENSE_KEY=%s\n' "$ACCOUNT" "$LICENSE" \
| "${SSH[@]}" 'sudo install -d -m 700 /etc/openstats && sudo tee /etc/openstats/maxmind.env >/dev/null && sudo chmod 600 /etc/openstats/maxmind.env && echo "已写入 /etc/openstats/maxmind.env"'
echo "开始第一次同步(三个库约 80 MB,需要一两分钟)…"
"${SSH[@]}" 'sudo systemctl start openstats-geoip.service && cat /var/www/getopenstats.com/geoip/manifest.json && echo'
-20
View File
@@ -1,20 +0,0 @@
#!/bin/bash
# 把 GeoLite2 更新脚本与定时任务装到服务器(不包含 License Key)。
# ./server/geoip/install.sh
set -euo pipefail
cd "$(dirname "$0")"
HOST="${SITE_HOST:-debian@51.38.126.148}"
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'
-56
View File
@@ -1,56 +0,0 @@
#!/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 已更新"
-13
View File
@@ -1,13 +0,0 @@
[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
-10
View File
@@ -1,10 +0,0 @@
[Unit]
Description=每周二、周五更新 GeoLite2(MaxMind 在这两天发布新数据)
[Timer]
OnCalendar=Tue,Fri *-*-* 08:00:00 UTC
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
+1 -1
View File
@@ -312,7 +312,7 @@
<li><b>账号</b><span>可选。用 GitHub、Google 或 Apple 登录后,偏好设置同步到你的其他 Mac;云端只保存邮箱、姓名与设置文档,可随时删除。详见<a href="privacy.html">隐私政策</a></span></li>
<li><b>统计上报</b><span>没有</span></li>
<li><b>公网 IP</b><span>打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可关闭</span></li>
<li><b>归属地</b><span>数据源自己选:CleanIP.io(默认,含网络类型与纯净度评分)、ipapi.is、DB-IP、ipinfo.io,都是这台 Mac 直接向它查自己的公网 IP;也可以用本机的 MaxMind GeoLite2 数据库完全离线查</span></li>
<li><b>归属地</b><span>归属地、ASN、网络类型与纯净度评分来自 cleanip.io,这台 Mac 直接向它查自己的公网 IP,只发送这个地址</span></li>
<li><b>连接探测</b><span>按设定间隔 ping 你选择的目标,只在显示网络项时运行,可关闭</span></li>
<li><b>偏好设置</b><span>保存在本机;登录后同步的那一份不含任何监控数据</span></li>
</ul>
+1 -1
View File
@@ -34,7 +34,7 @@
</div>
<ul class="fact-list">
<li><b>公网 IP</b><span>打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可在设置中关闭</span></li>
<li><b>归属地</b><span>由你在设置里选择数据源:CleanIP.io(默认)、ipapi.is、DB-IP 或 ipinfo.io,这台 Mac 直接向所选服务查询自己的公网 IP,只发送这个地址、不带其他信息;也可以改用本机的 MaxMind GeoLite2 数据库完全离线查询</span></li>
<li><b>归属地</b><span>归属地、ASN、网络类型与纯净度评分由 cleanip.io 提供:这台 Mac 直接向它查询自己的公网 IP,只发送这个地址、不带其他信息,不经过 OpenStats 的服务器</span></li>
<li><b>连接探测</b><span>按设定间隔 ping 你选择的目标,只在显示网络项时运行,可关闭</span></li>
<li><b>检查更新</b><span>启动时与每天一次读取 getopenstats.com 上的版本清单,请求不带任何标识</span></li>
</ul>