feat: add native Capswitch menu bar app

This commit is contained in:
root 2026-10-25 16:20:32 +03:00
commit cb0f42e11e
13 changed files with 961 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)
}
}

View 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.

View 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.