feat: 实现 Linux Fcitx5 实时输入链路

加入 Unix Socket v2 协议、会话状态机、动态快捷键、完整 preedit 更新、结束提交以及断线重连和安全取消。

AI-assisted-by: Codex
AI-model: gpt-5.6-sol
AI-reasoning-effort: medium
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
2026-08-23 02:34:14 +08:00
co-authored by Codex
parent f1f41f4f48
commit 6ff1d3bf77
13 changed files with 1265 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
cmake_minimum_required(VERSION 3.16)
project(meloasr-fcitx5 VERSION 0.1.0 LANGUAGES CXX)
include(GNUInstallDirs)
include(CTest)
find_package(Fcitx5Core 5.0 REQUIRED)
find_package(Fcitx5Utils 5.0 REQUIRED)
find_package(nlohmann_json 3.2 REQUIRED)
add_library(meloasr MODULE voiceinput.cpp)
target_compile_features(meloasr PRIVATE cxx_std_20)
target_compile_options(meloasr PRIVATE -Wall -Wextra -Wpedantic)
target_link_libraries(meloasr PRIVATE Fcitx5::Core Fcitx5::Utils nlohmann_json::nlohmann_json)
set_target_properties(meloasr PROPERTIES PREFIX "lib")
set(MELOASR_LIBRARY_PATH "${FCITX_INSTALL_ADDONDIR}/libmeloasr")
configure_file(meloasr.conf.in meloasr.conf @ONLY)
install(TARGETS meloasr LIBRARY DESTINATION "${FCITX_INSTALL_ADDONDIR}")
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/meloasr.conf" DESTINATION "${FCITX_INSTALL_PKGDATADIR}/addon")
if(BUILD_TESTING)
add_executable(protocolsession_test protocolsession_test.cpp)
target_compile_features(protocolsession_test PRIVATE cxx_std_17)
add_test(NAME protocol-session COMMAND protocolsession_test)
endif()
+25
View File
@@ -0,0 +1,25 @@
# MeloASR Fcitx5 addon
该 addon 作为 Fcitx5 常驻 Module 工作,不需要切换到独立输入法。
- 按住设置的快捷键:向 Electron 请求开始语音输入;
- 松开主键:请求停止;
- `Esc`、普通键盘输入、焦点丢失、InputContext reset 或 Socket 断开:取消且不提交;
- Electron 每次发送网页编辑器的完整文本时,addon 整体替换 client preedit;
- `finish` 只调用一次 `commitString()`。
用户级安装:
```bash
./linux/fcitx5/install-user.sh
fcitx5 -r
```
安装文件:
```text
~/.local/lib/fcitx5/libmeloasr.so
~/.local/share/fcitx5/addon/meloasr.conf
```
本地协议使用 `$XDG_RUNTIME_DIR/meloasr/fcitx5.sock`,目录权限 `0700`,Socket 权限 `0600`。协议版本为 2,支持 `configure` 动态快捷键和 `activate` 悬浮球启动请求。
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
build_dir="${project_dir}/build"
cmake -S "${project_dir}" -B "${build_dir}" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="${HOME}/.local" \
-DCMAKE_INSTALL_LIBDIR=lib \
-DCMAKE_INSTALL_DATADIR=share
cmake --build "${build_dir}" --parallel
cmake --install "${build_dir}"
# 0.1.0 之前的原型使用 voiceinput 名称;升级时移除,避免两个常驻模块同时监听快捷键。
rm -f -- \
"${HOME}/.local/lib/fcitx5/libvoiceinput.so" \
"${HOME}/.local/share/fcitx5/addon/voiceinput.conf"
echo "已安装 MeloASR Fcitx5 插件,请重启 Fcitx5。"
+14
View File
@@ -0,0 +1,14 @@
[Addon]
Name=MeloASR
Name[zh_CN]=MeloASR
Comment=Connect MeloASR web backends to the active input context
Comment[zh_CN]=将 MeloASR 网页后端连接到当前输入上下文
Type=SharedLibrary
Library=@MELOASR_LIBRARY_PATH@
Category=Module
Version=0.1.0
OnDemand=False
Configurable=False
[Addon/Dependencies]
0=core:5.0.0
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <cstdint>
#include <string>
#include <utility>
class ProtocolSession {
public:
void start(std::string sessionId) {
sessionId_ = std::move(sessionId);
revision_ = 0;
}
bool active() const { return !sessionId_.empty(); }
bool accept(const std::string &sessionId, std::int64_t revision) {
if (!active() || sessionId != sessionId_ || revision <= revision_) return false;
revision_ = revision;
return true;
}
bool matches(const std::string &sessionId) const {
return active() && sessionId == sessionId_;
}
void reset() {
sessionId_.clear();
revision_ = 0;
}
private:
std::string sessionId_;
std::int64_t revision_ = 0;
};
+21
View File
@@ -0,0 +1,21 @@
#include "protocolsession.h"
#include <cassert>
int main() {
ProtocolSession session;
assert(!session.active());
session.start("round-1");
assert(session.active());
assert(session.accept("round-1", 1));
assert(!session.accept("round-1", 1));
assert(!session.accept("round-1", 0));
assert(!session.accept("old-round", 2));
assert(session.accept("round-1", 3));
session.reset();
assert(!session.active());
assert(!session.accept("round-1", 4));
session.start("round-2");
assert(!session.accept("round-1", 100));
assert(session.accept("round-2", 1));
}
+406
View File
@@ -0,0 +1,406 @@
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <string>
#include <utility>
#include <fcntl.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <nlohmann/json.hpp>
#include <fcitx-utils/event.h>
#include <fcitx-utils/eventloopinterface.h>
#include <fcitx-utils/capabilityflags.h>
#include <fcitx-utils/key.h>
#include <fcitx-utils/keysym.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/inputpanel.h>
#include <fcitx/instance.h>
#include <fcitx/text.h>
#include "protocolsession.h"
namespace {
constexpr int kProtocolVersion = 2;
constexpr std::size_t kMaxBufferBytes = 1024 * 1024;
class VoiceInputAddon final : public fcitx::AddonInstance {
public:
explicit VoiceInputAddon(fcitx::Instance *instance)
: instance_(instance), trigger_("Control+Shift+space") {
keyWatcher_ = instance_->watchEvent(
fcitx::EventType::InputContextKeyEvent,
// 必须在原输入法之前吞掉快捷键;否则长按产生的重复事件会先触发
// Fcitx5 自身的输入法切换窗口,并打断网页录音焦点。
fcitx::EventWatcherPhase::PreInputMethod,
[this](fcitx::Event &event) {
handleKeyEvent(static_cast<fcitx::KeyEvent &>(event));
});
focusOutWatcher_ = instance_->watchEvent(
fcitx::EventType::InputContextFocusOut,
fcitx::EventWatcherPhase::PostInputMethod,
[this](fcitx::Event &event) {
cancelForContext(static_cast<fcitx::InputContextEvent &>(event).inputContext());
});
focusInWatcher_ = instance_->watchEvent(
fcitx::EventType::InputContextFocusIn,
fcitx::EventWatcherPhase::PostInputMethod,
[this](fcitx::Event &event) {
focusedInputContext_ =
static_cast<fcitx::InputContextEvent &>(event).inputContext();
});
resetWatcher_ = instance_->watchEvent(
fcitx::EventType::InputContextReset,
fcitx::EventWatcherPhase::PostInputMethod,
[this](fcitx::Event &event) {
cancelForContext(static_cast<fcitx::InputContextEvent &>(event).inputContext());
});
destroyedWatcher_ = instance_->watchEvent(
fcitx::EventType::InputContextDestroyed,
fcitx::EventWatcherPhase::PostInputMethod,
[this](fcitx::Event &event) {
auto *context = static_cast<fcitx::InputContextEvent &>(event).inputContext();
if (context == focusedInputContext_) focusedInputContext_ = nullptr;
if (context == inputContext_) {
sendControl("request-cancel");
resetLocalState(false, false);
}
});
reconnectTimer_ = instance_->eventLoop().addTimeEvent(
CLOCK_MONOTONIC, fcitx::now(CLOCK_MONOTONIC) + 100000, 100000,
[this](fcitx::EventSourceTime *source, uint64_t) {
if (socketFd_ < 0) ensureConnected();
source->setNextInterval(1000000);
// 时间事件触发后会退出 one-shot 状态;重新启用才能持续探测
// Electron 的重启和冷启动。
source->setOneShot();
return true;
});
}
~VoiceInputAddon() override {
if (inputContext_) {
clearPreedit();
restorePreeditState();
}
disconnectSocket();
}
private:
void handleKeyEvent(fcitx::KeyEvent &event) {
const bool isTriggerPress = !event.isRelease() && event.key().check(trigger_);
const bool isTriggerRelease = event.isRelease() && held_ &&
event.rawKey().sym() == trigger_.sym();
if (isTriggerPress) {
event.filterAndAccept();
if (event.rawKey().states().test(fcitx::KeyState::Repeat)) return;
FCITX_INFO() << "网页语音输入:快捷键按下 held=" << held_
<< " awaiting=" << awaitingStart_
<< " active=" << session_.active();
if (held_) return;
if (!ensureConnected()) {
FCITX_WARN() << "网页语音输入:Electron Socket 尚未就绪";
return;
}
held_ = true;
if (!prepareInputContext(event.inputContext())) held_ = false;
return;
}
if (isTriggerRelease) {
event.filterAndAccept();
FCITX_INFO() << "网页语音输入:快捷键松开 awaiting=" << awaitingStart_
<< " active=" << session_.active();
held_ = false;
if (awaitingStart_ || session_.active()) sendControl("request-stop");
return;
}
if (!session_.active() && !awaitingStart_) return;
if (event.isRelease() || event.key().isModifier()) return;
if (event.key().check(FcitxKey_Escape)) {
event.filterAndAccept();
sendControl("request-cancel");
clearPreedit();
resetLocalState(false);
return;
}
// 用户开始键盘输入时,立即取消语音会话,并让该按键继续交给原输入法。
sendControl("request-cancel");
clearPreedit();
resetLocalState(false);
}
bool prepareInputContext(fcitx::InputContext *context) {
if (!context || !context->hasFocus()) return false;
if (!context->inputPanel().clientPreedit().empty() ||
!context->inputPanel().preedit().empty()) {
FCITX_WARN() << "MeloASR:当前输入法存在未提交文本,本轮未启动";
return false;
}
inputContext_ = context;
preeditWasEnabled_ = inputContext_->isPreeditEnabled();
preeditStateCaptured_ = true;
inputContext_->setEnablePreedit(true);
awaitingStart_ = true;
sendControl("request-start");
return true;
}
bool ensureConnected() {
if (socketFd_ >= 0) {
char byte;
const ssize_t probe = ::recv(socketFd_, &byte, sizeof(byte), MSG_PEEK | MSG_DONTWAIT);
if (probe > 0 || (probe < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))) {
return true;
}
FCITX_INFO() << "网页语音输入:检测到失效的 Electron Socket,立即重连";
handleDisconnect();
}
const char *runtimeDir = std::getenv("XDG_RUNTIME_DIR");
if (!runtimeDir || !*runtimeDir) return false;
const std::string socketPath = std::string(runtimeDir) + "/meloasr/fcitx5.sock";
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0) return false;
sockaddr_un address{};
if (socketPath.size() >= sizeof(address.sun_path)) {
::close(fd);
return false;
}
address.sun_family = AF_UNIX;
std::memcpy(address.sun_path, socketPath.c_str(), socketPath.size() + 1);
if (::connect(fd, reinterpret_cast<sockaddr *>(&address), sizeof(address)) < 0) {
::close(fd);
return false;
}
const int flags = ::fcntl(fd, F_GETFL, 0);
if (flags >= 0) ::fcntl(fd, F_SETFL, flags | O_NONBLOCK);
socketFd_ = fd;
socketWatcher_ = instance_->eventLoop().addIOEvent(
socketFd_, fcitx::IOEventFlags{fcitx::IOEventFlag::In, fcitx::IOEventFlag::Err,
fcitx::IOEventFlag::Hup},
[this](fcitx::EventSourceIO *, int, fcitx::IOEventFlags flags) {
if (flags.test(fcitx::IOEventFlag::Err) || flags.test(fcitx::IOEventFlag::Hup)) {
handleDisconnect();
return false;
}
return readMessages();
});
return writeJson({{"type", "hello"}, {"protocol", kProtocolVersion}});
}
bool readMessages() {
char chunk[8192];
while (true) {
const ssize_t size = ::recv(socketFd_, chunk, sizeof(chunk), 0);
if (size > 0) {
readBuffer_.append(chunk, static_cast<std::size_t>(size));
if (readBuffer_.size() > kMaxBufferBytes) {
handleDisconnect();
return false;
}
consumeLines();
continue;
}
if (size == 0) {
handleDisconnect();
return false;
}
if (errno == EINTR) continue;
if (errno == EAGAIN || errno == EWOULDBLOCK) return true;
handleDisconnect();
return false;
}
}
void consumeLines() {
std::size_t newline;
while ((newline = readBuffer_.find('\n')) != std::string::npos) {
std::string line = readBuffer_.substr(0, newline);
readBuffer_.erase(0, newline + 1);
if (line.empty()) continue;
try {
handleMessage(nlohmann::json::parse(line));
} catch (const std::exception &error) {
FCITX_WARN() << "网页语音输入协议解析失败:" << error.what();
}
}
}
void handleMessage(const nlohmann::json &message) {
if (!message.is_object() || !message.contains("type") || !message["type"].is_string()) return;
const std::string type = message["type"].get<std::string>();
if (type == "hello") {
if (message.value("protocol", 0) != kProtocolVersion) handleDisconnect();
return;
}
if (type == "configure") {
if (!message.contains("shortcut") || !message["shortcut"].is_string() ||
awaitingStart_ || session_.active()) return;
fcitx::Key candidate(message["shortcut"].get<std::string>());
if (!candidate.isValid() || !candidate.hasModifier()) {
FCITX_WARN() << "MeloASR:拒绝无效快捷键";
return;
}
trigger_ = std::move(candidate);
FCITX_INFO() << "MeloASR:快捷键已更新为 " << trigger_.toString();
return;
}
if (type == "activate") {
if (!awaitingStart_ && !session_.active()) {
prepareInputContext(focusedInputContext_);
}
return;
}
if (type == "start") {
if (!awaitingStart_ || !inputContext_ || !inputContext_->hasFocus()) return;
if (!message.contains("sessionId") || !message["sessionId"].is_string()) return;
session_.start(message["sessionId"].get<std::string>());
awaitingStart_ = false;
FCITX_INFO() << "网页语音输入:会话开始,clientPreedit="
<< inputContext_->capabilityFlags().test(fcitx::CapabilityFlag::Preedit);
setPreedit("");
return;
}
if (type == "cancel") {
const std::string incoming = message.value("sessionId", std::string());
if (!incoming.empty() && !session_.matches(incoming)) return;
clearPreedit();
resetLocalState(false);
return;
}
if (type == "update" || type == "finish") {
if (!message.contains("revision") || !message["revision"].is_number_integer() ||
!message.contains("text") || !message["text"].is_string()) return;
const std::int64_t incomingRevision = message["revision"].get<std::int64_t>();
if (!session_.accept(message.value("sessionId", std::string()), incomingRevision)) return;
currentText_ = message["text"].get<std::string>();
FCITX_INFO() << "网页语音输入:收到 " << type << " revision="
<< incomingRevision << " bytes=" << currentText_.size();
setPreedit(currentText_);
if (type == "finish") {
if (inputContext_ && inputContext_->hasFocus() && !currentText_.empty()) {
inputContext_->commitString(currentText_);
}
clearPreedit();
resetLocalState(false);
}
}
}
void setPreedit(const std::string &text) {
if (!inputContext_ || !inputContext_->hasFocus()) return;
fcitx::Text formatted(text);
formatted.setCursor(static_cast<int>(text.size()));
inputContext_->inputPanel().setClientPreedit(formatted);
inputContext_->updatePreedit();
}
void clearPreedit() {
if (!inputContext_) return;
inputContext_->inputPanel().setClientPreedit(fcitx::Text());
if (inputContext_->hasFocus()) {
inputContext_->updatePreedit();
}
}
void cancelForContext(fcitx::InputContext *context) {
if (context == focusedInputContext_) focusedInputContext_ = nullptr;
if (context != inputContext_) return;
sendControl("request-cancel");
clearPreedit();
resetLocalState(false);
}
void restorePreeditState() {
if (inputContext_ && preeditStateCaptured_) {
inputContext_->setEnablePreedit(preeditWasEnabled_);
}
preeditStateCaptured_ = false;
}
void resetLocalState(bool keepContext, bool restorePreedit = true) {
if (restorePreedit) restorePreeditState();
held_ = false;
awaitingStart_ = false;
session_.reset();
currentText_.clear();
if (!keepContext) inputContext_ = nullptr;
}
bool sendControl(const char *type) { return writeJson({{"type", type}}); }
bool writeJson(const nlohmann::json &message) {
if (socketFd_ < 0) return false;
const std::string line = message.dump() + "\n";
std::size_t offset = 0;
while (offset < line.size()) {
const ssize_t written = ::send(socketFd_, line.data() + offset, line.size() - offset, MSG_NOSIGNAL);
if (written > 0) {
offset += static_cast<std::size_t>(written);
continue;
}
if (written < 0 && errno == EINTR) continue;
handleDisconnect();
return false;
}
return true;
}
void handleDisconnect() {
clearPreedit();
resetLocalState(false);
disconnectSocket();
}
void disconnectSocket() {
socketWatcher_.reset();
if (socketFd_ >= 0) ::close(socketFd_);
socketFd_ = -1;
readBuffer_.clear();
}
fcitx::Instance *instance_;
fcitx::Key trigger_;
fcitx::InputContext *inputContext_ = nullptr;
fcitx::InputContext *focusedInputContext_ = nullptr;
bool held_ = false;
bool awaitingStart_ = false;
bool preeditStateCaptured_ = false;
bool preeditWasEnabled_ = true;
ProtocolSession session_;
std::string currentText_;
int socketFd_ = -1;
std::string readBuffer_;
std::unique_ptr<fcitx::EventSourceIO> socketWatcher_;
std::unique_ptr<fcitx::EventSourceTime> reconnectTimer_;
std::unique_ptr<fcitx::HandlerTableEntry<fcitx::EventHandler>> keyWatcher_;
std::unique_ptr<fcitx::HandlerTableEntry<fcitx::EventHandler>> focusOutWatcher_;
std::unique_ptr<fcitx::HandlerTableEntry<fcitx::EventHandler>> focusInWatcher_;
std::unique_ptr<fcitx::HandlerTableEntry<fcitx::EventHandler>> resetWatcher_;
std::unique_ptr<fcitx::HandlerTableEntry<fcitx::EventHandler>> destroyedWatcher_;
};
class VoiceInputAddonFactory final : public fcitx::AddonFactory {
public:
fcitx::AddonInstance *create(fcitx::AddonManager *manager) override {
return new VoiceInputAddon(manager->instance());
}
};
} // namespace
FCITX_ADDON_FACTORY(VoiceInputAddonFactory);