setHotkey/setHotkeyRaw now clear hasCustomDictationKey_ to prevent stale custom combo from persisting after switching to a preset modifier. setCustomDictationTrigger now persists TriggerRawSym=TriggerRawStates=0 to raw config so the old raw trigger doesn't reload after fcitx5 restart. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
433 lines
17 KiB
C++
433 lines
17 KiB
C++
/*
|
||
* SPDX-FileCopyrightText: 2025 OpenLess Contributors
|
||
*
|
||
* SPDX-License-Identifier: LGPL-2.1-or-later
|
||
*
|
||
* fcitx5 插件 — 供 OpenLess 听写文字提交 + 快捷键监听。
|
||
*
|
||
* DBus 接口: org.fcitx.Fcitx.OpenLess1 (对象路径 /openless)
|
||
* 方法:
|
||
* CommitText(s: text) — 将文字提交到当前焦点输入上下文
|
||
* 安全性:本接口在会话总线(session bus)上对同用户
|
||
* 所有进程开放,此为 fcitx5/IBus 体系的标准安全模型
|
||
* (非特权进程隔离)。
|
||
* SetHotkey(as: keys) — 设置听写触发快捷键 (Key::parse 格式)
|
||
* SetHotkeyRaw(uu: sym, states) — 直接设听写触发 sym+states (不走 parse)
|
||
* SetCustomDictationTrigger(s: keyString) — 设置自定义组合键 (Key::parse 格式)
|
||
* SetQaHotkeyRaw(uu: sym, states) — 直接设 QA 面板触发 sym+states
|
||
* SetTranslationHotkeyRaw(uu: sym, states) — 直接设翻译模式触发 sym+states
|
||
* 信号:
|
||
* DictationKeyEvent(uub: sym, states, isPress) — 听写热键按下/抬起
|
||
* QaShortcutEvent(uub: sym, states, isPress) — QA 快捷键按下/抬起
|
||
* TranslationModifierEvent(uub: sym, states, isPress) — 翻译修饰键按下/抬起
|
||
*/
|
||
|
||
#include <memory>
|
||
#include <string>
|
||
#include <vector>
|
||
|
||
#include <fcitx-config/configuration.h>
|
||
#include <fcitx-config/iniparser.h>
|
||
#include <fcitx-config/option.h>
|
||
#include <fcitx-utils/dbus/bus.h>
|
||
#include <fcitx-utils/dbus/objectvtable.h>
|
||
#include <fcitx-utils/handlertable.h>
|
||
#include <fcitx-utils/i18n.h>
|
||
#include <fcitx-utils/key.h>
|
||
#include <fcitx-utils/log.h>
|
||
#include <fcitx/addonfactory.h>
|
||
#include <fcitx/addoninstance.h>
|
||
#include <fcitx/addonmanager.h>
|
||
#include <fcitx/event.h>
|
||
#include <fcitx/inputcontext.h>
|
||
#include <fcitx/inputcontextmanager.h>
|
||
#include <fcitx/instance.h>
|
||
#include <fcitx-module/dbus/dbus_public.h>
|
||
|
||
FCITX_DEFINE_LOG_CATEGORY(openless, "openless");
|
||
|
||
namespace fcitx {
|
||
|
||
FCITX_CONFIGURATION(OpenLessConfig,
|
||
KeyListOption triggerKey{this,
|
||
"TriggerKey",
|
||
_("Dictation trigger key"),
|
||
{},
|
||
KeyListConstrain()};
|
||
);
|
||
|
||
class OpenLess final : public AddonInstance,
|
||
public dbus::ObjectVTable<OpenLess> {
|
||
public:
|
||
OpenLess(Instance *instance)
|
||
: instance_(instance),
|
||
triggerRawSym_(0),
|
||
triggerRawStates_(0),
|
||
qaRawSym_(0),
|
||
qaRawStates_(0),
|
||
translationRawSym_(0),
|
||
translationRawStates_(0),
|
||
hasCustomDictationKey_(false),
|
||
savedIc_(nullptr) {
|
||
|
||
// 1. 读取配置
|
||
reloadConfig();
|
||
|
||
// 2. 注册 DBus 接口
|
||
auto *dbusMod = instance_->addonManager().addon("dbus", true);
|
||
if (dbusMod) {
|
||
auto *bus = dbusMod->call<IDBusModule::bus>();
|
||
if (bus) {
|
||
bus->addObjectVTable(
|
||
"/openless",
|
||
"org.fcitx.Fcitx.OpenLess1",
|
||
*this);
|
||
FCITX_LOGC(openless, Info)
|
||
<< "DBus interface registered at /openless";
|
||
} else {
|
||
FCITX_LOGC(openless, Warn)
|
||
<< "Failed to get DBus bus";
|
||
}
|
||
} else {
|
||
FCITX_LOGC(openless, Warn)
|
||
<< "DBus module not available";
|
||
}
|
||
|
||
// 3. 注册快捷键事件监听
|
||
eventHandlers_.push_back(
|
||
instance_->watchEvent(
|
||
EventType::InputContextKeyEvent,
|
||
EventWatcherPhase::PreInputMethod,
|
||
[this](Event &event) {
|
||
auto &keyEvent = static_cast<KeyEvent &>(event);
|
||
// 保存当前输入上下文:快捷键按下时用户在目标 app 中,
|
||
// 此后胶囊窗口可能抢走焦点,但 commitText 仍能用此 IC 提交文字。
|
||
if (!keyEvent.isRelease()) {
|
||
savedIc_ = keyEvent.inputContext();
|
||
}
|
||
|
||
auto sym = static_cast<uint32_t>(keyEvent.key().sym());
|
||
auto states = static_cast<uint32_t>(keyEvent.key().states());
|
||
bool isPress = !keyEvent.isRelease();
|
||
|
||
// 检查自定义组合键(优先级最高)
|
||
if (hasCustomDictationKey_ &&
|
||
keyEvent.key().sym() == customDictationKey_.sym() &&
|
||
keyEvent.key().states() == customDictationKey_.states()) {
|
||
FCITX_LOGC(openless, Debug)
|
||
<< "Custom dictation combo: sym=" << sym
|
||
<< " states=" << states
|
||
<< " isPress=" << isPress;
|
||
dictationKeyEvent(
|
||
static_cast<uint32_t>(customDictationKey_.sym()),
|
||
static_cast<uint32_t>(customDictationKey_.states()),
|
||
isPress);
|
||
keyEvent.filterAndAccept();
|
||
return;
|
||
}
|
||
|
||
// 检查听写触发键(raw + keylist 双路径)
|
||
if ((triggerRawSym_ != 0 &&
|
||
sym == triggerRawSym_ &&
|
||
states == triggerRawStates_) ||
|
||
(triggerRawSym_ == 0 && [&]() {
|
||
for (const auto &hk : triggerKeyList_) {
|
||
if (sym == static_cast<uint32_t>(hk.sym()) &&
|
||
states == static_cast<uint32_t>(hk.states()))
|
||
return true;
|
||
}
|
||
return false;
|
||
}())) {
|
||
auto dsym = triggerRawSym_ != 0
|
||
? triggerRawSym_
|
||
: static_cast<uint32_t>(triggerKeyList_[0].sym());
|
||
auto dstates = triggerRawStates_ != 0
|
||
? triggerRawStates_
|
||
: static_cast<uint32_t>(triggerKeyList_[0].states());
|
||
FCITX_LOGC(openless, Debug)
|
||
<< "Dictation hotkey: sym=" << dsym
|
||
<< " states=" << dstates
|
||
<< " isPress=" << isPress;
|
||
dictationKeyEvent(dsym, dstates, isPress);
|
||
keyEvent.filterAndAccept();
|
||
return;
|
||
}
|
||
|
||
// 检查 QA 快捷键
|
||
if (qaRawSym_ != 0 &&
|
||
sym == qaRawSym_ &&
|
||
states == qaRawStates_) {
|
||
FCITX_LOGC(openless, Debug)
|
||
<< "QA shortcut: sym=" << qaRawSym_
|
||
<< " states=" << qaRawStates_
|
||
<< " isPress=" << isPress;
|
||
qaShortcutEvent(qaRawSym_, qaRawStates_, isPress);
|
||
keyEvent.filterAndAccept();
|
||
return;
|
||
}
|
||
|
||
// 检查翻译模式修饰键(自定义 + 内置 Shift)
|
||
bool translationMatched = false;
|
||
if (translationRawSym_ != 0 &&
|
||
sym == translationRawSym_ &&
|
||
states == translationRawStates_) {
|
||
translationMatched = true;
|
||
}
|
||
// 内置 Shift 修饰键
|
||
if (sym == 0xffe1 || sym == 0xffe2) {
|
||
translationMatched = true;
|
||
}
|
||
if (translationMatched) {
|
||
FCITX_LOGC(openless, Debug)
|
||
<< "Translation modifier: sym=" << sym
|
||
<< " states=" << states
|
||
<< " isPress=" << isPress;
|
||
translationModifierEvent(sym, states, isPress);
|
||
keyEvent.filterAndAccept();
|
||
return;
|
||
}
|
||
}));
|
||
|
||
// 4. 监听 InputContext 销毁事件,自动清空 savedIc_ 避免野指针
|
||
eventHandlers_.push_back(
|
||
instance_->watchEvent(
|
||
EventType::InputContextDestroyed,
|
||
EventWatcherPhase::Default,
|
||
[this](Event &event) {
|
||
auto &icEvent = static_cast<InputContextEvent &>(event);
|
||
if (icEvent.inputContext() == savedIc_) {
|
||
savedIc_ = nullptr;
|
||
}
|
||
}));
|
||
|
||
FCITX_LOGC(openless, Info) << "OpenLess plugin loaded";
|
||
}
|
||
|
||
~OpenLess() = default;
|
||
|
||
// ---- DBus 方法 ----
|
||
// 返回 void 而非 std::tuple<>,以匹配 FCITX_OBJECT_VTABLE_METHOD 的 RET("")
|
||
|
||
void commitText(const std::string &text) {
|
||
// 优先使用快捷键按下时保存的输入上下文(savedIc_),
|
||
// 此时用户在目标 app 中,此后胶囊窗口抢焦点不影响提交。
|
||
// 若 savedIc_ 为空则兜底用 foreachFocused。
|
||
auto *ic = savedIc_;
|
||
if (!ic) {
|
||
FCITX_LOGC(openless, Warn)
|
||
<< "CommitText: savedIc_ is null, trying foreachFocused";
|
||
auto &mgr = instance_->inputContextManager();
|
||
mgr.foreachFocused([&](InputContext *focusedIc) {
|
||
ic = focusedIc;
|
||
return false;
|
||
});
|
||
}
|
||
if (!ic) {
|
||
FCITX_LOGC(openless, Warn)
|
||
<< "CommitText: no input context available";
|
||
throw std::runtime_error("no focused input context");
|
||
}
|
||
FCITX_LOGC(openless, Debug) << "CommitText: " << text;
|
||
ic->commitString(text);
|
||
}
|
||
|
||
void setHotkey(const std::vector<std::string> &keys) {
|
||
// 切换预设修饰键时清空自定义组合键,避免双发
|
||
hasCustomDictationKey_ = false;
|
||
KeyList keyList;
|
||
for (const auto &s : keys) {
|
||
Key key(s);
|
||
if (key.isValid()) {
|
||
keyList.push_back(key);
|
||
} else {
|
||
FCITX_LOGC(openless, Warn)
|
||
<< "SetHotkey: invalid key '" << s << "'";
|
||
}
|
||
}
|
||
config_.triggerKey.setValue(keyList);
|
||
// KeyList 路径激活时清空 raw 路径,避免优先级冲突
|
||
triggerRawSym_ = 0;
|
||
triggerRawStates_ = 0;
|
||
safeSaveAsIni(config_, configFile());
|
||
// 同时清除磁盘上残留的 TriggerRawSym/TriggerRawStates(旧 raw 模式的持久化值),
|
||
// 防止下次 fcitx5 重启 reloadConfig 重新加载旧 raw 热键覆盖新配置。
|
||
{
|
||
RawConfig raw;
|
||
readAsIni(raw, configFile());
|
||
raw.setValueByPath("TriggerRawSym", "0");
|
||
raw.setValueByPath("TriggerRawStates", "0");
|
||
safeSaveAsIni(raw, configFile());
|
||
}
|
||
rebuildTriggerKeys();
|
||
}
|
||
|
||
void setHotkeyRaw(uint32_t sym, uint32_t states) {
|
||
// 切换预设修饰键时清空自定义组合键,避免双发
|
||
hasCustomDictationKey_ = false;
|
||
triggerRawSym_ = sym;
|
||
triggerRawStates_ = states;
|
||
// 同时尝试维护 KeyList(如果 sym 可转为有效 key)
|
||
Key key(static_cast<KeySym>(sym),
|
||
static_cast<KeyStates>(states));
|
||
if (key.isValid()) {
|
||
KeyList keys = {key};
|
||
config_.triggerKey.setValue(keys);
|
||
} else {
|
||
// 修饰键无法用 KeyList 表达,清空 KeyList 避免误匹配
|
||
config_.triggerKey.setValue(KeyList{});
|
||
}
|
||
// 合并写入 config 和 raw sym/states
|
||
RawConfig raw;
|
||
raw.setValueByPath("TriggerRawSym", std::to_string(sym));
|
||
raw.setValueByPath("TriggerRawStates", std::to_string(states));
|
||
config_.save(raw);
|
||
safeSaveAsIni(raw, configFile());
|
||
rebuildTriggerKeys();
|
||
}
|
||
|
||
void setCustomDictationTrigger(const std::string &keyString) {
|
||
Key key(keyString);
|
||
if (!key.isValid()) {
|
||
FCITX_LOGC(openless, Warn)
|
||
<< "SetCustomDictationTrigger: invalid key '" << keyString << "'";
|
||
hasCustomDictationKey_ = false;
|
||
return;
|
||
}
|
||
customDictationKey_ = key;
|
||
hasCustomDictationKey_ = true;
|
||
// 有自定义键时清空已有 raw+keylist 路径,避免双发
|
||
triggerRawSym_ = 0;
|
||
triggerRawStates_ = 0;
|
||
config_.triggerKey.setValue(KeyList{});
|
||
// 同时持久化清空 TriggerRawSym/TriggerRawStates,防止 fcitx5 重启后从 INI 加载旧值
|
||
{
|
||
RawConfig raw;
|
||
readAsIni(raw, configFile());
|
||
config_.save(raw);
|
||
raw.setValueByPath("TriggerRawSym", "0");
|
||
raw.setValueByPath("TriggerRawStates", "0");
|
||
safeSaveAsIni(raw, configFile());
|
||
}
|
||
FCITX_LOGC(openless, Info)
|
||
<< "SetCustomDictationTrigger: '" << keyString << "'"
|
||
<< " sym=" << static_cast<uint32_t>(key.sym())
|
||
<< " states=" << static_cast<uint32_t>(key.states());
|
||
}
|
||
|
||
void setQaHotkeyRaw(uint32_t sym, uint32_t states) {
|
||
qaRawSym_ = sym;
|
||
qaRawStates_ = states;
|
||
RawConfig raw;
|
||
readAsIni(raw, configFile());
|
||
raw.setValueByPath("QaRawSym", std::to_string(sym));
|
||
raw.setValueByPath("QaRawStates", std::to_string(states));
|
||
safeSaveAsIni(raw, configFile());
|
||
FCITX_LOGC(openless, Info)
|
||
<< "SetQaHotkeyRaw: sym=" << sym << " states=" << states;
|
||
}
|
||
|
||
void setTranslationHotkeyRaw(uint32_t sym, uint32_t states) {
|
||
translationRawSym_ = sym;
|
||
translationRawStates_ = states;
|
||
RawConfig raw;
|
||
readAsIni(raw, configFile());
|
||
raw.setValueByPath("TranslationRawSym", std::to_string(sym));
|
||
raw.setValueByPath("TranslationRawStates", std::to_string(states));
|
||
safeSaveAsIni(raw, configFile());
|
||
FCITX_LOGC(openless, Info)
|
||
<< "SetTranslationHotkeyRaw: sym=" << sym << " states=" << states;
|
||
}
|
||
|
||
FCITX_OBJECT_VTABLE_METHOD(commitText, "CommitText", "s", "");
|
||
FCITX_OBJECT_VTABLE_METHOD(setHotkey, "SetHotkey", "as", "");
|
||
FCITX_OBJECT_VTABLE_METHOD(setHotkeyRaw, "SetHotkeyRaw", "uu", "");
|
||
FCITX_OBJECT_VTABLE_METHOD(setCustomDictationTrigger, "SetCustomDictationTrigger", "s", "");
|
||
FCITX_OBJECT_VTABLE_METHOD(setQaHotkeyRaw, "SetQaHotkeyRaw", "uu", "");
|
||
FCITX_OBJECT_VTABLE_METHOD(setTranslationHotkeyRaw, "SetTranslationHotkeyRaw", "uu", "");
|
||
FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub");
|
||
FCITX_OBJECT_VTABLE_SIGNAL(qaShortcutEvent, "QaShortcutEvent", "uub");
|
||
FCITX_OBJECT_VTABLE_SIGNAL(translationModifierEvent, "TranslationModifierEvent", "uub");
|
||
|
||
Instance *instance() { return instance_; }
|
||
|
||
void reloadConfig() override {
|
||
readAsIni(config_, configFile());
|
||
// 加载原始 sym/states(由 SetHotkeyRaw / SetQaHotkeyRaw / SetTranslationHotkeyRaw 写入的持久化键值)
|
||
RawConfig raw;
|
||
readAsIni(raw, configFile());
|
||
{
|
||
auto *v = raw.valueByPath("TriggerRawSym");
|
||
triggerRawSym_ = v ? std::stoul(*v, nullptr, 0) : 0;
|
||
}
|
||
{
|
||
auto *v = raw.valueByPath("TriggerRawStates");
|
||
triggerRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0;
|
||
}
|
||
{
|
||
auto *v = raw.valueByPath("QaRawSym");
|
||
qaRawSym_ = v ? std::stoul(*v, nullptr, 0) : 0;
|
||
}
|
||
{
|
||
auto *v = raw.valueByPath("QaRawStates");
|
||
qaRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0;
|
||
}
|
||
{
|
||
auto *v = raw.valueByPath("TranslationRawSym");
|
||
translationRawSym_ = v ? std::stoul(*v, nullptr, 0) : 0;
|
||
}
|
||
{
|
||
auto *v = raw.valueByPath("TranslationRawStates");
|
||
translationRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0;
|
||
}
|
||
rebuildTriggerKeys();
|
||
}
|
||
|
||
const Configuration *getConfig() const override {
|
||
return &config_;
|
||
}
|
||
|
||
void setConfig(const RawConfig &rawConfig) override {
|
||
config_.load(rawConfig, true);
|
||
safeSaveAsIni(config_, configFile());
|
||
rebuildTriggerKeys();
|
||
}
|
||
|
||
private:
|
||
static constexpr const char *configFile() {
|
||
return "conf/openless.conf";
|
||
}
|
||
|
||
void rebuildTriggerKeys() {
|
||
triggerKeyList_ = config_.triggerKey.value();
|
||
}
|
||
|
||
Instance *instance_;
|
||
OpenLessConfig config_;
|
||
KeyList triggerKeyList_;
|
||
uint32_t triggerRawSym_;
|
||
uint32_t triggerRawStates_;
|
||
uint32_t qaRawSym_;
|
||
uint32_t qaRawStates_;
|
||
uint32_t translationRawSym_;
|
||
uint32_t translationRawStates_;
|
||
Key customDictationKey_;
|
||
bool hasCustomDictationKey_;
|
||
/// 快捷键按下时保存的输入上下文指针,用于 commitText 在失焦后仍能提交文字。
|
||
/// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。
|
||
/// 通过 InputContextDestroyed 事件监听 IC 销毁时自动清空指针。
|
||
InputContext *savedIc_;
|
||
std::vector<std::unique_ptr<HandlerTableEntry<EventHandler>>>
|
||
eventHandlers_;
|
||
};
|
||
|
||
class OpenLessFactory : public AddonFactory {
|
||
public:
|
||
AddonInstance *create(AddonManager *manager) override {
|
||
return new OpenLess(manager->instance());
|
||
}
|
||
};
|
||
|
||
} // namespace fcitx
|
||
|
||
FCITX_ADDON_FACTORY(fcitx::OpenLessFactory);
|