From 6ff1d3bf77c1cba559284de899f315a96a5b10f2 Mon Sep 17 00:00:00 2001 From: Melorise <0d00@0721.hk> Date: Sun, 23 Aug 2026 02:34:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Linux=20Fcitx5=20?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E8=BE=93=E5=85=A5=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 加入 Unix Socket v2 协议、会话状态机、动态快捷键、完整 preedit 更新、结束提交以及断线重连和安全取消。 AI-assisted-by: Codex AI-model: gpt-5.6-sol AI-reasoning-effort: medium Co-authored-by: Codex --- linux/fcitx5/CMakeLists.txt | 26 ++ linux/fcitx5/README.md | 25 ++ linux/fcitx5/install-user.sh | 20 ++ linux/fcitx5/meloasr.conf.in | 14 + linux/fcitx5/protocolsession.h | 34 +++ linux/fcitx5/protocolsession_test.cpp | 21 ++ linux/fcitx5/voiceinput.cpp | 406 ++++++++++++++++++++++++++ src/main/backend-manager.ts | 274 +++++++++++++++++ src/main/fcitx-bridge.ts | 181 ++++++++++++ src/main/session-controller.ts | 140 +++++++++ src/main/settings-store.ts | 56 ++++ src/shared/contracts.ts | 50 ++++ test/fcitx-bridge.test.ts | 18 ++ 13 files changed, 1265 insertions(+) create mode 100644 linux/fcitx5/CMakeLists.txt create mode 100644 linux/fcitx5/README.md create mode 100755 linux/fcitx5/install-user.sh create mode 100644 linux/fcitx5/meloasr.conf.in create mode 100644 linux/fcitx5/protocolsession.h create mode 100644 linux/fcitx5/protocolsession_test.cpp create mode 100644 linux/fcitx5/voiceinput.cpp create mode 100644 src/main/backend-manager.ts create mode 100644 src/main/fcitx-bridge.ts create mode 100644 src/main/session-controller.ts create mode 100644 src/main/settings-store.ts create mode 100644 src/shared/contracts.ts create mode 100644 test/fcitx-bridge.test.ts diff --git a/linux/fcitx5/CMakeLists.txt b/linux/fcitx5/CMakeLists.txt new file mode 100644 index 0000000..e1e8deb --- /dev/null +++ b/linux/fcitx5/CMakeLists.txt @@ -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() diff --git a/linux/fcitx5/README.md b/linux/fcitx5/README.md new file mode 100644 index 0000000..0ed28b6 --- /dev/null +++ b/linux/fcitx5/README.md @@ -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` 悬浮球启动请求。 diff --git a/linux/fcitx5/install-user.sh b/linux/fcitx5/install-user.sh new file mode 100755 index 0000000..66cd331 --- /dev/null +++ b/linux/fcitx5/install-user.sh @@ -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。" diff --git a/linux/fcitx5/meloasr.conf.in b/linux/fcitx5/meloasr.conf.in new file mode 100644 index 0000000..b30e66f --- /dev/null +++ b/linux/fcitx5/meloasr.conf.in @@ -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 diff --git a/linux/fcitx5/protocolsession.h b/linux/fcitx5/protocolsession.h new file mode 100644 index 0000000..e1659b8 --- /dev/null +++ b/linux/fcitx5/protocolsession.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +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; +}; diff --git a/linux/fcitx5/protocolsession_test.cpp b/linux/fcitx5/protocolsession_test.cpp new file mode 100644 index 0000000..584278e --- /dev/null +++ b/linux/fcitx5/protocolsession_test.cpp @@ -0,0 +1,21 @@ +#include "protocolsession.h" + +#include + +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)); +} diff --git a/linux/fcitx5/voiceinput.cpp b/linux/fcitx5/voiceinput.cpp new file mode 100644 index 0000000..4d98c66 --- /dev/null +++ b/linux/fcitx5/voiceinput.cpp @@ -0,0 +1,406 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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(event)); + }); + focusOutWatcher_ = instance_->watchEvent( + fcitx::EventType::InputContextFocusOut, + fcitx::EventWatcherPhase::PostInputMethod, + [this](fcitx::Event &event) { + cancelForContext(static_cast(event).inputContext()); + }); + focusInWatcher_ = instance_->watchEvent( + fcitx::EventType::InputContextFocusIn, + fcitx::EventWatcherPhase::PostInputMethod, + [this](fcitx::Event &event) { + focusedInputContext_ = + static_cast(event).inputContext(); + }); + resetWatcher_ = instance_->watchEvent( + fcitx::EventType::InputContextReset, + fcitx::EventWatcherPhase::PostInputMethod, + [this](fcitx::Event &event) { + cancelForContext(static_cast(event).inputContext()); + }); + destroyedWatcher_ = instance_->watchEvent( + fcitx::EventType::InputContextDestroyed, + fcitx::EventWatcherPhase::PostInputMethod, + [this](fcitx::Event &event) { + auto *context = static_cast(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(&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(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(); + 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()); + 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()); + 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(); + if (!session_.accept(message.value("sessionId", std::string()), incomingRevision)) return; + currentText_ = message["text"].get(); + 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(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(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 socketWatcher_; + std::unique_ptr reconnectTimer_; + std::unique_ptr> keyWatcher_; + std::unique_ptr> focusOutWatcher_; + std::unique_ptr> focusInWatcher_; + std::unique_ptr> resetWatcher_; + std::unique_ptr> 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); diff --git a/src/main/backend-manager.ts b/src/main/backend-manager.ts new file mode 100644 index 0000000..5bf1b68 --- /dev/null +++ b/src/main/backend-manager.ts @@ -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; + 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(); + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/src/main/fcitx-bridge.ts b/src/main/fcitx-bridge.ts new file mode 100644 index 0000000..fef30e0 --- /dev/null +++ b/src/main/fcitx-bridge.ts @@ -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 { + 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((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 { + const server = this.server; + this.server = null; + this.client?.destroy(); + this.client = null; + this.ready = false; + this.activeSession = null; + if (server) await new Promise((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; + } +} diff --git a/src/main/session-controller.ts b/src/main/session-controller.ts new file mode 100644 index 0000000..7b49988 --- /dev/null +++ b/src/main/session-controller.ts @@ -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 { + 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); + } +} diff --git a/src/main/settings-store.ts b/src/main/settings-store.ts new file mode 100644 index 0000000..0064cbe --- /dev/null +++ b/src/main/settings-store.ts @@ -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 & { + 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 { + 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); + } +} diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts new file mode 100644 index 0000000..1a0bc15 --- /dev/null +++ b/src/shared/contracts.ts @@ -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; +} + +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; +} diff --git a/test/fcitx-bridge.test.ts b/test/fcitx-bridge.test.ts new file mode 100644 index 0000000..97acee4 --- /dev/null +++ b/test/fcitx-bridge.test.ts @@ -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); +});