feat: add native Capswitch menu bar app
This commit is contained in:
commit
0289b73f2c
11 changed files with 814 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue