Files
TamaASR/linux/fcitx5/voiceinput.cpp
T
MeloriseandCodex 6ff1d3bf77 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>
2026-08-23 02:34:14 +08:00

407 lines
16 KiB
C++

#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);