Commit Graph
758 Commits
Author SHA1 Message Date
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
Qiu Yuzhou e0f9016116 feat(servers): add form-based new-server creation with toolbar entry
Servers view toolbar gains an icon-only new-server button opening a
sheet that mirrors the edit form (port 8388 / aes-256-gcm defaults,
managed-plugin section). New CatalogWorkflow.createServer command keeps
validation symmetric with edit commit: form validation fails bare
before the credential journal; password and plugin options commit with
the catalog as one logical change and roll back on persistence
failure. Placement reuses importTargetParent semantics; success
dismisses and selects the new server. Fresh UUID manual source, no
content dedup, activation target untouched.
2026-09-28 22:03:41 +08:00
Qiu Yuzhou a3ff3c4f92 Revert "fix(servers): align group action with list"
This reverts commit 55d2a58e3e.
2026-09-28 21:07:51 +08:00
Qiu Yuzhou a668340a97 fix(ui): adjust safe area inset spacing for header and status card 2026-09-28 20:52:27 +08:00
Qiu Yuzhou 005f4db63b fix(ui): adjust sidebar column width for better layout 2026-09-28 20:48:47 +08:00
Qiu Yuzhou 55d2a58e3e fix(servers): align group action with list 2026-09-28 20:45:34 +08:00
Qiu Yuzhou 3108b3ad5a fix(ui): place add menu in sidebar toolbar 2026-09-28 19:36:53 +08:00
Qiu Yuzhou 0d0e74d59c feat(ui): menu-bar-only form with scene-based main window
Replace the hybrid form (LSUIElement + runtime activation-policy
switching + AppKit-owned NSWindow) with a plain SwiftUI Window scene:
the app stays accessory at all times, launch presents the workspace
window via defaultLaunchBehavior(.presented), and the status menu
reopens it after close via openWindow. Drop AppDelegate and the
window-opening adapter and intents; WorkspaceRoute keeps navigation
state only, with the scene id defined there. Documented in ADR 0016
and CONTEXT.md.
2026-09-28 19:18:27 +08:00
Qiu Yuzhou c9384d2de9 fix(ui): fix filename for ServersView 2026-09-28 18:32:42 +08:00
Qiu Yuzhou 4f67c4a73c fix(ui): align add menu with sidebar toggle 2026-09-28 12:44:56 +08:00
Qiu Yuzhou 63e33724f7 feat(ui): consolidate workspace add actions 2026-09-28 11:45:58 +08:00
Qiu Yuzhou 92040baef0 fix(ng2): remove direct clipboard import shortcut 2026-09-28 10:54:34 +08:00
Qiu Yuzhou 67ced2674a fix(ng2): use canonical app name 2026-09-28 10:24:06 +08:00
Qiu Yuzhou 5abb6c6033 build: migrate NG2 to Swift 6 and clear warnings 2026-09-28 08:44:53 +08:00
Qiu Yuzhou 2946c34ddb refactor(settings): remove whole-form draft workflow 2026-09-28 08:09:35 +08:00
Qiu Yuzhou 8fd451b22e style(settings): simplify section headings 2026-09-28 07:47:22 +08:00
Qiu Yuzhou 93af9806df feat(settings): edit system proxy exceptions
Keep user entries separate from app-fixed ACL bypass rules, and fix
simple-hostname exclusion while the app owns system proxy settings.
2026-09-27 23:44:21 +08:00
Qiu Yuzhou cf1834a775 feat(ng2): add four-way listener mode 2026-09-27 20:58:54 +08:00
Qiu Yuzhou 5a05b8a661 refactor(ng2): use sslocal runtime defaults 2026-09-27 17:51:34 +08:00
Qiu Yuzhou 59665f9b76 fix(settings): align SOCKS label wording 2026-09-27 17:24:25 +08:00
Qiu Yuzhou b99087532c fix(settings): refine port editor alignment 2026-09-27 17:21:23 +08:00
Qiu Yuzhou e62ce007d7 feat(settings): save proxy ports independently 2026-09-27 17:07:47 +08:00
Qiu Yuzhou 8013e7942c feat(gitignore): add .gitignore for CodeGraph data files 2026-09-27 16:08:13 +08:00
Qiu Yuzhou 88af8bfdf2 feat(system-proxy): write HTTP/HTTPS alongside SOCKS
Apps honoring only the HTTP proxy family were never proxied under
the SOCKS-only projection. Point system HTTP and HTTPS at the
always-on sslocal HTTP inbound (CONNECT); reject documents lacking
that inbound instead of aiming at port 0. See ADR 0012.
2026-09-27 15:49:03 +08:00
Qiu Yuzhou effae048b8 feat(settings): remove holistic preferences reset
Not every preference warrants a reset; future resets are per
configuration item, added only where a field truly needs one (tracked
in #69). The whole chain goes: the 恢复默认 button and its confirmation
alert in SettingsView, the SettingsWorkflow reset/confirm/cancel
commands and the confirmation machinery they existed for (the
SettingsConfirmation enum had a single case), the
SettingsCommitting.resetPreferences seam entry, the runtime
resetPreferences() and ProxySettingsFileStore.reset() with its
file-rollback helpers and the rollbackFailed store error.

CONTEXT.md drops the three stale reset phrases and now records the
domain decision: no restore-all-factory-defaults action for
preferences; a reset, where it exists, is scoped to one configuration
item. ADR-0004/0005/0011 carry amendment notes since their reset
sentences describe a removed capability (0004's also predate the
PAC removal).
2026-09-27 13:29:32 +08:00
Qiu Yuzhou 8aedf8c42e perf(acl): per-variant ACL files behind a stable active link
Stop rewriting the whole ACL on every proxy-mode switch: materialize one
file per routing shape (acl-<summary>.ini), keep acl-active.ini as a stable
symlink the contract points at, and skip unchanged writes via a digest
manifest. The contract carries only path/summary/sha256 — the agent checks
the link stays inside the runtime directory and never reads ACL bytes.

Agent switch now defaults to off (no server config on a fresh install) and
survives preferences reset, which also returns any held system proxy and
converges a still-running agent onto the factory runtime. See ADR-0011.
2026-09-27 03:14:38 +08:00
Qiu Yuzhou 851660f1c6 test: split real-process smoke into opt-in target
Real sslocal smoke (11 tests, ~27s) sat on every task test as the
slow tail. Tests/Smoke now builds as a separate target behind the
ShadowsocksX-NG2Smoke scheme; task test:smoke runs it on demand and
the README release gate requires it green next to packaging-gate.

SignalRecorder also gains kill(_, 0) semantics (stale pids report
ESRCH): the GUI-restart test burned the 5s waitForWrapperExit
ceiling on pid 99999, now 0.18s. Default lane: 617 tests ~40s.
2026-09-27 01:11:48 +08:00
Qiu Yuzhou e25e211165 perf(test): default fixtures to short launch health window
Negative-path tests with a refusing FakeProbe each burned the full
production 15s launch health window; the three 30s tests run two
windows (activate + setAgentEnabled). Five tests accounted for 120s
of the 188.5s suite.

All test-side controller constructors now inject 0.05s; production
default stays 15s. Deterministic fakes exercise the same timeout
presentation path, so the suite drops to 71.4s with no behavior
change. Remaining slow tests are real-process smoke tests.
2026-09-27 00:54:10 +08:00