feat: add native Capswitch menu bar app
This commit is contained in:
commit
cb0f42e11e
13 changed files with 961 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
|
.swiftpm/
|
||||||
|
.task/
|
||||||
26
Package.swift
Normal file
26
Package.swift
Normal 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
37
README.md
Normal 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
24
Resources/Info.plist
Normal 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>
|
||||||
83
Sources/Capswitch/CapsGestureMachine.swift
Normal file
83
Sources/Capswitch/CapsGestureMachine.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Sources/CapswitchApp/AppDelegate.swift
Normal file
14
Sources/CapswitchApp/AppDelegate.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
370
Sources/CapswitchApp/MacOSInputController.swift
Normal file
370
Sources/CapswitchApp/MacOSInputController.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
129
Sources/CapswitchApp/StatusBarController.swift
Normal file
129
Sources/CapswitchApp/StatusBarController.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Sources/CapswitchApp/main.swift
Normal file
6
Sources/CapswitchApp/main.swift
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
let application = NSApplication.shared
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
application.delegate = delegate
|
||||||
|
application.run()
|
||||||
70
Taskfile.yml
Normal file
70
Taskfile.yml
Normal 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
|
||||||
51
Tests/CapswitchCoreTests/CapsGestureMachineTests.swift
Normal file
51
Tests/CapswitchCoreTests/CapsGestureMachineTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
109
docs/superpowers/plans/2026-10-25-capswitch.md
Normal file
109
docs/superpowers/plans/2026-10-25-capswitch.md
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# Capswitch Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (\`- [ ]\`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a native Swift menu-bar app that suppresses physical Caps Lock, switches to the next enabled keyboard input source on Caps release, and preserves real Caps Lock on any Caps+Option gesture.
|
||||||
|
|
||||||
|
**Architecture:** A pure Swift gesture state machine owns Caps/Option classification. A macOS adapter uses CGEventTap for global flagsChanged events, Carbon TIS APIs for input source selection, and AppKit NSStatusItem for the menu bar. A Taskfile and shell-free Swift build command assemble an unsigned/ad-hoc signed \`.app\` without an Xcode project.
|
||||||
|
|
||||||
|
**Tech Stack:** Swift 5-compatible source; AppKit, CoreGraphics, ApplicationServices, Carbon; Taskfile v3; XCTest on macOS; no third-party dependencies.
|
||||||
|
|
||||||
|
**Spec:** \`docs/superpowers/specs/2026-10-25-capswitch-design.md\`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Target macOS 14.0+; validate on the user's macOS 14.8.9.
|
||||||
|
- Work directly on \`main\`; repository is initially empty.
|
||||||
|
- Use Taskfiles for build, test, lint, bundle, and clean scripts.
|
||||||
|
- No Xcode project, Storyboard, CocoaPods, Homebrew, or runtime dependency.
|
||||||
|
- Plain Caps switches only on Caps release when Option was not seen during the gesture.
|
||||||
|
- Caps+Option preserves real Caps Lock in either key order.
|
||||||
|
- Use a stable bundle identifier and LSUIElement menu-bar app configuration.
|
||||||
|
- Accessibility is required for the global event tap; show its state in the menu.
|
||||||
|
- Linux verification must distinguish host-unavailable Swift tooling from actual failures.
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
- Caps release must switch exactly once and never on Caps press.
|
||||||
|
- Option before Caps and Caps before Option must both toggle real Caps Lock.
|
||||||
|
- Option released after Caps must complete the pending Caps toggle without switching layout.
|
||||||
|
- Tap reset/sleep recovery must clear pending state safely.
|
||||||
|
- Input source enumeration must ignore disabled/non-keyboard sources and keep the configured order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Repository skeleton and plan
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: \`docs/superpowers/specs/2026-10-25-capswitch-design.md\`
|
||||||
|
- Create: \`docs/superpowers/plans/2026-10-25-capswitch.md\`
|
||||||
|
- Create: \`Taskfile.yml\`
|
||||||
|
- Create: \`.gitignore\`
|
||||||
|
- Create: \`README.md\`
|
||||||
|
|
||||||
|
- [ ] Write the design/spec and repository workflow files.
|
||||||
|
- [ ] Run Taskfile syntax/help checks.
|
||||||
|
- [ ] Commit the skeleton.
|
||||||
|
|
||||||
|
### Task 2: Gesture state machine
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: \`Sources/Capswitch/CapsGestureMachine.swift\`
|
||||||
|
- Test: \`Tests/CapswitchTests/CapsGestureMachineTests.swift\`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- \`enum GestureEvent\`: \`capsDown(optionDown: Bool)\`, \`capsUp(optionDown: Bool)\`, \`optionDown\`, \`optionUp\`, \`reset\`.
|
||||||
|
- \`enum GestureAction\`: \`none\`, \`switchToNextInputSource\`, \`toggleCapsLock\`.
|
||||||
|
- \`struct CapsGestureMachine\` with \`mutating func handle(_ event: GestureEvent) -> GestureAction\`.
|
||||||
|
|
||||||
|
- [ ] Write failing XCTest cases for plain Caps, both Option orders, delayed Option release, and reset.
|
||||||
|
- [ ] Run the targeted test on macOS and verify it fails before implementation; on Linux record Swift unavailable.
|
||||||
|
- [ ] Implement the minimal state machine.
|
||||||
|
- [ ] Run the targeted test and the full test task.
|
||||||
|
|
||||||
|
### Task 3: macOS event tap and input source adapter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: \`Sources/Capswitch/MacOSInputController.swift\`
|
||||||
|
- Modify: \`Sources/Capswitch/CapsGestureMachine.swift\` only if adapter-facing visibility requires it.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- \`final class MacOSInputController\` with \`start() throws\`, \`stop()\`, and \`onInputSourceChanged: (() -> Void)?\`.
|
||||||
|
- Uses keycode 57, flagsChanged transitions, CGEventTap recovery, TIS selection, and synthetic Caps events.
|
||||||
|
- Synthetic Caps events must be marked/guarded so they do not re-enter the gesture state machine.
|
||||||
|
|
||||||
|
- [ ] Add adapter tests or compile-only checks where platform APIs are available.
|
||||||
|
- [ ] Implement the event tap and Carbon input source enumeration.
|
||||||
|
- [ ] Add tap timeout recovery and reset handling.
|
||||||
|
- [ ] Build on macOS with Taskfile.
|
||||||
|
|
||||||
|
### Task 4: Status bar app and permissions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: \`Sources/Capswitch/AppDelegate.swift\`
|
||||||
|
- Create: \`Sources/Capswitch/StatusBarController.swift\`
|
||||||
|
- Create: \`Resources/Info.plist\`
|
||||||
|
|
||||||
|
- [ ] Add LSUIElement app delegate and status item.
|
||||||
|
- [ ] Add current-layout display, permission status, pause, launch-at-login placeholder, and quit menu actions.
|
||||||
|
- [ ] Add Accessibility check and System Settings URL.
|
||||||
|
- [ ] Build the .app bundle.
|
||||||
|
|
||||||
|
### Task 5: CLI packaging and documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: \`Taskfile.yml\`
|
||||||
|
- Modify: \`README.md\`
|
||||||
|
- Create: \`scripts/build-app.sh\` only if Taskfile cannot express the bundle operation clearly.
|
||||||
|
|
||||||
|
- [ ] Add \`task build\`, \`task bundle\`, \`task test\`, \`task lint\`, \`task clean\`, and \`task verify\`.
|
||||||
|
- [ ] Document one-time Command Line Tools setup, Accessibility permission, and first launch.
|
||||||
|
- [ ] Run all Linux-safe checks and report the Mac-only build limitation.
|
||||||
|
|
||||||
|
### Task 6: Final verification and delivery
|
||||||
|
|
||||||
|
- [ ] Inspect diff and repository status.
|
||||||
|
- [ ] Run Taskfile verification and any available tests.
|
||||||
|
- [ ] Commit on \`main\`.
|
||||||
|
- [ ] Push \`main\` to \`origin\`.
|
||||||
|
- [ ] Report commit, remote branch, and Mac-only checks still required.
|
||||||
38
docs/superpowers/specs/2026-10-25-capswitch-design.md
Normal file
38
docs/superpowers/specs/2026-10-25-capswitch-design.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Capswitch Design
|
||||||
|
|
||||||
|
Capswitch is a small native macOS menu-bar application for a user who wants physical Caps Lock to cycle keyboard layouts without the stock input-source shortcut HUD or animation.
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
On a plain Caps gesture, suppress the system Caps Lock event and switch to the next enabled keyboard input source on Caps release. Reserve Caps+Option for the real Caps Lock behavior.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Global Caps interception through Accessibility-authorized CGEventTap.
|
||||||
|
- Plain Caps switches once on release.
|
||||||
|
- Caps+Option toggles real Caps Lock in either key order.
|
||||||
|
- Input sources are selected directly through Carbon TIS APIs.
|
||||||
|
- Menu-bar indicator shows the current input source and permission state.
|
||||||
|
- Build and bundle from Taskfile/CLI on macOS without an Xcode project.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Replacing or modifying macOS input-source configuration.
|
||||||
|
- Supporting App Store sandbox or notarized distribution in the MVP.
|
||||||
|
- Implementing a full preferences window.
|
||||||
|
- Supporting arbitrary remapping beyond Caps and Caps+Option.
|
||||||
|
|
||||||
|
## Interaction Contract
|
||||||
|
|
||||||
|
The event tap suppresses Caps down/up events. It records whether Option was already down or became active while Caps is held. On Caps release, no Option seen selects the next source; any Option seen schedules a synthetic clean Caps down/up. If Option remains held, the synthetic Caps event is posted when Option is released.
|
||||||
|
|
||||||
|
## Platform Contract
|
||||||
|
|
||||||
|
- Swift 5-compatible source targeting macOS 14.0+.
|
||||||
|
- AppKit status item, CoreGraphics event tap, ApplicationServices Accessibility check, Carbon TIS input source APIs.
|
||||||
|
- Stable bundle identifier \`dev.local.Capswitch\`.
|
||||||
|
- \`LSUIElement=true\` so the app has no Dock icon.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Linux may run only platform-independent/static checks because the remote host has no Swift/macOS SDK. The authoritative build and XCTest run are on macOS 14.8.9 with Command Line Tools installed.
|
||||||
Loading…
Reference in a new issue