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);
+274
View File
@@ -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 };
}
}
+181
View File
@@ -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;
}
}
+140
View File
@@ -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);
}
}
+56
View File
@@ -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);
}
}
+50
View File
@@ -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;
}
+18
View File
@@ -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);
});