From b8db7eacaed21dc3aed64499989c63d42fc19948 Mon Sep 17 00:00:00 2001 From: gentpan Date: Tue, 15 Sep 2026 04:24:42 +0500 Subject: [PATCH] =?UTF-8?q?=E5=BD=92=E5=B1=9E=E5=9C=B0=E5=8F=AA=E7=94=A8?= =?UTF-8?q?=20cleanip.io=EF=BC=9A=E7=A7=BB=E9=99=A4=20MaxMind=20GeoLite2?= =?UTF-8?q?=20=E4=B8=8E=E5=85=B6=E4=BB=96=E5=9C=A8=E7=BA=BF=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删掉 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 条不再使用的翻译 --- ARCHITECTURE.md | 10 +- CHANGELOG.md | 2 + .../Sources/Localization/Translations.swift | 56 +--- .../Sources/Metrics/MaxMindDatabase.swift | 296 ------------------ .../Sources/Metrics/PublicAddressLookup.swift | 203 ++---------- .../OpenStatsUI/AppKit/AppController.swift | 2 - .../Sources/OpenStatsUI/Design/Charts.swift | 1 - .../OpenStatsUI/Popover/NetworkPopover.swift | 92 +++--- .../Settings/AccountSettings.swift | 2 +- .../OpenStatsUI/Settings/SettingsPages.swift | 96 +----- .../Sources/OpenStatsUI/State/AppModel.swift | 4 +- .../OpenStatsUI/State/AppSettings.swift | 58 +--- .../OpenStatsUI/State/Diagnostics.swift | 2 - .../State/GeoDatabaseController.swift | 193 ------------ .../OpenStatsUI/State/NetworkController.swift | 37 +-- .../OpenStatsUI/State/SettingsDocument.swift | 11 +- .../Tests/MetricsTests/MetricsTests.swift | 107 ------- README.md | 12 +- README.zh-CN.md | 8 +- Scripts/deploy_site.sh | 4 +- ThirdPartyNotices.md | 8 - project.yml | 2 +- server/geoip/configure.sh | 20 -- server/geoip/install.sh | 20 -- server/geoip/openstats-geoip-update.sh | 56 ---- server/geoip/openstats-geoip.service | 13 - server/geoip/openstats-geoip.timer | 10 - web/index.html | 2 +- web/privacy.html | 2 +- 29 files changed, 108 insertions(+), 1221 deletions(-) delete mode 100644 Packages/OpenStatsKit/Sources/Metrics/MaxMindDatabase.swift delete mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/State/GeoDatabaseController.swift delete mode 100755 server/geoip/configure.sh delete mode 100755 server/geoip/install.sh delete mode 100755 server/geoip/openstats-geoip-update.sh delete mode 100644 server/geoip/openstats-geoip.service delete mode 100644 server/geoip/openstats-geoip.timer diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c9d641e..f8408b4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f73578..d0a86db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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。徽章样式作为通用组件收进设计系统。 diff --git a/Packages/OpenStatsKit/Sources/Localization/Translations.swift b/Packages/OpenStatsKit/Sources/Localization/Translations.swift index e492830..d498c57 100644 --- a/Packages/OpenStatsKit/Sources/Localization/Translations.swift +++ b/Packages/OpenStatsKit/Sources/Localization/Translations.swift @@ -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 diff --git a/Packages/OpenStatsKit/Sources/Metrics/MaxMindDatabase.swift b/Packages/OpenStatsKit/Sources/Metrics/MaxMindDatabase.swift deleted file mode 100644 index 6e99735..0000000 --- a/Packages/OpenStatsKit/Sources/Metrics/MaxMindDatabase.swift +++ /dev/null @@ -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.. 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..> 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.. 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(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 - } -} diff --git a/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift b/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift index 9abf5a7..1b050ed 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift @@ -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 } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index 39db1ad..3121d57 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -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 } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift index 8fd3e74..1bf8ed3 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift @@ -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 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift index f83a12b..8b1f376 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift @@ -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( + 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) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/AccountSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/AccountSettings.swift index 914eeae..ec67f2c 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/AccountSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/AccountSettings.swift @@ -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) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift index fd6439a..2d4087c 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift @@ -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: - 散热与防休眠 /// 放在“温度与风扇”页底部 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 65c9297..e5a5e9f 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -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) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index f589dd0..2c1a270 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -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 { 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" diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/Diagnostics.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/Diagnostics.swift index 5b74585..faa9d00 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/Diagnostics.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/Diagnostics.swift @@ -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: ", ")) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/GeoDatabaseController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/GeoDatabaseController.swift deleted file mode 100644 index 69cc4b9..0000000 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/GeoDatabaseController.swift +++ /dev/null @@ -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 } - } -} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift index a032375..f7d571c 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift @@ -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) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift index 046e870..9ba72e1 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SettingsDocument.swift @@ -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)) diff --git a/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift b/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift index 287d93a..d41e75f 100644 --- a/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift +++ b/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift @@ -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)) } - } -} diff --git a/README.md b/README.md index 726b968..cf22c5f 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,10 @@ Developed and tested on Apple Silicon. The interface is in Simplified Chinese. -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)
-2026-09-15 · Unreleased · 3 added · 2 added · 11 changed · 4 fixed · 1 added +2026-09-15 · Unreleased · 3 added · 2 added · 13 changed · 4 fixed · 1 added **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. diff --git a/README.zh-CN.md b/README.zh-CN.md index a0bf1b9..09aabe7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -46,10 +46,10 @@ brew install --cask gentpan/tap/openstats -最新版本 **0.2.0**(2026-09-13) · 开发中 **50** 项改动尚未发布 · [完整更新日志](CHANGELOG.md) +最新版本 **0.2.0**(2026-09-13) · 开发中 **52** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
-2026-09-15 · 未发布 · 新增 3 · 新增 2 · 调整 11 · 修复 4 · 新增 1 +2026-09-15 · 未发布 · 新增 3 · 新增 2 · 调整 13 · 修复 4 · 新增 1 **新增** @@ -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。 diff --git a/Scripts/deploy_site.sh b/Scripts/deploy_site.sh index d80c59e..2864dda 100755 --- a/Scripts/deploy_site.sh +++ b/Scripts/deploy_site.sh @@ -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" diff --git a/ThirdPartyNotices.md b/ThirdPartyNotices.md index 41ced24..904c317 100644 --- a/ThirdPartyNotices.md +++ b/ThirdPartyNotices.md @@ -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)来自 diff --git a/project.yml b/project.yml index 94104e6..c42f043 100644 --- a/project.yml +++ b/project.yml @@ -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 diff --git a/server/geoip/configure.sh b/server/geoip/configure.sh deleted file mode 100755 index b30efcc..0000000 --- a/server/geoip/configure.sh +++ /dev/null @@ -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' diff --git a/server/geoip/install.sh b/server/geoip/install.sh deleted file mode 100755 index f2a70d8..0000000 --- a/server/geoip/install.sh +++ /dev/null @@ -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' diff --git a/server/geoip/openstats-geoip-update.sh b/server/geoip/openstats-geoip-update.sh deleted file mode 100755 index 7c191e4..0000000 --- a/server/geoip/openstats-geoip-update.sh +++ /dev/null @@ -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 已更新" diff --git a/server/geoip/openstats-geoip.service b/server/geoip/openstats-geoip.service deleted file mode 100644 index 1369b2e..0000000 --- a/server/geoip/openstats-geoip.service +++ /dev/null @@ -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 diff --git a/server/geoip/openstats-geoip.timer b/server/geoip/openstats-geoip.timer deleted file mode 100644 index 06060c9..0000000 --- a/server/geoip/openstats-geoip.timer +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=每周二、周五更新 GeoLite2(MaxMind 在这两天发布新数据) - -[Timer] -OnCalendar=Tue,Fri *-*-* 08:00:00 UTC -RandomizedDelaySec=30m -Persistent=true - -[Install] -WantedBy=timers.target diff --git a/web/index.html b/web/index.html index 3099ccb..b2a2e31 100644 --- a/web/index.html +++ b/web/index.html @@ -312,7 +312,7 @@
  • 账号可选。用 GitHub、Google 或 Apple 登录后,偏好设置同步到你的其他 Mac;云端只保存邮箱、姓名与设置文档,可随时删除。详见隐私政策
  • 统计上报没有
  • 公网 IP打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可关闭
  • -
  • 归属地数据源自己选:CleanIP.io(默认,含网络类型与纯净度评分)、ipapi.is、DB-IP、ipinfo.io,都是这台 Mac 直接向它查自己的公网 IP;也可以用本机的 MaxMind GeoLite2 数据库完全离线查
  • +
  • 归属地归属地、ASN、网络类型与纯净度评分来自 cleanip.io,这台 Mac 直接向它查自己的公网 IP,只发送这个地址
  • 连接探测按设定间隔 ping 你选择的目标,只在显示网络项时运行,可关闭
  • 偏好设置保存在本机;登录后同步的那一份不含任何监控数据
  • diff --git a/web/privacy.html b/web/privacy.html index e5a594b..29ca72d 100644 --- a/web/privacy.html +++ b/web/privacy.html @@ -34,7 +34,7 @@
    • 公网 IP打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可在设置中关闭
    • -
    • 归属地由你在设置里选择数据源:CleanIP.io(默认)、ipapi.is、DB-IP 或 ipinfo.io,这台 Mac 直接向所选服务查询自己的公网 IP,只发送这个地址、不带其他信息;也可以改用本机的 MaxMind GeoLite2 数据库完全离线查询
    • +
    • 归属地归属地、ASN、网络类型与纯净度评分由 cleanip.io 提供:这台 Mac 直接向它查询自己的公网 IP,只发送这个地址、不带其他信息,不经过 OpenStats 的服务器
    • 连接探测按设定间隔 ping 你选择的目标,只在显示网络项时运行,可关闭
    • 检查更新启动时与每天一次读取 getopenstats.com 上的版本清单,请求不带任何标识