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:
parent
27c3374ea3
commit
82cd4112f5
@ -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 复制(交叉编译时自动拷贝到输出目录)
|
||||
# ============================================================================
|
||||
|
||||
@ -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
|
||||
// 优先尝试 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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user