mirror of
https://github.com/Melorise/TamaASR.git
synced 2026-11-07 14:39:32 +00:00
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:
@@ -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()
|
||||||
@@ -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` 悬浮球启动请求。
|
||||||
Executable
+20
@@ -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。"
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { app, BrowserWindow, dialog, screen, session, type IpcMainEvent } from 'electron';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { backendOwnsUrl, type BackendDefinition } from '../backends/contracts';
|
||||||
|
import { listBackends, requireBackend } from '../backends/registry';
|
||||||
|
import type { BackendRuntimeStatus, BackendStatusPayload } from '../shared/contracts';
|
||||||
|
import type { SettingsStore } from './settings-store';
|
||||||
|
|
||||||
|
interface BackendRecord {
|
||||||
|
definition: BackendDefinition;
|
||||||
|
root: BrowserWindow;
|
||||||
|
page: BrowserWindow;
|
||||||
|
windows: Set<BrowserWindow>;
|
||||||
|
exposed: boolean;
|
||||||
|
status: BackendRuntimeStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingStatus = (definition: BackendDefinition): BackendRuntimeStatus => ({
|
||||||
|
backendId: definition.id,
|
||||||
|
ready: false,
|
||||||
|
login: 'unknown',
|
||||||
|
detail: '正在加载'
|
||||||
|
});
|
||||||
|
|
||||||
|
export class BackendManager extends EventEmitter {
|
||||||
|
private readonly records = new Map<string, BackendRecord>();
|
||||||
|
|
||||||
|
constructor(private readonly settings: SettingsStore) { super(); }
|
||||||
|
|
||||||
|
definitions(): readonly BackendDefinition[] {
|
||||||
|
return listBackends();
|
||||||
|
}
|
||||||
|
|
||||||
|
activeDefinition(): BackendDefinition {
|
||||||
|
return requireBackend(this.settings.get().backendId);
|
||||||
|
}
|
||||||
|
|
||||||
|
status(id: string): BackendRuntimeStatus {
|
||||||
|
const definition = requireBackend(id);
|
||||||
|
return this.records.get(id)?.status ?? loadingStatus(definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensure(id: string): Promise<BrowserWindow> {
|
||||||
|
const existing = this.records.get(id);
|
||||||
|
if (existing?.page && !existing.page.isDestroyed()) return existing.page;
|
||||||
|
if (existing?.root && !existing.root.isDestroyed()) return existing.root;
|
||||||
|
|
||||||
|
const definition = requireBackend(id);
|
||||||
|
const backendSession = session.fromPartition(definition.partition);
|
||||||
|
this.configurePermissions(backendSession, definition);
|
||||||
|
const root = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
skipTaskbar: true,
|
||||||
|
width: 1280,
|
||||||
|
height: 840,
|
||||||
|
title: `${definition.label} · MeloASR`,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
icon: path.join(app.getAppPath(), 'dist', 'assets', 'logo.png'),
|
||||||
|
webPreferences: {
|
||||||
|
partition: definition.partition,
|
||||||
|
preload: path.join(__dirname, '..', 'preload', 'backend.js'),
|
||||||
|
additionalArguments: [`--meloasr-backend=${id}`],
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
backgroundThrottling: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const record: BackendRecord = {
|
||||||
|
definition,
|
||||||
|
root,
|
||||||
|
page: root,
|
||||||
|
windows: new Set([root]),
|
||||||
|
exposed: false,
|
||||||
|
status: loadingStatus(definition)
|
||||||
|
};
|
||||||
|
this.records.set(id, record);
|
||||||
|
this.registerWindow(root, record);
|
||||||
|
try {
|
||||||
|
await root.loadURL(definition.url);
|
||||||
|
this.hideRecord(record);
|
||||||
|
root.webContents.send('backend-control', { action: 'refresh-status', backend: id });
|
||||||
|
} catch (error) {
|
||||||
|
record.status = { backendId: id, ready: false, login: 'unknown', detail: `加载失败:${(error as Error).message}` };
|
||||||
|
this.emit('status-changed', record.status);
|
||||||
|
}
|
||||||
|
return record.page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async select(id: string): Promise<void> {
|
||||||
|
requireBackend(id);
|
||||||
|
this.hideRecord(this.records.get(this.settings.get().backendId));
|
||||||
|
this.settings.update({ backendId: id });
|
||||||
|
await this.ensure(id);
|
||||||
|
this.emit('active-changed', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async showDebug(id = this.settings.get().backendId): Promise<void> {
|
||||||
|
const page = await this.ensure(id);
|
||||||
|
const record = this.records.get(id)!;
|
||||||
|
if (!this.settings.get().loginNoticeShown[id]) {
|
||||||
|
await dialog.showMessageBox({
|
||||||
|
type: 'info',
|
||||||
|
title: `${record.definition.label}登录`,
|
||||||
|
message: `请登录${record.definition.label}网页平台`,
|
||||||
|
detail: '登录完成后即可关闭此窗口。登录状态会保存在当前后端的独立会话中。',
|
||||||
|
buttons: ['继续'],
|
||||||
|
defaultId: 0
|
||||||
|
});
|
||||||
|
this.settings.update({
|
||||||
|
loginNoticeShown: { ...this.settings.get().loginNoticeShown, [id]: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
record.exposed = true;
|
||||||
|
page.setSkipTaskbar(false);
|
||||||
|
page.setBounds(this.debugBounds());
|
||||||
|
page.show();
|
||||||
|
page.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
hideDebug(id = this.settings.get().backendId): void {
|
||||||
|
this.hideRecord(this.records.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
activePage(): BrowserWindow | null {
|
||||||
|
const record = this.records.get(this.settings.get().backendId);
|
||||||
|
if (!record) return null;
|
||||||
|
return !record.page.isDestroyed() ? record.page : (!record.root.isDestroyed() ? record.root : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveActivePage(): Promise<BrowserWindow | null> {
|
||||||
|
const definition = this.activeDefinition();
|
||||||
|
const record = this.records.get(definition.id);
|
||||||
|
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||||||
|
const candidates = [record?.page, record?.root, ...BrowserWindow.getAllWindows()]
|
||||||
|
.filter((window): window is BrowserWindow => Boolean(window && !window.isDestroyed()))
|
||||||
|
.filter((window, index, all) => all.indexOf(window) === index)
|
||||||
|
.filter((window) => backendOwnsUrl(definition, window.webContents.getURL()));
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const found = await candidate.webContents.executeJavaScript(
|
||||||
|
`Boolean(document.querySelector(${JSON.stringify(definition.editorSelector)}))`, true
|
||||||
|
);
|
||||||
|
if (found) {
|
||||||
|
if (record) record.page = candidate;
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
} catch { /* 页面正在导航,继续重试。 */ }
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearActiveEditor(window: BrowserWindow): Promise<boolean> {
|
||||||
|
const selector = this.activeDefinition().editorSelector;
|
||||||
|
let hadContent = false;
|
||||||
|
let consecutiveEmpty = 0;
|
||||||
|
for (let attempt = 1; attempt <= 12; attempt += 1) {
|
||||||
|
const result = await window.webContents.executeJavaScript(`(() => {
|
||||||
|
const editors = Array.from(document.querySelectorAll(${JSON.stringify(selector)}));
|
||||||
|
const editor = editors.find((candidate) => {
|
||||||
|
const style = getComputedStyle(candidate);
|
||||||
|
const rect = candidate.getBoundingClientRect();
|
||||||
|
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
||||||
|
}) || editors[0];
|
||||||
|
if (!editor) return { found: false, empty: false, beforeBytes: 0 };
|
||||||
|
const clone = editor.cloneNode(true);
|
||||||
|
clone.querySelectorAll('[data-slate-placeholder], [data-slate-zero-width], .ql-placeholder').forEach((node) => node.remove());
|
||||||
|
const text = (clone.textContent || '').trim();
|
||||||
|
if (!text) return { found: true, empty: true, beforeBytes: 0 };
|
||||||
|
editor.focus({ preventScroll: true });
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(editor);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
return { found: true, empty: false, beforeBytes: new TextEncoder().encode(text).length };
|
||||||
|
})()`, true) as { found: boolean; empty: boolean; beforeBytes: number };
|
||||||
|
if (!result.found) return false;
|
||||||
|
if (!hadContent && result.beforeBytes === 0) return true;
|
||||||
|
if (result.empty) {
|
||||||
|
consecutiveEmpty += 1;
|
||||||
|
if (consecutiveEmpty >= 2) return true;
|
||||||
|
} else {
|
||||||
|
hadContent = true;
|
||||||
|
consecutiveEmpty = 0;
|
||||||
|
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Backspace' });
|
||||||
|
window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Backspace' });
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleStatus(event: IpcMainEvent, payload: BackendStatusPayload): void {
|
||||||
|
const record = this.records.get(payload?.backend);
|
||||||
|
if (!record || !this.recordOwnsSender(record, event.sender.id)) return;
|
||||||
|
record.status = {
|
||||||
|
backendId: payload.backend,
|
||||||
|
ready: payload.ready,
|
||||||
|
login: payload.loggedIn === null ? 'unknown' : payload.loggedIn ? 'logged-in' : 'logged-out',
|
||||||
|
detail: payload.detail || (payload.ready ? '已就绪' : '尚未就绪')
|
||||||
|
};
|
||||||
|
this.emit('status-changed', record.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
senderIsActive(event: IpcMainEvent, backendId: string): boolean {
|
||||||
|
if (backendId !== this.settings.get().backendId) return false;
|
||||||
|
const record = this.records.get(backendId);
|
||||||
|
return Boolean(record && this.recordOwnsSender(record, event.sender.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerWindow(window: BrowserWindow, record: BackendRecord): void {
|
||||||
|
window.on('close', (event) => {
|
||||||
|
if ((app as typeof app & { isQuitting?: boolean }).isQuitting) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this.hideRecord(record);
|
||||||
|
});
|
||||||
|
window.webContents.setWindowOpenHandler(({ url }) => ({
|
||||||
|
action: url.startsWith('https://') ? 'allow' : 'deny',
|
||||||
|
overrideBrowserWindowOptions: { show: record.exposed, skipTaskbar: !record.exposed, focusable: true }
|
||||||
|
}));
|
||||||
|
window.webContents.on('did-create-window', (child) => {
|
||||||
|
record.windows.add(child);
|
||||||
|
child.on('closed', () => record.windows.delete(child));
|
||||||
|
this.registerWindow(child, record);
|
||||||
|
if (!record.exposed) this.parkWindow(child);
|
||||||
|
child.webContents.on('did-finish-load', () => {
|
||||||
|
if (backendOwnsUrl(record.definition, child.webContents.getURL())) record.page = child;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
window.webContents.on('did-fail-load', (_event, code, description, _url, isMainFrame) => {
|
||||||
|
if (!isMainFrame) return;
|
||||||
|
record.status = { backendId: record.definition.id, ready: false, login: 'unknown', detail: `加载失败(${code}):${description}` };
|
||||||
|
this.emit('status-changed', record.status);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private configurePermissions(backendSession: Electron.Session, definition: BackendDefinition): void {
|
||||||
|
const matches = (url = ''): boolean => backendOwnsUrl(definition, url);
|
||||||
|
backendSession.setPermissionCheckHandler((_contents, permission, requestingOrigin, details) =>
|
||||||
|
matches(requestingOrigin) && permission === 'media' && (!details.mediaType || details.mediaType === 'audio'));
|
||||||
|
backendSession.setPermissionRequestHandler((_contents, permission, callback, details) => {
|
||||||
|
const mediaTypes = 'mediaTypes' in details ? details.mediaTypes : undefined;
|
||||||
|
const audioOnly = !mediaTypes || mediaTypes.every((type: string) => type === 'audio');
|
||||||
|
callback(Boolean(matches(details.requestingUrl) && permission === 'media' && audioOnly));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideRecord(record?: BackendRecord): void {
|
||||||
|
if (!record) return;
|
||||||
|
record.exposed = false;
|
||||||
|
for (const window of record.windows) this.parkWindow(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parkWindow(window: BrowserWindow): void {
|
||||||
|
if (window.isDestroyed()) return;
|
||||||
|
window.hide();
|
||||||
|
window.setSkipTaskbar(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordOwnsSender(record: BackendRecord, senderId: number): boolean {
|
||||||
|
return [...record.windows].some((window) => !window.isDestroyed() && window.webContents.id === senderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private debugBounds(): Electron.Rectangle {
|
||||||
|
const area = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea;
|
||||||
|
const margin = 24;
|
||||||
|
const width = Math.min(1280, area.width - margin * 2);
|
||||||
|
const height = Math.min(840, area.height - margin * 2);
|
||||||
|
return { x: Math.round(area.x + (area.width - width) / 2), y: Math.round(area.y + (area.height - height) / 2), width, height };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import net, { type Socket } from 'node:net';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const PROTOCOL_VERSION = 2;
|
||||||
|
const MAX_LINE_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
export function defaultSocketPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
if (!env.XDG_RUNTIME_DIR) throw new Error('缺少 XDG_RUNTIME_DIR,无法创建 Fcitx5 本地 Socket');
|
||||||
|
return path.join(env.XDG_RUNTIME_DIR, 'meloasr', 'fcitx5.sock');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JsonLineDecoder {
|
||||||
|
private buffer = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly onMessage: (message: unknown) => void,
|
||||||
|
private readonly onError: (error: Error) => void,
|
||||||
|
private readonly maxLineBytes = MAX_LINE_BYTES
|
||||||
|
) {}
|
||||||
|
|
||||||
|
push(chunk: string | Buffer): void {
|
||||||
|
this.buffer += chunk.toString();
|
||||||
|
if (Buffer.byteLength(this.buffer) > this.maxLineBytes && !this.buffer.includes('\n')) {
|
||||||
|
this.onError(new Error('协议消息超过长度限制'));
|
||||||
|
this.buffer = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let newline: number;
|
||||||
|
while ((newline = this.buffer.indexOf('\n')) !== -1) {
|
||||||
|
const line = this.buffer.slice(0, newline);
|
||||||
|
this.buffer = this.buffer.slice(newline + 1);
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
if (Buffer.byteLength(line) > this.maxLineBytes) {
|
||||||
|
this.onError(new Error('协议消息超过长度限制'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try { this.onMessage(JSON.parse(line)); }
|
||||||
|
catch (error) { this.onError(new Error(`无效 JSON:${(error as Error).message}`)); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FcitxMessage { type?: string; protocol?: number }
|
||||||
|
|
||||||
|
export class FcitxBridge extends EventEmitter {
|
||||||
|
readonly socketPath: string;
|
||||||
|
private server: net.Server | null = null;
|
||||||
|
private client: Socket | null = null;
|
||||||
|
ready = false;
|
||||||
|
activeSession: string | null = null;
|
||||||
|
private revision = 0;
|
||||||
|
private shortcut = 'Control+Shift+space';
|
||||||
|
|
||||||
|
constructor(options: { socketPath?: string; env?: NodeJS.ProcessEnv } = {}) {
|
||||||
|
super();
|
||||||
|
this.socketPath = options.socketPath ?? defaultSocketPath(options.env);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.server) return;
|
||||||
|
const directory = path.dirname(this.socketPath);
|
||||||
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||||
|
fs.chmodSync(directory, 0o700);
|
||||||
|
try {
|
||||||
|
const stat = fs.lstatSync(this.socketPath);
|
||||||
|
if (!stat.isSocket()) throw new Error(`拒绝覆盖非 Socket 路径:${this.socketPath}`);
|
||||||
|
fs.unlinkSync(this.socketPath);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
this.server = net.createServer((socket) => this.accept(socket));
|
||||||
|
this.server.on('error', (error) => this.emit('server-error', error));
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const server = this.server!;
|
||||||
|
const onError = (error: Error): void => { server.off('listening', onListening); reject(error); };
|
||||||
|
const onListening = (): void => { server.off('error', onError); resolve(); };
|
||||||
|
server.once('error', onError);
|
||||||
|
server.once('listening', onListening);
|
||||||
|
server.listen(this.socketPath);
|
||||||
|
});
|
||||||
|
fs.chmodSync(this.socketPath, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(shortcut: string): boolean {
|
||||||
|
this.shortcut = shortcut;
|
||||||
|
return this.write({ type: 'configure', shortcut });
|
||||||
|
}
|
||||||
|
|
||||||
|
requestInputStart(): boolean {
|
||||||
|
return this.write({ type: 'activate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
begin(sessionId: string, backend: string): boolean {
|
||||||
|
this.activeSession = sessionId;
|
||||||
|
this.revision = 0;
|
||||||
|
return this.write({ type: 'start', sessionId, backend });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(text: string): boolean {
|
||||||
|
if (!this.activeSession) return false;
|
||||||
|
this.revision += 1;
|
||||||
|
return this.write({ type: 'update', sessionId: this.activeSession, revision: this.revision, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(text: string): boolean {
|
||||||
|
if (!this.activeSession) return false;
|
||||||
|
this.revision += 1;
|
||||||
|
const sent = this.write({ type: 'finish', sessionId: this.activeSession, revision: this.revision, text });
|
||||||
|
this.activeSession = null;
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): boolean {
|
||||||
|
if (!this.activeSession) return false;
|
||||||
|
const sent = this.write({ type: 'cancel', sessionId: this.activeSession });
|
||||||
|
this.activeSession = null;
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectStart(message: string): boolean {
|
||||||
|
return this.write({ type: 'cancel', sessionId: '', message });
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
const server = this.server;
|
||||||
|
this.server = null;
|
||||||
|
this.client?.destroy();
|
||||||
|
this.client = null;
|
||||||
|
this.ready = false;
|
||||||
|
this.activeSession = null;
|
||||||
|
if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
try { fs.unlinkSync(this.socketPath); }
|
||||||
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private accept(socket: Socket): void {
|
||||||
|
this.client?.destroy();
|
||||||
|
this.client = socket;
|
||||||
|
this.ready = false;
|
||||||
|
socket.setEncoding('utf8');
|
||||||
|
const decoder = new JsonLineDecoder(
|
||||||
|
(message) => this.handleMessage(socket, message as FcitxMessage),
|
||||||
|
(error) => this.emit('protocol-error', error)
|
||||||
|
);
|
||||||
|
socket.on('data', (chunk) => decoder.push(chunk));
|
||||||
|
socket.on('error', (error) => this.emit('client-error', error));
|
||||||
|
socket.on('close', () => {
|
||||||
|
if (this.client !== socket) return;
|
||||||
|
this.client = null;
|
||||||
|
this.ready = false;
|
||||||
|
this.emit('disconnect');
|
||||||
|
});
|
||||||
|
this.write({ type: 'hello', protocol: PROTOCOL_VERSION });
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMessage(socket: Socket, message: FcitxMessage): void {
|
||||||
|
if (!message || typeof message.type !== 'string') return;
|
||||||
|
if (message.type === 'hello') {
|
||||||
|
if (message.protocol !== PROTOCOL_VERSION) {
|
||||||
|
socket.destroy(new Error(`不支持的协议版本:${message.protocol}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ready = true;
|
||||||
|
this.configure(this.shortcut);
|
||||||
|
this.emit('connect');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.ready) return;
|
||||||
|
if (message.type === 'request-start') this.emit('request-start');
|
||||||
|
if (message.type === 'request-stop') this.emit('request-stop');
|
||||||
|
if (message.type === 'request-cancel') this.emit('request-cancel');
|
||||||
|
}
|
||||||
|
|
||||||
|
private write(message: object): boolean {
|
||||||
|
if (!this.client?.writable) return false;
|
||||||
|
this.client.write(`${JSON.stringify(message)}\n`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { BrowserWindow, Notification } from 'electron';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { SessionState } from '../shared/contracts';
|
||||||
|
import type { BackendManager } from './backend-manager';
|
||||||
|
import type { FcitxBridge } from './fcitx-bridge';
|
||||||
|
import type { OverlayController } from './overlay-controller';
|
||||||
|
|
||||||
|
export class SessionController extends EventEmitter {
|
||||||
|
state: SessionState = 'idle';
|
||||||
|
private mirroredText = '';
|
||||||
|
private stopRequestedDuringStart = false;
|
||||||
|
private generation = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly backends: BackendManager,
|
||||||
|
private readonly bridge: FcitxBridge,
|
||||||
|
private readonly overlay: OverlayController
|
||||||
|
) { super(); }
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.state !== 'idle') {
|
||||||
|
this.bridge.rejectStart(`上一轮仍处于 ${this.state} 状态`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const definition = this.backends.activeDefinition();
|
||||||
|
const status = this.backends.status(definition.id);
|
||||||
|
if (!this.bridge.ready) return this.reject('Fcitx5 插件尚未连接');
|
||||||
|
if (!status.ready) {
|
||||||
|
return this.reject(status.login === 'logged-out'
|
||||||
|
? `${definition.label}尚未登录,请从托盘打开设置`
|
||||||
|
: `${definition.label}页面尚未就绪`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 'starting';
|
||||||
|
this.emitState();
|
||||||
|
const generation = ++this.generation;
|
||||||
|
this.stopRequestedDuringStart = false;
|
||||||
|
await this.backends.ensure(definition.id);
|
||||||
|
const page = await this.backends.resolveActivePage();
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
if (!page) return this.failStart(`没有找到${definition.label}输入框`);
|
||||||
|
if (!await this.backends.clearActiveEditor(page)) {
|
||||||
|
if (generation === this.generation) this.failStart(`无法清空${definition.label}输入框`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
this.mirroredText = '';
|
||||||
|
this.state = 'recording';
|
||||||
|
this.bridge.begin(randomUUID(), definition.id);
|
||||||
|
this.overlay.showState('recording');
|
||||||
|
this.emitState();
|
||||||
|
page.webContents.send('backend-control', { action: 'start', backend: definition.id });
|
||||||
|
if (this.stopRequestedDuringStart) this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
requestStartFromOverlay(): void {
|
||||||
|
if (this.state === 'recording' || this.state === 'starting') {
|
||||||
|
this.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.bridge.ready || !this.bridge.requestInputStart()) this.reject('Fcitx5 插件尚未连接');
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.state === 'starting') {
|
||||||
|
this.stopRequestedDuringStart = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.state !== 'recording') return;
|
||||||
|
this.state = 'stopping';
|
||||||
|
this.overlay.showState('stopping');
|
||||||
|
this.emitState();
|
||||||
|
this.backends.activePage()?.webContents.send('backend-control', {
|
||||||
|
action: 'stop', backend: this.backends.activeDefinition().id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(message = '语音输入已取消'): void {
|
||||||
|
if (!['starting', 'recording', 'stopping'].includes(this.state)) return;
|
||||||
|
this.generation += 1;
|
||||||
|
this.stopRequestedDuringStart = false;
|
||||||
|
this.backends.activePage()?.webContents.send('backend-control', {
|
||||||
|
action: 'cancel', backend: this.backends.activeDefinition().id
|
||||||
|
});
|
||||||
|
if (this.bridge.activeSession) this.bridge.cancel(); else this.bridge.rejectStart(message);
|
||||||
|
this.state = 'idle';
|
||||||
|
this.mirroredText = '';
|
||||||
|
this.overlay.showState('error');
|
||||||
|
this.overlay.hideLater();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(text: string): void {
|
||||||
|
if (!['recording', 'stopping'].includes(this.state) || text === this.mirroredText || !this.bridge.activeSession) return;
|
||||||
|
this.bridge.update(text);
|
||||||
|
this.mirroredText = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(text: string): void {
|
||||||
|
if (!['recording', 'stopping'].includes(this.state)) return;
|
||||||
|
this.update(text);
|
||||||
|
this.bridge.finish(text);
|
||||||
|
this.state = 'idle';
|
||||||
|
this.mirroredText = '';
|
||||||
|
this.overlay.showState('done');
|
||||||
|
this.overlay.hideLater();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
backendError(message: string): void {
|
||||||
|
if (this.bridge.activeSession) this.bridge.cancel(); else this.bridge.rejectStart(message);
|
||||||
|
this.state = 'idle';
|
||||||
|
this.mirroredText = '';
|
||||||
|
this.overlay.showState('error');
|
||||||
|
this.overlay.hideLater(2200);
|
||||||
|
this.notify(message);
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private failStart(message: string): void {
|
||||||
|
this.state = 'idle';
|
||||||
|
this.bridge.rejectStart(message);
|
||||||
|
this.notify(message);
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reject(message: string): void {
|
||||||
|
this.bridge.rejectStart(message);
|
||||||
|
this.notify(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(message: string): void {
|
||||||
|
if (Notification.isSupported()) new Notification({ title: 'MeloASR', body: message }).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitState(): void {
|
||||||
|
this.emit('state-changed', this.state);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { AppSettings } from '../shared/contracts';
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
backendId: 'qianwen',
|
||||||
|
shortcut: 'Control+Shift+space',
|
||||||
|
autoStart: true,
|
||||||
|
overlayPersistent: false,
|
||||||
|
overlayPosition: null,
|
||||||
|
loginNoticeShown: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SettingsStore extends EventEmitter {
|
||||||
|
private value: AppSettings = structuredClone(DEFAULT_SETTINGS);
|
||||||
|
private readonly filePath = path.join(app.getPath('userData'), 'settings.json');
|
||||||
|
|
||||||
|
load(): AppSettings {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) as Partial<AppSettings> & {
|
||||||
|
backend?: string;
|
||||||
|
};
|
||||||
|
this.value = {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
...parsed,
|
||||||
|
backendId: parsed.backendId ?? parsed.backend ?? DEFAULT_SETTINGS.backendId,
|
||||||
|
overlayPosition: this.isPoint(parsed.overlayPosition) ? {
|
||||||
|
x: Math.round(parsed.overlayPosition.x), y: Math.round(parsed.overlayPosition.y)
|
||||||
|
} : null,
|
||||||
|
loginNoticeShown: parsed.loginNoticeShown ?? {}
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
this.value = structuredClone(DEFAULT_SETTINGS);
|
||||||
|
}
|
||||||
|
return this.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): AppSettings {
|
||||||
|
return structuredClone(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(patch: Partial<AppSettings>): AppSettings {
|
||||||
|
this.value = { ...this.value, ...patch };
|
||||||
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(this.filePath, `${JSON.stringify(this.value, null, 2)}\n`, { mode: 0o600 });
|
||||||
|
this.emit('changed', this.get());
|
||||||
|
return this.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPoint(value: unknown): value is { x: number; y: number } {
|
||||||
|
return typeof value === 'object' && value !== null &&
|
||||||
|
Number.isFinite((value as { x?: number }).x) && Number.isFinite((value as { y?: number }).y);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export type SessionState = 'idle' | 'starting' | 'recording' | 'stopping';
|
||||||
|
export type BackendLoginState = 'unknown' | 'logged-in' | 'logged-out';
|
||||||
|
export type BackendReadyState = 'loading' | 'ready' | 'login-required' | 'error';
|
||||||
|
|
||||||
|
export interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
backendId: string;
|
||||||
|
shortcut: string;
|
||||||
|
autoStart: boolean;
|
||||||
|
overlayPersistent: boolean;
|
||||||
|
overlayPosition: Point | null;
|
||||||
|
loginNoticeShown: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendRuntimeStatus {
|
||||||
|
backendId: string;
|
||||||
|
ready: boolean;
|
||||||
|
login: BackendLoginState;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendStatusPayload {
|
||||||
|
backend: string;
|
||||||
|
ready: boolean;
|
||||||
|
loggedIn: boolean | null;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplayInfo {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
workArea: Electron.Rectangle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsViewState {
|
||||||
|
backendId: string;
|
||||||
|
backends: Array<{ id: string; label: string; status: BackendRuntimeStatus }>;
|
||||||
|
sessionState: SessionState;
|
||||||
|
shortcut: string;
|
||||||
|
autoStart: boolean;
|
||||||
|
overlayPersistent: boolean;
|
||||||
|
overlayPosition: Point;
|
||||||
|
displays: DisplayInfo[];
|
||||||
|
version: string;
|
||||||
|
repositoryUrl: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { JsonLineDecoder, defaultSocketPath } from '../src/main/fcitx-bridge';
|
||||||
|
|
||||||
|
test('Socket 路径只使用当前用户运行时目录', () => {
|
||||||
|
assert.equal(defaultSocketPath({ XDG_RUNTIME_DIR: '/run/user/1000' }), '/run/user/1000/meloasr/fcitx5.sock');
|
||||||
|
assert.throws(() => defaultSocketPath({}), /XDG_RUNTIME_DIR/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('JSONL 解码器支持拆包、粘包与无效 JSON', () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const errors: Error[] = [];
|
||||||
|
const decoder = new JsonLineDecoder((message) => messages.push(message), (error) => errors.push(error));
|
||||||
|
decoder.push('{"type":"hel');
|
||||||
|
decoder.push('lo","protocol":2}\n{"type":"request-start"}\nnot-json\n');
|
||||||
|
assert.deepEqual(messages, [{ type: 'hello', protocol: 2 }, { type: 'request-start' }]);
|
||||||
|
assert.equal(errors.length, 1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user