System proxy convergence runs after every successful deploy (activation, mode change, settings saves, health convergence, launch resync), and each apply locked SCPreferences and rewrote every owned service even when the projected dictionaries were semantically equal to what macOS already applies — so the admin authorization prompt fired on every redeploy while intent was on, including server switches and GUI relaunches that cannot change the system proxy dictionary at all. New SystemProxyPlanner (Domain, pure) decides per service by comparing the desired Proxies dictionary with the live value: equal services are not written, and an all-equal apply returns .unchanged without locking or committing, so no authorization is requested. A foreign change that coincidentally equals the desired values (or an unowned service already at them) is adopted into ownership with the recorded original preserved instead of reported as a conflict. Owned services keep their recorded original verbatim — nil stays nil — fixing a latent restore bug where a second real change on a service originally taken over with no proxies dictionary recorded 2.0's own previous values as the restore point. Real value changes still write and authorize once. SystemProxyControlling.apply returns written/unchanged; the gate emits a systemProxyUnchanged event line (diagnostic log view and redacted report) so it is observable why no prompt appeared. SystemProxyError moves to Domain with the planner. CONTEXT.md invariant extended; decision table and test points in issue #70. Closes #70.
ShadowsocksX-NG2
ShadowsocksX-NG 的现代化重写版本。新工程的全部源码与构建配置放在本目录下。
依赖的二进制工具(sslocal、v2ray-plugin 等)不再以子模块方式在仓库内构建,而是直接复制外部项目发布好的可执行文件。HTTP 入站由 shadowsocks-rust 直接提供,2.0 不再携带 Legacy Privoxy。
旧版工程源码位于仓库根目录的 Legacy/ 下,仅供参考,不保证可构建。
工程结构
project.yml— XcodeGen 工程定义,唯一事实来源。.xcodeproj与App/Info.plist、*.entitlements、Tests/Info.plist都是生成物(已 gitignore),改工程结构一律改project.yml后重新生成。App/— app target 源码(SwiftUI 菜单栏 app,LSUIElement)。Agent/— 代理运行时 wrapper(独立可执行文件,装入Contents/MacOS/,由 LaunchAgent 常驻):读sslocal-active.json契约,以绝对路径启动和监管 sslocal(SIGTERM 链式停止、SIGUSR1 热重载/结构重启、崩溃时非零退出交 KeepAlive 重放,spec #21 D2/D5/D7)。PAC HTTP endpoint 已随 issue #67 移除。LaunchAgent/—SMAppService.agent(plistName:)的注册清单,装入Contents/Library/LaunchAgents/;ProgramArguments用 bundle 相对路径,由 launchd 按注册 app 的 bundle 位置解析。Domain/— 领域核心(配置目录树、凭据引用与持久化,spec #21 D3/D5),与 UI 无关;其中RuntimePaths、SslocalRuntimeDocument、RuntimeLog同时编入 wrapper 目标,保证跨进程契约单一来源。Tests/— 单元测试 target,随ShadowsocksX-NG2scheme 运行。Vendor/<name>/manifest.json— 外部二进制的固定供应链清单(tag + 资产 URL + 归档 SHA-256 + bundle 内位置 + 签名 identifier);二进制本体与.fetched.sha256戳是构建缓存,不入库。Vendor/rules/geolocation-cn/— 内置中国域名规则快照(issue #63):snapshot.json(规范化规则 + 元数据 + 损失报告)、manifest.json(上游版本与快照 SHA-256)、NOTICE(许可证与归属)。普通构建只读本地快照,绝不抓取或转换。Vendor/rules/china-ipv4/— 内置中国 IPv4 CIDR 直连候选快照(issue #64):同上三件套,来源为 gaoyifan/china-operator-ipchina.txt固定 commit。规则模式的proxy_allACL 同时编入中国域名与 IPv4 CIDR 直连候选。Vendor/rules/gfwlist/— 内置 GFWList 代理候选快照(issue #65):同上三件套,来源为 gfwlist/gfwlist 官方 Base64 AutoProxygfwlist.txt固定 commit。只转换可按目标域名无损表达的规则;「未匹配时直连」的bypass_allACL 编入这些代理候选。Scripts/— 供应链脚本(见下节)与打包门槛断言;另有update-geolocation-cn.sh/update-china-ipv4.sh/update-gfwlist.sh(显式维护动作,抓取固定版上游并转换)与verify-rule-snapshots.sh(构建前离线校验快照完整性)。
外部二进制供应链
外部二进制只用上游官方构建产物:sslocal(shadowsocks-rust v1.25.0,装于 Contents/Helpers/)与 v2ray-plugin(v1.3.2,装于 Contents/Helpers/Plugins/)。信任根是提交在库里的清单哈希;构建链如下(spec #21 D6/D10):
- fetch —
Scripts/fetch-external-binaries.sh下载清单固定 URL 的归档、逐字节校验归档 SHA-256、只提取清单指名的成员并确认 arm64,落盘Vendor/<name>/并记录戳。归档哈希不符 → 立即失败;本地缓存漂移或清单变更 → 自动重新下载复验。 - generate —
xcodegen generate把两个二进制作为 Copy Files 项装入Contents/Helpers[/Plugins]。注意顺序:先 fetch 再 generate,文件在生成时必须已存在,否则 Copy Files 阶段会被静默丢弃(generate 也会直接报缺文件)。 - verify on every build — 预构建脚本阶段重跑 fetch 脚本(缓存命中时只对本地戳快速复验),哈希或清单漂移使构建即失败。
- sign —
Scripts/sign-embedded-helpers.sh(post-build 阶段)对每个二进制 Developer ID 重签:--options runtime --timestamp --identifier <清单 signIdentifier>(插件形如<bundle-id>.plugin.<name>);随后 Xcode 完成 app 外层签名,封存重签后的嵌套代码。 - gate — 构建产物必须过
Scripts/packaging-gate.sh <App.app>:嵌套代码签名严格校验、主应用 DevID + Hardened Runtime 且无 App Sandbox entitlement、逐二进制清单覆盖(Helpers 下每个 Mach-O 都在清单内且 DevID 重签通过)。篡改任一二进制会因签名失效被断言发现。
签名与运行形态基线
- bundle id
com.qiuyuzhou.ShadowsocksX-NG2,与 Legacy(com.qiuyuzhou.ShadowsocksX-NG)区分;运行时数据落~/Library/Application Support/ShadowsocksX-NG2/。macOS 15+ / arm64。 - Hardened Runtime + Developer ID 签名(
DEVELOPMENT_TEAM/ 证书见project.yml);不启用 App Sandbox,entitlements 为空 dict。 - 测试 target 用 Apple Development 证书签名:宿主 app 开启 Hardened Runtime 后 library validation 要求被注入的 xctest 包同 Team 签名。
- Release 配置关闭
CODE_SIGN_INJECT_BASE_ENTITLEMENTS,保证发布产物 entitlements 只来自项目文件。
开发、构建与测试
在本目录内,日常格式化、静态检查、工程生成、构建和测试优先使用 Taskfile.dist.yml 定义的任务:
task format
task lint
task lint:fix # 需要自动修复 SwiftLint 违规时使用;执行后再运行 task lint
task gen:prj # 生成 Xcode 工程
task build # 构建 Debug 版本 app
task test # 运行单元测试(不含真实进程冒烟)
task test:smoke # 运行真实进程冒烟测试(拉起真实 sslocal,见下节发布门槛)
task format 会格式化 App/、Tests/、Domain/ 和 Agent/ 下的 Swift 文件;task lint:fix 会修改源文件,执行后检查并复核 diff。
新 clone 或清单变更后,在本目录内先取外部二进制,再生成工程(顺序不可颠倒):
Scripts/fetch-external-binaries.sh
task gen:prj
Tests/Smoke/ 是真实进程冒烟(经 wrapper 拉起真实 sslocal 验证启动、握手、
ACL 路由与插件监管),不在默认 task test 内,由独立 scheme 按需运行。
发布前运行发布门槛检查:
# 发布门槛(必须全绿):真实进程冒烟 + 打包门槛
task test:smoke
Scripts/packaging-gate.sh \
.build/derivedData/Build/Products/Debug/ShadowsocksX-NG2.app
代码风格工具
swift format随 Xcode/Swift 工具链提供,无需单独安装(基线:Apple Swift 6.4);SwiftLint 需brew install swiftlint(基线 0.65.0)。工具缺失时,含本目录 Swift 文件的提交会被 pre-commit 钩子拒绝而不是放行。- 配置文件:
.swift-format是 Swift 6.4 工具链默认规则的快照(固定成文件,工具链升级不漂移);.swiftlint.yml默认规则起步,只声明排除项与个别和 format 基线冲突的关闭项(文件内有触发案例注释)。 - 仓库级
pre-commit钩子的启用方式和检查范围见根目录README.md。 - 调整规则只针对实际痛点:修改
.swift-format/.swiftlint.yml的 PR 须给出触发案例,并同步更新本节中的基线版本。
内置规则快照
规则模式使用的内置快照是显式维护产物:
- geolocation-cn(issue #63):人工复验上游后运行
Scripts/update-geolocation-cn.sh,从固定版 Loyalsoldier/domain-list-customgeosite.dat解析 typed 条目并生成Vendor/rules/geolocation-cn/snapshot.json。 - china-ipv4(issue #64):运行
Scripts/update-china-ipv4.sh,从固定版 gaoyifan/china-operator-ipchina.txt规范化并去重 IPv4 CIDR,生成Vendor/rules/china-ipv4/snapshot.json。 - gfwlist(issue #65):运行
Scripts/update-gfwlist.sh,从固定版 gfwlist/gfwlistgfwlist.txt解码官方 Base64 AutoProxy 列表并生成Vendor/rules/gfwlist/snapshot.json。只转换可按目标域名无损表达的||host/||host^规则;URL 路径、协议条件、通配符、正则、单标签前缀与 IP 字面量域名规则逐类计入损失报告,绝不扩大为整域名。被更宽代理规则遮蔽的@@例外不写入无效 ACL 项(sslocal 域名匹配 proxy_list 优先于 bypass_list),保留代理规则并逐项报告。
更新失败保留上一份有效快照。geolocation-cn 转换会因未知语法/损坏输入失败;china-ipv4 转换还会因异常格式(拒绝率过高)、全部失效或相对上一份快照的异常规模变化失败;gfwlist 转换因未知语法、损坏 Base64、异常规模或相对上一份快照的异常规模变化失败。普通构建由 Scripts/verify-rule-snapshots.sh 离线校验快照存在、摘要匹配且 schema/转换器版本一致,缺失或损坏即构建失败。分发物必须携带各自的 NOTICE(许可证与归属)。
规则模式「未匹配时代理」生成的 proxy_all ACL 同时包含中国域名与中国 IPv4 CIDR 直连候选,固定本地绕过优先。规则模式「未匹配时直连」生成的 bypass_all ACL 只编入 GFWList 可生效代理候选;两种默认动作不把全部内置来源无条件并集。未匹配目标与无可用规则命中的 IP 字面目标直连。CIDR 判定可能触发本地 DNS 查询:sslocal 为未命中域名做 IP 匹配时可能发起本地 DNS 查询,产品不承诺 DNS 查询均经远端 Shadowsocks 服务器。
依赖升级
升级任一外部二进制是显式供应链动作:人工复验新 release(下载归档、静态观测哈希/架构/签名,不执行),随后在一次 PR 中同时改 Vendor/<name>/manifest.json 的 release/asset/url/archiveSHA256 与对应测试锚点。不接受 latest,不在仓库内构建,不做应用内更新通道(升级仅随 app 发版)。