commit 0289b73f2c4e0c8dd863dd796e8da9698c3613dd Author: casona Date: Sun Oct 25 16:20:32 2026 +0300 feat: add native Capswitch menu bar app 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..0e0d5ff --- /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..5f2cda5 --- /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) + } +}