Compare commits
3 Commits
82cd4112f5
...
5e0651453c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e0651453c | ||
|
|
2c8b2a8ad9 | ||
|
|
20bc24a028 |
@ -79,6 +79,7 @@ set(SOURCES
|
|||||||
src/ui/widgets/text_output.cpp
|
src/ui/widgets/text_output.cpp
|
||||||
src/ui/widgets/progress_panel.cpp
|
src/ui/widgets/progress_panel.cpp
|
||||||
src/ui/widgets/hotkey_recorder.cpp
|
src/ui/widgets/hotkey_recorder.cpp
|
||||||
|
src/ui/widgets/recording_indicator.cpp
|
||||||
|
|
||||||
# Utils
|
# Utils
|
||||||
src/utils/logger.cpp
|
src/utils/logger.cpp
|
||||||
@ -114,6 +115,7 @@ set(HEADERS
|
|||||||
src/ui/widgets/text_output.h
|
src/ui/widgets/text_output.h
|
||||||
src/ui/widgets/progress_panel.h
|
src/ui/widgets/progress_panel.h
|
||||||
src/ui/widgets/hotkey_recorder.h
|
src/ui/widgets/hotkey_recorder.h
|
||||||
|
src/ui/widgets/recording_indicator.h
|
||||||
|
|
||||||
src/utils/logger.h
|
src/utils/logger.h
|
||||||
src/utils/timer.h
|
src/utils/timer.h
|
||||||
@ -134,9 +136,21 @@ elseif(APPLE)
|
|||||||
list(APPEND HEADERS src/core/mac_hotkey.h src/core/mac_text_injector.h)
|
list(APPEND HEADERS src/core/mac_hotkey.h src/core/mac_text_injector.h)
|
||||||
add_compile_definitions(PLATFORM_MACOS)
|
add_compile_definitions(PLATFORM_MACOS)
|
||||||
else()
|
else()
|
||||||
# Linux 实现(libei + D-Bus Portal + XTest)
|
# Linux 实现(X11 XGrabKey / evdev / libei / D-Bus Portal)
|
||||||
list(APPEND SOURCES src/core/caps_lock_voice_hotkey.cpp src/core/wayland_text_injector.cpp)
|
list(APPEND SOURCES
|
||||||
list(APPEND HEADERS src/core/caps_lock_voice_hotkey.h src/core/wayland_text_injector.h)
|
src/core/caps_lock_voice_hotkey.cpp
|
||||||
|
src/core/caps_lock_backend.cpp
|
||||||
|
src/core/caps_lock_xcb.cpp
|
||||||
|
src/core/caps_lock_evdev.cpp
|
||||||
|
src/core/wayland_text_injector.cpp
|
||||||
|
)
|
||||||
|
list(APPEND HEADERS
|
||||||
|
src/core/caps_lock_voice_hotkey.h
|
||||||
|
src/core/caps_lock_backend.h
|
||||||
|
src/core/caps_lock_xcb.h
|
||||||
|
src/core/caps_lock_evdev.h
|
||||||
|
src/core/wayland_text_injector.h
|
||||||
|
)
|
||||||
add_compile_definitions(PLATFORM_LINUX)
|
add_compile_definitions(PLATFORM_LINUX)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
@ -200,6 +214,26 @@ if(NOT WIN32 AND NOT APPLE)
|
|||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Linux: X11 全局快捷键(XCB XGrabKey)
|
||||||
|
# ============================================================================
|
||||||
|
if(NOT WIN32 AND NOT APPLE)
|
||||||
|
find_package(PkgConfig QUIET)
|
||||||
|
if(PKG_CONFIG_FOUND)
|
||||||
|
pkg_check_modules(XCB QUIET xcb)
|
||||||
|
pkg_check_modules(X11 QUIET x11)
|
||||||
|
endif()
|
||||||
|
if(XCB_FOUND AND X11_FOUND)
|
||||||
|
message(STATUS "找到 XCB: ${XCB_LIBDIR}")
|
||||||
|
message(STATUS "找到 X11: ${X11_LIBDIR}")
|
||||||
|
target_include_directories(${PROJECT_NAME} PRIVATE ${XCB_INCLUDE_DIRS} ${X11_INCLUDE_DIRS})
|
||||||
|
target_link_libraries(${PROJECT_NAME} PRIVATE ${XCB_LIBRARIES} ${X11_LIBRARIES})
|
||||||
|
add_compile_definitions(HAVE_XCB)
|
||||||
|
else()
|
||||||
|
message(STATUS "XCB/X11 未找到,X11 全局快捷键不可用")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Windows DLL 复制(交叉编译时自动拷贝到输出目录)
|
# Windows DLL 复制(交叉编译时自动拷贝到输出目录)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -239,6 +273,17 @@ install(TARGETS ${PROJECT_NAME}
|
|||||||
BUNDLE DESTINATION .
|
BUNDLE DESTINATION .
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# GNOME Shell 扩展
|
||||||
|
if(NOT WIN32 AND NOT APPLE)
|
||||||
|
set(GNOME_EXT_UUID "io.impress.voice-input-hotkey@impress")
|
||||||
|
set(GNOME_EXT_SRC "${CMAKE_CURRENT_SOURCE_DIR}/gnome-extension/${GNOME_EXT_UUID}")
|
||||||
|
set(GNOME_EXT_DST "${CMAKE_INSTALL_PREFIX}/share/gnome-shell/extensions/${GNOME_EXT_UUID}")
|
||||||
|
|
||||||
|
install(FILES "${GNOME_EXT_SRC}/extension.js"
|
||||||
|
"${GNOME_EXT_SRC}/metadata.json"
|
||||||
|
DESTINATION share/gnome-shell/extensions/${GNOME_EXT_UUID})
|
||||||
|
endif()
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 测试
|
# 测试
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@ -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 = `
|
||||||
|
<node>
|
||||||
|
<interface name="io.impress.VoiceInputHotkey">
|
||||||
|
<signal name="CapsLockPressed"/>
|
||||||
|
<signal name="CapsLockReleased"/>
|
||||||
|
</interface>
|
||||||
|
</node>`;
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
218
package-linux.sh
Executable file
218
package-linux.sh
Executable file
@ -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 # 启动应用"
|
||||||
@ -96,7 +96,7 @@ void ConfigManager::loadDefaults() {
|
|||||||
{"show_confidence", true}
|
{"show_confidence", true}
|
||||||
}},
|
}},
|
||||||
{"shortcuts", QVariantMap{
|
{"shortcuts", QVariantMap{
|
||||||
{"voice_hotkey", "CapsLock"}
|
{"voice_hotkey", "Ctrl+Alt+C"}
|
||||||
}}
|
}}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
114
src/core/caps_lock_backend.cpp
Normal file
114
src/core/caps_lock_backend.cpp
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
#include "caps_lock_backend.h"
|
||||||
|
#include "utils/logger.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#ifdef Q_OS_LINUX
|
||||||
|
#include <unistd.h>
|
||||||
|
#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
|
||||||
73
src/core/caps_lock_backend.h
Normal file
73
src/core/caps_lock_backend.h
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
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
|
||||||
426
src/core/caps_lock_evdev.cpp
Normal file
426
src/core/caps_lock_evdev.cpp
Normal file
@ -0,0 +1,426 @@
|
|||||||
|
#include "caps_lock_evdev.h"
|
||||||
|
#include "utils/logger.h"
|
||||||
|
|
||||||
|
#include <QSocketNotifier>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <cstring>
|
||||||
|
#include <sys/ioctl.h>
|
||||||
|
#include <linux/input.h>
|
||||||
|
#include <linux/input-event-codes.h>
|
||||||
|
#include <linux/uinput.h>
|
||||||
|
|
||||||
|
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<KeyboardDevice> 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<KeyboardDevice> findKeyboards() {
|
||||||
|
QList<KeyboardDevice> 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<Impl>())
|
||||||
|
{}
|
||||||
|
|
||||||
|
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
|
||||||
45
src/core/caps_lock_evdev.h
Normal file
45
src/core/caps_lock_evdev.h
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "caps_lock_backend.h"
|
||||||
|
|
||||||
|
#include <QThread>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
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<Impl> m_impl;
|
||||||
|
bool m_active = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace impress
|
||||||
@ -1,7 +1,9 @@
|
|||||||
#include "caps_lock_voice_hotkey.h"
|
#include "caps_lock_voice_hotkey.h"
|
||||||
|
#include "caps_lock_backend.h"
|
||||||
#include "utils/logger.h"
|
#include "utils/logger.h"
|
||||||
|
|
||||||
#include <QSocketNotifier>
|
#include <QSocketNotifier>
|
||||||
|
#include <QProcessEnvironment>
|
||||||
|
|
||||||
#ifdef HAVE_LIBEI
|
#ifdef HAVE_LIBEI
|
||||||
extern "C" {
|
extern "C" {
|
||||||
@ -23,8 +25,10 @@ static const char* const kPortalObjectPath = "/org/freedesktop/portal/desktop";
|
|||||||
static const char* const kGlobalShortcutsIface = "org.freedesktop.portal.GlobalShortcuts";
|
static const char* const kGlobalShortcutsIface = "org.freedesktop.portal.GlobalShortcuts";
|
||||||
static const char* const kRequestIface = "org.freedesktop.portal.Request";
|
static const char* const kRequestIface = "org.freedesktop.portal.Request";
|
||||||
|
|
||||||
// Linux CapsLock keycode (scan code)
|
// GNOME 扩展 D-Bus 接口
|
||||||
static const uint32_t kCapsLockKeycode = 58;
|
static const char* const kGnomeHotkeyService = "io.impress.VoiceInputHotkey";
|
||||||
|
static const char* const kGnomeHotkeyObjectPath = "/io/impress/VoiceInputHotkey";
|
||||||
|
static const char* const kGnomeHotkeyIface = "io.impress.VoiceInputHotkey";
|
||||||
|
|
||||||
namespace impress {
|
namespace impress {
|
||||||
|
|
||||||
@ -33,9 +37,13 @@ struct CapsLockVoiceHotkey::Impl {
|
|||||||
QString sessionPath;
|
QString sessionPath;
|
||||||
QString pendingRequestPath;
|
QString pendingRequestPath;
|
||||||
|
|
||||||
enum State { Idle, WaitingSession, WaitingBind, Active };
|
enum State { Idle, WaitingSession, WaitingBind, Active, GnomeActive, LibeiActive };
|
||||||
State state = Idle;
|
State state = Idle;
|
||||||
|
|
||||||
|
// 快捷键配置
|
||||||
|
int modifiers = 0;
|
||||||
|
int key = 0;
|
||||||
|
|
||||||
// libei 相关
|
// libei 相关
|
||||||
#ifdef HAVE_LIBEI
|
#ifdef HAVE_LIBEI
|
||||||
struct ei* eiCtx = nullptr;
|
struct ei* eiCtx = nullptr;
|
||||||
@ -65,56 +73,200 @@ struct CapsLockVoiceHotkey::Impl {
|
|||||||
CapsLockVoiceHotkey::CapsLockVoiceHotkey(QObject* parent)
|
CapsLockVoiceHotkey::CapsLockVoiceHotkey(QObject* parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
, impl_(std::make_unique<Impl>())
|
, impl_(std::make_unique<Impl>())
|
||||||
{}
|
{
|
||||||
|
hotkeyCombo_ = "Ctrl+Alt+C";
|
||||||
|
}
|
||||||
|
|
||||||
CapsLockVoiceHotkey::~CapsLockVoiceHotkey() {
|
CapsLockVoiceHotkey::~CapsLockVoiceHotkey() {
|
||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool CapsLockVoiceHotkey::start() {
|
void CapsLockVoiceHotkey::setHotkeyCombo(const QString& combo) {
|
||||||
if (active_) return true;
|
hotkeyCombo_ = combo;
|
||||||
|
|
||||||
#ifdef HAVE_LIBEI
|
// 如果原生后端已注册,更新它
|
||||||
// 优先尝试 libei(GNOME 47+ 推荐方案)
|
if (nativeBackend_) {
|
||||||
LOG_INFO(kTag, "尝试 libei 后端...");
|
int mods, key;
|
||||||
startLibei();
|
if (parseHotkeyCombo(combo, mods, key)) {
|
||||||
if (active_) {
|
impl_->modifiers = mods;
|
||||||
LOG_INFO(kTag, "libei 快捷键已就绪");
|
impl_->key = key;
|
||||||
|
nativeBackend_->setHotkeyCombo(mods, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CapsLockVoiceHotkey::start() {
|
||||||
|
if (active_ || pending_) return true;
|
||||||
|
|
||||||
|
// 解析快捷键组合
|
||||||
|
if (!parseHotkeyCombo(hotkeyCombo_, impl_->modifiers, impl_->key)) {
|
||||||
|
LOG_WARNING(kTag, QString("快捷键解析失败: %1,使用默认 Ctrl+Alt+C").arg(hotkeyCombo_));
|
||||||
|
parseHotkeyCombo("Ctrl+Alt+C", impl_->modifiers, impl_->key);
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO(kTag, QString("快捷键配置: %1 (modifiers=0x%2, key=0x%3)")
|
||||||
|
.arg(hotkeyCombo_).arg(impl_->modifiers, 0, 16).arg(impl_->key, 0, 16));
|
||||||
|
|
||||||
|
// 打印环境信息
|
||||||
|
const char* sessionType = getenv("XDG_SESSION_TYPE");
|
||||||
|
LOG_INFO(kTag, QString("当前会话类型: %1").arg(sessionType ? sessionType : "(未知)"));
|
||||||
|
|
||||||
|
// 1. 尝试纯 C++ 原生后端(X11 XGrabKey / evdev)
|
||||||
|
nativeBackend_ = std::unique_ptr<CapsLockBackend>(createCapsLockBackend(this));
|
||||||
|
if (nativeBackend_) {
|
||||||
|
LOG_INFO(kTag, QString("尝试原生后端: %1").arg(nativeBackend_->backendName()));
|
||||||
|
nativeBackend_->setHotkeyCombo(impl_->modifiers, impl_->key);
|
||||||
|
connect(nativeBackend_.get(), &CapsLockBackend::pressed,
|
||||||
|
this, &CapsLockVoiceHotkey::onNativePressed);
|
||||||
|
connect(nativeBackend_.get(), &CapsLockBackend::ready,
|
||||||
|
this, &CapsLockVoiceHotkey::ready);
|
||||||
|
connect(nativeBackend_.get(), &CapsLockBackend::error,
|
||||||
|
this, &CapsLockVoiceHotkey::error);
|
||||||
|
|
||||||
|
if (nativeBackend_->start()) {
|
||||||
|
active_ = true;
|
||||||
|
LOG_INFO(kTag, QString("✓ 快捷键已就绪(%1)")
|
||||||
|
.arg(nativeBackend_->backendName()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
nativeBackend_.reset();
|
||||||
|
} else {
|
||||||
|
LOG_INFO(kTag, "原生后端不可用: X11 下需 XCB 支持;Wayland 下需配置 evdev 权限");
|
||||||
|
LOG_INFO(kTag, " Wayland 权限配置: sudo usermod -aG input $USER");
|
||||||
|
LOG_INFO(kTag, " 或创建 udev 规则: /etc/udev/rules.d/99-impress-keyboard.rules");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 尝试 GNOME 扩展后端
|
||||||
|
LOG_INFO(kTag, "尝试 GNOME 扩展后端...");
|
||||||
|
LOG_INFO(kTag, " 需安装: gnome-extension/io.impress.voice-input-hotkey@impress/");
|
||||||
|
LOG_INFO(kTag, " 安装到 ~/.local/share/gnome-shell/extensions/ 并重新登录");
|
||||||
|
if (startGnomeExtension()) {
|
||||||
|
LOG_INFO(kTag, "✓ GNOME 扩展快捷键已就绪");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
LOG_INFO(kTag, "libei 不可用,回退到 Portal...");
|
LOG_INFO(kTag, "GNOME 扩展不可用: 扩展未安装或 GNOME Shell 未重启");
|
||||||
|
|
||||||
|
#ifdef HAVE_LIBEI
|
||||||
|
// 3. 尝试 libei(GNOME 47+/KDE)
|
||||||
|
LOG_INFO(kTag, "尝试 libei 后端...");
|
||||||
|
LOG_INFO(kTag, " 需 EIS socket 可用(KDE 原生支持,GNOME 不暴露 EIS socket)");
|
||||||
|
startLibei();
|
||||||
|
if (active_) {
|
||||||
|
LOG_INFO(kTag, "✓ libei 快捷键已就绪");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
LOG_INFO(kTag, "libei 不可用: EIS socket 不可访问");
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// 回退到 Portal(KDE 等支持 GlobalShortcuts 的桌面)
|
// 4. 回退到 Portal(KDE 等支持 GlobalShortcuts 的桌面)
|
||||||
startPortal();
|
LOG_INFO(kTag, "尝试 Portal GlobalShortcuts 后端...");
|
||||||
return active_;
|
LOG_INFO(kTag, " 需设置 XDG_DESKTOP_PORTAL_APP_ID 环境变量");
|
||||||
|
return startPortal();
|
||||||
}
|
}
|
||||||
|
|
||||||
void CapsLockVoiceHotkey::stop() {
|
void CapsLockVoiceHotkey::stop() {
|
||||||
if (!active_ && impl_->state == Impl::Idle) return;
|
if (!active_ && !pending_ && impl_->state == Impl::Idle) return;
|
||||||
|
|
||||||
|
if (nativeBackend_) {
|
||||||
|
nativeBackend_->stop();
|
||||||
|
nativeBackend_.reset();
|
||||||
|
}
|
||||||
|
stopGnomeExtension();
|
||||||
#ifdef HAVE_LIBEI
|
#ifdef HAVE_LIBEI
|
||||||
stopLibei();
|
stopLibei();
|
||||||
#endif
|
#endif
|
||||||
stopPortal();
|
stopPortal();
|
||||||
|
|
||||||
active_ = false;
|
active_ = false;
|
||||||
|
pending_ = false;
|
||||||
recording_ = false;
|
recording_ = false;
|
||||||
impl_->state = Impl::Idle;
|
impl_->state = Impl::Idle;
|
||||||
impl_->sessionPath.clear();
|
impl_->sessionPath.clear();
|
||||||
LOG_INFO(kTag, "CapsLock 语音快捷键已停止");
|
LOG_INFO(kTag, "语音快捷键已停止");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief 切换模式:按下快捷键时在录音/停止之间切换
|
||||||
|
*/
|
||||||
|
void CapsLockVoiceHotkey::toggleRecording() {
|
||||||
|
if (!active_ || ignoreEvents_) return;
|
||||||
|
|
||||||
|
if (recording_) {
|
||||||
|
// 正在录音 → 停止
|
||||||
|
recording_ = false;
|
||||||
|
emit recordingStopped();
|
||||||
|
} else {
|
||||||
|
// 空闲 → 开始录音
|
||||||
|
recording_ = true;
|
||||||
|
emit recordingStarted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GNOME 扩展后端(通过 D-Bus 信号监听快捷键)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
bool CapsLockVoiceHotkey::startGnomeExtension() {
|
||||||
|
QDBusConnection bus = Impl::bus();
|
||||||
|
if (!bus.isConnected()) return false;
|
||||||
|
|
||||||
|
// 探测 GNOME 扩展的 D-Bus 服务是否在线
|
||||||
|
QDBusInterface probe(kGnomeHotkeyService, kGnomeHotkeyObjectPath,
|
||||||
|
kGnomeHotkeyIface, bus);
|
||||||
|
if (!probe.isValid()) {
|
||||||
|
LOG_DEBUG(kTag, "GNOME 扩展 D-Bus 服务不可用");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接快捷键按下信号(GNOME 扩展目前只支持 CapsLock 检测)
|
||||||
|
bool okPressed = bus.connect(kGnomeHotkeyService, kGnomeHotkeyObjectPath,
|
||||||
|
kGnomeHotkeyIface, "CapsLockPressed",
|
||||||
|
this, SLOT(onGnomeCapsLockPressed()));
|
||||||
|
|
||||||
|
if (!okPressed) {
|
||||||
|
LOG_ERROR(kTag, "GNOME 扩展 D-Bus 信号连接失败");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
active_ = true;
|
||||||
|
impl_->state = Impl::GnomeActive;
|
||||||
|
emit ready();
|
||||||
|
LOG_INFO(kTag, "语音快捷键已就绪(GNOME 扩展)");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CapsLockVoiceHotkey::stopGnomeExtension() {
|
||||||
|
QDBusConnection bus = Impl::bus();
|
||||||
|
bus.disconnect(kGnomeHotkeyService, kGnomeHotkeyObjectPath,
|
||||||
|
kGnomeHotkeyIface, "CapsLockPressed",
|
||||||
|
this, SLOT(onGnomeCapsLockPressed()));
|
||||||
|
|
||||||
|
if (impl_->state == Impl::GnomeActive) {
|
||||||
|
impl_->state = Impl::Idle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CapsLockVoiceHotkey::onGnomeCapsLockPressed() {
|
||||||
|
LOG_DEBUG(kTag, "GNOME 扩展: 快捷键触发");
|
||||||
|
toggleRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原生后端(X11 / evdev)事件处理
|
||||||
|
void CapsLockVoiceHotkey::onNativePressed() {
|
||||||
|
LOG_DEBUG(kTag, "原生后端: 快捷键触发");
|
||||||
|
toggleRecording();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Portal 后端(KDE 等支持 GlobalShortcuts 的桌面)
|
// Portal 后端(KDE 等支持 GlobalShortcuts 的桌面)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
void CapsLockVoiceHotkey::startPortal() {
|
bool CapsLockVoiceHotkey::startPortal() {
|
||||||
QDBusConnection bus = Impl::bus();
|
QDBusConnection bus = Impl::bus();
|
||||||
if (!bus.isConnected()) {
|
if (!bus.isConnected()) {
|
||||||
LOG_ERROR(kTag, "无法连接到 D-Bus session bus");
|
LOG_ERROR(kTag, "无法连接到 D-Bus session bus");
|
||||||
emit error("无法连接到 D-Bus session bus");
|
emit error("无法连接到 D-Bus session bus");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 连接信号
|
// 连接信号
|
||||||
@ -122,10 +274,6 @@ void CapsLockVoiceHotkey::startPortal() {
|
|||||||
kGlobalShortcutsIface, "Activated",
|
kGlobalShortcutsIface, "Activated",
|
||||||
this, SLOT(handleActivated(QString)));
|
this, SLOT(handleActivated(QString)));
|
||||||
|
|
||||||
bus.connect(kPortalService, kPortalObjectPath,
|
|
||||||
kGlobalShortcutsIface, "Deactivated",
|
|
||||||
this, SLOT(handleDeactivated(QString)));
|
|
||||||
|
|
||||||
// 连接 Response 信号
|
// 连接 Response 信号
|
||||||
bus.connect(kPortalService, QString(),
|
bus.connect(kPortalService, QString(),
|
||||||
kRequestIface, "Response",
|
kRequestIface, "Response",
|
||||||
@ -148,16 +296,18 @@ void CapsLockVoiceHotkey::startPortal() {
|
|||||||
emit error(QString("CreateSession 失败: %1").arg(reply.errorMessage()));
|
emit error(QString("CreateSession 失败: %1").arg(reply.errorMessage()));
|
||||||
LOG_ERROR(kTag, reply.errorMessage());
|
LOG_ERROR(kTag, reply.errorMessage());
|
||||||
impl_->state = Impl::Idle;
|
impl_->state = Impl::Idle;
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存预期 session path
|
// CreateSession 已接受,进入等待授权状态
|
||||||
QString sender = bus.baseService();
|
QString sender = bus.baseService();
|
||||||
impl_->sessionPath = Impl::makeSessionPath(sender, sessionToken);
|
impl_->sessionPath = Impl::makeSessionPath(sender, sessionToken);
|
||||||
impl_->state = Impl::WaitingSession;
|
impl_->state = Impl::WaitingSession;
|
||||||
|
pending_ = true;
|
||||||
|
|
||||||
LOG_INFO(kTag, "CreateSession 已发送,等待用户授权...");
|
LOG_INFO(kTag, "CreateSession 已发送,等待 Portal 响应...");
|
||||||
LOG_DEBUG(kTag, QString("Session path: %1").arg(impl_->sessionPath));
|
LOG_DEBUG(kTag, QString("Session path: %1").arg(impl_->sessionPath));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void CapsLockVoiceHotkey::stopPortal() {
|
void CapsLockVoiceHotkey::stopPortal() {
|
||||||
@ -165,9 +315,6 @@ void CapsLockVoiceHotkey::stopPortal() {
|
|||||||
bus.disconnect(kPortalService, kPortalObjectPath,
|
bus.disconnect(kPortalService, kPortalObjectPath,
|
||||||
kGlobalShortcutsIface, "Activated",
|
kGlobalShortcutsIface, "Activated",
|
||||||
this, SLOT(handleActivated(QString)));
|
this, SLOT(handleActivated(QString)));
|
||||||
bus.disconnect(kPortalService, kPortalObjectPath,
|
|
||||||
kGlobalShortcutsIface, "Deactivated",
|
|
||||||
this, SLOT(handleDeactivated(QString)));
|
|
||||||
bus.disconnect(kPortalService, QString(),
|
bus.disconnect(kPortalService, QString(),
|
||||||
kRequestIface, "Response",
|
kRequestIface, "Response",
|
||||||
this, SLOT(onPortalResponse(uint, QVariantMap)));
|
this, SLOT(onPortalResponse(uint, QVariantMap)));
|
||||||
@ -185,6 +332,7 @@ void CapsLockVoiceHotkey::handleSessionResponse(uint response, const QVariantMap
|
|||||||
if (impl_->state != Impl::WaitingSession) return;
|
if (impl_->state != Impl::WaitingSession) return;
|
||||||
|
|
||||||
if (response != 0) {
|
if (response != 0) {
|
||||||
|
pending_ = false;
|
||||||
emit error(QString("Session 被拒绝 (response=%1)").arg(response));
|
emit error(QString("Session 被拒绝 (response=%1)").arg(response));
|
||||||
LOG_ERROR(kTag, QString("Session 被拒绝: %1").arg(response));
|
LOG_ERROR(kTag, QString("Session 被拒绝: %1").arg(response));
|
||||||
impl_->state = Impl::Idle;
|
impl_->state = Impl::Idle;
|
||||||
@ -195,7 +343,7 @@ void CapsLockVoiceHotkey::handleSessionResponse(uint response, const QVariantMap
|
|||||||
if (!actualPath.isEmpty()) {
|
if (!actualPath.isEmpty()) {
|
||||||
impl_->sessionPath = actualPath;
|
impl_->sessionPath = actualPath;
|
||||||
}
|
}
|
||||||
LOG_INFO(kTag, QString("Session 已授权: %1").arg(impl_->sessionPath));
|
LOG_INFO(kTag, QString("Portal Session 已授权: %1").arg(impl_->sessionPath));
|
||||||
|
|
||||||
// 发送 BindShortcuts
|
// 发送 BindShortcuts
|
||||||
impl_->state = Impl::WaitingBind;
|
impl_->state = Impl::WaitingBind;
|
||||||
@ -206,7 +354,7 @@ void CapsLockVoiceHotkey::handleSessionResponse(uint response, const QVariantMap
|
|||||||
QString bindToken = Impl::makeToken("io_impress_bind");
|
QString bindToken = Impl::makeToken("io_impress_bind");
|
||||||
|
|
||||||
QVariantMap shortcutProps;
|
QVariantMap shortcutProps;
|
||||||
shortcutProps["description"] = "语音输入(CapsLock)";
|
shortcutProps["description"] = QString("语音输入(%1)").arg(hotkeyCombo_);
|
||||||
|
|
||||||
QList<QVariant> shortcuts;
|
QList<QVariant> shortcuts;
|
||||||
QVariantMap shortcutEntry;
|
QVariantMap shortcutEntry;
|
||||||
@ -239,6 +387,7 @@ void CapsLockVoiceHotkey::handleBindResponse(uint response, const QVariantMap&)
|
|||||||
if (impl_->state != Impl::WaitingBind) return;
|
if (impl_->state != Impl::WaitingBind) return;
|
||||||
|
|
||||||
if (response != 0) {
|
if (response != 0) {
|
||||||
|
pending_ = false;
|
||||||
emit error(QString("快捷键绑定被拒绝 (response=%1)").arg(response));
|
emit error(QString("快捷键绑定被拒绝 (response=%1)").arg(response));
|
||||||
LOG_ERROR(kTag, QString("Bind 被拒绝: %1").arg(response));
|
LOG_ERROR(kTag, QString("Bind 被拒绝: %1").arg(response));
|
||||||
impl_->state = Impl::Idle;
|
impl_->state = Impl::Idle;
|
||||||
@ -246,23 +395,15 @@ void CapsLockVoiceHotkey::handleBindResponse(uint response, const QVariantMap&)
|
|||||||
}
|
}
|
||||||
|
|
||||||
active_ = true;
|
active_ = true;
|
||||||
|
pending_ = false;
|
||||||
impl_->state = Impl::Active;
|
impl_->state = Impl::Active;
|
||||||
emit ready();
|
emit ready();
|
||||||
LOG_INFO(kTag, "快捷键已注册(Portal),CapsLock 语音输入已就绪");
|
LOG_INFO(kTag, "快捷键已注册(Portal),语音输入已就绪");
|
||||||
}
|
}
|
||||||
|
|
||||||
void CapsLockVoiceHotkey::handleActivated(const QString& shortcutId) {
|
void CapsLockVoiceHotkey::handleActivated(const QString& shortcutId) {
|
||||||
if (!active_ || ignoreEvents_) return;
|
LOG_DEBUG(kTag, QString("快捷键触发: %1").arg(shortcutId));
|
||||||
LOG_DEBUG(kTag, QString("快捷键按下: %1").arg(shortcutId));
|
toggleRecording();
|
||||||
recording_ = true;
|
|
||||||
emit recordingStarted();
|
|
||||||
}
|
|
||||||
|
|
||||||
void CapsLockVoiceHotkey::handleDeactivated(const QString& shortcutId) {
|
|
||||||
if (!active_ || ignoreEvents_) return;
|
|
||||||
LOG_DEBUG(kTag, QString("快捷键松开: %1").arg(shortcutId));
|
|
||||||
recording_ = false;
|
|
||||||
emit recordingStopped();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -296,9 +437,9 @@ void CapsLockVoiceHotkey::startLibei() {
|
|||||||
LOG_INFO(kTag, "libei 已连接,等待键盘事件...");
|
LOG_INFO(kTag, "libei 已连接,等待键盘事件...");
|
||||||
|
|
||||||
active_ = true;
|
active_ = true;
|
||||||
impl_->state = Impl::Active;
|
impl_->state = Impl::LibeiActive;
|
||||||
emit ready();
|
emit ready();
|
||||||
LOG_INFO(kTag, "CapsLock 语音快捷键已就绪(libei)");
|
LOG_INFO(kTag, "语音快捷键已就绪(libei)");
|
||||||
}
|
}
|
||||||
|
|
||||||
void CapsLockVoiceHotkey::stopLibei() {
|
void CapsLockVoiceHotkey::stopLibei() {
|
||||||
@ -324,20 +465,11 @@ void CapsLockVoiceHotkey::onLibeiEvent() {
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case EI_EVENT_KEYBOARD_KEY: {
|
case EI_EVENT_KEYBOARD_KEY: {
|
||||||
uint32_t keycode = ei_event_keyboard_get_key(ev);
|
// TODO: 根据 impl_->key 过滤配置的按键
|
||||||
bool isPress = ei_event_keyboard_get_key_is_press(ev);
|
bool isPress = ei_event_keyboard_get_key_is_press(ev);
|
||||||
|
if (isPress) {
|
||||||
if (keycode == kCapsLockKeycode) {
|
LOG_DEBUG(kTag, "libei: 快捷键触发");
|
||||||
if (ignoreEvents_) break;
|
toggleRecording();
|
||||||
if (isPress) {
|
|
||||||
LOG_DEBUG(kTag, "libei: CapsLock 按下");
|
|
||||||
recording_ = true;
|
|
||||||
emit recordingStarted();
|
|
||||||
} else {
|
|
||||||
LOG_DEBUG(kTag, "libei: CapsLock 松开");
|
|
||||||
recording_ = false;
|
|
||||||
emit recordingStopped();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,18 +6,21 @@
|
|||||||
|
|
||||||
namespace impress {
|
namespace impress {
|
||||||
|
|
||||||
|
class CapsLockBackend;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief CapsLock 长按语音输入快捷键管理器
|
* @brief 全局语音输入快捷键管理器
|
||||||
*
|
*
|
||||||
* 双后端实现:
|
* 支持可配置的组合键(默认 Ctrl+Alt+C),切换模式:
|
||||||
* - Portal (KDE/默认):freedesktop GlobalShortcuts D-Bus Portal
|
* 按一次 → 开始录音
|
||||||
* - libei (GNOME 47+):libinput-emulator,直接监听键盘事件
|
* 再按一次 → 停止录音并转写
|
||||||
*
|
*
|
||||||
* 工作流程:
|
* 五后端实现(按优先级):
|
||||||
* 1. 用户长按 CapsLock 1 秒后触发录音
|
* 1. X11 XGrabKey(X11 会话,零权限)
|
||||||
* 2. 长按期间持续录音
|
* 2. evdev grab + uinput replay(Wayland,需 input 组)
|
||||||
* 3. 松开 CapsLock 后停止录音并触发转写
|
* 3. GNOME 扩展(GNOME 46+,通过 D-Bus 信号)
|
||||||
* 4. 短按(< 1s)直接传递 CapsLock 事件(切换大小写锁定)
|
* 4. libei(GNOME 47+/KDE,libinput-emulator)
|
||||||
|
* 5. Portal(KDE/默认,GlobalShortcuts D-Bus Portal)
|
||||||
*/
|
*/
|
||||||
class CapsLockVoiceHotkey : public QObject {
|
class CapsLockVoiceHotkey : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
@ -31,15 +34,18 @@ public:
|
|||||||
/** @brief 停止并注销快捷键 */
|
/** @brief 停止并注销快捷键 */
|
||||||
void stop();
|
void stop();
|
||||||
|
|
||||||
/** @brief 是否已激活 */
|
/** @brief 是否已激活或正在等待 Portal 授权 */
|
||||||
bool isActive() const { return active_; }
|
bool isActive() const { return active_ || pending_; }
|
||||||
|
|
||||||
/** @brief 当前是否正在录音(CapsLock 长按超过 1s 后) */
|
/** @brief 当前是否正在录音 */
|
||||||
bool isRecording() const { return recording_; }
|
bool isRecording() const { return recording_; }
|
||||||
|
|
||||||
/** @brief 临时忽略信号(XTest 模拟按键期间) */
|
/** @brief 临时忽略信号(XTest 模拟按键期间) */
|
||||||
void setIgnoreEvents(bool ignore) { ignoreEvents_ = ignore; }
|
void setIgnoreEvents(bool ignore) { ignoreEvents_ = ignore; }
|
||||||
|
|
||||||
|
/** @brief 设置快捷键组合(如 "Ctrl+Alt+C") */
|
||||||
|
void setHotkeyCombo(const QString& combo);
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void recordingStarted();
|
void recordingStarted();
|
||||||
void recordingStopped();
|
void recordingStopped();
|
||||||
@ -50,11 +56,28 @@ private:
|
|||||||
struct Impl;
|
struct Impl;
|
||||||
std::unique_ptr<Impl> impl_;
|
std::unique_ptr<Impl> impl_;
|
||||||
bool active_ = false;
|
bool active_ = false;
|
||||||
|
bool pending_ = false; // Portal 授权进行中
|
||||||
bool recording_ = false;
|
bool recording_ = false;
|
||||||
bool ignoreEvents_ = false;
|
bool ignoreEvents_ = false;
|
||||||
|
QString hotkeyCombo_;
|
||||||
|
|
||||||
// Portal 后端方法
|
// 纯 C++ 后端
|
||||||
void startPortal();
|
std::unique_ptr<CapsLockBackend> nativeBackend_;
|
||||||
|
|
||||||
|
/** @brief 切换模式:按下快捷键时在录音/停止之间切换 */
|
||||||
|
void toggleRecording();
|
||||||
|
|
||||||
|
void onNativePressed();
|
||||||
|
void onNativeReleased();
|
||||||
|
|
||||||
|
// GNOME 扩展后端
|
||||||
|
bool startGnomeExtension();
|
||||||
|
void stopGnomeExtension();
|
||||||
|
void onGnomeCapsLockPressed();
|
||||||
|
void onGnomeCapsLockReleased();
|
||||||
|
|
||||||
|
// Portal 后端
|
||||||
|
bool startPortal();
|
||||||
void stopPortal();
|
void stopPortal();
|
||||||
void handleSessionResponse(uint response, const QVariantMap& results);
|
void handleSessionResponse(uint response, const QVariantMap& results);
|
||||||
void handleBindResponse(uint response, const QVariantMap& results);
|
void handleBindResponse(uint response, const QVariantMap& results);
|
||||||
@ -62,7 +85,7 @@ private:
|
|||||||
void handleDeactivated(const QString& shortcutId);
|
void handleDeactivated(const QString& shortcutId);
|
||||||
void onPortalResponse(uint response, const QVariantMap& results);
|
void onPortalResponse(uint response, const QVariantMap& results);
|
||||||
|
|
||||||
// libei 后端方法
|
// libei 后端
|
||||||
void startLibei();
|
void startLibei();
|
||||||
void stopLibei();
|
void stopLibei();
|
||||||
void onLibeiEvent();
|
void onLibeiEvent();
|
||||||
|
|||||||
203
src/core/caps_lock_xcb.cpp
Normal file
203
src/core/caps_lock_xcb.cpp
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
#include "caps_lock_xcb.h"
|
||||||
|
#include "utils/logger.h"
|
||||||
|
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QAbstractEventDispatcher>
|
||||||
|
#include <QtGui/qguiapplication_platform.h>
|
||||||
|
|
||||||
|
#include <xcb/xcb.h>
|
||||||
|
#include <X11/Xlib.h>
|
||||||
|
#include <X11/keysym.h>
|
||||||
|
|
||||||
|
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<QGuiApplication*>(QCoreApplication::instance());
|
||||||
|
if (!guiApp) return false;
|
||||||
|
|
||||||
|
auto* x11App = guiApp->nativeInterface<QNativeInterface::QX11Application>();
|
||||||
|
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<xcb_generic_event_t*>(message);
|
||||||
|
uint8_t responseType = ev->response_type & ~0x80;
|
||||||
|
|
||||||
|
if (responseType == XCB_KEY_PRESS) {
|
||||||
|
auto* kev = reinterpret_cast<xcb_key_press_event_t*>(ev);
|
||||||
|
if (kev->detail == m_keycode) {
|
||||||
|
LOG_DEBUG(kTag, "快捷键按下 (X11)");
|
||||||
|
emit pressed();
|
||||||
|
return true; // 阻止事件传播
|
||||||
|
}
|
||||||
|
} else if (responseType == XCB_KEY_RELEASE) {
|
||||||
|
auto* kev = reinterpret_cast<xcb_key_release_event_t*>(ev);
|
||||||
|
if (kev->detail == m_keycode) {
|
||||||
|
LOG_DEBUG(kTag, "快捷键松开 (X11)");
|
||||||
|
emit released();
|
||||||
|
return true; // 阻止事件传播
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace impress
|
||||||
46
src/core/caps_lock_xcb.h
Normal file
46
src/core/caps_lock_xcb.h
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "caps_lock_backend.h"
|
||||||
|
|
||||||
|
#include <QAbstractNativeEventFilter>
|
||||||
|
#include <QByteArray>
|
||||||
|
|
||||||
|
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
|
||||||
@ -41,20 +41,6 @@ VoiceInputService::VoiceInputService(ConfigManager* configManager,
|
|||||||
, impl_(std::make_unique<Impl>())
|
, impl_(std::make_unique<Impl>())
|
||||||
{
|
{
|
||||||
impl_->sttEngine = sttEngine;
|
impl_->sttEngine = sttEngine;
|
||||||
|
|
||||||
// 确认长按定时器 → 直接进入 Recording,消除 1s 延迟
|
|
||||||
longPressTimer_ = new QTimer(this);
|
|
||||||
longPressTimer_->setSingleShot(true);
|
|
||||||
|
|
||||||
// 松开后冷却定时器
|
|
||||||
cooldownTimer_ = new QTimer(this);
|
|
||||||
cooldownTimer_->setSingleShot(true);
|
|
||||||
connect(cooldownTimer_, &QTimer::timeout, this, [this]() {
|
|
||||||
if (state_ == Cooldown) {
|
|
||||||
state_ = Idle;
|
|
||||||
LOG_DEBUG(kTag, "Cooldown → Idle (冷却结束)");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
VoiceInputService::~VoiceInputService() {
|
VoiceInputService::~VoiceInputService() {
|
||||||
@ -80,6 +66,11 @@ bool VoiceInputService::start() {
|
|||||||
|
|
||||||
// 3. 初始化全局快捷键
|
// 3. 初始化全局快捷键
|
||||||
impl_->hotkey = new CapsLockVoiceHotkey(this);
|
impl_->hotkey = new CapsLockVoiceHotkey(this);
|
||||||
|
|
||||||
|
// 从配置读取快捷键组合
|
||||||
|
QString hotkeyStr = configManager_->get("shortcuts.voice_hotkey").toString();
|
||||||
|
impl_->hotkey->setHotkeyCombo(hotkeyStr);
|
||||||
|
|
||||||
connect(impl_->hotkey, &CapsLockVoiceHotkey::recordingStarted,
|
connect(impl_->hotkey, &CapsLockVoiceHotkey::recordingStarted,
|
||||||
this, &VoiceInputService::onHotkeyActivated);
|
this, &VoiceInputService::onHotkeyActivated);
|
||||||
connect(impl_->hotkey, &CapsLockVoiceHotkey::recordingStopped,
|
connect(impl_->hotkey, &CapsLockVoiceHotkey::recordingStopped,
|
||||||
@ -91,6 +82,15 @@ bool VoiceInputService::start() {
|
|||||||
connect(impl_->hotkey, &CapsLockVoiceHotkey::error,
|
connect(impl_->hotkey, &CapsLockVoiceHotkey::error,
|
||||||
this, &VoiceInputService::error);
|
this, &VoiceInputService::error);
|
||||||
|
|
||||||
|
// 监听配置变化更新快捷键
|
||||||
|
connect(configManager_, &ConfigManager::configChanged, this, [this]() {
|
||||||
|
QString hotkeyStr = configManager_->get("shortcuts.voice_hotkey").toString();
|
||||||
|
LOG_INFO(kTag, QString("配置变更: voice_hotkey=%1").arg(hotkeyStr));
|
||||||
|
if (impl_->hotkey) {
|
||||||
|
impl_->hotkey->setHotkeyCombo(hotkeyStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 4. 初始化文本注入器
|
// 4. 初始化文本注入器
|
||||||
impl_->injector = new WaylandTextInjector(this);
|
impl_->injector = new WaylandTextInjector(this);
|
||||||
if (!impl_->injector->initialize()) {
|
if (!impl_->injector->initialize()) {
|
||||||
@ -114,9 +114,6 @@ bool VoiceInputService::start() {
|
|||||||
void VoiceInputService::stop() {
|
void VoiceInputService::stop() {
|
||||||
if (!running_) return;
|
if (!running_) return;
|
||||||
|
|
||||||
longPressTimer_->stop();
|
|
||||||
cooldownTimer_->stop();
|
|
||||||
|
|
||||||
if (impl_->audioCapture) {
|
if (impl_->audioCapture) {
|
||||||
impl_->audioCapture->stopAndClose(); // 彻底关闭流
|
impl_->audioCapture->stopAndClose(); // 彻底关闭流
|
||||||
}
|
}
|
||||||
@ -133,13 +130,13 @@ void VoiceInputService::stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void VoiceInputService::onHotkeyActivated() {
|
void VoiceInputService::onHotkeyActivated() {
|
||||||
// Recording 和 Cooldown 状态:屏蔽所有 Activated(防抖核心)
|
// Recording 和 Cooldown 状态:屏蔽所有 Activated(防抖)
|
||||||
if (state_ == Recording || state_ == Cooldown) {
|
if (state_ == Recording || state_ == Cooldown) {
|
||||||
LOG_DEBUG(kTag, QString("忽略 Activated (state=%1)").arg(state_ == Recording ? "Recording" : "Cooldown"));
|
LOG_DEBUG(kTag, QString("忽略 Activated (state=%1)").arg(state_ == Recording ? "Recording" : "Cooldown"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle → 直接进入 Recording,消除 1s 延迟
|
// Idle → 直接进入 Recording
|
||||||
state_ = Recording;
|
state_ = Recording;
|
||||||
recording_ = true;
|
recording_ = true;
|
||||||
audioBuffer_.clear();
|
audioBuffer_.clear();
|
||||||
@ -149,21 +146,20 @@ void VoiceInputService::onHotkeyActivated() {
|
|||||||
int bufferSizeMs = configManager_->get("audio.buffer_size_ms").toInt();
|
int bufferSizeMs = configManager_->get("audio.buffer_size_ms").toInt();
|
||||||
impl_->audioCapture->start(deviceIndex, sampleRate, bufferSizeMs);
|
impl_->audioCapture->start(deviceIndex, sampleRate, bufferSizeMs);
|
||||||
|
|
||||||
// 延迟统计(现在应该接近 0)
|
|
||||||
hotkeyLatencyTimer_.start();
|
hotkeyLatencyTimer_.start();
|
||||||
latencyTracking_ = true;
|
latencyTracking_ = true;
|
||||||
qint64 latencyMs = 0;
|
qint64 latencyMs = 0;
|
||||||
|
|
||||||
LOG_DEBUG(kTag, "Idle → Recording (立即开始录音)");
|
LOG_DEBUG(kTag, "→ Recording (开始录音)");
|
||||||
emit statusChanged("正在录音...");
|
emit statusChanged("正在录音...");
|
||||||
|
|
||||||
// 统计打印
|
// 统计
|
||||||
totalKeyCount_++;
|
totalKeyCount_++;
|
||||||
totalLatencyMs_ += latencyMs;
|
totalLatencyMs_ += latencyMs;
|
||||||
maxLatencyMs_ = std::max(maxLatencyMs_, (double)latencyMs);
|
maxLatencyMs_ = std::max(maxLatencyMs_, (double)latencyMs);
|
||||||
minLatencyMs_ = std::min(minLatencyMs_, (double)latencyMs);
|
minLatencyMs_ = std::min(minLatencyMs_, (double)latencyMs);
|
||||||
double avgMs = totalLatencyMs_ / totalKeyCount_;
|
double avgMs = totalLatencyMs_ / totalKeyCount_;
|
||||||
LOG_INFO(kTag, QString("⏱ 按键→录音延迟: %1ms (平均: %2ms, 最小: %3ms, 最大: %4ms, 累计: %5次)")
|
LOG_INFO(kTag, QString("⏱ 快捷键→录音延迟: %1ms (平均: %2ms, 最小: %3ms, 最大: %4ms, 累计: %5次)")
|
||||||
.arg(latencyMs).arg(avgMs, 0, 'f', 0)
|
.arg(latencyMs).arg(avgMs, 0, 'f', 0)
|
||||||
.arg(minLatencyMs_, 0, 'f', 0).arg(maxLatencyMs_, 0, 'f', 0)
|
.arg(minLatencyMs_, 0, 'f', 0).arg(maxLatencyMs_, 0, 'f', 0)
|
||||||
.arg(totalKeyCount_));
|
.arg(totalKeyCount_));
|
||||||
@ -171,14 +167,13 @@ void VoiceInputService::onHotkeyActivated() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void VoiceInputService::onHotkeyDeactivated() {
|
void VoiceInputService::onHotkeyDeactivated() {
|
||||||
// Cooldown 状态的 Deactivated → 忽略
|
// Cooldown 状态忽略
|
||||||
if (state_ == Cooldown) {
|
if (state_ == Cooldown) {
|
||||||
LOG_DEBUG(kTag, "忽略 Deactivated (Cooldown)");
|
LOG_DEBUG(kTag, "忽略 Deactivated (Cooldown)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
recording_ = false;
|
recording_ = false;
|
||||||
longPressTimer_->stop();
|
|
||||||
|
|
||||||
// 停止音频采集
|
// 停止音频采集
|
||||||
if (impl_->audioCapture && impl_->audioCapture->isRunning()) {
|
if (impl_->audioCapture && impl_->audioCapture->isRunning()) {
|
||||||
@ -186,15 +181,20 @@ void VoiceInputService::onHotkeyDeactivated() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state_ == Recording) {
|
if (state_ == Recording) {
|
||||||
// 松开 → 开始识别(CapsLock 灯在识别完成后复位,避免重复)
|
|
||||||
state_ = Idle;
|
state_ = Idle;
|
||||||
LOG_DEBUG(kTag, "Recording → Idle (松开转写)");
|
LOG_DEBUG(kTag, "Recording → Idle (停止转写)");
|
||||||
stopRecordingAndTranscribe();
|
stopRecordingAndTranscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动冷却期
|
// 启动冷却期
|
||||||
state_ = Cooldown;
|
state_ = Cooldown;
|
||||||
cooldownTimer_->start(releaseCooldownMs_);
|
// 用单次 QTimer 实现冷却
|
||||||
|
QTimer::singleShot(releaseCooldownMs_, this, [this]() {
|
||||||
|
if (state_ == Cooldown) {
|
||||||
|
state_ = Idle;
|
||||||
|
LOG_DEBUG(kTag, "Cooldown → Idle (冷却结束)");
|
||||||
|
}
|
||||||
|
});
|
||||||
LOG_DEBUG(kTag, QString("→ Cooldown (%1ms)").arg(releaseCooldownMs_));
|
LOG_DEBUG(kTag, QString("→ Cooldown (%1ms)").arg(releaseCooldownMs_));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,8 +207,6 @@ void VoiceInputService::onAudioData(const std::vector<float>& samples, int sampl
|
|||||||
|
|
||||||
void VoiceInputService::stopRecordingAndTranscribe() {
|
void VoiceInputService::stopRecordingAndTranscribe() {
|
||||||
if (audioBuffer_.empty()) {
|
if (audioBuffer_.empty()) {
|
||||||
// 无音频 → 复位 CapsLock 灯
|
|
||||||
simulateCapsLock();
|
|
||||||
emit statusChanged("未检测到音频输入");
|
emit statusChanged("未检测到音频输入");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -237,9 +235,6 @@ void VoiceInputService::stopRecordingAndTranscribe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void VoiceInputService::onRecognitionComplete(const QString& text) {
|
void VoiceInputService::onRecognitionComplete(const QString& text) {
|
||||||
// 识别完成后,复位 CapsLock 灯
|
|
||||||
simulateCapsLock();
|
|
||||||
|
|
||||||
if (text.isEmpty()) {
|
if (text.isEmpty()) {
|
||||||
emit statusChanged("识别结果:无语音输入");
|
emit statusChanged("识别结果:无语音输入");
|
||||||
return;
|
return;
|
||||||
@ -257,25 +252,4 @@ void VoiceInputService::onRecognitionComplete(const QString& text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void VoiceInputService::simulateCapsLock() {
|
|
||||||
#ifndef PLATFORM_WINDOWS
|
|
||||||
// XTest 模拟的按键会被 D-Bus portal 再次捕获,导致 Activated/Deactivated 信号。
|
|
||||||
// 在模拟期间屏蔽 portal 信号,防止状态机被打断。
|
|
||||||
if (impl_->hotkey) {
|
|
||||||
impl_->hotkey->setIgnoreEvents(true);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
if (impl_->injector && impl_->injector->isInitialized()) {
|
|
||||||
impl_->injector->simulateKeysym(0xffe5);
|
|
||||||
LOG_DEBUG(kTag, "模拟 CapsLock 按键");
|
|
||||||
} else {
|
|
||||||
LOG_WARNING(kTag, "文本注入器未初始化,无法模拟 CapsLock");
|
|
||||||
}
|
|
||||||
#ifndef PLATFORM_WINDOWS
|
|
||||||
if (impl_->hotkey) {
|
|
||||||
impl_->hotkey->setIgnoreEvents(false);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace impress
|
} // namespace impress
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QThread>
|
#include <QThread>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
#include <QTimer>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
static const char* const kTag = "CapsLockVoiceHotkey";
|
static const char* const kTag = "CapsLockVoiceHotkey";
|
||||||
@ -16,14 +17,11 @@ namespace impress {
|
|||||||
struct CapsLockVoiceHotkey::Impl {
|
struct CapsLockVoiceHotkey::Impl {
|
||||||
#ifdef Q_OS_WIN
|
#ifdef Q_OS_WIN
|
||||||
int hotkeyId = 0;
|
int hotkeyId = 0;
|
||||||
QTimer* longPressTimer = nullptr;
|
QTimer* keyUpTimer = nullptr; // 检测松开的轮询定时器
|
||||||
QTimer* keyUpDebounce = nullptr;
|
|
||||||
bool isHolding = false;
|
|
||||||
bool longPressFired = false;
|
|
||||||
bool pollThreadRunning = false;
|
|
||||||
void* nativeEventFilter = nullptr;
|
|
||||||
QWidget* hiddenWindow = nullptr;
|
QWidget* hiddenWindow = nullptr;
|
||||||
static constexpr int kLongPressMs = 1000;
|
void* nativeEventFilter = nullptr;
|
||||||
|
bool isHolding = false;
|
||||||
|
bool pollThreadRunning = false;
|
||||||
#endif
|
#endif
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -53,78 +51,132 @@ private:
|
|||||||
};
|
};
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
/** Qt::KeyboardModifiers → Win32 MOD_* */
|
||||||
|
static int qtModsToWin32(int qtMods) {
|
||||||
|
int mods = 0;
|
||||||
|
if (qtMods & Qt::ControlModifier) mods |= MOD_CONTROL;
|
||||||
|
if (qtMods & Qt::AltModifier) mods |= MOD_ALT;
|
||||||
|
if (qtMods & Qt::ShiftModifier) mods |= MOD_SHIFT;
|
||||||
|
if (qtMods & Qt::MetaModifier) mods |= MOD_WIN;
|
||||||
|
return mods;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Qt::Key → Win32 virtual key code */
|
||||||
|
static int qtKeyToWin32(int key) {
|
||||||
|
if (key >= Qt::Key_A && key <= Qt::Key_Z) {
|
||||||
|
return 'A' + (key - Qt::Key_A);
|
||||||
|
}
|
||||||
|
if (key >= Qt::Key_0 && key <= Qt::Key_9) {
|
||||||
|
return '0' + (key - Qt::Key_0);
|
||||||
|
}
|
||||||
|
if (key >= Qt::Key_F1 && key <= Qt::Key_F12) {
|
||||||
|
return VK_F1 + (key - Qt::Key_F1);
|
||||||
|
}
|
||||||
|
switch (key) {
|
||||||
|
case Qt::Key_Space: return VK_SPACE;
|
||||||
|
case Qt::Key_Return:
|
||||||
|
case Qt::Key_Enter: return VK_RETURN;
|
||||||
|
case Qt::Key_Escape: return VK_ESCAPE;
|
||||||
|
case Qt::Key_Tab: return VK_TAB;
|
||||||
|
case Qt::Key_Backspace: return VK_BACK;
|
||||||
|
case Qt::Key_Delete: return VK_DELETE;
|
||||||
|
case Qt::Key_Insert: return VK_INSERT;
|
||||||
|
case Qt::Key_Home: return VK_HOME;
|
||||||
|
case Qt::Key_End: return VK_END;
|
||||||
|
case Qt::Key_PageUp: return VK_PRIOR;
|
||||||
|
case Qt::Key_PageDown: return VK_NEXT;
|
||||||
|
case Qt::Key_Up: return VK_UP;
|
||||||
|
case Qt::Key_Down: return VK_DOWN;
|
||||||
|
case Qt::Key_Left: return VK_LEFT;
|
||||||
|
case Qt::Key_Right: return VK_RIGHT;
|
||||||
|
case Qt::Key_CapsLock: return VK_CAPITAL;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CapsLockVoiceHotkey::CapsLockVoiceHotkey(QObject* parent)
|
CapsLockVoiceHotkey::CapsLockVoiceHotkey(QObject* parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
, impl_(std::make_unique<Impl>())
|
, impl_(std::make_unique<Impl>())
|
||||||
{}
|
{
|
||||||
|
hotkeyCombo_ = "Ctrl+Alt+C";
|
||||||
|
}
|
||||||
|
|
||||||
CapsLockVoiceHotkey::~CapsLockVoiceHotkey() {
|
CapsLockVoiceHotkey::~CapsLockVoiceHotkey() {
|
||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CapsLockVoiceHotkey::setHotkeyCombo(const QString& combo) {
|
||||||
|
hotkeyCombo_ = combo;
|
||||||
|
|
||||||
|
int mods = 0, key = 0;
|
||||||
|
QStringList parts = combo.split('+', Qt::SkipEmptyParts);
|
||||||
|
|
||||||
|
// 解析修饰键(除了最后一个部分)
|
||||||
|
for (int i = 0; i < parts.size() - 1; i++) {
|
||||||
|
QString part = parts[i].trimmed().toLower();
|
||||||
|
if (part == "ctrl" || part == "control") mods |= Qt::ControlModifier;
|
||||||
|
else if (part == "alt") mods |= Qt::AltModifier;
|
||||||
|
else if (part == "shift") mods |= Qt::ShiftModifier;
|
||||||
|
else if (part == "meta" || part == "super" || part == "win") mods |= Qt::MetaModifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析主键(最后一个部分)
|
||||||
|
QString mainKey = parts.last().trimmed();
|
||||||
|
if (mainKey.length() == 1 && mainKey[0].isLetter()) {
|
||||||
|
key = Qt::Key_A + (mainKey[0].toUpper().unicode() - 'A');
|
||||||
|
} else if (mainKey.length() == 1 && mainKey[0].isDigit()) {
|
||||||
|
key = Qt::Key_0 + (mainKey[0].unicode() - '0');
|
||||||
|
} else {
|
||||||
|
// 特殊按键映射
|
||||||
|
QString mk = mainKey.toLower();
|
||||||
|
if (mk == "capslock") key = Qt::Key_CapsLock;
|
||||||
|
else if (mk == "space") key = Qt::Key_Space;
|
||||||
|
else if (mk == "enter" || mk == "return") key = Qt::Key_Return;
|
||||||
|
else if (mk == "esc") key = Qt::Key_Escape;
|
||||||
|
else if (mk == "tab") key = Qt::Key_Tab;
|
||||||
|
else if (mk == "backspace") key = Qt::Key_Backspace;
|
||||||
|
else if (mk == "delete") key = Qt::Key_Delete;
|
||||||
|
else if (mk == "insert") key = Qt::Key_Insert;
|
||||||
|
else if (mk == "home") key = Qt::Key_Home;
|
||||||
|
else if (mk == "end") key = Qt::Key_End;
|
||||||
|
else if (mk == "pageup") key = Qt::Key_PageUp;
|
||||||
|
else if (mk == "pagedown") key = Qt::Key_PageDown;
|
||||||
|
else if (mk == "up") key = Qt::Key_Up;
|
||||||
|
else if (mk == "down") key = Qt::Key_Down;
|
||||||
|
else if (mk == "left") key = Qt::Key_Left;
|
||||||
|
else if (mk == "right") key = Qt::Key_Right;
|
||||||
|
else if (mk.startsWith('f') && mk.length() >= 2) {
|
||||||
|
int num = mk.mid(1).toInt();
|
||||||
|
if (num >= 1 && num <= 12) key = Qt::Key_F1 + (num - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key == 0) {
|
||||||
|
LOG_ERROR(kTag, QString("无法解析快捷键组合: %1").arg(combo));
|
||||||
|
emit error(QString("无效的快捷键: %1").arg(combo));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modifiers_ = qtModsToWin32(mods);
|
||||||
|
vkKey_ = qtKeyToWin32(key);
|
||||||
|
|
||||||
|
LOG_INFO(kTag, QString("快捷键已更新: %1 (mods=%2, vk=0x%3)").arg(combo).arg(modifiers_).arg(vkKey_, 0, 16));
|
||||||
|
|
||||||
|
if (active_) {
|
||||||
|
unregisterHotkey();
|
||||||
|
registerHotkey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool CapsLockVoiceHotkey::start() {
|
bool CapsLockVoiceHotkey::start() {
|
||||||
if (active_) return true;
|
if (active_) return true;
|
||||||
|
|
||||||
#ifdef Q_OS_WIN
|
#ifdef Q_OS_WIN
|
||||||
// 创建隐藏窗口用于接收 WM_HOTKEY 消息
|
registerHotkey();
|
||||||
if (!impl_->hiddenWindow) {
|
|
||||||
impl_->hiddenWindow = new QWidget();
|
|
||||||
impl_->hiddenWindow->setObjectName("HotkeyReceiver");
|
|
||||||
impl_->hiddenWindow->setWindowFlags(Qt::Tool | Qt::FramelessWindowHint);
|
|
||||||
impl_->hiddenWindow->resize(0, 0);
|
|
||||||
}
|
|
||||||
// 确保窗口已创建(show 会创建原生句柄)
|
|
||||||
impl_->hiddenWindow->show();
|
|
||||||
|
|
||||||
HWND hwnd = reinterpret_cast<HWND>(impl_->hiddenWindow->winId());
|
|
||||||
if (!hwnd) {
|
|
||||||
emit error("无法创建窗口句柄");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注册 CapsLock (VK_CAPITAL = 0x14) 全局快捷键
|
|
||||||
// MOD_NOREPEAT 防止按住时重复触发
|
|
||||||
const int vkCapsLock = 0x14;
|
|
||||||
impl_->hotkeyId = GlobalAddAtom(L"ImpressVoiceHotkey");
|
|
||||||
|
|
||||||
BOOL ok = RegisterHotKey(hwnd, impl_->hotkeyId, MOD_NOREPEAT, vkCapsLock);
|
|
||||||
if (!ok) {
|
|
||||||
DWORD err = GetLastError();
|
|
||||||
emit error(QString("注册 CapsLock 快捷键失败 (错误码: %1)").arg(err));
|
|
||||||
LOG_ERROR(kTag, QString("RegisterHotKey failed: %1").arg(err));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 安装原生事件过滤器
|
|
||||||
auto* filter = new HotkeyNativeEventFilter(this);
|
|
||||||
QGuiApplication::instance()->installNativeEventFilter(filter);
|
|
||||||
impl_->nativeEventFilter = filter;
|
|
||||||
|
|
||||||
// 长按定时器
|
|
||||||
impl_->longPressTimer = new QTimer(this);
|
|
||||||
impl_->longPressTimer->setSingleShot(true);
|
|
||||||
impl_->longPressTimer->setInterval(Impl::kLongPressMs);
|
|
||||||
connect(impl_->longPressTimer, &QTimer::timeout, this, [this]() {
|
|
||||||
if (impl_->isHolding && !impl_->longPressFired) {
|
|
||||||
impl_->longPressFired = true;
|
|
||||||
recording_ = true;
|
|
||||||
emit recordingStarted();
|
|
||||||
LOG_DEBUG(kTag, "长按触发,开始录音");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 松开后延迟重置,避免 CapsLock 状态闪烁
|
|
||||||
impl_->keyUpDebounce = new QTimer(this);
|
|
||||||
impl_->keyUpDebounce->setSingleShot(true);
|
|
||||||
impl_->keyUpDebounce->setInterval(200);
|
|
||||||
connect(impl_->keyUpDebounce, &QTimer::timeout, this, [this]() {
|
|
||||||
impl_->isHolding = false;
|
|
||||||
impl_->longPressFired = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
active_ = true;
|
active_ = true;
|
||||||
emit ready();
|
emit ready();
|
||||||
LOG_INFO(kTag, "CapsLock 快捷键已注册");
|
LOG_INFO(kTag, QString("快捷键已注册(%1)").arg(hotkeyCombo_));
|
||||||
return true;
|
return true;
|
||||||
#else
|
#else
|
||||||
emit error("CapsLockVoiceHotkey 仅支持 Windows 平台");
|
emit error("CapsLockVoiceHotkey 仅支持 Windows 平台");
|
||||||
@ -137,15 +189,61 @@ void CapsLockVoiceHotkey::stop() {
|
|||||||
|
|
||||||
#ifdef Q_OS_WIN
|
#ifdef Q_OS_WIN
|
||||||
impl_->pollThreadRunning = false;
|
impl_->pollThreadRunning = false;
|
||||||
|
unregisterHotkey();
|
||||||
|
|
||||||
if (impl_->longPressTimer) {
|
active_ = false;
|
||||||
impl_->longPressTimer->stop();
|
recording_ = false;
|
||||||
}
|
impl_->isHolding = false;
|
||||||
if (impl_->keyUpDebounce) {
|
LOG_INFO(kTag, "快捷键已停止");
|
||||||
impl_->keyUpDebounce->stop();
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
void CapsLockVoiceHotkey::registerHotkey() {
|
||||||
|
if (!impl_->hiddenWindow) {
|
||||||
|
impl_->hiddenWindow = new QWidget();
|
||||||
|
impl_->hiddenWindow->setObjectName("HotkeyReceiver");
|
||||||
|
impl_->hiddenWindow->setWindowFlags(Qt::Tool | Qt::FramelessWindowHint);
|
||||||
|
impl_->hiddenWindow->resize(1, 1);
|
||||||
|
impl_->hiddenWindow->move(-10000, -10000); // 移到屏幕外
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过 winId() 创建原生窗口(不显示)
|
||||||
|
WId wid = impl_->hiddenWindow->winId();
|
||||||
|
HWND hwnd = reinterpret_cast<HWND>(wid);
|
||||||
|
if (!hwnd) {
|
||||||
|
emit error("无法创建窗口句柄");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_->hotkeyId = GlobalAddAtom(L"ImpressVoiceHotkey");
|
||||||
|
|
||||||
|
// MOD_NOREPEAT 防止按住时重复触发 WM_HOTKEY
|
||||||
|
BOOL ok = RegisterHotKey(hwnd, impl_->hotkeyId, modifiers_ | MOD_NOREPEAT, vkKey_);
|
||||||
|
if (!ok) {
|
||||||
|
DWORD err = GetLastError();
|
||||||
|
emit error(QString("注册快捷键 %1 失败 (错误码: %2)").arg(hotkeyCombo_).arg(err));
|
||||||
|
LOG_ERROR(kTag, QString("RegisterHotKey failed: %1").arg(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DEBUG(kTag, QString("热键窗口已注册 (HWND=%1)").arg((qulonglong)hwnd));
|
||||||
|
|
||||||
|
auto* filter = new HotkeyNativeEventFilter(this);
|
||||||
|
QGuiApplication::instance()->installNativeEventFilter(filter);
|
||||||
|
impl_->nativeEventFilter = filter;
|
||||||
|
|
||||||
|
// keyUpTimer 用于检测松开
|
||||||
|
impl_->keyUpTimer = new QTimer(this);
|
||||||
|
impl_->keyUpTimer->setSingleShot(true);
|
||||||
|
impl_->keyUpTimer->setInterval(500); // 最长等待 500ms
|
||||||
|
}
|
||||||
|
|
||||||
|
void CapsLockVoiceHotkey::unregisterHotkey() {
|
||||||
|
if (impl_->keyUpTimer) {
|
||||||
|
impl_->keyUpTimer->stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除原生事件过滤器
|
|
||||||
if (impl_->nativeEventFilter) {
|
if (impl_->nativeEventFilter) {
|
||||||
auto* filter = static_cast<HotkeyNativeEventFilter*>(impl_->nativeEventFilter);
|
auto* filter = static_cast<HotkeyNativeEventFilter*>(impl_->nativeEventFilter);
|
||||||
QGuiApplication::instance()->removeNativeEventFilter(filter);
|
QGuiApplication::instance()->removeNativeEventFilter(filter);
|
||||||
@ -153,9 +251,9 @@ void CapsLockVoiceHotkey::stop() {
|
|||||||
impl_->nativeEventFilter = nullptr;
|
impl_->nativeEventFilter = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注销快捷键
|
|
||||||
if (impl_->hiddenWindow) {
|
if (impl_->hiddenWindow) {
|
||||||
HWND hwnd = reinterpret_cast<HWND>(impl_->hiddenWindow->winId());
|
WId wid = impl_->hiddenWindow->winId();
|
||||||
|
HWND hwnd = reinterpret_cast<HWND>(wid);
|
||||||
if (hwnd && impl_->hotkeyId) {
|
if (hwnd && impl_->hotkeyId) {
|
||||||
UnregisterHotKey(hwnd, impl_->hotkeyId);
|
UnregisterHotKey(hwnd, impl_->hotkeyId);
|
||||||
GlobalDeleteAtom(impl_->hotkeyId);
|
GlobalDeleteAtom(impl_->hotkeyId);
|
||||||
@ -163,60 +261,72 @@ void CapsLockVoiceHotkey::stop() {
|
|||||||
}
|
}
|
||||||
impl_->hiddenWindow->hide();
|
impl_->hiddenWindow->hide();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
active_ = false;
|
|
||||||
recording_ = false;
|
|
||||||
impl_->isHolding = false;
|
|
||||||
impl_->longPressFired = false;
|
|
||||||
LOG_INFO(kTag, "CapsLock 快捷键已停止");
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
void CapsLockVoiceHotkey::toggleRecording() {
|
||||||
|
if (!active_ || ignoreEvents_) return;
|
||||||
|
|
||||||
|
if (recording_) {
|
||||||
|
recording_ = false;
|
||||||
|
emit recordingStopped();
|
||||||
|
} else {
|
||||||
|
recording_ = true;
|
||||||
|
emit recordingStarted();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef Q_OS_WIN
|
#ifdef Q_OS_WIN
|
||||||
void CapsLockVoiceHotkey::onHotkeyEvent(int /*hotkeyId*/) {
|
void CapsLockVoiceHotkey::onHotkeyEvent(int /*hotkeyId*/) {
|
||||||
if (!active_) return;
|
if (!active_ || impl_->isHolding) return;
|
||||||
|
|
||||||
// Windows 只在按键按下时触发 WM_HOTKEY
|
LOG_DEBUG(kTag, "快捷键按下,开始录音");
|
||||||
// 我们通过 GetAsyncKeyState 轮询检测松开
|
recording_ = true;
|
||||||
impl_->isHolding = true;
|
impl_->isHolding = true;
|
||||||
impl_->longPressFired = false;
|
emit recordingStarted();
|
||||||
|
|
||||||
// 启动长按定时器
|
// 启动轮询线程检测松开(任何修饰键或主键松开即停止)
|
||||||
if (impl_->longPressTimer) {
|
|
||||||
impl_->longPressTimer->start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动轮询线程检测松开
|
|
||||||
if (impl_->pollThreadRunning) return;
|
if (impl_->pollThreadRunning) return;
|
||||||
impl_->pollThreadRunning = true;
|
impl_->pollThreadRunning = true;
|
||||||
QThread::create([this]() {
|
|
||||||
const int vkCapsLock = 0x14;
|
const int vk = vkKey_;
|
||||||
|
const int mods = modifiers_;
|
||||||
|
|
||||||
|
QThread::create([this, vk, mods]() {
|
||||||
|
// 等待所有相关键松开
|
||||||
while (impl_->pollThreadRunning && impl_->isHolding) {
|
while (impl_->pollThreadRunning && impl_->isHolding) {
|
||||||
SHORT state = GetAsyncKeyState(vkCapsLock);
|
// 检查主键
|
||||||
if (!(state & 0x8000)) {
|
SHORT mainState = GetAsyncKeyState(vk);
|
||||||
// 按键松开
|
bool mainHeld = (mainState & 0x8000) != 0;
|
||||||
|
|
||||||
|
// 检查修饰键
|
||||||
|
bool ctrlHeld = (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0;
|
||||||
|
bool altHeld = (GetAsyncKeyState(VK_MENU) & 0x8000) != 0;
|
||||||
|
bool shiftHeld = (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0;
|
||||||
|
bool winHeld = (GetAsyncKeyState(VK_LWIN) & 0x8000) != 0 ||
|
||||||
|
(GetAsyncKeyState(VK_RWIN) & 0x8000) != 0;
|
||||||
|
|
||||||
|
bool modsHeld = true;
|
||||||
|
if (mods & MOD_CONTROL && !ctrlHeld) modsHeld = false;
|
||||||
|
if (mods & MOD_ALT && !altHeld) modsHeld = false;
|
||||||
|
if (mods & MOD_SHIFT && !shiftHeld) modsHeld = false;
|
||||||
|
if (mods & MOD_WIN && !winHeld) modsHeld = false;
|
||||||
|
|
||||||
|
if (!mainHeld || !modsHeld) {
|
||||||
|
// 任一组合键松开
|
||||||
impl_->isHolding = false;
|
impl_->isHolding = false;
|
||||||
if (impl_->longPressTimer) {
|
|
||||||
impl_->longPressTimer->stop();
|
|
||||||
}
|
|
||||||
QMetaObject::invokeMethod(this, [this]() {
|
QMetaObject::invokeMethod(this, [this]() {
|
||||||
if (impl_->longPressFired) {
|
if (recording_) {
|
||||||
// 长按 → 停止录音
|
LOG_DEBUG(kTag, "快捷键松开,停止录音");
|
||||||
recording_ = false;
|
recording_ = false;
|
||||||
emit recordingStopped();
|
emit recordingStopped();
|
||||||
LOG_DEBUG(kTag, "长按结束,停止录音");
|
|
||||||
} else {
|
|
||||||
// 短按 → 不处理(让系统处理 CapsLock)
|
|
||||||
LOG_DEBUG(kTag, "短按,不拦截 CapsLock");
|
|
||||||
}
|
|
||||||
impl_->longPressFired = false;
|
|
||||||
if (impl_->keyUpDebounce) {
|
|
||||||
impl_->keyUpDebounce->start();
|
|
||||||
}
|
}
|
||||||
|
impl_->isHolding = false;
|
||||||
}, Qt::QueuedConnection);
|
}, Qt::QueuedConnection);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
QThread::msleep(50);
|
QThread::msleep(30);
|
||||||
}
|
}
|
||||||
impl_->pollThreadRunning = false;
|
impl_->pollThreadRunning = false;
|
||||||
})->start();
|
})->start();
|
||||||
|
|||||||
@ -2,20 +2,15 @@
|
|||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QTimer>
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
namespace impress {
|
namespace impress {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief CapsLock 长按语音输入快捷键管理器(Windows)
|
* @brief 全局语音输入快捷键管理器(Windows)
|
||||||
*
|
*
|
||||||
* 使用 Windows RegisterHotKey API 实现全局快捷键。
|
* 使用 Windows RegisterHotKey API 实现全局快捷键。
|
||||||
* 工作流程:
|
* 切换模式:按一次开始录音,再按一次停止录音并转写。
|
||||||
* 1. 用户长按 CapsLock 1 秒后触发录音
|
|
||||||
* 2. 长按期间持续录音
|
|
||||||
* 3. 松开 CapsLock 后停止录音并触发转写
|
|
||||||
* 4. 短按(< 1s)直接传递 CapsLock 事件(切换大小写锁定)
|
|
||||||
*/
|
*/
|
||||||
class CapsLockVoiceHotkey : public QObject {
|
class CapsLockVoiceHotkey : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
@ -32,20 +27,19 @@ public:
|
|||||||
/** @brief 是否已激活 */
|
/** @brief 是否已激活 */
|
||||||
bool isActive() const { return active_; }
|
bool isActive() const { return active_; }
|
||||||
|
|
||||||
/** @brief 当前是否正在录音(CapsLock 长按超过 1s 后) */
|
/** @brief 当前是否正在录音 */
|
||||||
bool isRecording() const { return recording_; }
|
bool isRecording() const { return recording_; }
|
||||||
|
|
||||||
|
/** @brief 临时忽略信号 */
|
||||||
|
void setIgnoreEvents(bool ignore) { ignoreEvents_ = ignore; }
|
||||||
|
|
||||||
|
/** @brief 设置快捷键组合(如 "Ctrl+Alt+C") */
|
||||||
|
void setHotkeyCombo(const QString& combo);
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
/** @brief 开始录音(长按超过 1 秒后) */
|
|
||||||
void recordingStarted();
|
void recordingStarted();
|
||||||
|
|
||||||
/** @brief 停止录音(松开快捷键后) */
|
|
||||||
void recordingStopped();
|
void recordingStopped();
|
||||||
|
|
||||||
/** @brief 快捷键已注册 */
|
|
||||||
void ready();
|
void ready();
|
||||||
|
|
||||||
/** @brief 初始化失败 */
|
|
||||||
void error(const QString& message);
|
void error(const QString& message);
|
||||||
|
|
||||||
/** @brief 处理 WM_HOTKEY 事件(由原生事件过滤器调用) */
|
/** @brief 处理 WM_HOTKEY 事件(由原生事件过滤器调用) */
|
||||||
@ -57,6 +51,14 @@ private:
|
|||||||
std::unique_ptr<Impl> impl_;
|
std::unique_ptr<Impl> impl_;
|
||||||
bool active_ = false;
|
bool active_ = false;
|
||||||
bool recording_ = false;
|
bool recording_ = false;
|
||||||
|
bool ignoreEvents_ = false;
|
||||||
|
QString hotkeyCombo_;
|
||||||
|
int modifiers_ = 0x0002 | 0x0001; // MOD_CONTROL | MOD_ALT
|
||||||
|
int vkKey_ = 'C';
|
||||||
|
|
||||||
|
void registerHotkey();
|
||||||
|
void unregisterHotkey();
|
||||||
|
void toggleRecording();
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace impress
|
} // namespace impress
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
#include "app/config_manager.h"
|
#include "app/config_manager.h"
|
||||||
#include "app/application.h"
|
#include "app/application.h"
|
||||||
#include "utils/logger.h"
|
#include "utils/logger.h"
|
||||||
|
#include "widgets/recording_indicator.h"
|
||||||
|
|
||||||
#include <QMenuBar>
|
#include <QMenuBar>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
@ -124,6 +125,17 @@ MainWindow::MainWindow(ConfigManager* configManager,
|
|||||||
});
|
});
|
||||||
LOG_INFO(kTag, "VoiceInputService 已创建");
|
LOG_INFO(kTag, "VoiceInputService 已创建");
|
||||||
|
|
||||||
|
// 创建录音指示器
|
||||||
|
recordingIndicator_ = new RecordingIndicator(this);
|
||||||
|
connect(voiceInputService_, &VoiceInputService::statusChanged,
|
||||||
|
this, [this](const QString& status) {
|
||||||
|
if (status.contains("正在录音")) {
|
||||||
|
recordingIndicator_->showAtCursor();
|
||||||
|
} else {
|
||||||
|
recordingIndicator_->hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 监听配置变化,动态启停语音输入服务
|
// 监听配置变化,动态启停语音输入服务
|
||||||
connect(configManager_, &ConfigManager::configChanged,
|
connect(configManager_, &ConfigManager::configChanged,
|
||||||
this, &MainWindow::onVoiceInputConfigChanged);
|
this, &MainWindow::onVoiceInputConfigChanged);
|
||||||
@ -352,7 +364,7 @@ void MainWindow::showUsage() {
|
|||||||
|
|
||||||
"<h3>二、快捷键操作</h3>"
|
"<h3>二、快捷键操作</h3>"
|
||||||
"<table cellpadding='4' cellspacing='0'>"
|
"<table cellpadding='4' cellspacing='0'>"
|
||||||
"<tr><td><b>语音输入</b></td><td>长按 CapsLock 超过 1 秒(可在配置中自定义)</td></tr>"
|
"<tr><td><b>语音输入</b></td><td>Ctrl+Alt+C(切换模式,可在配置中自定义)</td></tr>"
|
||||||
"<tr><td><b>使用说明</b></td><td>F1</td></tr>"
|
"<tr><td><b>使用说明</b></td><td>F1</td></tr>"
|
||||||
"<tr><td><b>重启应用</b></td><td>Ctrl+R</td></tr>"
|
"<tr><td><b>重启应用</b></td><td>Ctrl+R</td></tr>"
|
||||||
"<tr><td><b>退出应用</b></td><td>Ctrl+Q</td></tr>"
|
"<tr><td><b>退出应用</b></td><td>Ctrl+Q</td></tr>"
|
||||||
@ -361,12 +373,12 @@ void MainWindow::showUsage() {
|
|||||||
"<h3>三、语音输入使用流程</h3>"
|
"<h3>三、语音输入使用流程</h3>"
|
||||||
"<ol>"
|
"<ol>"
|
||||||
"<li>在<b>配置</b>页面中设置正确的 STT 模型路径并保存。</li>"
|
"<li>在<b>配置</b>页面中设置正确的 STT 模型路径并保存。</li>"
|
||||||
"<li>设置语音输入快捷键(默认长按 CapsLock)。</li>"
|
"<li>语音输入快捷键默认为 Ctrl+Alt+C(可在配置中自定义)。</li>"
|
||||||
"<li>将光标定位到需要输入文字的目标应用(如微信、Word、浏览器等)。</li>"
|
"<li>将光标定位到需要输入文字的目标应用(如微信、Word、浏览器等)。</li>"
|
||||||
"<li>长按快捷键开始说话,说完后松开快捷键。</li>"
|
"<li>按下快捷键开始录音,说完后再次按下快捷键停止录音并自动转写。</li>"
|
||||||
"<li>识别的文字将通过模拟按键自动输入到目标应用中。</li>"
|
"<li>识别的文字将通过模拟按键自动输入到目标应用中。</li>"
|
||||||
"</ol>"
|
"</ol>"
|
||||||
"<p><b>CapsLock 工作模式:</b>短按(<1 秒)正常切换大小写锁定;长按(>1 秒)触发语音输入,松开后自动识别并注入文字。</p>"
|
"<p><b>切换模式:</b>第一次按下快捷键开始录音(光标位置显示录音指示器),再次按下停止录音并自动转写。</p>"
|
||||||
|
|
||||||
"<h3>四、文件转写使用流程</h3>"
|
"<h3>四、文件转写使用流程</h3>"
|
||||||
"<ol>"
|
"<ol>"
|
||||||
@ -412,8 +424,8 @@ void MainWindow::showUsage() {
|
|||||||
"A: 某些应用可能拦截模拟按键输入,请尝试在管理员权限下运行本程序。</p>"
|
"A: 某些应用可能拦截模拟按键输入,请尝试在管理员权限下运行本程序。</p>"
|
||||||
"<p><b>Q: 识别速度慢?</b><br/>"
|
"<p><b>Q: 识别速度慢?</b><br/>"
|
||||||
"A: 在配置中增大 ONNX 线程数,或使用 GPU 版本的 ONNX Runtime。</p>"
|
"A: 在配置中增大 ONNX 线程数,或使用 GPU 版本的 ONNX Runtime。</p>"
|
||||||
"<p><b>Q: CapsLock 短按不起作用?</b><br/>"
|
"<p><b>Q: 快捷键不生效?</b><br/>"
|
||||||
"A: 请确保按键时间小于 1 秒,超过 1 秒会触发语音输入模式。</p>";
|
"A: 检查是否与其他应用快捷键冲突,或在配置中修改为其他组合键。</p>";
|
||||||
|
|
||||||
// 使用可调整大小、可滚动的 QDialog 替代 QMessageBox
|
// 使用可调整大小、可滚动的 QDialog 替代 QMessageBox
|
||||||
QDialog* dialog = new QDialog(this);
|
QDialog* dialog = new QDialog(this);
|
||||||
|
|||||||
@ -17,6 +17,7 @@ class STTTestPage;
|
|||||||
class FileTranscribePage;
|
class FileTranscribePage;
|
||||||
class SettingsPage;
|
class SettingsPage;
|
||||||
class VoiceInputService;
|
class VoiceInputService;
|
||||||
|
class RecordingIndicator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief 主窗口
|
* @brief 主窗口
|
||||||
@ -60,6 +61,7 @@ private:
|
|||||||
QMenu* trayMenu_ = nullptr;
|
QMenu* trayMenu_ = nullptr;
|
||||||
QIcon idleIcon_; // SP_MediaStop — 就绪/停止
|
QIcon idleIcon_; // SP_MediaStop — 就绪/停止
|
||||||
QIcon activeIcon_; // SP_MediaPlay — 录音/识别
|
QIcon activeIcon_; // SP_MediaPlay — 录音/识别
|
||||||
|
RecordingIndicator* recordingIndicator_ = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace impress
|
} // namespace impress
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
#include "hotkey_recorder.h"
|
#include "hotkey_recorder.h"
|
||||||
|
#include "utils/logger.h"
|
||||||
|
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QKeyEvent>
|
#include <QKeyEvent>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
|
|
||||||
|
static const char* const kTag = "HotkeyRecorder";
|
||||||
|
|
||||||
namespace impress {
|
namespace impress {
|
||||||
|
|
||||||
HotkeyRecorder::HotkeyRecorder(const QString& label, QWidget* parent)
|
HotkeyRecorder::HotkeyRecorder(const QString& label, QWidget* parent)
|
||||||
@ -19,11 +22,12 @@ HotkeyRecorder::HotkeyRecorder(const QString& label, QWidget* parent)
|
|||||||
btn_ = new QPushButton(this);
|
btn_ = new QPushButton(this);
|
||||||
btn_->setMinimumWidth(140);
|
btn_->setMinimumWidth(140);
|
||||||
btn_->setFocusPolicy(Qt::StrongFocus);
|
btn_->setFocusPolicy(Qt::StrongFocus);
|
||||||
|
btn_->installEventFilter(this); // 拦截按钮的键盘事件
|
||||||
connect(btn_, &QPushButton::clicked, this, &HotkeyRecorder::onToggleRecording);
|
connect(btn_, &QPushButton::clicked, this, &HotkeyRecorder::onToggleRecording);
|
||||||
layout->addWidget(btn_);
|
layout->addWidget(btn_);
|
||||||
layout->addStretch();
|
layout->addStretch();
|
||||||
|
|
||||||
setHotkeyText("CapsLock");
|
setHotkeyText("Ctrl+Alt+C");
|
||||||
applyStyle();
|
applyStyle();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,11 +48,13 @@ void HotkeyRecorder::onToggleRecording() {
|
|||||||
if (recording_) {
|
if (recording_) {
|
||||||
// 取消录制
|
// 取消录制
|
||||||
recording_ = false;
|
recording_ = false;
|
||||||
|
LOG_INFO(kTag, "录制模式已取消");
|
||||||
updateDisplay();
|
updateDisplay();
|
||||||
applyStyle();
|
applyStyle();
|
||||||
} else {
|
} else {
|
||||||
// 进入录制模式
|
// 进入录制模式
|
||||||
recording_ = true;
|
recording_ = true;
|
||||||
|
LOG_INFO(kTag, "进入录制模式,等待按键...");
|
||||||
updateDisplay();
|
updateDisplay();
|
||||||
applyStyle();
|
applyStyle();
|
||||||
btn_->setFocus();
|
btn_->setFocus();
|
||||||
@ -61,15 +67,18 @@ bool HotkeyRecorder::eventFilter(QObject* obj, QEvent* event) {
|
|||||||
|
|
||||||
if (event->type() == QEvent::KeyPress) {
|
if (event->type() == QEvent::KeyPress) {
|
||||||
auto* ke = static_cast<QKeyEvent*>(event);
|
auto* ke = static_cast<QKeyEvent*>(event);
|
||||||
|
LOG_INFO(kTag, QString("捕获按键: key=%1 modifiers=%2").arg(ke->key()).arg(static_cast<int>(ke->modifiers())));
|
||||||
|
|
||||||
// 忽略单独的修饰键
|
// 忽略单独的修饰键
|
||||||
if (ke->key() == Qt::Key_Control || ke->key() == Qt::Key_Alt ||
|
if (ke->key() == Qt::Key_Control || ke->key() == Qt::Key_Alt ||
|
||||||
ke->key() == Qt::Key_Shift || ke->key() == Qt::Key_Meta) {
|
ke->key() == Qt::Key_Shift || ke->key() == Qt::Key_Meta) {
|
||||||
|
LOG_DEBUG(kTag, "忽略单独修饰键");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Esc 取消录制
|
// Esc 取消录制
|
||||||
if (ke->key() == Qt::Key_Escape) {
|
if (ke->key() == Qt::Key_Escape) {
|
||||||
|
LOG_INFO(kTag, "Esc 取消录制");
|
||||||
recording_ = false;
|
recording_ = false;
|
||||||
updateDisplay();
|
updateDisplay();
|
||||||
applyStyle();
|
applyStyle();
|
||||||
@ -94,6 +103,7 @@ bool HotkeyRecorder::eventFilter(QObject* obj, QEvent* event) {
|
|||||||
|
|
||||||
hotkeyText_ = text;
|
hotkeyText_ = text;
|
||||||
recording_ = false;
|
recording_ = false;
|
||||||
|
LOG_INFO(kTag, QString("快捷键已设置: %1").arg(hotkeyText_));
|
||||||
updateDisplay();
|
updateDisplay();
|
||||||
applyStyle();
|
applyStyle();
|
||||||
btn_->releaseKeyboard();
|
btn_->releaseKeyboard();
|
||||||
|
|||||||
131
src/ui/widgets/recording_indicator.cpp
Normal file
131
src/ui/widgets/recording_indicator.cpp
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
#include "recording_indicator.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QCursor>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QMovie>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QScreen>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
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<Impl>())
|
||||||
|
{
|
||||||
|
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
|
||||||
41
src/ui/widgets/recording_indicator.h
Normal file
41
src/ui/widgets/recording_indicator.h
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QWidget>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
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> impl_;
|
||||||
|
bool isShowing_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace impress
|
||||||
Loading…
Reference in New Issue
Block a user