diff --git a/ShadowsocksX-NG2/App/LegacyAppController.swift b/ShadowsocksX-NG2/App/LegacyAppController.swift new file mode 100644 index 00000000..4bb39565 --- /dev/null +++ b/ShadowsocksX-NG2/App/LegacyAppController.swift @@ -0,0 +1,47 @@ +import AppKit + +/// NSWorkspace 实现的 Legacy app 检测缝(issue #37):NG2 与 Legacy 共享 +/// bundle id(D1 继承),「正在运行的旧版 app」= 该 bundle id 下除自身进程外 +/// 的任何进程。NSRunningApplication 要求主线程,非主线程调用同步派发—— +/// 调用方(交接检测在后台任务)挂起等待而非阻塞主线程,无死锁。 +final class WorkspaceLegacyAppController: LegacyAppControlling { + static let legacyBundleIdentifier = "com.qiuyuzhou.ShadowsocksX-NG" + + func isRunning() -> Bool { + MainActor.runUnsafelyIfNeeded { legacyApplication() != nil } + } + + func isInstalled() -> Bool { + Self.bundleURLs.contains { FileManager.default.fileExists(atPath: $0.path) } + } + + func requestGracefulQuit() -> Bool { + MainActor.runUnsafelyIfNeeded { legacyApplication()?.terminate() ?? false } + } + + /// 常见安装位置(Legacy LaunchHelper 自身也按这些位置找主 app)。 + private static let bundleURLs: [URL] = [ + URL(fileURLWithPath: "/Applications/ShadowsocksX-NG.app"), + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( + "Applications/ShadowsocksX-NG.app", isDirectory: true), + ] + + private func legacyApplication() -> NSRunningApplication? { + NSRunningApplication + .runningApplications(withBundleIdentifier: Self.legacyBundleIdentifier) + .first { $0.processIdentifier != ProcessInfo.processInfo.processIdentifier } + } +} + +extension MainActor { + /// 供非主线程上下文同步执行 AppKit 调用的小帮手;主线程上直接执行。 + fileprivate static func runUnsafelyIfNeeded(_ body: @MainActor () -> T) -> T { + if Thread.isMainThread { + // 主线程上已满足隔离要求,直接执行(MainActor.assumeIsolated)。 + return MainActor.assumeIsolated(body) + } + return DispatchQueue.main.sync { + MainActor.assumeIsolated(body) + } + } +} diff --git a/ShadowsocksX-NG2/App/LegacyHandoffSheet.swift b/ShadowsocksX-NG2/App/LegacyHandoffSheet.swift new file mode 100644 index 00000000..a7dc3ecf --- /dev/null +++ b/ShadowsocksX-NG2/App/LegacyHandoffSheet.swift @@ -0,0 +1,291 @@ +import ServiceManagement +import SwiftUI + +/// Legacy 交接确认与呈现面(issue #37,用户故事 7-9):显式确认「切换到 +/// 2.0」后才停用旧版后台服务;告知系统代理将被清理(需授权)且非 Legacy 代 +/// 理不动;提示退出旧版 app(其 GUI 进程持有 PAC 端口);Legacy 数据全部保 +/// 留,残留与旧登录项给人工处理指引。 +struct LegacyHandoffSheet: View { + @ObservedObject var viewModel: LegacyHandoffViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(title) + .font(.title2.weight(.semibold)) + Text( + "此操作会:停用旧版的三个后台服务(ss-local、Privoxy、kcptun,仅限这几个名字);清理旧版写入的系统代理(需要授权,其他软件的代理配置不会被改动)。旧版的配置、日志、二进制等数据全部保留,可随时回退。" + ) + .font(.callout) + .foregroundStyle(.secondary) + + switch viewModel.phase { + case .idle: + ProgressView().controlSize(.small) + case .ready: + detectionView + case .performing: + performingView + case .completed: + completedView + case .failed(let reason): + failedView(reason) + } + + Spacer() + footerButtons + } + .padding(24) + .frame(width: 560, height: 620) + .task { + await viewModel.refresh() + } + } + + private var title: String { + if case .completed = viewModel.phase { return "交接完成" } + return viewModel.handoffCompleted ? "Legacy 交接状态" : "切换到 2.0" + } + + // MARK: - 识别结果(确认前) + + @ViewBuilder + private var detectionView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if let detection = viewModel.detection { + if detection.legacyAppRunning { + legacyAppRunningCard + } + if !detection.printFailures.isEmpty { + Label( + "无法确认部分旧版服务状态:\(detection.printFailures.joined(separator: "、"))", + systemImage: "exclamationmark.triangle.fill" + ) + .foregroundStyle(.orange) + } + + stateSection(title: "旧版后台服务(launchd)") { + ForEach(LegacyLaunchAgentLabel.allCases, id: \.rawValue) { label in + let loaded = detection.loadedLabels.contains(label.rawValue) + HStack { + Image(systemName: loaded ? "circle.fill" : "circle") + .foregroundStyle(loaded ? Color.orange : Color.secondary) + .font(.system(size: 8)) + Text(loaded ? "运行中:" + label.rawValue : "未加载:" + label.rawValue) + .font(.callout) + } + } + } + + let residues = detection.plists.filter { $0.fileExists } + if !residues.isEmpty { + stateSection(title: "残留的旧版 LaunchAgent 配置(保留,不自动删除)") { + ForEach(residues, id: \.label) { residue in + Text(residueDescription(residue)) + .font(.callout) + .foregroundStyle( + residue.isActiveForm ? AnyShapeStyle(.orange) : AnyShapeStyle(.secondary)) + } + } + } + + stateSection(title: "端口释放检查(交接完成前确认 1086/1087/1089 或自定义值)") { + Text("旧版监听端口未释放时不会启动 2.0,避免端口冲突。") + .font(.callout) + .foregroundStyle(.secondary) + } + + loginItemGuidance(detection: detection) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var legacyAppRunningCard: some View { + VStack(alignment: .leading, spacing: 8) { + Label( + "旧版 ShadowsocksX-NG 正在运行", systemImage: "exclamationmark.triangle.fill" + ) + .foregroundStyle(.orange) + Text("请先退出旧版 app:它占用的 PAC 端口不会被后台服务的停用动作释放。") + .font(.callout) + Button("退出旧版应用") { + viewModel.quitLegacyApp() + } + .controlSize(.small) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + } + + // MARK: - 执行中 / 完成 / 失败 + + private var performingView: some View { + VStack(alignment: .leading, spacing: 12) { + ProgressView().controlSize(.small) + Text("正在停用旧版后台服务并清理系统代理…") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + private var completedView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if let report = viewModel.report { + if !report.bootedOutLabels.isEmpty { + Label( + "已停用 \(report.bootedOutLabels.count) 个旧版后台服务", + systemImage: "checkmark.circle.fill" + ) + .foregroundStyle(.green) + ForEach(report.bootedOutLabels, id: \.self) { label in + Text(label).font(.callout).foregroundStyle(.secondary) + } + } else { + Label("没有发现需要停用的旧版后台服务", systemImage: "checkmark.circle") + .foregroundStyle(.secondary) + } + if !report.disabledLabels.isEmpty { + Text( + "已阻止 \(report.disabledLabels.count) 个残留配置在下次登录时自动加载。" + ) + .font(.callout) + } + if !report.proxyServicesCleaned.isEmpty { + Text("已清理 \(report.proxyServicesCleaned.count) 个网络服务的旧版系统代理配置。") + .font(.callout) + } + if !report.proxyServicesUntouchedUnknownOwner.isEmpty { + Text( + "以下网络服务的代理配置不属于旧版,未改动:" + + report.proxyServicesUntouchedUnknownOwner.joined(separator: "、") + ) + .font(.callout) + .foregroundStyle(.orange) + } + if !report.confirmedFreePorts.isEmpty { + Text( + "端口 \(report.confirmedFreePorts.map(String.init).joined(separator: "、")) 已释放,2.0 代理正在启动。" + ) + .font(.callout) + } + if !report.residue.isEmpty { + Divider() + Text("Legacy 数据全部保留,可随时回退。以下残留建议之后手动处理:") + .font(.callout) + ForEach(report.residue, id: \.label) { residue in + Text( + "• ~/Library/LaunchAgents/\(residue.label).plist" + + residueDetailSuffix(residue) + ) + .font(.callout) + .foregroundStyle(.secondary) + } + } + Divider() + loginItemGuidanceIfKnown() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func failedView(_ reason: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Label("交接未完成", systemImage: "xmark.octagon.fill") + .foregroundStyle(.red) + Text(reason) + .font(.callout) + Text("已执行的动作不会回滚(已停用的服务保持停用);解决上面的问题后可重新执行交接。") + .font(.callout) + .foregroundStyle(.secondary) + Button("重新检查") { + Task { await viewModel.refresh() } + } + .controlSize(.small) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - 登录项指引(无编程停用路径,仅人工) + + @ViewBuilder + private func loginItemGuidance(detection: LegacyHandoffDetection) -> some View { + stateSection(title: "旧版的开机自启动需要手动关闭") { + Text( + detection.legacyAppInstalled + ? "打开 系统设置 → 通用 → 登录项与扩展,移除 ShadowsocksX-NG(或其 LaunchHelper)条目;也可以打开旧版 app,在偏好设置中关闭「登录时启动」。" + : "若曾使用旧版的开机自启动:打开 系统设置 → 通用 → 登录项与扩展,移除 ShadowsocksX-NG(或其 LaunchHelper)条目。" + ) + .font(.callout) + .foregroundStyle(.secondary) + Button("打开系统设置的登录项面板") { + openLoginItemsSettings() + } + .controlSize(.small) + } + } + + @ViewBuilder + private func loginItemGuidanceIfKnown() -> some View { + if let detection = viewModel.detection { + loginItemGuidance(detection: detection) + } + } + + // MARK: - 底部按钮 + + @ViewBuilder + private var footerButtons: some View { + HStack { + Button("关闭") { dismiss() } + Spacer() + if case .ready = viewModel.phase { + Button("切换到 2.0") { + Task { await viewModel.performHandoff() } + } + .keyboardShortcut(.defaultAction) + .disabled(!viewModel.canConfirm) + } + } + } + + private func stateSection( + title: String?, @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + if let title { + Text(title) + .font(.headline) + } + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8)) + } + + /// 残留 plist 的一行说明:活跃性必须读内容判断(2017 前的生成代码带 + /// KeepAlive,会登录自启);读不出内容时如实说明,不冒充休眠。 + private func residueDescription(_ residue: LegacyAgentPlistResidue) -> String { + if !residue.contentReadable { + return "\(residue.label):存在但内容无法读取,无法判断活跃性;交接将阻止其加载,建议之后手动检查删除" + } + return residue.isActiveForm + ? "\(residue.label):含 KeepAlive/RunAtLoad,会在登录时自动运行;交接将阻止其再次加载,建议之后手动删除该文件" + : "\(residue.label):休眠残留(登录时仅注册、不运行);交接将阻止其再次加载" + } + + private func residueDetailSuffix(_ residue: LegacyAgentPlistResidue) -> String { + if !residue.contentReadable { return "(内容无法读取)" } + return residue.isActiveForm ? "(含 KeepAlive/RunAtLoad,交接已阻止其加载)" : "" + } + + private func openLoginItemsSettings() { + SMAppService.openSystemSettingsLoginItems() + } +} diff --git a/ShadowsocksX-NG2/App/LegacyHandoffViewModel.swift b/ShadowsocksX-NG2/App/LegacyHandoffViewModel.swift new file mode 100644 index 00000000..6d07b8ab --- /dev/null +++ b/ShadowsocksX-NG2/App/LegacyHandoffViewModel.swift @@ -0,0 +1,112 @@ +import Foundation + +/// Legacy 交接视图模型(issue #37):驱动「切换到 2.0」第二阶段——确认前置 +/// (旧版 app 已退出)、执行白名单停用与系统代理清理,成功后启动 2.0 代理。 +/// 端口门禁失败、bootout 失败都不启动 2.0(显式失败呈现,无静默回退)。 +/// 与 ProxyRuntimeController 通过启停闭包解耦,便于测试。 +@MainActor +final class LegacyHandoffViewModel: ObservableObject { + enum Phase: Equatable { + /// 尚未完成识别。 + case idle + case ready + case performing + case completed + case failed(String) + } + + @Published private(set) var detection: LegacyHandoffDetection? + @Published private(set) var phase: Phase = .idle + @Published private(set) var report: LegacyHandoffReport? + @Published private(set) var handoffCompleted: Bool + /// 引导退出旧版 app 失败时的呈现面。 + @Published var presentedError: String? + + /// 交接成功后启动 2.0 代理(生产接线 ProxyRuntimeController.setProxyEnabled); + /// 执行前若 2.0 代理在跑则先停(重跑交接时 2.0 可能持有端口)。 + var startProxy: (() async -> Void)? + var stopProxy: (() async -> Void)? + + private let service: LegacyHandoffService + private let appController: LegacyAppControlling + + init( + service: LegacyHandoffService = LegacyHandoffService( + launchctl: ProcessLaunchctlController(), + plistInspector: FileSystemLegacyAgentPlistInspector(), + appController: WorkspaceLegacyAppController(), + portsProvider: UserDefaultsLegacyListenPortsProvider(), + occupancyProbe: SystemPortOccupancyProbe(), + proxyCleaner: SystemConfigurationLegacyProxyCleaner(), + marker: UserDefaultsLegacyHandoffMarkerStore()), + appController: LegacyAppControlling = WorkspaceLegacyAppController() + ) { + self.service = service + self.appController = appController + handoffCompleted = service.isCompleted() + } + + /// 只读识别(launchctl print 退出码 + plist 存在性 + 端口来源),不改任何 + /// launchd 状态、不动 Legacy 数据;process 派发避免阻塞主线程。 + func refresh() async { + handoffCompleted = service.isCompleted() + let detected = await Task.detached(priority: .userInitiated) { + self.service.detect() + }.value + detection = detected + if phase != .performing { + phase = .ready + } + } + + /// 确认按钮的可用条件:识别完成、无未知状态、旧版 app 已退出、确有可停用 + /// 的痕迹(无可停用内容时不提供确认,避免无意义地走一遍流程)。 + var canConfirm: Bool { + guard let detection, phase != .performing else { return false } + guard detection.printFailures.isEmpty, !detection.legacyAppRunning else { return false } + let plan = LegacyHandoffPlan.make(from: detection) + return !plan.actions.isEmpty + } + + var hasUnknownState: Bool { + detection?.printFailures.isEmpty == false + } + + /// 仍存在旧版运行痕迹(已加载 job、残留 plist 或旧版 app 本体)——主窗口 + /// 据此在交接完成后持续提供「Legacy 交接与残留」入口(D12「后续检测到残 + /// 留提示用户处理」)。 + var hasLegacyEvidence: Bool { + guard let detection else { return false } + return !detection.loadedLabels.isEmpty + || detection.plists.contains { $0.fileExists } + || detection.legacyAppRunning + } + + func performHandoff() async { + guard canConfirm else { return } + phase = .performing + await stopProxy?() + do { + let handoffReport = try await service.performHandoff() + report = handoffReport + handoffCompleted = true + phase = .completed + // 端口门禁通过后才启动 2.0(service 内部已确认);启动失败在代理控制 + // 器状态与菜单栏点名呈现,不影响交接完成态。 + await startProxy?() + } catch let error as LegacyHandoffError { + phase = .failed(error.presentedReason) + } catch { + phase = .failed(String(describing: error)) + } + } + + /// 引导退出旧版 app(优雅退出,等价用户点退出菜单);退出后自动重新识别。 + func quitLegacyApp() { + guard appController.requestGracefulQuit() else { + presentedError = "无法向旧版 ShadowsocksX-NG 发送退出请求,请手动退出后重试" + return + } + Task { await refresh() } + } +} diff --git a/ShadowsocksX-NG2/App/LegacyImportSheet.swift b/ShadowsocksX-NG2/App/LegacyImportSheet.swift index 61c642fa..e5c8a0dc 100644 --- a/ShadowsocksX-NG2/App/LegacyImportSheet.swift +++ b/ShadowsocksX-NG2/App/LegacyImportSheet.swift @@ -5,6 +5,9 @@ import SwiftUI /// named skipped records, identity regeneration, and migration warnings. struct LegacyImportSheet: View { @ObservedObject var viewModel: CatalogViewModel + /// 导入成功后的「切换到 2.0」第二阶段入口(issue #37:交接与导入分离, + /// 由用户显式发起);nil 时隐藏。 + var onHandoffRequested: (() -> Void)? @Environment(\.dismiss) private var dismiss @State private var isImporting = false @@ -38,6 +41,9 @@ struct LegacyImportSheet: View { HStack { Button("暂不导入") { dismiss() } Spacer() + if viewModel.legacyImportReport != nil, let onHandoffRequested { + Button("切换到 2.0…") { onHandoffRequested() } + } Button(viewModel.legacyImportCompleted ? "再次导入" : "导入") { importLegacy() } diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index 34c60f66..9923571e 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -16,6 +16,8 @@ struct MainWindowView: View { @ObservedObject var proxyController: ProxyRuntimeController let eventStore: RuntimeEventStore + @StateObject private var legacyHandoffModel = LegacyHandoffViewModel() + @State private var pane: Pane = .servers @State private var renameTarget: NodeID? @State private var renameText = "" @@ -26,6 +28,7 @@ struct MainWindowView: View { @State private var showImportURLSheet = false @State private var showQRImportSheet = false @State private var showLegacyImportSheet = false + @State private var showLegacyHandoffSheet = false @State private var didOfferLegacyImport = false @State private var rootDropHovering = false @@ -85,7 +88,22 @@ struct MainWindowView: View { QRImportSheet(viewModel: viewModel) } .sheet(isPresented: $showLegacyImportSheet) { - LegacyImportSheet(viewModel: viewModel) + LegacyImportSheet( + viewModel: viewModel, + onHandoffRequested: { + showLegacyImportSheet = false + showLegacyHandoffSheet = true + }) + } + .sheet(isPresented: $showLegacyHandoffSheet) { + LegacyHandoffSheet(viewModel: legacyHandoffModel) + .task { + // 交接成功后经正常启停入口启动 2.0(健康门禁、系统代理授权写入 + // 全部走 #29 路径);重跑交接前先停 2.0 以释放端口。 + legacyHandoffModel.stopProxy = { await proxyController.setProxyEnabled(false) } + legacyHandoffModel.startProxy = { await proxyController.setProxyEnabled(true) } + await legacyHandoffModel.refresh() + } } .sheet( item: Binding( @@ -99,6 +117,10 @@ struct MainWindowView: View { didOfferLegacyImport = true showLegacyImportSheet = true } + .task { + // 交接入口可见性依赖交接完成标记(issue #37)。 + await legacyHandoffModel.refresh() + } } // MARK: - 侧栏(分区切换 + 树 / 诊断摘要) @@ -252,6 +274,16 @@ struct MainWindowView: View { showLegacyImportSheet = true } } + if viewModel.legacyImportAvailable || !legacyHandoffModel.handoffCompleted + || legacyHandoffModel.hasLegacyEvidence + { + Button( + legacyHandoffModel.handoffCompleted + ? "Legacy 交接与残留…" : "切换到 2.0(交接旧版服务)…" + ) { + showLegacyHandoffSheet = true + } + } Divider() Button("新建分组…") { newGroupParent = viewModel.importTargetParent(for: viewModel.selectedNodeID) diff --git a/ShadowsocksX-NG2/App/SystemConfigurationLegacyProxyCleaner.swift b/ShadowsocksX-NG2/App/SystemConfigurationLegacyProxyCleaner.swift new file mode 100644 index 00000000..5cd0dc09 --- /dev/null +++ b/ShadowsocksX-NG2/App/SystemConfigurationLegacyProxyCleaner.swift @@ -0,0 +1,203 @@ +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) + } +} diff --git a/ShadowsocksX-NG2/Domain/LegacyHandoff.swift b/ShadowsocksX-NG2/Domain/LegacyHandoff.swift new file mode 100644 index 00000000..527d5fe1 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/LegacyHandoff.swift @@ -0,0 +1,677 @@ +import Darwin +import Foundation + +/// Legacy 交接(spec #21 D12,issue #37):与导入分离的第二阶段。用户明确确认 +/// 「切换到 2.0」后,按硬编码白名单停用三个 Legacy launchd label(全历史无变 +/// 体,见 docs/research/wayfinder-issue-17-legacy-handoff.md §1)——不泛匹配、 +/// 不 pkill;前置确认旧版 app 已退出(其 GUI 进程持有 PAC 端口,bootout 不释 +/// 放),端口释放确认通过后才允许启动 2.0;Legacy 数据一律不删除,残留仅提示。 +enum LegacyLaunchAgentLabel: String, CaseIterable, Sendable { + case local = "com.qiuyuzhou.shadowsocksX-NG.local" + case http = "com.qiuyuzhou.shadowsocksX-NG.http" + case kcptun = "com.qiuyuzhou.shadowsocksX-NG.kcptun" +} + +/// Legacy 三端口的配置值(只读读取,属于 Legacy 数据——只读、不删改)。 +struct LegacyListenPorts: Equatable, Sendable { + var socksPort: Int = 1086 + var httpPort: Int = 1087 + var pacPort: Int = 1089 + var socksAddress: String = "127.0.0.1" +} + +protocol LegacyListenPortsProviding: Sendable { + func currentPorts() -> LegacyListenPorts +} + +/// 从 Legacy defaults 域读取端口(inventory §1:端口不在 LaunchAgent plist 内, +/// 用户自定义值在 defaults)。缺失或无效回落出厂三端口,不改写任何值。 +/// UserDefaults 线程安全(Apple 文档),Sendable 依存性核对为安全。 +struct UserDefaultsLegacyListenPortsProvider: LegacyListenPortsProviding, @unchecked Sendable { + static let legacyBundleIdentifier = "com.qiuyuzhou.ShadowsocksX-NG" + + let defaults: UserDefaults + let bundleIdentifier: String + + init( + defaults: UserDefaults = .standard, + bundleIdentifier: String = UserDefaultsLegacyListenPortsProvider.legacyBundleIdentifier + ) { + self.defaults = defaults + self.bundleIdentifier = bundleIdentifier + } + + func currentPorts() -> LegacyListenPorts { + let domain = defaults.persistentDomain(forName: bundleIdentifier) ?? [:] + func port(_ key: String, fallback: Int) -> Int { + guard let number = domain[key] as? NSNumber else { return fallback } + let value = number.intValue + return (1...65_535).contains(value) ? value : fallback + } + let address = (domain["LocalSocks5.ListenAddress"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return LegacyListenPorts( + socksPort: port("LocalSocks5.ListenPort", fallback: 1086), + httpPort: port("LocalHTTP.ListenPort", fallback: 1087), + pacPort: port("PacServer.ListenPort", fallback: 1089), + socksAddress: (address?.isEmpty == false) ? address! : "127.0.0.1") + } +} + +// MARK: - 系统代理归属判定(特征值) + +/// Legacy 特征判定结果:`.none` 无启用代理;`.legacy` 全部启用面与 Legacy 写 +/// 入特征匹配;`.other` 存在非 Legacy 特征(未知所有者,不覆盖——D12)。 +enum LegacyProxyOwnershipClass: Equatable, Sendable { + case none + case legacy + case other +} + +/// 系统代理无所有者标记(wayfinder #17 §5.1),归属 = 特征值匹配。特征镜像 +/// Legacy 自身 `proxy_conf_helper` off 模式的判定(main.m:191-202):本地 PAC +/// URL 恰为 `http://localhost:/proxy.pac`(host 硬编码 localhost), +/// SOCKS 为其监听地址与端口,HTTP/HTTPS 为 Privoxy 地址与端口。任一启用面不 +/// 匹配(含混合)即按未知所有者处理。2.0 自身写 `/v1/proxy.pac`,不会碰撞。 +enum LegacySystemProxySignature { + static let pacPath = "/proxy.pac" + static let httpProxy = "HTTPProxy" + static let httpPort = "HTTPPort" + static let httpsProxy = "HTTPSProxy" + static let httpsPort = "HTTPSPort" + + /// Legacy off 模式写入的静止形态:全部禁用。 + static let emptyDisabled: [String: Any] = [ + SystemProxyPropertyList.httpEnabled: 0, + SystemProxyPropertyList.httpsEnabled: 0, + SystemProxyPropertyList.pacEnabled: 0, + SystemProxyPropertyList.socksEnabled: 0, + SystemProxyPropertyList.exceptionsList: [String](), + ] + + static func legacyPACURL(port: Int) -> String { + "http://localhost:\(port)\(pacPath)" + } + + static func classify( + _ dictionary: [String: Any], ports: LegacyListenPorts + ) -> LegacyProxyOwnershipClass { + var sawEnabledFacet = false + + if intValue(dictionary[SystemProxyPropertyList.pacEnabled]) == 1 { + sawEnabledFacet = true + guard + stringValue(dictionary[SystemProxyPropertyList.pacURL]) == legacyPACURL(port: ports.pacPort) + else { return .other } + } + if intValue(dictionary[SystemProxyPropertyList.socksEnabled]) == 1 { + sawEnabledFacet = true + guard + isEquivalentHost( + stringValue(dictionary[SystemProxyPropertyList.socksProxy]), to: ports.socksAddress), + intValue(dictionary[SystemProxyPropertyList.socksPort]) == ports.socksPort + else { return .other } + } + if intValue(dictionary[SystemProxyPropertyList.httpEnabled]) == 1 { + sawEnabledFacet = true + guard + isLoopbackHost(stringValue(dictionary[httpProxy])), + intValue(dictionary[httpPort]) == ports.httpPort + else { return .other } + } + if intValue(dictionary[SystemProxyPropertyList.httpsEnabled]) == 1 { + sawEnabledFacet = true + guard + isLoopbackHost(stringValue(dictionary[httpsProxy])), + intValue(dictionary[httpsPort]) == ports.httpPort + else { return .other } + } + return sawEnabledFacet ? .legacy : .none + } + + /// 清理投影:只处理**启用**且归属匹配的代理面——禁用面的端点值可能是 + /// 用户或其他软件的静止数据(Legacy helper 整字典替换的写入形态下不会与 + /// Legacy 特征共存,但手工混入可能),一律不动;绕过列表等其余键保留。 + /// 幂等。 + static func cleaned(_ dictionary: [String: Any]) -> [String: Any] { + var result = dictionary + let enabledFacets: [(enableKey: String, endpointKeys: [String])] = [ + ( + SystemProxyPropertyList.pacEnabled, + [SystemProxyPropertyList.pacURL, SystemProxyPropertyList.pacJavaScript] + ), + ( + SystemProxyPropertyList.socksEnabled, + [SystemProxyPropertyList.socksProxy, SystemProxyPropertyList.socksPort] + ), + (SystemProxyPropertyList.httpEnabled, [httpProxy, httpPort]), + (SystemProxyPropertyList.httpsEnabled, [httpsProxy, httpsPort]), + ] + for facet in enabledFacets { + guard intValue(dictionary[facet.enableKey]) == 1 else { continue } + result[facet.enableKey] = 0 + for key in facet.endpointKeys { + result.removeValue(forKey: key) + } + } + return result + } + + private static let loopbackWrites: Set = ["127.0.0.1", "localhost", "::1"] + + /// 127.0.0.1 / localhost / ::1 视为同一回环写法;其余按大小写不敏感全等。 + static func isEquivalentHost(_ actual: String?, to expected: String) -> Bool { + guard let actual, !actual.isEmpty else { return false } + let normalizedActual = actual.lowercased() + let normalizedExpected = expected.lowercased() + if normalizedActual == normalizedExpected { return true } + return loopbackWrites.contains(normalizedActual) + && loopbackWrites.contains(normalizedExpected) + } + + static func isLoopbackHost(_ actual: String?) -> Bool { + guard let actual, !actual.isEmpty else { return false } + return loopbackWrites.contains(actual.lowercased()) + } + + private static func intValue(_ value: Any?) -> Int? { + (value as? NSNumber)?.intValue + } + + private static func stringValue(_ value: Any?) -> String? { + value as? String + } +} + +// MARK: - launchctl 命令缝 + +struct LaunchctlOutcome: Equatable, Sendable { + let exitCode: Int32 + let stderr: String +} + +/// `launchctl print` 的状态判定只依赖退出码(man 明文 print 输出结构非 API, +/// 不解析文本):0 已加载、113 不存在、其余未知。 +enum LaunchctlServiceState: Equatable, Sendable { + case loaded + case notLoaded + case unknown(detail: String) +} + +protocol LaunchctlControlling: Sendable { + func serviceState(label: String) -> LaunchctlServiceState + func bootout(label: String) -> LaunchctlOutcome + func disable(label: String) -> LaunchctlOutcome + func kill(label: String, signal: Int32) -> LaunchctlOutcome +} + +/// 系统实现:gui 域按 service target 操作(`gui//