feat: add native Capswitch menu bar app

This commit is contained in:
casona 2026-10-25 16:20:32 +03:00
commit 0289b73f2c
11 changed files with 814 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
build/
.DS_Store
.swiftpm/
.task/

26
Package.swift Normal file
View file

@ -0,0 +1,26 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "Capswitch",
platforms: [.macOS(.v14)],
products: [
.executable(name: "Capswitch", targets: ["Capswitch"]),
],
targets: [
.target(
name: "CapswitchCore",
path: "Sources/Capswitch"
),
.executableTarget(
name: "Capswitch",
dependencies: ["CapswitchCore"],
path: "Sources/CapswitchApp"
),
.testTarget(
name: "CapswitchCoreTests",
dependencies: ["CapswitchCore"],
path: "Tests/CapswitchCoreTests"
),
]
)

37
README.md Normal file
View file

@ -0,0 +1,37 @@
# Capswitch
A small native macOS menu-bar app that uses Caps Lock to switch to the next enabled keyboard input source.
## Behavior
- Plain Caps: switch once on release.
- Caps+Option in either order: preserve real Caps Lock.
- Stock Caps/input-source events are suppressed.
- The app has no Dock icon and reports Accessibility permission in its menu.
## Build on macOS
The Linux RSB host cannot compile AppKit or run Swift/XCTest. On macOS 14+ with Apple's Command Line Tools installed:
```bash
xcode-select --install
task bundle
open build/Capswitch.app
```
No Xcode project or full Xcode.app is required.
Grant the app access at **System Settings → Privacy & Security → Accessibility**. Keep the bundle identifier and installation path stable so macOS can retain the permission.
## Development commands
```bash
task lint
task test
task build
task bundle
task clean
task verify
```
The authoritative checks are `task test` and `task bundle` on macOS. Linux runs only host-independent checks and reports the missing Swift/macOS toolchain explicitly.

24
Resources/Info.plist Normal file
View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Capswitch</string>
<key>CFBundleExecutable</key>
<string>Capswitch</string>
<key>CFBundleIdentifier</key>
<string>dev.local.Capswitch</string>
<key>CFBundleName</key>
<string>Capswitch</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,83 @@
public enum GestureEvent: Equatable {
case capsDown(optionDown: Bool)
case capsUp(optionDown: Bool)
case optionDown
case optionUp
case reset
}
public enum GestureAction: Equatable {
case none
case switchToNextInputSource
case toggleCapsLock
}
public struct CapsGestureMachine {
private var capsHeld = false
private var optionDown = false
private var optionSeen = false
private var pendingCapsToggle = false
public init() {}
public mutating func handle(_ event: GestureEvent) -> GestureAction {
switch event {
case let .capsDown(optionDown):
guard !capsHeld else {
return .none
}
capsHeld = true
self.optionDown = optionDown
optionSeen = optionDown
return .none
case let .capsUp(optionDown):
guard capsHeld else {
return .none
}
capsHeld = false
self.optionDown = optionDown
guard optionSeen else {
return .switchToNextInputSource
}
guard !optionDown else {
pendingCapsToggle = true
return .none
}
return completeCapsToggle()
case .optionDown:
optionDown = true
if capsHeld {
optionSeen = true
}
return .none
case .optionUp:
optionDown = false
guard pendingCapsToggle else {
return .none
}
return completeCapsToggle()
case .reset:
capsHeld = false
optionDown = false
optionSeen = false
pendingCapsToggle = false
return .none
}
}
private mutating func completeCapsToggle() -> GestureAction {
pendingCapsToggle = false
optionSeen = false
return .toggleCapsLock
}
}

View file

@ -0,0 +1,14 @@
import AppKit
final class AppDelegate: NSObject, NSApplicationDelegate {
private var statusBarController: StatusBarController?
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
statusBarController = StatusBarController()
}
func applicationWillTerminate(_ notification: Notification) {
statusBarController = nil
}
}

View file

@ -0,0 +1,370 @@
import AppKit
import CapswitchCore
import ApplicationServices
import Carbon
import CoreGraphics
import Foundation
enum InputControllerError: LocalizedError {
case accessibilityRequired
case eventTapUnavailable
var errorDescription: String? {
switch self {
case .accessibilityRequired:
return "Accessibility permission is required to intercept Caps Lock."
case .eventTapUnavailable:
return "macOS did not allow the global keyboard event tap to start."
}
}
}
final class InputSourceManager {
private struct Source {
let inputSource: TISInputSource
let id: String
}
var currentDisplayName: String {
guard let source = currentSource() else {
return "-"
}
return property(source, key: kTISPropertyLocalizedName)
?? property(source, key: kTISPropertyInputSourceID)
?? "-"
}
var currentShortName: String {
let name = currentDisplayName
if let first = name.split(whereSeparator: { $0 == " " || $0 == "-" }).first,
!first.isEmpty {
return String(first).uppercased()
}
return name
}
@discardableResult
func selectNextInputSource() -> Bool {
let sources = enabledKeyboardSourcesInUserOrder()
guard sources.count > 1 else {
return false
}
let currentID = currentSource().flatMap {
property($0, key: kTISPropertyInputSourceID)
}
let currentIndex = currentID.flatMap { id in
sources.firstIndex { $0.id == id }
}
let nextIndex = ((currentIndex ?? -1) + 1) % sources.count
return TISSelectInputSource(sources[nextIndex].inputSource) == noErr
}
private func enabledKeyboardSourcesInUserOrder() -> [Source] {
let allSources = TISCreateInputSourceList(nil, false)
.takeRetainedValue() as NSArray as! [TISInputSource]
let sources = allSources.compactMap { inputSource -> Source? in
guard isEnabled(inputSource),
property(inputSource, key: kTISPropertyInputSourceCategory)
== (kTISCategoryKeyboardInputSource as String),
let id = property(inputSource, key: kTISPropertyInputSourceID) else {
return nil
}
return Source(inputSource: inputSource, id: id)
}
let byID = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, $0) })
let configuredIDs = configuredInputSourceIDs()
var ordered = configuredIDs.compactMap { byID[$0] }
let configuredSet = Set(configuredIDs)
ordered.append(contentsOf: sources.filter { !configuredSet.contains($0.id) })
return ordered
}
private func currentSource() -> TISInputSource? {
TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
}
private func configuredInputSourceIDs() -> [String] {
guard let entries = UserDefaults.standard.array(
forKey: "AppleEnabledInputSources"
) as? [[String: Any]] else {
return []
}
return entries.compactMap { $0["InputSourceID"] as? String }
}
private func isEnabled(_ inputSource: TISInputSource) -> Bool {
guard let pointer = TISGetInputSourceProperty(
inputSource,
kTISPropertyInputSourceIsEnabled
) else {
return false
}
let value = Unmanaged<CFBoolean>.fromOpaque(pointer).takeUnretainedValue()
return CFBooleanGetValue(value)
}
private func property(
_ inputSource: TISInputSource,
key: CFString
) -> String? {
guard let pointer = TISGetInputSourceProperty(inputSource, key) else {
return nil
}
return Unmanaged<CFString>.fromOpaque(pointer)
.takeUnretainedValue() as String
}
}
final class MacOSInputController {
private static let capsLockKeyCode: CGKeyCode = 57
private static let syntheticEventMarker: Int64 = 0x4361707353776974
private let inputSources = InputSourceManager()
private var eventTap: CFMachPort?
private var runLoopSource: CFRunLoopSource?
private var gestureMachine = CapsGestureMachine()
private var alphaShiftDown = false
private var optionDown = false
private var isPostingSyntheticCaps = false
var isPaused = false
var isRunning: Bool {
eventTap != nil
}
var onStateChanged: (() -> Void)?
var currentInputSourceName: String {
inputSources.currentDisplayName
}
var currentInputSourceShortName: String {
inputSources.currentShortName
}
var accessibilityTrusted: Bool {
AXIsProcessTrusted()
}
func start() throws {
guard eventTap == nil else {
return
}
guard AXIsProcessTrusted() else {
throw InputControllerError.accessibilityRequired
}
let eventMask = CGEventMask(1 << CGEventType.flagsChanged.rawValue)
let refcon = Unmanaged.passUnretained(self).toOpaque()
guard let tap = CGEventTapCreate(
.cgSessionEventTap,
.headInsertEventTap,
.defaultTap,
eventMask,
Self.eventTapCallback,
refcon
) else {
throw InputControllerError.eventTapUnavailable
}
eventTap = tap
runLoopSource = CFMachPortCreateRunLoopSource(
kCFAllocatorDefault,
tap,
0
)
if let runLoopSource {
CFRunLoopAddSource(
CFRunLoopGetMain(),
runLoopSource,
.commonModes
)
}
CGEventTapEnable(tap, true)
resetGestureState()
onStateChanged?()
}
func stop() {
if let eventTap {
CGEventTapEnable(eventTap, false)
}
if let runLoopSource {
CFRunLoopRemoveSource(
CFRunLoopGetMain(),
runLoopSource,
.commonModes
)
}
eventTap = nil
runLoopSource = nil
resetGestureState()
onStateChanged?()
}
func setPaused(_ paused: Bool) {
isPaused = paused
resetGestureState()
onStateChanged?()
}
func openAccessibilitySettings() {
guard let url = URL(
string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
) else {
return
}
NSWorkspace.shared.open(url)
}
private static let eventTapCallback: CGEventTapCallBack = {
_, type, event, refcon in
guard let refcon else {
return Unmanaged.passUnretained(event)
}
let controller = Unmanaged<MacOSInputController>
.fromOpaque(refcon)
.takeUnretainedValue()
return controller.handle(type: type, event: event)
}
private func handle(
type: CGEventType,
event: CGEvent
) -> Unmanaged<CGEvent>? {
if event.getIntegerValueField(.eventSourceUserData)
== Self.syntheticEventMarker {
return Unmanaged.passUnretained(event)
}
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
resetGestureState()
if let eventTap {
CGEventTapEnable(eventTap, true)
}
return Unmanaged.passUnretained(event)
}
guard type == .flagsChanged else {
return Unmanaged.passUnretained(event)
}
let flags = event.flags
let newAlphaShiftDown = flags.contains(.maskAlphaShift)
let newOptionDown = flags.contains(.maskAlternate)
if isPaused {
alphaShiftDown = newAlphaShiftDown
optionDown = newOptionDown
return Unmanaged.passUnretained(event)
}
let capsDown = newAlphaShiftDown && !alphaShiftDown
let capsUp = !newAlphaShiftDown && alphaShiftDown
let optionPressed = newOptionDown && !optionDown
let optionReleased = !newOptionDown && optionDown
var action: GestureAction = .none
if optionPressed {
action = combine(action, gestureMachine.handle(.optionDown))
}
if capsDown {
action = combine(
action,
gestureMachine.handle(.capsDown(optionDown: newOptionDown))
)
}
if capsUp {
action = combine(
action,
gestureMachine.handle(.capsUp(optionDown: newOptionDown))
)
}
if optionReleased {
action = combine(action, gestureMachine.handle(.optionUp))
}
alphaShiftDown = newAlphaShiftDown
optionDown = newOptionDown
apply(action)
if capsDown || capsUp {
return nil
}
return Unmanaged.passUnretained(event)
}
private func apply(_ action: GestureAction) {
switch action {
case .none:
break
case .switchToNextInputSource:
if inputSources.selectNextInputSource() {
onStateChanged?()
}
case .toggleCapsLock:
postSyntheticCapsLock()
}
}
private func postSyntheticCapsLock() {
guard !isPostingSyntheticCaps,
let down = CGEvent(
keyboardEventSource: nil,
virtualKey: Self.capsLockKeyCode,
keyDown: true
),
let up = CGEvent(
keyboardEventSource: nil,
virtualKey: Self.capsLockKeyCode,
keyDown: false
) else {
return
}
isPostingSyntheticCaps = true
for event in [down, up] {
event.flags = []
event.setIntegerValueField(
.eventSourceUserData,
value: Self.syntheticEventMarker
)
event.post(tap: .cghidEventTap)
}
isPostingSyntheticCaps = false
}
private func resetGestureState() {
_ = gestureMachine.handle(.reset)
alphaShiftDown = false
optionDown = false
isPostingSyntheticCaps = false
}
private func combine(
_ first: GestureAction,
_ second: GestureAction
) -> GestureAction {
first == .none ? second : first
}
}

View file

@ -0,0 +1,129 @@
import AppKit
import Foundation
final class StatusBarController: NSObject, NSMenuDelegate {
private let inputController: MacOSInputController
private let statusItem: NSStatusItem
private let menu = NSMenu()
private let currentLayoutItem = NSMenuItem(
title: "",
action: nil,
keyEquivalent: ""
)
private let permissionItem = NSMenuItem(
title: "",
action: #selector(openAccessibilitySettings),
keyEquivalent: ""
)
private let pauseItem = NSMenuItem(
title: "",
action: #selector(togglePaused),
keyEquivalent: ""
)
private var refreshTimer: Timer?
init(inputController: MacOSInputController = MacOSInputController()) {
self.inputController = inputController
self.statusItem = NSStatusBar.system.statusItem(
withLength: NSStatusItem.variableLength
)
super.init()
configureMenu()
inputController.onStateChanged = { [weak self] in
self?.update()
}
refreshTimer = Timer.scheduledTimer(
withTimeInterval: 1,
repeats: true
) { [weak self] _ in
self?.refreshController()
}
refreshController()
}
deinit {
refreshTimer?.invalidate()
inputController.stop()
statusItem.menu = nil
NSStatusBar.system.removeStatusItem(statusItem)
}
private func configureMenu() {
menu.delegate = self
menu.addItem(currentLayoutItem)
menu.addItem(NSMenuItem.separator())
menu.addItem(pauseItem)
menu.addItem(permissionItem)
menu.addItem(NSMenuItem.separator())
let quitItem = NSMenuItem(
title: "Quit Capswitch",
action: #selector(quit),
keyEquivalent: "q"
)
menu.addItem(quitItem)
for item in [pauseItem, permissionItem, quitItem] {
item.target = self
}
statusItem.menu = menu
statusItem.button?.toolTip = "Capswitch"
}
private func refreshController() {
if inputController.accessibilityTrusted,
!inputController.isRunning {
try? inputController.start()
}
update()
}
private func update() {
let shortName = inputController.currentInputSourceShortName
statusItem.button?.title = shortName
statusItem.button?.toolTip = "Capswitch - \(inputController.currentInputSourceName)"
currentLayoutItem.title =
"Current layout: \(inputController.currentInputSourceName)"
pauseItem.title = inputController.isPaused
? "Resume switching"
: "Pause switching"
pauseItem.state = inputController.isPaused ? .on : .off
if inputController.accessibilityTrusted {
permissionItem.title = inputController.isRunning
? "Accessibility: Granted"
: "Accessibility: Granted (tap stopped)"
} else {
permissionItem.title = "Grant Accessibility permission…"
}
for item in [currentLayoutItem, permissionItem, pauseItem] {
item.target = self
}
}
func menuWillOpen(_ menu: NSMenu) {
refreshController()
}
@objc private func togglePaused() {
inputController.setPaused(!inputController.isPaused)
update()
}
@objc private func openAccessibilitySettings() {
inputController.openAccessibilitySettings()
}
@objc private func quit() {
NSApp.terminate(nil)
}
}

View file

@ -0,0 +1,6 @@
import AppKit
let application = NSApplication.shared
let delegate = AppDelegate()
application.delegate = delegate
application.run()

70
Taskfile.yml Normal file
View file

@ -0,0 +1,70 @@
version: '3'
vars:
APP_NAME: Capswitch
APP_DIR: build/Capswitch.app
RELEASE_BINARY: .build/release/Capswitch
tasks:
default:
cmds:
- task: verify
test:
desc: Run XCTest on macOS, or report the host limitation on Linux
cmds:
- |
if command -v swift >/dev/null 2>&1 && [ "$(uname -s)" = "Darwin" ]; then
swift test
else
echo "SKIP: Swift/XCTest requires macOS with Command Line Tools"
fi
build:
desc: Build the macOS executable with SwiftPM
cmds:
- |
if [ "$(uname -s)" != "Darwin" ]; then
echo "SKIP: macOS build requires Darwin with Command Line Tools"
exit 0
fi
swift build -c release
bundle:
desc: Assemble and ad-hoc sign the menu-bar .app
deps: [build]
cmds:
- |
if [ "$(uname -s)" != "Darwin" ]; then
echo "SKIP: macOS app bundle requires Darwin with Command Line Tools"
exit 0
fi
rm -rf "{{.APP_DIR}}"
mkdir -p "{{.APP_DIR}}/Contents/MacOS" "{{.APP_DIR}}/Contents/Resources"
cp "{{.RELEASE_BINARY}}" "{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}"
cp Resources/Info.plist "{{.APP_DIR}}/Contents/Info.plist"
codesign --force --sign - "{{.APP_DIR}}"
lint:
desc: Run host-independent source and bundle checks
cmds:
- git diff --check
- test -f Package.swift
- test -f Resources/Info.plist
- test -f Sources/Capswitch/CapsGestureMachine.swift
- test -f Sources/CapswitchApp/MacOSInputController.swift
- grep -q "LSUIElement" Resources/Info.plist
- grep -q ".flagsChanged" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "TISSelectInputSource" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "switchToNextInputSource" Sources/Capswitch/CapsGestureMachine.swift
clean:
desc: Remove build outputs
cmds:
- rm -rf build .build
verify:
desc: Run all checks available on the current host
cmds:
- task: lint
- task: test

View file

@ -0,0 +1,51 @@
import XCTest
@testable import CapswitchCore
final class CapsGestureMachineTests: XCTestCase {
func testPlainCapsSwitchesOnlyOnRelease() {
var machine = CapsGestureMachine()
XCTAssertEqual(machine.handle(.capsDown(optionDown: false)), .none)
XCTAssertEqual(machine.handle(.capsUp(optionDown: false)), .switchToNextInputSource)
}
func testOptionBeforeCapsTogglesRealCapsLock() {
var machine = CapsGestureMachine()
XCTAssertEqual(machine.handle(.optionDown), .none)
XCTAssertEqual(machine.handle(.capsDown(optionDown: true)), .none)
XCTAssertEqual(machine.handle(.capsUp(optionDown: true)), .toggleCapsLock)
XCTAssertEqual(machine.handle(.optionUp), .none)
}
func testCapsBeforeOptionTogglesRealCapsLock() {
var machine = CapsGestureMachine()
XCTAssertEqual(machine.handle(.capsDown(optionDown: false)), .none)
XCTAssertEqual(machine.handle(.optionDown), .none)
XCTAssertEqual(machine.handle(.capsUp(optionDown: true)), .toggleCapsLock)
XCTAssertEqual(machine.handle(.optionUp), .none)
}
func testOptionReleasedAfterCapsCompletesPendingToggle() {
var machine = CapsGestureMachine()
XCTAssertEqual(machine.handle(.optionDown), .none)
XCTAssertEqual(machine.handle(.capsDown(optionDown: true)), .none)
XCTAssertEqual(machine.handle(.capsUp(optionDown: true)), .none)
XCTAssertEqual(machine.handle(.optionUp), .toggleCapsLock)
}
func testResetCancelsPendingToggle() {
var machine = CapsGestureMachine()
XCTAssertEqual(machine.handle(.capsDown(optionDown: false)), .none)
XCTAssertEqual(machine.handle(.optionDown), .none)
XCTAssertEqual(machine.handle(.capsUp(optionDown: true)), .none)
XCTAssertEqual(machine.handle(.reset), .none)
XCTAssertEqual(machine.handle(.optionUp), .none)
XCTAssertEqual(machine.handle(.capsDown(optionDown: false)), .none)
XCTAssertEqual(machine.handle(.capsUp(optionDown: false)), .switchToNextInputSource)
}
}