From f6c02c6a97f59934a8cae79f343cfb43a6a568a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AA=A0=E8=AA=A0-ChengCheng?= <37478187+TW527E@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:48:54 +0800 Subject: [PATCH] Release ShiftInput 0.3.0 with per-app shortcut bypass --- Makefile | 2 +- README.en.md | 14 +- README.md | 14 +- README.zh-CN.md | 14 +- Scripts/StateMachineChecks.swift | 68 +++++++ Sources/ShiftInput/AppDelegate.swift | 34 +++- .../ForegroundApplicationInfo.swift | 25 +++ .../ShiftInput/GlobalKeyboardMonitor.swift | 3 +- .../PreferencesWindowController.swift | 166 +++++++++++++++++- Sources/ShiftInput/SettingsStore.swift | 76 ++++++++ Sources/ShiftInput/StatusBarController.swift | 70 ++++++++ .../ApplicationBypassPolicy.swift | 109 ++++++++++++ VERSION | 2 +- 13 files changed, 578 insertions(+), 19 deletions(-) create mode 100644 Sources/ShiftInput/ForegroundApplicationInfo.swift create mode 100644 Sources/ShiftInputCore/ApplicationBypassPolicy.swift diff --git a/Makefile b/Makefile index b0bbdfc..d4c7aff 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ build: test: mkdir -p .build/checks - swiftc Sources/ShiftInputCore/*.swift Scripts/StateMachineChecks.swift -o .build/checks/state-machine-checks + swiftc Sources/ShiftInputCore/*.swift Sources/ShiftInput/SettingsStore.swift Scripts/StateMachineChecks.swift -o .build/checks/state-machine-checks .build/checks/state-machine-checks app: diff --git a/README.en.md b/README.en.md index 85edd07..1af0215 100644 --- a/README.en.md +++ b/README.en.md @@ -23,6 +23,13 @@ ShiftInput is a lightweight, event-driven, native macOS enhancement for switchin The two shortcuts can be enabled or disabled independently in Settings. +### Remote-app and game bypass + +- `Shift` and `Shift + Space` pass through by default in common remote-desktop, streaming, and game applications so ShiftInput does not intercept them. +- Automatic detection covers common remote apps, the macOS game category, and Steam, GOG, and Epic game paths. +- Add other apps to the Settings list and control the `Shift` and `Shift + Space` bypasses independently. +- Newly added apps bypass both shortcuts by default; the menu-bar menu can also add the current app quickly. + ## Other settings - Show the menu bar icon; enabled by default. @@ -48,20 +55,20 @@ make test # Create dist/ShiftInput.app make app -# Create dist/ShiftInput-0.2.2.dmg +# Create dist/ShiftInput-0.3.0.dmg make dmg ``` Build a Universal Binary for both Apple Silicon and Intel: ```bash -BUILD_ARCHS="arm64 x86_64" VERSION=0.2.2 make dmg +BUILD_ARCHS="arm64 x86_64" VERSION=0.3.0 make dmg ``` Other supported build parameters: ```bash -VERSION=0.2.2 BUILD_NUMBER=2 CONFIGURATION=release make app +VERSION=0.3.0 BUILD_NUMBER=2 CONFIGURATION=release make app ``` Run all local verification: @@ -98,6 +105,7 @@ To publish a new version, change the semantic version in the root `VERSION` file - Each input-source notification uses one system snapshot to update persistence, Pinyin capability, and UI consistently. - `TISSelectInputSource` performs input-source changes and the previous source is persisted; each switch is confirmed, including the active keyboard layout for Apple Pinyin, and retried when necessary. - `Shift + Space` is forwarded as Apple's native `Option + Shift + H` command only when Apple Pinyin is detected. +- Foreground-app metadata is refreshed only when the active app changes; remote-app and game bypass checks do not poll windows or processes. - A timed-out event tap recovers automatically; monitoring stops if permissions are revoked. - Shortcut changes or a system-disabled event tap rebuild the monitor safely with the latest configuration. diff --git a/README.md b/README.md index 66d5e9c..0f685dc 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,13 @@ ShiftInput 是一個原生、輕量且事件驅動的 macOS 輸入法切換增 以上兩項功能可在設定中分別啟用或停用。 +### 遠端軟體與遊戲放行 + +- 預設在常見遠端桌面、串流軟體及遊戲中,同時放行 `Shift` 與 `Shift + Space`,避免快捷鍵被 ShiftInput 攔截。 +- 自動辨識常見遠端軟體、macOS 遊戲分類,以及 Steam、GOG、Epic 遊戲路徑。 +- 可在設定的應用程式列表中加入其他 App,並分別開關 `Shift` 與 `Shift + Space` 放行規則。 +- 新加入的 App 預設同時放行兩項快捷鍵;選單列選單也能快速加入目前 App。 + ## 其他設定 - 顯示選單列圖標,預設開啟。 @@ -48,20 +55,20 @@ make test # 建立 dist/ShiftInput.app make app -# 建立 dist/ShiftInput-0.2.2.dmg +# 建立 dist/ShiftInput-0.3.0.dmg make dmg ``` 建立同時支援 Apple Silicon 與 Intel 的 Universal Binary: ```bash -BUILD_ARCHS="arm64 x86_64" VERSION=0.2.2 make dmg +BUILD_ARCHS="arm64 x86_64" VERSION=0.3.0 make dmg ``` 其他可用參數: ```bash -VERSION=0.2.2 BUILD_NUMBER=2 CONFIGURATION=release make app +VERSION=0.3.0 BUILD_NUMBER=2 CONFIGURATION=release make app ``` 執行完整驗證: @@ -98,6 +105,7 @@ make verify - 每次輸入來源通知只讀取一次系統快照,並一致地更新保存來源、拼音能力與介面。 - 使用 `TISSelectInputSource` 切換輸入法並保存上次來源;切換後會確認實際來源,Apple 拼音亦會確認其鍵盤配置並在需要時重試。 - `Shift + Space` 僅在辨識為 Apple 拼音時轉送為原生 `Option + Shift + H` 命令。 +- 前景 App 改變時才更新應用程式資料;遠端軟體與遊戲放行判斷不輪詢窗口或程序。 - Event tap 逾時停用時會自動恢復;權限被撤銷時會停止監聽。 - 快捷鍵設定改變或 event tap 被系統停用時,監聽器會以最新設定安全重建。 diff --git a/README.zh-CN.md b/README.zh-CN.md index daaed2d..140ec35 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,6 +23,13 @@ ShiftInput 是一个原生、轻量且事件驱动的 macOS 输入法切换增 以上两项功能可以在设置中分别启用或停用。 +### 远程软件与游戏放行 + +- 默认在常见远程桌面、串流软件和游戏中同时放行 `Shift` 与 `Shift + Space`,避免快捷键被 ShiftInput 拦截。 +- 自动识别常见远程软件、macOS 游戏分类,以及 Steam、GOG、Epic 游戏路径。 +- 可以在设置的应用程序列表中加入其他 App,并分别开关 `Shift` 与 `Shift + Space` 放行规则。 +- 新加入的 App 默认同时放行两项快捷键;菜单栏菜单也能快速加入当前 App。 + ## 其他设置 - 显示菜单栏图标,默认开启。 @@ -48,20 +55,20 @@ make test # 创建 dist/ShiftInput.app make app -# 创建 dist/ShiftInput-0.2.2.dmg +# 创建 dist/ShiftInput-0.3.0.dmg make dmg ``` 创建同时支持 Apple Silicon 和 Intel 的 Universal Binary: ```bash -BUILD_ARCHS="arm64 x86_64" VERSION=0.2.2 make dmg +BUILD_ARCHS="arm64 x86_64" VERSION=0.3.0 make dmg ``` 其他可用参数: ```bash -VERSION=0.2.2 BUILD_NUMBER=2 CONFIGURATION=release make app +VERSION=0.3.0 BUILD_NUMBER=2 CONFIGURATION=release make app ``` 运行完整验证: @@ -98,6 +105,7 @@ make verify - 每次输入来源通知只读取一次系统快照,并一致地更新保存来源、拼音能力和界面。 - 使用 `TISSelectInputSource` 切换输入法并保存上次来源;切换后会确认实际来源,Apple 拼音也会确认其键盘布局并在需要时重试。 - `Shift + Space` 仅在识别为 Apple 拼音时转发为原生 `Option + Shift + H` 命令。 +- 仅在前台 App 改变时更新应用程序数据;远程软件和游戏放行判断不会轮询窗口或进程。 - Event tap 超时停用时会自动恢复;权限被撤销时会停止监听。 - 快捷键设置改变或 event tap 被系统停用时,监听器会使用最新设置安全重建。 diff --git a/Scripts/StateMachineChecks.swift b/Scripts/StateMachineChecks.swift index 7f68af7..4c42423 100644 --- a/Scripts/StateMachineChecks.swift +++ b/Scripts/StateMachineChecks.swift @@ -79,6 +79,74 @@ enum StateMachineChecks { localizedName: "Shuangpin – Simplified" ), "Shuangpin is not mistaken for standard Pinyin") + expect(ApplicationBypassPolicy.isAutomaticallyBypassed( + bundleIdentifier: "com.parsecgaming.parsec", + localizedName: "Parsec", + bundlePath: "/Applications/Parsec.app", + applicationCategory: nil + ), "known remote desktop apps are automatically bypassed") + expect(ApplicationBypassPolicy.isAutomaticallyBypassed( + bundleIdentifier: "com.example.game", + localizedName: "Example Game", + bundlePath: "/Users/test/Library/Application Support/Steam/steamapps/common/Example/Example.app", + applicationCategory: nil + ), "Steam games are automatically bypassed by path") + expect(ApplicationBypassPolicy.isAutomaticallyBypassed( + bundleIdentifier: "com.example.arcade", + localizedName: "Example Arcade", + bundlePath: "/Applications/Example Arcade.app", + applicationCategory: "public.app-category.games" + ), "apps categorized as games are automatically bypassed") + expect(!ApplicationBypassPolicy.isAutomaticallyBypassed( + bundleIdentifier: "com.apple.Safari", + localizedName: "Safari", + bundlePath: "/Applications/Safari.app", + applicationCategory: "public.app-category.productivity" + ), "ordinary apps are not automatically bypassed") + expect(ApplicationBypassPolicy.shouldBypass( + bundleIdentifier: "com.example.custom", + localizedName: "Custom App", + bundlePath: "/Applications/Custom App.app", + applicationCategory: nil, + excludedBundleIdentifiers: ["com.example.custom"], + automaticBypassEnabled: false + ), "manual exclusions work when automatic bypass is disabled") + expect(!ApplicationBypassPolicy.shouldBypass( + bundleIdentifier: "com.parsecgaming.parsec", + localizedName: "Parsec", + bundlePath: "/Applications/Parsec.app", + applicationCategory: nil, + excludedBundleIdentifiers: [], + automaticBypassEnabled: false + ), "automatic bypass can be disabled") + + let defaultsName = "ShiftInputChecks.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: defaultsName) else { + fputs("FAILED: could not create isolated defaults\n", stderr) + exit(1) + } + defer { defaults.removePersistentDomain(forName: defaultsName) } + let settings = SettingsStore(defaults: defaults) + settings.addExcludedApplication(bundleIdentifier: "com.example.remote") + expect(settings.shiftExcludedBundleIDs.contains("com.example.remote"), "new app bypasses Shift by default") + expect(settings.pinyinWidthExcludedBundleIDs.contains("com.example.remote"), "new app bypasses Shift-Space by default") + settings.setShiftExcluded(false, bundleIdentifier: "com.example.remote") + expect(!settings.shiftExcludedBundleIDs.contains("com.example.remote"), "Shift bypass can be disabled independently") + expect(settings.pinyinWidthExcludedBundleIDs.contains("com.example.remote"), "Shift-Space bypass remains enabled independently") + expect(settings.excludedApplicationBundleIDs.contains("com.example.remote"), "partially enabled app remains in the list") + settings.removeExcludedApplication(bundleIdentifier: "com.example.remote") + expect(!settings.excludedApplicationBundleIDs.contains("com.example.remote"), "removing an app clears both bypass switches") + + let migrationDefaultsName = "ShiftInputMigrationChecks.\(UUID().uuidString)" + guard let migrationDefaults = UserDefaults(suiteName: migrationDefaultsName) else { + fputs("FAILED: could not create migration defaults\n", stderr) + exit(1) + } + defer { migrationDefaults.removePersistentDomain(forName: migrationDefaultsName) } + migrationDefaults.set(["com.example.old"], forKey: "pinyinWidthExcludedBundleIDs") + let migratedSettings = SettingsStore(defaults: migrationDefaults) + expect(migratedSettings.shiftExcludedBundleIDs.contains("com.example.old"), "old test-build exclusions migrate to bypass both shortcuts") + print("State machine checks passed: \(checkCount)") } } diff --git a/Sources/ShiftInput/AppDelegate.swift b/Sources/ShiftInput/AppDelegate.swift index 8f9d1a1..aae2100 100644 --- a/Sources/ShiftInput/AppDelegate.swift +++ b/Sources/ShiftInput/AppDelegate.swift @@ -1,4 +1,5 @@ import AppKit +import ShiftInputCore final class AppDelegate: NSObject, NSApplicationDelegate { private let settings = SettingsStore.shared @@ -9,9 +10,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private lazy var preferences = PreferencesWindowController(settings: settings) private var settingsObserver: NSObjectProtocol? private var workspaceObserver: NSObjectProtocol? + private var foregroundApplication: ForegroundApplicationInfo? func applicationDidFinishLaunching(_ notification: Notification) { let isFirstLaunch = settings.consumeFirstLaunch() + rememberForegroundApplication(NSWorkspace.shared.frontmostApplication) configureApplicationMenu() applyDockVisibility() configureCallbacks() @@ -29,7 +32,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main - ) { [weak self] _ in + ) { [weak self] notification in + let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + self?.rememberForegroundApplication(application) self?.refreshKeyboardMonitor() self?.refreshPermissionUI() self?.preferences.refresh() @@ -85,9 +90,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } } + keyboardMonitor.shouldHandleShiftTap = { [weak self] in + guard let self, self.settings.shiftToggleEnabled else { return false } + return !self.shouldBypassShortcut(manualBundleIDs: self.settings.shiftExcludedBundleIDs) + } keyboardMonitor.shouldHandleWidthToggle = { [weak self] in guard let self, self.settings.pinyinWidthToggleEnabled else { return false } - return self.inputSources.currentSourceSupportsPinyinWidthToggle + guard self.inputSources.currentSourceSupportsPinyinWidthToggle else { return false } + return !self.shouldBypassShortcut(manualBundleIDs: self.settings.pinyinWidthExcludedBundleIDs) } keyboardMonitor.onWidthToggle = { GlobalKeyboardMonitor.postNativeChineseWidthShortcut() @@ -100,14 +110,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } statusBar.onOpenPreferences = { [weak self] in self?.preferences.showAndActivate() } statusBar.onRetryPermission = { [weak self] in self?.requestAndRefreshPermission() } + statusBar.foregroundApplicationProvider = { [weak self] in self?.foregroundApplication } preferences.onRetryPermission = { [weak self] in self?.requestAndRefreshPermission() } } + private func shouldBypassShortcut(manualBundleIDs: Set) -> Bool { + guard let application = foregroundApplication else { return false } + return ApplicationBypassPolicy.shouldBypass( + bundleIdentifier: application.bundleIdentifier, + localizedName: application.localizedName, + bundlePath: application.bundlePath, + applicationCategory: application.applicationCategory, + excludedBundleIdentifiers: manualBundleIDs, + automaticBypassEnabled: settings.automaticallyBypassRemoteAppsAndGames + ) + } + + private func rememberForegroundApplication(_ application: NSRunningApplication?) { + guard let application, + let info = ForegroundApplicationInfo(runningApplication: application) else { return } + foregroundApplication = info + } + private func settingsDidChange() { applyDockVisibility() statusBar.applyVisibility() refreshKeyboardMonitor() refreshPermissionUI() + preferences.refresh() } private func refreshKeyboardMonitor() { diff --git a/Sources/ShiftInput/ForegroundApplicationInfo.swift b/Sources/ShiftInput/ForegroundApplicationInfo.swift new file mode 100644 index 0000000..a63a1ef --- /dev/null +++ b/Sources/ShiftInput/ForegroundApplicationInfo.swift @@ -0,0 +1,25 @@ +import AppKit + +struct ForegroundApplicationInfo: Equatable { + let bundleIdentifier: String + let localizedName: String + let bundlePath: String? + let applicationCategory: String? + + init?(runningApplication: NSRunningApplication) { + guard let bundleIdentifier = runningApplication.bundleIdentifier, + bundleIdentifier != Bundle.main.bundleIdentifier else { + return nil + } + + let bundleURL = runningApplication.bundleURL + let appBundle = bundleURL.flatMap(Bundle.init(url:)) + self.bundleIdentifier = bundleIdentifier + self.localizedName = runningApplication.localizedName + ?? appBundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? appBundle?.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? bundleIdentifier + self.bundlePath = bundleURL?.path + self.applicationCategory = appBundle?.object(forInfoDictionaryKey: "LSApplicationCategoryType") as? String + } +} diff --git a/Sources/ShiftInput/GlobalKeyboardMonitor.swift b/Sources/ShiftInput/GlobalKeyboardMonitor.swift index 134b3e0..ecbd9ad 100644 --- a/Sources/ShiftInput/GlobalKeyboardMonitor.swift +++ b/Sources/ShiftInput/GlobalKeyboardMonitor.swift @@ -8,6 +8,7 @@ final class GlobalKeyboardMonitor { } var onShiftTap: (() -> Void)? + var shouldHandleShiftTap: (() -> Bool)? var shouldHandleWidthToggle: (() -> Bool)? var onWidthToggle: (() -> Void)? var onTapDisabled: (() -> Void)? @@ -107,7 +108,7 @@ final class GlobalKeyboardMonitor { let action = isShiftKey ? state.shiftFlagsChanged(keyCode: keyCode, hasOtherModifiers: !otherFlags.isEmpty) : state.otherModifierChanged() - if action == .toggleInputSource { + if action == .toggleInputSource, shouldHandleShiftTap?() ?? true { DispatchQueue.main.async { [weak self] in self?.onShiftTap?() } } diff --git a/Sources/ShiftInput/PreferencesWindowController.swift b/Sources/ShiftInput/PreferencesWindowController.swift index 1a02d44..3c0b3e8 100644 --- a/Sources/ShiftInput/PreferencesWindowController.swift +++ b/Sources/ShiftInput/PreferencesWindowController.swift @@ -1,11 +1,17 @@ import AppKit +import UniformTypeIdentifiers -final class PreferencesWindowController: NSWindowController, NSWindowDelegate { +final class PreferencesWindowController: NSWindowController, NSWindowDelegate, NSTableViewDataSource, NSTableViewDelegate { var onRetryPermission: (() -> Void)? private let settings: SettingsStore private let shiftToggleButton = NSButton(checkboxWithTitle: "啟用 Shift 輸入法切換", target: nil, action: nil) private let pinyinWidthToggleButton = NSButton(checkboxWithTitle: "啟用 Shift + Space 拼音全形/半形切換", target: nil, action: nil) + private let automaticBypassButton = NSButton(checkboxWithTitle: "在遠端軟體與遊戲中自動放行兩項快捷鍵", target: nil, action: nil) + private let excludedAppsTable = NSTableView() + private var excludedBundleIDs: [String] = [] + private let addExcludedAppButton = NSButton(title: "加入 App…", target: nil, action: nil) + private let removeExcludedAppButton = NSButton(title: "移除", target: nil, action: nil) private let statusItemButton = NSButton(checkboxWithTitle: "在選單列顯示圖標", target: nil, action: nil) private let dockIconButton = NSButton(checkboxWithTitle: "在 Dock 顯示圖標", target: nil, action: nil) private let permissionStatus = NSTextField(labelWithString: "") @@ -14,7 +20,7 @@ final class PreferencesWindowController: NSWindowController, NSWindowDelegate { init(settings: SettingsStore) { self.settings = settings let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 420), + contentRect: NSRect(x: 0, y: 0, width: 520, height: 650), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false @@ -43,6 +49,8 @@ final class PreferencesWindowController: NSWindowController, NSWindowDelegate { func refresh() { shiftToggleButton.state = settings.shiftToggleEnabled ? .on : .off pinyinWidthToggleButton.state = settings.pinyinWidthToggleEnabled ? .on : .off + automaticBypassButton.state = settings.automaticallyBypassRemoteAppsAndGames ? .on : .off + refreshExcludedApps() statusItemButton.state = settings.showStatusItem ? .on : .off dockIconButton.state = settings.showDockIcon ? .on : .off let status = AccessibilityPermission.status @@ -61,7 +69,7 @@ final class PreferencesWindowController: NSWindowController, NSWindowDelegate { let subtitle = NSTextField(wrappingLabelWithString: "兩項快捷鍵可獨立啟用。Shift 用於輸入法切換;Shift + Space 僅用於 Apple 拼音輸入法的全形/半形切換。") subtitle.textColor = .secondaryLabelColor - [shiftToggleButton, pinyinWidthToggleButton, statusItemButton, dockIconButton].forEach { + [shiftToggleButton, pinyinWidthToggleButton, automaticBypassButton, statusItemButton, dockIconButton].forEach { $0.target = self $0.action = #selector(settingChanged(_:)) } @@ -79,20 +87,62 @@ final class PreferencesWindowController: NSWindowController, NSWindowDelegate { let shortcuts = NSTextField(wrappingLabelWithString: "Shift(單獨點按) 英文 ⇄ 上次輸入法\nShift + Space   Apple 拼音輸入法全形/半形") shortcuts.font = .monospacedSystemFont(ofSize: 13, weight: .regular) + let bypassTitle = NSTextField(labelWithString: "應用程式快捷鍵放行") + bypassTitle.font = .systemFont(ofSize: 13, weight: .semibold) + let bypassNote = NSTextField(wrappingLabelWithString: "可分別決定每個 App 是否略過 Shift 輸入法切換及 Shift + Space 全/半形切換。新加入的 App 預設兩項都放行。自動模式會對常見遠端軟體及遊戲放行兩項快捷鍵。") + bypassNote.font = .systemFont(ofSize: 12) + bypassNote.textColor = .secondaryLabelColor + let excludedAppsTitle = NSTextField(labelWithString: "應用程式列表") + excludedAppsTitle.font = .systemFont(ofSize: 12, weight: .medium) + let appColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("application")) + appColumn.title = "應用程式" + appColumn.width = 230 + let shiftColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("shift")) + shiftColumn.title = "Shift" + shiftColumn.width = 90 + let shiftSpaceColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("shiftSpace")) + shiftSpaceColumn.title = "Shift + Space" + shiftSpaceColumn.width = 125 + excludedAppsTable.addTableColumn(appColumn) + excludedAppsTable.addTableColumn(shiftColumn) + excludedAppsTable.addTableColumn(shiftSpaceColumn) + excludedAppsTable.delegate = self + excludedAppsTable.dataSource = self + excludedAppsTable.usesAlternatingRowBackgroundColors = true + excludedAppsTable.allowsMultipleSelection = false + excludedAppsTable.rowHeight = 28 + let excludedAppsScrollView = NSScrollView() + excludedAppsScrollView.hasVerticalScroller = true + excludedAppsScrollView.autohidesScrollers = true + excludedAppsScrollView.borderType = .bezelBorder + excludedAppsScrollView.documentView = excludedAppsTable + excludedAppsScrollView.widthAnchor.constraint(equalToConstant: 456).isActive = true + excludedAppsScrollView.heightAnchor.constraint(equalToConstant: 106).isActive = true + addExcludedAppButton.target = self + addExcludedAppButton.action = #selector(addExcludedApp) + removeExcludedAppButton.target = self + removeExcludedAppButton.action = #selector(removeExcludedApp) + let excludedAppControls = NSStackView(views: [addExcludedAppButton, removeExcludedAppButton]) + excludedAppControls.orientation = .horizontal + excludedAppControls.alignment = .centerY + excludedAppControls.spacing = 8 + let stack = NSStackView(views: [ title, subtitle, separator(), shiftToggleButton, pinyinWidthToggleButton, - shortcutTitle, shortcuts, separator(), statusItemButton, + shortcutTitle, shortcuts, separator(), bypassTitle, automaticBypassButton, + bypassNote, excludedAppsTitle, excludedAppsScrollView, excludedAppControls, separator(), statusItemButton, dockIconButton, visibilityNote, separator(), permissionStatus, permissionButton ]) stack.orientation = .vertical stack.alignment = .leading - stack.spacing = 10 + stack.spacing = 8 stack.translatesAutoresizingMaskIntoConstraints = false content.addSubview(stack) subtitle.widthAnchor.constraint(equalToConstant: 456).isActive = true visibilityNote.widthAnchor.constraint(equalToConstant: 456).isActive = true + bypassNote.widthAnchor.constraint(equalToConstant: 456).isActive = true stack.arrangedSubviews.compactMap { $0 as? NSBox }.forEach { $0.widthAnchor.constraint(equalToConstant: 456).isActive = true } @@ -115,6 +165,8 @@ final class PreferencesWindowController: NSWindowController, NSWindowDelegate { settings.shiftToggleEnabled = sender.state == .on case pinyinWidthToggleButton: settings.pinyinWidthToggleEnabled = sender.state == .on + case automaticBypassButton: + settings.automaticallyBypassRemoteAppsAndGames = sender.state == .on case statusItemButton: settings.showStatusItem = sender.state == .on case dockIconButton: @@ -128,4 +180,108 @@ final class PreferencesWindowController: NSWindowController, NSWindowDelegate { onRetryPermission?() DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in self?.refresh() } } + + @objc private func addExcludedApp() { + guard let window else { return } + let panel = NSOpenPanel() + panel.title = "選擇要放行快捷鍵的 App" + panel.prompt = "加入" + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.applicationBundle] + panel.beginSheetModal(for: window) { [weak self] response in + guard response == .OK, + let url = panel.url, + let bundleIdentifier = Bundle(url: url)?.bundleIdentifier else { return } + self?.settings.addExcludedApplication(bundleIdentifier: bundleIdentifier) + self?.refresh() + if let row = self?.excludedBundleIDs.firstIndex(of: bundleIdentifier) { + self?.excludedAppsTable.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } + } + } + + @objc private func removeExcludedApp() { + let row = excludedAppsTable.selectedRow + guard excludedBundleIDs.indices.contains(row) else { return } + settings.removeExcludedApplication(bundleIdentifier: excludedBundleIDs[row]) + refresh() + } + + private func refreshExcludedApps() { + excludedBundleIDs = settings.excludedApplicationBundleIDs.sorted { + displayName(for: $0).localizedCaseInsensitiveCompare(displayName(for: $1)) == .orderedAscending + } + excludedAppsTable.reloadData() + removeExcludedAppButton.isEnabled = excludedAppsTable.selectedRow >= 0 + } + + func numberOfRows(in tableView: NSTableView) -> Int { + excludedBundleIDs.count + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard excludedBundleIDs.indices.contains(row), let tableColumn else { return nil } + let bundleIdentifier = excludedBundleIDs[row] + switch tableColumn.identifier.rawValue { + case "application": + let label = NSTextField(labelWithString: displayName(for: bundleIdentifier)) + label.lineBreakMode = .byTruncatingTail + label.toolTip = bundleIdentifier + return label + case "shift": + return bypassButton( + title: "放行", + bundleIdentifier: bundleIdentifier, + isOn: settings.shiftExcludedBundleIDs.contains(bundleIdentifier), + tag: 1 + ) + case "shiftSpace": + return bypassButton( + title: "放行", + bundleIdentifier: bundleIdentifier, + isOn: settings.pinyinWidthExcludedBundleIDs.contains(bundleIdentifier), + tag: 2 + ) + default: + return nil + } + } + + func tableViewSelectionDidChange(_ notification: Notification) { + removeExcludedAppButton.isEnabled = excludedAppsTable.selectedRow >= 0 + } + + private func bypassButton( + title: String, + bundleIdentifier: String, + isOn: Bool, + tag: Int + ) -> NSButton { + let button = NSButton(checkboxWithTitle: title, target: self, action: #selector(shortcutBypassChanged(_:))) + button.identifier = NSUserInterfaceItemIdentifier(bundleIdentifier) + button.state = isOn ? .on : .off + button.tag = tag + return button + } + + @objc private func shortcutBypassChanged(_ sender: NSButton) { + guard let bundleIdentifier = sender.identifier?.rawValue else { return } + let excluded = sender.state == .on + if sender.tag == 1 { + settings.setShiftExcluded(excluded, bundleIdentifier: bundleIdentifier) + } else if sender.tag == 2 { + settings.setPinyinWidthExcluded(excluded, bundleIdentifier: bundleIdentifier) + } + refresh() + } + + private func displayName(for bundleIdentifier: String) -> String { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier), + let bundle = Bundle(url: url) else { return bundleIdentifier } + return bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? bundleIdentifier + } } diff --git a/Sources/ShiftInput/SettingsStore.swift b/Sources/ShiftInput/SettingsStore.swift index c6a3f25..79ad2ba 100644 --- a/Sources/ShiftInput/SettingsStore.swift +++ b/Sources/ShiftInput/SettingsStore.swift @@ -11,6 +11,10 @@ final class SettingsStore { static let legacyFeatureEnabled = "featureEnabled" static let shiftToggleEnabled = "shiftToggleEnabled" static let pinyinWidthToggleEnabled = "pinyinWidthToggleEnabled" + static let automaticallyBypassRemoteAppsAndGames = "automaticallyBypassRemoteAppsAndGames" + static let shiftExcludedBundleIDs = "shiftExcludedBundleIDs" + static let pinyinWidthExcludedBundleIDs = "pinyinWidthExcludedBundleIDs" + static let shortcutBypassRulesVersion = "shortcutBypassRulesVersion" static let showStatusItem = "showStatusItem" static let showDockIcon = "showDockIcon" static let lastNonEnglishSourceID = "lastNonEnglishSourceID" @@ -22,12 +26,25 @@ final class SettingsStore { init(defaults: UserDefaults = .standard) { self.defaults = defaults let legacyValue = defaults.object(forKey: Key.legacyFeatureEnabled) as? Bool + let existingWidthExclusions = defaults.stringArray(forKey: Key.pinyinWidthExcludedBundleIDs) ?? [] + let shortcutBypassRulesVersion = defaults.integer(forKey: Key.shortcutBypassRulesVersion) defaults.register(defaults: [ Key.shiftToggleEnabled: legacyValue ?? true, Key.pinyinWidthToggleEnabled: legacyValue ?? true, + Key.automaticallyBypassRemoteAppsAndGames: true, + Key.shiftExcludedBundleIDs: [String](), + Key.pinyinWidthExcludedBundleIDs: [String](), Key.showStatusItem: true, Key.showDockIcon: false ]) + // The first local test build only stored Shift + Space exclusions. + // Promote those entries to the new default of bypassing both shortcuts. + if shortcutBypassRulesVersion < 1 { + if !existingWidthExclusions.isEmpty { + defaults.set(existingWidthExclusions, forKey: Key.shiftExcludedBundleIDs) + } + defaults.set(1, forKey: Key.shortcutBypassRulesVersion) + } } var shiftToggleEnabled: Bool { @@ -44,6 +61,52 @@ final class SettingsStore { shiftToggleEnabled || pinyinWidthToggleEnabled } + var automaticallyBypassRemoteAppsAndGames: Bool { + get { defaults.bool(forKey: Key.automaticallyBypassRemoteAppsAndGames) } + set { set(newValue, forKey: Key.automaticallyBypassRemoteAppsAndGames) } + } + + var pinyinWidthExcludedBundleIDs: Set { + Set(defaults.stringArray(forKey: Key.pinyinWidthExcludedBundleIDs) ?? []) + } + + var shiftExcludedBundleIDs: Set { + Set(defaults.stringArray(forKey: Key.shiftExcludedBundleIDs) ?? []) + } + + var excludedApplicationBundleIDs: Set { + shiftExcludedBundleIDs.union(pinyinWidthExcludedBundleIDs) + } + + func addExcludedApplication(bundleIdentifier: String) { + var shiftBundleIDs = shiftExcludedBundleIDs + var widthBundleIDs = pinyinWidthExcludedBundleIDs + shiftBundleIDs.insert(bundleIdentifier) + widthBundleIDs.insert(bundleIdentifier) + defaults.set(shiftBundleIDs.sorted(), forKey: Key.shiftExcludedBundleIDs) + defaults.set(widthBundleIDs.sorted(), forKey: Key.pinyinWidthExcludedBundleIDs) + NotificationCenter.default.post(name: .shiftInputSettingsDidChange, object: self) + } + + func removeExcludedApplication(bundleIdentifier: String) { + var shiftBundleIDs = shiftExcludedBundleIDs + var widthBundleIDs = pinyinWidthExcludedBundleIDs + let shiftRemoved = shiftBundleIDs.remove(bundleIdentifier) != nil + let widthRemoved = widthBundleIDs.remove(bundleIdentifier) != nil + guard shiftRemoved || widthRemoved else { return } + defaults.set(shiftBundleIDs.sorted(), forKey: Key.shiftExcludedBundleIDs) + defaults.set(widthBundleIDs.sorted(), forKey: Key.pinyinWidthExcludedBundleIDs) + NotificationCenter.default.post(name: .shiftInputSettingsDidChange, object: self) + } + + func setShiftExcluded(_ excluded: Bool, bundleIdentifier: String) { + setBundleIdentifier(bundleIdentifier, excluded: excluded, forKey: Key.shiftExcludedBundleIDs) + } + + func setPinyinWidthExcluded(_ excluded: Bool, bundleIdentifier: String) { + setBundleIdentifier(bundleIdentifier, excluded: excluded, forKey: Key.pinyinWidthExcludedBundleIDs) + } + var showStatusItem: Bool { get { defaults.bool(forKey: Key.showStatusItem) } set { set(newValue, forKey: Key.showStatusItem) } @@ -71,4 +134,17 @@ final class SettingsStore { defaults.set(value, forKey: key) NotificationCenter.default.post(name: .shiftInputSettingsDidChange, object: self) } + + private func setBundleIdentifier(_ bundleIdentifier: String, excluded: Bool, forKey key: String) { + var bundleIDs = Set(defaults.stringArray(forKey: key) ?? []) + let changed: Bool + if excluded { + changed = bundleIDs.insert(bundleIdentifier).inserted + } else { + changed = bundleIDs.remove(bundleIdentifier) != nil + } + guard changed else { return } + defaults.set(bundleIDs.sorted(), forKey: key) + NotificationCenter.default.post(name: .shiftInputSettingsDidChange, object: self) + } } diff --git a/Sources/ShiftInput/StatusBarController.swift b/Sources/ShiftInput/StatusBarController.swift index 63854f3..42ae093 100644 --- a/Sources/ShiftInput/StatusBarController.swift +++ b/Sources/ShiftInput/StatusBarController.swift @@ -1,14 +1,18 @@ import AppKit +import ShiftInputCore final class StatusBarController: NSObject, NSMenuDelegate { var onOpenPreferences: (() -> Void)? var onRetryPermission: (() -> Void)? + var foregroundApplicationProvider: (() -> ForegroundApplicationInfo?)? private let settings: SettingsStore private var statusItem: NSStatusItem? private var currentSource: InputSourceDescriptor private var accessibilityGranted = false private static let currentSourceItemIdentifier = NSUserInterfaceItemIdentifier("ShiftInput.currentSource") + private static let automaticBypassItemIdentifier = NSUserInterfaceItemIdentifier("ShiftInput.automaticBypass") + private static let currentAppBypassItemIdentifier = NSUserInterfaceItemIdentifier("ShiftInput.currentAppBypass") init(settings: SettingsStore, initialSource: InputSourceDescriptor) { self.settings = settings @@ -71,6 +75,17 @@ final class StatusBarController: NSObject, NSMenuDelegate { widthToggle.state = settings.pinyinWidthToggleEnabled ? .on : .off menu.addItem(widthToggle) + let automaticBypass = NSMenuItem(title: "在遠端軟體與遊戲中自動放行兩項快捷鍵", action: #selector(toggleAutomaticBypass(_:)), keyEquivalent: "") + automaticBypass.identifier = Self.automaticBypassItemIdentifier + automaticBypass.target = self + automaticBypass.state = settings.automaticallyBypassRemoteAppsAndGames ? .on : .off + menu.addItem(automaticBypass) + + let currentAppBypass = NSMenuItem(title: "在目前 App 中放行兩項快捷鍵", action: #selector(toggleCurrentAppBypass(_:)), keyEquivalent: "") + currentAppBypass.identifier = Self.currentAppBypassItemIdentifier + currentAppBypass.target = self + menu.addItem(currentAppBypass) + if !accessibilityGranted { let permission = NSMenuItem(title: "完成鍵盤監聽權限設定…", action: #selector(retryPermission), keyEquivalent: "") permission.target = self @@ -96,11 +111,32 @@ final class StatusBarController: NSObject, NSMenuDelegate { settings.pinyinWidthToggleEnabled.toggle() } + @objc private func toggleAutomaticBypass(_ sender: NSMenuItem) { + settings.automaticallyBypassRemoteAppsAndGames.toggle() + } + + @objc private func toggleCurrentAppBypass(_ sender: NSMenuItem) { + guard let application = foregroundApplicationProvider?() else { return } + let shiftIsExcluded = settings.shiftExcludedBundleIDs.contains(application.bundleIdentifier) + let widthIsExcluded = settings.pinyinWidthExcludedBundleIDs.contains(application.bundleIdentifier) + if shiftIsExcluded && widthIsExcluded { + settings.removeExcludedApplication(bundleIdentifier: application.bundleIdentifier) + } else { + settings.addExcludedApplication(bundleIdentifier: application.bundleIdentifier) + } + } + func menuWillOpen(_ menu: NSMenu) { for item in menu.items { if item.identifier == Self.currentSourceItemIdentifier { item.title = "目前:\(currentSource.name)" } + if item.identifier == Self.automaticBypassItemIdentifier { + item.state = settings.automaticallyBypassRemoteAppsAndGames ? .on : .off + } + if item.identifier == Self.currentAppBypassItemIdentifier { + updateCurrentAppBypassItem(item) + } switch item.action { case #selector(toggleShift(_:)): item.state = settings.shiftToggleEnabled ? .on : .off @@ -112,6 +148,40 @@ final class StatusBarController: NSObject, NSMenuDelegate { } } + private func updateCurrentAppBypassItem(_ item: NSMenuItem) { + guard let application = foregroundApplicationProvider?() else { + item.title = "在目前 App 中放行兩項快捷鍵" + item.state = .off + item.isEnabled = false + return + } + + let isAutomaticallyBypassed = settings.automaticallyBypassRemoteAppsAndGames + && ApplicationBypassPolicy.isAutomaticallyBypassed( + bundleIdentifier: application.bundleIdentifier, + localizedName: application.localizedName, + bundlePath: application.bundlePath, + applicationCategory: application.applicationCategory + ) + let shiftIsExcluded = settings.shiftExcludedBundleIDs.contains(application.bundleIdentifier) + let widthIsExcluded = settings.pinyinWidthExcludedBundleIDs.contains(application.bundleIdentifier) + if isAutomaticallyBypassed { + item.title = "已自動放行兩項快捷鍵:\(application.localizedName)" + item.state = .on + item.isEnabled = false + } else { + item.title = "在「\(application.localizedName)」中放行兩項快捷鍵" + if shiftIsExcluded && widthIsExcluded { + item.state = .on + } else if shiftIsExcluded || widthIsExcluded { + item.state = .mixed + } else { + item.state = .off + } + item.isEnabled = true + } + } + @objc private func retryPermission() { onRetryPermission?() } diff --git a/Sources/ShiftInputCore/ApplicationBypassPolicy.swift b/Sources/ShiftInputCore/ApplicationBypassPolicy.swift new file mode 100644 index 0000000..e401576 --- /dev/null +++ b/Sources/ShiftInputCore/ApplicationBypassPolicy.swift @@ -0,0 +1,109 @@ +import Foundation + +/// Decides whether a ShiftInput shortcut should be left untouched for the foreground app. +/// +/// The policy intentionally uses stable application metadata instead of polling +/// windows or processes, keeping the event-tap callback deterministic and cheap. +public enum ApplicationBypassPolicy { + private static let remoteBundleIDFragments = [ + "anydesk", + "chrome-remote-desktop", + "chromeremotedesktop", + "jumpdesktop", + "logmein", + "moonlight", + "nomachine", + "parsec", + "realvnc", + "remotedesktop", + "rustdesk", + "splashtop", + "steamlink", + "teamviewer", + "todesk", + "vncviewer" + ] + + private static let remoteBundleIDs = Set([ + "com.microsoft.rdc.macos", + "com.microsoft.windowsapp", + "com.parallels.desktop.console", + "com.valvesoftware.steam", + "com.vmware.fusion" + ]) + + private static let remoteNameFragments = [ + "anydesk", + "jump desktop", + "microsoft remote desktop", + "moonlight", + "nomachine", + "parsec", + "remote desktop", + "rustdesk", + "splashtop", + "steam link", + "teamviewer", + "todesk", + "vnc viewer", + "windows app" + ] + + private static let gamePathFragments = [ + "/steamapps/common/", + "/gog games/", + "/epic games/" + ] + + public static func shouldBypass( + bundleIdentifier: String?, + localizedName: String?, + bundlePath: String?, + applicationCategory: String?, + excludedBundleIdentifiers: Set, + automaticBypassEnabled: Bool + ) -> Bool { + let normalizedBundleID = normalize(bundleIdentifier) + if !normalizedBundleID.isEmpty, + excludedBundleIdentifiers.contains(where: { normalize($0) == normalizedBundleID }) { + return true + } + + guard automaticBypassEnabled else { return false } + return isAutomaticallyBypassed( + bundleIdentifier: bundleIdentifier, + localizedName: localizedName, + bundlePath: bundlePath, + applicationCategory: applicationCategory + ) + } + + public static func isAutomaticallyBypassed( + bundleIdentifier: String?, + localizedName: String?, + bundlePath: String?, + applicationCategory: String? + ) -> Bool { + let normalizedBundleID = normalize(bundleIdentifier) + if remoteBundleIDs.contains(normalizedBundleID) + || remoteBundleIDFragments.contains(where: normalizedBundleID.contains) { + return true + } + + let normalizedName = normalize(localizedName) + if remoteNameFragments.contains(where: normalizedName.contains) { + return true + } + + if normalize(applicationCategory) == "public.app-category.games" { + return true + } + + let normalizedPath = normalize(bundlePath) + return gamePathFragments.contains(where: normalizedPath.contains) + } + + private static func normalize(_ value: String?) -> String { + value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + } +} diff --git a/VERSION b/VERSION index ee1372d..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.2 +0.3.0