diff --git a/ShadowsocksX-NG2/App/CatalogViewModel.swift b/ShadowsocksX-NG2/App/CatalogViewModel.swift index 81ad5d98..f4cdb4a5 100644 --- a/ShadowsocksX-NG2/App/CatalogViewModel.swift +++ b/ShadowsocksX-NG2/App/CatalogViewModel.swift @@ -58,15 +58,9 @@ final class CatalogViewModel: ObservableObject { (try? catalog.isEffectivelyEnabled(id)) ?? false } - /// 行显示名:服务器备注优先、回退地址(含订阅 remarks 回退语义);分组用名称。 + /// 行显示名:委托目录条目的共用口径(`CatalogEntry.displayName`)。 func displayName(for id: NodeID) -> String { - guard let entry = catalog.entry(for: id) else { return "" } - switch entry.kind { - case .group(let fields): - return fields.name - case .server(let fields): - return fields.remark.isEmpty ? fields.address : fields.remark - } + catalog.entry(for: id)?.displayName ?? "" } // MARK: - 侧栏树快照 diff --git a/ShadowsocksX-NG2/App/GlobalShortcuts.swift b/ShadowsocksX-NG2/App/GlobalShortcuts.swift new file mode 100644 index 00000000..7d09a504 --- /dev/null +++ b/ShadowsocksX-NG2/App/GlobalShortcuts.swift @@ -0,0 +1,34 @@ +import KeyboardShortcuts + +/// 全局快捷键(spec #21 D11,issue #31):开关代理、切换模式两个系统级热键。 +/// 快捷键不迁移 Legacy(D12),用 2.0 默认键位(Name.initialShortcut:用户 +/// 未改过即生效);编辑 UI 在设置区(#33)。 +extension KeyboardShortcuts.Name { + /// ⌃⌥⌘S:开关代理。 + static let toggleProxy = Self( + "toggleProxy", + initial: .init(.s, modifiers: [.control, .option, .command])) + /// ⌃⌥⌘M:在 PAC / 全局 / 手动三个内置模式间循环切换。 + static let cycleProxyMode = Self( + "cycleProxyMode", + initial: .init(.m, modifiers: [.control, .option, .command])) +} + +enum GlobalShortcuts { + /// 接线快捷键动作(应用启动时调用一次)。两个动作都与菜单栏同名项走同一 + /// 控制器入口,状态一致由 @Published 事实来源保证;「开关代理」的意图判定 + /// 与菜单开关联用同一 `isOn(state:)` 口径(含启动失败等中间态的「开」判定)。 + @MainActor + static func wire(controller: ProxyRuntimeController) { + KeyboardShortcuts.onKeyUp(for: .toggleProxy) { + Task { @MainActor in + await controller.setProxyEnabled(!StatusMenuModel.isOn(state: controller.state)) + } + } + KeyboardShortcuts.onKeyUp(for: .cycleProxyMode) { + Task { @MainActor in + await controller.setProxyMode(StatusMenuModel.nextMode(after: controller.proxyMode)) + } + } + } +} diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index e783919a..c0d8ae46 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -20,6 +20,8 @@ struct ShadowsocksXNG2App: App { // 全部订阅」(issue #35)等未开窗路径的目录提交同样立即重展开运行时。 let controller = proxyController catalogViewModel.postCommit = { await controller.catalogDidCommit() } + // 全局快捷键(issue #31):开关代理、切换模式,与菜单项同一入口。 + GlobalShortcuts.wire(controller: controller) await proxyController.resyncOnLaunch() } } diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift index c5f3352e..0f9421c2 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift @@ -38,6 +38,10 @@ final class ProxyRuntimeController: ObservableObject { @Published private(set) var state: ProxyState = .off @Published private(set) var pacURL: URL? + /// 当前活动目标(菜单栏状态摘要与级联只读呈现用,issue #31)。machine 是 + /// 非发布值的普通结构体,代理关闭路径的激活动作不会触碰 state,菜单的 + /// 「目标」行依赖这里的独立发布保持实时。 + @Published private(set) var activeTargetID: NodeID? private(set) var machine: ActivationStateMachine private var catalog: ConfigurationCatalog @@ -103,6 +107,7 @@ final class ProxyRuntimeController: ObservableObject { catalog = (try? catalogFileStore.load().catalog) ?? ConfigurationCatalog() let persistedTarget = try? activationFileStore.loadActiveTargetID() machine = ActivationStateMachine(activeTargetID: persistedTarget) + activeTargetID = machine.activeTargetID self.proxyMode = proxyMode } @@ -138,6 +143,7 @@ final class ProxyRuntimeController: ObservableObject { do { let configuration = try machine.activate( target, in: catalog, credentials: credentials, plugins: plugins, listen: listen) + activeTargetID = target do { try activationFileStore.save(activeTargetID: target) } catch { @@ -582,9 +588,12 @@ extension ProxyRuntimeController { catalog = (try? catalogFileStore.load().catalog) ?? catalog } + /// 目录以磁盘为事实来源重载后重展开;目标被清除时一并发布。 private func reexpand() -> ActivationEffect? { - machine.catalogDidCommit( + let effect = machine.catalogDidCommit( catalog, credentials: credentials, plugins: plugins, listen: listen) + activeTargetID = machine.activeTargetID + return effect } private func describe(_ error: Error) -> String { diff --git a/ShadowsocksX-NG2/App/ProxyStatusMenu.swift b/ShadowsocksX-NG2/App/ProxyStatusMenu.swift index 17fd3308..c3998d8c 100644 --- a/ShadowsocksX-NG2/App/ProxyStatusMenu.swift +++ b/ShadowsocksX-NG2/App/ProxyStatusMenu.swift @@ -1,52 +1,79 @@ import AppKit import SwiftUI -/// 状态菜单的代理控制区(spec #21 D11 白名单中本票的子集):头部状态摘要、 -/// 代理开关、「立即更新全部订阅」(issue #35)与「启动失败」呈现。模式选择、 -/// 活动目标级联等项由后续工单接入。 +/// 状态菜单(spec #21 D11 八项白名单,issue #31):①头部状态摘要(运行状态、 +/// 当前模式、活动目标)②代理开关 ③模式选择(勾选态)④活动目标级联选择器 +/// (组树子菜单、只读)⑤立即更新全部订阅 ⑥复制 HTTP 导出行 ⑦打开主窗口 +/// ⑧退出(明示代理仍在后台运行)。白名单外操作一律不进菜单栏;编辑类操作 +/// 只在主窗口。 struct ProxyStatusMenu: View { - private struct Presentation { - let isOn: Bool - let status: String - let detail: String? - } - @Environment(\.openWindow) private var openWindow @ObservedObject var controller: ProxyRuntimeController @ObservedObject var catalogViewModel: CatalogViewModel var body: some View { - let presentation = presentation - Text(presentation.status) + let summary = presentation + let targetTree = StatusMenuModel.targetTree( + catalog: catalogViewModel.catalog, activeTargetID: controller.activeTargetID) + let exportLine = StatusMenuModel.httpExportLine(settings: controller.listenSettings) - Divider() - - Button(presentation.isOn ? "停止代理" : "启动代理") { - Task { await controller.setProxyEnabled(!presentation.isOn) } - } - - if let failure = presentation.detail { - Text(failure) + // ① 头部状态摘要 + Text(summary.status) + Text("模式:\(summary.modeLabel)") + Text(summary.targetPath.map { "目标:\($0)" } ?? "目标:未激活") + if let detail = summary.detail { + Text(detail) .font(.caption) .foregroundStyle(.secondary) } - if let pacURL = controller.pacURL { - Button("复制 PAC URL") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(pacURL.absoluteString, forType: .string) + Divider() + + // ② 代理开关 + Button(summary.isOn ? "停止代理" : "启动代理") { + Task { await controller.setProxyEnabled(!summary.isOn) } + } + + // ③ 模式选择(勾选态) + Picker("模式", selection: modeBinding) { + Text(ProxyMode.pac.label).tag(ProxyMode.pac) + Text(ProxyMode.global.label).tag(ProxyMode.global) + Text(ProxyMode.manual.label).tag(ProxyMode.manual) + if case .externalPAC = controller.proxyMode { + Text(controller.proxyMode.label).tag(controller.proxyMode) + } + } + .pickerStyle(.inline) + + // ④ 活动目标级联(只读) + Menu("活动目标") { + if targetTree.isEmpty { + Text("目录为空") + } else { + TargetCascade(nodes: targetTree) } } Divider() + // ⑤ 立即更新全部订阅(实际刷新语义 #35) Button("立即更新全部订阅") { Task { await catalogViewModel.refreshAllSubscriptions() } } .disabled(catalogViewModel.subscriptions.isEmpty) + // ⑥ 复制 HTTP 导出行 + Button("复制 HTTP 导出行") { + if let exportLine { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(exportLine, forType: .string) + } + } + .disabled(exportLine == nil) + Divider() + // ⑦ 打开主窗口 Button("打开主窗口…") { NSApp.activate() openWindow(id: "main") @@ -54,34 +81,49 @@ struct ProxyStatusMenu: View { Divider() - Button("退出 ShadowsocksX-NG 2.0") { + // ⑧ 退出:仅退 GUI;agent 由 launchd 持有,代理不受影响(构造上成立)。 + Button("退出 ShadowsocksX-NG 2.0(代理仍在后台运行)") { NSApp.terminate(nil) } } - private var presentation: Presentation { - switch controller.state { - case .off: - return Presentation(isOn: false, status: "代理未运行", detail: nil) - case .starting: - return Presentation(isOn: true, status: "正在启动代理…", detail: nil) - case .running: - return Presentation(isOn: true, status: "代理运行中", detail: nil) - case .firewallBlocked(let detail): - return Presentation(isOn: true, status: "代理运行中(局域网受阻)", detail: detail) - case .launchFailed(let detail): - return Presentation(isOn: false, status: "启动失败", detail: detail) - case .activationFailed(let reason): - return Presentation(isOn: false, status: "无法启动", detail: reason) - case .requiresApproval: - return Presentation( - isOn: true, - status: "等待允许后台代理", - detail: "请在系统设置-登录项中允许 ShadowsocksX-NG 后台项") - case .serviceFailed(let detail): - return Presentation(isOn: false, status: "服务管理失败", detail: detail) - case .systemProxyFailed(let detail): - return Presentation(isOn: true, status: "系统代理未应用", detail: detail) + private var presentation: StatusMenuModel.Summary { + StatusMenuModel.summary( + state: controller.state, + mode: controller.proxyMode, + targetPath: StatusMenuModel.targetPath( + catalog: catalogViewModel.catalog, activeTargetID: controller.activeTargetID)) + } + + /// 模式选择走控制器同一入口:切换的系统代理语义(#29)与菜单勾选态由 + /// 同一 @Published 事实来源保证一致。 + private var modeBinding: Binding { + Binding( + get: { controller.proxyMode }, + set: { mode in Task { await controller.setProxyMode(mode) } }) + } + + /// 只读级联树:分组展开为子菜单,活动目标以勾选呈现;无编辑入口。 + /// 递归经由具名 View 类型展开(opaque 自引用无法编译)。 + private struct TargetCascade: View { + let nodes: [StatusMenuModel.TargetNode] + + var body: some View { + ForEach(nodes, id: \.id) { node in + if node.isGroup { + Menu(node.name) { + if node.children.isEmpty { + Text("空分组") + } else { + TargetCascade(nodes: node.children) + } + } + } else if node.isActive { + Text("✓ \(node.name)") + } else { + Text(node.name) + } + } } } } diff --git a/ShadowsocksX-NG2/App/StatusMenuModel.swift b/ShadowsocksX-NG2/App/StatusMenuModel.swift new file mode 100644 index 00000000..5573e7cb --- /dev/null +++ b/ShadowsocksX-NG2/App/StatusMenuModel.swift @@ -0,0 +1,144 @@ +import Foundation + +/// 状态菜单八项白名单的呈现模型(spec #21 D11,issue #31):从运行状态、 +/// 模式、目录树与监听设置派生菜单数据。不持有 AppKit 类型,纯逻辑可测; +/// 视觉与交互细节(勾选标记、禁用态)在 ProxyStatusMenu 呈现,不建自动测试。 +enum StatusMenuModel { + /// 头部状态摘要:运行状态、当前模式、活动目标(含点名失败细节)。 + struct Summary: Equatable { + /// 代理开关的当前意图:true 为已开(菜单项呈现「停止代理」)。 + let isOn: Bool + let status: String + /// 启动/激活/系统代理失败的点名原因,随状态摘要一并呈现。 + let detail: String? + let modeLabel: String + /// 活动目标从根到节点的显示名路径;未激活为 nil。 + let targetPath: String? + } + + /// 运行状态 → 摘要映射(与主窗口诊断区口径一致)。 + static func summary( + state: ProxyRuntimeController.ProxyState, + mode: ProxyMode, + targetPath: String? + ) -> Summary { + let status: String + let detail: String? + switch state { + case .off: + status = "代理未运行" + detail = nil + case .starting: + status = "正在启动代理…" + detail = nil + case .running: + status = "代理运行中" + detail = nil + case .firewallBlocked(let reason): + status = "代理运行中(局域网受阻)" + detail = reason + case .launchFailed(let reason): + status = "启动失败" + detail = reason + case .activationFailed(let reason): + status = "无法启动" + detail = reason + case .requiresApproval: + status = "等待允许后台代理" + detail = "请在系统设置-登录项中允许 ShadowsocksX-NG 后台项" + case .serviceFailed(let reason): + status = "服务管理失败" + detail = reason + case .systemProxyFailed(let reason): + status = "系统代理未应用" + detail = reason + } + return Summary( + isOn: isOn(state: state), status: status, detail: detail, modeLabel: mode.label, + targetPath: targetPath) + } + + /// 代理开关的当前意图(菜单开关与全局快捷键共用的同一判定):启动失败等 + /// 中间态视为未开——两个入口的下一步动作都是「启动」。 + static func isOn(state: ProxyRuntimeController.ProxyState) -> Bool { + switch state { + case .off, .launchFailed, .activationFailed, .serviceFailed: + false + case .starting, .running, .firewallBlocked, .requiresApproval, .systemProxyFailed: + true + } + } + + /// 复制 HTTP 导出行(D11):shell 可直接 source 的 http/https 双导出; + /// HTTP 入站未启用时无行可复制(菜单项禁用)。地址取监听范围的对外地址 + /// (回环态 127.0.0.1,主机态为对外公布地址),与 PAC 语义一致。 + static func httpExportLine(settings: SslocalListenSettings) -> String? { + guard settings.httpProxyEnabled else { return nil } + let endpoint = "http://\(settings.scope.advertisedAddress):\(settings.httpPort)" + return "export http_proxy=\(endpoint);export https_proxy=\(endpoint);" + } + + /// 全局快捷键「切换模式」的循环序:仅内置三模式参与;外部 PAC 的 URL 只能 + /// 在设置区配置(#33),不进循环——快捷键从外部 PAC 回到 PAC。 + static func nextMode(after mode: ProxyMode) -> ProxyMode { + switch mode { + case .pac: .global + case .global: .manual + case .manual, .externalPAC: .pac + } + } + + /// 活动目标级联子菜单的只读树快照:结构与配置树一致(含停用节点),活动 + /// 目标以勾选呈现。分组节点持有子树,服务器叶子无子。`id` 供 SwiftUI + /// ForEach 稳定标识(导入不去重,同名兄弟节点合法)。 + struct TargetNode: Equatable { + let id: NodeID + let name: String + let isGroup: Bool + let isActive: Bool + let children: [TargetNode] + } + + static func targetTree( + catalog: ConfigurationCatalog, + activeTargetID: NodeID? + ) -> [TargetNode] { + targetChildren(of: nil, catalog: catalog, activeTargetID: activeTargetID) + } + + /// 活动目标的显示名路径(根 → 节点," / " 连接);未激活或目标已不在目录 + /// 中为 nil。 + static func targetPath(catalog: ConfigurationCatalog, activeTargetID: NodeID?) -> String? { + guard let activeTargetID, catalog.contains(activeTargetID) else { return nil } + let chain = catalog.ancestors(of: activeTargetID).reversed() + [activeTargetID] + return chain.map { displayName(catalog: catalog, id: $0) }.joined(separator: " / ") + } + + private static func targetChildren( + of parent: NodeID?, + catalog: ConfigurationCatalog, + activeTargetID: NodeID? + ) -> [TargetNode] { + let ids = (try? catalog.children(of: parent)) ?? [] + return ids.compactMap { id in + guard let entry = catalog.entry(for: id) else { return nil } + let isGroup: Bool = { + if case .group = entry.kind { return true } + return false + }() + return TargetNode( + id: id, + name: displayName(catalog: catalog, id: id), + isGroup: isGroup, + isActive: id == activeTargetID, + children: isGroup + ? targetChildren(of: id, catalog: catalog, activeTargetID: activeTargetID) + : []) + } + } + + /// 行显示名:委托目录条目的共用口径(`CatalogEntry.displayName`)。 + private static func displayName(catalog: ConfigurationCatalog, id: NodeID) -> String { + catalog.entry(for: id)?.displayName ?? "" + } +} diff --git a/ShadowsocksX-NG2/Domain/CatalogModels.swift b/ShadowsocksX-NG2/Domain/CatalogModels.swift index 19247b00..fa1ccad3 100644 --- a/ShadowsocksX-NG2/Domain/CatalogModels.swift +++ b/ShadowsocksX-NG2/Domain/CatalogModels.swift @@ -51,3 +51,16 @@ struct CatalogEntry: Codable, Equatable, Sendable { var enabled: Bool var kind: Kind } + +extension CatalogEntry { + /// 行显示名(主窗口侧栏与菜单栏级联共用口径,issue #31):服务器备注优先、 + /// 回退地址(含订阅 remarks 回退语义);分组用名称。 + var displayName: String { + switch kind { + case .group(let fields): + return fields.name + case .server(let fields): + return fields.remark.isEmpty ? fields.address : fields.remark + } + } +} diff --git a/ShadowsocksX-NG2/Domain/ProxyMode.swift b/ShadowsocksX-NG2/Domain/ProxyMode.swift index 6f04c896..a11f0969 100644 --- a/ShadowsocksX-NG2/Domain/ProxyMode.swift +++ b/ShadowsocksX-NG2/Domain/ProxyMode.swift @@ -1,7 +1,8 @@ import Foundation /// The mutually exclusive ways in which 2.0 exposes the local proxy to macOS. -enum ProxyMode: Codable, Equatable, Sendable { +/// Hashable 供菜单栏模式选择的勾选态 Picker 使用(issue #31)。 +enum ProxyMode: Codable, Equatable, Hashable, Sendable { case pac case global case manual diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift index 7616e1a6..c3860d0e 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift @@ -1,3 +1,4 @@ +import Combine import XCTest @testable import ShadowsocksX_NG2 @@ -107,6 +108,22 @@ final class ProxyRuntimeControllerTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: runtime.contract.path)) } + /// 菜单栏「目标」行的实时性(issue #31):代理关闭时激活只改活动目标、 + /// 不触碰 state,目标变更必须仍经 @Published 发布到菜单。 + func testActivateWhileProxyOffPublishesActiveTargetID() async throws { + let seeded = try makeSeededCatalog() + let controller = makeController(probe: ProxyRuntimeFixture.FakeProbe.reachable()) + var observed: NodeID? + let cancellable = controller.$activeTargetID.dropFirst().sink { observed = $0 } + defer { cancellable.cancel() } + + await controller.activate(seeded.server) + + XCTAssertEqual(controller.activeTargetID, seeded.server) + XCTAssertEqual(observed, seeded.server, "代理关闭路径的激活也要发布目标变更") + XCTAssertEqual(controller.state, .off, "仅激活不启动代理") + } + func testActivateThenEnableWritesContractRegistersAndReachesRunning() async throws { let seeded = try makeSeededCatalog() let probe = ProxyRuntimeFixture.FakeProbe.reachable() diff --git a/ShadowsocksX-NG2/Tests/StatusMenuModelTests.swift b/ShadowsocksX-NG2/Tests/StatusMenuModelTests.swift new file mode 100644 index 00000000..36b289e9 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/StatusMenuModelTests.swift @@ -0,0 +1,140 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 状态菜单八项白名单的纯呈现逻辑(spec #21 D11,issue #31):头部状态摘要 +/// 映射、HTTP 导出行派生、「切换模式」循环、活动目标级联树快照。 +final class StatusMenuModelTests: XCTestCase { + // MARK: - 头部状态摘要 + + func testSummaryRunningStateIsOnWithNoDetail() { + let summary = StatusMenuModel.summary( + state: .running, mode: .pac, targetPath: "分组A / 香港 01") + XCTAssertTrue(summary.isOn) + XCTAssertEqual(summary.status, "代理运行中") + XCTAssertNil(summary.detail) + } + + func testSummaryOffStateIsOff() { + let summary = StatusMenuModel.summary(state: .off, mode: .global, targetPath: nil) + XCTAssertFalse(summary.isOn) + XCTAssertEqual(summary.status, "代理未运行") + XCTAssertNil(summary.detail) + } + + func testSummaryFailureStatesCarryNamedDetail() { + // 启动/激活失败:代理未在运行,开关意图为「启动」。 + for (state, expectedStatus) in [ + (ProxyRuntimeController.ProxyState.launchFailed(detail: "HTTP 端点未就绪"), "启动失败"), + (.activationFailed(reason: "尚未激活任何服务器"), "无法启动"), + ] { + let summary = StatusMenuModel.summary(state: state, mode: .manual, targetPath: nil) + XCTAssertFalse(summary.isOn) + XCTAssertEqual(summary.status, expectedStatus) + XCTAssertEqual(summary.detail, failureDetail(of: state)) + } + // 系统代理失败:代理本体仍在运行,开关意图保持「停止」。 + let summary = StatusMenuModel.summary( + state: .systemProxyFailed(detail: "系统代理写入失败"), mode: .manual, targetPath: nil) + XCTAssertTrue(summary.isOn) + XCTAssertEqual(summary.status, "系统代理未应用") + XCTAssertEqual(summary.detail, "系统代理写入失败") + } + + private func failureDetail( + of state: ProxyRuntimeController.ProxyState + ) -> String? { + switch state { + case .launchFailed(let detail), .activationFailed(let detail), .systemProxyFailed(let detail): + return detail + default: + return nil + } + } + + func testSummaryCarriesModeLabelAndTargetPath() { + let summary = StatusMenuModel.summary( + state: .running, mode: .externalPAC(URL(string: "http://example.com/pac")!), + targetPath: "订阅分组 / 嵌套分组 / 日本 02") + XCTAssertEqual(summary.modeLabel, "外部 PAC") + XCTAssertEqual(summary.targetPath, "订阅分组 / 嵌套分组 / 日本 02") + } + + // MARK: - 复制 HTTP 导出行 + + func testHTTPExportLineUsesLoopbackDefaults() { + XCTAssertEqual( + StatusMenuModel.httpExportLine(settings: SslocalListenSettings()), + "export http_proxy=http://127.0.0.1:1087;export https_proxy=http://127.0.0.1:1087;") + } + + func testHTTPExportLineUsesAdvertisedAddressAndPortInHostScope() { + var settings = SslocalListenSettings() + settings.scope = .host(advertisedAddress: "192.168.1.10") + settings.httpPort = 8080 + XCTAssertEqual( + StatusMenuModel.httpExportLine(settings: settings), + "export http_proxy=http://192.168.1.10:8080;export https_proxy=http://192.168.1.10:8080;") + } + + func testHTTPExportLineUnavailableWhenHTTPInboundDisabled() { + var settings = SslocalListenSettings() + settings.httpProxyEnabled = false + XCTAssertNil(StatusMenuModel.httpExportLine(settings: settings)) + } + + // MARK: - 全局快捷键「切换模式」循环 + + func testNextModeCyclesBuiltinModes() { + XCTAssertEqual(StatusMenuModel.nextMode(after: .pac), .global) + XCTAssertEqual(StatusMenuModel.nextMode(after: .global), .manual) + XCTAssertEqual(StatusMenuModel.nextMode(after: .manual), .pac) + } + + func testNextModeFromExternalPACReturnsToPAC() { + XCTAssertEqual( + StatusMenuModel.nextMode(after: .externalPAC(URL(string: "http://example.com/pac")!)), + .pac) + } + + // MARK: - 活动目标级联树(只读) + + func testTargetTreeMirrorsCatalogStructureAndMarksActiveServer() throws { + let fixture = try CatalogFixtures.makeSubscriptionFixture() + let tree = StatusMenuModel.targetTree( + catalog: fixture.catalog, activeTargetID: fixture.serverIDs[0]) + XCTAssertEqual(tree.count, 1) + let group = tree[0] + XCTAssertEqual(group.name, "订阅分组") + XCTAssertTrue(group.isGroup) + XCTAssertFalse(group.isActive) + XCTAssertEqual(group.children.count, 2) + XCTAssertEqual(group.children[0].name, "嵌套分组") + XCTAssertTrue(group.children[0].isGroup) + XCTAssertEqual(group.children[0].children.count, 1) + XCTAssertEqual(group.children[0].children[0].name, "日本 02") + XCTAssertFalse(group.children[0].children[0].isActive) + XCTAssertEqual(group.children[1].name, "香港 01") + XCTAssertFalse(group.children[1].isGroup) + XCTAssertTrue(group.children[1].isActive) + } + + func testTargetTreeMarksActiveGroupAndServerNameFallsBackToAddress() throws { + var catalog = ConfigurationCatalog() + let serverID = NodeID(rawValue: "manual:server") + try catalog.addServer(CatalogFixtures.serverFields(remark: ""), id: serverID) + let groupID = NodeID(rawValue: "manual:group") + try catalog.addGroup("本地分组", id: groupID) + let tree = StatusMenuModel.targetTree(catalog: catalog, activeTargetID: groupID) + XCTAssertEqual(tree.count, 2) + XCTAssertEqual(tree[0].name, "203.0.113.7") + XCTAssertFalse(tree[0].isActive) + XCTAssertEqual(tree[1].name, "本地分组") + XCTAssertTrue(tree[1].isActive) + } + + func testTargetTreeEmptyCatalogYieldsEmptyTree() { + XCTAssertTrue( + StatusMenuModel.targetTree(catalog: ConfigurationCatalog(), activeTargetID: nil).isEmpty) + } +} diff --git a/ShadowsocksX-NG2/project.yml b/ShadowsocksX-NG2/project.yml index 4c660e9c..ad4a41da 100644 --- a/ShadowsocksX-NG2/project.yml +++ b/ShadowsocksX-NG2/project.yml @@ -4,6 +4,12 @@ options: developmentLanguage: zh-Hans deploymentTarget: macOS: "15.0" +# Swift 包依赖:全局快捷键(spec #21 D11,issue #31)用 KeyboardShortcuts; +# 快捷键不迁移 Legacy(D12),默认键位见 App/GlobalShortcuts.swift。 +packages: + KeyboardShortcuts: + url: https://github.com/sindresorhus/KeyboardShortcuts + from: 3.1.0 settings: base: ARCHS: arm64 @@ -89,6 +95,8 @@ targets: copy: destination: wrapper subpath: Contents/MacOS + # 全局快捷键(D11,issue #31):开关代理、切换模式。 + - package: KeyboardShortcuts ShadowsocksX-NG2Agent: type: tool platform: macOS