Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/SystemConfigurationLegacyProxyCleaner.swift
T
Qiu Yuzhou 8d1cdf27d1 feat(ng2): Legacy 交接第二阶段(#37)
与导入分离的「切换到 2.0」确认流程(spec #21 D12):

- Domain/LegacyHandoff:硬编码白名单三个 Legacy label(.local/.http/.kcptun),
  bootout 按 service target(覆盖 plist 缺失错位形态),print 退出码验证
  (0/113),`launchctl kill SIGTERM` 有文档后备,绝不泛匹配、不 pkill;
  残留 plist(含 KeepAlive 形态)读内容判活跃性后 disable,阻止下次登录复活;
  系统代理按特征值判定归属(Legacy 自身 off 模式的判定镜像:本地 PAC URL
  http://localhost:<port>/proxy.pac、SOCKS/HTTP 地址端口),仅清理归属为
  Legacy 的服务,未知所有者点名保留;端口释放门禁用无复用选项的 bind 探测
  (Legacy ss-local 带 --reuse-port),未释放不启动 2.0;Legacy 数据零删除。
- App 层:SystemConfigurationLegacyProxyCleaner(授权在最前、失败零影响、
  事务回滚);WorkspaceLegacyAppController(共享 bundle id 需排除自身进程);
  LegacyHandoffSheet 确认/进度/报告呈现,含旧登录项人工指引与残留提示;
  主窗口工具栏入口 + 导入完成后「切换到 2.0」入口;交接成功经正常启停入口
  启动 2.0(健康门禁走 #29 路径)。
- bootout 实弹验证(Further Notes #5)闭环:docs/research/
  legacy-handoff-bootout-live-fire.md——bootout 未加载 label 退出码为 3
  (非 print 的 113),KeepAlive 残留 kill 后复活、bootout 后不再加载,
  结论已回写产品代码与测试。
2026-09-21 12:49:49 +08:00

204 lines
7.8 KiB
Swift

import Foundation
import Security
import SystemConfiguration
/// Legacy 写入的系统代理清理(issue #37):读取当前网络位置每个服务的
/// Proxies 字典并按 Legacy 特征值判定归属,只改写判定为 Legacy 的服务
/// (禁用全部代理面、移除端点值);空配置跳过;未知所有者原样保留并点名
/// 返回,绝不覆盖。事务纪律与 SystemConfigurationProxyController 一致:
/// 写入失败回滚到本次操作前状态。实现只抛 LegacyHandoffError.proxyCleanFailed。
final class SystemConfigurationLegacyProxyCleaner: LegacyProxyCleaning {
private struct ServiceSnapshot {
let id: String
let proxyProtocol: SCNetworkProtocol
let configuration: Data?
}
func cleanLegacyOwnedProxy(ports: LegacyListenPorts) throws -> LegacyProxyCleanOutcome {
do {
return try withPreferences { preferences in
let (legacyServices, unknownOwnerServiceIDs) =
try classifyServices(in: preferences, ports: ports)
guard !legacyServices.isEmpty else {
return LegacyProxyCleanOutcome(
cleanedServiceIDs: [], unknownOwnerServiceIDs: unknownOwnerServiceIDs)
}
let cleanedServiceIDs = try clean(legacyServices, in: preferences)
return LegacyProxyCleanOutcome(
cleanedServiceIDs: cleanedServiceIDs,
unknownOwnerServiceIDs: unknownOwnerServiceIDs)
}
} catch let error as LegacyHandoffError {
throw error
} catch {
throw LegacyHandoffError.proxyCleanFailed(detail: describe(error))
}
}
// MARK: - SystemConfiguration 管道(与 SystemConfigurationProxyController 同纪律)
/// 归属分类(只读):Legacy 服务捕获原字典供清理投影;未知所有者点名返回。
private func classifyServices(
in preferences: SCPreferences, ports: LegacyListenPorts
) throws -> (
legacy: [(ServiceSnapshot, [String: Any])], unknownOwnerIDs: [String]
) {
let services = try serviceSnapshots(in: preferences)
guard !services.isEmpty else {
throw LegacyHandoffError.proxyCleanFailed(
detail: SystemProxyError.noProxyServices.presentedReason)
}
var legacyServices: [(ServiceSnapshot, [String: Any])] = []
var unknownOwnerIDs: [String] = []
for service in services {
let dictionary = try readableDictionary(
from: service.configuration, serviceID: service.id)
switch LegacySystemProxySignature.classify(dictionary, ports: ports) {
case .none:
continue
case .legacy:
legacyServices.append((service, dictionary))
case .other:
unknownOwnerIDs.append(service.id)
}
}
return (legacyServices, unknownOwnerIDs)
}
/// 写入并提交;失败回滚到本次操作前状态。
private func clean(
_ legacyServices: [(ServiceSnapshot, [String: Any])], in preferences: SCPreferences
) throws -> [String] {
try SCPreferencesLockOrThrow(preferences)
defer { SCPreferencesUnlock(preferences) }
do {
for (service, dictionary) in legacyServices {
let cleaned = LegacySystemProxySignature.cleaned(dictionary)
guard SCNetworkProtocolSetConfiguration(service.proxyProtocol, cleaned as CFDictionary)
else {
throw LegacyHandoffError.proxyCleanFailed(
detail: SystemProxyError.cannotWriteService(service.id).presentedReason)
}
}
try commitAndApply(preferences)
} catch {
let rollbackSucceeded = rollback(legacyServices, in: preferences)
var detail = describe(error)
if !rollbackSucceeded {
detail += ";系统代理变更回滚失败:\(systemConfigurationError())"
}
throw LegacyHandoffError.proxyCleanFailed(detail: detail)
}
return legacyServices.map { $0.0.id }
}
private func withPreferences(
_ body: (SCPreferences) throws -> LegacyProxyCleanOutcome
) throws -> LegacyProxyCleanOutcome {
var authorization: AuthorizationRef?
let flags: AuthorizationFlags = [.interactionAllowed, .extendRights, .preAuthorize]
let authorizationStatus = AuthorizationCreate(nil, nil, flags, &authorization)
guard authorizationStatus == errAuthorizationSuccess, let authorization else {
throw LegacyHandoffError.proxyCleanFailed(
detail: SystemProxyError.authorizationFailed(authorizationStatus).presentedReason)
}
defer { AuthorizationFree(authorization, []) }
guard
let preferences = SCPreferencesCreateWithAuthorization(
nil, "ShadowsocksX-NG" as CFString, nil, authorization)
else {
throw LegacyHandoffError.proxyCleanFailed(
detail: SystemProxyError.preferencesUnavailable.presentedReason)
}
return try body(preferences)
}
private func serviceSnapshots(in preferences: SCPreferences) throws -> [ServiceSnapshot] {
guard let set = SCNetworkSetCopyCurrent(preferences) else {
throw LegacyHandoffError.proxyCleanFailed(
detail: SystemProxyError.noCurrentNetworkSet.presentedReason)
}
guard let services = SCNetworkSetCopyServices(set) as? [SCNetworkService] else {
throw LegacyHandoffError.proxyCleanFailed(
detail: SystemProxyError.noProxyServices.presentedReason)
}
return try services.compactMap { service in
guard
let serviceID = SCNetworkServiceGetServiceID(service) as String?,
let proxyProtocol = SCNetworkServiceCopyProtocol(service, kSCNetworkProtocolTypeProxies)
else { return nil }
return ServiceSnapshot(
id: serviceID,
proxyProtocol: proxyProtocol,
configuration: try propertyListData(
from: SCNetworkProtocolGetConfiguration(proxyProtocol), serviceID: serviceID))
}
}
private func rollback(
_ services: [(ServiceSnapshot, [String: Any])], in preferences: SCPreferences
) -> Bool {
for (service, original) in services {
guard SCNetworkProtocolSetConfiguration(service.proxyProtocol, original as CFDictionary)
else {
return false
}
}
return SCPreferencesCommitChanges(preferences) && SCPreferencesApplyChanges(preferences)
}
private func readableDictionary(
from data: Data?, serviceID: String
) throws -> [String: Any] {
guard let data else { return [:] }
guard
let propertyList = try? PropertyListSerialization.propertyList(
from: data, options: [], format: nil),
let dictionary = propertyList as? [String: Any]
else {
throw LegacyHandoffError.proxyCleanFailed(
detail: SystemProxyError.invalidStoredConfiguration(serviceID).presentedReason)
}
return dictionary
}
private func propertyListData(
from value: CFPropertyList?, serviceID: String
) throws -> Data? {
guard let value else { return nil }
guard let dictionary = value as? [String: Any] else {
throw LegacyHandoffError.proxyCleanFailed(
detail: SystemProxyError.unreadableService(serviceID).presentedReason)
}
return try PropertyListSerialization.data(
fromPropertyList: dictionary, format: .binary, options: 0)
}
private func commitAndApply(_ preferences: SCPreferences) throws {
guard SCPreferencesCommitChanges(preferences) else {
throw LegacyHandoffError.proxyCleanFailed(
detail: "提交失败:\(systemConfigurationError())")
}
guard SCPreferencesApplyChanges(preferences) else {
throw LegacyHandoffError.proxyCleanFailed(
detail: "应用失败:\(systemConfigurationError())")
}
}
private func describe(_ error: Error) -> String {
(error as? SystemProxyError)?.presentedReason ?? String(describing: error)
}
private func systemConfigurationError() -> String {
String(cString: SCErrorString(SCError()))
}
}
private func SCPreferencesLockOrThrow(_ preferences: SCPreferences) throws {
guard SCPreferencesLock(preferences, true) else {
throw LegacyHandoffError.proxyCleanFailed(
detail: SystemProxyError.preferencesBusy.presentedReason)
}
}