diff --git a/CMakeLists.txt b/CMakeLists.txt index 1a52db0..d8f7d7c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -134,11 +134,12 @@ elseif(APPLE) list(APPEND HEADERS src/core/mac_hotkey.h src/core/mac_text_injector.h) add_compile_definitions(PLATFORM_MACOS) else() - # Linux 实现(D-Bus Portal + XTest) + # Linux 实现(libei + D-Bus Portal + XTest) list(APPEND SOURCES src/core/caps_lock_voice_hotkey.cpp src/core/wayland_text_injector.cpp) list(APPEND HEADERS src/core/caps_lock_voice_hotkey.h src/core/wayland_text_injector.h) add_compile_definitions(PLATFORM_LINUX) endif() + # Windows 使用 WIN32 标志隐藏启动控制台,并嵌入应用图标 if(WIN32) add_executable(${PROJECT_NAME} WIN32 ${SOURCES} ${HEADERS} @@ -177,6 +178,28 @@ target_compile_options(${PROJECT_NAME} PRIVATE $<$:/W4> ) +# ============================================================================ +# Linux: libei 全局快捷键(GNOME 47+) +# ============================================================================ +if(NOT WIN32 AND NOT APPLE) + find_package(PkgConfig QUIET) + if(PKG_CONFIG_FOUND) + pkg_check_modules(LIBEI QUIET libei-1.0) + endif() + if(LIBEI_FOUND) + find_library(LIBEI_LIB_PATH + NAMES ei + PATHS /usr/lib64 /usr/lib /usr/local/lib + ) + if(LIBEI_LIB_PATH) + message(STATUS "找到 libei: ${LIBEI_LIB_PATH} (${LIBEI_VERSION})") + target_include_directories(${PROJECT_NAME} PRIVATE ${LIBEI_INCLUDE_DIRS}) + target_link_libraries(${PROJECT_NAME} PRIVATE ${LIBEI_LIB_PATH}) + add_compile_definitions(HAVE_LIBEI) + endif() + endif() +endif() + # ============================================================================ # Windows DLL 复制(交叉编译时自动拷贝到输出目录) # ============================================================================ diff --git a/src/core/caps_lock_voice_hotkey.cpp b/src/core/caps_lock_voice_hotkey.cpp index 97e39a9..d4fe7d3 100644 --- a/src/core/caps_lock_voice_hotkey.cpp +++ b/src/core/caps_lock_voice_hotkey.cpp @@ -1,11 +1,18 @@ #include "caps_lock_voice_hotkey.h" #include "utils/logger.h" +#include + +#ifdef HAVE_LIBEI +extern "C" { +#include +} +#endif + #include #include #include #include -#include #include static const char* const kTag = "CapsLockVoiceHotkey"; @@ -16,15 +23,25 @@ static const char* const kPortalObjectPath = "/org/freedesktop/portal/desktop"; static const char* const kGlobalShortcutsIface = "org.freedesktop.portal.GlobalShortcuts"; static const char* const kRequestIface = "org.freedesktop.portal.Request"; +// Linux CapsLock keycode (scan code) +static const uint32_t kCapsLockKeycode = 58; + namespace impress { struct CapsLockVoiceHotkey::Impl { + // Portal 相关 QString sessionPath; QString pendingRequestPath; enum State { Idle, WaitingSession, WaitingBind, Active }; State state = Idle; + // libei 相关 +#ifdef HAVE_LIBEI + struct ei* eiCtx = nullptr; + QSocketNotifier* socketNotifier = nullptr; +#endif + /** 生成唯一 token */ static QString makeToken(const QString& prefix) { return prefix + "_" + QUuid::createUuid().toString().mid(1, 8); @@ -57,10 +74,47 @@ CapsLockVoiceHotkey::~CapsLockVoiceHotkey() { bool CapsLockVoiceHotkey::start() { if (active_) return true; +#ifdef HAVE_LIBEI + // 优先尝试 libei(GNOME 47+ 推荐方案) + LOG_INFO(kTag, "尝试 libei 后端..."); + startLibei(); + if (active_) { + LOG_INFO(kTag, "libei 快捷键已就绪"); + return true; + } + LOG_INFO(kTag, "libei 不可用,回退到 Portal..."); +#endif + + // 回退到 Portal(KDE 等支持 GlobalShortcuts 的桌面) + startPortal(); + return active_; +} + +void CapsLockVoiceHotkey::stop() { + if (!active_ && impl_->state == Impl::Idle) return; + +#ifdef HAVE_LIBEI + stopLibei(); +#endif + stopPortal(); + + active_ = false; + recording_ = false; + impl_->state = Impl::Idle; + impl_->sessionPath.clear(); + LOG_INFO(kTag, "CapsLock 语音快捷键已停止"); +} + +// ============================================================================ +// Portal 后端(KDE 等支持 GlobalShortcuts 的桌面) +// ============================================================================ + +void CapsLockVoiceHotkey::startPortal() { QDBusConnection bus = Impl::bus(); if (!bus.isConnected()) { + LOG_ERROR(kTag, "无法连接到 D-Bus session bus"); emit error("无法连接到 D-Bus session bus"); - return false; + return; } // 连接信号 @@ -93,7 +147,8 @@ bool CapsLockVoiceHotkey::start() { if (reply.type() == QDBusMessage::ErrorMessage) { emit error(QString("CreateSession 失败: %1").arg(reply.errorMessage())); LOG_ERROR(kTag, reply.errorMessage()); - return false; + impl_->state = Impl::Idle; + return; } // 保存预期 session path @@ -103,12 +158,9 @@ bool CapsLockVoiceHotkey::start() { LOG_INFO(kTag, "CreateSession 已发送,等待用户授权..."); LOG_DEBUG(kTag, QString("Session path: %1").arg(impl_->sessionPath)); - return true; } -void CapsLockVoiceHotkey::stop() { - if (!active_ && impl_->state == Impl::Idle) return; - +void CapsLockVoiceHotkey::stopPortal() { QDBusConnection bus = Impl::bus(); bus.disconnect(kPortalService, kPortalObjectPath, kGlobalShortcutsIface, "Activated", @@ -119,12 +171,6 @@ void CapsLockVoiceHotkey::stop() { bus.disconnect(kPortalService, QString(), kRequestIface, "Response", this, SLOT(onPortalResponse(uint, QVariantMap))); - - active_ = false; - recording_ = false; - impl_->state = Impl::Idle; - impl_->sessionPath.clear(); - LOG_INFO(kTag, "CapsLock 语音快捷键已停止"); } void CapsLockVoiceHotkey::onPortalResponse(uint response, const QVariantMap& results) { @@ -199,11 +245,10 @@ void CapsLockVoiceHotkey::handleBindResponse(uint response, const QVariantMap&) return; } - // 快捷键绑定成功 active_ = true; impl_->state = Impl::Active; emit ready(); - LOG_INFO(kTag, "快捷键已注册,CapsLock 语音输入已就绪"); + LOG_INFO(kTag, "快捷键已注册(Portal),CapsLock 语音输入已就绪"); } void CapsLockVoiceHotkey::handleActivated(const QString& shortcutId) { @@ -220,4 +265,110 @@ void CapsLockVoiceHotkey::handleDeactivated(const QString& shortcutId) { emit recordingStopped(); } +// ============================================================================ +// libei 后端(GNOME 47+) +// ============================================================================ + +#ifdef HAVE_LIBEI + +void CapsLockVoiceHotkey::startLibei() { + impl_->eiCtx = ei_new(this); + if (!impl_->eiCtx) { + LOG_ERROR(kTag, "ei_new 失败"); + return; + } + + ei_configure_name(impl_->eiCtx, "io.impress.voice-input"); + + int fd = ei_setup_backend_socket(impl_->eiCtx, nullptr); + if (fd < 0) { + LOG_ERROR(kTag, "ei_setup_backend_socket 失败,libei 不可用"); + ei_unref(impl_->eiCtx); + impl_->eiCtx = nullptr; + return; + } + + // 使用 QSocketNotifier 集成到 Qt 事件循环 + impl_->socketNotifier = new QSocketNotifier(fd, QSocketNotifier::Read, this); + connect(impl_->socketNotifier, &QSocketNotifier::activated, + this, &CapsLockVoiceHotkey::onLibeiEvent); + + LOG_INFO(kTag, "libei 已连接,等待键盘事件..."); + + active_ = true; + impl_->state = Impl::Active; + emit ready(); + LOG_INFO(kTag, "CapsLock 语音快捷键已就绪(libei)"); +} + +void CapsLockVoiceHotkey::stopLibei() { + if (impl_->socketNotifier) { + impl_->socketNotifier->setEnabled(false); + delete impl_->socketNotifier; + impl_->socketNotifier = nullptr; + } + + if (impl_->eiCtx) { + ei_disconnect(impl_->eiCtx); + ei_unref(impl_->eiCtx); + impl_->eiCtx = nullptr; + } +} + +void CapsLockVoiceHotkey::onLibeiEvent() { + if (!impl_->eiCtx) return; + + struct ei_event* ev; + while ((ev = ei_get_event(impl_->eiCtx)) != nullptr) { + enum ei_event_type type = ei_event_get_type(ev); + + switch (type) { + case EI_EVENT_KEYBOARD_KEY: { + uint32_t keycode = ei_event_keyboard_get_key(ev); + bool isPress = ei_event_keyboard_get_key_is_press(ev); + + if (keycode == kCapsLockKeycode) { + if (ignoreEvents_) break; + if (isPress) { + LOG_DEBUG(kTag, "libei: CapsLock 按下"); + recording_ = true; + emit recordingStarted(); + } else { + LOG_DEBUG(kTag, "libei: CapsLock 松开"); + recording_ = false; + emit recordingStopped(); + } + } + break; + } + + case EI_EVENT_DISCONNECT: + LOG_WARNING(kTag, "libei 连接已断开"); + active_ = false; + break; + + default: + break; + } + + ei_event_unref(ev); + } +} + +#else // HAVE_LIBEI + +void CapsLockVoiceHotkey::startLibei() { + LOG_WARNING(kTag, "libei 未编译启用"); +} + +void CapsLockVoiceHotkey::stopLibei() { + // nothing +} + +void CapsLockVoiceHotkey::onLibeiEvent() { + // nothing +} + +#endif // HAVE_LIBEI + } // namespace impress diff --git a/src/core/caps_lock_voice_hotkey.h b/src/core/caps_lock_voice_hotkey.h index 796db1b..4b998a0 100644 --- a/src/core/caps_lock_voice_hotkey.h +++ b/src/core/caps_lock_voice_hotkey.h @@ -2,7 +2,6 @@ #include #include -#include #include namespace impress { @@ -10,14 +9,15 @@ namespace impress { /** * @brief CapsLock 长按语音输入快捷键管理器 * - * 使用 freedesktop GlobalShortcuts D-Bus Portal 实现 Wayland 兼容的全局快捷键。 + * 双后端实现: + * - Portal (KDE/默认):freedesktop GlobalShortcuts D-Bus Portal + * - libei (GNOME 47+):libinput-emulator,直接监听键盘事件 + * * 工作流程: * 1. 用户长按 CapsLock 1 秒后触发录音 * 2. 长按期间持续录音 * 3. 松开 CapsLock 后停止录音并触发转写 * 4. 短按(< 1s)直接传递 CapsLock 事件(切换大小写锁定) - * - * 首次启动时需要用户通过 GNOME 对话框授权。 */ class CapsLockVoiceHotkey : public QObject { Q_OBJECT @@ -25,7 +25,7 @@ public: explicit CapsLockVoiceHotkey(QObject* parent = nullptr); ~CapsLockVoiceHotkey() override; - /** @brief 初始化并注册快捷键(首次需要用户授权) */ + /** @brief 初始化并注册快捷键 */ bool start(); /** @brief 停止并注销快捷键 */ @@ -37,20 +37,13 @@ public: /** @brief 当前是否正在录音(CapsLock 长按超过 1s 后) */ bool isRecording() const { return recording_; } - /** @brief 临时忽略 portal 信号(XTest 模拟按键期间) */ + /** @brief 临时忽略信号(XTest 模拟按键期间) */ void setIgnoreEvents(bool ignore) { ignoreEvents_ = ignore; } signals: - /** @brief 开始录音(长按超过 1 秒后) */ void recordingStarted(); - - /** @brief 停止录音(松开快捷键后) */ void recordingStopped(); - - /** @brief 快捷键已注册(用户授权后) */ void ready(); - - /** @brief 初始化失败 */ void error(const QString& message); private: @@ -60,11 +53,19 @@ private: bool recording_ = false; bool ignoreEvents_ = false; + // Portal 后端方法 + void startPortal(); + void stopPortal(); void handleSessionResponse(uint response, const QVariantMap& results); void handleBindResponse(uint response, const QVariantMap& results); void handleActivated(const QString& shortcutId); void handleDeactivated(const QString& shortcutId); void onPortalResponse(uint response, const QVariantMap& results); + + // libei 后端方法 + void startLibei(); + void stopLibei(); + void onLibeiEvent(); }; } // namespace impress