diff --git a/.gitignore b/.gitignore index e43b0f9..1d6c346 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,11 @@ .DS_Store + +# Build output +build/ +*.o +*.app +moc_*.cpp +moc_*.h +qrc_*.cpp +Makefile +.qmake.stash diff --git a/README.md b/README.md index 4e9f0bc..39ee126 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,120 @@ -[![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://vshymanskyy.github.io/StandWithUkraine/) +# CommandShift -# Backstory -Initially I wrote this program for myself since I bought my first Macbook and quickly realized that I can't use 'Alt'+Shift to change language on macOS. -After a while a friend of mine bought his first Macbook and asked me "How can I change language using 'Alt'+Shift? I can't seem to change it in system preferences". I shared my app with him and at that point I realized that other people might find it useful as well so decided to share this app with the world. +CommandShift is a free and open-source macOS app that lets you change input source +with a Windows-style shortcut made of modifier keys only β€” Command + Shift, +Option + Shift, Control + Shift, Fn + Shift, or even Shift on its own. macOS does not +support modifier-only shortcuts by itself; CommandShift adds them. -# CommandShift -CommandShift is a free and open-source app that allows you to change input source using Windows-style shortcut (e.g. Command + Shift, Option + Shift, Control + Shift, Fn + Shift or even just Shift. It's quite customizable). -By default MacOS doesn't support shortcuts that consist of modifier keys only. CommandShift solves this problem. - -# Support -I'm a single software engineer from Ukraine. If you found CommandShift useful and would like to say 'thank you' please consider supporting me on [Patreon](https://www.patreon.com/Vasyl_Baran) or [PayPal](https://www.paypal.com/donate/?hosted_button_id=WZAJV3PYPWUHA). It encourages me to further improve, support and update CommandShift as new versions of MacOS come out. - -**My current goal is $99 which is a membership fee for Apple Developer program** so that I can propertly sign and notarize CommandShift using Apple developer certificate. -# How-to use -1. Download CommandShift-universal.dmg -2. Drag'n'drop CommandShift application from dmg-file to your Applications folder -3. Add CommandShift to "Security & Privacy -> Privacy -> Accessibility" in order for it to work properly -4. Enjoy! -5. (optionally) Add CommandShift to your startup items ("Users & Groups -> Login Items") - -# FAQ: -* **Q:** I've added CommandShift to "Security & Privacy -> Privacy -> Accessibility" but it doesn't work =( -* **A:** Please make sure that your settings in "Keyboard -> Shortcuts -> Input Sources" are set to default (you can easily do that by pressing "Restore Defaults" button there) +## About this fork + +The original CommandShift was written by **Vasyl Baran** and lives at +[VasylBaran/CommandShift](https://github.com/VasylBaran/CommandShift). All the credit +for the application belongs to him β€” this is a fork that builds on his work, kept for +personal use, and it remains under the same GPL-3.0 license. + +What this fork changes: + +* **No dependency on macOS' own input-source shortcut.** The original simulated a + Ctrl+Space keystroke and relied on that binding still being in place, which meant + giving up Ctrl+Space β€” the autocomplete shortcut in most IDEs. This fork asks Text + Input Services to change the source directly, so no system shortcut needs to stay + bound, and switching is immediate rather than going through the system's hotkey + machinery. +* **Other shortcuts are left alone.** The language only changes when the shortcut's + modifiers are pressed and released on their own. Press another key, click or scroll + while holding them and CommandShift stays out of the way, so Cmd+Shift+A still does + what the app under it expects. In Shift-only mode this also means typing capital + letters no longer switches the layout. +* **Switching returns to the previously used input source**, the way Alt+Tab returns + you to your last window, including when you changed source from the menu bar. +* **One settings file.** Every preference now lives in `~/.config/commandShift.ini`. +* **A build script**, so the app can be built, signed and installed in one command. + +Because switching now has to distinguish a language change from the start of another +shortcut, it happens when the keys are released rather than when they are pressed. + +## Installing + +This fork has no prebuilt releases β€” build it from source with the instructions below. +Vasyl's original builds are available from +[his releases page](https://github.com/VasylBaran/CommandShift/releases/), but they +predate everything listed above. + +Once installed: + +1. Add CommandShift to "Privacy & Security -> Accessibility" so it can see key presses. +2. (optionally) Add CommandShift to your login items ("General -> Login Items"). + +## FAQ + +* **Q:** I've added CommandShift to "Privacy & Security -> Accessibility" but it doesn't work =( +* **A:** Check that you have at least two input sources enabled in "Keyboard -> Text Input -> Input Sources", and that the switch next to CommandShift is actually turned on (adding the app to the list is not enough). If you have just replaced the app with a newer version, remove the old entry with the "-" button and add it again. +* **Q:** Do I need to keep macOS' own "Select the previous input source" shortcut (Ctrl+Space) bound? +* **A:** No. CommandShift changes the input source directly, so you are free to unbind Ctrl+Space in "Keyboard -> Keyboard Shortcuts -> Input Sources" and use it for something else. +* **Q:** Does CommandShift interfere with shortcuts like Cmd+Shift+A? +* **A:** No. If you press any other key, or click or scroll, while holding the modifiers, CommandShift leaves that shortcut alone and does not change the language. This is also why the language changes when you release the keys rather than when you press them: until you let go, there is no way to tell a language switch from the start of another shortcut. * **Q:** How do I choose which shortcut should switch language? -* **A:** Find CommandShift ('CS' icon) in menu bar at the top-right and click on it, there you'll find "Change language with..." drop-down. Select the one you prefer =) -* **Q:** I get "CommandShift is damaged and can't be opened" error -* **A:** Please run this command in Terminal: _xattr -cr /Applications/CommandShift.app_ -* **Q:** How to automatically launch CommandShift when computer starts? -* **A:** You can add CommandShift to your list of auto-start items by opening System Settings panel -> Users and Groups OR General (depending on what version of macOS you're using) -> Login Items -> [+] -> [select CommandShift app] -* **A:** I use 3+ languages and CommandShift switches only between the last 2. How to make it cycle between all 3+ languages? -* **Q:** Press and hold second key (the one you chose from the "Change language with..." drop-down) the then press Shift multiple times in order to cycle through your languages. -* **Q:** I want to switch language after releasing Shift key instead of when pressing it -* **A:** From CommandShift's menu bar at the top-right you can toggle 'Change language after Shift release' -* **Q:** I want to switch language by just pressing Shift key alone -* **A:** From CommandShift's menu bar go to "Change language with..." drop-down and select "Shift" -* **Q:** I want to hide CommandShift icon from menu tray -* **A:** From CommandShift's menu bar select "Hide icon from tray menu..." and choose whether you want to hide it "Permanently" or "Until restart" -* **Q:** I have Arm/Intel based Mac. Is CommandShift going to work on both? -* **A:** Yes, CommandShift is a universal app and runs natively on both Arm and Intel Macs. - -# Downloads -* Bitbucket: https://bitbucket.org/vasylbaran7/downloads/downloads/ -* Sourceforge: https://sourceforge.net/projects/commandshift/files/ -* GitHub: https://github.com/VasylBaran/CommandShift/releases/ - -**Latest CommandShift checksum (MD5)** -* CommandShift-universal.dmg: c2fced57a7b755f237a1327941b5049e - -Again, if you have found CommandShift useful please consider supporting my endeavors. It encourages me to further improve, support and update CommandShift as new versions of MacOS come out: -* **Buy Me a Coffee (Stripe) (one-time):** https://buymeacoffee.com/vasylbaran -* **Patreon (monthly):** https://www.patreon.com/Vasyl_Baran - -And remember, stay Safe and stay Strong! πŸ‡ΊπŸ‡¦ +* **A:** Find CommandShift ('CS' icon) in the menu bar at the top-right and click on it, there you'll find a "Change language with..." drop-down. Select the one you prefer. +* **Q:** I use 3+ languages. Which one does CommandShift switch to? +* **A:** The one you used before the current one, the same way Alt+Tab returns you to your last window. To reach a third language, pick it once from the input menu in the menu bar; CommandShift will then switch between that one and whichever you came from. +* **Q:** I want to switch language by just pressing the Shift key alone +* **A:** From CommandShift's menu bar go to the "Change language with..." drop-down and select "Shift". Typing capital letters will not switch anything, since Shift is then being held together with another key. +* **Q:** I want to hide the CommandShift icon from the menu bar +* **A:** From CommandShift's menu select "Tray menu icon..." and choose whether to hide it "permanently" or "until restart". +* **Q:** How do I automatically launch CommandShift when the computer starts? +* **A:** Add it to your login items: System Settings -> General -> Login Items -> [+] -> [select CommandShift]. +* **Q:** I get a "CommandShift is damaged and can't be opened" error +* **A:** Run this in Terminal: `xattr -cr /Applications/CommandShift.app` + +## Building from source (macOS) + +### One-time setup + +1. Xcode Command Line Tools: + + ``` + xcode-select --install + ``` + +2. Qt 6: + + ``` + brew install qt + ``` + +3. A code-signing identity. CommandShift needs Accessibility permission, and macOS ties + that permission to the code signature. Without a stable signing identity you have to + re-grant Accessibility after *every* rebuild. Creating a self-signed certificate once + avoids that: + + ``` + openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout key.pem -out cert.pem -subj "/CN=CommandShift Dev" \ + -addext "basicConstraints=critical,CA:false" \ + -addext "keyUsage=critical,digitalSignature" \ + -addext "extendedKeyUsage=critical,codeSigning" + openssl pkcs12 -export -inkey key.pem -in cert.pem -out identity.p12 \ + -passout pass:csdev -name "CommandShift Dev" + security import identity.p12 -k ~/Library/Keychains/login.keychain-db \ + -P csdev -T /usr/bin/codesign + security add-trusted-cert -r trustRoot -p codeSign \ + -k ~/Library/Keychains/login.keychain-db cert.pem + ``` + + Verify with `security find-identity -v -p codesigning`. + +### Build + +``` +./build.sh # build + sign into ./build/CommandShift.app +./build.sh install # ...and replace /Applications/CommandShift.app, then launch +``` + +Override the defaults with `QT_PREFIX` and `CS_SIGN_IDENTITY` if needed. + +Note: a Homebrew Qt build is arm64-only and targets macOS 14+. Use the official Qt +installer if you need a universal binary or a lower deployment target. + +## License + +GPL-3.0, inherited from the original project. See [LICENSE](LICENSE). diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..e7db580 --- /dev/null +++ b/build.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Build, sign and install CommandShift locally. +# +# ./build.sh build + sign +# ./build.sh install build + sign + replace /Applications/CommandShift.app +# +# Requires: brew install qt and a "CommandShift Dev" code-signing identity +# (see README "Building from source"). + +set -euo pipefail + +QT_PREFIX="${QT_PREFIX:-/opt/homebrew/opt/qt}" +QMAKE="$QT_PREFIX/bin/qmake" +MACDEPLOYQT="$QT_PREFIX/bin/macdeployqt" +IDENTITY="${CS_SIGN_IDENTITY:-CommandShift Dev}" + +ROOT="$(cd "$(dirname "$0")" && pwd)" +BUILD="$ROOT/build" +APP="$BUILD/CommandShift.app" + +[ -x "$QMAKE" ] || { echo "qmake not found at $QMAKE -- run: brew install qt"; exit 1; } + +echo "==> Configuring" +mkdir -p "$BUILD" +cd "$BUILD" +"$QMAKE" "$ROOT/src/CommandShift.pro" CONFIG+=release + +echo "==> Building" +make -j"$(sysctl -n hw.ncpu)" + +echo "==> Bundling Qt frameworks" +"$MACDEPLOYQT" "$APP" + +echo "==> Signing as '$IDENTITY'" +# Sign nested code first, then the bundle, so seals stay valid. +find "$APP/Contents/Frameworks" "$APP/Contents/PlugIns" -name '*.dylib' -o -name '*.framework' 2>/dev/null \ + | while read -r item; do codesign --force --timestamp=none --sign "$IDENTITY" "$item" >/dev/null 2>&1 || true; done +codesign --force --deep --timestamp=none --sign "$IDENTITY" "$APP" +codesign --verify --verbose=2 "$APP" + +echo "==> Built: $APP" + +if [ "${1:-}" = "install" ]; then + echo "==> Installing to /Applications" + pkill -x CommandShift 2>/dev/null || true + sleep 1 + rm -rf /Applications/CommandShift.app + cp -R "$APP" /Applications/CommandShift.app + echo "==> Installed. Launching." + open /Applications/CommandShift.app +fi diff --git a/src/CommandShift.pro b/src/CommandShift.pro index dcd43ed..ab96260 100644 --- a/src/CommandShift.pro +++ b/src/CommandShift.pro @@ -3,12 +3,13 @@ QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets macx { -QMAKE_APPLE_DEVICE_ARCHS = x86_64 arm64 +QMAKE_APPLE_DEVICE_ARCHS = arm64 +QMAKE_MACOSX_DEPLOYMENT_TARGET = 14.0 } -CONFIG += c++11 +CONFIG += c++17 -ICON = icons\icon.icns +ICON = icons/icon.icns # You can make your code fail to compile if it uses deprecated APIs. # In order to do so, uncomment the following line. @@ -22,7 +23,7 @@ HEADERS += \ constants.h \ keypresscatcher.h -LIBS += -framework ApplicationServices +LIBS += -framework ApplicationServices -framework Carbon # Use custom Info.plist macx { @@ -36,3 +37,4 @@ else: unix:!android: target.path = /opt/$${TARGET}/bin RESOURCES += \ app_resources.qrc + diff --git a/src/Info.plist b/src/Info.plist index a75523b..5323c20 100644 --- a/src/Info.plist +++ b/src/Info.plist @@ -17,7 +17,7 @@ CFBundleSignature ???? LSMinimumSystemVersion - 11.0 + 14.0 LSUIElement 1 NSPrincipalClass diff --git a/src/keypresscatcher.cpp b/src/keypresscatcher.cpp index 3ca12cc..f0c355c 100644 --- a/src/keypresscatcher.cpp +++ b/src/keypresscatcher.cpp @@ -5,10 +5,164 @@ #include #include +#include -KeyPressCatcher::KeyPressCatcher(std::function showMessageCallback) +namespace +{ + // An input source's unique identifier, e.g. "com.apple.keylayout.ABC" + QString inputSourceID(TISInputSourceRef source) + { + if (source == nullptr) + { + return QString(); + } + + auto identifier = (CFStringRef)TISGetInputSourceProperty(source, kTISPropertyInputSourceID); + return (identifier != nullptr) ? QString::fromCFString(identifier) : QString(); + } + + QString currentInputSourceID() + { + TISInputSourceRef current = TISCopyCurrentKeyboardInputSource(); + auto identifier = inputSourceID(current); + + if (current != nullptr) + { + CFRelease(current); + } + + return identifier; + } + + // Keyboard layouts (ABC, RussianWin, ...) and input modes (Chinese, Japanese, ...) are the + // only things a user actually switches between. Character palettes, Press-and-Hold and other + // helpers also live in the "keyboard" category, so filtering on category alone is not enough. + bool isSwitchableKeyboardSource(TISInputSourceRef source) + { + auto type = (CFStringRef)TISGetInputSourceProperty(source, kTISPropertyInputSourceType); + if (type == nullptr) + { + return false; + } + + return CFEqual(type, kTISTypeKeyboardLayout) || CFEqual(type, kTISTypeKeyboardInputMode); + } + + // Enabled, selectable keyboard sources in the order macOS reports them. Caller owns the result. + CFArrayRef copySwitchableInputSources() + { + const void* keys[] = { + kTISPropertyInputSourceCategory, + kTISPropertyInputSourceIsEnabled, + kTISPropertyInputSourceIsSelectCapable + }; + const void* values[] = { + kTISCategoryKeyboardInputSource, + kCFBooleanTrue, + kCFBooleanTrue + }; + + CFDictionaryRef filter = CFDictionaryCreate(kCFAllocatorDefault, + keys, + values, + 3, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks); + if (filter == nullptr) + { + return nullptr; + } + + CFArrayRef candidates = TISCreateInputSourceList(filter, false); + CFRelease(filter); + + if (candidates == nullptr) + { + return nullptr; + } + + CFMutableArrayRef sources = CFArrayCreateMutable(kCFAllocatorDefault, 0, &kCFTypeArrayCallBacks); + CFIndex candidateCount = CFArrayGetCount(candidates); + for (CFIndex i = 0; i < candidateCount; ++i) + { + auto candidate = (TISInputSourceRef)CFArrayGetValueAtIndex(candidates, i); + if (isSwitchableKeyboardSource(candidate)) + { + CFArrayAppendValue(sources, candidate); + } + } + CFRelease(candidates); + + return sources; + } + + bool selectInputSourceWithID(const QString& wantedID) + { + CFArrayRef sources = copySwitchableInputSources(); + if (sources == nullptr) + { + return false; + } + + bool selected = false; + CFIndex count = CFArrayGetCount(sources); + for (CFIndex i = 0; i < count && !selected; ++i) + { + auto source = (TISInputSourceRef)CFArrayGetValueAtIndex(sources, i); + if (inputSourceID(source) == wantedID) + { + selected = (TISSelectInputSource(source) == noErr); + } + } + + CFRelease(sources); + return selected; + } + + // Used when there is no remembered previous source yet, or it has since been disabled + bool selectNextInputSourceAfter(const QString& currentID) + { + CFArrayRef sources = copySwitchableInputSources(); + if (sources == nullptr) + { + return false; + } + + CFIndex count = CFArrayGetCount(sources); + if (count < 2) + { + CFRelease(sources); + return false; + } + + // Match on the identifier string: two TISInputSourceRef handles to the same + // source are not guaranteed to compare equal, so CFEqual is not reliable here. + CFIndex currentIndex = -1; + for (CFIndex i = 0; i < count; ++i) + { + if (inputSourceID((TISInputSourceRef)CFArrayGetValueAtIndex(sources, i)) == currentID) + { + currentIndex = i; + break; + } + } + + CFIndex nextIndex = (currentIndex >= 0) ? ((currentIndex + 1) % count) : 0; + auto next = (TISInputSourceRef)CFArrayGetValueAtIndex(sources, nextIndex); + bool selected = (next != nullptr) && (TISSelectInputSource(next) == noErr); + + CFRelease(sources); + return selected; + } +} + +KeyPressCatcher::KeyPressCatcher(QSettings& settings, + std::function showMessageCallback) : m_showMessageCallback{showMessageCallback} -{ +, m_settings{settings} +{ + migrateSecondShortcutKeyFromLegacySettings(); + auto secondShortcutKeyQVariant = m_settings.value(CS::secondShortcutKeySettingKeyword); if (!secondShortcutKeyQVariant.isNull()) { @@ -20,6 +174,8 @@ KeyPressCatcher::KeyPressCatcher(std::function(observer)->onInputSourceChanged(); + }, + kTISNotifySelectedKeyboardInputSourceChanged, + nullptr, + CFNotificationSuspensionBehaviorDeliverImmediately); + + m_observing_input_source = true; +} + +void KeyPressCatcher::stopObservingInputSource() +{ + if (!m_observing_input_source) + { + return; + } + + CFNotificationCenterRemoveObserver(CFNotificationCenterGetDistributedCenter(), + this, + kTISNotifySelectedKeyboardInputSourceChanged, + nullptr); + + m_observing_input_source = false; +} + +void KeyPressCatcher::onInputSourceChanged() +{ + auto nowSelected = currentInputSourceID(); + if (nowSelected.isEmpty() || nowSelected == m_currentSourceID) + { + return; + } + + m_previousSourceID = m_currentSourceID; + m_currentSourceID = nowSelected; +} + +void KeyPressCatcher::requestInputSourceSwitch() +{ + // Text Input Services must not be called from the event-tap callback: the tap runs on a + // latency budget and macOS disables it if we take too long. Hand the work to the event loop. + if (m_switch_queued) + { + return; + } + + m_switch_queued = true; + QTimer::singleShot(0, [this] + { + m_switch_queued = false; + toggleInputSource(); + }); +} + +void KeyPressCatcher::toggleInputSource() { - // Creating a 'Shift + Alt' event - CGEventSourceRef src = CGEventSourceCreate(kCGEventSourceStateHIDSystemState); - CGEventRef spaceDown = CGEventCreateKeyboardEvent(src, 0x31, true); - CGEventRef spaceUp = CGEventCreateKeyboardEvent(src, 0x31, false); + auto current = currentInputSourceID(); - CGEventSetFlags(spaceDown, kCGEventFlagMaskAlternate); - CGEventSetFlags(spaceUp, kCGEventFlagMaskAlternate); - CGEventSetFlags(spaceDown, kCGEventFlagMaskControl); - CGEventSetFlags(spaceUp, kCGEventFlagMaskControl); + // The notification can be missed (it does not fire for the source we start up on), + // so reconcile what we think is current before deciding where to go. + if (!current.isEmpty() && current != m_currentSourceID) + { + m_previousSourceID = m_currentSourceID; + m_currentSourceID = current; + } - CGEventTapLocation loc = kCGHIDEventTap; + if (!m_previousSourceID.isEmpty() && + m_previousSourceID != current && + selectInputSourceWithID(m_previousSourceID)) + { + return; + } - CGEventPost(loc, spaceDown); - CGEventPost(loc, spaceUp); + // Nothing worth going back to yet, or it was removed in System Settings + selectNextInputSourceAfter(current); +} - CFRelease(src); - CFRelease(spaceDown); - CFRelease(spaceUp); +void KeyPressCatcher::reenableEventTap() +{ + if (m_eventTapPtr != nullptr) + { + CGEventTapEnable(m_eventTapPtr, true); + } +} + +void KeyPressCatcher::noteUnrelatedInput() +{ + if (m_combo_armed) + { + m_combo_used_with_other_input = true; + } } void KeyPressCatcher::handleModifierKeysStatusChange(bool shift_pressed_down, bool second_key_pressed_down) { - if (m_change_language_on_release) + if (shift_pressed_down && second_key_pressed_down) { - if (shift_pressed_down && second_key_pressed_down) - { - m_pending = true; - } - else if (!shift_pressed_down && m_pending) + if (!m_combo_armed) { - sendSystemDefaultChangeLanguageShortcut(); - m_pending = false; + m_combo_armed = true; + m_combo_used_with_other_input = false; } + return; } - else + + if (!m_combo_armed) { - if (shift_pressed_down && second_key_pressed_down) - { - sendSystemDefaultChangeLanguageShortcut(); - } + return; + } + + m_combo_armed = false; + + // Whether the combo was a request to change language can only be known once it is let + // go of: Cmd+Shift+A is somebody using a shortcut, and Shift+H is somebody typing a + // capital letter. Only a press of the modifiers on their own means a language change. + if (!m_combo_used_with_other_input) + { + requestInputSourceSwitch(); } } bool KeyPressCatcher::init() { - CGEventMask modifiersPressedMask = CGEventMaskBit(kCGEventFlagsChanged); + // Key and mouse events are watched purely to notice that the modifiers were held as part + // of some other shortcut, so that we can leave that shortcut alone. + CGEventMask eventMask = CGEventMaskBit(kCGEventFlagsChanged) + | CGEventMaskBit(kCGEventKeyDown) + | CGEventMaskBit(kCGEventLeftMouseDown) + | CGEventMaskBit(kCGEventRightMouseDown) + | CGEventMaskBit(kCGEventOtherMouseDown) + | CGEventMaskBit(kCGEventScrollWheel); - m_eventTapPtr = CGEventTapCreate(kCGSessionEventTap, kCGHeadInsertEventTap, kCGEventTapOptionDefault, modifiersPressedMask, + // Listen-only, because we never modify or swallow anything. It also keeps the app out of + // the input latency path, which matters now that every keystroke passes through here. + m_eventTapPtr = CGEventTapCreate(kCGSessionEventTap, kCGHeadInsertEventTap, kCGEventTapOptionListenOnly, eventMask, [] (CGEventTapProxy, CGEventType type, CGEventRef event, void *keyPressCatcherRawPtr) - { + { auto catcher = static_cast(keyPressCatcherRawPtr); + + // These two arrive regardless of the mask we asked for. Without handling + // them the app goes quietly deaf until it is restarted. + if (type == kCGEventTapDisabledByTimeout || type == kCGEventTapDisabledByUserInput) + { + catcher->reenableEventTap(); + return event; + } + + if (type != kCGEventFlagsChanged) + { + catcher->noteUnrelatedInput(); + return event; + } + CGEventFlags flags = CGEventGetFlags(event); auto secondTriggerKey = catcher->getSecondShortcutKey(); // Checking whether a second key that we expected (depending on configuration) was pressed @@ -179,10 +462,10 @@ bool KeyPressCatcher::init() return false; } - CFRunLoopAddSource(CFRunLoopGetCurrent(), - CFMachPortCreateRunLoopSource(kCFAllocatorDefault, m_eventTapPtr, 0), - kCFRunLoopCommonModes); + CFRunLoopSourceRef runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, m_eventTapPtr, 0); + CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, kCFRunLoopCommonModes); + CFRelease(runLoopSource); - CGEventTapEnable(m_eventTapPtr, true); - return true; + CGEventTapEnable(m_eventTapPtr, true); + return true; } diff --git a/src/keypresscatcher.h b/src/keypresscatcher.h index ab3c712..a1cdb12 100644 --- a/src/keypresscatcher.h +++ b/src/keypresscatcher.h @@ -13,37 +13,54 @@ class KeyPressCatcher { public: - KeyPressCatcher(std::function showMessageCallback); + KeyPressCatcher(QSettings& settings, + std::function showMessageCallback); ~KeyPressCatcher(); void setSecondShortcutKey(CS::SecondShortcutKeyEnum keyValue); CS::SecondShortcutKeyEnum getSecondShortcutKey() const; - void setChangeLanguageOnRelease(bool change_language_on_release); - bool changeLanguageOnRelease() const; - private: bool init(); - void retryInit(); + + // Move the shortcut preference out of the location older versions wrote it to + void migrateSecondShortcutKeyFromLegacySettings(); // Notify user that we've started successfully void notifyAboutSuccessfulStart(); // Notify user that we lost 'Privileges' (i.e. removed from Accessibility) void notifyUserAboutLostPrivileges(); - // Tell the system that we want to change the language - void sendSystemDefaultChangeLanguageShortcut(); + // Ask for a language change; the switch itself runs outside the event-tap callback + void requestInputSourceSwitch(); + // Switch to the previously used input source (or the next one, if there isn't one yet) + void toggleInputSource(); + // Keep track of the most-recently-used pair of input sources + void startObservingInputSource(); + void stopObservingInputSource(); + void onInputSourceChanged(); + // Turn the event tap back on after macOS has disabled it + void reenableEventTap(); // Handle modifiers state change (pressed/released) void handleModifierKeysStatusChange(bool shift_pressed_down, bool second_key_pressed_down); + // Note that something other than the modifiers themselves was pressed + void noteUnrelatedInput(); // Perpetual loop checking (every 1 sec) if we still have Accessibility permissions void loop(); std::function m_showMessageCallback; __CFMachPort* m_eventTapPtr = nullptr; - QSettings m_settings; + QSettings& m_settings; bool m_successfully_started = false; bool m_accessibility_granted = false; - bool m_change_language_on_release = false; - bool m_pending = false; + // The shortcut's modifiers are currently held down together + bool m_combo_armed = false; + // ...and something else was pressed while they were, so this is a real shortcut + bool m_combo_used_with_other_input = false; + bool m_switch_queued = false; + bool m_observing_input_source = false; + // Identifiers ("com.apple.keylayout.ABC" and friends) of the two most recently used sources + QString m_currentSourceID; + QString m_previousSourceID; CS::SecondShortcutKeyEnum m_secondShortcutKey = CS::SecondShortcutKeyEnum::Command; }; diff --git a/src/main.cpp b/src/main.cpp index 364291f..f98c9a7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -17,7 +17,6 @@ namespace { constexpr static auto s_hide_tray_icon_setting_name = "tray_icon/hide"; - constexpr static auto s_change_language_on_release_setting_name = "language/trigger_on_key_release"; constexpr static auto s_white_tray_icon_preference_setting_name = "tray_icon/white_tray_icon"; } @@ -50,7 +49,7 @@ int main(int argc, char *argv[]) 30000); }; - KeyPressCatcher catcher(showMessageCallback); + KeyPressCatcher catcher(*settings, showMessageCallback); auto authorAction = menu.addAction("Visit CommandShift page"); QObject::connect(authorAction, &QAction::triggered, [] { QDesktopServices::openUrl(QUrl("https://github.com/VasylBaran/CommandShift")); }); @@ -110,14 +109,6 @@ int main(int argc, char *argv[]) secondShortcutKeyDropDownActionMenu->addAction(commandAction); secondShortcutKeyDropDownActionMenu->addAction(shiftAction); - auto change_language_on_release = settings->value(s_change_language_on_release_setting_name, false).toBool(); - catcher.setChangeLanguageOnRelease(change_language_on_release); - - auto changeLanguageOnKeyReleasedAction = menu.addAction("Change language after Shift release"); - changeLanguageOnKeyReleasedAction->setCheckable(true); - changeLanguageOnKeyReleasedAction->setChecked(change_language_on_release); - QObject::connect(changeLanguageOnKeyReleasedAction, &QAction::triggered, changeLanguageOnKeyReleasedAction, [&catcher, settings] { catcher.setChangeLanguageOnRelease(!catcher.changeLanguageOnRelease()); settings->setValue(s_change_language_on_release_setting_name, catcher.changeLanguageOnRelease()); }); - auto trayIconGroup = new QActionGroup(&menu); trayIconGroup->setExclusive(false);