diff --git a/server/src/settings/settings_app.cpp b/server/src/settings/settings_app.cpp index 4122f4d5..5d9be688 100644 --- a/server/src/settings/settings_app.cpp +++ b/server/src/settings/settings_app.cpp @@ -428,9 +428,10 @@ void PostConfig(bool refresh_skin_catalog = false) {"show_qp_helpcode_in_candidate_window", GetConfiguredShowQuanpinHelpcodeInCandidateWindow()}}}}}}; payload["data"]["voice_input"]["polish_presets"] = std::move(polish_presets); payload["protocolVersion"] = metasequoia::webview::Version; - if (!metasequoia::webview::Validate(json::parse(payload.dump()), "server")) + const std::string serialized = payload.dump(); + if (!metasequoia::webview::Validate(json::parse(serialized), "server")) return; - const std::wstring message = string_to_wstring(payload.dump()); + const std::wstring message = string_to_wstring(serialized); g_webview->PostWebMessageAsJson(message.c_str()); } @@ -440,9 +441,10 @@ void PostWindowState(HWND hwnd) return; nlohmann::json payload = {{"type", "windowState"}, {"data", {{"isMaximized", IsZoomed(hwnd) != FALSE}}}}; payload["protocolVersion"] = metasequoia::webview::Version; - if (!metasequoia::webview::Validate(json::parse(payload.dump()), "server")) + const std::string serialized = payload.dump(); + if (!metasequoia::webview::Validate(json::parse(serialized), "server")) return; - const std::wstring message = string_to_wstring(payload.dump()); + const std::wstring message = string_to_wstring(serialized); g_webview->PostWebMessageAsJson(message.c_str()); } @@ -452,9 +454,10 @@ void PostMaximizeButtonEvent(const char *event_name) return; nlohmann::json payload = {{"type", "maxButtonEvent"}, {"data", {{"event", event_name}}}}; payload["protocolVersion"] = metasequoia::webview::Version; - if (!metasequoia::webview::Validate(json::parse(payload.dump()), "server")) + const std::string serialized = payload.dump(); + if (!metasequoia::webview::Validate(json::parse(serialized), "server")) return; - const std::wstring message = string_to_wstring(payload.dump()); + const std::wstring message = string_to_wstring(serialized); g_webview->PostWebMessageAsJson(message.c_str()); } @@ -794,14 +797,14 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args) else if (type == "dictionaryRequest") { const auto &data = value.at("data").as_object(); - nlohmann::json response = nlohmann::json::parse(boost::json::serialize(SettingsDictionary::HandleRequest(data))); - response["type"] = "dictionaryResponse"; + 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["requestId"] = json::value_to(*request_id); - response["protocolVersion"] = metasequoia::webview::Version; - if (!metasequoia::webview::Validate(json::parse(response.dump()), "server")) + response.as_object()["requestId"] = json::value_to(*request_id); + response.as_object()["protocolVersion"] = metasequoia::webview::Version; + if (!metasequoia::webview::Validate(response, "server")) return; - const std::wstring message = string_to_wstring(response.dump()); + const std::wstring message = string_to_wstring(json::serialize(response)); g_webview->PostWebMessageAsJson(message.c_str()); } } diff --git a/server/src/webview2/inline_protocol.h b/server/src/webview2/inline_protocol.h new file mode 100644 index 00000000..2be854f5 --- /dev/null +++ b/server/src/webview2/inline_protocol.h @@ -0,0 +1,26 @@ +#pragma once +#include +#include + +// Only replace our explicit shared imports; legacy/custom pages are left alone. +// Keep the original imports if either local file is missing. +inline bool InlineWebViewProtocolScripts(std::wstring &html, std::wstring_view schema, + std::wstring_view runtime) +{ + constexpr std::wstring_view schemaTag = L""; + constexpr std::wstring_view runtimeTag = L""; + if (schema.empty() || runtime.empty() || html.find(schemaTag) == std::wstring::npos || + html.find(runtimeTag) == std::wstring::npos) + return false; + const auto script = [](std::wstring_view source) { + std::wstring escaped(source); + // A slash escape preserves JavaScript string values while preventing an + // embedded HTML end tag (including mixed case) from closing the script. + for (size_t pos = 0; (pos = escaped.find(L"" + escaped + L""; + }; + html.replace(html.find(schemaTag), schemaTag.size(), script(schema)); + html.replace(html.find(runtimeTag), runtimeTag.size(), script(runtime)); + return true; +} diff --git a/server/src/webview2/windows_webview2.cpp b/server/src/webview2/windows_webview2.cpp index 9488aca1..cde76ca1 100644 --- a/server/src/webview2/windows_webview2.cpp +++ b/server/src/webview2/windows_webview2.cpp @@ -1,5 +1,6 @@ #include "MetasequoiaImeEngine/contracts/webview/validator.h" #include "windows_webview2.h" +#include "webview2/inline_protocol.h" #include "webview2/candidate_window_template.h" #include "config/ime_config.h" #include "defines/globals.h" @@ -2419,6 +2420,14 @@ int PrepareHtmlForWnds() L"external-toolbar-skin"); } } + // The small windows navigate from strings. Inline the pinned local runtime + // before navigation instead of blocking first paint on two virtual-host loads. + // Immutable for this process, like the executable's protocol contract. + static const std::wstring protocolSchema = ReadHtmlFile(assetPath + L"/html/webview2/shared/schema.js"); + static const std::wstring protocolRuntime = ReadHtmlFile(assetPath + L"/html/webview2/shared/runtime.js"); + InlineWebViewProtocolScripts(::HTMLStringCandWnd, protocolSchema, protocolRuntime); + InlineWebViewProtocolScripts(::HTMLStringMenuWnd, protocolSchema, protocolRuntime); + InlineWebViewProtocolScripts(::HTMLStringFtbWnd, protocolSchema, protocolRuntime); preparedCandidateSkin = candidateSkin; return 0; @@ -2808,8 +2817,8 @@ HRESULT OnControllerCreatedCandWnd( // // Configure virtual host path if (SUCCEEDED(webviewCandWnd->QueryInterface(IID_PPV_ARGS(&webview3CandWnd)))) { - const auto contractsPath = std::filesystem::path(CommonUtils::get_local_appdata_path()) / GlobalIme::AppName / - "html" / "webview2" / "shared"; + const auto contractsPath = std::filesystem::path(CommonUtils::get_local_appdata_path_w()) / GlobalIme::AppName / + L"html" / L"webview2" / L"shared"; webview3CandWnd->SetVirtualHostNameToFolderMapping(L"msime-contracts", contractsPath.wstring().c_str(), COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW); @@ -3222,8 +3231,8 @@ HRESULT OnControllerCreatedMenuWnd( // // Configure virtual host path if (SUCCEEDED(webviewMenuWnd->QueryInterface(IID_PPV_ARGS(&webview3MenuWnd)))) { - const auto contractsPath = std::filesystem::path(CommonUtils::get_local_appdata_path()) / GlobalIme::AppName / - "html" / "webview2" / "shared"; + const auto contractsPath = std::filesystem::path(CommonUtils::get_local_appdata_path_w()) / GlobalIme::AppName / + L"html" / L"webview2" / L"shared"; webview3MenuWnd->SetVirtualHostNameToFolderMapping(L"msime-contracts", contractsPath.wstring().c_str(), COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW); @@ -4685,8 +4694,8 @@ HRESULT OnControllerCreatedFtbWnd( // // Configure virtual host path if (SUCCEEDED(webviewFtbWnd->QueryInterface(IID_PPV_ARGS(&webview3FtbWnd)))) { - const auto contractsPath = std::filesystem::path(CommonUtils::get_local_appdata_path()) / GlobalIme::AppName / - "html" / "webview2" / "shared"; + const auto contractsPath = std::filesystem::path(CommonUtils::get_local_appdata_path_w()) / GlobalIme::AppName / + L"html" / L"webview2" / L"shared"; webview3FtbWnd->SetVirtualHostNameToFolderMapping(L"msime-contracts", contractsPath.wstring().c_str(), COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW); diff --git a/server/tests/CMakeLists.txt b/server/tests/CMakeLists.txt index 4f97de8b..e846dfef 100644 --- a/server/tests/CMakeLists.txt +++ b/server/tests/CMakeLists.txt @@ -29,6 +29,7 @@ set(TEST_SOURCE_FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_view_model.cpp" "${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_candidate_size_estimator.cpp" "${CMAKE_SOURCE_DIR}/src/utils/candidate_size_estimator.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_ipc_protocol_constants.cpp" diff --git a/server/tests/src/test_inline_protocol.cpp b/server/tests/src/test_inline_protocol.cpp new file mode 100644 index 00000000..0739176b --- /dev/null +++ b/server/tests/src/test_inline_protocol.cpp @@ -0,0 +1,38 @@ +#include "tests/includes/test_framework.h" +#include "webview2/inline_protocol.h" + +static const std::wstring imports = + L"" + L""; + +TEST_CASE(protocol_inline_preserves_order_and_removes_resource_loads) +{ + std::wstring html = L"" + imports + L"toolbar"; + REQUIRE(InlineWebViewProtocolScripts(html, L"const schema = {};", L"const runtime = schema;")); + REQUIRE(html.find(L"src=") == std::wstring::npos); + REQUIRE(html.find(L"const schema") < html.find(L"const runtime")); + REQUIRE(html.find(L"toolbar") != std::wstring::npos); + const auto inlined = html; + REQUIRE(!InlineWebViewProtocolScripts(html, L"schema", L"runtime")); + REQUIRE_EQ(html, inlined); +} + +TEST_CASE(protocol_inline_keeps_fallback_if_assets_are_missing) +{ + std::wstring html = imports; + REQUIRE(!InlineWebViewProtocolScripts(html, L"schema", L"")); + REQUIRE_EQ(html, imports); + REQUIRE(!InlineWebViewProtocolScripts(html, L"", L"runtime")); + REQUIRE_EQ(html, imports); + html = L"legacy toolbar"; + REQUIRE(!InlineWebViewProtocolScripts(html, L"schema", L"runtime")); + REQUIRE_EQ(html, L"legacy toolbar"); +} + +TEST_CASE(protocol_inline_escapes_embedded_html_end_tags) +{ + std::wstring html = imports; + REQUIRE(InlineWebViewProtocolScripts(html, L"const schema = '\u003c/ScRiPt>';", L"const runtime = {};")); + REQUIRE(html.find(L"") == std::wstring::npos); + REQUIRE(html.find(L"<\\/ScRiPt>") != std::wstring::npos); +} diff --git a/ui-html/webview2/settings/ime-settings/src/main.ts b/ui-html/webview2/settings/ime-settings/src/main.ts index daae1949..9214abc7 100644 --- a/ui-html/webview2/settings/ime-settings/src/main.ts +++ b/ui-html/webview2/settings/ime-settings/src/main.ts @@ -1,4 +1,5 @@ -import { serializeHostMessage, isServerMessage } from '../../../shared/messages'; +import { onHostMessage } from './utils/host-messages'; +import { serializeHostMessage } from '../../../shared/messages'; import './styles/critical.css'; import { loadHTML, showOnlyCurrentModule } from './utils/common-utils'; @@ -61,41 +62,11 @@ function setupWindowStateSync(): void { return; } - window.chrome.webview.addEventListener('message', (event: Event & { data?: any }) => { - const payload = typeof event.data === 'string' ? safeParseJson(event.data) : event.data; - if (!isServerMessage(payload)) { - return; - } - - if (payload.type !== 'windowState') { - return; - } - - const nextState = payload.data; - if (typeof nextState === 'boolean') { - setWindowMaximized(nextState); - return; - } - - if (typeof nextState === 'string') { - setWindowMaximized(nextState === 'maximized'); - return; - } - - if (nextState && typeof nextState.isMaximized === 'boolean') { - setWindowMaximized(nextState.isMaximized); - } + onHostMessage('windowState', payload => { + setWindowMaximized(payload.data.isMaximized); }); } -function safeParseJson(value: string): unknown { - try { - return JSON.parse(value); - } catch { - return null; - } -} - function setupTitlebarButtons(): void { const minimizeBtn = document.getElementById('btn-minimize'); const maximizeBtn = document.getElementById('btn-maximize'); @@ -169,16 +140,7 @@ function setupTitlebarButtons(): void { }; if (window.chrome?.webview) { - window.chrome.webview.addEventListener('message', (event: Event & { data?: any }) => { - const payload = typeof event.data === 'string' ? safeParseJson(event.data) : event.data; - if (!isServerMessage(payload)) { - return; - } - - if (payload.type !== 'maxButtonEvent') { - return; - } - + onHostMessage('maxButtonEvent', payload => { const eventType = payload.data?.event; if (typeof eventType !== 'string') { return; @@ -207,7 +169,7 @@ function setupTitlebarButtons(): void { if (eventType === 'up') { activeBtn.classList.remove('host-active'); - if (windowState.isMaximized) { + if (windowState.isMaximized) { postWindowMessage('restore'); } else { postWindowMessage('maximize'); diff --git a/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts b/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts index 7c64a627..9c2a9d8e 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts @@ -1,14 +1,7 @@ -import { serializeHostMessage, isServerMessage } from '../../../../shared/messages'; +import { onHostMessage } from '../utils/host-messages'; +import { serializeHostMessage } from '../../../../shared/messages'; import { applyCandidateArrange, applyDropdownValue, applyToggleState } from './shared'; -function safeParseJson(value: string): unknown { - try { - return JSON.parse(value); - } catch { - return null; - } -} - let lastSnapshot: Record | null = null; const readyModules = new Set(); @@ -275,11 +268,7 @@ export function setupConfigSync(): void { return; } - window.chrome.webview.addEventListener('message', (event: Event & { data?: any }) => { - const payload = typeof event.data === 'string' ? safeParseJson(event.data) : event.data; - if (!isServerMessage(payload) || payload.type !== 'configSnapshot') { - return; - } + onHostMessage('configSnapshot', payload => { applyConfigData(payload.data ?? {}); }); diff --git a/ui-html/webview2/settings/ime-settings/src/modules/dict.ts b/ui-html/webview2/settings/ime-settings/src/modules/dict.ts index 73969144..d28486ab 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/dict.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/dict.ts @@ -1,6 +1,7 @@ +import { onHostMessage } from '../utils/host-messages'; import type { SettingsMessage } from '../../../../shared/messages'; type DictionaryRequest = Extract['data']; -import { serializeHostMessage, isServerMessage } from '../../../../shared/messages'; +import { serializeHostMessage } from '../../../../shared/messages'; type DictionaryType = 'quanpin' | 'wubi' | 'english'; type DictionaryRow = { code?: string; word: string; weight?: number; display?: string }; @@ -221,9 +222,8 @@ export function setupDictionary(): void { }); else post(editing ? 'update' : 'create', { code, word, weight, oldCode: editing?.code, oldWord: editing?.word }); }); - window.chrome?.webview?.addEventListener('message', (event: Event & { data?: any }) => { - const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; - if (!isServerMessage(payload) || payload.type !== 'dictionaryResponse' || !String(payload.requestId ?? '').startsWith('dict-')) return; + onHostMessage('dictionaryResponse', payload => { + if (!payload.requestId.startsWith('dict-')) return; const isImport = lastAction === 'import' || lastAction === 'importHans'; const isExport = lastAction === 'export'; if (isExport && payload.ok && typeof payload.content === 'string' && typeof payload.filename === 'string') { diff --git a/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts b/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts index 570f20e0..38ba3b51 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts @@ -1,6 +1,7 @@ +import { onHostMessage } from '../utils/host-messages'; import type { SettingsMessage } from '../../../../shared/messages'; type DictionaryRequest = Extract['data']; -import { serializeHostMessage, isServerMessage } from '../../../../shared/messages'; +import { serializeHostMessage } from '../../../../shared/messages'; import { setupToggleButton } from './shared'; import { updateConfig } from './config-sync'; @@ -155,9 +156,8 @@ export function setupToolsSettings(): void { if (event.key === 'Escape' && document.getElementById('quickPhraseModal')?.classList.contains('open')) { event.preventDefault(); closeDialog(); (document.activeElement as HTMLElement | null)?.blur(); } }); window.addEventListener('resize', syncHeader); - window.chrome?.webview?.addEventListener('message', (event: Event & { data?: any }) => { - const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; - if (!isServerMessage(payload) || payload.type !== 'dictionaryResponse' || !String(payload.requestId ?? '').startsWith('quick-')) return; + onHostMessage('dictionaryResponse', payload => { + if (!payload.requestId.startsWith('quick-')) return; const isImport = lastAction === 'import'; const isExport = lastAction === 'export'; if (isExport && payload.ok && typeof payload.content === 'string' && typeof payload.filename === 'string') { diff --git a/ui-html/webview2/settings/ime-settings/src/utils/host-messages.ts b/ui-html/webview2/settings/ime-settings/src/utils/host-messages.ts new file mode 100644 index 00000000..0fdb5977 --- /dev/null +++ b/ui-html/webview2/settings/ime-settings/src/utils/host-messages.ts @@ -0,0 +1,30 @@ +import { isServerMessage, type ServerMessage } from '../../../../shared/messages'; + +const subscribers = new Set<(message: ServerMessage) => void>(); +let listening = false; + +// WebView2 dispatches each message once; parsing and schema traversal belong here, +// before fan-out to the lazily loaded settings modules. +export function onHostMessage( + type: T, + handler: (message: Extract) => void, +): () => void { + const webview = window.chrome?.webview; + if (!webview) return () => {}; + subscribers.add(deliver); + if (!listening) { + webview.addEventListener('message', (event: Event & { data?: unknown }) => { + let payload = event.data; + if (typeof payload === 'string') { + try { payload = JSON.parse(payload); } catch { return; } + } + if (!isServerMessage(payload)) return; + subscribers.forEach(subscriber => subscriber(payload)); + }); + listening = true; + } + function deliver(message: ServerMessage): void { + if (message.type === type) handler(message as Extract); + } + return () => { subscribers.delete(deliver); }; +}