diff --git a/gnome-extension/io.impress.voice-input-hotkey@impress/extension.js b/gnome-extension/io.impress.voice-input-hotkey@impress/extension.js
new file mode 100644
index 0000000..b95b73d
--- /dev/null
+++ b/gnome-extension/io.impress.voice-input-hotkey@impress/extension.js
@@ -0,0 +1,125 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import Clutter from 'gi://Clutter';
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
+
+const DBUS_IFACE = `
+
+
+
+
+
+`;
+
+const DBUS_PATH = '/io/impress/VoiceInputHotkey';
+const CAPS_LOCK_KEYSYM = 0xffe5;
+
+export default class VoiceInputHotkeyExtension extends Extension {
+ constructor(metadata) {
+ super(metadata);
+ this._dbusNode = null;
+ this._captorId = 0;
+ this._releaseCaptorId = 0;
+ this._retryId = 0;
+ }
+
+ enable() {
+ try {
+ this._dbusNode = Gio.DBusExportedObject.wrapJSObject(DBUS_IFACE, {});
+ this._dbusNode.export(Gio.DBus.session, DBUS_PATH);
+ this.log('D-Bus 已导出');
+
+ this._tryConnect();
+ } catch (e) {
+ this.log('enable 失败: ' + e.message);
+ }
+ }
+
+ _tryConnect() {
+ if (this._retryId) {
+ GLib.Source.remove(this._retryId);
+ this._retryId = 0;
+ }
+
+ // 优先使用 uiGroup(始终可用),回退到 global.stage
+ let target = null;
+ try {
+ target = Main.layoutManager?.uiGroup || null;
+ } catch (e) {}
+ if (!target) {
+ try { target = global.stage || null; } catch (e) {}
+ }
+
+ if (!target) {
+ this.log('uiGroup 和 stage 均不可用,200ms 后重试');
+ this._retryId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 200, () => {
+ this._retryId = 0;
+ this._tryConnect();
+ return GLib.SOURCE_REMOVE;
+ });
+ return;
+ }
+
+ try {
+ this._captorId = target.connect('captured-event::key-press', (_actor, event) => {
+ if (event.type() !== Clutter.EventType.KEY_PRESS) return Clutter.EVENT_PROPAGATE;
+ const sym = event.get_key_symbol();
+ if (sym === CAPS_LOCK_KEYSYM || sym === Clutter.KEY_Caps_Lock) {
+ this._dbusNode.emit_signal('CapsLockPressed', null);
+ this.log('CapsLock pressed');
+ }
+ return Clutter.EVENT_PROPAGATE;
+ });
+
+ this._releaseCaptorId = target.connect('captured-event::key-release', (_actor, event) => {
+ if (event.type() !== Clutter.EventType.KEY_RELEASE) return Clutter.EVENT_PROPAGATE;
+ const sym = event.get_key_symbol();
+ if (sym === CAPS_LOCK_KEYSYM || sym === Clutter.KEY_Caps_Lock) {
+ this._dbusNode.emit_signal('CapsLockReleased', null);
+ this.log('CapsLock released');
+ }
+ return Clutter.EVENT_PROPAGATE;
+ });
+
+ this.log('全局按键捕获已就绪');
+ } catch (e) {
+ this.log('连接事件失败: ' + e.message);
+ this._retryId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => {
+ this._retryId = 0;
+ this._tryConnect();
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+ }
+
+ disable() {
+ try {
+ if (this._retryId) {
+ GLib.Source.remove(this._retryId);
+ this._retryId = 0;
+ }
+
+ // 从所有可能的目标断开
+ let targets = [];
+ try { if (Main.layoutManager?.uiGroup) targets.push(Main.layoutManager.uiGroup); } catch (e) {}
+ try { if (global.stage) targets.push(global.stage); } catch (e) {}
+
+ for (const t of targets) {
+ if (this._captorId) { try { t.disconnect(this._captorId); } catch (e) {} }
+ if (this._releaseCaptorId) { try { t.disconnect(this._releaseCaptorId); } catch (e) {} }
+ }
+ this._captorId = 0;
+ this._releaseCaptorId = 0;
+
+ if (this._dbusNode) { this._dbusNode.unexport(); this._dbusNode = null; }
+ this.log('Extension disabled');
+ } catch (e) {
+ this.log('disable 异常: ' + e.message);
+ }
+ }
+
+ log(msg) {
+ console.log(`[ImpressHotkey] ${msg}`);
+ }
+}
diff --git a/gnome-extension/io.impress.voice-input-hotkey@impress/metadata.json b/gnome-extension/io.impress.voice-input-hotkey@impress/metadata.json
new file mode 100644
index 0000000..3b2a7d8
--- /dev/null
+++ b/gnome-extension/io.impress.voice-input-hotkey@impress/metadata.json
@@ -0,0 +1,8 @@
+{
+ "uuid": "io.impress.voice-input-hotkey@impress",
+ "name": "Impress Voice Input Hotkey",
+ "description": "Provides CapsLock global key events for Impress Voice Input via D-Bus",
+ "version": 1,
+ "shell-version": ["46", "47", "48", "50"],
+ "url": "https://gitea.impressionyang.top/impressionyang/impress_voice_input"
+}
diff --git a/package-linux.sh b/package-linux.sh
new file mode 100755
index 0000000..63d37f3
--- /dev/null
+++ b/package-linux.sh
@@ -0,0 +1,218 @@
+#!/bin/bash
+# ============================================================================
+# Impress Voice Input — Linux 发布包打包脚本
+# 用法: ./package-linux.sh [--clean]
+# ============================================================================
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+BUILD_DIR="${SCRIPT_DIR}/build_linux"
+DIST_DIR="${SCRIPT_DIR}/dist"
+PKG_NAME="impress_voice_input_linux"
+PKG_DIR="${DIST_DIR}/${PKG_NAME}"
+
+for arg in "$@"; do
+ case "$arg" in
+ --clean)
+ echo "清理发布包目录..."
+ rm -rf "${PKG_DIR}" "${DIST_DIR}/${PKG_NAME}.tar.gz"
+ ;;
+ esac
+done
+
+# 1. 确保已编译
+if [ ! -f "${BUILD_DIR}/impress_voice_input" ]; then
+ echo "错误:未找到可执行文件,请先运行 ./build-linux.sh"
+ exit 1
+fi
+
+# 2. 创建发布目录结构
+echo "[1/6] 创建发布目录..."
+rm -rf "${PKG_DIR}"
+mkdir -p "${PKG_DIR}/lib"
+mkdir -p "${PKG_DIR}/platforms"
+mkdir -p "${PKG_DIR}/styles"
+mkdir -p "${PKG_DIR}/gnome-extension/io.impress.voice-input-hotkey@impress"
+
+# 3. 复制核心文件
+echo "[2/6] 复制可执行文件和资源..."
+cp "${BUILD_DIR}/impress_voice_input" "${PKG_DIR}/"
+cp "${SCRIPT_DIR}/configs/default_config.json" "${PKG_DIR}/default_config.json"
+cp "${SCRIPT_DIR}/src/ui/resources/styles/main.qss" "${PKG_DIR}/styles/" 2>/dev/null || true
+cp "${SCRIPT_DIR}/src/ui/resources/styles/main_dark.qss" "${PKG_DIR}/styles/" 2>/dev/null || true
+
+# 4. 复制依赖库
+echo "[3/6] 复制依赖库..."
+cp "${SCRIPT_DIR}/third_party/onnxruntime/lib/libonnxruntime.so"* "${PKG_DIR}/lib/"
+cp "${SCRIPT_DIR}/third_party/portaudio/lib/libportaudio.so"* "${PKG_DIR}/lib/" 2>/dev/null || true
+
+for lib in libQt6Core.so.6 libQt6Widgets.so.6 libQt6Gui.so.6 libQt6Concurrent.so.6 libQt6Network.so.6; do
+ src=$(find /home/alvin/Qt/6.11.1/gcc_64/lib -name "${lib}" -o -name "${lib}.*" 2>/dev/null | head -1)
+ [ -n "$src" ] && cp -L "$src" "${PKG_DIR}/lib/" || true
+done
+
+for lib in libicui18n.so.77 libicuuc.so.77 libicudata.so.77; do
+ src=$(find /home/alvin/Qt/6.11.1/gcc_64/lib -name "${lib}" -o -name "${lib}.*" 2>/dev/null | head -1)
+ [ -n "$src" ] && cp -L "$src" "${PKG_DIR}/lib/" || true
+done
+
+for lib in libpcre2-16.so.0 libfontconfig.so.1 libfreetype.so.6; do
+ src=$(find /usr/lib64 /usr/lib -name "${lib}" -o -name "${lib}.*" 2>/dev/null | head -1)
+ [ -n "$src" ] && cp -L "$src" "${PKG_DIR}/lib/" || true
+done
+
+for lib in libei.so.1 libeis.so.1; do
+ src=$(find /usr/lib64 /usr/lib -name "${lib}" -o -name "${lib}.*" 2>/dev/null | head -1)
+ [ -n "$src" ] && cp -L "$src" "${PKG_DIR}/lib/" || true
+done
+
+QT_PLUGIN_DIR=$(find /home/alvin/Qt -path "*/plugins/platforms" -type d 2>/dev/null | head -1)
+if [ -n "$QT_PLUGIN_DIR" ]; then
+ cp "${QT_PLUGIN_DIR}"/libq*.so "${PKG_DIR}/platforms/" 2>/dev/null || true
+fi
+
+# 5. 复制 GNOME 扩展
+echo "[4/6] 复制 GNOME Shell 扩展..."
+cp "${SCRIPT_DIR}/gnome-extension/io.impress.voice-input-hotkey@impress/extension.js" \
+ "${PKG_DIR}/gnome-extension/io.impress.voice-input-hotkey@impress/"
+cp "${SCRIPT_DIR}/gnome-extension/io.impress.voice-input-hotkey@impress/metadata.json" \
+ "${PKG_DIR}/gnome-extension/io.impress.voice-input-hotkey@impress/"
+
+# 6. 生成脚本和桌面文件
+echo "[5/6] 生成安装脚本和启动脚本..."
+
+# --- install.sh ---
+cat > "${PKG_DIR}/install.sh" << 'INSTALLSH'
+#!/bin/bash
+set -e
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+APP_NAME="impress_voice_input"
+GNOME_EXT_UUID="io.impress.voice-input-hotkey@impress"
+INSTALL_MODE="user"
+for arg in "$@"; do case "$arg" in --system) INSTALL_MODE="system" ;; --user) INSTALL_MODE="user" ;; esac; done
+
+echo "============================================"
+echo " Impress Voice Input — 安装"
+echo " 模式: ${INSTALL_MODE}"
+echo "============================================"
+
+echo ""
+echo "[1/3] GNOME Shell 扩展(GNOME 用户可选)..."
+if [ -d "${SCRIPT_DIR}/gnome-extension/${GNOME_EXT_UUID}" ]; then
+ if [ "$INSTALL_MODE" = "system" ]; then
+ EXT_DST="/usr/share/gnome-shell/extensions/${GNOME_EXT_UUID}"
+ echo " 系统安装: ${EXT_DST}"
+ sudo mkdir -p "$(dirname "${EXT_DST}")"
+ sudo rm -rf "${EXT_DST}"
+ sudo cp -r "${SCRIPT_DIR}/gnome-extension/${GNOME_EXT_UUID}" "${EXT_DST}"
+ sudo chmod -R o+r "${EXT_DST}"
+ else
+ EXT_DST="${HOME}/.local/share/gnome-shell/extensions/${GNOME_EXT_UUID}"
+ echo " 用户安装: ${EXT_DST}"
+ mkdir -p "$(dirname "${EXT_DST}")"
+ rm -rf "${EXT_DST}"
+ cp -r "${SCRIPT_DIR}/gnome-extension/${GNOME_EXT_UUID}" "${EXT_DST}"
+ fi
+ echo " ✓ GNOME 扩展已安装"
+
+ ENABLED=$(gsettings get org.gnome.shell enabled-extensions 2>/dev/null || echo "[]")
+ if echo "${ENABLED}" | grep -q "${GNOME_EXT_UUID}"; then
+ echo " ✓ 扩展已在启用列表中"
+ else
+ echo " 添加到 enabled-extensions..."
+ NEW_LIST=$(echo "${ENABLED}" | sed 's/\]$/,"'"${GNOME_EXT_UUID}"'"]/')
+ gsettings set org.gnome.shell enabled-extensions "${NEW_LIST}" 2>/dev/null || {
+ echo " ⚠ 无法自动启用,请在「扩展」应用中手动启用"
+ }
+ fi
+else
+ echo " ⚠ 未找到扩展目录,跳过"
+fi
+
+echo ""
+echo "[2/3] Wayland evdev 权限(Wayland 用户可选)..."
+SESSION_TYPE="${XDG_SESSION_TYPE:-}"
+if [ "$SESSION_TYPE" = "wayland" ] && [ ! -w /dev/uinput ]; then
+ echo " 当前为 Wayland 会话,evdev 快捷键需要 input 组权限"
+ echo " 执行以下命令并重新登录:"
+ echo " sudo usermod -aG input $USER"
+ echo " 或创建 udev 规则:"
+ echo ' KERNEL=="uinput", MODE="0660", GROUP="input"'
+ echo ' KERNEL=="event*", SUBSYSTEM=="input", MODE="0660", GROUP="input"'
+else
+ echo " ✓ evdev 权限正常(X11 或已有 input 组访问)"
+fi
+
+echo ""
+echo "[3/3] Desktop Entry..."
+if [ -f "${SCRIPT_DIR}/${APP_NAME}.desktop" ]; then
+ if [ "$INSTALL_MODE" = "system" ]; then
+ DST="/usr/local/share/applications/${APP_NAME}.desktop"
+ sudo mkdir -p "$(dirname "${DST}")"
+ sudo cp "${SCRIPT_DIR}/${APP_NAME}.desktop" "${DST}"
+ sudo sed -i "s|^Exec=.*|Exec=${SCRIPT_DIR}/run.sh|" "${DST}"
+ echo " ✓ 已安装到 ${DST}"
+ else
+ DST="${HOME}/.local/share/applications/${APP_NAME}.desktop"
+ mkdir -p "$(dirname "${DST}")"
+ cp "${SCRIPT_DIR}/${APP_NAME}.desktop" "${DST}"
+ sed -i "s|^Exec=.*|Exec=${SCRIPT_DIR}/run.sh|" "${DST}"
+ echo " ✓ 已安装到 ${DST}"
+ fi
+else
+ echo " ⚠ 未找到 .desktop 文件"
+fi
+
+echo ""
+echo "============================================"
+echo " 安装完成"
+echo "============================================"
+echo ""
+echo "启动: ${SCRIPT_DIR}/run.sh 或从应用菜单搜索"
+echo ""
+echo "快捷键方案(自动检测,默认 Ctrl+Alt+C 可自定义):"
+echo " 1. X11: XGrabKey(零权限)"
+echo " 2. Wayland: evdev(需 input 组)"
+echo " 3. GNOME 扩展: D-Bus 信号(备用)"
+if echo "${ENABLED:-}" | grep -q "${GNOME_EXT_UUID}" 2>/dev/null; then
+ echo " 4. GNOME 扩展已启用"
+fi
+INSTALLSH
+chmod +x "${PKG_DIR}/install.sh"
+
+# --- run.sh ---
+cat > "${PKG_DIR}/run.sh" << 'RUNSH'
+#!/bin/bash
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+export LD_LIBRARY_PATH="${SCRIPT_DIR}/lib:${LD_LIBRARY_PATH}"
+export QT_QPA_PLATFORM_PLUGIN_PATH="${SCRIPT_DIR}/platforms"
+export XDG_DESKTOP_PORTAL_APP_ID="io.impress.voice-input"
+
+if [ -d "${SCRIPT_DIR}/gnome-extension" ] && \
+ [ ! -d "${HOME}/.local/share/gnome-shell/extensions/io.impress.voice-input-hotkey@impress" ]; then
+ echo "GNOME 用户推荐安装 CapsLock 快捷键扩展(一次即可):"
+ echo " ./install.sh"
+ echo ""
+fi
+
+exec "${SCRIPT_DIR}/impress_voice_input" "$@"
+RUNSH
+chmod +x "${PKG_DIR}/run.sh"
+
+# desktop entry
+cp "${DIST_DIR}/${PKG_NAME}/io.impress.voice-input.desktop" "${PKG_DIR}/" 2>/dev/null || true
+
+# 7. 打包
+echo "[6/6] 打包..."
+cd "${DIST_DIR}"
+tar czf "${PKG_NAME}.tar.gz" "${PKG_NAME}/"
+
+echo ""
+echo "发布包已生成: ${DIST_DIR}/${PKG_NAME}.tar.gz"
+echo "大小: $(du -h "${DIST_DIR}/${PKG_NAME}.tar.gz" | cut -f1)"
+echo ""
+echo "使用方式:"
+echo " tar xzf impress_voice_input_linux.tar.gz"
+echo " cd impress_voice_input_linux"
+echo " ./install.sh # 安装 GNOME 扩展 + 桌面入口"
+echo " ./run.sh # 启动应用"
diff --git a/src/core/caps_lock_backend.cpp b/src/core/caps_lock_backend.cpp
new file mode 100644
index 0000000..778bc78
--- /dev/null
+++ b/src/core/caps_lock_backend.cpp
@@ -0,0 +1,114 @@
+#include "caps_lock_backend.h"
+#include "utils/logger.h"
+
+#include
+#include
+#include
+
+#ifdef Q_OS_LINUX
+#include
+#include "caps_lock_xcb.h"
+#include "caps_lock_evdev.h"
+#endif
+
+static const char* const kTag = "HotkeyBackend";
+
+namespace impress {
+
+bool parseHotkeyCombo(const QString& combo, int& modifiers, int& key) {
+ if (combo.isEmpty() || combo == "未设置" || combo == "CapsLock") {
+ return false;
+ }
+
+ modifiers = 0;
+ key = 0;
+
+ QStringList parts = combo.split('+', Qt::SkipEmptyParts);
+ if (parts.isEmpty()) return false;
+
+ for (int i = 0; i < parts.size() - 1; i++) {
+ QString part = parts[i].trimmed().toLower();
+ if (part == "ctrl" || part == "control") modifiers |= Qt::ControlModifier;
+ else if (part == "alt") modifiers |= Qt::AltModifier;
+ else if (part == "shift") modifiers |= Qt::ShiftModifier;
+ else if (part == "meta" || part == "super" || part == "win") modifiers |= Qt::MetaModifier;
+ else {
+ LOG_WARNING(kTag, QString("未知的修饰键: %1").arg(part));
+ return false;
+ }
+ }
+
+ // 最后一个部分是主键
+ QString mainKey = parts.last().trimmed();
+ if (mainKey.length() == 1) {
+ // 单字母/数字
+ QChar ch = mainKey[0];
+ if (ch.isLetter()) {
+ key = Qt::Key_A + (ch.toUpper().unicode() - 'A');
+ } else if (ch.isDigit()) {
+ key = Qt::Key_0 + (ch.unicode() - '0');
+ }
+ } else if (mainKey.startsWith('F') && mainKey.size() <= 3) {
+ bool ok;
+ int num = mainKey.mid(1).toInt(&ok);
+ if (ok && num >= 1 && num <= 35) {
+ key = Qt::Key_F1 + (num - 1);
+ }
+ } else if (mainKey.compare("space", Qt::CaseInsensitive) == 0) {
+ key = Qt::Key_Space;
+ } else if (mainKey.compare("enter", Qt::CaseInsensitive) == 0) {
+ key = Qt::Key_Return;
+ } else if (mainKey.compare("return", Qt::CaseInsensitive) == 0) {
+ key = Qt::Key_Return;
+ } else if (mainKey.compare("escape", Qt::CaseInsensitive) == 0) {
+ key = Qt::Key_Escape;
+ } else if (mainKey.compare("tab", Qt::CaseInsensitive) == 0) {
+ key = Qt::Key_Tab;
+ } else if (mainKey.compare("backspace", Qt::CaseInsensitive) == 0) {
+ key = Qt::Key_Backspace;
+ } else if (mainKey.compare("delete", Qt::CaseInsensitive) == 0) {
+ key = Qt::Key_Delete;
+ } else {
+ LOG_WARNING(kTag, QString("未知的主键: %1").arg(mainKey));
+ return false;
+ }
+
+ return key != 0;
+}
+
+/**
+ * @brief 检测当前是否在 X11 下运行
+ */
+static bool isX11Session() {
+ const char* sessionType = getenv("XDG_SESSION_TYPE");
+ if (sessionType && std::strcmp(sessionType, "x11") == 0)
+ return true;
+
+ // 回退:检查 DISPLAY 环境变量
+ return getenv("DISPLAY") != nullptr;
+}
+
+CapsLockBackend* createCapsLockBackend(QObject* parent) {
+ // 1. X11 会话优先使用 XGrabKey(零权限)
+ if (isX11Session()) {
+#ifdef Q_OS_LINUX
+ return new XcbCapsLockBackend(parent);
+#else
+ Q_UNUSED(parent);
+ return nullptr;
+#endif
+ }
+
+ // 2. Wayland 会话尝试 evdev(需权限)
+#ifdef Q_OS_LINUX
+ // 先快速检查权限
+ if (access("/dev/uinput", W_OK) == 0) {
+ return new EvdevCapsLockBackend(parent);
+ }
+#endif
+
+ // 3. 没有可用的纯 C++ 后端
+ return nullptr;
+}
+
+} // namespace impress
diff --git a/src/core/caps_lock_backend.h b/src/core/caps_lock_backend.h
new file mode 100644
index 0000000..3fbd35e
--- /dev/null
+++ b/src/core/caps_lock_backend.h
@@ -0,0 +1,73 @@
+#pragma once
+
+#include
+#include
+
+namespace impress {
+
+/**
+ * @brief 解析快捷键组合字符串
+ *
+ * 支持格式: "Ctrl+Alt+C", "Ctrl+Shift+F1", "Alt+Space" 等
+ *
+ * @param combo 如 "Ctrl+Alt+C"
+ * @param modifiers 输出 Qt::KeyboardModifiers
+ * @param key 输出 Qt::Key(主键)
+ * @return 解析是否成功
+ */
+bool parseHotkeyCombo(const QString& combo, int& modifiers, int& key);
+
+/**
+ * @brief 全局快捷键抽象接口
+ *
+ * 不同后端实现:
+ * - X11: XCB XGrabKey(零权限)
+ * - Wayland: evdev grab + uinput replay(需 input 组 + /dev/uinput)
+ */
+class CapsLockBackend : public QObject {
+ Q_OBJECT
+public:
+ explicit CapsLockBackend(QObject* parent = nullptr) : QObject(parent) {}
+ virtual ~CapsLockBackend() = default;
+
+ /** @brief 注册全局快捷键 */
+ virtual bool start() = 0;
+
+ /** @brief 注销快捷键 */
+ virtual void stop() = 0;
+
+ /** @brief 是否正在工作 */
+ virtual bool isActive() const = 0;
+
+ /** @brief 后端名称(用于日志) */
+ virtual const char* backendName() const = 0;
+
+ /** @brief 设置快捷键组合(在 start() 前调用) */
+ virtual void setHotkeyCombo(int modifiers, int key) {
+ m_modifiers = modifiers;
+ m_key = key;
+ }
+
+ /** @brief 获取当前设置的修饰符 */
+ int modifiers() const { return m_modifiers; }
+
+ /** @brief 获取当前设置的主键 */
+ int key() const { return m_key; }
+
+signals:
+ void pressed();
+ void released();
+ void ready();
+ void error(const QString& message);
+
+protected:
+ int m_modifiers = 0;
+ int m_key = 0;
+};
+
+/**
+ * @brief 工厂:根据当前会话类型自动选择后端
+ */
+CapsLockBackend* createCapsLockBackend(QObject* parent = nullptr);
+
+} // namespace impress
diff --git a/src/core/caps_lock_evdev.cpp b/src/core/caps_lock_evdev.cpp
new file mode 100644
index 0000000..d675c58
--- /dev/null
+++ b/src/core/caps_lock_evdev.cpp
@@ -0,0 +1,426 @@
+#include "caps_lock_evdev.h"
+#include "utils/logger.h"
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static const char* const kTag = "EvdevCapsLockBackend";
+
+namespace impress {
+
+// Qt::Key → Linux evdev keycode 映射
+// 只映射常用键(字母、数字、功能键等)
+static int qtKeyToEvdevCode(int key) {
+ if (key >= Qt::Key_A && key <= Qt::Key_Z) {
+ return KEY_A + (key - Qt::Key_A);
+ }
+ if (key >= Qt::Key_0 && key <= Qt::Key_9) {
+ return KEY_0 + (key - Qt::Key_0);
+ }
+ if (key >= Qt::Key_F1 && key <= Qt::Key_F12) {
+ return KEY_F1 + (key - Qt::Key_F1);
+ }
+ if (key >= Qt::Key_F13 && key <= Qt::Key_F24) {
+ return KEY_F13 + (key - Qt::Key_F13);
+ }
+ switch (key) {
+ case Qt::Key_Space: return KEY_SPACE;
+ case Qt::Key_Return:
+ case Qt::Key_Enter: return KEY_ENTER;
+ case Qt::Key_Escape: return KEY_ESC;
+ case Qt::Key_Tab: return KEY_TAB;
+ case Qt::Key_Backspace: return KEY_BACKSPACE;
+ case Qt::Key_Delete: return KEY_DELETE;
+ case Qt::Key_Insert: return KEY_INSERT;
+ case Qt::Key_Home: return KEY_HOME;
+ case Qt::Key_End: return KEY_END;
+ case Qt::Key_PageUp: return KEY_PAGEUP;
+ case Qt::Key_PageDown: return KEY_PAGEDOWN;
+ case Qt::Key_Up: return KEY_UP;
+ case Qt::Key_Down: return KEY_DOWN;
+ case Qt::Key_Left: return KEY_LEFT;
+ case Qt::Key_Right: return KEY_RIGHT;
+ case Qt::Key_Comma: return KEY_COMMA;
+ case Qt::Key_Period: return KEY_DOT;
+ case Qt::Key_Slash: return KEY_SLASH;
+ case Qt::Key_Semicolon: return KEY_SEMICOLON;
+ case Qt::Key_BracketLeft: return KEY_LEFTBRACE;
+ case Qt::Key_BracketRight: return KEY_RIGHTBRACE;
+ case Qt::Key_Apostrophe: return KEY_APOSTROPHE;
+ case Qt::Key_Minus: return KEY_MINUS;
+ case Qt::Key_Equal: return KEY_EQUAL;
+ case Qt::Key_Backslash: return KEY_BACKSLASH;
+ default: return 0;
+ }
+}
+
+struct EvdevCapsLockBackend::Impl {
+ struct KeyboardDevice {
+ int fd = -1;
+ QString path;
+ bool grabbed = false;
+ };
+
+ int uinputFd = -1;
+ QList keyboards;
+ QTimer* retryTimer = nullptr;
+ int retryCount = 0;
+ static constexpr int MaxRetries = 3;
+
+ // 当前修饰键状态(跟踪所有键盘的修饰键)
+ bool ctrlPressed = false;
+ bool altPressed = false;
+ bool shiftPressed = false;
+ bool metaPressed = false;
+
+ // 快捷键配置
+ int targetKeycode = 0; // evdev keycode
+ int targetModifiers = 0; // Qt::KeyboardModifiers
+
+ /** 判断修饰键 */
+ static bool isModifierKey(uint16_t code) {
+ return code == KEY_LEFTCTRL || code == KEY_RIGHTCTRL ||
+ code == KEY_LEFTALT || code == KEY_RIGHTALT ||
+ code == KEY_LEFTSHIFT || code == KEY_RIGHTSHIFT ||
+ code == KEY_LEFTMETA || code == KEY_RIGHTMETA;
+ }
+
+ /** 获取当前合成修饰符状态 */
+ int currentModifiers() const {
+ int mods = 0;
+ if (ctrlPressed) mods |= Qt::ControlModifier;
+ if (altPressed) mods |= Qt::AltModifier;
+ if (shiftPressed) mods |= Qt::ShiftModifier;
+ if (metaPressed) mods |= Qt::MetaModifier;
+ return mods;
+ }
+
+ /** 判断是否为快捷键组合 */
+ bool isHotkeyCombo(uint16_t code) const {
+ if (code != (uint16_t)targetKeycode) return false;
+ return currentModifiers() == targetModifiers;
+ }
+
+ /** 判断设备是否为键盘 */
+ static bool isKeyboard(int fd) {
+ static constexpr size_t kEvdevBitArraySize = 64;
+ unsigned long evbits[kEvdevBitArraySize] = {};
+ if (ioctl(fd, EVIOCGBIT(0, sizeof(evbits)), evbits) < 0)
+ return false;
+
+ static constexpr size_t kEvKeyIdx = EV_KEY / (sizeof(unsigned long) * 8);
+ static constexpr int kEvKeyBit = EV_KEY % (sizeof(unsigned long) * 8);
+ if (!(evbits[kEvKeyIdx] & (1ULL << kEvKeyBit)))
+ return false;
+
+ bool hasAlpha = false;
+ for (int code = KEY_Q; code <= KEY_P; code++) {
+ unsigned long keybits[KEY_CNT / (sizeof(unsigned long) * 8)] = {};
+ if (ioctl(fd, EVIOCGBIT(EV_KEY, sizeof(keybits)), keybits) >= 0) {
+ if (keybits[code / (sizeof(unsigned long) * 8)] & (1ULL << (code % (sizeof(unsigned long) * 8)))) {
+ hasAlpha = true;
+ break;
+ }
+ }
+ }
+ return hasAlpha;
+ }
+
+ /** 查找所有键盘设备 */
+ QList findKeyboards() {
+ QList result;
+ QDir inputDir("/dev/input");
+ QStringList filters = {"event*"};
+ QFileInfoList files = inputDir.entryInfoList(filters, QDir::System | QDir::Readable);
+
+ for (const QFileInfo& fi : files) {
+ QString path = fi.absoluteFilePath();
+ int fd = open(qPrintable(path), O_RDONLY | O_NONBLOCK);
+ if (fd < 0) continue;
+
+ if (isKeyboard(fd)) {
+ KeyboardDevice dev;
+ dev.fd = fd;
+ dev.path = path;
+ result.append(dev);
+ LOG_DEBUG(kTag, QString("发现键盘: %1").arg(path));
+ } else {
+ close(fd);
+ }
+ }
+ return result;
+ }
+
+ /** 创建 uinput 虚拟设备 */
+ bool createUinputDevice() {
+ uinputFd = open("/dev/uinput", O_WRONLY | O_NONBLOCK);
+ if (uinputFd < 0) {
+ LOG_ERROR(kTag, "无法打开 /dev/uinput,需要 input 组或 udev 规则");
+ return false;
+ }
+
+ struct uinput_setup setup = {};
+ memset(&setup, 0, sizeof(setup));
+ strncpy(setup.name, "Impress Voice Input Virtual Keyboard", UINPUT_MAX_NAME_SIZE - 1);
+ setup.id.bustype = BUS_VIRTUAL;
+ setup.id.vendor = 0x1234;
+ setup.id.product = 0x5678;
+ setup.id.version = 1;
+
+ ioctl(uinputFd, UI_SET_EVBIT, EV_KEY);
+ for (int code = 0; code < KEY_CNT; code++) {
+ ioctl(uinputFd, UI_SET_KEYBIT, code);
+ }
+
+ if (ioctl(uinputFd, UI_DEV_SETUP, &setup) < 0) {
+ LOG_ERROR(kTag, "UI_DEV_SETUP 失败");
+ close(uinputFd);
+ uinputFd = -1;
+ return false;
+ }
+
+ if (ioctl(uinputFd, UI_DEV_CREATE) < 0) {
+ LOG_ERROR(kTag, "UI_DEV_CREATE 失败");
+ close(uinputFd);
+ uinputFd = -1;
+ return false;
+ }
+
+ LOG_INFO(kTag, "uinput 虚拟键盘已创建");
+ return true;
+ }
+
+ /** 抓取键盘设备 */
+ bool grabAllKeyboards() {
+ for (auto& dev : keyboards) {
+ struct input_event ev;
+ while (read(dev.fd, &ev, sizeof(ev)) > 0) {}
+
+ unsigned char keyState[KEY_MAX / 8 + 1];
+ int attempts = 0;
+ while (attempts < 50) {
+ memset(keyState, 0, sizeof(keyState));
+ if (ioctl(dev.fd, EVIOCGKEY(sizeof(keyState)), keyState) >= 0) {
+ bool allReleased = true;
+ for (size_t i = 0; i < sizeof(keyState); i++) {
+ if (keyState[i] != 0) {
+ allReleased = false;
+ break;
+ }
+ }
+ if (allReleased) break;
+ }
+ usleep(50000);
+ attempts++;
+ }
+
+ if (ioctl(dev.fd, EVIOCGRAB, 1) == 0) {
+ dev.grabbed = true;
+ LOG_INFO(kTag, QString("已抓取: %1").arg(dev.path));
+ } else {
+ LOG_WARNING(kTag, QString("抓取失败: %1").arg(dev.path));
+ }
+ }
+ return true;
+ }
+
+ /** 释放所有键盘抓取 */
+ void ungrabAllKeyboards() {
+ for (auto& dev : keyboards) {
+ if (dev.grabbed) {
+ ioctl(dev.fd, EVIOCGRAB, 0);
+ dev.grabbed = false;
+ }
+ }
+ }
+
+ /** 重放事件到 uinput */
+ void replayEvent(uint16_t type, uint16_t code, int32_t value) {
+ if (uinputFd < 0) return;
+ struct input_event ev;
+ memset(&ev, 0, sizeof(ev));
+ gettimeofday(&ev.time, nullptr);
+ ev.type = type;
+ ev.code = code;
+ ev.value = value;
+ write(uinputFd, &ev, sizeof(ev));
+ }
+
+ /** 处理单个键盘的事件 */
+ void handleKeyboardEvents(KeyboardDevice& dev, QObject* parent) {
+ struct input_event ev;
+ while (true) {
+ ssize_t n = read(dev.fd, &ev, sizeof(ev));
+ if (n < 0) break;
+
+ if (ev.type != EV_KEY) {
+ // 非按键事件,重放
+ replayEvent(ev.type, ev.code, ev.value);
+ continue;
+ }
+
+ // value: 1=按下, 0=松开, 2=自动重复
+ if (ev.value == 2) {
+ replayEvent(ev.type, ev.code, ev.value);
+ continue;
+ }
+
+ // 更新修饰键状态
+ switch (ev.code) {
+ case KEY_LEFTCTRL:
+ case KEY_RIGHTCTRL:
+ ctrlPressed = (ev.value == 1);
+ break;
+ case KEY_LEFTALT:
+ case KEY_RIGHTALT:
+ altPressed = (ev.value == 1);
+ break;
+ case KEY_LEFTSHIFT:
+ case KEY_RIGHTSHIFT:
+ shiftPressed = (ev.value == 1);
+ break;
+ case KEY_LEFTMETA:
+ case KEY_RIGHTMETA:
+ metaPressed = (ev.value == 1);
+ break;
+ }
+
+ // 判断是否为配置的快捷键
+ if (isHotkeyCombo(ev.code)) {
+ // 快捷键组合 — 不重放(抑制)
+ if (ev.value == 1) {
+ LOG_DEBUG(kTag, "快捷键按下 (evdev)");
+ QMetaObject::invokeMethod(parent, "emitPressed", Qt::QueuedConnection);
+ } else {
+ LOG_DEBUG(kTag, "快捷键松开 (evdev)");
+ QMetaObject::invokeMethod(parent, "emitReleased", Qt::QueuedConnection);
+ }
+ } else if (isModifierKey(ev.code)) {
+ // 单独的修饰键 — 正常重放
+ replayEvent(ev.type, ev.code, ev.value);
+ } else {
+ // 其他按键 — 重放到虚拟设备
+ replayEvent(ev.type, ev.code, ev.value);
+ }
+ }
+ }
+};
+
+EvdevCapsLockBackend::EvdevCapsLockBackend(QObject* parent)
+ : CapsLockBackend(parent)
+ , m_impl(std::make_unique())
+{}
+
+EvdevCapsLockBackend::~EvdevCapsLockBackend() {
+ stop();
+}
+
+void EvdevCapsLockBackend::setHotkeyCombo(int modifiers, int key) {
+ CapsLockBackend::setHotkeyCombo(modifiers, key);
+
+ int evdevCode = qtKeyToEvdevCode(key);
+ m_impl->targetKeycode = evdevCode;
+ m_impl->targetModifiers = modifiers;
+
+ if (m_active) {
+ stop();
+ start();
+ }
+}
+
+bool EvdevCapsLockBackend::start() {
+ if (m_active) return true;
+
+ // 检查快捷键配置
+ int evdevCode = qtKeyToEvdevCode(m_key);
+ if (evdevCode == 0) {
+ LOG_ERROR(kTag, QString("不支持的按键 (key=0x%1)").arg(m_key, 0, 16));
+ emit error("不支持的按键,请使用字母、数字或功能键");
+ return false;
+ }
+ m_impl->targetKeycode = evdevCode;
+ m_impl->targetModifiers = m_modifiers;
+
+ LOG_INFO(kTag, QString("快捷键配置: keycode=%1, modifiers=0x%2").arg(evdevCode).arg(m_modifiers, 0, 16));
+
+ // 检查 /dev/uinput 权限
+ if (access("/dev/uinput", W_OK) != 0) {
+ LOG_ERROR(kTag, "/dev/uinput 不可写。请执行以下操作之一:\n"
+ " 1. sudo usermod -aG input $USER(然后重新登录)\n"
+ " 2. 创建 /etc/udev/rules.d/99-impress-keyboard.rules:\n"
+ " KERNEL==\"uinput\", MODE=\"0660\", GROUP=\"input\"\n"
+ " KERNEL==\"event*\", SUBSYSTEM==\"input\", MODE=\"0660\", GROUP=\"input\"");
+ emit error("/dev/uinput 权限不足,请配置 evdev 访问权限");
+ return false;
+ }
+
+ // 查找键盘
+ m_impl->keyboards = m_impl->findKeyboards();
+ if (m_impl->keyboards.isEmpty()) {
+ LOG_ERROR(kTag, "未找到键盘设备");
+ return false;
+ }
+
+ LOG_INFO(kTag, QString("找到 %1 个键盘设备").arg(m_impl->keyboards.size()));
+
+ // 创建 uinput 虚拟设备
+ if (!m_impl->createUinputDevice()) {
+ return false;
+ }
+
+ // 抓取所有键盘
+ m_impl->grabAllKeyboards();
+
+ // 为每个键盘创建 QSocketNotifier
+ for (auto& dev : m_impl->keyboards) {
+ if (!dev.grabbed) continue;
+ auto* notifier = new QSocketNotifier(dev.fd, QSocketNotifier::Read, this);
+ connect(notifier, &QSocketNotifier::activated, this, [this, &dev]() {
+ m_impl->handleKeyboardEvents(dev, this);
+ });
+ }
+
+ m_active = true;
+ emit ready();
+ LOG_INFO(kTag, "全局快捷键已注册(evdev)");
+ return true;
+}
+
+void EvdevCapsLockBackend::stop() {
+ if (!m_active) return;
+
+ m_impl->ungrabAllKeyboards();
+
+ if (m_impl->uinputFd >= 0) {
+ ioctl(m_impl->uinputFd, UI_DEV_DESTROY);
+ close(m_impl->uinputFd);
+ m_impl->uinputFd = -1;
+ }
+
+ for (auto& dev : m_impl->keyboards) {
+ if (dev.fd >= 0) {
+ close(dev.fd);
+ dev.fd = -1;
+ }
+ }
+ m_impl->keyboards.clear();
+ m_impl->ctrlPressed = false;
+ m_impl->altPressed = false;
+ m_impl->shiftPressed = false;
+ m_impl->metaPressed = false;
+
+ m_active = false;
+ LOG_INFO(kTag, "全局快捷键已注销(evdev)");
+}
+
+} // namespace impress
diff --git a/src/core/caps_lock_evdev.h b/src/core/caps_lock_evdev.h
new file mode 100644
index 0000000..d2a152d
--- /dev/null
+++ b/src/core/caps_lock_evdev.h
@@ -0,0 +1,45 @@
+#pragma once
+
+#include "caps_lock_backend.h"
+
+#include
+#include
+
+namespace impress {
+
+/**
+ * @brief Wayland 后端:通过 evdev 直接读取键盘设备
+ *
+ * 使用 EVIOCGRAB 抓取键盘 + uinput 重放非快捷键事件。
+ * 需要 input 组权限或 udev 规则。
+ *
+ * 权限设置(一次性):
+ * sudo usermod -aG input $USER
+ * 或创建 /etc/udev/rules.d/99-impress-keyboard.rules:
+ * KERNEL=="event*", SUBSYSTEM=="input", MODE="0660", GROUP="input"
+ * KERNEL=="uinput", MODE="0660", GROUP="input"
+ */
+class EvdevCapsLockBackend : public CapsLockBackend {
+ Q_OBJECT
+public:
+ explicit EvdevCapsLockBackend(QObject* parent = nullptr);
+ ~EvdevCapsLockBackend() override;
+
+ bool start() override;
+ void stop() override;
+ bool isActive() const override { return m_active; }
+ const char* backendName() const override { return "evdev"; }
+
+ void setHotkeyCombo(int modifiers, int key) override;
+
+public slots:
+ void emitPressed() { emit pressed(); }
+ void emitReleased() { emit released(); }
+
+private:
+ struct Impl;
+ std::unique_ptr m_impl;
+ bool m_active = false;
+};
+
+} // namespace impress
diff --git a/src/core/caps_lock_xcb.cpp b/src/core/caps_lock_xcb.cpp
new file mode 100644
index 0000000..5a7a178
--- /dev/null
+++ b/src/core/caps_lock_xcb.cpp
@@ -0,0 +1,203 @@
+#include "caps_lock_xcb.h"
+#include "utils/logger.h"
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+static const char* const kTag = "XcbCapsLockBackend";
+
+namespace impress {
+
+XcbCapsLockBackend::XcbCapsLockBackend(QObject* parent)
+ : CapsLockBackend(parent)
+{}
+
+XcbCapsLockBackend::~XcbCapsLockBackend() {
+ stop();
+}
+
+void XcbCapsLockBackend::setHotkeyCombo(int modifiers, int key) {
+ CapsLockBackend::setHotkeyCombo(modifiers, key);
+
+ // 如果已经在运行,需要重新注册
+ if (m_active) {
+ stop();
+ start();
+ }
+}
+
+bool XcbCapsLockBackend::start() {
+ if (m_active) return true;
+
+ // 获取 X11 连接(需要 QGuiApplication 类型的指针来访问 nativeInterface)
+ auto* guiApp = dynamic_cast(QCoreApplication::instance());
+ if (!guiApp) return false;
+
+ auto* x11App = guiApp->nativeInterface();
+ if (!x11App) {
+ LOG_INFO(kTag, "非 X11 会话,XGrabKey 不可用");
+ return false;
+ }
+
+ m_conn = x11App->connection();
+ if (!m_conn) {
+ LOG_ERROR(kTag, "无法获取 XCB 连接");
+ return false;
+ }
+
+ // 获取根窗口(通过 XCB setup 信息)
+ auto* setup = xcb_get_setup(m_conn);
+ if (!setup || setup->roots_len == 0) {
+ LOG_ERROR(kTag, "无法获取 XCB 根窗口");
+ return false;
+ }
+ m_rootWindow = xcb_setup_roots_iterator(setup).data->root;
+
+ // 获取按键 keycode(XCB 没有直接的 keysym->keycode,用 Xlib 获取)
+ Display* dpy = XOpenDisplay(nullptr);
+ if (!dpy) {
+ LOG_ERROR(kTag, "无法打开 X Display");
+ return false;
+ }
+ m_keycode = XKeysymToKeycode(dpy, KeySym(m_key));
+ XCloseDisplay(dpy);
+
+ if (m_keycode == 0) {
+ LOG_ERROR(kTag, QString("无法获取按键 keycode (key=0x%1)").arg(m_key, 0, 16));
+ return false;
+ }
+
+ LOG_INFO(kTag, QString("快捷键 keycode: %1 (modifiers=0x%2)").arg(m_keycode).arg(m_modifiers, 0, 16));
+
+ // 抓取快捷键
+ if (!grabKey()) {
+ return false;
+ }
+
+ // 安装 native event filter
+ QAbstractEventDispatcher::instance()->installNativeEventFilter(this);
+
+ m_active = true;
+ emit ready();
+ LOG_INFO(kTag, "全局快捷键已注册(X11-XGrabKey)");
+ return true;
+}
+
+void XcbCapsLockBackend::stop() {
+ if (!m_active) return;
+
+ // 移除 native event filter
+ if (QAbstractEventDispatcher* dispatcher = QAbstractEventDispatcher::instance()) {
+ dispatcher->removeNativeEventFilter(this);
+ }
+
+ ungrabKey();
+ m_active = false;
+ LOG_INFO(kTag, "全局快捷键已注销(X11)");
+}
+
+bool XcbCapsLockBackend::grabKey() {
+ // 需要用所有 modifier 组合来抓取
+ // 包括: 无修饰、NumLock(Mask2)、Lock(Mask1)、Mode_switch(Mask5) 等
+ const uint16_t masks[] = {
+ 0, // 无修饰
+ XCB_MOD_MASK_2, // NumLock
+ XCB_MOD_MASK_1, // Lock
+ XCB_MOD_MASK_1 | XCB_MOD_MASK_2, // Lock + NumLock
+ XCB_MOD_MASK_5, // Mode_switch
+ XCB_MOD_MASK_2 | XCB_MOD_MASK_5, // NumLock + Mode_switch
+ XCB_MOD_MASK_1 | XCB_MOD_MASK_5, // Lock + Mode_switch
+ XCB_MOD_MASK_1 | XCB_MOD_MASK_2 | XCB_MOD_MASK_5, // 全部
+ };
+
+ // Qt modifier → XCB modifier mask
+ uint16_t baseMask = 0;
+ if (m_modifiers & Qt::ShiftModifier) baseMask |= XCB_MOD_MASK_SHIFT;
+ if (m_modifiers & Qt::ControlModifier) baseMask |= XCB_MOD_MASK_CONTROL;
+ if (m_modifiers & Qt::AltModifier) baseMask |= XCB_MOD_MASK_1; // Alt is typically Mod1
+ if (m_modifiers & Qt::MetaModifier) baseMask |= XCB_MOD_MASK_4; // Meta is typically Mod4 (Super)
+
+ for (uint16_t mask : masks) {
+ uint16_t combined = baseMask | mask;
+
+ xcb_void_cookie_t cookie = xcb_grab_key_checked(
+ m_conn,
+ 1, // owner_events = true
+ m_rootWindow, // 在根窗口上抓取
+ combined, // 修饰掩码
+ m_keycode, // 按键码
+ XCB_GRAB_MODE_ASYNC, // pointer mode
+ XCB_GRAB_MODE_ASYNC // keyboard mode
+ );
+
+ xcb_generic_error_t* error = xcb_request_check(m_conn, cookie);
+ if (error) {
+ // 某些 modifier 组合可能已被其他程序占用,忽略
+ LOG_DEBUG(kTag, QString("grab modifier %1 失败 (code=%2)")
+ .arg(combined).arg(error->error_code));
+ free(error);
+ }
+ }
+
+ return true;
+}
+
+void XcbCapsLockBackend::ungrabKey() {
+ if (!m_conn || !m_keycode) return;
+
+ const uint16_t masks[] = {
+ 0,
+ XCB_MOD_MASK_2,
+ XCB_MOD_MASK_1,
+ XCB_MOD_MASK_1 | XCB_MOD_MASK_2,
+ XCB_MOD_MASK_5,
+ XCB_MOD_MASK_2 | XCB_MOD_MASK_5,
+ XCB_MOD_MASK_1 | XCB_MOD_MASK_5,
+ XCB_MOD_MASK_1 | XCB_MOD_MASK_2 | XCB_MOD_MASK_5,
+ };
+
+ uint16_t baseMask = 0;
+ if (m_modifiers & Qt::ShiftModifier) baseMask |= XCB_MOD_MASK_SHIFT;
+ if (m_modifiers & Qt::ControlModifier) baseMask |= XCB_MOD_MASK_CONTROL;
+ if (m_modifiers & Qt::AltModifier) baseMask |= XCB_MOD_MASK_1;
+ if (m_modifiers & Qt::MetaModifier) baseMask |= XCB_MOD_MASK_4;
+
+ for (uint16_t mask : masks) {
+ xcb_ungrab_key(m_conn, m_keycode, m_rootWindow, baseMask | mask);
+ }
+ xcb_flush(m_conn);
+}
+
+bool XcbCapsLockBackend::nativeEventFilter(const QByteArray& eventType,
+ void* message, qintptr*) {
+ if (!m_active || eventType != "xcb_generic_event_t")
+ return false;
+
+ auto* ev = static_cast(message);
+ uint8_t responseType = ev->response_type & ~0x80;
+
+ if (responseType == XCB_KEY_PRESS) {
+ auto* kev = reinterpret_cast(ev);
+ if (kev->detail == m_keycode) {
+ LOG_DEBUG(kTag, "快捷键按下 (X11)");
+ emit pressed();
+ return true; // 阻止事件传播
+ }
+ } else if (responseType == XCB_KEY_RELEASE) {
+ auto* kev = reinterpret_cast(ev);
+ if (kev->detail == m_keycode) {
+ LOG_DEBUG(kTag, "快捷键松开 (X11)");
+ emit released();
+ return true; // 阻止事件传播
+ }
+ }
+
+ return false;
+}
+
+} // namespace impress
diff --git a/src/core/caps_lock_xcb.h b/src/core/caps_lock_xcb.h
new file mode 100644
index 0000000..38fab1b
--- /dev/null
+++ b/src/core/caps_lock_xcb.h
@@ -0,0 +1,46 @@
+#pragma once
+
+#include "caps_lock_backend.h"
+
+#include
+#include
+
+struct xcb_connection_t;
+
+typedef unsigned int xcb_window_t;
+typedef unsigned char xcb_keycode_t;
+
+namespace impress {
+
+/**
+ * @brief X11 后端:使用 XCB XGrabKey 捕获全局快捷键
+ *
+ * 零权限,在 X11 下工作良好。Wayland 下不可用。
+ * 支持可配置的快捷键组合(如 Ctrl+Alt+C)。
+ */
+class XcbCapsLockBackend : public CapsLockBackend, public QAbstractNativeEventFilter {
+public:
+ explicit XcbCapsLockBackend(QObject* parent = nullptr);
+ ~XcbCapsLockBackend() override;
+
+ bool start() override;
+ void stop() override;
+ bool isActive() const override { return m_active; }
+ const char* backendName() const override { return "X11-XGrabKey"; }
+
+ void setHotkeyCombo(int modifiers, int key) override;
+
+ bool nativeEventFilter(const QByteArray& eventType, void* message,
+ qintptr* result) override;
+
+private:
+ bool grabKey();
+ void ungrabKey();
+
+ xcb_connection_t* m_conn = nullptr;
+ xcb_window_t m_rootWindow = 0;
+ xcb_keycode_t m_keycode = 0;
+ bool m_active = false;
+};
+
+} // namespace impress
diff --git a/src/ui/widgets/recording_indicator.cpp b/src/ui/widgets/recording_indicator.cpp
new file mode 100644
index 0000000..3f64d8b
--- /dev/null
+++ b/src/ui/widgets/recording_indicator.cpp
@@ -0,0 +1,131 @@
+#include "recording_indicator.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace impress {
+
+struct RecordingIndicator::Impl {
+ QLabel* iconLabel = nullptr;
+ QLabel* textLabel = nullptr;
+ QTimer* blinkTimer = nullptr;
+ bool iconBlinkState = true;
+
+ /** 创建录音中图标(红色三角形 + 脉冲动画) */
+ QPixmap createRecordingIcon(int size) {
+ QPixmap pixmap(size, size);
+ pixmap.fill(Qt::transparent);
+ QPainter painter(&pixmap);
+ painter.setRenderHint(QPainter::Antialiasing);
+
+ const int margin = size / 8;
+ const int s = size - margin * 2;
+
+ // 红色圆点(背景脉冲)
+ painter.setPen(Qt::NoPen);
+ QColor dotColor = iconBlinkState ? QColor(231, 76, 60) : QColor(192, 57, 43);
+ painter.setBrush(dotColor);
+ painter.drawEllipse(QPoint(size / 2, size / 2), s / 2, s / 2);
+
+ // 白色三角形(播放图标)
+ painter.setBrush(Qt::white);
+ const int triMargin = size / 3;
+ QPolygon triangle;
+ triangle << QPoint(triMargin, triMargin)
+ << QPoint(triMargin, size - triMargin)
+ << QPoint(size - triMargin, size / 2);
+ painter.drawPolygon(triangle);
+
+ return pixmap;
+ }
+};
+
+RecordingIndicator::RecordingIndicator(QWidget* parent)
+ : QWidget(parent, Qt::FramelessWindowHint | Qt::Tool | Qt::WindowStaysOnTopHint | Qt::NoDropShadowWindowHint)
+ , impl_(std::make_unique())
+{
+ setAttribute(Qt::WA_TranslucentBackground);
+ setAttribute(Qt::WA_ShowWithoutActivating);
+
+ createUI();
+}
+
+RecordingIndicator::~RecordingIndicator() = default;
+
+void RecordingIndicator::createUI() {
+ auto* layout = new QHBoxLayout(this);
+ layout->setContentsMargins(6, 4, 6, 4);
+ layout->setSpacing(6);
+
+ impl_->iconLabel = new QLabel(this);
+ impl_->iconLabel->setFixedSize(24, 24);
+ layout->addWidget(impl_->iconLabel);
+
+ impl_->textLabel = new QLabel("录音中...", this);
+ impl_->textLabel->setStyleSheet(
+ "QLabel { color: #e74c3c; font-size: 13px; font-weight: bold; }");
+ layout->addWidget(impl_->textLabel);
+
+ // 闪烁动画
+ impl_->blinkTimer = new QTimer(this);
+ impl_->blinkTimer->setInterval(500);
+ connect(impl_->blinkTimer, &QTimer::timeout, this, [this]() {
+ impl_->iconBlinkState = !impl_->iconBlinkState;
+ updateIcon(true);
+ });
+}
+
+void RecordingIndicator::updateIcon(bool) {
+ if (impl_->iconLabel) {
+ impl_->iconLabel->setPixmap(impl_->createRecordingIcon(24));
+ }
+}
+
+void RecordingIndicator::showAtCursor() {
+ if (isShowing_) return;
+
+ QPoint pos = cursorGlobalPosition();
+
+ // 偏移:显示在光标右下方
+ pos += QPoint(16, 16);
+
+ // 确保不超出屏幕
+ QScreen* screen = QApplication::screenAt(pos);
+ if (!screen) screen = QApplication::primaryScreen();
+
+ QRect geo = screen->availableGeometry();
+ if (pos.x() + width() > geo.right()) {
+ pos.setX(geo.right() - width());
+ }
+ if (pos.y() + height() > geo.bottom()) {
+ pos.setY(geo.bottom() - height());
+ }
+
+ move(pos);
+ updateIcon(true);
+ show();
+ isShowing_ = true;
+
+ // 启动闪烁
+ impl_->blinkTimer->start();
+}
+
+void RecordingIndicator::hide() {
+ if (!isShowing_) return;
+
+ impl_->blinkTimer->stop();
+ QWidget::hide();
+ isShowing_ = false;
+}
+
+QPoint RecordingIndicator::cursorGlobalPosition() const {
+ return QCursor::pos();
+}
+
+} // namespace impress
diff --git a/src/ui/widgets/recording_indicator.h b/src/ui/widgets/recording_indicator.h
new file mode 100644
index 0000000..84a549f
--- /dev/null
+++ b/src/ui/widgets/recording_indicator.h
@@ -0,0 +1,41 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+namespace impress {
+
+/**
+ * @brief 录音指示器 — 光标位置浮动窗口
+ *
+ * 快捷键触发时在鼠标光标位置显示一个小的录音指示器,
+ * 使用和系统托盘一致的动态图标(播放三角形 = 录音中)。
+ */
+class RecordingIndicator : public QWidget {
+ Q_OBJECT
+public:
+ explicit RecordingIndicator(QWidget* parent = nullptr);
+ ~RecordingIndicator() override;
+
+ /** @brief 在鼠标位置显示指示器 */
+ void showAtCursor();
+
+ /** @brief 隐藏指示器 */
+ void hide();
+
+ /** @brief 是否正在显示 */
+ bool isVisible() const { return isShowing_; }
+
+private:
+ void createUI();
+ void updateIcon(bool active);
+ QPoint cursorGlobalPosition() const;
+
+ struct Impl;
+ std::unique_ptr impl_;
+ bool isShowing_ = false;
+};
+
+} // namespace impress