Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 11 additions & 3 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ ShiftInput 是一個原生、輕量且事件驅動的 macOS 輸入法切換增

以上兩項功能可在設定中分別啟用或停用。

### 遠端軟體與遊戲放行

- 預設在常見遠端桌面、串流軟體及遊戲中,同時放行 `Shift` 與 `Shift + Space`,避免快捷鍵被 ShiftInput 攔截。
- 自動辨識常見遠端軟體、macOS 遊戲分類,以及 Steam、GOG、Epic 遊戲路徑。
- 可在設定的應用程式列表中加入其他 App,並分別開關 `Shift` 與 `Shift + Space` 放行規則。
- 新加入的 App 預設同時放行兩項快捷鍵;選單列選單也能快速加入目前 App。

## 其他設定

- 顯示選單列圖標,預設開啟。
Expand All @@ -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
```

執行完整驗證:
Expand Down Expand Up @@ -98,6 +105,7 @@ make verify
- 每次輸入來源通知只讀取一次系統快照,並一致地更新保存來源、拼音能力與介面。
- 使用 `TISSelectInputSource` 切換輸入法並保存上次來源;切換後會確認實際來源,Apple 拼音亦會確認其鍵盤配置並在需要時重試。
- `Shift + Space` 僅在辨識為 Apple 拼音時轉送為原生 `Option + Shift + H` 命令。
- 前景 App 改變時才更新應用程式資料;遠端軟體與遊戲放行判斷不輪詢窗口或程序。
- Event tap 逾時停用時會自動恢復;權限被撤銷時會停止監聽。
- 快捷鍵設定改變或 event tap 被系統停用時,監聽器會以最新設定安全重建。

Expand Down
14 changes: 11 additions & 3 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ ShiftInput 是一个原生、轻量且事件驱动的 macOS 输入法切换增

以上两项功能可以在设置中分别启用或停用。

### 远程软件与游戏放行

- 默认在常见远程桌面、串流软件和游戏中同时放行 `Shift` 与 `Shift + Space`,避免快捷键被 ShiftInput 拦截。
- 自动识别常见远程软件、macOS 游戏分类,以及 Steam、GOG、Epic 游戏路径。
- 可以在设置的应用程序列表中加入其他 App,并分别开关 `Shift` 与 `Shift + Space` 放行规则。
- 新加入的 App 默认同时放行两项快捷键;菜单栏菜单也能快速加入当前 App。

## 其他设置

- 显示菜单栏图标,默认开启。
Expand All @@ -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
```

运行完整验证:
Expand Down Expand Up @@ -98,6 +105,7 @@ make verify
- 每次输入来源通知只读取一次系统快照,并一致地更新保存来源、拼音能力和界面。
- 使用 `TISSelectInputSource` 切换输入法并保存上次来源;切换后会确认实际来源,Apple 拼音也会确认其键盘布局并在需要时重试。
- `Shift + Space` 仅在识别为 Apple 拼音时转发为原生 `Option + Shift + H` 命令。
- 仅在前台 App 改变时更新应用程序数据;远程软件和游戏放行判断不会轮询窗口或进程。
- Event tap 超时停用时会自动恢复;权限被撤销时会停止监听。
- 快捷键设置改变或 event tap 被系统停用时,监听器会使用最新设置安全重建。

Expand Down
68 changes: 68 additions & 0 deletions Scripts/StateMachineChecks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
}
34 changes: 32 additions & 2 deletions Sources/ShiftInput/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import ShiftInputCore

final class AppDelegate: NSObject, NSApplicationDelegate {
private let settings = SettingsStore.shared
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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<String>) -> 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
}
Comment on lines +129 to +133

private func settingsDidChange() {
applyDockVisibility()
statusBar.applyVisibility()
refreshKeyboardMonitor()
refreshPermissionUI()
preferences.refresh()
}

private func refreshKeyboardMonitor() {
Expand Down
25 changes: 25 additions & 0 deletions Sources/ShiftInput/ForegroundApplicationInfo.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 2 additions & 1 deletion Sources/ShiftInput/GlobalKeyboardMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ final class GlobalKeyboardMonitor {
}

var onShiftTap: (() -> Void)?
var shouldHandleShiftTap: (() -> Bool)?
var shouldHandleWidthToggle: (() -> Bool)?
var onWidthToggle: (() -> Void)?
var onTapDisabled: (() -> Void)?
Expand Down Expand Up @@ -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?() }
}

Expand Down
Loading