From cb0f42e11e0600e5754bb02c92657f5c0202c703 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 25 Oct 2026 16:20:32 +0300 Subject: [PATCH] feat: add native Capswitch menu bar app --- .gitignore | 4 + Package.swift | 26 ++ README.md | 37 ++ Resources/Info.plist | 24 ++ Sources/Capswitch/CapsGestureMachine.swift | 83 ++++ Sources/CapswitchApp/AppDelegate.swift | 14 + .../CapswitchApp/MacOSInputController.swift | 370 ++++++++++++++++++ .../CapswitchApp/StatusBarController.swift | 129 ++++++ Sources/CapswitchApp/main.swift | 6 + Taskfile.yml | 70 ++++ .../CapsGestureMachineTests.swift | 51 +++ .../superpowers/plans/2026-10-25-capswitch.md | 109 ++++++ .../specs/2026-10-25-capswitch-design.md | 38 ++ 13 files changed, 961 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Resources/Info.plist create mode 100644 Sources/Capswitch/CapsGestureMachine.swift create mode 100644 Sources/CapswitchApp/AppDelegate.swift create mode 100644 Sources/CapswitchApp/MacOSInputController.swift create mode 100644 Sources/CapswitchApp/StatusBarController.swift create mode 100644 Sources/CapswitchApp/main.swift create mode 100644 Taskfile.yml create mode 100644 Tests/CapswitchCoreTests/CapsGestureMachineTests.swift create mode 100644 docs/superpowers/plans/2026-10-25-capswitch.md create mode 100644 docs/superpowers/specs/2026-10-25-capswitch-design.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ecba98b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +.DS_Store +.swiftpm/ +.task/ diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..d673990 --- /dev/null +++ b/Package.swift @@ -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" + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0000ace --- /dev/null +++ b/README.md @@ -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. diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..30c9d37 --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDisplayName + Capswitch + CFBundleExecutable + Capswitch + CFBundleIdentifier + dev.local.Capswitch + CFBundleName + Capswitch + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSUIElement + + + diff --git a/Sources/Capswitch/CapsGestureMachine.swift b/Sources/Capswitch/CapsGestureMachine.swift new file mode 100644 index 0000000..ac4791d --- /dev/null +++ b/Sources/Capswitch/CapsGestureMachine.swift @@ -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 + } +} diff --git a/Sources/CapswitchApp/AppDelegate.swift b/Sources/CapswitchApp/AppDelegate.swift new file mode 100644 index 0000000..8771068 --- /dev/null +++ b/Sources/CapswitchApp/AppDelegate.swift @@ -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 + } +} diff --git a/Sources/CapswitchApp/MacOSInputController.swift b/Sources/CapswitchApp/MacOSInputController.swift new file mode 100644 index 0000000..6bf4f4b --- /dev/null +++ b/Sources/CapswitchApp/MacOSInputController.swift @@ -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.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.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 + .fromOpaque(refcon) + .takeUnretainedValue() + + return controller.handle(type: type, event: event) + } + + private func handle( + type: CGEventType, + event: CGEvent + ) -> Unmanaged? { + 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 + } +} diff --git a/Sources/CapswitchApp/StatusBarController.swift b/Sources/CapswitchApp/StatusBarController.swift new file mode 100644 index 0000000..d1b1049 --- /dev/null +++ b/Sources/CapswitchApp/StatusBarController.swift @@ -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) + } +} diff --git a/Sources/CapswitchApp/main.swift b/Sources/CapswitchApp/main.swift new file mode 100644 index 0000000..5dec54f --- /dev/null +++ b/Sources/CapswitchApp/main.swift @@ -0,0 +1,6 @@ +import AppKit + +let application = NSApplication.shared +let delegate = AppDelegate() +application.delegate = delegate +application.run() diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..4859584 --- /dev/null +++ b/Taskfile.yml @@ -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 diff --git a/Tests/CapswitchCoreTests/CapsGestureMachineTests.swift b/Tests/CapswitchCoreTests/CapsGestureMachineTests.swift new file mode 100644 index 0000000..b0a25dd --- /dev/null +++ b/Tests/CapswitchCoreTests/CapsGestureMachineTests.swift @@ -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) + } +} diff --git a/docs/superpowers/plans/2026-10-25-capswitch.md b/docs/superpowers/plans/2026-10-25-capswitch.md new file mode 100644 index 0000000..bc8e985 --- /dev/null +++ b/docs/superpowers/plans/2026-10-25-capswitch.md @@ -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. diff --git a/docs/superpowers/specs/2026-10-25-capswitch-design.md b/docs/superpowers/specs/2026-10-25-capswitch-design.md new file mode 100644 index 0000000..49c1770 --- /dev/null +++ b/docs/superpowers/specs/2026-10-25-capswitch-design.md @@ -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.