Files
ShadowsocksX-NG/ShadowsocksX-NG2/Domain/RuntimeFileStore.swift
T
Qiu Yuzhou 6bfd3c0d35 feat(ng2): proxy runtime LaunchAgent, wrapper and sslocal lifecycle (#27)
GUI → LaunchAgent → wrapper → sslocal full chain (spec #21 D2/D5):

- ShadowsocksX-NG2Agent: Swift BundleProgram tool target embedded at
  Contents/MacOS, supervised by launchd via SMAppService agent plist
  (Contents/Library/LaunchAgents, bundle-relative ProgramArguments,
  KeepAlive={SuccessfulExit:false}). Wrapper reads the cross-process
  contract v2/sslocal-active.json, spawns sslocal -c <absolute path>
  and supervises it: SIGTERM chain stop, SIGUSR1 forward for
  server-only changes vs graceful restart on listen fingerprint
  changes, non-zero exit on unexpected child loss for KeepAlive
  replay, invalid/missing contract → clean exit with plain unlink.
- Runtime file protocol: RuntimeFileStore reuses AtomicFileWriter
  (0700 dir self-heal, 0600 temp at creation, atomic replace);
  explicit-stop cleanup unlinks contract, agent pid file and stale
  temporaries. Shared read-side validation via
  SslocalRuntimeDocument.decodeValidated so GUI and wrapper cannot
  drift. Wrapper pid file bridges SMAppService's missing pid.
- GUI: ProxyRuntimeController consumes ActivationStateMachine effects
  through pure ProxyRuntimePlan decision table (ordered actions,
  idempotent skip of identical contract); proxy toggle and launch
  failure presentation (naming endpoint:port) in the status menu;
  launch resync reconciles agent registration, wrapper liveness and
  on-disk contract; catalog commits re-expand immediately.
- Log sanitization baseline: closed RuntimeLogEvent enum plus Redactor,
  wrapper/sslocal output converged to a 0600 agent.log with RUST_LOG
  defaulting to warn.

Tests (126 total, all green): stub-sslocal driven wrapper lifecycle
(start/stop order, SIGTERM chain, crash replay, cleanup, reload
protocol), real sslocal smoke (SOCKS port binding + full SOCKS5
handshake), plan decision table, controller flows with fakes,
runtime file permissions/atomic replace, endpoint probe, redaction.

Manual release-gate items (real launchd/SMAppService): agent
registration approval flow, GUI-quit persistence, bootout behaviors.

Closes #27
2026-09-20 20:26:54 +08:00

68 lines
2.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// 运行时文件存取(spec #21 D5,issue #27):`v2/sslocal-active.json` 是 GUI
/// 写、wrapper 读的跨进程交接契约。写入复用 `AtomicFileWriter` 的物理安全
/// 基线(目录 0700 含自愈、临时文件创建即 0600、写全校验后原子替换);显式
/// 停止后的清理只做普通 unlink,不承诺安全擦除(D5)。
struct RuntimeFileStore {
enum PersistenceError: Error, Equatable {
/// 写入时的文件系统错误。
case ioFailure(detail: String)
}
let fileURL: URL
init(fileURL: URL = RuntimePaths.runtimeFileURL()) {
self.fileURL = fileURL
}
/// wrapper pid 文件与契约同目录(v2/):GUI 判活与 SIGUSR1 投递依据
/// (SMAppService 不暴露运行中 agent 的 pid)。
var pidFileURL: URL {
fileURL.deletingLastPathComponent().appendingPathComponent("agent.pid")
}
/// 原子写盘;任一步失败保留原文件。
func write(_ document: SslocalRuntimeDocument) throws {
let data: Data
do {
data = try document.jsonData()
} catch {
throw PersistenceError.ioFailure(detail: String(describing: error))
}
do {
try AtomicFileWriter.write(data, to: fileURL)
} catch {
throw PersistenceError.ioFailure(detail: String(describing: error))
}
}
/// 读取侧判定与 wrapper 同源:缺失、损坏或结构性无效一律 `nil`。
func loadDocument() -> SslocalRuntimeDocument? {
guard let data = try? Data(contentsOf: fileURL) else { return nil }
return SslocalRuntimeDocument.decodeValidated(data)
}
/// 磁盘原始字节,供与派生文档比较以跳过相同内容的写入(幂等)。
func readData() -> Data? {
try? Data(contentsOf: fileURL)
}
/// 显式停止清理(D2 停止协议末端):运行时契约文件、wrapper pid 文件与
/// 原子写残留的临时文件。异常崩溃路径不调用本方法(文件保留供 KeepAlive
/// 重放,D5)。文件缺失时调用无害。
func deleteRuntimeFiles() {
let fileManager = FileManager.default
try? fileManager.removeItem(at: fileURL)
try? fileManager.removeItem(at: pidFileURL)
let directory = fileURL.deletingLastPathComponent()
guard let contents = try? fileManager.contentsOfDirectory(atPath: directory.path) else {
return
}
let temporaryPrefix = ".\(fileURL.lastPathComponent).tmp-"
for name in contents where name.hasPrefix(temporaryPrefix) {
try? fileManager.removeItem(at: directory.appendingPathComponent(name))
}
}
}