mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-30 06:03:48 +00:00
feat(ng2): menu bar quick actions and global shortcuts (#31)
Status menu rebuilt around the D11 eight-item whitelist: header summary (running status, mode, active target path), proxy toggle, mode picker with checkmark, read-only active-target cascade over the catalog tree, update all subscriptions, copy HTTP export line, open main window, and quit that states the proxy keeps running in the background. Out-of-whitelist items (copy PAC URL) removed. Two global shortcuts (KeyboardShortcuts 3.1.0) toggle the proxy and cycle modes through the same controller entry points as the menu. Presentation logic extracted to StatusMenuModel (state summary mapping, HTTP export line, mode cycle, read-only target tree) with unit tests; activeTargetID is now published so the menu stays live when activation happens while the proxy is off.
This commit is contained in:
@@ -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: - 侧栏树快照
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ProxyMode> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user