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.
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).
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.
- 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)
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.