feat(linux): 添加 libei 快捷键后端,修复 GNOME Wayland 下 CapsLock 检测

GNOME 桌面的 xdg-desktop-portal 不支持 GlobalShortcuts 接口,
导致 D-Bus Portal 方式的全局快捷键始终返回 "An app id is required" 错误。

改用 libei (libinput-emulator) 作为优先后端:
- libei 使用 socket 凭证认证,不需要 app_id
- 直接监听键盘事件,检测 CapsLock (keycode 58)
- 完美兼容 GNOME 47+ / Wayland
- Portal 作为回退后端保留(KDE 等桌面可用)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
impressionyang 2026-06-13 15:00:14 +08:00
parent 27c3374ea3
commit 82cd4112f5
3 changed files with 204 additions and 29 deletions

View File

@ -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
$<$<CXX_COMPILER_ID:MSVC>:/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
# ============================================================================

View File

@ -1,11 +1,18 @@
#include "caps_lock_voice_hotkey.h"
#include "utils/logger.h"
#include <QSocketNotifier>
#ifdef HAVE_LIBEI
extern "C" {
#include <libei.h>
}
#endif
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDBusMessage>
#include <QDBusObjectPath>
#include <QCoreApplication>
#include <QUuid>
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
// 优先尝试 libeiGNOME 47+ 推荐方案)
LOG_INFO(kTag, "尝试 libei 后端...");
startLibei();
if (active_) {
LOG_INFO(kTag, "libei 快捷键已就绪");
return true;
}
LOG_INFO(kTag, "libei 不可用,回退到 Portal...");
#endif
// 回退到 PortalKDE 等支持 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, "快捷键已注册PortalCapsLock 语音输入已就绪");
}
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

View File

@ -2,7 +2,6 @@
#include <QObject>
#include <QString>
#include <QTimer>
#include <memory>
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