fix(server): keep settings I/O off UI thread and bound dictionary rendering

This commit is contained in:
Evan Hu committed 2026-09-06 03:31:40 +09:00
1 parent b48dc82652
commit 06bba15d4f
19 files changed
+546 -148

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@
},
"engine": {
"repository": "metasequoiaime/MSIME-Engine",
"commit": "6bd22549d6e275e401052c55cca6c57fe8408819"
"commit": "403b4a4d72b0ab5110155aeb4a1f0c170730fc7b"
}
},
"dictionary": {
+35 -37
View File
@@ -1,4 +1,6 @@
#include "settings/dictionary_manager.h"
#include "settings/dictionary_page.h"
#include <set>
#include "config/ime_config.h"
#include "defines/defines.h"
@@ -321,7 +323,21 @@ bool ValidateChineseEntry(const std::string &mode, const std::string &code, cons
return true;
}
json::object QueryChinese(const std::string &mode, const std::string &search)
bool EnsureEnglishSchema()
{
// Installed dictionaries keep their schema for the lifetime of Settings.
// Do not open a write transaction for every read-only search.
static std::mutex mutex;
static std::set<std::string> initialized;
std::lock_guard<std::mutex> lock(mutex);
const auto path = CommonUtils::get_ime_data_path() + "\\english.db";
if (initialized.count(path)) return true;
if (!EnglishDictionary::ensure_schema(path)) return false;
initialized.insert(path);
return true;
}
json::object QueryChinese(const std::string &mode, const std::string &search, const json::object &request)
{
std::string error;
Db db = OpenDatabase("msime.db", error);
@@ -334,14 +350,9 @@ json::object QueryChinese(const std::string &mode, const std::string &search)
if (!normalized.empty())
{
const std::string table = quanpin::build_table_name(segments);
Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM \"" + table + "\" WHERE key=?1 ORDER BY weight DESC", error);
Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM \"" + table + "\" WHERE key=?1 ORDER BY weight DESC,value" + Paging::Sql(request), error);
if (!stmt || !BindText(stmt.get(), 1, normalized)) return Result(false, "查询失败:" + error);
while (sqlite3_step(stmt.get()) == SQLITE_ROW)
{
rows.push_back({{"code", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 0))},
{"word", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 1))},
{"weight", sqlite3_column_int(stmt.get(), 2)}});
}
return Paging::Read(stmt.get(), request);
}
json::object result = Result(true, rows.empty() ? "没有找到词条" : "查询成功");
result["rows"] = std::move(rows);
@@ -665,7 +676,7 @@ json::object ImportEnglish(const json::object &request)
return Result(false, "文件内容为空");
std::string error;
(void)EnglishDictionary::ensure_schema(CommonUtils::get_ime_data_path() + "\\english.db");
if (!EnsureEnglishSchema()) return Result(false, "英文词库初始化失败");
Db db = OpenDatabase("english.db", error);
if (!db) return Result(false, "打开英文词库失败:" + error);
Stmt insert = Prepare(db.get(),
@@ -795,22 +806,21 @@ json::object HandleEnglish(const json::object &request)
const int weight = (std::max)(0, IntValue(request, "weight", 10));
std::transform(word.begin(), word.end(), word.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
std::string error;
(void)EnglishDictionary::ensure_schema(CommonUtils::get_ime_data_path() + "\\english.db");
if (!EnsureEnglishSchema()) return Result(false, "英文词库初始化失败");
Db db = OpenDatabase("english.db", error);
if (!db) return Result(false, "打开英文词库失败:" + error);
if (action == "query")
{
Stmt stmt = Prepare(db.get(), "SELECT word,display,weight FROM english_words WHERE word LIKE ?1 "
"ORDER BY CASE WHEN word=?2 THEN 0 ELSE 1 END,weight DESC,length(word),word,display", error);
const std::string pattern = word + "%";
if (!stmt || !BindText(stmt.get(), 1, pattern) || !BindText(stmt.get(), 2, word)) return Result(false, "查询失败");
json::array rows;
while (sqlite3_step(stmt.get()) == SQLITE_ROW)
rows.push_back({{"word", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 0))},
{"display", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 1))},
{"weight", sqlite3_column_int(stmt.get(), 2)}});
json::object result = Result(true, rows.empty() ? "没有找到词条" : "查询成功"); result["rows"] = std::move(rows); return result;
if (!IsAsciiWord(word)) return Result(false, "请输入英文单词前缀");
std::string upper = word;
++upper.back(); // ASCII prefix upper bound, compatible with the BINARY primary key.
Stmt stmt = Prepare(db.get(), "SELECT word,display,weight FROM english_words WHERE word>=?1 AND word<?3 "
"ORDER BY CASE WHEN word=?2 THEN 0 ELSE 1 END,weight DESC,length(word),word,display" + Paging::Sql(request), error);
if (!stmt || !BindText(stmt.get(), 1, word) || !BindText(stmt.get(), 2, word) || !BindText(stmt.get(), 3, upper))
return Result(false, "查询失败:" + error);
return Paging::Read(stmt.get(), request, true);
}
if (!IsAsciiWord(word) || display.empty()) return Result(false, "英文单词仅支持英文字母、连字符和撇号,显示内容不能为空");
std::string old_word = StringValue(request, "oldWord");
const std::string old_display = StringValue(request, "oldDisplay");
@@ -981,14 +991,9 @@ json::object HandleWubi(const json::object &request)
{
if (code.empty() || !std::all_of(code.begin(), code.end(), [](unsigned char ch) { return ch >= 'a' && ch <= 'z'; }))
return Result(false, "请输入合法的五笔编码");
Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM wubi86 WHERE key LIKE ?1 ORDER BY weight DESC", error);
Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM wubi86 WHERE key LIKE ?1 ORDER BY weight DESC,key,value" + Paging::Sql(request), error);
if (!stmt || !BindText(stmt.get(), 1, code + "%")) return Result(false, "查询失败:" + error);
json::array rows;
while (sqlite3_step(stmt.get()) == SQLITE_ROW)
rows.push_back({{"code", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 0))},
{"word", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 1))},
{"weight", sqlite3_column_int(stmt.get(), 2)}});
json::object result = Result(true, rows.empty() ? "没有找到词条" : "查询成功"); result["rows"] = std::move(rows); return result;
return Paging::Read(stmt.get(), request);
}
if (code.empty() || word.empty() || !std::all_of(code.begin(), code.end(), [](unsigned char ch) { return ch >= 'a' && ch <= 'z'; }))
return Result(false, "五笔编码和词条不能为空,编码只能包含英文字母");
@@ -1192,16 +1197,9 @@ json::object HandleQuickPhrase(const json::object &request)
{
if (!code.empty() && !valid_code(code)) return Result(false, "编码只能包含英文字母");
Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM quick_parases WHERE key LIKE ?1 "
"ORDER BY weight DESC,key,value", error);
"ORDER BY weight DESC,key,value" + Paging::Sql(request), error);
if (!stmt || !BindText(stmt.get(), 1, code + "%")) return Result(false, "查询失败:" + error);
json::array rows;
while (sqlite3_step(stmt.get()) == SQLITE_ROW)
rows.push_back({{"code", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 0))},
{"word", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 1))},
{"weight", sqlite3_column_int(stmt.get(), 2)}});
json::object result = Result(true, rows.empty() ? "没有找到快捷短语" : "查询成功");
result["rows"] = std::move(rows);
return result;
return Paging::Read(stmt.get(), request);
}
if (!valid_code(code) || phrase.empty()) return Result(false, "编码只能包含英文字母,短语不能为空");
@@ -1266,7 +1264,7 @@ json::object HandleRequest(const json::object &request)
if (dictionary == "wubi") return HandleWubi(request);
if (dictionary == "quick") return HandleQuickPhrase(request);
if (dictionary != "quanpin") return Result(false, "未知词库");
if (action == "query") return QueryChinese(dictionary, StringValue(request, "code"));
if (action == "query") return QueryChinese(dictionary, StringValue(request, "code"), request);
if (action == "import") return ImportChinese(request);
return MutateChinese(request);
}
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <boost/json.hpp>
#include <sqlite3.h>
#include <algorithm>
#include <cstdint>
#include <string>
namespace SettingsDictionary::Paging
{
inline int Integer(const boost::json::object &request, const char *key, int fallback, int maximum)
{
const auto *value = request.if_contains(key);
if (!value || !value->is_int64()) return fallback;
return static_cast<int>((std::max)(int64_t{0}, (std::min)(value->as_int64(), int64_t{maximum})));
}
inline int Limit(const boost::json::object &request)
{
return (std::max)(1, Integer(request, "limit", 200, 200));
}
inline std::string Sql(const boost::json::object &request)
{
return " LIMIT " + std::to_string(Limit(request) + 1) + " OFFSET " +
std::to_string(Integer(request, "offset", 0, 2147483647));
}
inline boost::json::object Read(sqlite3_stmt *statement, const boost::json::object &request, bool english = false)
{
namespace json = boost::json;
json::array rows;
int status;
while ((status = sqlite3_step(statement)) == SQLITE_ROW)
{
const auto text = [statement](int column) {
const auto *value = sqlite3_column_text(statement, column);
return value ? reinterpret_cast<const char *>(value) : "";
};
rows.push_back({{english ? "word" : "code", text(0)}, {english ? "display" : "word", text(1)},
{"weight", sqlite3_column_int(statement, 2)}});
}
if (status != SQLITE_DONE)
return {{"ok", false}, {"message", "查询失败,请重试"}, {"rows", json::array{}}};
const bool more = rows.size() > static_cast<size_t>(Limit(request));
if (more) rows.pop_back();
return {{"ok", true}, {"message", rows.empty() ? "没有找到词条" : "查询成功"},
{"rows", std::move(rows)}, {"hasMore", more},
{"offset", Integer(request, "offset", 0, 2147483647)}};
}
} // namespace SettingsDictionary::Paging
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include <condition_variable>
#include <deque>
#include <functional>
#include <mutex>
#include <thread>
#include <utility>
// Work runs serially on one worker. Completions are explicitly drained by the
// owner thread. Stop drains accepted work; no detached threads outlive the owner.
class SerialTaskQueue
{
public:
using Completion = std::function<void()>;
using Task = std::function<Completion()>;
SerialTaskQueue(Completion notify, Completion error, Completion cleanup = {})
: notify_(std::move(notify)), error_(std::move(error)), cleanup_(std::move(cleanup)),
thread_([this] { Run(); }) {}
~SerialTaskQueue() { Stop(); thread_.join(); }
bool Submit(Task task)
{
std::lock_guard<std::mutex> lock(mutex_);
if (stopping_) return false;
tasks_.push_back(std::move(task));
ready_.notify_one();
return true;
}
void Stop()
{
std::lock_guard<std::mutex> lock(mutex_);
stopping_ = true;
ready_.notify_one();
}
bool Stopped()
{
std::lock_guard<std::mutex> lock(mutex_);
return stopped_;
}
void Drain(bool deliver = true)
{
std::deque<Completion> completed;
{ std::lock_guard<std::mutex> lock(mutex_); completed.swap(completed_); }
if (deliver) for (auto &completion : completed) completion();
}
private:
void Run()
{
for (;;)
{
Task task;
{
std::unique_lock<std::mutex> lock(mutex_);
ready_.wait(lock, [this] { return stopping_ || !tasks_.empty(); });
if (tasks_.empty()) break;
task = std::move(tasks_.front()); tasks_.pop_front();
}
Completion result;
try { result = task(); } catch (...) { result = error_; }
if (result)
{
{ std::lock_guard<std::mutex> lock(mutex_); completed_.push_back(std::move(result)); }
notify_();
}
}
if (cleanup_) cleanup_();
{ std::lock_guard<std::mutex> lock(mutex_); stopped_ = true; }
notify_();
}
Completion notify_, error_, cleanup_;
std::mutex mutex_;
std::condition_variable ready_;
std::deque<Task> tasks_;
std::deque<Completion> completed_;
bool stopping_ = false, stopped_ = false;
std::thread thread_;
};
+118 -33
View File
@@ -5,6 +5,9 @@
#include "settings/settings_launcher.h"
#include "settings/settings_splash.h"
#include "settings/dictionary_manager.h"
#include "settings/serial_task_queue.h"
#include <memory>
#include <stdexcept>
#include "skin/candidate_skin_catalog.h"
#include "utils/common_utils.h"
#include "utils/single_instance.h"
@@ -47,6 +50,7 @@ constexpr wchar_t kSingleInstanceMutex[] = L"Local\\MetasequoiaImeSettings.Singl
constexpr UINT kActivateExistingWindow = WM_APP + 1;
constexpr UINT kOpenAboutSection = WM_APP + 2;
constexpr UINT kScanSkinCatalog = WM_APP + 3;
constexpr UINT kWorkerCompleted = WM_APP + 4;
constexpr UINT_PTR kConfigReloadTimer = 1;
ComPtr<ICoreWebView2Controller> g_controller;
@@ -67,6 +71,14 @@ bool g_webview_content_started = false;
HWND g_settings_hwnd = nullptr;
std::optional<CandidateSkinCatalog::ScanResult> g_candidate_skin_catalog;
uint64_t g_candidate_skin_catalog_revision = 0;
std::unique_ptr<SerialTaskQueue> g_worker;
bool g_closing = false;
bool g_reload_pending = false;
bool g_settings_light = false;
std::wstring g_last_config_message;
bool g_worker_com_initialized = false; // worker-only
void CloseSettings(HWND hwnd);
nlohmann::json CandidateColorsToJson(const CandidateSkinCatalog::CandidateColors &colors)
{
@@ -173,7 +185,7 @@ void ApplyWindowActivationAppearance()
void ApplySettingsChromeTheme(HWND hwnd = nullptr)
{
const HWND target = hwnd ? hwnd : g_settings_hwnd;
const bool light = ResolveConfiguredTheme(GetConfiguredThemeSettings()) == "light";
const bool light = g_settings_light;
if (target)
{
BOOL dark = light ? FALSE : TRUE;
@@ -235,10 +247,8 @@ void ResetTitlebarHoverAfterVisibilityChange()
})())JS", nullptr);
}
void PostConfig(bool refresh_skin_catalog = false)
std::wstring BuildConfigMessage(bool refresh_skin_catalog)
{
if (!g_webview)
return;
const VoiceInputConfig &voice = GetConfiguredVoiceInput();
const AiAssistantConfig &ai = GetConfiguredAiAssistant();
@@ -430,9 +440,30 @@ void PostConfig(bool refresh_skin_catalog = false)
payload["protocolVersion"] = metasequoia::webview::Version;
const std::string serialized = payload.dump();
if (!metasequoia::webview::Validate(json::parse(serialized), "server"))
return;
const std::wstring message = string_to_wstring(serialized);
g_webview->PostWebMessageAsJson(message.c_str());
throw std::runtime_error("Invalid config snapshot");
return string_to_wstring(serialized);
}
SerialTaskQueue::Completion ConfigCompletion(bool refresh_skin_catalog = false)
{
auto message = BuildConfigMessage(refresh_skin_catalog);
const bool light = ResolveConfiguredTheme(GetConfiguredThemeSettings()) == "light";
return [message = std::move(message), light] {
g_settings_light = light;
ApplySettingsChromeTheme();
SettingsSplash::SetTheme(light);
if (g_webview && message != g_last_config_message)
{
if (SUCCEEDED(g_webview->PostWebMessageAsJson(message.c_str())))
g_last_config_message = message;
}
};
}
void PostConfig(bool refresh_skin_catalog = false)
{
if (!g_worker || g_closing) return;
g_worker->Submit([refresh_skin_catalog] { return ConfigCompletion(refresh_skin_catalog); });
}
void PostWindowState(HWND hwnd)
@@ -699,6 +730,7 @@ bool CopyTextToClipboard(HWND hwnd, const std::wstring &text)
void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
{
if (g_closing || !g_worker) return;
wil::unique_cotaskmem_string raw;
if (FAILED(args->TryGetWebMessageAsString(&raw)) || !raw)
return;
@@ -733,7 +765,7 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
if (command == "minimize") ShowWindow(hwnd, SW_MINIMIZE);
else if (command == "maximize") ShowWindow(hwnd, SW_MAXIMIZE);
else if (command == "restore") ShowWindow(hwnd, SW_RESTORE);
else if (command == "close") DestroyWindow(hwnd);
else if (command == "close") CloseSettings(hwnd);
}
else if (type == "maximizeButtonRect")
{
@@ -757,8 +789,11 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
}
else if (type == "skinCatalogRequest")
{
PostConfig(true);
NotifyImeServerCandidateSkinRefresh();
g_worker->Submit([] {
auto completion = ConfigCompletion(true);
NotifyImeServerCandidateSkinRefresh();
return completion;
});
}
else if (type == "openSkinDirectory")
{
@@ -768,13 +803,17 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
std::filesystem::create_directories(skins, ec);
if (!ec) ShellExecuteW(hwnd, L"open", skins.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
}
else if (type == "configUpdate" && ApplyConfigUpdate(value.at("data").as_object()))
else if (type == "configUpdate")
{
const auto &data = value.at("data").as_object();
const std::string path = json::value_to<std::string>(data.at("path"));
if (path == "appearance.theme_mode" || path == "appearance.theme_settings")
ApplySettingsChromeTheme(hwnd);
PostConfig();
const auto data = value.at("data").as_object();
g_worker->Submit([data] {
const bool saved = ApplyConfigUpdate(data);
auto completion = ConfigCompletion();
return [saved, completion = std::move(completion)] {
completion();
if (!saved) MessageBoxW(g_settings_hwnd, L"设置保存失败,请重试。", L"水杉输入法", MB_OK | MB_ICONWARNING);
};
});
}
else if (type == "openKeyboardPanel")
{
@@ -796,16 +835,24 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
}
else if (type == "dictionaryRequest")
{
const auto &data = value.at("data").as_object();
json::value response = SettingsDictionary::HandleRequest(data);
response.as_object()["type"] = "dictionaryResponse";
if (const auto *request_id = data.if_contains("requestId"); request_id && request_id->is_string())
response.as_object()["requestId"] = json::value_to<std::string>(*request_id);
response.as_object()["protocolVersion"] = metasequoia::webview::Version;
if (!metasequoia::webview::Validate(response, "server"))
return;
const std::wstring message = string_to_wstring(json::serialize(response));
g_webview->PostWebMessageAsJson(message.c_str());
const auto data = value.at("data").as_object();
g_worker->Submit([data]() -> SerialTaskQueue::Completion {
json::value response;
try { response = SettingsDictionary::HandleRequest(data); }
catch (...) { response = json::object{{"ok", false}, {"message", "词库操作失败,请重试"}, {"rows", json::array{}}}; }
auto &object = response.as_object();
object["type"] = "dictionaryResponse";
object["requestId"] = data.at("requestId");
object["action"] = data.at("action");
object["dictionary"] = data.at("dictionary");
object["protocolVersion"] = metasequoia::webview::Version;
if (!metasequoia::webview::Validate(response, "server"))
throw std::runtime_error("Invalid dictionary response");
auto message = string_to_wstring(json::serialize(response));
return [message = std::move(message)] {
if (g_webview) g_webview->PostWebMessageAsJson(message.c_str());
};
});
}
}
catch (const std::exception &)
@@ -883,6 +930,7 @@ HRESULT OnControllerCreated(HWND hwnd, HRESULT result, ICoreWebView2CompositionC
g_webview->add_ContentLoading(
Callback<ICoreWebView2ContentLoadingEventHandler>(
[](ICoreWebView2 *, ICoreWebView2ContentLoadingEventArgs *) -> HRESULT {
g_last_config_message.clear();
g_webview_content_started = true;
SettingsSplash::Dismiss();
return S_OK;
@@ -1027,12 +1075,24 @@ int TopNonClientInset(HWND hwnd)
void ActivateWindow(HWND hwnd)
{
if (g_closing) return;
if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE);
else ShowWindow(hwnd, SW_SHOW);
SetForegroundWindow(hwnd);
SetFocus(hwnd);
}
void CloseSettings(HWND hwnd)
{
if (g_closing) return;
g_closing = true;
KillTimer(hwnd, kConfigReloadTimer);
SettingsSplash::Dismiss();
ShowWindow(hwnd, SW_HIDE);
if (g_worker) g_worker->Stop();
else DestroyWindow(hwnd);
}
LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_param)
{
switch (message)
@@ -1138,7 +1198,7 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
{
RECT client{};
GetClientRect(hwnd, &client);
const bool light = ResolveConfiguredTheme(GetConfiguredThemeSettings()) == "light";
const bool light = g_settings_light;
HBRUSH brush = CreateSolidBrush(light ? RGB(243, 243, 243) : RGB(32, 32, 32));
FillRect(reinterpret_cast<HDC>(w_param), &client, brush);
DeleteObject(brush);
@@ -1168,16 +1228,33 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
break;
}
case WM_TIMER:
if (w_param == kConfigReloadTimer && ReloadImeConfigIfChanged())
if (w_param == kConfigReloadTimer && g_worker && !g_closing && !g_reload_pending)
{
ApplySettingsChromeTheme(hwnd);
PostConfig();
g_reload_pending = true;
g_worker->Submit([] {
SerialTaskQueue::Completion completion;
try { if (ReloadImeConfigIfChanged()) completion = ConfigCompletion(); }
catch (...) { /* Retry on the next timer tick; do not poison the queue. */ }
return [completion = std::move(completion)] {
g_reload_pending = false;
if (completion) completion();
};
});
}
return 0;
case kWorkerCompleted:
if (g_worker)
{
g_worker->Drain(!g_closing);
if (g_closing && g_worker->Stopped()) DestroyWindow(hwnd);
}
return 0;
case WM_CLOSE:
DestroyWindow(hwnd);
CloseSettings(hwnd);
return 0;
case WM_DESTROY:
g_closing = true;
if (g_worker) g_worker->Stop();
KillTimer(hwnd, kConfigReloadTimer);
SettingsSplash::Dismiss();
g_webview.Reset();
@@ -1214,7 +1291,6 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_
return 0;
}
InitImeConfig();
WNDCLASSEXW window_class{sizeof(window_class)};
window_class.style = CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS;
window_class.lpfnWndProc = WindowProc;
@@ -1235,6 +1311,14 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_
x, y, width, height, nullptr, nullptr, instance, nullptr);
if (!hwnd) return 1;
g_settings_hwnd = hwnd;
g_worker = std::make_unique<SerialTaskQueue>([hwnd] { PostMessageW(hwnd, kWorkerCompleted, 0, 0); },
[] { MessageBoxW(g_settings_hwnd, L"设置操作失败,请重试。", L"水杉输入法", MB_OK | MB_ICONWARNING); },
[] { if (g_worker_com_initialized) CoUninitialize(); });
g_worker->Submit([] {
g_worker_com_initialized = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
InitImeConfig();
return ConfigCompletion();
});
DWM_WINDOW_CORNER_PREFERENCE corner = DWMWCP_ROUND;
DWM_SYSTEMBACKDROP_TYPE backdrop = DWMSBT_NONE;
@@ -1254,7 +1338,7 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_
InitWebView(hwnd);
ShowWindow(hwnd, show_command == SW_HIDE ? SW_SHOWNORMAL : show_command);
UpdateWindow(hwnd);
SettingsSplash::Show(hwnd);
SettingsSplash::Show(hwnd, g_settings_light);
if (g_webview_content_started)
SettingsSplash::Dismiss();
@@ -1264,6 +1348,7 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_
TranslateMessage(&message);
DispatchMessageW(&message);
}
g_worker.reset(); // Drain accepted writes even if the window was destroyed externally.
CoUninitialize();
return static_cast<int>(message.wParam);
}
+11 -9
View File
@@ -1,5 +1,4 @@
#include "settings/settings_splash.h"
#include "config/ime_config.h"
#include <d2d1.h>
#include <d2d1helper.h>
@@ -46,11 +45,6 @@ bool g_close_hover = false;
float g_phase = 0.0f;
ULONGLONG g_last_tick = 0;
bool IsLightTheme()
{
return ResolveConfiguredTheme(GetConfiguredThemeSettings()) == "light";
}
void ReleaseDeviceResources()
{
g_text_brush.Reset();
@@ -76,7 +70,7 @@ void ApplyNativeChrome(HWND hwnd)
DWM_WINDOW_CORNER_PREFERENCE corner = DWMWCP_ROUND;
DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));
const BOOL dark = IsLightTheme() ? FALSE : TRUE;
const BOOL dark = g_light ? FALSE : TRUE;
DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &dark, sizeof(dark));
DWM_SYSTEMBACKDROP_TYPE backdrop = DWMSBT_NONE;
@@ -145,7 +139,6 @@ HRESULT EnsureRenderTarget()
D2D1::HwndRenderTargetProperties(g_hwnd, D2D1::SizeU(width, height)), &g_rt);
if (FAILED(hr)) return hr;
g_light = IsLightTheme();
const D2D1_COLOR_F text = g_light ? D2D1::ColorF(0x1A1A1A) : D2D1::ColorF(0xF0F0F0);
const D2D1_COLOR_F muted = g_light ? D2D1::ColorF(0x6B6B6B) : D2D1::ColorF(0xA8A8A8);
const D2D1_COLOR_F accent = g_light ? D2D1::ColorF(0x7A3E91) : D2D1::ColorF(0xD88BDE);
@@ -342,8 +335,17 @@ ATOM RegisterSplashClass(HINSTANCE instance)
namespace SettingsSplash
{
bool Show(HWND owner)
void SetTheme(bool light)
{
if (g_light == light) return;
g_light = light;
ReleaseDeviceResources();
if (g_hwnd) { ApplyNativeChrome(g_hwnd); InvalidateRect(g_hwnd, nullptr, FALSE); }
}
bool Show(HWND owner, bool light)
{
g_light = light;
if (!owner) return false;
if (g_hwnd && IsWindow(g_hwnd))
{
+2 -1
View File
@@ -6,7 +6,8 @@
// Uses the same DWM rounded-corner chrome so edges match the host window.
namespace SettingsSplash
{
bool Show(HWND owner);
bool Show(HWND owner, bool light);
void SetTheme(bool light);
void Dismiss();
void SyncToOwner();
bool IsVisible();
+1
View File
@@ -30,6 +30,7 @@ set(TEST_SOURCE_FILES
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_text_policy.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_window_template.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_inline_protocol.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_settings_performance.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_size_estimator.cpp"
"${CMAKE_SOURCE_DIR}/src/utils/candidate_size_estimator.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_ipc_protocol_constants.cpp"
@@ -0,0 +1,83 @@
#include "tests/includes/test_framework.h"
#include "settings/dictionary_page.h"
#include "settings/serial_task_queue.h"
#include <chrono>
#include <future>
#include <set>
TEST_CASE(settings_worker_preserves_order_and_drains_on_close)
{
const auto owner = std::this_thread::get_id();
std::promise<void> started, release;
auto wait = release.get_future().share();
std::vector<int> work, delivered;
bool off_ui = false;
{
SerialTaskQueue queue([] {}, [] {});
REQUIRE(queue.Submit([&] {
off_ui = std::this_thread::get_id() != owner;
started.set_value(); wait.wait(); work.push_back(1);
return [&] { delivered.push_back(1); };
}));
const auto status = started.get_future().wait_for(std::chrono::seconds(5));
// Release even if scheduling failed so a failed test cannot deadlock destruction.
if (status != std::future_status::ready) release.set_value();
REQUIRE(status == std::future_status::ready);
REQUIRE(queue.Submit([&] { work.push_back(2); return [&] { delivered.push_back(2); }; }));
queue.Stop();
REQUIRE(!queue.Submit([] { return SerialTaskQueue::Completion{}; }));
queue.Drain(false);
REQUIRE(delivered.empty());
release.set_value();
}
REQUIRE(off_ui);
REQUIRE_EQ(work, (std::vector<int>{1, 2}));
REQUIRE(delivered.empty()); // Destruction never invokes a callback into a closed UI.
}
TEST_CASE(settings_worker_delivers_results_only_on_owner_and_survives_errors)
{
const auto owner = std::this_thread::get_id();
std::promise<void> done;
int errors = 0, delivered = 0;
SerialTaskQueue queue([] {}, [&] { REQUIRE(std::this_thread::get_id() == owner); ++errors; }, [&] { done.set_value(); });
queue.Submit([]() -> SerialTaskQueue::Completion { throw std::runtime_error("test"); });
queue.Submit([&] { return [&] { REQUIRE(std::this_thread::get_id() == owner); ++delivered; }; });
queue.Stop();
REQUIRE(done.get_future().wait_for(std::chrono::seconds(5)) == std::future_status::ready);
REQUIRE_EQ(errors, 0); REQUIRE_EQ(delivered, 0);
queue.Drain();
REQUIRE_EQ(errors, 1); REQUIRE_EQ(delivered, 1);
}
TEST_CASE(dictionary_pages_cover_ties_without_duplicates_or_unbounded_results)
{
namespace json = boost::json;
sqlite3 *db = nullptr;
REQUIRE(sqlite3_open(":memory:", &db) == SQLITE_OK);
struct Close { sqlite3 *db; ~Close() { sqlite3_close(db); } } close{db};
REQUIRE(sqlite3_exec(db, "CREATE TABLE words(code TEXT,word TEXT PRIMARY KEY,weight INTEGER)", nullptr, nullptr, nullptr) == SQLITE_OK);
for (int i = 0; i < 205; ++i)
{
const auto sql = "INSERT INTO words VALUES('a','word" + std::to_string(i) + "',100)";
REQUIRE(sqlite3_exec(db, sql.c_str(), nullptr, nullptr, nullptr) == SQLITE_OK);
}
std::set<std::string> seen;
for (int offset : {0, 100, 200})
{
json::object request{{"offset", offset}, {"limit", 100}};
const auto sql = "SELECT code,word,weight FROM words ORDER BY weight DESC,code,word" + SettingsDictionary::Paging::Sql(request);
sqlite3_stmt *stmt = nullptr;
REQUIRE(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) == SQLITE_OK);
auto result = SettingsDictionary::Paging::Read(stmt, request);
sqlite3_finalize(stmt);
REQUIRE(result.at("ok").as_bool());
REQUIRE_EQ(result.at("offset").as_int64(), offset);
REQUIRE_EQ(result.at("hasMore").as_bool(), offset < 200);
REQUIRE_EQ(result.at("rows").as_array().size(), offset < 200 ? size_t{100} : size_t{5});
for (const auto &row : result.at("rows").as_array())
REQUIRE(seen.insert(json::value_to<std::string>(row.at("word"))).second);
}
REQUIRE_EQ(seen.size(), size_t{205});
REQUIRE_EQ(SettingsDictionary::Paging::Limit({{"limit", 999999}}), 200);
}
@@ -1,22 +1,32 @@
import { onHostMessage } from '../utils/host-messages';
import { serializeHostMessage } from '../../../../shared/messages';
import { applyCandidateArrange, applyDropdownValue, applyToggleState } from './shared';
import { applyCandidateArrange, applyDropdownValue as applyDropdown, applyToggleState as applyToggle } from './shared';
let lastSnapshot: Record<string, any> | null = null;
const readyModules = new Set<string>();
export function notifySettingsModuleReady(name: string): void {
readyModules.add(name);
if (lastSnapshot) applyConfigData(lastSnapshot);
if (lastSnapshot) applyConfigData(lastSnapshot, name);
}
function applyConfigData(data: Record<string, any>): void {
function applyConfigData(data: Record<string, any>, target?: string): void {
const applies = (name: string) => readyModules.has(name) && (!target || target === name);
const findElement = (id: string) => {
const element = document.getElementById(id);
return !target || (element && document.getElementById(target)?.contains(element)) ? element : null;
};
const applyToggleState = (id: string, value: boolean) => { if (findElement(id)) applyToggle(id, value); };
const applyDropdownValue = (id: string, menu: string, value: string | undefined) => {
if (findElement(id)) applyDropdown(id, menu, value);
};
lastSnapshot = data;
applyCandidateArrange(data?.appearance?.candidate_window_layout);
if (!target || target === 'appearance' || target === 'skin') applyCandidateArrange(data?.appearance?.candidate_window_layout);
if (readyModules.has('appearance')) {
if (applies('appearance')) {
void import('./appearance').then((module) => {
if (data !== lastSnapshot) return;
module.applyAppearanceConfig(
data?.appearance?.candidate_window_preedit_style,
data?.appearance?.tsf_preedit_style,
@@ -57,8 +67,9 @@ function applyConfigData(data: Record<string, any>): void {
});
}
if (readyModules.has('skin')) {
if (!target || target === 'skin') {
void import('./skin').then((module) => {
if (data !== lastSnapshot) return;
module.applyCandidateSkinCatalog(
data?.appearance?.external_candidate_skins,
data?.appearance?.candidate_skin_scan_issues,
@@ -102,7 +113,7 @@ function applyConfigData(data: Record<string, any>): void {
}
if (typeof data?.general?.candidate_translations === 'boolean') {
applyToggleState('candidateTranslationsToggleBtn', data.general.candidate_translations);
document.getElementById('candidateTranslationApiOptions')?.classList.toggle(
findElement('candidateTranslationApiOptions')?.classList.toggle(
'is-disabled',
!data.general.candidate_translations
);
@@ -144,27 +155,27 @@ function applyConfigData(data: Record<string, any>): void {
applyToggleState('clipboardHistoryToggleBtn', data.utility.clipboard_history);
}
if (typeof data?.general?.paging_minus_equal === 'boolean') {
const checkbox = document.getElementById('pagingMinusEqualCheckbox') as HTMLInputElement | null;
const checkbox = findElement('pagingMinusEqualCheckbox') as HTMLInputElement | null;
if (checkbox) checkbox.checked = data.general.paging_minus_equal;
}
if (typeof data?.general?.paging_tab === 'boolean') {
const checkbox = document.getElementById('pagingTabCheckbox') as HTMLInputElement | null;
const checkbox = findElement('pagingTabCheckbox') as HTMLInputElement | null;
if (checkbox) checkbox.checked = data.general.paging_tab;
}
if (typeof data?.general?.paging_comma_period === 'boolean') {
const checkbox = document.getElementById('pagingCommaPeriodCheckbox') as HTMLInputElement | null;
const checkbox = findElement('pagingCommaPeriodCheckbox') as HTMLInputElement | null;
if (checkbox) checkbox.checked = data.general.paging_comma_period;
}
if (typeof data?.general?.paging_brackets === 'boolean') {
const checkbox = document.getElementById('pagingBracketsCheckbox') as HTMLInputElement | null;
const checkbox = findElement('pagingBracketsCheckbox') as HTMLInputElement | null;
if (checkbox) checkbox.checked = data.general.paging_brackets;
}
if (typeof data?.general?.paging_page_up_down === 'boolean') {
const checkbox = document.getElementById('pagingPageUpDownCheckbox') as HTMLInputElement | null;
const checkbox = findElement('pagingPageUpDownCheckbox') as HTMLInputElement | null;
if (checkbox) checkbox.checked = data.general.paging_page_up_down;
}
if (typeof data?.general?.candidate_arrow_navigation === 'boolean') {
const checkbox = document.getElementById('candidateArrowNavigationCheckbox') as HTMLInputElement | null;
const checkbox = findElement('candidateArrowNavigationCheckbox') as HTMLInputElement | null;
if (checkbox) checkbox.checked = data.general.candidate_arrow_navigation;
}
if (typeof data?.helpcode?.show_sp_helpcode_in_candidate_window === 'boolean') {
@@ -215,8 +226,9 @@ function applyConfigData(data: Record<string, any>): void {
}
}
if (readyModules.has('input') || readyModules.has('helpcode') || readyModules.has('tools-settings')) {
if (applies('input') || applies('helpcode') || applies('tools-settings')) {
void import('./input').then((module) => {
if (data !== lastSnapshot) return;
module.applyInputConfig(
data?.input?.mode,
data?.input?.schema,
@@ -236,14 +248,15 @@ function applyConfigData(data: Record<string, any>): void {
module.applyCustomTranslationConfig(data?.custom_translation);
});
}
if (readyModules.has('voice') && data?.voice_input && typeof data.voice_input === 'object') {
void import('./voice').then((module) => module.applyVoiceConfig(data.voice_input));
if (applies('voice') && data?.voice_input && typeof data.voice_input === 'object') {
void import('./voice').then((module) => { if (data === lastSnapshot) module.applyVoiceConfig(data.voice_input); });
}
if (readyModules.has('ai-settings') && data?.ai_assistant && typeof data.ai_assistant === 'object') {
void import('./ai-settings').then((module) => module.applyAiConfig(data.ai_assistant));
if (applies('ai-settings') && data?.ai_assistant && typeof data.ai_assistant === 'object') {
void import('./ai-settings').then((module) => { if (data === lastSnapshot) module.applyAiConfig(data.ai_assistant); });
}
if (readyModules.has('floating-toolbar')) {
if (applies('floating-toolbar')) {
void import('./floating-toolbar').then((module) => {
if (data !== lastSnapshot) return;
module.applyFloatingToolbarItemsConfig({
fullwidth: data?.general?.floating_toolbar_fullwidth,
punctuation: data?.general?.floating_toolbar_punctuation,
@@ -258,8 +271,8 @@ function applyConfigData(data: Record<string, any>): void {
);
});
}
if (readyModules.has('shortcut')) {
void import('./shortcut').then((module) => module.applyShortcutConfig(data?.keybindings));
if (applies('shortcut')) {
void import('./shortcut').then((module) => { if (data === lastSnapshot) module.applyShortcutConfig(data?.keybindings); });
}
}
@@ -1,3 +1,4 @@
import { createDictionaryPager, DICTIONARY_PAGE_SIZE } from '../utils/dictionary-pagination';
import { onHostMessage } from '../utils/host-messages';
import type { SettingsMessage } from '../../../../shared/messages';
type DictionaryRequest = Extract<SettingsMessage, { type: 'dictionaryRequest' }>['data'];
@@ -9,14 +10,19 @@ let dictionary: DictionaryType = 'quanpin';
let editing: DictionaryRow | null = null;
let requestCounter = 0;
let lastQuery = '';
let lastAction = 'query';
const pendingRequests = new Map<string, { action: DictionaryRequest['action']; dictionary: string }>();
let latestQueryId = '';
let pager: ReturnType<typeof createDictionaryPager> | undefined;
let toastTimer: number | null = null;
function post(action: DictionaryRequest['action'], data: Partial<Omit<DictionaryRequest, 'action' | 'requestId'>> = {}): void {
lastAction = action;
const requestId = `dict-${++requestCounter}`;
const targetDictionary = dictionary;
pendingRequests.set(requestId, { action, dictionary: targetDictionary });
if (action === 'query') { latestQueryId = requestId; pager?.loading(); }
window.chrome?.webview?.postMessage(serializeHostMessage({
type: 'dictionaryRequest',
data: { requestId: `dict-${++requestCounter}`, dictionary, action, ...data }
data: { requestId, dictionary, action, ...data }
}));
}
@@ -52,7 +58,7 @@ function renderRows(rows: DictionaryRow[]): void {
const tr = document.createElement('tr');
const indexCell = document.createElement('td');
indexCell.className = 'dict-index-column';
indexCell.textContent = String(index + 1);
indexCell.textContent = String((pager?.offset ?? 0) + index + 1);
tr.appendChild(indexCell);
const values = dictionary === 'english'
? [row.word, row.display ?? row.word, String(row.weight ?? 0)]
@@ -90,6 +96,8 @@ function syncTableHeaderWidth(): void {
}
function updateMode(): void {
latestQueryId = '';
pager?.reset();
const english = dictionary === 'english';
const quanpin = dictionary === 'quanpin';
const search = document.getElementById('dictSearch') as HTMLInputElement;
@@ -129,10 +137,10 @@ function closeDialog(): void {
const modal = document.getElementById('dictModal')!; modal.classList.remove('open'); modal.setAttribute('aria-hidden', 'true'); editing = null;
}
function query(): void {
lastQuery = (document.getElementById('dictSearch') as HTMLInputElement).value.trim();
function query(offset = 0): void {
if (offset === 0) lastQuery = (document.getElementById('dictSearch') as HTMLInputElement).value.trim();
if (!lastQuery) { showToast('请输入查询内容', false); return; }
post('query', dictionary === 'english' ? { word: lastQuery } : { code: lastQuery });
post('query', { ...(dictionary === 'english' ? { word: lastQuery } : { code: lastQuery }), offset, limit: DICTIONARY_PAGE_SIZE });
}
function downloadExport(content: string, filename: string): void {
@@ -151,11 +159,13 @@ function downloadExport(content: string, filename: string): void {
}
export function setupDictionary(): void {
const table = document.querySelector<HTMLElement>('.dict-table-wrap');
if (table) pager = createDictionaryPager(table, offset => query(offset));
document.querySelectorAll<HTMLButtonElement>('.dict-tab').forEach((tab) => tab.addEventListener('click', () => {
document.querySelector('.dict-tab.active')?.classList.remove('active'); tab.classList.add('active');
dictionary = tab.dataset.dictionary as DictionaryType; updateMode();
}));
document.getElementById('dictSearchButton')?.addEventListener('click', query);
document.getElementById('dictSearchButton')?.addEventListener('click', () => query());
document.getElementById('dictSearch')?.addEventListener('keydown', (event) => { if ((event as KeyboardEvent).key === 'Enter') query(); });
document.getElementById('dictAddButton')?.addEventListener('click', () => openDialog());
document.getElementById('dictImportButton')?.addEventListener('click', () => {
@@ -224,6 +234,17 @@ export function setupDictionary(): void {
});
onHostMessage('dictionaryResponse', payload => {
if (!payload.requestId.startsWith('dict-')) return;
const context = pendingRequests.get(payload.requestId);
if (!context) return;
pendingRequests.delete(payload.requestId);
if (context.action === 'query' && payload.requestId !== latestQueryId) return;
if (context.action === 'query' && context.dictionary !== dictionary) return;
const lastAction = context.action;
if (lastAction === 'query') {
if (payload.ok) pager?.update(payload.offset ?? 0, payload.rows.length, payload.hasMore === true);
else pager?.failed();
}
const isImport = lastAction === 'import' || lastAction === 'importHans';
const isExport = lastAction === 'export';
if (isExport && payload.ok && typeof payload.content === 'string' && typeof payload.filename === 'string') {
@@ -234,8 +255,8 @@ export function setupDictionary(): void {
Boolean(payload.ok),
isImport ? 5600 : 3200,
);
if (Array.isArray(payload.rows)) renderRows(payload.rows);
if (payload.ok && lastAction !== 'query' && !isExport) { closeDialog(); if (lastQuery) query(); }
if (lastAction === 'query' && payload.ok) renderRows(payload.rows);
if (payload.ok && lastAction !== 'query' && !isExport && context.dictionary === dictionary) { closeDialog(); if (lastQuery) query(pager?.offset ?? 0); }
});
updateMode();
}
@@ -6,7 +6,6 @@ const BACKGROUND_MODULES = [
'helpcode',
'shortcut',
'dict',
'skin',
'voice',
'screenkb-settings',
'handwriting-settings',
@@ -1,3 +1,7 @@
let skinInitialized = false;
let catalogKey = '';
let styleGeneration = 0;
const styleLoads = new Map<string, Promise<void>>();
import { serializeHostMessage } from '../../../../shared/messages';
import { loadHTML } from '../utils/common-utils';
import ftbHTML from '../../../../ftb/default.html?raw';
@@ -138,26 +142,30 @@ function candidatePreviewCss(skin: ExternalSkin): string {
return css;
}
async function injectScopedSkinCss(skin: ExternalSkin, stylesheet: string | undefined, styleId: string, force = false): Promise<void> {
if (!stylesheet) return;
if (!force && loadedExternalStyleIds.has(styleId) && document.getElementById(styleId)) return;
try {
const response = await fetch(resourceUrl(skin.id, stylesheet), { cache: 'no-store' });
if (!response.ok) return;
const css = rewriteSkinCssUrls(await response.text(), skin.id).replace(/:root\b/g, ':scope');
const scoped = `@scope ([data-external-skin-preview="${skin.id}"]) {\n${css}\n}`;
let style = document.getElementById(styleId) as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = styleId;
style.dataset.externalSkinStyle = skin.id;
document.head.appendChild(style);
function injectScopedSkinCss(skin: ExternalSkin, stylesheet: string | undefined, styleId: string, force = false): Promise<void> {
if (!stylesheet || (!force && loadedExternalStyleIds.has(styleId) && document.getElementById(styleId))) return Promise.resolve();
const pending = styleLoads.get(styleId);
if (pending) return pending;
const generation = styleGeneration;
const task = (async () => {
try {
const response = await fetch(resourceUrl(skin.id, stylesheet), { cache: 'no-store' });
if (!response.ok) return;
const css = rewriteSkinCssUrls(await response.text(), skin.id).replace(/:root\b/g, ':scope');
if (generation !== styleGeneration) return;
let style = document.getElementById(styleId) as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style'); style.id = styleId;
style.dataset.externalSkinStyle = skin.id; document.head.appendChild(style);
}
style.textContent = `@scope ([data-external-skin-preview="${skin.id}"]) {\n${css}\n}`;
loadedExternalStyleIds.add(styleId);
} catch {
// Keep the inherited built-in preview if a stylesheet is unavailable.
}
style.textContent = scoped;
loadedExternalStyleIds.add(styleId);
} catch {
// Preview stays on the inherited built-in base if the stylesheet cannot load.
}
})().finally(() => { if (styleLoads.get(styleId) === task) styleLoads.delete(styleId); });
styleLoads.set(styleId, task);
return task;
}
function injectGeneratedCandidateCss(skin: ExternalSkin, force = false): void {
@@ -183,6 +191,8 @@ async function ensureExternalSkinStyle(skin: ExternalSkin, force = false): Promi
}
function resetExternalSkinStyles(): void {
styleGeneration++;
styleLoads.clear();
document.querySelectorAll('style[data-external-skin-style]').forEach((node) => node.remove());
loadedExternalStyleIds.clear();
}
@@ -302,6 +312,7 @@ function bindSkinSwitch(element: HTMLElement, skinId: string, enabled: boolean):
}
function renderExternalSkins(): void {
if (!skinInitialized) return;
const list = document.getElementById('externalSkinList');
const empty = document.getElementById('externalSkinEmpty');
const directory = document.getElementById('externalSkinDirectory');
@@ -389,7 +400,7 @@ function renderExternalSkins(): void {
previews.append(horizontal, vertical, toolbar);
card.append(header, previews);
void ensureExternalSkinStyle(skin, true).then(() => applyExternalCardTheme(skin));
void ensureExternalSkinStyle(skin).then(() => applyExternalCardTheme(skin));
return card;
}));
empty.textContent = catalogScanned ? '没有发现外部皮肤。' : '尚未扫描。点击“刷新皮肤”读取皮肤目录。';
@@ -416,6 +427,9 @@ function renderExternalSkins(): void {
export function applyCandidateSkinCatalog(
skins: unknown, issues: unknown, directory: unknown, scanned: unknown, revision: unknown
): void {
const key = JSON.stringify([skins, issues, directory, scanned, revision]);
if (key === catalogKey) return;
catalogKey = key;
const nextRevision = typeof revision === 'number' && Number.isFinite(revision) ? revision : 0;
if (nextRevision !== catalogRevision) resetExternalSkinStyles();
externalSkins = Array.isArray(skins) ? skins.filter((skin): skin is ExternalSkin =>
@@ -449,7 +463,6 @@ export function syncSkinPreviewTheme(theme: SkinPreviewTheme): void {
}
export async function setupSkin(): Promise<void> {
resetExternalSkinStyles();
previewHorizontalHtml = await loadHTML('/src/partials/candidate/candidate-wnd-h.html');
previewVerticalHtml = await loadHTML('/src/partials/candidate/candidate-wnd-v.html');
document.querySelectorAll<HTMLElement>('[data-skin-horizontal]').forEach((host) => {
@@ -459,6 +472,7 @@ export async function setupSkin(): Promise<void> {
host.innerHTML = previewVerticalHtml; limitCandidatePreview(host);
});
document.querySelectorAll<HTMLElement>('[data-skin-toolbar]').forEach(fillToolbar);
skinInitialized = true;
BUILTIN_SKINS.forEach((skin) => applyBuiltinCardTheme(skin, resolvedPreviewTheme(skin)));
syncSkinSwitches();
syncAppearancePreviews();
@@ -1,3 +1,4 @@
import { createDictionaryPager, DICTIONARY_PAGE_SIZE } from '../utils/dictionary-pagination';
import { onHostMessage } from '../utils/host-messages';
import type { SettingsMessage } from '../../../../shared/messages';
type DictionaryRequest = Extract<SettingsMessage, { type: 'dictionaryRequest' }>['data'];
@@ -9,14 +10,20 @@ type QuickPhraseRow = { code: string; word: string; weight: number };
let editing: QuickPhraseRow | null = null;
let requestCounter = 0;
let lastAction = 'query';
const pendingRequests = new Map<string, { action: DictionaryRequest['action']; dictionary: string }>();
let latestQueryId = '';
let lastQuery = '';
let pager: ReturnType<typeof createDictionaryPager> | undefined;
let toastTimer: number | null = null;
function post(action: DictionaryRequest['action'], data: Partial<Omit<DictionaryRequest, 'action' | 'requestId'>> = {}): void {
lastAction = action;
const requestId = `quick-${++requestCounter}`;
const targetDictionary = 'quick';
pendingRequests.set(requestId, { action, dictionary: targetDictionary });
if (action === 'query') { latestQueryId = requestId; pager?.loading(); }
window.chrome?.webview?.postMessage(serializeHostMessage({
type: 'dictionaryRequest',
data: { requestId: `quick-${++requestCounter}`, dictionary: 'quick', action, ...data }
data: { requestId, dictionary: 'quick', action, ...data }
}));
}
@@ -46,7 +53,7 @@ function renderRows(rows: QuickPhraseRow[]): void {
if (!rows.length) { body.innerHTML = '<tr><td colspan="5" class="dict-empty">没有找到快捷短语</td></tr>'; syncHeader(); return; }
body.replaceChildren(...rows.map((row, index) => {
const tr = document.createElement('tr');
[String(index + 1), row.code, row.word, String(row.weight)].forEach((value, cellIndex) => {
[String((pager?.offset ?? 0) + index + 1), row.code, row.word, String(row.weight)].forEach((value, cellIndex) => {
const td = document.createElement('td'); td.textContent = value;
if (cellIndex === 0) td.className = 'dict-index-column';
td.addEventListener('mouseenter', () => td.scrollWidth > td.clientWidth ? td.title = value : td.removeAttribute('title'));
@@ -74,8 +81,9 @@ function closeDialog(): void {
const modal = document.getElementById('quickPhraseModal')!; modal.classList.remove('open'); modal.setAttribute('aria-hidden', 'true'); editing = null;
}
function query(): void {
post('query', { code: (document.getElementById('quickPhraseSearch') as HTMLInputElement).value.trim() });
function query(offset = 0): void {
if (offset === 0) lastQuery = (document.getElementById('quickPhraseSearch') as HTMLInputElement).value.trim();
post('query', { code: lastQuery, offset, limit: DICTIONARY_PAGE_SIZE });
}
function downloadExport(content: string, filename: string): void {
@@ -94,6 +102,8 @@ function downloadExport(content: string, filename: string): void {
}
export function setupToolsSettings(): void {
const table = document.getElementById('quickPhraseTableWrap');
if (table) pager = createDictionaryPager(table, offset => query(offset));
setupToggleButton('clipboardHistoryToggleBtn', (active) => {
updateConfig('utility.clipboard_history', active);
});
@@ -121,7 +131,7 @@ export function setupToolsSettings(): void {
setupToggleButton('rModeToggleBtn', (active) => {
updateConfig('utility.r_mode', active);
});
document.getElementById('quickPhraseSearchButton')?.addEventListener('click', query);
document.getElementById('quickPhraseSearchButton')?.addEventListener('click', () => query());
document.getElementById('quickPhraseSearch')?.addEventListener('keydown', event => { if ((event as KeyboardEvent).key === 'Enter') query(); });
document.getElementById('quickPhraseAddButton')?.addEventListener('click', () => openDialog());
document.getElementById('quickPhraseImportButton')?.addEventListener('click', () => {
@@ -158,14 +168,24 @@ export function setupToolsSettings(): void {
window.addEventListener('resize', syncHeader);
onHostMessage('dictionaryResponse', payload => {
if (!payload.requestId.startsWith('quick-')) return;
const context = pendingRequests.get(payload.requestId);
if (!context) return;
pendingRequests.delete(payload.requestId);
if (context.action === 'query' && payload.requestId !== latestQueryId) return;
const lastAction = context.action;
if (lastAction === 'query') {
if (payload.ok) pager?.update(payload.offset ?? 0, payload.rows.length, payload.hasMore === true);
else pager?.failed();
}
const isImport = lastAction === 'import';
const isExport = lastAction === 'export';
if (isExport && payload.ok && typeof payload.content === 'string' && typeof payload.filename === 'string') {
downloadExport(payload.content, payload.filename);
}
showToast(payload.message ?? (payload.ok ? '操作成功' : '操作失败'), Boolean(payload.ok), isImport ? 5600 : 3200);
renderRows(payload.rows.filter((row): row is QuickPhraseRow =>
if (lastAction === 'query' && payload.ok) renderRows(payload.rows.filter((row): row is QuickPhraseRow =>
typeof row.code === 'string' && typeof row.weight === 'number'));
if (payload.ok && lastAction !== 'query' && !isExport) { closeDialog(); query(); }
if (payload.ok && lastAction !== 'query' && !isExport) { closeDialog(); query(pager?.offset ?? 0); }
});
}
@@ -400,3 +400,6 @@ html[data-theme="dark"] .dict-toast.error .dict-toast-icon {
.dict-toast-close:focus {
outline: none;
}
.dict-pagination { display: flex; align-items: center; gap: 12px; margin: 12px 0; }
.dict-pagination button:disabled { opacity: .45; cursor: default; }
@@ -0,0 +1,35 @@
export const DICTIONARY_PAGE_SIZE = 100;
export function createDictionaryPager(table: HTMLElement, load: (offset: number) => void) {
let offset = 0;
let hasMore = false;
let pending = false;
const footer = document.createElement('div');
footer.className = 'dict-pagination';
const previous = document.createElement('button');
const next = document.createElement('button');
const status = document.createElement('span');
previous.type = next.type = 'button';
previous.className = next.className = 'dict-button secondary';
previous.textContent = '上一页'; next.textContent = '下一页';
status.setAttribute('aria-live', 'polite');
footer.append(previous, status, next); table.after(footer);
const sync = () => {
previous.disabled = pending || offset === 0;
next.disabled = pending || !hasMore;
};
previous.addEventListener('click', () => load(Math.max(0, offset - DICTIONARY_PAGE_SIZE)));
next.addEventListener('click', () => load(offset + DICTIONARY_PAGE_SIZE));
sync();
return {
get offset() { return offset; },
loading() { pending = true; status.textContent = '查询中…'; sync(); },
failed() { pending = false; status.textContent = '查询失败,请重试'; sync(); },
reset() { offset = 0; hasMore = false; pending = false; status.textContent = ''; sync(); },
update(start: number, count: number, more: boolean) {
offset = start; hasMore = more; pending = false;
status.textContent = count ? `第 ${start + 1}–${start + count} 条${more ? ',后面还有结果' : ''}` : '没有更多结果';
sync();
},
};
}
+3 -3
View File
@@ -15,7 +15,7 @@ export type ClientMessage =
| { "type": "windowControl"; "protocolVersion"?: 1; "data": "minimize" | "maximize" | "restore" | "close" }
| { "type": "maximizeButtonRect"; "protocolVersion"?: 1; "data": { "x": number; "y": number; "width": number; "height": number; "dpr": number } }
| { "type": "configUpdate"; "protocolVersion"?: 1; "data": { "path": string; "value": string | number | boolean } }
| { "type": "dictionaryRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "dictionary": "quanpin" | "wubi" | "english" | "quick"; "action": "query" | "create" | "update" | "delete" | "import" | "importHans" | "export"; "word"?: string; "code"?: string; "content"?: string; "weight"?: number; "oldWord"?: string; "oldCode"?: string; "display"?: string; "oldDisplay"?: string } }
| { "type": "dictionaryRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "dictionary": "quanpin" | "wubi" | "english" | "quick"; "action": "query" | "create" | "update" | "delete" | "import" | "importHans" | "export"; "word"?: string; "code"?: string; "content"?: string; "weight"?: number; "oldWord"?: string; "oldCode"?: string; "display"?: string; "oldDisplay"?: string; "offset"?: number; "limit"?: number } }
| { "type": "openKeyboardPanel"; "protocolVersion"?: 1 }
| { "type": "candidate"; "protocolVersion"?: 1; "data": number }
| { "type": "delete"; "protocolVersion"?: 1; "data": number }
@@ -48,7 +48,7 @@ export type ServerMessage =
| { "type": "configSnapshot"; "protocolVersion"?: 1; "data": { [key: string]: unknown } }
| { "type": "windowState"; "protocolVersion"?: 1; "data": { "isMaximized": boolean } }
| { "type": "maxButtonEvent"; "protocolVersion"?: 1; "data": { "event": "enter" | "leave" | "down" | "up" | "click" } }
| { "type": "dictionaryResponse"; "protocolVersion"?: 1; "requestId": string; "action"?: string; "dictionary"?: string; "ok": boolean; "message": string; "rows": Array<{ "word": string; "code"?: string; "display"?: string; "weight"?: number }>; "content"?: string; "filename"?: string; [key: string]: unknown };
| { "type": "dictionaryResponse"; "protocolVersion"?: 1; "requestId": string; "action"?: string; "dictionary"?: string; "ok": boolean; "message": string; "rows": Array<{ "word": string; "code"?: string; "display"?: string; "weight"?: number }>; "content"?: string; "filename"?: string; "offset"?: number; "hasMore"?: boolean; [key: string]: unknown };
export type SettingsMessage =
| { "type": "configRequest"; "protocolVersion"?: 1 }
| { "type": "skinCatalogRequest"; "protocolVersion"?: 1 }
@@ -63,7 +63,7 @@ export type SettingsMessage =
| { "type": "windowControl"; "protocolVersion"?: 1; "data": "minimize" | "maximize" | "restore" | "close" }
| { "type": "maximizeButtonRect"; "protocolVersion"?: 1; "data": { "x": number; "y": number; "width": number; "height": number; "dpr": number } }
| { "type": "configUpdate"; "protocolVersion"?: 1; "data": { "path": string; "value": string | number | boolean } }
| { "type": "dictionaryRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "dictionary": "quanpin" | "wubi" | "english" | "quick"; "action": "query" | "create" | "update" | "delete" | "import" | "importHans" | "export"; "word"?: string; "code"?: string; "content"?: string; "weight"?: number; "oldWord"?: string; "oldCode"?: string; "display"?: string; "oldDisplay"?: string } }
| { "type": "dictionaryRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "dictionary": "quanpin" | "wubi" | "english" | "quick"; "action": "query" | "create" | "update" | "delete" | "import" | "importHans" | "export"; "word"?: string; "code"?: string; "content"?: string; "weight"?: number; "oldWord"?: string; "oldCode"?: string; "display"?: string; "oldDisplay"?: string; "offset"?: number; "limit"?: number } }
| { "type": "openKeyboardPanel"; "protocolVersion"?: 1 };
declare global {
var MsimeProtocol: {
+1 -1
View File
@@ -1,6 +1,6 @@
// Generated by generate.py; edit messages.json.
(function(root) {
const schema = {"version":1,"client":{"configRequest":{"surfaces":["settings"]},"skinCatalogRequest":{"surfaces":["settings"]},"openSkinDirectory":{"surfaces":["settings"]},"openHandwritingPanel":{"surfaces":["settings"]},"copyText":{"surfaces":["settings"],"data":{"type":"string"}},"openExternalUrl":{"surfaces":["settings"],"data":{"type":"string"}},"dragStart":{"surfaces":["settings","toolbar"]},"focus":{"surfaces":["settings"]},"resizeStart":{"surfaces":["settings"],"data":{"type":"string","enum":["left","right","top","bottom","left-top","right-top","left-bottom","right-bottom","client"]}},"resizeHitTest":{"surfaces":["settings"],"data":{"type":"string","enum":["left","right","top","bottom","left-top","right-top","left-bottom","right-bottom","client"]}},"windowControl":{"surfaces":["settings"],"data":{"type":"string","enum":["minimize","maximize","restore","close"]}},"maximizeButtonRect":{"surfaces":["settings"],"data":{"type":"object","properties":{"x":{"type":"number"},"y":{"type":"number"},"width":{"type":"number","minimum":0},"height":{"type":"number","minimum":0},"dpr":{"type":"number","minimum":0.01}},"required":["x","y","width","height","dpr"],"additionalProperties":false}},"configUpdate":{"surfaces":["settings"],"data":{"type":"object","properties":{"path":{"type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"required":["path","value"],"additionalProperties":false}},"dictionaryRequest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"dictionary":{"type":"string","enum":["quanpin","wubi","english","quick"]},"action":{"type":"string","enum":["query","create","update","delete","import","importHans","export"]},"word":{"type":"string"},"code":{"type":"string"},"content":{"type":"string"},"weight":{"type":"integer"},"oldWord":{"type":"string"},"oldCode":{"type":"string"},"display":{"type":"string"},"oldDisplay":{"type":"string"}},"required":["requestId","dictionary","action"],"additionalProperties":false}},"openKeyboardPanel":{"surfaces":["settings","toolbar"]},"candidate":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"delete":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"pin":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"clearPosition":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"fixPosition":{"surfaces":["candidate"],"data":{"type":"object","properties":{"index":{"type":"integer","minimum":1,"maximum":10},"position":{"type":"integer","minimum":1,"maximum":5}},"required":["index","position"],"additionalProperties":false}},"contextMenuResize":{"surfaces":["candidate"],"data":{"type":"object","properties":{"width":{"type":"integer","minimum":0,"maximum":65535},"height":{"type":"integer","minimum":0,"maximum":65535},"topExpansion":{"type":"integer","minimum":0,"maximum":65535},"leftExpansion":{"type":"integer","minimum":0,"maximum":65535}},"required":["width","height"],"additionalProperties":false}},"contextMenuClosed":{"surfaces":["candidate"]},"candidatePointerArmed":{"surfaces":["candidate"]},"candidatePointerMotion":{"surfaces":["candidate"]},"candidateFrameProbe":{"surfaces":["candidate"],"data":{"type":"object","properties":{"revision":{"type":"integer"},"stage":{"type":"integer"},"performanceMs":{"type":"number","minimum":0}},"required":["revision","stage","performanceMs"],"additionalProperties":false}},"candidatePointerProbe":{"surfaces":["candidate"],"data":{"type":"object","properties":{"probe":{"type":"integer"},"screenX":{"type":"integer"},"screenY":{"type":"integer"},"clientX":{"type":"integer"},"clientY":{"type":"integer"},"movementX":{"type":"integer"},"movementY":{"type":"integer"},"armed":{"type":"boolean"}},"required":["probe","screenX","screenY","clientX","clientY","movementX","movementY","armed"],"additionalProperties":false}},"contentTruncated":{"surfaces":["candidate","toolbar","menu"],"data":{"type":"object","properties":{"surface":{"type":"string","enum":["candidate","toolbar","menu","ftb","candwnd","cand"]},"width":{"type":"number","minimum":0},"height":{"type":"number","minimum":0}},"required":["surface","width","height"],"additionalProperties":true}},"floatingToggle":{"surfaces":["menu"],"data":{"type":"boolean"}},"settings":{"surfaces":["menu"]},"about":{"surfaces":["menu"]},"emojiSymbols":{"surfaces":["menu"]},"keyboardPanel":{"surfaces":["menu"]},"handwritingPanel":{"surfaces":["menu"]},"voiceInput":{"surfaces":["menu"]},"ready":{"surfaces":["toolbar"]},"exitEnglishInputMode":{"surfaces":["toolbar"]},"changeCharacterSet":{"surfaces":["toolbar"]},"openSettings":{"surfaces":["toolbar"]},"openEmojiPanel":{"surfaces":["toolbar"]},"changeIMEMode":{"surfaces":["toolbar"],"data":{"type":"string","enum":["cn","en"]}},"changeCharMode":{"surfaces":["toolbar"],"data":{"type":"string","enum":["fullwidth","halfwidth"]}},"changePuncMode":{"surfaces":["toolbar"],"data":{"tLine truncated
const schema = {"version":1,"client":{"configRequest":{"surfaces":["settings"]},"skinCatalogRequest":{"surfaces":["settings"]},"openSkinDirectory":{"surfaces":["settings"]},"openHandwritingPanel":{"surfaces":["settings"]},"copyText":{"surfaces":["settings"],"data":{"type":"string"}},"openExternalUrl":{"surfaces":["settings"],"data":{"type":"string"}},"dragStart":{"surfaces":["settings","toolbar"]},"focus":{"surfaces":["settings"]},"resizeStart":{"surfaces":["settings"],"data":{"type":"string","enum":["left","right","top","bottom","left-top","right-top","left-bottom","right-bottom","client"]}},"resizeHitTest":{"surfaces":["settings"],"data":{"type":"string","enum":["left","right","top","bottom","left-top","right-top","left-bottom","right-bottom","client"]}},"windowControl":{"surfaces":["settings"],"data":{"type":"string","enum":["minimize","maximize","restore","close"]}},"maximizeButtonRect":{"surfaces":["settings"],"data":{"type":"object","properties":{"x":{"type":"number"},"y":{"type":"number"},"width":{"type":"number","minimum":0},"height":{"type":"number","minimum":0},"dpr":{"type":"number","minimum":0.01}},"required":["x","y","width","height","dpr"],"additionalProperties":false}},"configUpdate":{"surfaces":["settings"],"data":{"type":"object","properties":{"path":{"type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"required":["path","value"],"additionalProperties":false}},"dictionaryRequest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"dictionary":{"type":"string","enum":["quanpin","wubi","english","quick"]},"action":{"type":"string","enum":["query","create","update","delete","import","importHans","export"]},"word":{"type":"string"},"code":{"type":"string"},"content":{"type":"string"},"weight":{"type":"integer"},"oldWord":{"type":"string"},"oldCode":{"type":"string"},"display":{"type":"string"},"oldDisplay":{"type":"string"},"offset":{"type":"integer","minimum":0,"maximum":2147483647},"limit":{"type":"integer","minimum":1,"maximum":200}},"required":["requestId","dictionary","action"],"additionalProperties":false}},"openKeyboardPanel":{"surfaces":["settings","toolbar"]},"candidate":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"delete":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"pin":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"clearPosition":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"fixPosition":{"surfaces":["candidate"],"data":{"type":"object","properties":{"index":{"type":"integer","minimum":1,"maximum":10},"position":{"type":"integer","minimum":1,"maximum":5}},"required":["index","position"],"additionalProperties":false}},"contextMenuResize":{"surfaces":["candidate"],"data":{"type":"object","properties":{"width":{"type":"integer","minimum":0,"maximum":65535},"height":{"type":"integer","minimum":0,"maximum":65535},"topExpansion":{"type":"integer","minimum":0,"maximum":65535},"leftExpansion":{"type":"integer","minimum":0,"maximum":65535}},"required":["width","height"],"additionalProperties":false}},"contextMenuClosed":{"surfaces":["candidate"]},"candidatePointerArmed":{"surfaces":["candidate"]},"candidatePointerMotion":{"surfaces":["candidate"]},"candidateFrameProbe":{"surfaces":["candidate"],"data":{"type":"object","properties":{"revision":{"type":"integer"},"stage":{"type":"integer"},"performanceMs":{"type":"number","minimum":0}},"required":["revision","stage","performanceMs"],"additionalProperties":false}},"candidatePointerProbe":{"surfaces":["candidate"],"data":{"type":"object","properties":{"probe":{"type":"integer"},"screenX":{"type":"integer"},"screenY":{"type":"integer"},"clientX":{"type":"integer"},"clientY":{"type":"integer"},"movementX":{"type":"integer"},"movementY":{"type":"integer"},"armed":{"type":"boolean"}},"required":["probe","screenX","screenY","clientX","clientY","movementX","movementY","armed"],"additionalProperties":false}},"contentTruncated":{"surfaces":["candidate","toolbar","menu"],"data":{"type":"object","properties":{"surface":{"type":"string","enum":["candidate","toolbar","menu","ftb","candwnd","cand"]},"width":{"type":"number","minimum":0},"height":{"type":"number","minimum":0}},"required":["surface","width","height"],"additionalProperties":true}},"floatingToggle":{"surfaces":["menu"],"data":{"type":"boolean"}},"settings":{"surfaces":["menu"]},"about":{"surfaces":["menu"]},"emojiSymbols":{"surfaces":["menu"]},"keyboardPanel":{"surfaces":["menu"]},"handwritingPanel":{"surfaces":["menu"]},"voiceInput":{"surfaces":["menu"]},"ready":{"surfaces":["toolbar"]},"exitEnglishInputMode":{"surfaces":["toolbar"]},"changeCharacterSet":{"surfaces":["toolbar"]},"openSettings":{"surfaces":["toolbar"]},"openEmojiPanel":{"surfaces":["toolbar"]},"changeIMEMode":{"surfaces":["toolbar"],"data":{"type":"string","enum":["cn","en"]}},"changeCharMode":{"surfaces":["toolbaLine truncated
root.MsimeMessageSchema = schema;
if (typeof module !== 'undefined' && module.exports) module.exports = schema;
})(globalThis);