refactor(ng2): remove system-level shortcuts

Keep proxy controls explicit and leave existing shortcut preferences untouched.
This commit is contained in:
Qiu Yuzhou
2026-09-22 16:16:58 +08:00
parent 46ebe1d835
commit 3dc6043dc6
8 changed files with 20 additions and 107 deletions
+3
View File
@@ -22,6 +22,9 @@
- **Sensitive information**: Server passwords, plugin options (sensitive in their entirety), and nonempty user-provided remote URLs, including subscription, external PAC, and custom GFW List URLs.
- **Credential exposure boundary**: This product reduces accidental disclosure and exposure to other user accounts, but does not guarantee protection against a compromised same-user process, root access, or APFS snapshots and backups; it does not promise secure erasure.
- **Credential reference**: A non-secret association from a server configuration, subscription, or remote-URL setting to its durable credential; the credential value is kept outside the configuration tree and resolved only when needed.
- **System-level shortcut**: A user-configurable keyboard action registered by the app that can trigger an app action even when the app has no focus.
_Avoid_: global hotkey, global shortcut.
- **Window-local default action shortcut**: A keyboard action bound to the focused app window or form context, such as confirming a dialog; it is distinct from a system-level shortcut.
## Relationships and invariants
@@ -1,36 +0,0 @@
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, availableModes: controller.settings.enabledModes))
}
}
}
}
-2
View File
@@ -41,8 +41,6 @@ struct ShadowsocksXNG2App: App {
MenuBarExtra("ShadowsocksX-NG 2.0", systemImage: "network") {
ProxyStatusMenu(controller: proxyController, catalogWorkflow: catalogWorkflow)
.task {
// 全局快捷键(issue #31):开关代理、切换模式,与菜单项同一入口。
GlobalShortcuts.wire(controller: proxyController)
await proxyController.resyncOnLaunch()
}
}
+1 -11
View File
@@ -1,5 +1,3 @@
import AppKit
import KeyboardShortcuts
import SwiftUI
/// Native macOS settings for issue #33. The view edits one local snapshot and
@@ -66,7 +64,7 @@ struct SettingsView: View {
}
Button("取消", role: .cancel) {}
} message: {
Text("端口、监听范围、快捷键意图和 PAC 设置都会恢复为出厂值。")
Text("端口、监听范围和 PAC 设置都会恢复为出厂值。")
}
}
@@ -88,13 +86,6 @@ struct SettingsView: View {
.foregroundStyle(.red)
}
LabeledContent("全局快捷键") {
VStack(alignment: .leading, spacing: 8) {
KeyboardShortcuts.Recorder("开关代理", name: .toggleProxy)
KeyboardShortcuts.Recorder("切换模式", name: .cycleProxyMode)
}
}
Picker("系统代理模式", selection: modeBinding) {
if isModeAvailable(.pac) {
Text(ProxyMode.pac.label).tag(ProxyMode.pac)
@@ -407,7 +398,6 @@ extension SettingsView {
Task { @MainActor in
do {
try await proxyController.resetPreferences()
KeyboardShortcuts.reset(.toggleProxy, .cycleProxyMode)
loginController.resetToDefaults()
draft = proxyController.settings
refreshPortOccupancy()
+2 -25
View File
@@ -58,8 +58,8 @@ enum StatusMenuModel {
targetPath: targetPath)
}
/// 代理开关的当前意图(菜单开关与全局快捷键共用的同一判定):启动失败等
/// 中间态视为未开——两个入口的下一步动作都是「启动」。
/// 代理开关的当前意图(状态菜单与当前运行端口判断共用同一判定):启动失败等
/// 中间态视为未开——状态菜单的下一步动作是「启动」。
static func isOn(state: ProxyRuntimeController.ProxyState) -> Bool {
switch state {
case .off, .launchFailed, .activationFailed, .serviceFailed:
@@ -78,29 +78,6 @@ enum StatusMenuModel {
return "export http_proxy=\(endpoint);export https_proxy=\(endpoint);"
}
/// 全局快捷键「切换模式」的循环序:仅内置三模式参与;外部 PAC 的 URL 只能
/// 在设置区配置(#33),不进循环——快捷键从外部 PAC 回到第一个可用内置模式。
static func nextMode(
after mode: ProxyMode,
availableModes: Set<ProxyModeKind> = ProxySettings.defaultEnabledModes
) -> ProxyMode {
let cycle = [ProxyModeKind.pac, .global, .manual].filter(availableModes.contains)
guard !cycle.isEmpty else { return mode }
guard let currentIndex = cycle.firstIndex(of: mode.kind) else {
return proxyMode(for: cycle[0])
}
return proxyMode(for: cycle[(currentIndex + 1) % cycle.count])
}
private static func proxyMode(for kind: ProxyModeKind) -> ProxyMode {
switch kind {
case .pac: .pac
case .global: .global
case .manual: .manual
case .externalPAC: .pac
}
}
/// 活动目标的显示名路径(根 → 节点," / " 连接);基于目录树 projection
/// 派生(issue #41)。未激活或目标已不在树中为 nil。
static func targetPath(in nodes: [CatalogTreeNode], activeTargetID: NodeID?) -> String? {
@@ -83,31 +83,6 @@ final class StatusMenuModelTests: XCTestCase {
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)
}
func testNextModeSkipsLegacyDisabledModes() {
let enabledModes: Set<ProxyModeKind> = [.pac, .manual]
XCTAssertEqual(
StatusMenuModel.nextMode(after: .pac, availableModes: enabledModes),
.manual)
XCTAssertEqual(
StatusMenuModel.nextMode(after: .manual, availableModes: enabledModes),
.pac)
}
// MARK: - 活动目标路径(目录树 projection 派生,issue #41)
/// 目录树 projection 构建夹具(同工作流 module 的推导路径)。
-8
View File
@@ -4,12 +4,6 @@ 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
@@ -96,8 +90,6 @@ targets:
copy:
destination: wrapper
subpath: Contents/MacOS
# 全局快捷键(D11,issue #31):开关代理、切换模式。
- package: KeyboardShortcuts
ShadowsocksX-NG2Agent:
type: tool
platform: macOS
@@ -0,0 +1,14 @@
# Remove system-level shortcuts from NG2
NG2 no longer provides user-configurable system-level shortcuts for toggling the proxy or cycling proxy modes. We remove the `KeyboardShortcuts` integration and keep status-menu controls and window-local default action shortcuts; Legacy remains frozen, and existing stored shortcut preferences are neither migrated nor proactively cleaned up.
## Considered options
- Keep the feature or replace it with another global shortcut library: rejected because the product now has an explicit menu-driven interaction boundary and no approved replacement gesture.
- Remove only the UI while retaining the dependency and handlers: rejected because it would leave an invisible system-level behavior and dead configuration state.
## Consequences
- Proxy enablement and mode selection remain available from the status menu and settings/window flows.
- The `nextMode` helper and its tests are removed because no remaining product behavior needs implicit mode cycling.
- Historical Legacy research and prior ADRs remain unchanged as historical records.