Commit Graph
785 Commits
Author SHA1 Message Date
Qiu Yuzhou 63cb42da2a fix(rules): keep enablement feedback stable and accurate (#78)
Reserve one toolbar status area through saving, deployment, and collection
refresh. Show counted transient success and persistent actionable failures,
with distinct applied, unchanged-runtime, and deferred-save results.

Keep cached rows when collection reload fails. Require explicit refresh
and fresh selection after a version conflict.

Validation: 774 hostless tests passed with local ad-hoc signing. Format,
lint, two-axis review, and isolated Chinese/English view rendering checked.
2026-10-02 22:37:02 +08:00
Qiu Yuzhou 09686a80db feat(rules): persist cross-source enablement and recover deployments (#78) 2026-10-02 21:38:03 +08:00
Qiu Yuzhou beaa92781a feat(rules): add independent offline address tests
Implement #77 using the complete saved versioned rule collection. Normalize domain, IP, and URL hosts; preserve three outcomes and all deciding identities, source memberships, and other target-specific matches without DNS or runtime dependencies.

Invalidate results when the target changes or refresh starts, and reject late asynchronous publication. Add the native address test panel and approved Chinese/English copy.

Validation: 760 hostless tests and 2 fixed-sslocal semantics smoke tests passed; ad-hoc Debug build passed. Changed-file format/lint clean; existing repository warnings remain. Standards and Spec reviews found no issues. Visual acceptance remains manual.
2026-10-02 20:59:26 +08:00
Qiu Yuzhou cb5a535297 feat(rules): simplify details and open conversion reports in a window 2026-10-02 20:38:23 +08:00
Qiu Yuzhou b8958957f0 fix(rules): stabilize report expansion and window layout
Bound source metadata and conversion reports to a scrolling region so expansion does not compete with the native table for window constraints. Replace the nested split with a fixed source list and flexible table to keep the rules page inside the main window.

Defer native binding writes until after the current view update, merge filter edits into the latest query, and suppress unchanged query and selection publications.

Validated with 744 hostless tests, an unsigned Debug build, and isolated native window checks at 920x738 and 920x580, including real source selection and disclosure clicks.
2026-10-02 18:10:00 +08:00
Qiu Yuzhou 3db66f5fff feat(rules): add read-only rule browsing (#76) 2026-10-02 17:18:08 +08:00
Qiu Yuzhou 280a3fe6fe fix(rules): make validation and ACL order independent (#75)
Normalize source-independent match/action identities while preserving custom UUIDs. Analyze complete valid candidates for same-action coverage and full or partial opposite-action shadowing without dropping saved intent. Keep duplicate and fixed-local conflicts blocking, including mapped IPv6.

Canonicalize ACL entries without changing runtime source selection or proxy-default skeleton. Verify persistence and no-restart reordering through the Controller, collection permutations, and pinned sslocal loopback comparisons.

Validation: 728 hostless tests and 2 pinned-binary smoke tests passed with ad-hoc arm64 signing; format, lint (3 existing warnings, 0 errors), and diff checks passed. No real system-proxy writes or automatic visual checks.
2026-10-02 16:34:52 +08:00
Qiu Yuzhou e3ca886b9f docs: refine language for clarity in AGENTS.md 2026-10-02 10:55:45 +08:00
Qiu Yuzhou e9712a86c0 docs: clarify maintained documentation authority 2026-10-02 10:35:56 +08:00
Qiu Yuzhou 640ebf9f51 docs: record system proxy repair and suspension design (#73) 2026-10-02 10:28:22 +08:00
Qiu Yuzhou 4a0a72c6cf feat: observe and repair system proxy configuration (#73) 2026-10-01 23:29:56 +08:00
Qiu Yuzhou 7b24033a73 fix(app): restore manage-servers button on home server list card
The home server list card redesign in 1c706aa dropped the trailing
manage-servers button; restore it in the HomeCard trailing slot wired
to the existing onManageServers callback.
2026-10-01 20:19:57 +08:00
Qiu Yuzhou 4a296930fa ui(app): update labels for unmatched rule actions in Picker components 2026-10-01 19:18:59 +08:00
Qiu Yuzhou 1c706aa65f feat: redesign home server activation tree
Preserve catalog hierarchy and order, separate selection from activation, and mark active targets and ancestors. Persist independent home expansion preferences and support explicit locate and keyboard navigation.

Show server names only and surface activation eligibility, skipped members, and failures. Refine row status visibility and use semantic selection colors for outlined activation buttons, including hover, pressed, disabled, contrast, and inactive-window states.

Validation: 695 hostless macOS arm64 tests passed for the tree implementation; 7 focused tests and ad-hoc app compilation passed after the semantic button refinement. Format and lint checks passed. Native UI interactions and theme changes remain unverified.
2026-10-01 19:03:48 +08:00
Qiu Yuzhou a7465154d2 ui(app): show selected address details as caption lines for #72
收起状态按钮只显示当前选中 IP;接口名称(接口:…)与地址类型
(地址类型:…)以说明行展示在按钮下方,与菜单项的 title/subtitle
结构对应。模型移除不再被呈现层使用的 label 组合字段。
2026-10-01 16:06:53 +08:00
Qiu Yuzhou 24666df4ac ui(app): command address menu items with native title/subtitle for #72
Picker(.menu) 的选项桥接会把多 Text 压平成独立菜单项(注记成了单独
item)。改为 Menu + adaptive-controls 桥接(Apple technote 文档化的
title/subtitle 机制):

- 菜单项主行 = IP,副标题 = 「注记 - 接口名称」(无注记时仅接口名称)
- 收起状态显示当前选中项的 {IP} - {name}(borderedButton 样式)
- 选择器下方的类型说明行保留,选中地址类型收起时也可见
2026-10-01 15:54:46 +08:00
Qiu Yuzhou 149cf39ea4 ui(app): render command address annotation as menu subtitle for #72
按 ifconfig 同源的 IPv6 类型注记改为次要文本行呈现:

- 菜单项用两个 Text 桥接 NSMenuItem 原生 subtitle(macOS 14+ 官方机制,
  Apple adaptive controls technote),主行 {IP} - {name} 不再内联注记
- 选择器下方新增说明行「地址类型:{注记}」——无论菜单桥接行为如何,
  当前选中地址的类型始终可见
- TerminalCommandAddress.label 收回纯主行,注记保持独立呈现事实;
  地址身份与命令内容不受影响
2026-10-01 15:43:31 +08:00
Qiu Yuzhou 111fa93131 feat(app): annotate and filter IPv6 command addresses by kernel flags for #72
经 SIOCGIFAFLAG_IN6 逐地址查询内核 IPv6 标志(与 ifconfig 的注记同源,
Darwin 模块不导出该 ioctl 请求宏,手工展开 _IOWR 编码):

- IN6_IFF_TEMPORARY(RFC 4941 隐私临时地址,会轮换)不进候选
- 其余 IPv6 候选在标签 IP 后附类型注记(位于中段):
  autoconf secured / autoconf / dynamic;标志查询失败不注记也不过滤
- 注记只进标签不进命令;地址身份仍为 BSD 接口名 + 规范化 IP

真实主机非视觉验证扩展:每 IPv6 地址标志查询非空、候选注记属 ifconfig
同源闭集、临时地址被过滤;替身用例覆盖 temporary 过滤与标签格式。
2026-10-01 15:19:26 +08:00
Qiu Yuzhou d647a4a859 refactor(app): address code review for #72
- 回环候选清单上收到 ListenerMode.compatibleLoopbackAddresses(Domain
  单点),proxyLoopbackAddress 派生为首项;策略不再复刻 mode→回环映射
- refreshCommandAddresses 更名 refreshTerminalCommands(返回命令的语义)
- 监视创建失败注明为尽力而为增强(进入首页/复制前刷新兜底)
- HTTPExportCapability 注释与首页完整命令的地址选择划清边界
2026-10-01 10:16:00 +08:00
Qiu Yuzhou 5c1781c2b8 docs: revise terminal command loopback convention for #72
按 issue #72 设计同步修订:ADR-0015 命令地址段落改为「默认兼容回环 +
非本机方式会话内可选 Wi‑Fi/Ethernet 地址」,表格列名改默认命令地址;
CONTEXT.md 命令定义与监听约束同步;设计文档标记已实现。健康检查与系统
代理目标仍由监听方式确定。
2026-10-01 10:07:05 +08:00
Qiu Yuzhou b26954dd20 feat(app): home terminal command address picker for #72
非「仅本机」监听方式下,首页命令卡在复制按钮上方提供单选 IP 下拉框:
候选仅含回环与当前启用 Wi‑Fi/Ethernet 接口单播地址(严格 SCNetworkInterface
类型白名单,排除 VPN/虚拟/未知类型),按监听地址族过滤(双栈含两个回环,
127.0.0.1 置顶;仅 IPv6 默认 ::1),标签 {IP} - {name}(本地化名称缺失回退
BSD 名)。两种 shell 的 HTTP/HTTPS/SOCKS 端点共用选中地址,端口取已保存
监听事实,IPv6 URL 方括号;选择会话内有效(跨首页返回保留、重启重置),
失效自动回退默认回环。进入首页、网络变化(SCDynamicStore 监视+去抖)与
复制前刷新。

- 新缝 LocalInterfaceFactsReading:原始接口事实(地址/类型/名称)+ 变化
  通知;生产适配器封装 getifaddrs/SCNetworkInterface/SCDynamicStore 细节
- 运行时缝以 listenFacts 替换 terminalProxyEnvironmentCommands;命令派生
  移至工作流层(监听事实 + 生效选择)
- 替身测试证明策略行为;真实主机非视觉验证证明系统适配器整合
  (回环识别/类型映射/地址规范化/链路本地判定)
2026-10-01 10:06:52 +08:00
Qiu Yuzhou 82411d92ea docs(design): publish terminal command address design for #72
grill-with-docs 会话产物:交互设计与测试边界定稿,spec 已发布为
issue #72;CONTEXT.md 增加 Terminal command address 词条。
2026-10-01 09:33:08 +08:00
Qiu Yuzhou 020d7a62d9 ui(app): drop redundant mode badge from home mode card 2026-09-30 22:23:52 +08:00
Qiu Yuzhou 740bf567b8 ui(app): drop subtitles from home target tree items
Group and server rows in the home target tree carried redundant
subtitles (source labels and a per-group server count) duplicating
what the tree structure already shows. Remove them so each item is
a single-line title, bumped callout to body for a stronger title
presence. TargetGroupInfo loses the now-unused sourceLabel field.
2026-09-30 22:20:26 +08:00
Qiu Yuzhou d72ecaf21f chore(codegraph): init codegraph at root dir 2026-09-30 21:53:34 +08:00
Qiu Yuzhou 2f8c4dbc2a fix(app): keep server tree collapse state across destinations
Both server trees held collapse state inside the destination
subtree — ServersView relied on OutlineGroup's built-in expansion
state and the home target tree kept a local @State set — so every
destination switch destroyed it. Hoist a shared
CatalogExpansionState to the composition root; the servers sidebar
and the home target tree now read and write one collapse set.

ServersView replaces OutlineGroup with a manual collapse tree
(flattened visible rows, .tag keeps List selection), trading away
OutlineGroup's arrow-key expand/collapse. MainApp extracts
makeRuntimeController to stay within the function-body lint budget.
2026-09-30 21:50:09 +08:00
Qiu Yuzhou ba07b38134 ui(app): remove unnecessary padding from SettingsView 2026-09-30 21:34:03 +08:00
Qiu Yuzhou d6fd30acd2 feat(app): rework status menu to six-item whitelist
Hoist "open main window" into its own section at the top; drop
the in-menu "update all subscriptions" and "copy HTTP export
line" entries, which stay available in the main workspace. The
removed copy path retires the menu's clipboard seam and its
error-alert plumbing.
2026-09-30 21:31:05 +08:00
Qiu Yuzhou 84972a5fbd feat(app): swap status bar icon to paper plane 2026-09-30 21:04:47 +08:00
Qiu Yuzhou 913696941d docs(agents): add hostless unit-test conventions to NG2 AGENTS.md 2026-09-30 20:54:08 +08:00
Qiu Yuzhou ab5a153477 docs(adr): record hostless unit tests and the appBundle resource seam in ADR-0021 2026-09-30 20:45:04 +08:00
Qiu Yuzhou 7d0ebc79b4 refactor(tests): drop TEST_HOST by linking tests against app-source static lib
单测不再以 app 可执行为宿主:新增 ShadowsocksX-NG2Core 静态库 target 同源
编译 App/(除 @main)与 Domain/,模块名沿用 ShadowsocksX_NG2 使全部测试的
@testable import 零改动;app target 模块改名 ShadowsocksX_NG2App 避免共享
Products 目录 swiftmodule 撞名。两个测试 target 改链 Core 后 xcodegen 不再
生成 TEST_HOST,xcodebuild test 只构建不启动 app——与安装版运行实例零接触
(pgrep 全程验证无 app 进程)。同源双编译沿用 Agent/Helper 共享 Domain 文件
的既定模式,Core 只被测试 target 依赖,发布链不编译它。

随之删除 MainApp.swift 的整套测试宿主脚手架(isUnitTesting/makeTesting/
Noop 服务/Ephemeral 存储,-142 行),app 在测试下不再启动,守卫失去存在
理由。宿主移除暴露三处 Bundle.main 依赖,改为显式缝:控制器新增 appBundle
注入(内置规则快照 BuiltinRuleCatalog 与 LaunchDaemon 清单指纹
launchDaemonPlistData(in:) 均按 bundle 读取,生产默认 .main 不变);XPC
listener delegate 期望客户端身份改为可注入(测试按本进程 SecCode 自查)。
测试经 AppArtifact.bundle(沿测试包落盘位置向上查找 app 产物,兼容
TEST_HOST 内嵌与平级两种布局)统一取产物。

665 单测 + 11 冒烟全绿,task build 生产构建不受影响。
2026-09-30 20:44:13 +08:00
Qiu Yuzhou ac60d14099 refactor(tests): locate app product via bundle ancestor walk, not Bundle.main
测试需要 app 产物(嵌入二进制、打包面断言)时改经 AppArtifact 沿测试包
落盘位置向上查找 ShadowsocksX-NG2.app,覆盖两种布局:TEST_HOST 过渡期
.xctest 嵌于 Contents/PlugIns/,去宿主化后与 .app 平级。接触点:
AgentLifecycleTests wrapper、BundleManagedPluginProviderTests 组合根事实
断言、smoke 三处 sslocal/wrapper/plugin 定位、SkeletonTests 改读产物
Info.plist。行为等价(665 单测 + 11 冒烟全绿),为去 TEST_HOST 铺路。
2026-09-30 20:05:37 +08:00
Qiu Yuzhou ba20e42f11 docs(adr): record BundleProgram key and throttled re-register in ADR-0020 2026-09-30 19:15:33 +08:00
Qiu Yuzhou ec92628939 fix(system-proxy): wait out launchd throttle before re-registering drifted job
实测(BTM uuid 不变、runs 持续累加):注销后立即重注会命中 launchd 尚未移除
的旧 job 定义,SMAppService 照常返回成功但定义不刷新,EX_CONFIG 循环继续。
注销后等过 minimum runtime(10s)节流窗再重注,launchd 才导入新定义
(program identifier + parent bundle 形态)并以新定义拉起 daemon。间隔经
controller 注入,生产 15s、用例 10ms。

已在实机验证全链路:BundleProgram 定义导入 → launchd 按需拉起 root daemon →
客户端签名校验 → apply 写入 SystemConfiguration(scutil --proxy 呈现
SOCKS/HTTP/HTTPS 指向本地入口,PAC 与自动发现关闭)→ 应答返回 → 二次收敛走
等价零写入路径。
2026-09-30 19:15:06 +08:00
Qiu Yuzhou 3bb6b88fbb chore(gitignore): add Taskfile.yml to .gitignore 2026-09-30 18:56:30 +08:00
Qiu Yuzhou 43ea00cb44 fix(system-proxy): use BundleProgram in daemon plist and refresh stale registration
launchd 对 ProgramArguments[0] 的相对路径按 _PATH_STDPATH(PATH)解析而非
bundle:系统域 daemon 的 job 因此永远 spawn 失败(EX_CONFIG 节流循环,
launchctl print 显示 job state = spawn failed、runs 持续累加),客户端 XPC
调用只能等到超时。agent 之所以正常,是 GUI 域导入时被解析成绝对路径,system
域 submitted job 没有这一步。SMAppService 清单的正确键是 BundleProgram
(bundle 相对,launchd.plist(5)),改用之。

launchd 同时沿用注册提交时的定义快照:app 更新改动 LaunchDaemon 清单后旧
定义不会自动刷新(register() 对已注册服务返回 alreadyRegistered),必须注销
重注。门禁在已批准分支按清单内容 SHA-256 指纹(UserDefaults)检测漂移,漂移
才注销重注——审批与 bundle 签名绑定,重注不重新弹授权;首次注册成功即记指
纹,失败不记(下次启动重试),指纹一致为一次哈希比对的 no-op。
2026-09-30 18:54:19 +08:00
Qiu Yuzhou c1aba5a882 fix(system-proxy): make helper XPC connection reachable and crash-free
GUI 侧 XPC 回调闭包(invalidationHandler 与 remoteObjectProxy 错误回调)在
@MainActor 方法里形成、传给非 @Sendable 的 Foundation 参数被推断为主 actor
隔离,Foundation 在 XPC 内部队列上调起时触发动态隔离断言直接崩溃;显式标注
@Sendable(nonisolated),闭包只捕获 Sendable 应答闸门。

连接系统域 LaunchDaemon 的 MachService 必须带 options: .privileged(否则只
在 per-user launchd 域查找,永远失败走错误回调);并把 resume() 提到首次远
程调用之前,超时兜底任务在应答到达后取消。

收敛/清除失败补 RuntimeLog systemProxyWriteFailed(detail:)(诊断报告白名单
按既有承诺脱敏收录),XPC/ helper 层失败原因不再只能靠崩溃报告逆向。

listener delegate 与客户端校验移入 Domain 共享编译单元(与引擎同模式),
main.swift 改为显式注入引擎;新增真实 XPC 装配层 round-trip 测试:匿名监听
器挂真实 delegate(注入假特权写入器)+ listenerEndpoint 连接,覆盖接口签
名、客户端签名校验、Data 传输与 XPC 队列应答路径,无需 launchd。
2026-09-30 13:40:16 +08:00
Qiu Yuzhou d2a8d6105c feat(system-proxy): route writes through privileged LaunchDaemon helper
Implements issue #71 with ADR-0020 (supersedes ADR-0019's endpoint-signature
cleanup, startup cleanup, and health-gate cleanup).

- New ShadowsocksX-NG2SystemProxyHelper target: app-bundled root LaunchDaemon
  (SMAppService.daemon, on-demand via MachServices) that mechanically applies
  a closed typed configuration or executes an unconditional clear. Clients are
  accepted by code-signing bundle identifier only; requests run serially on a
  shared queue (last-writer-wins).
- SystemProxyConfiguration is now the closed typed apply payload (Codable over
  JSON XPC): explicit SOCKS/HTTP/HTTPS enable + endpoints, PAC and
  auto-discovery disabled with URL/JS removed, explicit simple-hostname
  exclusion, complete ExceptionsList. Unmodeled Proxies keys are preserved and
  equivalent values skip the commit.
- Cleanup is a side effect of a persisted system-proxy intent on-to-off
  transition only: the switch itself, or the agent-off cascade (persist both
  intents off, clear, then stop listeners; failed cascade persistence records
  no transition and clears nothing). Launch with the switch off, health-gate
  closure, invalid targets, and passive events never clear. Failed clears are
  surfaced without deferred retry.
- Endpoint-signature store, ownership migration, and direct Authorization
  Services writes are removed; old signature files stay inert. Helper
  unavailability or pending approval keeps an enabled intent pending and
  surfaces the Login Items approval path (HomeView button + menu label).
2026-09-30 12:26:40 +08:00
Qiu Yuzhou 08682724c9 fix(system-proxy): reconcile configuration across network changes 2026-09-29 23:39:25 +08:00
Qiu Yuzhou 5aad79e41f feat(ui): simplify sidebar proxy status card 2026-09-29 21:13:34 +08:00
Qiu Yuzhou 9284b1c9a1 feat: export configuration groups as SIP-008 JSON 2026-09-29 20:16:05 +08:00
Qiu Yuzhou 1b57807cba feat(ng2): add no_proxy to terminal proxy commands 2026-09-29 17:42:16 +08:00
Qiu Yuzhou 59f50380cf fix(home): show shell command in copy tooltip 2026-09-29 17:18:17 +08:00
Qiu Yuzhou e8c2e86068 feat(home): add shell-specific proxy environment commands 2026-09-29 16:42:50 +08:00
Qiu Yuzhou c50470dc89 refactor(settings): move silent-launch persistence to UserDefaults
Migrate SilentLaunchStore from its versioned JSON file to the app's
standard defaults domain (key `silentLaunch`), the platform's canonical
home for GUI-scalar preferences, so the GUI preference category stops
growing one file per item. Reads stay safe-side strict: a missing key
or a value not bridgeable to Bool loads as not silent (plain
bool(forKey:) would coerce "yes" to true). The file-form
persistence-failure naming path is dropped — UserDefaults.set reports
no failure — so SilentLaunchController keeps its persist-then-flip seam
while errorMessage and its SettingsView presentation go away.

The legacy file never shipped in any release; migrateLegacyFileIfPresent
runs in the production composition root only, preserves the value on
developer machines, and deletes the file (no-op when absent). The test
composition uses a dedicated suite cleared at assembly; unit tests use
per-test suites with removePersistentDomain teardown, never the
standard domain.

ADR-0017 carries a dated amendment (Status plus the affected
consequence line); the settings.json separation rationale stands
unchanged.
2026-09-29 16:06:52 +08:00
Qiu Yuzhou 8b698a0bf1 feat(app): adaptive app form with window-following activation policy
- switch to regular while the workspace window is open (Dock icon,
  app switcher, default menu bar) and back to accessory when it closes;
  driven by NSWindow didBecomeKey/willClose with an anchor-view identity
  filter (scenePhase follows the app rather than windows on macOS, so it
  fires no event on close)
- add silent launch preference (settings-general, off by default,
  persisted in its own silent-launch.json since settings.json is rewritten
  whole from the controller's in-memory snapshot): suppressed launches
  keep the menu-bar baseline and proxy runtime restoration is unaffected
- docs: CONTEXT.md adaptive app form / silent launch terms and invariants;
  ADR 0017 superseding 0016 (scene-based model and reopening carry over)
2026-09-29 15:13:30 +08:00
Qiu Yuzhou 747c6f24af docs(agents): mark docs/research as point-in-time research reports
A research doc was treated as an adopted in-flight design and a design
round was built on its proposal until corrected: nothing in the repo
documents said docs/research/ holds capability studies rather than
project state, so the directory's semantic status had to be inferred.
State it beside the other domain-truth pointers — research reports
record what was investigated at writing time, current state and adopted
designs live in code, CONTEXT.md and docs/adr/, and a report cited as
implementation basis must be verified in code first.
2026-09-29 12:04:30 +08:00
Qiu Yuzhou b8e5919bcd fix(runtime): converge proxy runtime only when contract bytes change
Every catalog commit re-expanded the active target and ran the full
deploy cycle unconditionally: the status face flashed back to starting,
the launch health gate re-probed both local inbounds, and system proxy
converged again — even when the derived contract was byte-identical to
what was already deployed. Remark edits went further: remarks were part
of SslocalServerDocument, so renaming the active server rewrote the
contract and signalled sslocal for a change with no runtime effect.

The reload predicate is now contract byte equality, shared by both
layers (CONTEXT.md: a committed edit that leaves the runtime
configuration file's bytes unchanged triggers no runtime convergence
work). remarks leave the contract — upstream treats them as optional
metadata and the GUI resolves display names from the catalog — so
remark-only edits and no-op re-saves derive byte-identical documents.
The catalogDidCommit deploy path skips write, signal, health gate and
system proxy converge when ProxyRuntimePlan.actions returns empty
(registered agent, live wrapper, on-disk contract equals the derived
document); disk drift or a dead wrapper keeps the self-healing deploy.
Catalog revalidation (candidate skips, target invalidation) still runs
on every commit, and explicit intents (activation, switches, settings,
launch resync) are untouched.

The remark-edit runtime test — which encoded the old behavior as the
example of a value change — is replaced by four catalog-convergence
cases: remark-only edit and outside-subtree edit stay untouched, group
member and active server value edits rewrite and signal.

Also repairs the smoke suite, which failed on HEAD before this change:
its ACL documents used a sslocal-active.acl path while
RuntimeFileStore.write validates against acl-active.ini, so every
ACL-bearing case threw before spawning the wrapper. Smoke ACL paths now
bind to the store-validated location, and the suite passes 11/11 against
real sslocal, confirming upstream accepts the contract without remarks.
2026-09-29 11:57:37 +08:00
Qiu Yuzhou 53c84384a4 fix(system-proxy): skip write when applied values already match desired
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.
2026-09-29 09:34:10 +08:00