From ffcd1aee288333ca714e7438c96b9de2ff863ff3 Mon Sep 17 00:00:00 2001 From: Jia Sui Date: Sun, 20 Sep 2026 19:08:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BE=93=E5=85=A5=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=EF=BC=88DLL=20=E9=87=87=E9=9B=86=E3=80=81Server=20=E8=81=9A?= =?UTF-8?q?=E5=90=88=E5=AD=98=E5=82=A8=E4=B8=8E=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - windows: 三上屏出口 + OnTestKeyDown 直通字符采集,修复半角数字恒零 - engine: 统计 Web 消息契约(statsRequest/statsResponse) - server: 统计管道接收、聚合、SQLite 存储、保留策略与每日自动清理 - ui-html: 设置页「统计」模块(卡片、热图、明细、开关、清理) - docs: PRIVACY / README 改为内置可选统计 --- PRIVACY.md | 10 +- README.en.md | 1 + README.md | 1 + .../contracts/tests/windows_ipc_contract.cpp | 13 +- engine/contracts/webview/fixtures.json | 274 +++++++ engine/contracts/webview/generate.py | 9 +- engine/contracts/webview/messages.json | 208 +++++ engine/contracts/webview/messages.ts | 5 +- engine/contracts/webview/schema.h | 2 +- engine/contracts/webview/schema.js | 2 +- engine/contracts/windows_ipc.h | 54 ++ installer/default_config/config.default.toml | 16 + server/CMakeLists.txt | 13 + server/assets/config/config.toml | 16 + server/src/config/ime_config.cpp | 43 ++ server/src/config/ime_config.h | 7 + server/src/ipc/ipc.cpp | 9 + server/src/ipc/ipc.h | 2 + server/src/main.cpp | 5 + server/src/settings/settings_app.cpp | 201 ++++- server/src/statistics/stats_aggregate.cpp | 153 ++++ server/src/statistics/stats_aggregate.h | 61 ++ server/src/statistics/stats_frames.cpp | 168 ++++ server/src/statistics/stats_frames.h | 66 ++ server/src/statistics/stats_overview.cpp | 185 +++++ server/src/statistics/stats_overview.h | 30 + server/src/statistics/stats_pipe.cpp | 229 ++++++ server/src/statistics/stats_pipe.h | 40 + server/src/statistics/stats_store.cpp | 662 ++++++++++++++++ server/src/statistics/stats_store.h | 155 ++++ server/src/statistics/stats_types.h | 289 +++++++ server/src/webview2/windows_webview2.cpp | 20 +- server/tests/CMakeLists.txt | 8 + .../tests/src/test_config_non_ascii_path.cpp | 142 ++++ .../tests/src/test_ipc_protocol_constants.cpp | 12 + server/tests/src/test_stats_aggregate.cpp | 250 ++++++ server/tests/src/test_stats_frame_decode.cpp | 223 ++++++ server/tests/src/test_stats_overview.cpp | 264 +++++++ server/tests/src/test_stats_store.cpp | 727 ++++++++++++++++++ .../webview2/settings/ime-settings/index.html | 1 + .../public/assets/sidebar/stats.svg | 3 + .../ime-settings/src/modules/config-sync.ts | 11 + .../ime-settings/src/modules/sidebar.ts | 4 + .../ime-settings/src/modules/stats.test.ts | 198 +++++ .../ime-settings/src/modules/stats.ts | 587 ++++++++++++++ .../ime-settings/src/partials/sidebar.html | 1 + .../ime-settings/src/partials/stats.html | 130 ++++ .../ime-settings/src/styles/deferred.css | 1 + .../ime-settings/src/styles/modules/stats.css | 469 +++++++++++ .../ime-settings/src/utils/common-utils.ts | 1 + ui-html/webview2/shared/messages.ts | 5 +- ui-html/webview2/shared/schema.js | 2 +- windows/CMakeLists.txt | 7 + windows/src/Composition/Composition.cpp | 98 +++ windows/src/Composition/EndComposition.cpp | 5 + windows/src/IME/MetasequoiaIME.cpp | 2 + windows/src/IME/MetasequoiaIME.h | 19 + windows/src/Key/KeyEventSink.cpp | 78 ++ windows/src/Statistics/char_classify.cpp | 147 ++++ windows/src/Statistics/char_classify.h | 30 + windows/src/Statistics/stats_collector.cpp | 167 ++++ windows/src/Statistics/stats_collector.h | 91 +++ windows/src/Statistics/stats_passthrough.h | 31 + windows/tests/CMakeLists.txt | 7 + windows/tests/statistics.cpp | 215 ++++++ 65 files changed, 6874 insertions(+), 11 deletions(-) create mode 100644 server/src/statistics/stats_aggregate.cpp create mode 100644 server/src/statistics/stats_aggregate.h create mode 100644 server/src/statistics/stats_frames.cpp create mode 100644 server/src/statistics/stats_frames.h create mode 100644 server/src/statistics/stats_overview.cpp create mode 100644 server/src/statistics/stats_overview.h create mode 100644 server/src/statistics/stats_pipe.cpp create mode 100644 server/src/statistics/stats_pipe.h create mode 100644 server/src/statistics/stats_store.cpp create mode 100644 server/src/statistics/stats_store.h create mode 100644 server/src/statistics/stats_types.h create mode 100644 server/tests/src/test_stats_aggregate.cpp create mode 100644 server/tests/src/test_stats_frame_decode.cpp create mode 100644 server/tests/src/test_stats_overview.cpp create mode 100644 server/tests/src/test_stats_store.cpp create mode 100644 ui-html/webview2/settings/ime-settings/public/assets/sidebar/stats.svg create mode 100644 ui-html/webview2/settings/ime-settings/src/modules/stats.test.ts create mode 100644 ui-html/webview2/settings/ime-settings/src/modules/stats.ts create mode 100644 ui-html/webview2/settings/ime-settings/src/partials/stats.html create mode 100644 ui-html/webview2/settings/ime-settings/src/styles/modules/stats.css create mode 100644 windows/src/Statistics/char_classify.cpp create mode 100644 windows/src/Statistics/char_classify.h create mode 100644 windows/src/Statistics/stats_collector.cpp create mode 100644 windows/src/Statistics/stats_collector.h create mode 100644 windows/src/Statistics/stats_passthrough.h create mode 100644 windows/tests/statistics.cpp diff --git a/PRIVACY.md b/PRIVACY.md index 025fdce3..1d30ec40 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -18,7 +18,15 @@ Every claim below is checkable against this repository. Where a default matters, ## No telemetry -There is no analytics, telemetry, crash reporting or usage measurement of any kind. No third-party SDK for those purposes is linked, and there is no endpoint that receives usage data. This is verifiable: searching the `server/`, `windows/`, `ui/`, `ui-html/` and `installer/` trees for `telemetry`, `analytics`, `sentry`, `matomo`, `posthog`, `amplitude`, `mixpanel`, `crashpad` and `breakpad` returns nothing. +There is no analytics, telemetry, crash reporting, or usage measurement that leaves this machine. The input method does not record, store or transmit what you type: the only records it can keep are the optional local counters described below, and those cover character classes and activity time, never text. No third-party SDK for those purposes is linked, and there is no endpoint that receives usage data. This is verifiable: searching the `server/`, `windows/`, `ui/`, `ui-html/` and `installer/` trees for `telemetry`, `analytics`, `sentry`, `matomo`, `posthog`, `amplitude`, `mixpanel`, `crashpad` and `breakpad` returns nothing. + +## Local input statistics (built-in, off by default) + +The input method can show a local typing summary in 设置 → 统计. This is a built-in capability, not a separately distributed tool: `statistics.enabled` is `false` in the shipped `installer/default_config/config.default.toml`, so a fresh install records nothing, and 设置 → 统计 lets you turn recording on. + +While enabled, the input method records only counters in a local SQLite database: per-day and per-hour counts of five character classes (Chinese, Latin, digits, punctuation, other) plus the time counted as actively typed. The counts come from text the input method commits to the document and from printable keystrokes it leaves for the application to insert; the second half is a keystroke-level approximation and can slightly over-count (for example an input field that rejects or truncates the character). No text, spelling, candidates, application name or window title is stored, and the counters are carried to the Server process over a session-scoped local named pipe only. The database is `\stats.db`, where `` is the data directory chosen at install time (default `%LOCALAPPDATA%\metasequoiaime`); it makes no network requests and uploads nothing. + +Turning the switch off stops new recordings; existing counters are kept until they are deleted. To remove them, use the controls in 设置 → 统计: the automatic retention policy (keep the last 30 / 90 / 180 / 365 days) deletes expired days on the next write after midnight, and the "clear all statistics" button immediately wipes every record, including today's. `statistics.retention` in `config.toml` stores the policy and defaults to `forever`, which never deletes automatically; an invalid value falls back to `forever` so a broken config cannot delete data. Deleting `stats.db` works too. ## Local data diff --git a/README.en.md b/README.en.md index df2ff5cf..5a006214 100644 --- a/README.en.md +++ b/README.en.md @@ -33,6 +33,7 @@ You need the **Microsoft Visual C++ 2015–2022 Redistributable (x64)**. The ser - Optional cloud candidates, and AI suggestions via DeepSeek / OpenAI / SiliconFlow / Groq - English-Chinese glosses in the vertical candidate window - Mixed Chinese-English input, emoji and kaomoji, a dedicated English candidate mode +- Local input statistics (off by default): character counts and active time stay on this machine; view and clean them under Settings → Statistics - Voice input with streaming recognition and optional cleanup - Handwriting, on-screen keyboard, floating toolbar, clipboard history - User dictionary management, candidate frequency tuning, skins with light and dark variants diff --git a/README.md b/README.md index 13027626..3aefe733 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ Windows 端的全部一方源码都在本仓,一次 clone 就能拿到完整 - 词库管理:全拼 / 五笔 / 英文查询、新增、批量导入与导出。 - 语音输入:豆包流式识别,以及 OpenAI / SiliconFlow / Groq 转写;可选文本润色。 - 手写识别、屏幕键盘、悬浮工具栏、剪贴板历史。 +- 输入统计(默认关闭):只在本机记录字符数量与活跃时长,可在设置 → 统计中查看与清理。 - 快捷短语(K)、日期时间(T)、Unicode(U)、Emoji(E)、颜文字(M)、超级简拼(J)、临时英文(Y)、临时日语(R)。 - 智能标点(中文标点后按空格转英文标点,可撤回)、成对标点补全、以词定字、简繁转换。 - 多种皮肤:Fluent、微信绿、石墨 Graphite、杨柳青 Willow green,均支持深色 / 浅色。 diff --git a/engine/contracts/tests/windows_ipc_contract.cpp b/engine/contracts/tests/windows_ipc_contract.cpp index da19020a..1d35fded 100644 --- a/engine/contracts/tests/windows_ipc_contract.cpp +++ b/engine/contracts/tests/windows_ipc_contract.cpp @@ -126,5 +126,16 @@ int main() std::copy(blank[0].begin(), blank[0].end(), header.begin()); const auto empty = FanyImeVoiceCompositionPipe::ParseFrame(header.data()); CHECK(empty.valid && empty.first && empty.last && empty.chunk.empty()); - std::cout << "Windows IPC layout, negotiation, upgrade and voice contracts passed\n"; + + // Statistics frames: fixed 28-byte header plus 24-byte events; the Go stats + // tool mirrors these numbers in internal/frames, so both sides must change + // together. + CHECK(FANY_IME_STATS_MAGIC == 0x54415453); + CHECK(FANY_IME_STATS_VERSION == 1); + CHECK(sizeof(FanyImeStatsBatchHeader) == 28); + CHECK(sizeof(FanyImeStatsEvent) == 24); + CHECK(offsetof(FanyImeStatsEvent, timestamp_utc_ft) == 0); + CHECK(offsetof(FanyImeStatsEvent, cjk) == 8); + CHECK(sizeof(FanyImeStatsBatchHeader) + 256 * sizeof(FanyImeStatsEvent) <= FANY_IME_STATS_MAX_FRAME_BYTES); + std::cout << "Windows IPC layout, negotiation, upgrade, voice and stats contracts passed\n"; } diff --git a/engine/contracts/webview/fixtures.json b/engine/contracts/webview/fixtures.json index ab60c625..76ae429a 100644 --- a/engine/contracts/webview/fixtures.json +++ b/engine/contracts/webview/fixtures.json @@ -446,5 +446,279 @@ "direction": "client", "surface": "settings", "valid": false + }, + { + "name": "stats overview request", + "message": { + "type": "statsRequest", + "data": { + "requestId": "stats-1", + "action": "overview" + } + }, + "direction": "client", + "surface": "settings", + "valid": true + }, + { + "name": "stats clear all request", + "message": { + "type": "statsRequest", + "data": { + "requestId": "stats-2", + "action": "clearAll" + } + }, + "direction": "client", + "surface": "settings", + "valid": true + }, + { + "name": "stats request legacy clear with retention", + "message": { + "type": "statsRequest", + "data": { + "requestId": "stats-legacy", + "action": "clear", + "retention": "forever" + } + }, + "direction": "client", + "surface": "settings", + "valid": false + }, + { + "name": "stats request legacy clear action", + "message": { + "type": "statsRequest", + "data": { + "requestId": "stats-legacy-action", + "action": "clear" + } + }, + "direction": "client", + "surface": "settings", + "valid": false + }, + { + "name": "stats request legacy retention field", + "message": { + "type": "statsRequest", + "data": { + "requestId": "stats-legacy-retention", + "action": "clearAll", + "retention": "30d" + } + }, + "direction": "client", + "surface": "settings", + "valid": false + }, + { + "name": "stats request unknown action", + "message": { + "type": "statsRequest", + "data": { + "requestId": "stats-3", + "action": "delete" + } + }, + "direction": "client", + "surface": "settings", + "valid": false + }, + { + "name": "stats request missing request id", + "message": { + "type": "statsRequest", + "data": { + "action": "overview" + } + }, + "direction": "client", + "surface": "settings", + "valid": false + }, + { + "name": "stats request extra field", + "message": { + "type": "statsRequest", + "data": { + "requestId": "stats-4", + "action": "openDirectory", + "path": "C:/tmp" + } + }, + "direction": "client", + "surface": "settings", + "valid": false + }, + { + "name": "stats response with overview", + "message": { + "type": "statsResponse", + "requestId": "stats-1", + "action": "overview", + "ok": true, + "message": "已加载统计数据", + "overview": { + "hasData": true, + "firstDayKey": 20260919, + "lastDayKey": 20260920, + "days": 2, + "totalChars": 120, + "totalActiveMs": 90000, + "todayDayKey": 20260920, + "todayChars": 20, + "todayActiveMs": 30000, + "averagePerDay": 60.0, + "currentStreak": 2, + "longestStreak": 2, + "bestDayKey": 20260919, + "bestDayChars": 100, + "todaySpeed": 40.0, + "averageSpeed": 80.0, + "fastestSpeed": 100.0, + "fastestDayKey": 20260919, + "categories": { + "cjk": 80, + "latin": 20, + "digit": 10, + "punct": 10, + "other": 0 + }, + "todayHourly": [0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 5, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "daily": [ + { + "dayKey": 20260919, + "cjk": 70, + "latin": 20, + "digit": 5, + "punct": 5, + "other": 0, + "activeMs": 60000 + }, + { + "dayKey": 20260920, + "cjk": 10, + "latin": 0, + "digit": 5, + "punct": 5, + "other": 0, + "activeMs": 30000 + } + ] + } + }, + "direction": "server", + "surface": "settings", + "valid": true + }, + { + "name": "stats response malformed overview", + "message": { + "type": "statsResponse", + "requestId": "stats-1", + "action": "overview", + "ok": true, + "message": "已加载统计数据", + "overview": { + "hasData": true, + "firstDayKey": 20260919, + "lastDayKey": 20260920, + "days": 2, + "totalChars": 120, + "totalActiveMs": 90000, + "todayDayKey": 20260920, + "todayChars": 20, + "todayActiveMs": 30000, + "averagePerDay": 60.0, + "currentStreak": 2, + "longestStreak": 2, + "bestDayKey": 20260919, + "bestDayChars": 100, + "todaySpeed": 40.0, + "averageSpeed": 80.0, + "fastestSpeed": 100.0, + "fastestDayKey": 20260919, + "categories": { + "cjk": 80, + "latin": 20, + "digit": 10, + "punct": 10, + "other": 0 + }, + "todayHourly": [], + "daily": [ + { + "dayKey": 20260919, + "latin": 20, + "digit": 5, + "punct": 5, + "other": 0, + "activeMs": 60000 + } + ] + } + }, + "direction": "server", + "surface": "settings", + "valid": false + }, + { + "name": "stats response clear all", + "message": { + "type": "statsResponse", + "requestId": "stats-2", + "action": "clearAll", + "ok": true, + "message": "已清空 12 天数据", + "removedDays": 12, + "overview": { + "hasData": false, + "firstDayKey": 0, + "lastDayKey": 0, + "days": 0, + "totalChars": 0, + "totalActiveMs": 0, + "todayDayKey": 20260920, + "todayChars": 0, + "todayActiveMs": 0, + "averagePerDay": 0.0, + "currentStreak": 0, + "longestStreak": 0, + "bestDayKey": 0, + "bestDayChars": 0, + "todaySpeed": 0.0, + "averageSpeed": 0.0, + "fastestSpeed": 0.0, + "fastestDayKey": 0, + "categories": { + "cjk": 0, + "latin": 0, + "digit": 0, + "punct": 0, + "other": 0 + }, + "todayHourly": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "daily": [] + } + }, + "direction": "server", + "surface": "settings", + "valid": true + }, + { + "name": "stats response error", + "message": { + "type": "statsResponse", + "requestId": "stats-3", + "action": "clearAll", + "ok": false, + "message": "清空统计数据失败:database is locked" + }, + "direction": "server", + "surface": "settings", + "valid": true } ] diff --git a/engine/contracts/webview/generate.py b/engine/contracts/webview/generate.py index 88069e3e..660e6594 100644 --- a/engine/contracts/webview/generate.py +++ b/engine/contracts/webview/generate.py @@ -73,11 +73,16 @@ def main(): args = parser.parse_args() for name, content in generate().items(): path = ROOT / name + payload = content.encode("utf-8") if args.check: - if not path.exists() or path.read_text() != content: + # Byte comparison, not read_text(): text mode folds CRLF to LF and + # would hide a line-ending drift in an otherwise identical file. + if not path.exists() or path.read_bytes() != payload: raise SystemExit(f"Stale binding: {path}; run generate.py") else: - path.write_text(content) + # newline="\n" keeps the checked-in bytes platform-independent; text + # mode's default translation writes CRLF on Windows. + path.write_text(content, encoding="utf-8", newline="\n") if __name__ == "__main__": diff --git a/engine/contracts/webview/messages.json b/engine/contracts/webview/messages.json index 5eaf8df9..af9edf1c 100644 --- a/engine/contracts/webview/messages.json +++ b/engine/contracts/webview/messages.json @@ -282,6 +282,32 @@ "additionalProperties": false } }, + "statsRequest": { + "surfaces": [ + "settings" + ], + "data": { + "type": "object", + "properties": { + "requestId": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "overview", + "clearAll", + "openDirectory" + ] + } + }, + "required": [ + "requestId", + "action" + ], + "additionalProperties": false + } + }, "openKeyboardPanel": { "surfaces": [ "settings", @@ -746,6 +772,188 @@ "rows" ], "additionalProperties": true + }, + "statsResponse": { + "properties": { + "requestId": { + "type": "string" + }, + "action": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "overview": { + "type": "object", + "properties": { + "hasData": { + "type": "boolean" + }, + "firstDayKey": { + "type": "integer" + }, + "lastDayKey": { + "type": "integer" + }, + "days": { + "type": "integer" + }, + "totalChars": { + "type": "integer" + }, + "totalActiveMs": { + "type": "integer" + }, + "todayDayKey": { + "type": "integer" + }, + "todayChars": { + "type": "integer" + }, + "todayActiveMs": { + "type": "integer" + }, + "averagePerDay": { + "type": "number" + }, + "currentStreak": { + "type": "integer" + }, + "longestStreak": { + "type": "integer" + }, + "bestDayKey": { + "type": "integer" + }, + "bestDayChars": { + "type": "integer" + }, + "todaySpeed": { + "type": "number" + }, + "averageSpeed": { + "type": "number" + }, + "fastestSpeed": { + "type": "number" + }, + "fastestDayKey": { + "type": "integer" + }, + "categories": { + "type": "object", + "properties": { + "cjk": { + "type": "integer" + }, + "latin": { + "type": "integer" + }, + "digit": { + "type": "integer" + }, + "punct": { + "type": "integer" + }, + "other": { + "type": "integer" + } + }, + "required": [ + "cjk", + "latin", + "digit", + "punct", + "other" + ], + "additionalProperties": false + }, + "todayHourly": { + "type": "array", + "items": { + "type": "integer" + } + }, + "daily": { + "type": "array", + "items": { + "type": "object", + "properties": { + "dayKey": { + "type": "integer" + }, + "cjk": { + "type": "integer" + }, + "latin": { + "type": "integer" + }, + "digit": { + "type": "integer" + }, + "punct": { + "type": "integer" + }, + "other": { + "type": "integer" + }, + "activeMs": { + "type": "integer" + } + }, + "required": [ + "dayKey", + "cjk", + "latin", + "digit", + "punct", + "other", + "activeMs" + ], + "additionalProperties": false + } + } + }, + "required": [ + "hasData", + "firstDayKey", + "lastDayKey", + "days", + "totalChars", + "totalActiveMs", + "todayDayKey", + "todayChars", + "todayActiveMs", + "averagePerDay", + "currentStreak", + "longestStreak", + "bestDayKey", + "bestDayChars", + "todaySpeed", + "averageSpeed", + "fastestSpeed", + "fastestDayKey", + "categories", + "todayHourly", + "daily" + ], + "additionalProperties": false + }, + "removedDays": { + "type": "integer" + } + }, + "required": [ + "requestId", + "action", + "ok", + "message" + ], + "additionalProperties": true } } } diff --git a/engine/contracts/webview/messages.ts b/engine/contracts/webview/messages.ts index eb6de240..474769a7 100644 --- a/engine/contracts/webview/messages.ts +++ b/engine/contracts/webview/messages.ts @@ -18,6 +18,7 @@ export type ClientMessage = | { "type": "configUpdate"; "protocolVersion"?: 1; "data": { "path": string; "value": string | number | boolean } } | { "type": "apiCredentialTest"; "protocolVersion"?: 1; "data": { "requestId": string; "service": "translation.tencent" | "translation.niutrans" | "translation.custom" | "voice.asr" | "voice.polish" | "ai.assistant"; "config": { [key: string]: unknown } } } | { "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": "statsRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "action": "overview" | "clearAll" | "openDirectory" } } | { "type": "openKeyboardPanel"; "protocolVersion"?: 1 } | { "type": "candidate"; "protocolVersion"?: 1; "data": number } | { "type": "delete"; "protocolVersion"?: 1; "data": number } @@ -52,7 +53,8 @@ export type ServerMessage = | { "type": "windowState"; "protocolVersion"?: 1; "data": { "isMaximized": boolean } } | { "type": "maxButtonEvent"; "protocolVersion"?: 1; "data": { "event": "enter" | "leave" | "down" | "up" | "click" } } | { "type": "apiCredentialTestResult"; "protocolVersion"?: 1; "requestId": string; "ok": boolean; "message": string } - | { "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 }; + | { "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 } + | { "type": "statsResponse"; "protocolVersion"?: 1; "requestId": string; "action": string; "ok": boolean; "message": string; "overview"?: { "hasData": boolean; "firstDayKey": number; "lastDayKey": number; "days": number; "totalChars": number; "totalActiveMs": number; "todayDayKey": number; "todayChars": number; "todayActiveMs": number; "averagePerDay": number; "currentStreak": number; "longestStreak": number; "bestDayKey": number; "bestDayChars": number; "todaySpeed": number; "averageSpeed": number; "fastestSpeed": number; "fastestDayKey": number; "categories": { "cjk": number; "latin": number; "digit": number; "punct": number; "other": number }; "todayHourly": Array; "daily": Array<{ "dayKey": number; "cjk": number; "latin": number; "digit": number; "punct": number; "other": number; "activeMs": number }> }; "removedDays"?: number; [key: string]: unknown }; export type SettingsMessage = | { "type": "configRequest"; "protocolVersion"?: 1 } | { "type": "skinCatalogRequest"; "protocolVersion"?: 1 } @@ -70,6 +72,7 @@ export type SettingsMessage = | { "type": "configUpdate"; "protocolVersion"?: 1; "data": { "path": string; "value": string | number | boolean } } | { "type": "apiCredentialTest"; "protocolVersion"?: 1; "data": { "requestId": string; "service": "translation.tencent" | "translation.niutrans" | "translation.custom" | "voice.asr" | "voice.polish" | "ai.assistant"; "config": { [key: string]: unknown } } } | { "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": "statsRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "action": "overview" | "clearAll" | "openDirectory" } } | { "type": "openKeyboardPanel"; "protocolVersion"?: 1 }; declare global { var MsimeProtocol: { diff --git a/engine/contracts/webview/schema.h b/engine/contracts/webview/schema.h index 1c5cbe48..1fe82c92 100644 --- a/engine/contracts/webview/schema.h +++ b/engine/contracts/webview/schema.h @@ -2,5 +2,5 @@ // Generated by generate.py; edit messages.json. namespace metasequoia::webview { inline constexpr int Version = 1; -inline constexpr char SchemaJson[] = R"msime({"version":1,"client":{"configRequest":{"surfaces":["settings"]},"skinCatalogRequest":{"surfaces":["settings"]},"openSkinDirectory":{"surfaces":["settings"]},"openHandwritingPanel":{"surfaces":["settings"]},"restartServer":{"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}},"apiCredentialTest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"service":{"type":"string","enum":["translation.tencent","translation.niutrans","translation.custom","voice.asr","voice.polish","ai.assistant"]},"config":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"required":["requestId","service","config"],"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}},"candidateWheel":{"surfaces":["candidate"],"data":{"type":"integer","minimum":-32768,"maximum":32767}},"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":{"type":"string","enum":["puncEn","puncCn"]}}},"server":{"configSnapshot":{"data":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"windowState":{"data":{"type":"object","properties":{"isMaximized":{"type":"boolean"}},"required":["isMaximized"],"additionalProperties":false}},"maxButtonEvent":{"data":{"type":"object","properties":{"event":{"type":"string","enum":["enter","leave","down","up","click"]}},"required":["event"],"additionalProperties":false}},"apiCredentialTestResult":{"properties":{"requestId":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"}},"required":["requestId","ok","message"],"additionalProperties":false},"dictionaryResponse":{"properties":{"requestId":{"type":"string"},"action":{"type":"string"},"dictionary":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"},"rows":{"type":"array","items":{"type":"object","properties":{"word":{"type":"string"},"code":{"type":"string"},"display":{"type":"string"},"weight":{"type":"integer"}},"required":["word"],"additionalProperties":false}},"content":{"type":"string"},"filename":{"type":"string"},"offset":{"type":"integer","minimum":0},"hasMore":{"type":"boolean"}},"required":["requestId","ok","message","rows"],"additionalProperties":true}}})msime"; +inline constexpr char SchemaJson[] = R"msime({"version":1,"client":{"configRequest":{"surfaces":["settings"]},"skinCatalogRequest":{"surfaces":["settings"]},"openSkinDirectory":{"surfaces":["settings"]},"openHandwritingPanel":{"surfaces":["settings"]},"restartServer":{"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}},"apiCredentialTest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"service":{"type":"string","enum":["translation.tencent","translation.niutrans","translation.custom","voice.asr","voice.polish","ai.assistant"]},"config":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"required":["requestId","service","config"],"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}},"statsRequest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"action":{"type":"string","enum":["overview","clearAll","openDirectory"]}},"required":["requestId","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}},"candidateWheel":{"surfaces":["candidate"],"data":{"type":"integer","minimum":-32768,"maximum":32767}},"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":{"type":"string","enum":["puncEn","puncCn"]}}},"server":{"configSnapshot":{"data":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"windowState":{"data":{"type":"object","properties":{"isMaximized":{"type":"boolean"}},"required":["isMaximized"],"additionalProperties":false}},"maxButtonEvent":{"data":{"type":"object","properties":{"event":{"type":"string","enum":["enter","leave","down","up","click"]}},"required":["event"],"additionalProperties":false}},"apiCredentialTestResult":{"properties":{"requestId":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"}},"required":["requestId","ok","message"],"additionalProperties":false},"dictionaryResponse":{"properties":{"requestId":{"type":"string"},"action":{"type":"string"},"dictionary":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"},"rows":{"type":"array","items":{"type":"object","properties":{"word":{"type":"string"},"code":{"type":"string"},"display":{"type":"string"},"weight":{"type":"integer"}},"required":["word"],"additionalProperties":false}},"content":{"type":"string"},"filename":{"type":"string"},"offset":{"type":"integer","minimum":0},"hasMore":{"type":"boolean"}},"required":["requestId","ok","message","rows"],"additionalProperties":true},"statsResponse":{"properties":{"requestId":{"type":"string"},"action":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"},"overview":{"type":"object","properties":{"hasData":{"type":"boolean"},"firstDayKey":{"type":"integer"},"lastDayKey":{"type":"integer"},"days":{"type":"integer"},"totalChars":{"type":"integer"},"totalActiveMs":{"type":"integer"},"todayDayKey":{"type":"integer"},"todayChars":{"type":"integer"},"todayActiveMs":{"type":"integer"},"averagePerDay":{"type":"number"},"currentStreak":{"type":"integer"},"longestStreak":{"type":"integer"},"bestDayKey":{"type":"integer"},"bestDayChars":{"type":"integer"},"todaySpeed":{"type":"number"},"averageSpeed":{"type":"number"},"fastestSpeed":{"type":"number"},"fastestDayKey":{"type":"integer"},"categories":{"type":"object","properties":{"cjk":{"type":"integer"},"latin":{"type":"integer"},"digit":{"type":"integer"},"punct":{"type":"integer"},"other":{"type":"integer"}},"required":["cjk","latin","digit","punct","other"],"additionalProperties":false},"todayHourly":{"type":"array","items":{"type":"integer"}},"daily":{"type":"array","items":{"type":"object","properties":{"dayKey":{"type":"integer"},"cjk":{"type":"integer"},"latin":{"type":"integer"},"digit":{"type":"integer"},"punct":{"type":"integer"},"other":{"type":"integer"},"activeMs":{"type":"integer"}},"required":["dayKey","cjk","latin","digit","punct","other","activeMs"],"additionalProperties":false}}},"required":["hasData","firstDayKey","lastDayKey","days","totalChars","totalActiveMs","todayDayKey","todayChars","todayActiveMs","averagePerDay","currentStreak","longestStreak","bestDayKey","bestDayChars","todaySpeed","averageSpeed","fastestSpeed","fastestDayKey","categories","todayHourly","daily"],"additionalProperties":false},"removedDays":{"type":"integer"}},"required":["requestId","action","ok","message"],"additionalProperties":true}}})msime"; } diff --git a/engine/contracts/webview/schema.js b/engine/contracts/webview/schema.js index 957d58ed..e39c8897 100644 --- a/engine/contracts/webview/schema.js +++ b/engine/contracts/webview/schema.js @@ -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"]},"restartServer":{"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}},"apiCredentialTest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"service":{"type":"string","enum":["translation.tencent","translation.niutrans","translation.custom","voice.asr","voice.polish","ai.assistant"]},"config":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"required":["requestId","service","config"],"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}},"candidateWheel":{"surfaces":["candidate"],"data":{"type":"integer","minimum":-32768,"maximum":32767}},"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":{"type":"string","enum":["puncEn","puncCn"]}}},"server":{"configSnapshot":{"data":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"windowState":{"data":{"type":"object","properties":{"isMaximized":{"type":"boolean"}},"required":["isMaximized"],"additionalProperties":false}},"maxButtonEvent":{"data":{"type":"object","properties":{"event":{"type":"string","enum":["enter","leave","down","up","click"]}},"required":["event"],"additionalProperties":false}},"apiCredentialTestResult":{"properties":{"requestId":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"}},"required":["requestId","ok","message"],"additionalProperties":false},"dictionaryResponse":{"properties":{"requestId":{"type":"string"},"action":{"type":"string"},"dictionary":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"},"rows":{"type":"array","items":{"type":"object","properties":{"word":{"type":"string"},"code":{"type":"string"},"display":{"type":"string"},"weight":{"type":"integer"}},"required":["word"],"additionalProperties":false}},"content":{"type":"string"},"filename":{"type":"string"},"offset":{"type":"integer","minimum":0},"hasMore":{"type":"boolean"}},"required":["requestId","ok","message","rows"],"additionalProperties":true}}}; + const schema = {"version":1,"client":{"configRequest":{"surfaces":["settings"]},"skinCatalogRequest":{"surfaces":["settings"]},"openSkinDirectory":{"surfaces":["settings"]},"openHandwritingPanel":{"surfaces":["settings"]},"restartServer":{"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}},"apiCredentialTest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"service":{"type":"string","enum":["translation.tencent","translation.niutrans","translation.custom","voice.asr","voice.polish","ai.assistant"]},"config":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"required":["requestId","service","config"],"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}},"statsRequest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"action":{"type":"string","enum":["overview","clearAll","openDirectory"]}},"required":["requestId","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}},"candidateWheel":{"surfaces":["candidate"],"data":{"type":"integer","minimum":-32768,"maximum":32767}},"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":{"type":"string","enum":["puncEn","puncCn"]}}},"server":{"configSnapshot":{"data":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"windowState":{"data":{"type":"object","properties":{"isMaximized":{"type":"boolean"}},"required":["isMaximized"],"additionalProperties":false}},"maxButtonEvent":{"data":{"type":"object","properties":{"event":{"type":"string","enum":["enter","leave","down","up","click"]}},"required":["event"],"additionalProperties":false}},"apiCredentialTestResult":{"properties":{"requestId":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"}},"required":["requestId","ok","message"],"additionalProperties":false},"dictionaryResponse":{"properties":{"requestId":{"type":"string"},"action":{"type":"string"},"dictionary":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"},"rows":{"type":"array","items":{"type":"object","properties":{"word":{"type":"string"},"code":{"type":"string"},"display":{"type":"string"},"weight":{"type":"integer"}},"required":["word"],"additionalProperties":false}},"content":{"type":"string"},"filename":{"type":"string"},"offset":{"type":"integer","minimum":0},"hasMore":{"type":"boolean"}},"required":["requestId","ok","message","rows"],"additionalProperties":true},"statsResponse":{"properties":{"requestId":{"type":"string"},"action":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"},"overview":{"type":"object","properties":{"hasData":{"type":"boolean"},"firstDayKey":{"type":"integer"},"lastDayKey":{"type":"integer"},"days":{"type":"integer"},"totalChars":{"type":"integer"},"totalActiveMs":{"type":"integer"},"todayDayKey":{"type":"integer"},"todayChars":{"type":"integer"},"todayActiveMs":{"type":"integer"},"averagePerDay":{"type":"number"},"currentStreak":{"type":"integer"},"longestStreak":{"type":"integer"},"bestDayKey":{"type":"integer"},"bestDayChars":{"type":"integer"},"todaySpeed":{"type":"number"},"averageSpeed":{"type":"number"},"fastestSpeed":{"type":"number"},"fastestDayKey":{"type":"integer"},"categories":{"type":"object","properties":{"cjk":{"type":"integer"},"latin":{"type":"integer"},"digit":{"type":"integer"},"punct":{"type":"integer"},"other":{"type":"integer"}},"required":["cjk","latin","digit","punct","other"],"additionalProperties":false},"todayHourly":{"type":"array","items":{"type":"integer"}},"daily":{"type":"array","items":{"type":"object","properties":{"dayKey":{"type":"integer"},"cjk":{"type":"integer"},"latin":{"type":"integer"},"digit":{"type":"integer"},"punct":{"type":"integer"},"other":{"type":"integer"},"activeMs":{"type":"integer"}},"required":["dayKey","cjk","latin","digit","punct","other","activeMs"],"additionalProperties":false}}},"required":["hasData","firstDayKey","lastDayKey","days","totalChars","totalActiveMs","todayDayKey","todayChars","todayActiveMs","averagePerDay","currentStreak","longestStreak","bestDayKey","bestDayChars","todaySpeed","averageSpeed","fastestSpeed","fastestDayKey","categories","todayHourly","daily"],"additionalProperties":false},"removedDays":{"type":"integer"}},"required":["requestId","action","ok","message"],"additionalProperties":true}}}; root.MsimeMessageSchema = schema; if (typeof module !== 'undefined' && module.exports) module.exports = schema; })(globalThis); diff --git a/engine/contracts/windows_ipc.h b/engine/contracts/windows_ipc.h index caa61423..8029f4f3 100644 --- a/engine/contracts/windows_ipc.h +++ b/engine/contracts/windows_ipc.h @@ -28,6 +28,15 @@ inline const wchar_t *FANY_IME_VOICE_CONTROL_NAMED_PIPE = L"\\\\.\\pipe\\FanyIme inline constexpr uint32_t FANY_IME_TSF_DIAGNOSTIC_MAGIC = 0x474F4C54; // "TLOG" inline constexpr uint32_t FANY_IME_TSF_DIAGNOSTIC_VERSION = 1; inline constexpr size_t FANY_IME_TSF_DIAGNOSTIC_MAX_FRAME_BYTES = 16 * 1024; +// Optional local statistics channel: the TSF DLL is the client and the Server's +// statistics pipe listener is the server. Named pipes live in the machine-global +// namespace, so the pipe name must carry a session suffix (fast user switching +// and RDP sessions could otherwise collide); both sides append the decimal +// ProcessIdToSessionId(GetCurrentProcessId()) result. +inline const wchar_t *FANY_IME_STATS_PIPE_NAME_PREFIX = L"\\\\.\\pipe\\FanyImeStatsNamedPipe-"; +inline constexpr uint32_t FANY_IME_STATS_MAGIC = 0x54415453; // "STAT" +inline constexpr uint32_t FANY_IME_STATS_VERSION = 1; +inline constexpr size_t FANY_IME_STATS_MAX_FRAME_BYTES = 16 * 1024; inline constexpr uint64_t FANY_IME_UNSOLICITED_REQUEST_ID = 0; inline constexpr uint64_t FANY_IME_NO_REQUEST_ID = UINT64_MAX; @@ -185,6 +194,36 @@ struct FanyImeTsfDiagnosticBatchHeader uint32_t source_process_id = 0; }; +// Statistics frames never carry text. The TSF DLL classifies committed text +// inside its own process and the pipe only sees per-class counts; local-day +// bucketing, active-time and every aggregate live in the stats tool. A frame is +// header + event_count * sizeof(FanyImeStatsEvent), written in one WriteFile. +// dropped_count covers the events lost since the previous frame (queue overflow +// plus failed sends). The tool drops any frame whose magic, version or sizes do +// not match instead of parsing it. +struct FanyImeStatsBatchHeader +{ + uint32_t magic = FANY_IME_STATS_MAGIC; + uint32_t version = FANY_IME_STATS_VERSION; + uint32_t header_size = 28; + uint32_t payload_bytes = 0; + uint32_t event_count = 0; + uint32_t dropped_count = 0; + uint32_t source_process_id = 0; +}; + +// One committed-text event: when it committed and how many user-perceived +// characters of each class it contained (a UTF-16 surrogate pair counts once). +struct FanyImeStatsEvent +{ + uint64_t timestamp_utc_ft = 0; // raw GetSystemTimeAsFileTime value, UTC 100 ns + uint16_t cjk = 0; + uint16_t latin = 0; + uint16_t digit = 0; + uint16_t punct = 0; + uint16_t other = 0; +}; + static_assert(sizeof(FanyImeWireChar) == 2, "The IPC ABI requires 16-bit FanyImeWireChar."); static_assert(offsetof(FanyImeNamedpipeData, client_id) == 8); static_assert(offsetof(FanyImeNamedpipeData, request_id) == 16); @@ -197,6 +236,21 @@ static_assert(sizeof(FanyImeNamedpipeDataToTsf) == 416); static_assert(sizeof(FanyImePipeHello) == 16); static_assert(sizeof(FanyImeNamedpipeDataToTsfWorkerThread) == 404); static_assert(sizeof(FanyImeTsfDiagnosticBatchHeader) == 28); +static_assert(sizeof(FanyImeStatsBatchHeader) == 28); +static_assert(offsetof(FanyImeStatsBatchHeader, magic) == 0); +static_assert(offsetof(FanyImeStatsBatchHeader, version) == 4); +static_assert(offsetof(FanyImeStatsBatchHeader, header_size) == 8); +static_assert(offsetof(FanyImeStatsBatchHeader, payload_bytes) == 12); +static_assert(offsetof(FanyImeStatsBatchHeader, event_count) == 16); +static_assert(offsetof(FanyImeStatsBatchHeader, dropped_count) == 20); +static_assert(offsetof(FanyImeStatsBatchHeader, source_process_id) == 24); +static_assert(sizeof(FanyImeStatsEvent) == 24); +static_assert(offsetof(FanyImeStatsEvent, timestamp_utc_ft) == 0); +static_assert(offsetof(FanyImeStatsEvent, cjk) == 8); +static_assert(offsetof(FanyImeStatsEvent, latin) == 10); +static_assert(offsetof(FanyImeStatsEvent, digit) == 12); +static_assert(offsetof(FanyImeStatsEvent, punct) == 14); +static_assert(offsetof(FanyImeStatsEvent, other) == 16); namespace FanyImeReplyType { diff --git a/installer/default_config/config.default.toml b/installer/default_config/config.default.toml index 8ee92c70..9e3d103e 100644 --- a/installer/default_config/config.default.toml +++ b/installer/default_config/config.default.toml @@ -415,3 +415,19 @@ prompt = """你是一个中文全拼输入法联想引擎。输入为已经切 prompt_custom_1 = "" prompt_custom_2 = "" prompt_custom_3 = "" + +# +# Input Statistics +# 输入统计 +# +[statistics] +# Local only, off by default: character counts and active typing time are +# recorded in a local database, no text and no network. +# 仅本机记录、默认关闭;只记字数与活跃时长,不记录文本、不联网。 +enabled = false +# Retention policy for the local statistics database: forever | 30d | 90d | +# 180d | 365d. The Server deletes records older than the window on the first +# write of each day; "forever" keeps everything and never deletes anything. +# 统计保留策略:forever | 30d | 90d | 180d | 365d;Server 每天首次写入时删除 +# 超出保留窗口的记录,forever 表示永久保留、不自动删除。非法值按 forever 处理。 +retention = "forever" diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index cbe8de89..32bcc290 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -199,6 +199,15 @@ set(SRC_FILES "./src/session/input_session.h" "./src/session/session_factory.cpp" "./src/session/session_factory.h" + "./src/statistics/stats_aggregate.cpp" + "./src/statistics/stats_aggregate.h" + "./src/statistics/stats_frames.cpp" + "./src/statistics/stats_frames.h" + "./src/statistics/stats_pipe.cpp" + "./src/statistics/stats_pipe.h" + "./src/statistics/stats_store.cpp" + "./src/statistics/stats_store.h" + "./src/statistics/stats_types.h" "./src/utils/candidate_size_estimator.cpp" "./src/utils/candidate_size_estimator.h" "./src/utils/common_utils.cpp" @@ -392,6 +401,10 @@ add_executable(${SETTINGS_EXECUTABLE_NAME} WIN32 "./src/settings/dictionary_manager.h" "./src/settings/dictionary_validation.cpp" "./src/settings/dictionary_validation.h" + "./src/statistics/stats_overview.cpp" + "./src/statistics/stats_overview.h" + "./src/statistics/stats_store.cpp" + "./src/statistics/stats_store.h" "./src/config/ime_config.cpp" "./src/config/ime_config.h" "./src/cloud/custom_translation.cpp" diff --git a/server/assets/config/config.toml b/server/assets/config/config.toml index 17a9ac94..547cf296 100644 --- a/server/assets/config/config.toml +++ b/server/assets/config/config.toml @@ -413,3 +413,19 @@ prompt = """你是一个中文全拼输入法联想引擎。输入为已经切 prompt_custom_1 = "" prompt_custom_2 = "" prompt_custom_3 = "" + +# +# Input Statistics +# 输入统计 +# +[statistics] +# Local only, off by default: character counts and active typing time are +# recorded in a local database, no text and no network. +# 仅本机记录、默认关闭;只记字数与活跃时长,不记录文本、不联网。 +enabled = false +# Retention policy for the local statistics database: forever | 30d | 90d | +# 180d | 365d. The Server deletes records older than the window on the first +# write of each day; "forever" keeps everything and never deletes anything. +# 统计保留策略:forever | 30d | 90d | 180d | 365d;Server 每天首次写入时删除 +# 超出保留窗口的记录,forever 表示永久保留、不自动删除。非法值按 forever 处理。 +retention = "forever" diff --git a/server/src/config/ime_config.cpp b/server/src/config/ime_config.cpp index 2ce27740..3b08b4da 100644 --- a/server/src/config/ime_config.cpp +++ b/server/src/config/ime_config.cpp @@ -27,6 +27,7 @@ #include "defines/defines.h" #include "ipc/ipc.h" #include "engine/common/helpcode_utils.h" +#include "statistics/stats_store.h" #include "voice-input/voice_providers.h" namespace @@ -64,6 +65,11 @@ int g_candidate_font_size = 16; int g_candidate_window_preedit_font_size = 16; std::atomic_bool g_diagnostic_log_enabled{false}; std::atomic_bool g_tsf_diagnostic_log_enabled{false}; +// 本机输入统计,默认关闭。关闭是整个统计链路的门控:线程收到帧也只丢弃, +// 不会创建 stats.db,这是隐私契约里可观察的那一半。 +std::atomic_bool g_statistics_enabled{false}; +// 统计保留策略(forever|30d|90d|180d|365d),默认永久保留。 +std::string g_statistics_retention = "forever"; std::string g_candidate_text_color = "auto"; std::string g_shuangpin_schema = "xiaohe"; std::string g_wubi_schema = "wubi86"; @@ -1016,6 +1022,13 @@ bool LoadImeConfig() std::memory_order_relaxed); g_tsf_diagnostic_log_enabled.store(tbl["general"]["tsf_diagnostic_log"].value_or(false), std::memory_order_relaxed); + g_statistics_enabled.store(tbl["statistics"]["enabled"].value_or(false), std::memory_order_relaxed); + { + // 读侧对缺键与非法值一律回落 forever:一个手写坏值不能让自动清理误删数据。 + const std::string retention = tbl["statistics"]["retention"].value_or(std::string("forever")); + MsimeStats::Retention parsed = MsimeStats::Retention::Forever; + g_statistics_retention = MsimeStats::ParseRetention(retention, parsed) ? retention : "forever"; + } g_floating_toolbar_items.fullwidth = tbl["general"]["floating_toolbar_fullwidth"].value_or(true); g_floating_toolbar_items.punctuation = tbl["general"]["floating_toolbar_punctuation"].value_or(true); g_floating_toolbar_items.character_set = tbl["general"]["floating_toolbar_character_set"].value_or(true); @@ -1808,6 +1821,36 @@ bool SetConfiguredTsfDiagnosticLogEnabled(bool enabled) return true; } +bool GetConfiguredStatisticsEnabled() +{ + return g_statistics_enabled.load(std::memory_order_relaxed); +} + +bool SetConfiguredStatisticsEnabled(bool enabled) +{ + if (!WriteConfiguredValue("statistics", "enabled", enabled ? "true" : "false")) + return false; + g_statistics_enabled.store(enabled, std::memory_order_relaxed); + return true; +} + +const std::string &GetConfiguredStatisticsRetention() +{ + return g_statistics_retention; +} + +bool SetConfiguredStatisticsRetention(const std::string &retention) +{ + // 先校验后落盘:非法枚举不写文件也不动内存值,设置页的请求会被静默拒绝。 + MsimeStats::Retention parsed = MsimeStats::Retention::Forever; + if (!MsimeStats::ParseRetention(retention, parsed)) + return false; + if (!WriteConfiguredValue("statistics", "retention", EscapeTomlBasicString(retention))) + return false; + g_statistics_retention = retention; + return true; +} + namespace { bool IsValidCandidateTextColor(const std::string &color) diff --git a/server/src/config/ime_config.h b/server/src/config/ime_config.h index a98fce3c..b9b5bdbd 100644 --- a/server/src/config/ime_config.h +++ b/server/src/config/ime_config.h @@ -164,6 +164,13 @@ bool GetConfiguredDiagnosticLogEnabled(); bool SetConfiguredDiagnosticLogEnabled(bool enabled); bool GetConfiguredTsfDiagnosticLogEnabled(); bool SetConfiguredTsfDiagnosticLogEnabled(bool enabled); +// 本机输入统计总开关,默认关闭:关闭时 Server 不创建也不写 stats.db。 +bool GetConfiguredStatisticsEnabled(); +bool SetConfiguredStatisticsEnabled(bool enabled); +// 统计保留策略:forever|30d|90d|180d|365d,默认 forever。缺键或非法值一律回落 +// forever——坏配置绝不能导致自动清理误删数据;setter 校验枚举才落盘。 +const std::string &GetConfiguredStatisticsRetention(); +bool SetConfiguredStatisticsRetention(const std::string &retention); const std::string &GetConfiguredCandidateTextColor(); bool SetConfiguredCandidateTextColor(const std::string &color); // Installed font family names for settings dropdowns (cached after first call). diff --git a/server/src/ipc/ipc.cpp b/server/src/ipc/ipc.cpp index 75fbd9f7..c44c125a 100644 --- a/server/src/ipc/ipc.cpp +++ b/server/src/ipc/ipc.cpp @@ -23,6 +23,7 @@ #include "ipc/outbound_session_state.h" #include "ipc/pipe_write_policy.h" #include "ipc/terminal_deactivation_policy.h" +#include "statistics/stats_pipe.h" #include "utils/common_utils.h" #include "fmt/xchar.h" @@ -430,6 +431,14 @@ HANDLE CreateTsfDiagnosticNamedPipeInstance() static_cast(FANY_IME_TSF_DIAGNOSTIC_MAX_FRAME_BYTES)); } +HANDLE CreateStatsNamedPipeInstance() +{ + // The name carries the session id, so fast user switching and RDP sessions + // get separate listeners instead of racing for one machine-global name. + const std::wstring pipe_name = MsimeStats::BuildStatsPipeName(MsimeStats::CurrentStatsSessionId()); + return CreateNamedPipeInstance(pipe_name.c_str(), 128, static_cast(FANY_IME_STATS_MAX_FRAME_BYTES)); +} + HANDLE CreateVoiceControlNamedPipeInstance() { return CreateNamedPipeInstance(FANY_IME_VOICE_CONTROL_NAMED_PIPE, 512, 512); diff --git a/server/src/ipc/ipc.h b/server/src/ipc/ipc.h index d45c69c3..bb4caa98 100644 --- a/server/src/ipc/ipc.h +++ b/server/src/ipc/ipc.h @@ -96,6 +96,8 @@ int CloseNamedPipe(); HANDLE CreateMainNamedPipeInstance(); HANDLE CreateAuxNamedPipeInstance(); HANDLE CreateTsfDiagnosticNamedPipeInstance(); +// Session-suffixed statistics channel; the TSF DLL is the only client. +HANDLE CreateStatsNamedPipeInstance(); HANDLE CreateVoiceControlNamedPipeInstance(); HANDLE CreateToTsfNamedPipeInstance(); HANDLE CreateToTsfWorkerThreadNamedPipeInstance(); diff --git a/server/src/main.cpp b/server/src/main.cpp index a4278bd4..4cb7a344 100644 --- a/server/src/main.cpp +++ b/server/src/main.cpp @@ -21,6 +21,7 @@ #include "utils/common_utils.h" #include "utils/single_instance.h" #include "session/session_factory.h" +#include "statistics/stats_pipe.h" #include "webview2/windows_webview2.h" #include "voice-input/voice_input_service.h" @@ -157,6 +158,8 @@ int CALLBACK WinMain(_In_ HINSTANCE hInstance, _In_ HINSTANCE /*hPrevInstance*/, std::thread aux_pipe_listener(FanyNamedPipe::AuxPipeEventListenerLoopThread); /* Short-lived TSF diagnostic batches; isolated from all control pipes. */ std::thread tsf_diagnostic_pipe_listener(FanyNamedPipe::TsfDiagnosticPipeEventListenerLoopThread); + /* Input statistics frames: one-shot per flush, validated before aggregation. */ + std::thread stats_pipe_listener(MsimeStats::StatsPipeEventListenerLoopThread); if (pipe_probe) { @@ -217,6 +220,8 @@ int CALLBACK WinMain(_In_ HINSTANCE hInstance, _In_ HINSTANCE /*hPrevInstance*/, to_tsf_worker_thread_pipe_listener.join(); aux_pipe_listener.join(); tsf_diagnostic_pipe_listener.join(); + stats_pipe_listener.join(); + MsimeStats::ShutdownStatsPipe(); ::CloseIpc(); CoUninitialize(); diff --git a/server/src/settings/settings_app.cpp b/server/src/settings/settings_app.cpp index ccb45321..514b0cf6 100644 --- a/server/src/settings/settings_app.cpp +++ b/server/src/settings/settings_app.cpp @@ -7,6 +7,8 @@ #include "settings/settings_splash.h" #include "settings/dictionary_manager.h" #include "settings/serial_task_queue.h" +#include "statistics/stats_overview.h" +#include "statistics/stats_store.h" #include #include #include "skin/candidate_skin_catalog.h" @@ -494,7 +496,9 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog) {"show_qp_helpcode_in_candidate_window", GetConfiguredShowQuanpinHelpcodeInCandidateWindow()}}}, {"quanpin", {{"autocorrect_transposition", GetConfiguredQuanpinAutocorrectTransposition()}, - {"autocorrect_neighbor", GetConfiguredQuanpinAutocorrectNeighbor()}}}}}}; + {"autocorrect_neighbor", GetConfiguredQuanpinAutocorrectNeighbor()}}}, + {"statistics", + {{"enabled", GetConfiguredStatisticsEnabled()}, {"retention", GetConfiguredStatisticsRetention()}}}}}}; payload["data"]["voice_input"]["polish_presets"] = std::move(polish_presets); payload["protocolVersion"] = metasequoia::webview::Version; const std::string serialized = payload.dump(); @@ -649,6 +653,10 @@ bool ApplyConfigUpdate(const json::object &data) return SetConfiguredDiagnosticLogEnabled(json::value_to(data.at("value"))); if (path == "general.tsf_diagnostic_log") return SetConfiguredTsfDiagnosticLogEnabled(json::value_to(data.at("value"))); + if (path == "statistics.enabled") + return SetConfiguredStatisticsEnabled(json::value_to(data.at("value"))); + if (path == "statistics.retention") + return SetConfiguredStatisticsRetention(json::value_to(data.at("value"))); if (path == "general.floating_toolbar_scale") { const json::value &value = data.at("value"); @@ -845,6 +853,170 @@ bool CopyTextToClipboard(HWND hwnd, const std::wstring &text) return true; } +std::filesystem::path StatisticsDatabasePath() +{ + return std::filesystem::path(CommonUtils::get_ime_data_path_w()) / L"stats.db"; +} + +// The local calendar day "today" is bucketed into. GetLocalTime resolves the +// current timezone and DST, matching the Server-side aggregator's resolver. +int LocalTodayDayKey() +{ + SYSTEMTIME now{}; + GetLocalTime(&now); + return MsimeStats::DayKeyOf(now.wYear, now.wMonth, now.wDay); +} + +json::object OverviewToJson(const MsimeStats::Overview &overview) +{ + json::object categories; + categories["cjk"] = overview.categories.cjk; + categories["latin"] = overview.categories.latin; + categories["digit"] = overview.categories.digit; + categories["punct"] = overview.categories.punct; + categories["other"] = overview.categories.other; + + json::array hourly; + hourly.reserve(overview.today_hourly.size()); + for (const int64_t chars : overview.today_hourly) + hourly.push_back(json::value(chars)); + + json::array daily; + daily.reserve(overview.daily.size()); + for (const MsimeStats::DailyRow &row : overview.daily) + { + json::object entry; + entry["dayKey"] = row.day_key; + entry["cjk"] = row.cjk; + entry["latin"] = row.latin; + entry["digit"] = row.digit; + entry["punct"] = row.punct; + entry["other"] = row.other; + entry["activeMs"] = row.active_ms; + daily.push_back(std::move(entry)); + } + + json::object result; + result["hasData"] = overview.has_data; + result["firstDayKey"] = overview.first_day_key; + result["lastDayKey"] = overview.last_day_key; + result["days"] = overview.days; + result["totalChars"] = overview.total_chars; + result["totalActiveMs"] = overview.total_active_ms; + result["todayDayKey"] = overview.today_day_key; + result["todayChars"] = overview.today_chars; + result["todayActiveMs"] = overview.today_active_ms; + result["averagePerDay"] = overview.average_per_day; + result["currentStreak"] = overview.current_streak; + result["longestStreak"] = overview.longest_streak; + result["bestDayKey"] = overview.best_day_key; + result["bestDayChars"] = overview.best_day_chars; + result["todaySpeed"] = overview.today_speed; + result["averageSpeed"] = overview.average_speed; + result["fastestSpeed"] = overview.fastest_speed; + result["fastestDayKey"] = overview.fastest_day_key; + result["categories"] = std::move(categories); + result["todayHourly"] = std::move(hourly); + result["daily"] = std::move(daily); + return result; +} + +// Handles one statistics request on the worker thread. Each request opens the +// store, uses it and closes it: the settings process can exit at any time, the +// store carries no cross-request state, and all requests are serialised by the +// single serial task queue, so no in-process lock is needed. WAL plus the +// store's busy timeout arbitrate with the Server process that writes the same +// file. +json::object HandleStatsRequest(const json::object &data, HWND hwnd) +{ + const std::string request_id = json::value_to(data.at("requestId")); + const std::string action = json::value_to(data.at("action")); + json::object response; + response["requestId"] = request_id; + response["action"] = action; + response["ok"] = true; + response["message"] = "成功"; + + if (action == "openDirectory") + { + const std::filesystem::path directory = StatisticsDatabasePath().parent_path(); + std::error_code error_code; + std::filesystem::create_directories(directory, error_code); + ShellExecuteW(hwnd, L"open", directory.c_str(), nullptr, nullptr, SW_SHOWNORMAL); + response["message"] = "已打开数据目录"; + return response; + } + if (action != "overview" && action != "clearAll") + { + response["ok"] = false; + response["message"] = "未知的统计操作。"; + return response; + } + + // The missing-file check is the observable half of "recording is off by + // default": Store::Open always opens with SQLITE_OPEN_CREATE, so merely + // browsing statistics must not create stats.db. + std::error_code exists_error; + const std::filesystem::path database_path = StatisticsDatabasePath(); + const bool database_exists = std::filesystem::exists(database_path, exists_error) && !exists_error; + std::unique_ptr store; + if (database_exists) + { + std::string error; + store = MsimeStats::Store::Open(database_path, error); + if (!store) + { + response["ok"] = false; + response["message"] = "无法打开统计数据库:" + error; + return response; + } + } + + const int today = LocalTodayDayKey(); + if (action == "clearAll") + { + int64_t removed_days = 0; + if (store) + { + std::string error; + if (!store->ClearAll(removed_days, error)) + { + response["ok"] = false; + response["message"] = "清空统计数据失败:" + error; + return response; + } + } + response["removedDays"] = removed_days; + response["message"] = + removed_days > 0 ? "已清空 " + std::to_string(removed_days) + " 天数据" : "没有需要清空的数据"; + } + + MsimeStats::Overview overview; + if (store) + { + std::vector daily; + std::vector hourly; + std::string error; + if (!store->DailyRows(daily, error) || !store->HourlyRows(today, hourly, error)) + { + response["ok"] = false; + response["message"] = "读取统计数据失败:" + error; + return response; + } + overview = MsimeStats::ComputeOverview(daily, hourly, today); + } + else + { + // Derive the empty overview through the same path so the JSON shape is + // identical (24 zeroed hour buckets, zeroed categories). + overview = MsimeStats::ComputeOverview({}, {}, today); + } + response["overview"] = OverviewToJson(overview); + if (action == "overview") + response["message"] = "已加载统计数据"; + return response; +} + void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args) { if (g_closing || !g_worker) @@ -1032,6 +1204,33 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args) }; }); } + else if (type == "statsRequest") + { + const auto data = value.at("data").as_object(); + g_worker->Submit([data, hwnd]() -> SerialTaskQueue::Completion { + json::object response; + try + { + response = HandleStatsRequest(data, hwnd); + } + catch (...) + { + response["ok"] = false; + response["message"] = "统计操作失败,请重试"; + } + response["requestId"] = data.at("requestId"); + response["action"] = data.at("action"); + response["type"] = "statsResponse"; + response["protocolVersion"] = metasequoia::webview::Version; + if (!metasequoia::webview::Validate(response, "server")) + throw std::runtime_error("Invalid statistics 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 &) { diff --git a/server/src/statistics/stats_aggregate.cpp b/server/src/statistics/stats_aggregate.cpp new file mode 100644 index 00000000..c56ea04b --- /dev/null +++ b/server/src/statistics/stats_aggregate.cpp @@ -0,0 +1,153 @@ +#include "statistics/stats_aggregate.h" + +#include "statistics/stats_frames.h" + +#include +#include + +#ifdef _WIN32 +#include +#endif + +namespace MsimeStats +{ +namespace +{ +bool IsUnknownTime(TimePoint time) +{ + return time == TimePoint{}; +} +} // namespace + +LocalTimeParts DefaultLocalTimeResolver(TimePoint utc) +{ +#ifdef _WIN32 + const uint64_t raw = TimeToFiletime(utc); + FILETIME filetime{}; + filetime.dwLowDateTime = static_cast(raw & 0xFFFFFFFFULL); + filetime.dwHighDateTime = static_cast(raw >> 32); + + SYSTEMTIME utc_system{}; + if (!FileTimeToSystemTime(&filetime, &utc_system)) + { + return UtcPartsOf(utc); + } + SYSTEMTIME local_system{}; + const SYSTEMTIME *resolved = &local_system; + if (!SystemTimeToTzSpecificLocalTime(nullptr, &utc_system, &local_system)) + { + resolved = &utc_system; + } + LocalTimeParts parts; + parts.year = resolved->wYear; + parts.month = resolved->wMonth; + parts.day = resolved->wDay; + parts.hour = resolved->wHour; + return parts; +#else + // Non-Windows builds only exist for the portable unit tests, which always + // inject their own resolver; UTC is the most honest fallback. + return UtcPartsOf(utc); +#endif +} + +int64_t ActiveDeltaMs(TimePoint prev, TimePoint cur) +{ + if (IsUnknownTime(prev) || IsUnknownTime(cur)) + { + return 0; + } + const auto gap = cur - prev; + if (gap <= std::chrono::milliseconds::zero() || gap > std::chrono::milliseconds(kActiveGapLimitMs)) + { + return 0; + } + return std::chrono::duration_cast(gap).count(); +} + +Accumulator::Accumulator() : local_time_(DefaultLocalTimeResolver) +{ +} + +Accumulator::Accumulator(LocalTimeResolver resolver) : local_time_(std::move(resolver)) +{ +} + +Batch Accumulator::Add(const std::vector &events) +{ + std::map days; + std::map, HourDelta> hours; + + for (const FanyImeStatsEvent &event : events) + { + TimePoint timestamp = FiletimeToTime(event.timestamp_utc_ft); + if (IsUnknownTime(timestamp)) + { + // A zero FILETIME cannot come from GetSystemTimeAsFileTime, so it + // means a broken frame. Keep the characters (attributed to the + // previous event's day, or to today when there is none) but never + // treat the event as active time. + timestamp = has_last_ ? last_ : std::chrono::system_clock::now(); + } + + int64_t active_ms = 0; + if (has_last_ && timestamp > last_) + { + active_ms = ActiveDeltaMs(last_, timestamp); + last_ = timestamp; + } + else if (!has_last_) + { + last_ = timestamp; + has_last_ = true; + } + + const LocalTimeParts local = local_time_(timestamp); + const int day_key = DayKeyOf(local); + DayDelta &day = days[day_key]; + day.day_key = day_key; + day.cjk += event.cjk; + day.latin += event.latin; + day.digit += event.digit; + day.punct += event.punct; + day.other += event.other; + day.active_ms += active_ms; + + const std::pair hour_key(day_key, local.hour); + HourDelta &hour = hours[hour_key]; + hour.day_key = day_key; + hour.hour = local.hour; + hour.chars += EventTotalChars(event); + hour.active_ms += active_ms; + } + + Batch batch; + batch.days.reserve(days.size()); + batch.hours.reserve(hours.size()); + for (const auto &entry : days) + { + batch.days.push_back(entry.second); + } + for (const auto &entry : hours) + { + batch.hours.push_back(entry.second); + } + return batch; +} + +void Accumulator::Reset() +{ + last_ = TimePoint{}; + has_last_ = false; +} + +TimePoint Accumulator::LastEventTime() const +{ + return last_; +} + +bool Accumulator::HasLastEvent() const +{ + return has_last_; +} +} // namespace MsimeStats diff --git a/server/src/statistics/stats_aggregate.h b/server/src/statistics/stats_aggregate.h new file mode 100644 index 00000000..a5a495f4 --- /dev/null +++ b/server/src/statistics/stats_aggregate.h @@ -0,0 +1,61 @@ +#pragma once + +// Event-stream aggregation for the input statistics feature: local-time +// bucketing and active-time accounting. A direct port of the standalone tool's +// internal/stats/stats.go; the state machine is deliberately tiny so its +// behaviour is pinned by unit tests. + +#include "engine/contracts/windows_ipc.h" +#include "statistics/stats_types.h" + +#include +#include + +namespace MsimeStats +{ +// LocalTimeResolver maps a UTC instant to local calendar fields. Production +// uses DefaultLocalTimeResolver (Win32, DST-aware); tests inject a fixed offset +// so the bucketing assertions do not depend on the machine's timezone. +using LocalTimeResolver = std::function; + +// DefaultLocalTimeResolver resolves through SystemTimeToTzSpecificLocalTime, +// which applies the current timezone including DST. When the conversion fails +// the UTC fields are returned instead of dropping the event. +LocalTimeParts DefaultLocalTimeResolver(TimePoint utc); + +// ActiveDeltaMs returns how much of the gap between two consecutive commits +// counts as active time: the gap itself when it is positive and at most +// kActiveGapLimitMs, otherwise zero. The unknown-time sentinel, zero gaps, +// out-of-order events and clock rollbacks all yield zero. +int64_t ActiveDeltaMs(TimePoint prev, TimePoint cur); + +// Accumulator turns a stream of raw events into per-day/per-hour deltas. +// +// The state is the timestamp of the last accepted event. The stream is global +// (not per source process): typing continuously across two applications counts +// as continuous activity, and events arriving out of order contribute no active +// time but do contribute characters. +class Accumulator +{ + public: + Accumulator(); + explicit Accumulator(LocalTimeResolver resolver); + + // Add folds one frame's events into per-day and per-hour deltas. The result + // is sorted by day key and by (day, hour). + Batch Add(const std::vector &events); + + // Reset clears the accumulator state (used when the collector restarts). + void Reset(); + + // LastEventTime exposes the accumulator state (tests and diagnostics); + // TimePoint{} when no event has been accepted yet. + TimePoint LastEventTime() const; + bool HasLastEvent() const; + + private: + LocalTimeResolver local_time_; + TimePoint last_{}; + bool has_last_ = false; +}; +} // namespace MsimeStats diff --git a/server/src/statistics/stats_frames.cpp b/server/src/statistics/stats_frames.cpp new file mode 100644 index 00000000..f6cf5486 --- /dev/null +++ b/server/src/statistics/stats_frames.cpp @@ -0,0 +1,168 @@ +#include "statistics/stats_frames.h" + +#include +#include + +namespace MsimeStats +{ +namespace +{ +constexpr size_t kHeaderSize = sizeof(FanyImeStatsBatchHeader); +constexpr size_t kEventSize = sizeof(FanyImeStatsEvent); + +// 1601-01-01 to 1970-01-01. +constexpr uint64_t kFiletimeTicksPerSecond = 10'000'000ULL; +constexpr uint64_t kFiletimeUnixEpochOffsetSeconds = 11'644'473'600ULL; +constexpr uint64_t kFiletimeUnixEpochTicks = kFiletimeUnixEpochOffsetSeconds * kFiletimeTicksPerSecond; + +// The wire format is little-endian; decode byte by byte instead of reinterpreting +// the contract struct so the byte order never depends on the host. +uint16_t ReadU16(const uint8_t *data) +{ + return static_cast(static_cast(data[0]) | (static_cast(data[1]) << 8)); +} + +uint32_t ReadU32(const uint8_t *data) +{ + return static_cast(data[0]) | (static_cast(data[1]) << 8) | + (static_cast(data[2]) << 16) | (static_cast(data[3]) << 24); +} + +uint64_t ReadU64(const uint8_t *data) +{ + return static_cast(ReadU32(data)) | (static_cast(ReadU32(data + 4)) << 32); +} +} // namespace + +const char *FrameErrorToString(FrameError error) +{ + switch (error) + { + case FrameError::None: + return "ok"; + case FrameError::ShortHeader: + return "frame shorter than its header"; + case FrameError::BadMagic: + return "bad magic"; + case FrameError::BadVersion: + return "unsupported version"; + case FrameError::BadHeaderSize: + return "unexpected header size"; + case FrameError::PayloadTooLarge: + return "payload exceeds max frame size"; + case FrameError::BadPayloadSize: + return "payload size is not a multiple of the event size"; + case FrameError::EventCountMismatch: + return "event count does not match payload size"; + case FrameError::TrailingBytes: + return "frame size does not match payload size"; + case FrameError::SourceProcessMismatch: + return "source process id does not match the pipe client"; + } + return "unknown frame error"; +} + +FrameError ValidateFrameHeader(const FanyImeStatsBatchHeader &header) +{ + if (header.magic != FANY_IME_STATS_MAGIC) + { + return FrameError::BadMagic; + } + if (header.version != FANY_IME_STATS_VERSION) + { + return FrameError::BadVersion; + } + if (header.header_size != kHeaderSize) + { + return FrameError::BadHeaderSize; + } + if (header.payload_bytes > FANY_IME_STATS_MAX_FRAME_BYTES - kHeaderSize) + { + return FrameError::PayloadTooLarge; + } + if (header.payload_bytes % kEventSize != 0) + { + return FrameError::BadPayloadSize; + } + if (header.event_count != header.payload_bytes / kEventSize) + { + return FrameError::EventCountMismatch; + } + return FrameError::None; +} + +FrameError ValidateFrameSource(const FanyImeStatsBatchHeader &header, uint32_t client_process_id) +{ + return header.source_process_id == client_process_id ? FrameError::None : FrameError::SourceProcessMismatch; +} + +FrameError DecodeFrame(const uint8_t *data, size_t size, FanyImeStatsBatchHeader &header, + std::vector &events) +{ + events.clear(); + if (data == nullptr || size < kHeaderSize) + { + return FrameError::ShortHeader; + } + + header.magic = ReadU32(data + offsetof(FanyImeStatsBatchHeader, magic)); + header.version = ReadU32(data + offsetof(FanyImeStatsBatchHeader, version)); + header.header_size = ReadU32(data + offsetof(FanyImeStatsBatchHeader, header_size)); + header.payload_bytes = ReadU32(data + offsetof(FanyImeStatsBatchHeader, payload_bytes)); + header.event_count = ReadU32(data + offsetof(FanyImeStatsBatchHeader, event_count)); + header.dropped_count = ReadU32(data + offsetof(FanyImeStatsBatchHeader, dropped_count)); + header.source_process_id = ReadU32(data + offsetof(FanyImeStatsBatchHeader, source_process_id)); + + const FrameError validation = ValidateFrameHeader(header); + if (validation != FrameError::None) + { + return validation; + } + if (size != kHeaderSize + static_cast(header.payload_bytes)) + { + return FrameError::TrailingBytes; + } + + events.reserve(header.event_count); + for (uint32_t index = 0; index < header.event_count; ++index) + { + const uint8_t *raw = data + kHeaderSize + static_cast(index) * kEventSize; + FanyImeStatsEvent event; + event.timestamp_utc_ft = ReadU64(raw + offsetof(FanyImeStatsEvent, timestamp_utc_ft)); + event.cjk = ReadU16(raw + offsetof(FanyImeStatsEvent, cjk)); + event.latin = ReadU16(raw + offsetof(FanyImeStatsEvent, latin)); + event.digit = ReadU16(raw + offsetof(FanyImeStatsEvent, digit)); + event.punct = ReadU16(raw + offsetof(FanyImeStatsEvent, punct)); + event.other = ReadU16(raw + offsetof(FanyImeStatsEvent, other)); + events.push_back(event); + } + return FrameError::None; +} + +TimePoint FiletimeToTime(uint64_t filetime_utc_ft) +{ + if (filetime_utc_ft < kFiletimeUnixEpochTicks) + { + return TimePoint{}; + } + const uint64_t seconds = filetime_utc_ft / kFiletimeTicksPerSecond - kFiletimeUnixEpochOffsetSeconds; + const uint64_t remainder_ticks = filetime_utc_ft % kFiletimeTicksPerSecond; + const auto since_epoch = std::chrono::duration_cast( + std::chrono::seconds(static_cast(seconds)) + + std::chrono::nanoseconds(static_cast(remainder_ticks) * 100)); + return TimePoint(since_epoch); +} + +uint64_t TimeToFiletime(TimePoint time) +{ + using HundredNanoseconds = std::chrono::duration>; + const int64_t ticks = std::chrono::duration_cast(time.time_since_epoch()).count(); + return static_cast(ticks + static_cast(kFiletimeUnixEpochTicks)); +} + +int EventTotalChars(const FanyImeStatsEvent &event) +{ + return static_cast(event.cjk) + static_cast(event.latin) + static_cast(event.digit) + + static_cast(event.punct) + static_cast(event.other); +} +} // namespace MsimeStats diff --git a/server/src/statistics/stats_frames.h b/server/src/statistics/stats_frames.h new file mode 100644 index 00000000..21ab2cf8 --- /dev/null +++ b/server/src/statistics/stats_frames.h @@ -0,0 +1,66 @@ +#pragma once + +// Frame decoding and validation for the input statistics pipe. +// +// The wire contract lives in engine/contracts/windows_ipc.h and the contract +// structs are used directly (no mirrored copy). A frame that does not match the +// contract exactly is rejected as a whole: the reader never tries to interpret +// an unexpected layout, which is what keeps a version skew from producing +// garbage counts. The reason codes mirror the standalone tool's sentinel errors +// (internal/frames/frames.go) so the branch lists stay comparable. + +#include "engine/contracts/windows_ipc.h" +#include "statistics/stats_types.h" + +#include +#include +#include + +namespace MsimeStats +{ +enum class FrameError +{ + None = 0, + ShortHeader, + BadMagic, + BadVersion, + BadHeaderSize, + PayloadTooLarge, + BadPayloadSize, + EventCountMismatch, + TrailingBytes, + SourceProcessMismatch, +}; + +const char *FrameErrorToString(FrameError error); + +// ValidateFrameHeader checks every structural invariant of the contract +// header. The payload-size checks also bound what DecodeFrame will accept. +FrameError ValidateFrameHeader(const FanyImeStatsBatchHeader &header); + +// ValidateFrameSource compares the header's claimed source process with the pid +// the pipe reports for its client. The decoder cannot know the pid, so the +// listener performs this check separately before accepting a decoded frame. +FrameError ValidateFrameSource(const FanyImeStatsBatchHeader &header, uint32_t client_process_id); + +// DecodeFrame validates and decodes one complete in-memory frame. `size` must +// be exactly sizeof(header) + header.payload_bytes: the one-shot pipe read +// guarantees the whole frame was written before decoding starts, so any other +// size means the frame is malformed. On failure the `header` out-parameter +// keeps whatever was decoded and `events` is left empty. +FrameError DecodeFrame(const uint8_t *data, size_t size, FanyImeStatsBatchHeader &header, + std::vector &events); + +// FiletimeToTime converts a raw FILETIME value (100ns ticks since 1601-01-01 +// UTC) to a UTC time point. Zero and any value before the Unix epoch yield the +// TimePoint{} sentinel, which downstream code treats as "unknown" and never +// counts as active time. +TimePoint FiletimeToTime(uint64_t filetime_utc_ft); + +// TimeToFiletime is the inverse of FiletimeToTime. Sub-100ns precision is +// truncated, which is exact for the system clock the DLL samples. +uint64_t TimeToFiletime(TimePoint time); + +// EventTotalChars returns the number of user-perceived characters in an event. +int EventTotalChars(const FanyImeStatsEvent &event); +} // namespace MsimeStats diff --git a/server/src/statistics/stats_overview.cpp b/server/src/statistics/stats_overview.cpp new file mode 100644 index 00000000..7084f7d2 --- /dev/null +++ b/server/src/statistics/stats_overview.cpp @@ -0,0 +1,185 @@ +#include "statistics/stats_overview.h" + +#include +#include +#include + +namespace MsimeStats +{ +namespace +{ +CategoryCounts SumCategories(const std::vector &rows) +{ + CategoryCounts counts; + for (const DailyRow &row : rows) + { + counts.cjk += row.cjk; + counts.latin += row.latin; + counts.digit += row.digit; + counts.punct += row.punct; + counts.other += row.other; + } + return counts; +} + +// BestDay returns the day with the most characters; the earliest wins a tie. +// It is a production metric and counts every category (Total), unlike the +// speed metric which counts readable characters only. +std::pair BestDay(const std::vector &rows) +{ + int day_key = 0; + int64_t chars = 0; + for (const DailyRow &row : rows) + { + if (row.Total() > chars) + { + day_key = row.day_key; + chars = row.Total(); + } + } + return {day_key, chars}; +} + +// FastestDay returns the highest daily speed over days with enough active time +// to be meaningful. Speed counts readable characters only (DailyRow::Speed). +std::pair FastestDay(const std::vector &rows) +{ + int day_key = 0; + double speed = 0.0; + for (const DailyRow &row : rows) + { + if (row.active_ms < kFastestSpeedMinActiveMs) + { + continue; + } + const double candidate = row.Speed(); + if (candidate > speed) + { + day_key = row.day_key; + speed = candidate; + } + } + return {day_key, speed}; +} +} // namespace + +Overview ComputeOverview(const std::vector &rows, const std::vector &today_hourly, + int today_day_key) +{ + std::vector sorted = rows; + std::sort(sorted.begin(), sorted.end(), + [](const DailyRow &left, const DailyRow &right) { return left.day_key < right.day_key; }); + + Overview overview; + overview.today_day_key = today_day_key; + overview.today_hourly.assign(24, 0); + overview.daily = sorted; + overview.categories = SumCategories(sorted); + overview.days = static_cast(sorted.size()); + int64_t total_speed_chars = 0; + int64_t today_speed_chars = 0; + for (const DailyRow &row : sorted) + { + overview.total_chars += row.Total(); + overview.total_active_ms += row.active_ms; + total_speed_chars += row.SpeedChars(); + if (row.day_key == today_day_key) + { + overview.today_chars = row.Total(); + overview.today_active_ms = row.active_ms; + today_speed_chars = row.SpeedChars(); + } + } + for (const HourlyRow &bucket : today_hourly) + { + if (bucket.day_key == today_day_key && bucket.hour >= 0 && bucket.hour < 24) + { + overview.today_hourly[static_cast(bucket.hour)] += bucket.chars; + } + } + + overview.has_data = !sorted.empty(); + if (!overview.has_data) + { + return overview; + } + + overview.first_day_key = sorted.front().day_key; + overview.last_day_key = sorted.back().day_key; + overview.average_per_day = static_cast(overview.total_chars) / static_cast(sorted.size()); + overview.current_streak = CurrentStreak(sorted, today_day_key); + overview.longest_streak = LongestStreak(sorted); + const std::pair best = BestDay(sorted); + overview.best_day_key = best.first; + overview.best_day_chars = best.second; + overview.today_speed = CharsPerMinute(today_speed_chars, overview.today_active_ms); + overview.average_speed = CharsPerMinute(total_speed_chars, overview.total_active_ms); + const std::pair fastest = FastestDay(sorted); + overview.fastest_day_key = fastest.first; + overview.fastest_speed = fastest.second; + return overview; +} + +int CurrentStreak(const std::vector &rows, int today_day_key) +{ + if (rows.empty()) + { + return 0; + } + std::set recorded; + for (const DailyRow &row : rows) + { + recorded.insert(row.day_key); + } + int current = today_day_key; + if (recorded.find(today_day_key) == recorded.end()) + { + current = AddDays(today_day_key, -1); + } + int streak = 0; + while (recorded.find(current) != recorded.end()) + { + ++streak; + current = AddDays(current, -1); + } + return streak; +} + +int LongestStreak(const std::vector &rows) +{ + if (rows.empty()) + { + return 0; + } + std::vector keys; + keys.reserve(rows.size()); + for (const DailyRow &row : rows) + { + keys.push_back(row.day_key); + } + std::sort(keys.begin(), keys.end()); + + int longest = 1; + int run = 1; + for (size_t index = 1; index < keys.size(); ++index) + { + if (keys[index] == AddDays(keys[index - 1], 1)) + { + ++run; + } + else if (keys[index] == keys[index - 1]) + { + continue; + } + else + { + run = 1; + } + if (run > longest) + { + longest = run; + } + } + return longest; +} +} // namespace MsimeStats diff --git a/server/src/statistics/stats_overview.h b/server/src/statistics/stats_overview.h new file mode 100644 index 00000000..d9fb2aee --- /dev/null +++ b/server/src/statistics/stats_overview.h @@ -0,0 +1,30 @@ +#pragma once + +// Derived metrics for the statistics settings page: cards, streaks, speeds, +// category split and the today-by-hour distribution. A direct port of the +// standalone tool's internal/stats/overview.go; the frontend only formats what +// comes out of here. + +#include "statistics/stats_types.h" + +#include + +namespace MsimeStats +{ +// ComputeOverview derives every metric from the stored rows. +// +// `rows` must be the full stats_daily table; `today_hourly` may be empty and +// backs the today-by-hour chart (buckets of other days are ignored). +// `today_day_key` is the local calendar day of "now"; the caller resolves it +// because only the caller knows the process's timezone. +Overview ComputeOverview(const std::vector &rows, const std::vector &today_hourly, + int today_day_key); + +// CurrentStreak counts consecutive recorded days ending today, or ending +// yesterday when today has no record yet (the day is still in progress, so it +// must not reset the streak). +int CurrentStreak(const std::vector &rows, int today_day_key); + +// LongestStreak returns the longest run of consecutive recorded days. +int LongestStreak(const std::vector &rows); +} // namespace MsimeStats diff --git a/server/src/statistics/stats_pipe.cpp b/server/src/statistics/stats_pipe.cpp new file mode 100644 index 00000000..b6958ea2 --- /dev/null +++ b/server/src/statistics/stats_pipe.cpp @@ -0,0 +1,229 @@ +#include "statistics/stats_pipe.h" + +#include "config/ime_config.h" +#include "engine/contracts/windows_ipc.h" +#include "ipc/event_listener.h" +#include "ipc/ipc.h" +#include "log/candidate_diag_log.h" +#include "statistics/stats_aggregate.h" +#include "statistics/stats_frames.h" +#include "statistics/stats_store.h" +#include "utils/common_utils.h" + +#include +#include +#include +#include +#include + +namespace MsimeStats +{ +namespace +{ +// A client that connects and never writes must not hold the single listener +// instance forever. The DLL writes immediately after connecting, so a couple of +// seconds is generous; the TSF diagnostic listener uses the same bound. +constexpr auto kStatsPipeReadTimeout = std::chrono::seconds(2); + +// Opened lazily on the first valid frame received while recording is enabled. +// With the switch off no database file is ever created, which is the +// observable half of the privacy contract. +std::unique_ptr g_stats_store; + +// The stream is global across host processes: typing continuously in two +// applications counts as continuous activity, matching the standalone tool. +Accumulator g_stats_accumulator; + +bool WaitForStatsPipeClient(HANDLE pipe) +{ + const BOOL connected = ConnectNamedPipe(pipe, nullptr); + return connected || GetLastError() == ERROR_PIPE_CONNECTED; +} + +Store *EnsureStatsStoreOpen() +{ + if (g_stats_store) + { + return g_stats_store.get(); + } + const std::filesystem::path path = std::filesystem::path(CommonUtils::get_ime_data_path_w()) / L"stats.db"; + std::string error; + g_stats_store = Store::Open(path, error); + if (!g_stats_store) + { + // Deliberately no retry throttle: frames arrive at typing speed and a + // transient failure (profile locked during an upgrade, missing parent + // directory) should recover on the next frame. + // DiagnosticLog is the only channel that reaches disk in production: + // InitializeSpdLog() is a no-op here, and the diag switch gates both + // the formatting and the write. + DIAG_LOGF(L"stats: unable to open stats.db: {}", string_to_wstring(error)); + } + return g_stats_store.get(); +} + +// The config reader already falls back to forever for a missing or invalid +// value; this repeats the fallback so a hand-edited file can never arm a +// cleanup with an unintended policy. +Retention ConfiguredRetention() +{ + Retention retention = Retention::Forever; + if (!ParseRetention(GetConfiguredStatisticsRetention(), retention)) + { + retention = Retention::Forever; + } + return retention; +} + +// Runs at most once per local day, after a successful write. The once-per-day +// marker keeps the cost to one extra meta read per frame and zero deletes on +// every day but the first one; a failure only logs, leaving the marker stale +// so the next frame retries. +void RunRetentionAfterWrite(Store &store) +{ + SYSTEMTIME now{}; + GetLocalTime(&now); + RetentionOutcome outcome; + std::string error; + if (!RunDailyRetention(store, ConfiguredRetention(), DayKeyOf(now.wYear, now.wMonth, now.wDay), outcome, error)) + { + DIAG_LOGF(L"stats: retention cleanup failed: {}", string_to_wstring(error)); + } +} + +void ProcessStatsFrame(HANDLE pipe, const uint8_t *data, DWORD size) +{ + FanyImeStatsBatchHeader header{}; + std::vector events; + FrameError error = DecodeFrame(data, size, header, events); + ULONG client_process_id = 0; + if (error == FrameError::None && GetNamedPipeClientProcessId(pipe, &client_process_id)) + { + error = ValidateFrameSource(header, client_process_id); + } + else if (error == FrameError::None) + { + // Without the client pid the claimed source cannot be verified, so the + // strict path rejects the frame instead of trusting the header. + error = FrameError::SourceProcessMismatch; + } + if (error != FrameError::None) + { + DIAG_LOGF(L"stats: dropped frame: {}", string_to_wstring(FrameErrorToString(error))); + return; + } + + // The switch can be flipped from the settings page at any time. Reloading + // per frame matches the other pipe threads and bounds the delay to one + // frame; a discarded frame never reaches the database. + ReloadImeConfigIfChanged(); + if (!GetConfiguredStatisticsEnabled()) + { + return; + } + + if (header.dropped_count != 0) + { + // Loss happened inside the DLL and cannot be attributed to a calendar + // day, so it is only reported. There is no text in this message. + DIAG_LOGF(L"stats: frame from pid {} reports {} dropped events", header.source_process_id, + header.dropped_count); + } + + if (events.empty()) + { + return; + } + Store *store = EnsureStatsStoreOpen(); + if (store == nullptr) + { + return; + } + const Batch batch = g_stats_accumulator.Add(events); + if (batch.Empty()) + { + return; + } + std::string error_message; + if (!store->Upsert(batch, error_message)) + { + DIAG_LOGF(L"stats: failed to persist a batch: {}", string_to_wstring(error_message)); + return; + } + RunRetentionAfterWrite(*store); +} +} // namespace + +uint32_t CurrentStatsSessionId() +{ + DWORD session_id = 0; + if (!ProcessIdToSessionId(GetCurrentProcessId(), &session_id)) + { + return 0; + } + return static_cast(session_id); +} + +void StatsPipeEventListenerLoopThread() +{ + HANDLE listening_pipe = INVALID_HANDLE_VALUE; + while (pipe_running) + { + if (!listening_pipe || listening_pipe == INVALID_HANDLE_VALUE) + { + listening_pipe = CreateStatsNamedPipeInstance(); + if (!listening_pipe || listening_pipe == INVALID_HANDLE_VALUE) + { + Sleep(50); + continue; + } + } + + if (WaitForStatsPipeClient(listening_pipe) && pipe_running) + { + std::vector frame(FANY_IME_STATS_MAX_FRAME_BYTES); + DWORD bytes_read = 0; + BOOL read_result = FALSE; + DWORD pipe_mode = PIPE_READMODE_MESSAGE | PIPE_NOWAIT; + if (SetNamedPipeHandleState(listening_pipe, &pipe_mode, nullptr, nullptr)) + { + const auto deadline = std::chrono::steady_clock::now() + kStatsPipeReadTimeout; + while (pipe_running && std::chrono::steady_clock::now() < deadline) + { + read_result = + ReadFile(listening_pipe, frame.data(), static_cast(frame.size()), &bytes_read, nullptr); + if (read_result || GetLastError() != ERROR_NO_DATA) + { + break; + } + Sleep(1); + } + } + if (read_result && bytes_read != 0) + { + // A frame that fails decoding is dropped here; the frame is the + // only thing an exception could damage, so no state escapes. + ProcessStatsFrame(listening_pipe, frame.data(), bytes_read); + } + } + + if (listening_pipe && listening_pipe != INVALID_HANDLE_VALUE) + { + DisconnectNamedPipe(listening_pipe); + CloseHandle(listening_pipe); + listening_pipe = INVALID_HANDLE_VALUE; + } + } + + if (listening_pipe && listening_pipe != INVALID_HANDLE_VALUE) + { + DisconnectNamedPipe(listening_pipe); + CloseHandle(listening_pipe); + } +} + +void ShutdownStatsPipe() +{ + g_stats_store.reset(); +} +} // namespace MsimeStats diff --git a/server/src/statistics/stats_pipe.h b/server/src/statistics/stats_pipe.h new file mode 100644 index 00000000..0c147631 --- /dev/null +++ b/server/src/statistics/stats_pipe.h @@ -0,0 +1,40 @@ +#pragma once + +// Session-scoped named pipe listener for input statistics frames. The wire +// contract lives in engine/contracts/windows_ipc.h; this header deliberately +// stays free of Windows types so the pipe-name helper can be asserted by a +// unit test without pulling in the listener implementation. + +#include "engine/contracts/windows_ipc.h" + +#include +#include + +namespace MsimeStats +{ +// BuildStatsPipeName appends the decimal session id to the contract prefix. +// Named pipes live in the machine-global namespace, so concurrent sessions +// (fast user switching, RDP) must not share a listener; the TSF DLL builds the +// identical name from ProcessIdToSessionId(GetCurrentProcessId()). +inline std::wstring BuildStatsPipeName(uint32_t session_id) +{ + return std::wstring(FANY_IME_STATS_PIPE_NAME_PREFIX) + std::to_wstring(session_id); +} + +// CurrentStatsSessionId returns the same session id the DLL computes. The zero +// returned when ProcessIdToSessionId fails cannot collide with a real session: +// session 0 is reserved for services. +uint32_t CurrentStatsSessionId(); + +// StatsPipeEventListenerLoopThread accepts one frame per connection, validates +// it against the contract, aggregates it and persists the result while the +// statistics switch is on. Frames that fail validation, and every frame while +// the switch is off, are dropped without touching the database. Runs until +// pipe_running turns false. +void StatsPipeEventListenerLoopThread(); + +// ShutdownStatsPipe releases the lazily opened database handle. Call after the +// listener thread has been joined; the store is only touched by that thread +// while it runs. +void ShutdownStatsPipe(); +} // namespace MsimeStats diff --git a/server/src/statistics/stats_store.cpp b/server/src/statistics/stats_store.cpp new file mode 100644 index 00000000..39dc5da5 --- /dev/null +++ b/server/src/statistics/stats_store.cpp @@ -0,0 +1,662 @@ +#include "statistics/stats_store.h" + +#include "engine/core/data_path.h" + +#include + +#include + +namespace MsimeStats +{ +namespace +{ +const char *kSchema = R"SQL( +CREATE TABLE IF NOT EXISTS stats_daily( + day_key INTEGER PRIMARY KEY, + cjk INTEGER NOT NULL DEFAULT 0, + latin INTEGER NOT NULL DEFAULT 0, + digit INTEGER NOT NULL DEFAULT 0, + punct INTEGER NOT NULL DEFAULT 0, + other INTEGER NOT NULL DEFAULT 0, + active_ms INTEGER NOT NULL DEFAULT 0); +CREATE TABLE IF NOT EXISTS stats_hourly( + day_key INTEGER NOT NULL, + hour INTEGER NOT NULL, + chars INTEGER NOT NULL DEFAULT 0, + active_ms INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(day_key, hour)); +CREATE TABLE IF NOT EXISTS stats_meta( + key TEXT PRIMARY KEY, + value TEXT NOT NULL); +)SQL"; + +const char *kUpsertDailySql = + "INSERT INTO stats_daily(day_key, cjk, latin, digit, punct, other, active_ms) " + "VALUES(?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(day_key) DO UPDATE SET " + "cjk = cjk + excluded.cjk, latin = latin + excluded.latin, digit = digit + excluded.digit, " + "punct = punct + excluded.punct, other = other + excluded.other, " + "active_ms = active_ms + excluded.active_ms"; + +const char *kUpsertHourlySql = "INSERT INTO stats_hourly(day_key, hour, chars, active_ms) " + "VALUES(?, ?, ?, ?) " + "ON CONFLICT(day_key, hour) DO UPDATE SET " + "chars = chars + excluded.chars, active_ms = active_ms + excluded.active_ms"; + +std::string SqliteMessage(sqlite3 *db) +{ + return db != nullptr ? std::string(sqlite3_errmsg(db)) : std::string("sqlite handle is null"); +} + +std::string Describe(const char *what, sqlite3 *db) +{ + return std::string("stats store: ") + what + ": " + SqliteMessage(db); +} +} // namespace + +bool ParseRetention(const std::string &value, Retention &out) +{ + if (value == "30d") + { + out = Retention::Days30; + } + else if (value == "90d") + { + out = Retention::Days90; + } + else if (value == "180d") + { + out = Retention::Days180; + } + else if (value == "365d") + { + out = Retention::Days365; + } + else if (value == "forever") + { + out = Retention::Forever; + } + else + { + return false; + } + return true; +} + +bool RetentionCutoff(Retention retention, int today_day_key, int &cutoff_day_key) +{ + const int days = static_cast(retention); + if (days <= 0) + { + return false; + } + cutoff_day_key = AddDays(today_day_key, -(days - 1)); + return true; +} + +Store::Store(sqlite3 *db, std::filesystem::path path) : db_(db), path_(std::move(path)) +{ +} + +Store::~Store() +{ + if (db_ != nullptr) + { + sqlite3_close(db_); + db_ = nullptr; + } +} + +std::unique_ptr Store::Open(const std::filesystem::path &path, std::string &error) +{ + // A UTF-8 path keeps SQLite on its own UTF-16 conversion; path::string() + // would go through the ANSI code page and corrupt non-ASCII profile paths. + const std::string utf8_path = metasequoia::path_to_utf8(path); + sqlite3 *raw = nullptr; + if (sqlite3_open_v2(utf8_path.c_str(), &raw, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, + nullptr) != SQLITE_OK) + { + error = raw != nullptr ? SqliteMessage(raw) : std::string("stats store: unable to open database"); + if (raw != nullptr) + { + sqlite3_close(raw); + } + return nullptr; + } + + std::unique_ptr store(new Store(raw, path)); + if (!store->Initialize(error)) + { + return nullptr; + } + return store; +} + +bool Store::Initialize(std::string &error) +{ + const char *pragmas[] = {"PRAGMA journal_mode=WAL", "PRAGMA synchronous=NORMAL", "PRAGMA busy_timeout=5000"}; + for (const char *pragma : pragmas) + { + if (!Exec(pragma, error)) + { + return false; + } + } + if (!Exec(kSchema, error)) + { + return false; + } + return SetMeta(kStoreSchemaVersion, kStoreSchemaVersion, error); +} + +bool Store::Exec(const char *sql, std::string &error) const +{ + char *message = nullptr; + if (sqlite3_exec(db_, sql, nullptr, nullptr, &message) != SQLITE_OK) + { + error = message != nullptr ? std::string(message) : SqliteMessage(db_); + sqlite3_free(message); + return false; + } + return true; +} + +bool Store::BeginTransaction(std::string &error) +{ + // BEGIN IMMEDIATE instead of a deferred transaction: the database is shared + // between two processes, and a deferred transaction that only takes the + // write lock at its first INSERT can fail with SQLITE_BUSY without the busy + // handler having been able to wait. + return Exec("BEGIN IMMEDIATE", error); +} + +void Store::Rollback() +{ + std::string ignored; + Exec("ROLLBACK", ignored); +} + +bool Store::Commit(std::string &error) +{ + if (!Exec("COMMIT", error)) + { + Rollback(); + return false; + } + return true; +} + +bool Store::RefreshFirstDay(std::string &error) +{ + sqlite3_stmt *statement = nullptr; + if (sqlite3_prepare_v2(db_, "SELECT MIN(day_key) FROM stats_daily", -1, &statement, nullptr) != SQLITE_OK) + { + error = Describe("read first_day", db_); + return false; + } + + int64_t minimum = 0; + bool present = false; + const int step = sqlite3_step(statement); + if (step == SQLITE_ROW) + { + if (sqlite3_column_type(statement, 0) != SQLITE_NULL) + { + minimum = sqlite3_column_int64(statement, 0); + present = true; + } + } + else if (step != SQLITE_DONE) + { + error = Describe("read first_day", db_); + sqlite3_finalize(statement); + return false; + } + sqlite3_finalize(statement); + + if (!present) + { + return Exec("DELETE FROM stats_meta WHERE key = 'first_day'", error); + } + return WriteMeta(kStoreMetaFirstDay, std::to_string(minimum), error); +} + +bool Store::WriteMeta(const std::string &key, const std::string &value, std::string &error) +{ + sqlite3_stmt *statement = nullptr; + if (sqlite3_prepare_v2(db_, + "INSERT INTO stats_meta(key, value) VALUES(?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + -1, &statement, nullptr) != SQLITE_OK) + { + error = Describe("write meta", db_); + return false; + } + sqlite3_bind_text(statement, 1, key.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(statement, 2, value.c_str(), -1, SQLITE_TRANSIENT); + const bool ok = sqlite3_step(statement) == SQLITE_DONE; + if (!ok) + { + error = Describe("write meta", db_); + } + sqlite3_finalize(statement); + return ok; +} + +bool Store::DeleteMetaRow(const std::string &key, std::string &error) +{ + sqlite3_stmt *statement = nullptr; + if (sqlite3_prepare_v2(db_, "DELETE FROM stats_meta WHERE key = ?", -1, &statement, nullptr) != SQLITE_OK) + { + error = Describe("delete meta", db_); + return false; + } + sqlite3_bind_text(statement, 1, key.c_str(), -1, SQLITE_TRANSIENT); + const bool ok = sqlite3_step(statement) == SQLITE_DONE; + if (!ok) + { + error = Describe("delete meta", db_); + } + sqlite3_finalize(statement); + return ok; +} + +bool Store::Upsert(const Batch &batch, std::string &error) +{ + if (batch.Empty()) + { + return true; + } + std::lock_guard lock(mutex_); + if (!BeginTransaction(error)) + { + return false; + } + + bool ok = true; + if (!batch.days.empty()) + { + sqlite3_stmt *statement = nullptr; + if (sqlite3_prepare_v2(db_, kUpsertDailySql, -1, &statement, nullptr) != SQLITE_OK) + { + error = Describe("prepare daily upsert", db_); + ok = false; + } + else + { + for (const DayDelta &day : batch.days) + { + sqlite3_bind_int64(statement, 1, day.day_key); + sqlite3_bind_int64(statement, 2, day.cjk); + sqlite3_bind_int64(statement, 3, day.latin); + sqlite3_bind_int64(statement, 4, day.digit); + sqlite3_bind_int64(statement, 5, day.punct); + sqlite3_bind_int64(statement, 6, day.other); + sqlite3_bind_int64(statement, 7, day.active_ms); + if (sqlite3_step(statement) != SQLITE_DONE) + { + error = Describe("write daily", db_); + ok = false; + break; + } + sqlite3_reset(statement); + } + sqlite3_finalize(statement); + } + } + if (ok && !batch.hours.empty()) + { + sqlite3_stmt *statement = nullptr; + if (sqlite3_prepare_v2(db_, kUpsertHourlySql, -1, &statement, nullptr) != SQLITE_OK) + { + error = Describe("prepare hourly upsert", db_); + ok = false; + } + else + { + for (const HourDelta &hour : batch.hours) + { + sqlite3_bind_int64(statement, 1, hour.day_key); + sqlite3_bind_int64(statement, 2, hour.hour); + sqlite3_bind_int64(statement, 3, hour.chars); + sqlite3_bind_int64(statement, 4, hour.active_ms); + if (sqlite3_step(statement) != SQLITE_DONE) + { + error = Describe("write hourly", db_); + ok = false; + break; + } + sqlite3_reset(statement); + } + sqlite3_finalize(statement); + } + } + if (ok) + { + ok = RefreshFirstDay(error); + } + if (!ok) + { + Rollback(); + return false; + } + return Commit(error); +} + +bool Store::DailyRows(std::vector &rows, std::string &error) const +{ + std::lock_guard lock(mutex_); + rows.clear(); + sqlite3_stmt *statement = nullptr; + if (sqlite3_prepare_v2(db_, + "SELECT day_key, cjk, latin, digit, punct, other, active_ms FROM stats_daily " + "ORDER BY day_key", + -1, &statement, nullptr) != SQLITE_OK) + { + error = Describe("query daily", db_); + return false; + } + + int step = SQLITE_OK; + while ((step = sqlite3_step(statement)) == SQLITE_ROW) + { + DailyRow row; + row.day_key = static_cast(sqlite3_column_int64(statement, 0)); + row.cjk = sqlite3_column_int64(statement, 1); + row.latin = sqlite3_column_int64(statement, 2); + row.digit = sqlite3_column_int64(statement, 3); + row.punct = sqlite3_column_int64(statement, 4); + row.other = sqlite3_column_int64(statement, 5); + row.active_ms = sqlite3_column_int64(statement, 6); + rows.push_back(row); + } + const bool ok = step == SQLITE_DONE; + if (!ok) + { + error = Describe("query daily", db_); + rows.clear(); + } + sqlite3_finalize(statement); + return ok; +} + +bool Store::HourlyRows(int day_key, std::vector &rows, std::string &error) const +{ + std::lock_guard lock(mutex_); + rows.clear(); + sqlite3_stmt *statement = nullptr; + if (sqlite3_prepare_v2(db_, + "SELECT day_key, hour, chars, active_ms FROM stats_hourly WHERE day_key = ? " + "ORDER BY hour", + -1, &statement, nullptr) != SQLITE_OK) + { + error = Describe("query hourly", db_); + return false; + } + sqlite3_bind_int64(statement, 1, day_key); + + int step = SQLITE_OK; + while ((step = sqlite3_step(statement)) == SQLITE_ROW) + { + HourlyRow row; + row.day_key = static_cast(sqlite3_column_int64(statement, 0)); + row.hour = static_cast(sqlite3_column_int64(statement, 1)); + row.chars = sqlite3_column_int64(statement, 2); + row.active_ms = sqlite3_column_int64(statement, 3); + rows.push_back(row); + } + const bool ok = step == SQLITE_DONE; + if (!ok) + { + error = Describe("query hourly", db_); + rows.clear(); + } + sqlite3_finalize(statement); + return ok; +} + +bool Store::Meta(const std::string &key, std::string &value, bool &present, std::string &error) const +{ + std::lock_guard lock(mutex_); + value.clear(); + present = false; + sqlite3_stmt *statement = nullptr; + if (sqlite3_prepare_v2(db_, "SELECT value FROM stats_meta WHERE key = ?", -1, &statement, nullptr) != SQLITE_OK) + { + error = Describe("read meta", db_); + return false; + } + sqlite3_bind_text(statement, 1, key.c_str(), -1, SQLITE_TRANSIENT); + + const int step = sqlite3_step(statement); + if (step == SQLITE_ROW) + { + const unsigned char *text = sqlite3_column_text(statement, 0); + value = text != nullptr ? reinterpret_cast(text) : std::string(); + present = true; + } + else if (step != SQLITE_DONE) + { + error = Describe("read meta", db_); + sqlite3_finalize(statement); + return false; + } + sqlite3_finalize(statement); + return true; +} + +bool Store::SetMeta(const std::string &key, const std::string &value, std::string &error) +{ + std::lock_guard lock(mutex_); + if (!BeginTransaction(error)) + { + return false; + } + if (!WriteMeta(key, value, error)) + { + Rollback(); + return false; + } + return Commit(error); +} + +bool Store::ClearThrough(int keep_from_day_key, int64_t &removed_days, std::string &error) +{ + std::lock_guard lock(mutex_); + removed_days = 0; + if (!BeginTransaction(error)) + { + return false; + } + + bool ok = true; + sqlite3_stmt *statement = nullptr; + if (sqlite3_prepare_v2(db_, "DELETE FROM stats_daily WHERE day_key < ?", -1, &statement, nullptr) != SQLITE_OK) + { + error = Describe("clear daily", db_); + ok = false; + } + else + { + sqlite3_bind_int64(statement, 1, keep_from_day_key); + ok = sqlite3_step(statement) == SQLITE_DONE; + if (!ok) + { + error = Describe("clear daily", db_); + } + else + { + removed_days = sqlite3_changes(db_); + } + sqlite3_finalize(statement); + } + + if (ok) + { + statement = nullptr; + if (sqlite3_prepare_v2(db_, "DELETE FROM stats_hourly WHERE day_key < ?", -1, &statement, nullptr) != SQLITE_OK) + { + error = Describe("clear hourly", db_); + ok = false; + } + else + { + sqlite3_bind_int64(statement, 1, keep_from_day_key); + ok = sqlite3_step(statement) == SQLITE_DONE; + if (!ok) + { + error = Describe("clear hourly", db_); + } + sqlite3_finalize(statement); + } + } + if (ok) + { + ok = RefreshFirstDay(error); + } + if (!ok) + { + Rollback(); + return false; + } + return Commit(error); +} + +bool Store::ClearAll(int64_t &removed_days, std::string &error) +{ + std::lock_guard lock(mutex_); + removed_days = 0; + if (!BeginTransaction(error)) + { + return false; + } + + // Unconditional deletes, not ClearThrough(0): day keys are positive, so a + // "day_key < 0" predicate would remove nothing. + bool ok = true; + sqlite3_stmt *statement = nullptr; + if (sqlite3_prepare_v2(db_, "DELETE FROM stats_daily", -1, &statement, nullptr) != SQLITE_OK) + { + error = Describe("clear all daily", db_); + ok = false; + } + else + { + ok = sqlite3_step(statement) == SQLITE_DONE; + if (!ok) + { + error = Describe("clear all daily", db_); + } + else + { + removed_days = sqlite3_changes(db_); + } + sqlite3_finalize(statement); + } + + if (ok) + { + statement = nullptr; + if (sqlite3_prepare_v2(db_, "DELETE FROM stats_hourly", -1, &statement, nullptr) != SQLITE_OK) + { + error = Describe("clear all hourly", db_); + ok = false; + } + else + { + ok = sqlite3_step(statement) == SQLITE_DONE; + if (!ok) + { + error = Describe("clear all hourly", db_); + } + sqlite3_finalize(statement); + } + } + if (ok) + { + // RefreshFirstDay deletes the empty first_day key, so the overview + // falls back to the empty state. + ok = RefreshFirstDay(error); + } + if (ok) + { + // The retention marker goes too: after a wipe there is nothing left to + // keep, and the next write may start a fresh once-per-day cycle. + ok = DeleteMetaRow(kStoreMetaLastRetentionDay, error); + } + if (!ok) + { + Rollback(); + return false; + } + return Commit(error); +} + +bool RunDailyRetention(Store &store, Retention retention, int today_day_key, RetentionOutcome &outcome, + std::string &error) +{ + outcome = RetentionOutcome{}; + + std::string marker; + bool present = false; + if (!store.Meta(kStoreMetaLastRetentionDay, marker, present, error)) + { + return false; + } + int last_day_key = 0; + if (present) + { + char *end = nullptr; + const long long parsed = std::strtoll(marker.c_str(), &end, 10); + // A malformed marker falls back to 0: the cleanup then runs once and + // rewrites the key, which repairs a hand-edited database instead of + // stalling the policy forever. + if (!marker.empty() && end != marker.c_str() && *end == '\0' && parsed > 0) + { + last_day_key = static_cast(parsed); + } + } + if (last_day_key == today_day_key) + { + outcome.already_ran_today = true; + return true; + } + + int cutoff_day_key = 0; + if (RetentionCutoff(retention, today_day_key, cutoff_day_key)) + { + if (!store.ClearThrough(cutoff_day_key, outcome.removed_days, error)) + { + return false; + } + } + // Forever still writes the marker: the once-per-day check is about the + // day, not about the policy, so a later switch to a finite window still + // cleans up on the next day's first write. + return store.SetMeta(kStoreMetaLastRetentionDay, std::to_string(today_day_key), error); +} + +bool Store::FirstDay(int &day_key, bool &present, std::string &error) const +{ + std::string value; + if (!Meta(kStoreMetaFirstDay, value, present, error)) + { + return false; + } + day_key = 0; + if (!present) + { + return true; + } + char *end = nullptr; + const long long parsed = std::strtoll(value.c_str(), &end, 10); + if (value.empty() || end == value.c_str() || *end != '\0') + { + error = "stats store: invalid first_day value \"" + value + "\""; + present = false; + return false; + } + day_key = static_cast(parsed); + return true; +} +} // namespace MsimeStats diff --git a/server/src/statistics/stats_store.h b/server/src/statistics/stats_store.h new file mode 100644 index 00000000..00de62ee --- /dev/null +++ b/server/src/statistics/stats_store.h @@ -0,0 +1,155 @@ +#pragma once + +// Local statistics database: schema, batch upsert, queries and cleanup. A +// direct port of the standalone tool's internal/store package. +// +// The schema deliberately holds only counts and time buckets: no text, no +// pinyin, no candidates, no application or window names. That property is the +// privacy contract and is asserted by tests. +// +// The store is used from two processes (Server writes, Settings reads and +// cleans). WAL mode plus a busy timeout serialise the cross-process access; +// in-process calls are serialised by a mutex. The path is supplied by the +// caller, so tests point it at a temporary directory and Server never reads an +// environment variable here. + +#include "statistics/stats_types.h" + +#include +#include +#include +#include +#include +#include + +struct sqlite3; + +namespace MsimeStats +{ +// SchemaVersion is written to stats_meta on open. +inline constexpr const char *kStoreSchemaVersion = "1"; + +// MetaFirstDay holds the smallest day_key currently stored; it doubles as the +// empty-state predicate (the key is deleted when no data remains) and is +// recomputed after every write and cleanup. +inline constexpr const char *kStoreMetaFirstDay = "first_day"; + +// MetaLastRetentionDay holds the local day key the automatic retention cleanup +// last ran for. It is what makes the cleanup once-per-day and idempotent: the +// Server checks it after every successful write and skips the work when the +// marker already names today. +inline constexpr const char *kStoreMetaLastRetentionDay = "last_retention_day"; + +// Retention is a cleanup policy chosen in the settings view. Months and years +// are approximated by days (the UI explains the approximation). The default +// value is Forever: keeping everything never deletes anything behind the user's +// back. +enum class Retention +{ + Days30 = 30, + Days90 = 90, + Days180 = 180, + Days365 = 365, + Forever = 0, +}; + +// ParseRetention validates a value coming from the settings page. +bool ParseRetention(const std::string &value, Retention &out); + +// RetentionCutoff returns the oldest day key a cleanup keeps: the retention +// window includes today, so "30 days" keeps today and the 29 days before it. +// Returns false for Forever (nothing may be deleted). +bool RetentionCutoff(Retention retention, int today_day_key, int &cutoff_day_key); + +class Store +{ + public: + // Open creates or opens the database at path and applies the schema. + // Returns nullptr on failure with `error` describing the reason. + static std::unique_ptr Open(const std::filesystem::path &path, std::string &error); + + ~Store(); + Store(const Store &) = delete; + Store &operator=(const Store &) = delete; + + const std::filesystem::path &Path() const + { + return path_; + } + + // Upsert applies one batch in a single transaction and refreshes + // first_day. An empty batch is a no-op. + bool Upsert(const Batch &batch, std::string &error); + + // DailyRows returns every recorded day, ascending. + bool DailyRows(std::vector &rows, std::string &error) const; + + // HourlyRows returns the hour buckets of one day, ascending by hour. + bool HourlyRows(int day_key, std::vector &rows, std::string &error) const; + + // Meta reads a stats_meta value; `present` reports whether the key exists. + bool Meta(const std::string &key, std::string &value, bool &present, std::string &error) const; + + // SetMeta writes a stats_meta value. + bool SetMeta(const std::string &key, const std::string &value, std::string &error); + + // ClearThrough deletes every day strictly before keep_from together with + // its hour buckets, and reports the number of removed days. Day keys are + // YYYYMMDD and therefore positive, so a keep_from of 0 removes nothing + // (use ClearAll to wipe the database). Cleanup is irreversible by design. + bool ClearThrough(int keep_from_day_key, int64_t &removed_days, std::string &error); + + // ClearAll deletes every recorded day and hour bucket and resets + // first_day and last_retention_day, so the overview returns to the empty + // state. It backs the explicit "clear all statistics" action; the + // confirmation is the caller's responsibility. + bool ClearAll(int64_t &removed_days, std::string &error); + + // FirstDay returns the smallest recorded day key; `present` is false for an + // empty database. + bool FirstDay(int &day_key, bool &present, std::string &error) const; + + private: + Store(sqlite3 *db, std::filesystem::path path); + + bool Initialize(std::string &error); + bool Exec(const char *sql, std::string &error) const; + bool BeginTransaction(std::string &error); + void Rollback(); + bool Commit(std::string &error); + bool RefreshFirstDay(std::string &error); + bool WriteMeta(const std::string &key, const std::string &value, std::string &error); + // Callers hold mutex_ and a transaction; ClearAll uses it to reset the + // retention marker in the same atomic wipe. + bool DeleteMetaRow(const std::string &key, std::string &error); + + sqlite3 *db_ = nullptr; + std::filesystem::path path_; + mutable std::mutex mutex_; +}; + +// RetentionOutcome reports what one RunDailyRetention call did. +struct RetentionOutcome +{ + // True when the marker already named today, so nothing was touched. + bool already_ran_today = false; + // Days removed by this run (0 for Forever or an already-run day). + int64_t removed_days = 0; +}; + +// RunDailyRetention enforces the retention policy at most once per local day. +// +// It reads stats_meta.last_retention_day and does nothing when that marker +// already names today. Otherwise it applies the policy: Forever only records +// the day, every other value deletes everything older than the retention +// window (the window includes today). The marker is written only when the +// deletion succeeded, so a failure leaves it stale and the next call retries. +// A marker that is not a positive decimal day key is treated as "never ran", +// which repairs a hand-edited database instead of stalling the policy. +// +// The function is intentionally free-standing and takes the store by +// reference: the Server calls it after a successful write, and tests drive it +// directly with a fixed day key instead of waiting for midnight. +bool RunDailyRetention(Store &store, Retention retention, int today_day_key, RetentionOutcome &outcome, + std::string &error); +} // namespace MsimeStats diff --git a/server/src/statistics/stats_types.h b/server/src/statistics/stats_types.h new file mode 100644 index 00000000..c1b3bb75 --- /dev/null +++ b/server/src/statistics/stats_types.h @@ -0,0 +1,289 @@ +#pragma once + +// Data structures and calendar helpers for the built-in input statistics. +// +// This is a direct port of the former standalone MSIME-Stats tool +// (internal/stats/stats.go and internal/stats/overview.go), which was archived +// when the feature moved into the Server. Field names and arithmetic mirror +// that source; the thresholds and derived-metric semantics were calibrated on +// real input data and must not drift. The one deliberate exception is the +// speed metric: it counts readable characters only (cjk + latin), see +// DailyRow::SpeedChars(). +// +// The header stays free of Windows headers: the day-key helpers are pure +// calendar arithmetic and the aggregator's timezone lookup is injected, so the +// derivation layer can also be built by the portable test target. + +#include +#include +#include + +namespace MsimeStats +{ +using TimePoint = std::chrono::system_clock::time_point; + +// A pause of at most this much between two consecutive commits counts as +// active typing time. Calibrated on real input data (#429): 5s counted normal +// thinking pauses as active time and inflated the speed metric. +inline constexpr int64_t kActiveGapLimitMs = 10'000; + +// Minimum active time a day needs before it may win the "fastest day" metric. +// Without it a day with a handful of characters in two seconds would dominate +// the ranking. +inline constexpr int64_t kFastestSpeedMinActiveMs = 60'000; + +// LocalTimeParts is a resolved local calendar time: exactly what the day/hour +// bucketing needs. The resolver producing it is injectable (see +// stats_aggregate.h) so tests never depend on the machine's timezone. +struct LocalTimeParts +{ + int year = 1970; + int month = 1; // 1-12 + int day = 1; // 1-31 + int hour = 0; // 0-23 +}; + +// DayKeyOf packs a calendar day into the YYYYMMDD integer used as the SQLite +// primary key. An integer keeps the key trivial and the ordering stable. +inline int DayKeyOf(int year, int month, int day) +{ + return year * 10000 + month * 100 + day; +} + +inline int DayKeyOf(const LocalTimeParts &parts) +{ + return DayKeyOf(parts.year, parts.month, parts.day); +} + +namespace detail +{ +// Days since 1970-01-01 for a proleptic Gregorian date (Howard Hinnant's +// algorithms). Calendar arithmetic must not go through std::mktime: that would +// pull the machine's timezone and DST state into a pure function. +inline int64_t DaysFromCivil(int year, int month, int day) +{ + const int64_t y = (month <= 2) ? static_cast(year) - 1 : year; + const int64_t era = (y >= 0 ? y : y - 399) / 400; + const unsigned yoe = static_cast(y - era * 400); + const unsigned mp = static_cast(month > 2 ? month - 3 : month + 9); + const unsigned doy = (153u * mp + 2u) / 5u + static_cast(day) - 1u; + const unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy; + return era * 146097 + static_cast(doe) - 719468; +} + +inline void CivilFromDays(int64_t days, int &year, int &month, int &day) +{ + days += 719468; + const int64_t era = (days >= 0 ? days : days - 146096) / 146097; + const unsigned doe = static_cast(days - era * 146097); + const unsigned yoe = (doe - doe / 1460u + doe / 36524u - doe / 146096u) / 365u; + const int64_t y = static_cast(yoe) + era * 400; + const unsigned doy = doe - (365u * yoe + yoe / 4u - yoe / 100u); + const unsigned mp = (5u * doy + 2u) / 153u; + day = static_cast(doy - (153u * mp + 2u) / 5u + 1u); + const int month_of_year = static_cast(mp) + (mp < 10u ? 3 : -9); + month = month_of_year; + year = static_cast(y + (month_of_year <= 2 ? 1 : 0)); +} +} // namespace detail + +// AddDays offsets a day key by whole days. The date math happens on calendar +// fields, so it stays correct across month/year boundaries and leap days +// without touching any instant or timezone. +inline int AddDays(int day_key, int days) +{ + const int64_t shifted = detail::DaysFromCivil(day_key / 10000, (day_key / 100) % 100, day_key % 100) + days; + int year = 0; + int month = 0; + int day = 0; + detail::CivilFromDays(shifted, year, month, day); + return DayKeyOf(year, month, day); +} + +// TimeFromUtcParts builds a time point from UTC calendar fields. Hour, minute +// and second may be outside their nominal ranges and simply offset the result, +// which lets callers express instants in a fixed offset without a timezone +// library (tests use it to model UTC+8). +inline TimePoint TimeFromUtcParts(int year, int month, int day, int hour, int minute, int second) +{ + const int64_t seconds = + detail::DaysFromCivil(year, month, day) * 86400 + static_cast(hour) * 3600 + minute * 60 + second; + return TimePoint(std::chrono::duration_cast(std::chrono::seconds(seconds))); +} + +// UtcPartsOf splits a time point into UTC calendar fields. Instants before 1970 +// are floored, not truncated toward zero. +inline LocalTimeParts UtcPartsOf(TimePoint time) +{ + constexpr int64_t kNsPerSecond = 1'000'000'000; + constexpr int64_t kNsPerDay = 86'400 * kNsPerSecond; + const int64_t nanoseconds = std::chrono::duration_cast(time.time_since_epoch()).count(); + int64_t days = nanoseconds / kNsPerDay; + int64_t remainder = nanoseconds % kNsPerDay; + if (remainder < 0) + { + remainder += kNsPerDay; + --days; + } + LocalTimeParts parts; + detail::CivilFromDays(days, parts.year, parts.month, parts.day); + parts.hour = static_cast(remainder / (3600 * kNsPerSecond)); + return parts; +} + +// DayKeyToTime converts a YYYYMMDD key to UTC midnight. Only the calendar +// fields matter; the time zone is a fixed vessel for date arithmetic. +inline TimePoint DayKeyToTime(int day_key) +{ + return TimeFromUtcParts(day_key / 10000, (day_key / 100) % 100, day_key % 100, 0, 0, 0); +} + +// CharsPerMinute converts a character count and an active duration to the +// speed metric. Zero characters or zero active time yield 0 instead of a +// division result. +inline double CharsPerMinute(int64_t chars, int64_t active_ms) +{ + if (chars <= 0 || active_ms <= 0) + { + return 0.0; + } + return static_cast(chars) / (static_cast(active_ms) / 60000.0); +} + +// DailyRow is one row of stats_daily: per-day category counts plus the active +// time attributed to that day. +struct DailyRow +{ + int day_key = 0; + int64_t cjk = 0; + int64_t latin = 0; + int64_t digit = 0; + int64_t punct = 0; + int64_t other = 0; + int64_t active_ms = 0; + + // Total returns the day's character count across all categories. This is + // the production metric used by the cards, best day, calendar and detail + // list; it is deliberately not the speed metric. + int64_t Total() const + { + return cjk + latin + digit + punct + other; + } + + // SpeedChars returns the characters that count toward the speed metric. + // Speed deliberately ignores digits, punctuation and "other": they are + // not typed prose and inflated the reading on real data. This is the only + // intentional divergence from the standalone tool, which measured speed on + // Total(); every threshold and formula elsewhere is unchanged. + int64_t SpeedChars() const + { + return cjk + latin; + } + + // Speed returns readable characters per active minute; 0 when no active + // time. + double Speed() const + { + return CharsPerMinute(SpeedChars(), active_ms); + } +}; + +// HourlyRow is one row of stats_hourly. +struct HourlyRow +{ + int day_key = 0; + int hour = 0; + int64_t chars = 0; + int64_t active_ms = 0; +}; + +// DayDelta is the per-day contribution of one batch of events. +struct DayDelta +{ + int day_key = 0; + int64_t cjk = 0; + int64_t latin = 0; + int64_t digit = 0; + int64_t punct = 0; + int64_t other = 0; + int64_t active_ms = 0; +}; + +// HourDelta is the per-hour contribution of one batch of events. +struct HourDelta +{ + int day_key = 0; + int hour = 0; + int64_t chars = 0; + int64_t active_ms = 0; +}; + +// Batch is the accumulated contribution of one frame, ready to be written in a +// single transaction. The vectors are sorted by day key and by (day, hour) for +// deterministic behaviour. +struct Batch +{ + std::vector days; + std::vector hours; + + // Empty reports whether the batch carries nothing to write. + bool Empty() const + { + return days.empty() && hours.empty(); + } +}; + +// CategoryCounts holds the five per-category totals. +struct CategoryCounts +{ + int64_t cjk = 0; + int64_t latin = 0; + int64_t digit = 0; + int64_t punct = 0; + int64_t other = 0; + + // Total returns the sum across all categories. + int64_t Total() const + { + return cjk + latin + digit + punct + other; + } +}; + +// Overview is the single payload the settings page renders: cards, calendar, +// hourly distribution, category split, speed card and the detail list all read +// from it. The frontend computes nothing beyond formatting. +struct Overview +{ + bool has_data = false; + // FirstDayKey/LastDayKey delimit the recorded range (0 when empty). + int first_day_key = 0; + int last_day_key = 0; + // Days is the number of days that have a record (zero-record days do not + // exist as rows and never enter the average denominator). + int days = 0; + int64_t total_chars = 0; + int64_t total_active_ms = 0; + + int today_day_key = 0; + int64_t today_chars = 0; + int64_t today_active_ms = 0; + + double average_per_day = 0.0; + int current_streak = 0; + int longest_streak = 0; + + int best_day_key = 0; + int64_t best_day_chars = 0; + + double today_speed = 0.0; + double average_speed = 0.0; + double fastest_speed = 0.0; + int fastest_day_key = 0; + + CategoryCounts categories; + // TodayHourly is the 24-bucket character distribution of today. + std::vector today_hourly; + // Daily is every recorded day, ascending by day key (calendar + details). + std::vector daily; +}; +} // namespace MsimeStats diff --git a/server/src/webview2/windows_webview2.cpp b/server/src/webview2/windows_webview2.cpp index a4f5ceed..cc1227e4 100644 --- a/server/src/webview2/windows_webview2.cpp +++ b/server/src/webview2/windows_webview2.cpp @@ -4183,6 +4183,22 @@ HRESULT OnControllerCreatedSettingsWnd( // PostSettingsConfig(); } } + else if (path == "statistics.enabled") + { + const bool value = json::value_to(data.at("value")); + if (SetConfiguredStatisticsEnabled(value)) + { + PostSettingsConfig(); + } + } + else if (path == "statistics.retention") + { + const std::string value = json::value_to(data.at("value")); + if (SetConfiguredStatisticsRetention(value)) + { + PostSettingsConfig(); + } + } else if (path.rfind("tencent_tmt.", 0) == 0) { const std::string value = json::value_to(data.at("value")); @@ -4672,7 +4688,9 @@ void PostSettingsConfig() {"quanpin_helpcode", GetConfiguredQuanpinHelpcodeEnabled()}, {"quanpin_helpcode_schema", GetConfiguredQuanpinHelpcodeSchema()}, {"show_sp_helpcode_in_candidate_window", GetConfiguredShowShuangpinHelpcodeInCandidateWindow()}, - {"show_qp_helpcode_in_candidate_window", GetConfiguredShowQuanpinHelpcodeInCandidateWindow()}}}}}}; + {"show_qp_helpcode_in_candidate_window", GetConfiguredShowQuanpinHelpcodeInCandidateWindow()}}}, + {"statistics", + {{"enabled", GetConfiguredStatisticsEnabled()}, {"retention", GetConfiguredStatisticsRetention()}}}}}}; const std::wstring message = string_to_wstring(payload.dump()); ::webviewSettingsWnd->PostWebMessageAsJson(message.c_str()); } diff --git a/server/tests/CMakeLists.txt b/server/tests/CMakeLists.txt index e0cbbb6e..f1d19564 100644 --- a/server/tests/CMakeLists.txt +++ b/server/tests/CMakeLists.txt @@ -66,6 +66,14 @@ set(TEST_SOURCE_FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/test_dictionary_validation.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_skin_css_policy.cpp" "${CMAKE_SOURCE_DIR}/src/webview2/skin_css_policy.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_stats_frame_decode.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_stats_aggregate.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_stats_overview.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_stats_store.cpp" + "${CMAKE_SOURCE_DIR}/src/statistics/stats_frames.cpp" + "${CMAKE_SOURCE_DIR}/src/statistics/stats_aggregate.cpp" + "${CMAKE_SOURCE_DIR}/src/statistics/stats_overview.cpp" + "${CMAKE_SOURCE_DIR}/src/statistics/stats_store.cpp" "${CMAKE_SOURCE_DIR}/src/config/ime_config.cpp" "${CMAKE_SOURCE_DIR}/src/session/engine_input_session.cpp" "${CMAKE_SOURCE_DIR}/src/utils/common_utils.cpp" diff --git a/server/tests/src/test_config_non_ascii_path.cpp b/server/tests/src/test_config_non_ascii_path.cpp index f3b6a450..0101235c 100644 --- a/server/tests/src/test_config_non_ascii_path.cpp +++ b/server/tests/src/test_config_non_ascii_path.cpp @@ -632,3 +632,145 @@ TEST_CASE(shipped_template_carries_smart_punctuation_conversion_keys) REQUIRE(!parsed["input"]["smart_punctuation_direct_letter"].value_or(true)); REQUIRE(!parsed["input"]["smart_punctuation_repeat_to_chinese"].value_or(true)); } + +// 统计总开关:默认关闭(无文件 / 出厂模板 / 模板缺段三形态),能落盘并在重读后保持。 +// 出厂模板若不给 [statistics] 段,首次打开开关也必须成功——setter 不能假设段存在, +// 否则设置页会报「保存失败」(先例:全拼纠错的 [quanpin] 段)。 +TEST_CASE(statistics_enabled_defaults_off_and_round_trips) +{ + namespace fs = std::filesystem; + const fs::path unique_root = + fs::temp_directory_path() / (L"msime-统计开关测试-" + std::to_wstring(GetCurrentProcessId())); + const fs::path local_app_data = unique_root / L"profile"; + const fs::path data_dir = local_app_data / L"metasequoiaime"; + + std::error_code ec; + fs::remove_all(unique_root, ec); + + // 无文件形态:配置目录里什么都没有,全局量保持静态默认 false。 + { + ScopedConfigLocation local_app_data_env(local_app_data); + InitImeConfig(); + REQUIRE(!GetConfiguredStatisticsEnabled()); + } + + SeedTemplate(data_dir); + + { + ScopedConfigLocation local_app_data_env(local_app_data); + + // 出厂模板形态:键存在且为 false。 + InitImeConfig(); + REQUIRE(!GetConfiguredStatisticsEnabled()); + + // 缺段写入:手写一份没有 [statistics] 的配置,首次打开开关必须自己把段补出来。 + WriteText(data_dir / L"config.toml", "[input]\nschema = \"quanpin\"\n"); + REQUIRE(SetConfiguredStatisticsEnabled(true)); + REQUIRE(GetConfiguredStatisticsEnabled()); + { + const std::string text = ReadText(data_dir / L"config.toml"); + REQUIRE(text.find("[statistics]") != std::string::npos); + REQUIRE(text.find("enabled = true") != std::string::npos); + } + + // 缺键回落:抹掉段后重读,模板缺键时默认必须是 false。 + WriteText(data_dir / L"config.toml", "[input]\nschema = \"quanpin\"\n"); + InitImeConfig(); + REQUIRE(!GetConfiguredStatisticsEnabled()); + + // 重读保持:开关真实落盘、跨 InitImeConfig 存活,关闭同样可逆。 + REQUIRE(SetConfiguredStatisticsEnabled(true)); + InitImeConfig(); + REQUIRE(GetConfiguredStatisticsEnabled()); + REQUIRE(SetConfiguredStatisticsEnabled(false)); + REQUIRE(!GetConfiguredStatisticsEnabled()); + InitImeConfig(); + REQUIRE(!GetConfiguredStatisticsEnabled()); + { + const std::string text = ReadText(data_dir / L"config.toml"); + REQUIRE(text.find("enabled = false") != std::string::npos); + } + } + + fs::remove_all(unique_root, ec); +} + +// 出厂模板(安装包真正分发的那份)必须携带 statistics.enabled 且默认 false;缺键会在 +// 升级合并时被静默丢弃,用户改过的值下次升级消失且无任何报错。 +TEST_CASE(shipped_template_carries_statistics_switch) +{ + std::ifstream input(MSIME_DEFAULT_CONFIG_PATH, std::ios::binary); + REQUIRE(static_cast(input)); + const std::string installed((std::istreambuf_iterator(input)), {}); + const auto parsed = toml::parse(installed); + REQUIRE(!parsed["statistics"]["enabled"].value_or(true)); + REQUIRE_EQ(parsed["statistics"]["retention"].value_or(std::string()), std::string("forever")); +} + +// 统计保留策略:默认 forever(无文件 / 出厂模板 / 缺键 / 非法值四种形态), +// 合法值能落盘并在重读后保持,非法 setter 被拒绝且不污染文件。 +TEST_CASE(statistics_retention_defaults_forever_and_round_trips) +{ + namespace fs = std::filesystem; + const fs::path unique_root = MakeProfileRoot(); + const fs::path local_app_data = unique_root / L"本地"; + const fs::path data_dir = local_app_data / L"metasequoiaime"; + + std::error_code ec; + fs::remove_all(unique_root, ec); + + // 无文件形态:全局量保持静态默认 forever。 + { + ScopedConfigLocation local_app_data_env(local_app_data); + InitImeConfig(); + REQUIRE_EQ(GetConfiguredStatisticsRetention(), std::string("forever")); + } + + SeedTemplate(data_dir); + + { + ScopedConfigLocation local_app_data_env(local_app_data); + + // 出厂模板形态:显式携带 forever。 + InitImeConfig(); + REQUIRE_EQ(GetConfiguredStatisticsRetention(), std::string("forever")); + + // 缺键形态:手写一份没有 retention 的配置 → forever。 + WriteText(data_dir / L"config.toml", "[statistics]\nenabled = true\n"); + InitImeConfig(); + REQUIRE_EQ(GetConfiguredStatisticsRetention(), std::string("forever")); + + // 非法值形态:手写坏值 → forever。坏配置绝不能触发自动清理误删数据。 + WriteText(data_dir / L"config.toml", "[statistics]\nretention = \"2d\"\n"); + InitImeConfig(); + REQUIRE_EQ(GetConfiguredStatisticsRetention(), std::string("forever")); + + // setter 拒绝非法枚举:不写文件、内存值不动。 + const std::string before_invalid_set = ReadText(data_dir / L"config.toml"); + REQUIRE(!SetConfiguredStatisticsRetention("2d")); + REQUIRE_EQ(GetConfiguredStatisticsRetention(), std::string("forever")); + REQUIRE_EQ(ReadText(data_dir / L"config.toml"), before_invalid_set); + + // 逐值落盘重读:每个合法枚举都能往返。 + for (const char *value : {"30d", "90d", "180d", "365d", "forever"}) + { + REQUIRE(SetConfiguredStatisticsRetention(value)); + REQUIRE_EQ(GetConfiguredStatisticsRetention(), std::string(value)); + InitImeConfig(); + REQUIRE_EQ(GetConfiguredStatisticsRetention(), std::string(value)); + } + + // 缺段写入:段不存在时 setter 必须自己补出 [statistics]。 + WriteText(data_dir / L"config.toml", "[input]\nschema = \"quanpin\"\n"); + REQUIRE(SetConfiguredStatisticsRetention("90d")); + { + const std::string text = ReadText(data_dir / L"config.toml"); + REQUIRE(text.find("[statistics]") != std::string::npos); + REQUIRE(text.find("retention = \"90d\"") != std::string::npos); + } + InitImeConfig(); + REQUIRE_EQ(GetConfiguredStatisticsRetention(), std::string("90d")); + } + + fs::remove_all(unique_root, ec); +} diff --git a/server/tests/src/test_ipc_protocol_constants.cpp b/server/tests/src/test_ipc_protocol_constants.cpp index 69829003..f2beadc1 100644 --- a/server/tests/src/test_ipc_protocol_constants.cpp +++ b/server/tests/src/test_ipc_protocol_constants.cpp @@ -3,6 +3,7 @@ #endif #include "ipc/ipc.h" #include "ipc/input_key_policy.h" +#include "statistics/stats_pipe.h" #include "tests/includes/test_framework.h" TEST_CASE(ipc_reverse_pipes_buffer_multiple_complete_frames) @@ -147,3 +148,14 @@ TEST_CASE(ipc_pipe_security_descriptor_fails_closed_without_an_owner_sid) { REQUIRE(FanyImeIpc::BuildPipeSecurityDescriptorSddl(L"").empty()); } + +TEST_CASE(stats_pipe_name_carries_the_decimal_session_suffix) +{ + // Named pipes are machine-global; a missing or non-decimal suffix would let + // concurrent sessions fight over one listener, so the DLL and Server must + // build the exact same name. + REQUIRE_EQ(MsimeStats::BuildStatsPipeName(0), std::wstring(L"\\\\.\\pipe\\FanyImeStatsNamedPipe-0")); + REQUIRE_EQ(MsimeStats::BuildStatsPipeName(7), std::wstring(L"\\\\.\\pipe\\FanyImeStatsNamedPipe-7")); + REQUIRE_EQ(MsimeStats::BuildStatsPipeName(42), std::wstring(L"\\\\.\\pipe\\FanyImeStatsNamedPipe-42")); + REQUIRE_EQ(std::wstring(FANY_IME_STATS_PIPE_NAME_PREFIX) + L"12", MsimeStats::BuildStatsPipeName(12)); +} diff --git a/server/tests/src/test_stats_aggregate.cpp b/server/tests/src/test_stats_aggregate.cpp new file mode 100644 index 00000000..f280898b --- /dev/null +++ b/server/tests/src/test_stats_aggregate.cpp @@ -0,0 +1,250 @@ +#include "tests/includes/test_framework.h" + +#include "statistics/stats_aggregate.h" +#include "statistics/stats_frames.h" + +#include +#include +#include + +namespace +{ +using ::FanyImeStatsEvent; + +// The reference tool's tests pin the bucketing in a fixed UTC+8 zone; the +// aggregator accepts the resolver as a parameter precisely so these assertions +// do not depend on the machine's timezone. +MsimeStats::LocalTimeParts Utc8(MsimeStats::TimePoint utc) +{ + return MsimeStats::UtcPartsOf(utc + std::chrono::hours(8)); +} + +// At8 builds the instant that reads as the given UTC+8 wall-clock time. +MsimeStats::TimePoint At8(int year, int month, int day, int hour, int minute, int second) +{ + return MsimeStats::TimeFromUtcParts(year, month, day, hour - 8, minute, second); +} + +uint64_t FiletimeAt8(int year, int month, int day, int hour, int minute, int second) +{ + return MsimeStats::TimeToFiletime(At8(year, month, day, hour, minute, second)); +} + +FanyImeStatsEvent MakeEvent(uint64_t filetime) +{ + FanyImeStatsEvent event; + event.timestamp_utc_ft = filetime; + return event; +} +} // namespace + +TEST_CASE(stats_active_delta_ms_boundaries) +{ + const MsimeStats::TimePoint base = At8(2026, 9, 20, 10, 0, 0); + const auto ms = [](int64_t value) { return std::chrono::milliseconds(value); }; + + REQUIRE_EQ(MsimeStats::ActiveDeltaMs(MsimeStats::TimePoint{}, base), 0); + REQUIRE_EQ(MsimeStats::ActiveDeltaMs(base, base), 0); + REQUIRE_EQ(MsimeStats::ActiveDeltaMs(base, base + ms(1)), 1); + REQUIRE_EQ(MsimeStats::ActiveDeltaMs(base, base + ms(MsimeStats::kActiveGapLimitMs - 1)), 9999); + REQUIRE_EQ(MsimeStats::ActiveDeltaMs(base, base + ms(MsimeStats::kActiveGapLimitMs)), 10000); + REQUIRE_EQ(MsimeStats::ActiveDeltaMs(base, base + ms(MsimeStats::kActiveGapLimitMs + 1)), 0); + REQUIRE_EQ(MsimeStats::ActiveDeltaMs(base, base - std::chrono::seconds(1)), 0); + REQUIRE_EQ(MsimeStats::ActiveDeltaMs(base, MsimeStats::TimePoint{}), 0); +} + +TEST_CASE(stats_day_key_and_hour_follow_the_requested_zone) +{ + // 2026-09-20 23:30 in UTC+8 is still 2026-09-20 locally. + const MsimeStats::LocalTimeParts local = Utc8(At8(2026, 9, 20, 23, 30, 0)); + REQUIRE_EQ(MsimeStats::DayKeyOf(local), 20260920); + REQUIRE_EQ(local.hour, 23); + + // The same instant is still 2026-09-20 in UTC. + const MsimeStats::TimePoint instant = At8(2026, 9, 20, 23, 30, 0); + REQUIRE_EQ(MsimeStats::DayKeyOf(MsimeStats::UtcPartsOf(instant)), 20260920); + + // 17:00 UTC is already the next day in UTC+8; the requested zone decides + // the bucket, not the timestamp's own. + const MsimeStats::TimePoint late = MsimeStats::TimeFromUtcParts(2026, 9, 20, 17, 0, 0); + REQUIRE_EQ(MsimeStats::DayKeyOf(Utc8(late)), 20260921); + REQUIRE_EQ(Utc8(late).hour, 1); +} + +TEST_CASE(stats_add_days_across_month_and_year) +{ + REQUIRE_EQ(MsimeStats::AddDays(20260920, -1), 20260919); + REQUIRE_EQ(MsimeStats::AddDays(20261001, -1), 20260930); + REQUIRE_EQ(MsimeStats::AddDays(20260101, -1), 20251231); + REQUIRE_EQ(MsimeStats::AddDays(20260228, 1), 20260301); + REQUIRE_EQ(MsimeStats::AddDays(20240228, 1), 20240229); // leap year + REQUIRE_EQ(MsimeStats::AddDays(20261231, 1), 20270101); +} + +TEST_CASE(stats_day_key_to_time_is_utc_midnight) +{ + REQUIRE(MsimeStats::DayKeyToTime(20260920) == MsimeStats::TimeFromUtcParts(2026, 9, 20, 0, 0, 0)); +} + +TEST_CASE(stats_accumulator_batches_and_active_time) +{ + MsimeStats::Accumulator accumulator(Utc8); + FanyImeStatsEvent first = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 0)); + first.cjk = 2; + FanyImeStatsEvent second = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 3)); + second.cjk = 1; + second.punct = 1; + FanyImeStatsEvent third = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 8)); + third.cjk = 1; + third.other = 1; + + MsimeStats::Batch batch = accumulator.Add({first, second, third}); + REQUIRE_EQ(batch.days.size(), 1u); + REQUIRE_EQ(batch.hours.size(), 1u); + REQUIRE_EQ(batch.days[0].day_key, 20260920); + REQUIRE_EQ(batch.days[0].cjk, 4); + REQUIRE_EQ(batch.days[0].punct, 1); + REQUIRE_EQ(batch.days[0].other, 1); + REQUIRE_EQ(batch.days[0].active_ms, 8000); + REQUIRE_EQ(batch.hours[0].hour, 10); + REQUIRE_EQ(batch.hours[0].chars, 6); + REQUIRE_EQ(batch.hours[0].active_ms, 8000); + + // A pause longer than the threshold contributes no active time. + FanyImeStatsEvent after_pause = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 1, 0)); + after_pause.latin = 3; + batch = accumulator.Add({after_pause}); + REQUIRE_EQ(batch.days[0].active_ms, 0); + REQUIRE_EQ(batch.days[0].latin, 3); +} + +TEST_CASE(stats_accumulator_is_global_across_day_and_hour_boundaries) +{ + MsimeStats::Accumulator accumulator(Utc8); + FanyImeStatsEvent first = MakeEvent(FiletimeAt8(2026, 9, 20, 23, 59, 58)); + first.cjk = 1; + MsimeStats::Batch batch = accumulator.Add({first}); + REQUIRE_EQ(batch.days[0].day_key, 20260920); + REQUIRE_EQ(batch.hours[0].hour, 23); + + // Two seconds later it is a new day and a new hour, but still active. + FanyImeStatsEvent second = MakeEvent(FiletimeAt8(2026, 9, 21, 0, 0, 0)); + second.cjk = 1; + batch = accumulator.Add({second}); + REQUIRE_EQ(batch.days[0].day_key, 20260921); + REQUIRE_EQ(batch.hours[0].hour, 0); + REQUIRE_EQ(batch.days[0].active_ms, 2000); + REQUIRE_EQ(batch.hours[0].active_ms, 2000); +} + +TEST_CASE(stats_accumulator_out_of_order_and_clock_rollback) +{ + MsimeStats::Accumulator accumulator(Utc8); + FanyImeStatsEvent reference = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 10)); + reference.cjk = 1; + accumulator.Add({reference}); + + // Out-of-order event from a slower process: characters counted, no time. + FanyImeStatsEvent slow = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 5)); + slow.cjk = 2; + MsimeStats::Batch batch = accumulator.Add({slow}); + REQUIRE_EQ(batch.days[0].active_ms, 0); + REQUIRE_EQ(batch.days[0].cjk, 2); + + // The accumulator clock must not have moved backwards. + REQUIRE(accumulator.HasLastEvent()); + REQUIRE(accumulator.LastEventTime() == At8(2026, 9, 20, 10, 0, 10)); + + // The next in-order event continues from the furthest point seen. + FanyImeStatsEvent next = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 14)); + next.cjk = 1; + batch = accumulator.Add({next}); + REQUIRE_EQ(batch.days[0].active_ms, 4000); +} + +TEST_CASE(stats_accumulator_merges_multiple_events_into_one_day_delta) +{ + MsimeStats::Accumulator accumulator(Utc8); + FanyImeStatsEvent first = MakeEvent(FiletimeAt8(2026, 9, 20, 9, 0, 0)); + first.cjk = 1; + FanyImeStatsEvent second = MakeEvent(FiletimeAt8(2026, 9, 20, 9, 0, 1)); + second.cjk = 1; + FanyImeStatsEvent third = MakeEvent(FiletimeAt8(2026, 9, 20, 20, 0, 0)); + third.latin = 1; + + const MsimeStats::Batch batch = accumulator.Add({first, second, third}); + REQUIRE_EQ(batch.days.size(), 1u); + REQUIRE_EQ(batch.days[0].cjk, 2); + REQUIRE_EQ(batch.days[0].latin, 1); + REQUIRE_EQ(batch.days[0].active_ms, 1000); + REQUIRE_EQ(batch.hours.size(), 2u); + REQUIRE_EQ(batch.hours[0].hour, 9); + REQUIRE_EQ(batch.hours[1].hour, 20); +} + +TEST_CASE(stats_accumulator_zero_timestamp) +{ + MsimeStats::Accumulator accumulator(Utc8); + FanyImeStatsEvent reference = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 0)); + reference.cjk = 1; + accumulator.Add({reference}); + + FanyImeStatsEvent broken = MakeEvent(0); + broken.cjk = 3; + const MsimeStats::Batch batch = accumulator.Add({broken}); + REQUIRE_EQ(batch.days.size(), 1u); + REQUIRE_EQ(batch.days[0].day_key, 20260920); + REQUIRE_EQ(batch.days[0].active_ms, 0); + REQUIRE_EQ(batch.days[0].cjk, 3); +} + +TEST_CASE(stats_accumulator_reset_clears_the_active_clock) +{ + MsimeStats::Accumulator accumulator(Utc8); + FanyImeStatsEvent reference = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 0)); + reference.cjk = 1; + accumulator.Add({reference}); + REQUIRE(accumulator.HasLastEvent()); + + accumulator.Reset(); + REQUIRE(!accumulator.HasLastEvent()); + REQUIRE(accumulator.LastEventTime() == MsimeStats::TimePoint{}); + + // After a reset the next event starts a fresh chain: no active time even + // though it arrives one second after the previous (discarded) event. + FanyImeStatsEvent next = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 1)); + next.cjk = 1; + const MsimeStats::Batch batch = accumulator.Add({next}); + REQUIRE_EQ(batch.days[0].active_ms, 0); +} + +TEST_CASE(stats_accumulator_batch_is_sorted) +{ + // Events belonging to several days in one frame must still come back sorted + // by day key and by (day, hour), so the caller can write them deterministically. + MsimeStats::Accumulator accumulator(Utc8); + FanyImeStatsEvent next_day = MakeEvent(FiletimeAt8(2026, 9, 21, 9, 0, 0)); + next_day.cjk = 1; + FanyImeStatsEvent evening = MakeEvent(FiletimeAt8(2026, 9, 20, 20, 0, 0)); + evening.latin = 1; + FanyImeStatsEvent morning = MakeEvent(FiletimeAt8(2026, 9, 20, 8, 0, 0)); + morning.digit = 1; + + const MsimeStats::Batch batch = accumulator.Add({next_day, evening, morning}); + REQUIRE_EQ(batch.days.size(), 2u); + REQUIRE_EQ(batch.days[0].day_key, 20260920); + REQUIRE_EQ(batch.days[1].day_key, 20260921); + REQUIRE_EQ(batch.hours.size(), 3u); + REQUIRE_EQ(batch.hours[0].day_key, 20260920); + REQUIRE_EQ(batch.hours[0].hour, 8); + REQUIRE_EQ(batch.hours[1].hour, 20); + REQUIRE_EQ(batch.hours[2].day_key, 20260921); + REQUIRE_EQ(batch.hours[2].hour, 9); +} + +TEST_CASE(stats_chars_per_minute_guard) +{ + REQUIRE_EQ(MsimeStats::CharsPerMinute(100, 0), 0.0); + REQUIRE_EQ(MsimeStats::CharsPerMinute(120, 60'000), 120.0); + REQUIRE_EQ(MsimeStats::CharsPerMinute(0, 60'000), 0.0); +} diff --git a/server/tests/src/test_stats_frame_decode.cpp b/server/tests/src/test_stats_frame_decode.cpp new file mode 100644 index 00000000..cf25316e --- /dev/null +++ b/server/tests/src/test_stats_frame_decode.cpp @@ -0,0 +1,223 @@ +#include "tests/includes/test_framework.h" + +#include "statistics/stats_frames.h" + +#include +#include +#include +#include + +namespace +{ +using ::FanyImeStatsBatchHeader; +using ::FanyImeStatsEvent; +using MsimeStats::FrameError; + +std::vector MakeFrame(const FanyImeStatsBatchHeader &header, const std::vector &events) +{ + std::vector frame(sizeof(header) + events.size() * sizeof(FanyImeStatsEvent)); + std::memcpy(frame.data(), &header, sizeof(header)); + if (!events.empty()) + { + std::memcpy(frame.data() + sizeof(header), events.data(), events.size() * sizeof(FanyImeStatsEvent)); + } + return frame; +} + +// MutateField writes a little-endian uint32 at the given header offset in a +// copy of the frame; the caller expects the resulting frame to be rejected. +std::vector MutateHeaderField(const std::vector &frame, size_t offset, uint32_t value) +{ + std::vector out = frame; + out[offset + 0] = static_cast(value); + out[offset + 1] = static_cast(value >> 8); + out[offset + 2] = static_cast(value >> 16); + out[offset + 3] = static_cast(value >> 24); + return out; +} +} // namespace + +// The C++ side pins the same numbers with static_assert in +// engine/contracts/windows_ipc.h. If either side is edited without the other, +// these assertions are the only thing that notices. +TEST_CASE(stats_frame_contract_layout_matches_contract) +{ + REQUIRE_EQ(sizeof(FanyImeStatsBatchHeader), 28u); + REQUIRE_EQ(sizeof(FanyImeStatsEvent), 24u); + REQUIRE_EQ(offsetof(FanyImeStatsBatchHeader, magic), 0u); + REQUIRE_EQ(offsetof(FanyImeStatsBatchHeader, version), 4u); + REQUIRE_EQ(offsetof(FanyImeStatsBatchHeader, header_size), 8u); + REQUIRE_EQ(offsetof(FanyImeStatsBatchHeader, payload_bytes), 12u); + REQUIRE_EQ(offsetof(FanyImeStatsBatchHeader, event_count), 16u); + REQUIRE_EQ(offsetof(FanyImeStatsBatchHeader, dropped_count), 20u); + REQUIRE_EQ(offsetof(FanyImeStatsBatchHeader, source_process_id), 24u); + REQUIRE_EQ(offsetof(FanyImeStatsEvent, timestamp_utc_ft), 0u); + REQUIRE_EQ(offsetof(FanyImeStatsEvent, cjk), 8u); + REQUIRE_EQ(offsetof(FanyImeStatsEvent, latin), 10u); + REQUIRE_EQ(offsetof(FanyImeStatsEvent, digit), 12u); + REQUIRE_EQ(offsetof(FanyImeStatsEvent, punct), 14u); + REQUIRE_EQ(offsetof(FanyImeStatsEvent, other), 16u); +} + +// Golden bytes pin the wire format independently of the encoder: the expected +// slice is spelled out by hand, exactly as the DLL would write it. +TEST_CASE(stats_frame_header_golden_bytes) +{ + const std::vector raw = { + 0x53, 0x54, 0x41, 0x54, // "STAT" + 0x01, 0x00, 0x00, 0x00, // version 1 + 0x1c, 0x00, 0x00, 0x00, // header_size 28 + 0x00, 0x00, 0x00, 0x00, // payload_bytes 0 + 0x00, 0x00, 0x00, 0x00, // event_count 0 + 0x03, 0x00, 0x00, 0x00, // dropped_count 3 + 0x04, 0x03, 0x02, 0x01, // source_process_id 0x01020304 + }; + FanyImeStatsBatchHeader header; + std::vector events; + REQUIRE(MsimeStats::DecodeFrame(raw.data(), raw.size(), header, events) == FrameError::None); + REQUIRE_EQ(header.magic, 0x54415453u); + REQUIRE_EQ(header.version, 1u); + REQUIRE_EQ(header.header_size, 28u); + REQUIRE_EQ(header.payload_bytes, 0u); + REQUIRE_EQ(header.event_count, 0u); + REQUIRE_EQ(header.dropped_count, 3u); + REQUIRE_EQ(header.source_process_id, 0x01020304u); + REQUIRE(events.empty()); +} + +TEST_CASE(stats_frame_event_golden_bytes) +{ + const std::vector event_bytes = { + 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, // timestamp 0x0102030405060708 + 0x0b, 0x0a, // cjk 0x0a0b + 0x0d, 0x0c, // latin 0x0c0d + 0x0f, 0x0e, // digit 0x0e0f + 0x11, 0x10, // punct 0x1011 + 0x13, 0x12, // other 0x1213 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // tail padding + }; + FanyImeStatsBatchHeader header; + header.payload_bytes = static_cast(event_bytes.size()); + header.event_count = 1; + std::vector frame = MakeFrame(header, {}); + frame.insert(frame.end(), event_bytes.begin(), event_bytes.end()); + + FanyImeStatsBatchHeader got_header; + std::vector events; + REQUIRE(MsimeStats::DecodeFrame(frame.data(), frame.size(), got_header, events) == FrameError::None); + REQUIRE_EQ(events.size(), 1u); + REQUIRE_EQ(events[0].timestamp_utc_ft, 0x0102030405060708ull); + REQUIRE_EQ(events[0].cjk, 0x0a0bu); + REQUIRE_EQ(events[0].latin, 0x0c0du); + REQUIRE_EQ(events[0].digit, 0x0e0fu); + REQUIRE_EQ(events[0].punct, 0x1011u); + REQUIRE_EQ(events[0].other, 0x1213u); +} + +TEST_CASE(stats_frame_decodes_metadata_and_events) +{ + FanyImeStatsBatchHeader header; + header.dropped_count = 7; + header.source_process_id = 4242; + + std::vector events(2); + events[0].timestamp_utc_ft = 100; + events[0].cjk = 2; + events[0].punct = 1; + events[1].timestamp_utc_ft = 200; + events[1].latin = 4; + events[1].digit = 1; + events[1].other = 1; + header.payload_bytes = static_cast(events.size() * sizeof(FanyImeStatsEvent)); + header.event_count = static_cast(events.size()); + const std::vector frame = MakeFrame(header, events); + + FanyImeStatsBatchHeader got_header; + std::vector got_events; + REQUIRE(MsimeStats::DecodeFrame(frame.data(), frame.size(), got_header, got_events) == FrameError::None); + REQUIRE_EQ(got_header.payload_bytes, 48u); + REQUIRE_EQ(got_header.event_count, 2u); + REQUIRE_EQ(got_header.dropped_count, 7u); + REQUIRE_EQ(got_header.source_process_id, 4242u); + REQUIRE_EQ(got_events.size(), 2u); + REQUIRE_EQ(got_events[0].timestamp_utc_ft, 100ull); + REQUIRE_EQ(got_events[0].cjk, 2u); + REQUIRE_EQ(got_events[0].punct, 1u); + REQUIRE_EQ(got_events[1].timestamp_utc_ft, 200ull); + REQUIRE_EQ(got_events[1].latin, 4u); + REQUIRE_EQ(got_events[1].digit, 1u); + REQUIRE_EQ(got_events[1].other, 1u); +} + +// A frame that fails any structural check must be rejected as a whole: the +// reader never tries to resynchronise inside a frame that may have a different +// layout. +TEST_CASE(stats_frame_bad_frames_are_rejected) +{ + FanyImeStatsBatchHeader header; + header.payload_bytes = static_cast(sizeof(FanyImeStatsEvent)); + header.event_count = 1; + const std::vector good = MakeFrame(header, std::vector(1)); + + struct Case + { + const char *name; + FrameError want; + std::vector raw; + }; + std::vector trailing = good; + trailing.push_back(0); + const uint32_t too_large = + static_cast(FANY_IME_STATS_MAX_FRAME_BYTES - sizeof(FanyImeStatsBatchHeader) + 1); + + const std::vector cases = { + {"short header", FrameError::ShortHeader, + std::vector(good.begin(), good.begin() + sizeof(header) - 1)}, + {"truncated payload", FrameError::TrailingBytes, std::vector(good.begin(), good.end() - 1)}, + {"bad magic", FrameError::BadMagic, MutateHeaderField(good, 0, 0)}, + {"bad version", FrameError::BadVersion, MutateHeaderField(good, 4, 99)}, + {"bad header size", FrameError::BadHeaderSize, MutateHeaderField(good, 8, 32)}, + {"payload too large", FrameError::PayloadTooLarge, MutateHeaderField(good, 12, too_large)}, + {"payload not event multiple", FrameError::BadPayloadSize, MutateHeaderField(good, 12, 23)}, + {"event count mismatch", FrameError::EventCountMismatch, MutateHeaderField(good, 16, 5)}, + {"trailing bytes", FrameError::TrailingBytes, trailing}, + }; + + for (const Case &test_case : cases) + { + FanyImeStatsBatchHeader got_header; + std::vector events; + const FrameError error = + MsimeStats::DecodeFrame(test_case.raw.data(), test_case.raw.size(), got_header, events); + REQUIRE(error == test_case.want); + REQUIRE(events.empty()); + } +} + +TEST_CASE(stats_frame_source_process_must_match_the_pipe_client) +{ + FanyImeStatsBatchHeader header; + header.source_process_id = 4242; + REQUIRE(MsimeStats::ValidateFrameSource(header, 4242) == FrameError::None); + REQUIRE(MsimeStats::ValidateFrameSource(header, 4243) == FrameError::SourceProcessMismatch); +} + +TEST_CASE(stats_frame_filetime_round_trip) +{ + const MsimeStats::TimePoint now = MsimeStats::TimeFromUtcParts(2026, 9, 20, 12, 34, 56); + const uint64_t filetime = MsimeStats::TimeToFiletime(now); + REQUIRE(MsimeStats::FiletimeToTime(filetime) == now); + REQUIRE(MsimeStats::FiletimeToTime(0) == MsimeStats::TimePoint{}); + REQUIRE(MsimeStats::FiletimeToTime(UINT64_MAX) != MsimeStats::TimePoint{}); +} + +TEST_CASE(stats_frame_event_total) +{ + FanyImeStatsEvent event; + event.cjk = 1; + event.latin = 2; + event.digit = 3; + event.punct = 4; + event.other = 5; + REQUIRE_EQ(MsimeStats::EventTotalChars(event), 15); +} diff --git a/server/tests/src/test_stats_overview.cpp b/server/tests/src/test_stats_overview.cpp new file mode 100644 index 00000000..e3ec44c5 --- /dev/null +++ b/server/tests/src/test_stats_overview.cpp @@ -0,0 +1,264 @@ +#include "tests/includes/test_framework.h" + +#include "statistics/stats_overview.h" + +#include +#include +#include +#include + +namespace +{ +using MsimeStats::DailyRow; +using MsimeStats::HourlyRow; +using MsimeStats::Overview; + +int DayKey(int year, int month, int day) +{ + return year * 10000 + month * 100 + day; +} + +DailyRow Row(int day_key, int64_t cjk = 0, int64_t latin = 0, int64_t digit = 0, int64_t punct = 0, int64_t other = 0, + int64_t active_ms = 0) +{ + DailyRow row; + row.day_key = day_key; + row.cjk = cjk; + row.latin = latin; + row.digit = digit; + row.punct = punct; + row.other = other; + row.active_ms = active_ms; + return row; +} + +HourlyRow Hour(int day_key, int hour, int64_t chars) +{ + HourlyRow row; + row.day_key = day_key; + row.hour = hour; + row.chars = chars; + return row; +} + +std::vector Rows(std::initializer_list day_keys) +{ + std::vector rows; + rows.reserve(day_keys.size()); + for (const int day_key : day_keys) + { + rows.push_back(Row(day_key, 1)); + } + return rows; +} + +bool NearlyEqual(double left, double right) +{ + return std::abs(left - right) <= 0.001; +} + +constexpr int kToday = 20260920; +} // namespace + +TEST_CASE(stats_overview_empty_state) +{ + const Overview overview = MsimeStats::ComputeOverview({}, {}, kToday); + REQUIRE(!overview.has_data); + REQUIRE_EQ(overview.total_chars, 0); + REQUIRE_EQ(overview.days, 0); + REQUIRE_EQ(overview.current_streak, 0); + REQUIRE_EQ(overview.longest_streak, 0); + REQUIRE_EQ(overview.today_day_key, kToday); + REQUIRE_EQ(overview.today_hourly.size(), 24u); + REQUIRE_EQ(overview.average_per_day, 0.0); + REQUIRE_EQ(overview.today_speed, 0.0); + REQUIRE_EQ(overview.average_speed, 0.0); + REQUIRE_EQ(overview.fastest_speed, 0.0); +} + +TEST_CASE(stats_overview_single_day) +{ + const std::vector rows = {Row(kToday, 100, 20, 5, 10, 1, 120'000)}; + const std::vector hourly = {Hour(kToday, 9, 50), Hour(kToday, 10, 86)}; + const Overview overview = MsimeStats::ComputeOverview(rows, hourly, kToday); + + REQUIRE(overview.has_data); + REQUIRE_EQ(overview.days, 1); + REQUIRE_EQ(overview.total_chars, 136); + REQUIRE_EQ(overview.today_chars, 136); + REQUIRE_EQ(overview.today_active_ms, 120'000); + REQUIRE_EQ(overview.average_per_day, 136.0); + REQUIRE_EQ(overview.current_streak, 1); + REQUIRE_EQ(overview.longest_streak, 1); + REQUIRE_EQ(overview.best_day_key, kToday); + REQUIRE_EQ(overview.best_day_chars, 136); + // 速度分子只含 cjk + latin:120 readable chars / 2 min + REQUIRE(NearlyEqual(overview.today_speed, 60.0)); + REQUIRE(NearlyEqual(overview.average_speed, overview.today_speed)); + REQUIRE(NearlyEqual(overview.fastest_speed, 60.0)); + REQUIRE_EQ(overview.fastest_day_key, kToday); + REQUIRE_EQ(overview.categories.Total(), 136); + REQUIRE_EQ(overview.categories.cjk, 100); + REQUIRE_EQ(overview.today_hourly[9], 50); + REQUIRE_EQ(overview.today_hourly[10], 86); + REQUIRE_EQ(overview.today_hourly[11], 0); +} + +TEST_CASE(stats_overview_excludes_other_days_from_hourly) +{ + const std::vector rows = {Row(20260919, 1), Row(kToday, 2)}; + const std::vector hourly = {Hour(20260919, 9, 100), Hour(kToday, 9, 7)}; + const Overview overview = MsimeStats::ComputeOverview(rows, hourly, kToday); + REQUIRE_EQ(overview.today_hourly[9], 7); +} + +// Zero-record days are not rows, so they must not enter the average +// denominator: 10 characters over two recorded days is 5/day even when the +// calendar span is twenty days. +TEST_CASE(stats_overview_average_per_day_excludes_days_without_records) +{ + const std::vector rows = {Row(20260901, 4), Row(kToday, 6)}; + const Overview overview = MsimeStats::ComputeOverview(rows, {}, kToday); + REQUIRE_EQ(overview.average_per_day, 5.0); + REQUIRE_EQ(overview.days, 2); +} + +TEST_CASE(stats_overview_current_streak) +{ + struct Case + { + const char *name; + std::vector rows; + int want; + }; + const std::vector cases = { + {"no rows", {}, 0}, + {"today only", Rows({kToday}), 1}, + {"today and yesterday", Rows({kToday, 20260919}), 2}, + {"today missing, streak alive through yesterday", Rows({20260919, 20260918}), 2}, + {"gap before yesterday stops the streak", Rows({20260918, 20260917}), 0}, + {"gap inside the run", Rows({kToday, 20260919, 20260917}), 2}, + {"future rows do not count", Rows({20260925}), 0}, + }; + for (const Case &test_case : cases) + { + REQUIRE_EQ(MsimeStats::CurrentStreak(test_case.rows, kToday), test_case.want); + } +} + +TEST_CASE(stats_overview_longest_streak) +{ + struct Case + { + const char *name; + std::vector rows; + int want; + }; + const std::vector cases = { + {"empty", {}, 0}, + {"single", Rows({kToday}), 1}, + {"across month boundary", Rows({20260929, 20260930, 20261001}), 3}, + {"longest in the middle", Rows({20260901, 20260910, 20260911, 20260912, 20260920}), 3}, + {"duplicates do not break a run", Rows({20260910, 20260910, 20260911}), 2}, + }; + for (const Case &test_case : cases) + { + REQUIRE_EQ(MsimeStats::LongestStreak(test_case.rows), test_case.want); + } +} + +TEST_CASE(stats_overview_best_day) +{ + const std::vector rows = {Row(20260918, 10), Row(20260919, 30, 5), Row(kToday, 20)}; + const Overview overview = MsimeStats::ComputeOverview(rows, {}, kToday); + REQUIRE_EQ(overview.best_day_key, 20260919); + REQUIRE_EQ(overview.best_day_chars, 35); +} + +TEST_CASE(stats_overview_speeds) +{ + const std::vector rows = { + // 120 readable chars in 2 min = 60/min; candidate for fastest. + Row(20260918, 120, 0, 0, 0, 0, 120'000), + // 600 readable chars in 5 min = 120/min; wins. + Row(20260919, 600, 0, 0, 0, 0, 300'000), + // 50 readable chars in 30s: too little active time to compete, but + // still counted in the overall average. + Row(kToday, 50, 0, 0, 0, 0, 30'000), + }; + const Overview overview = MsimeStats::ComputeOverview(rows, {}, kToday); + + REQUIRE(NearlyEqual(overview.today_speed, 100.0)); // 50 / 0.5 min + REQUIRE(NearlyEqual(overview.average_speed, 770.0 / 7.5)); // total 7.5 active minutes + REQUIRE(NearlyEqual(overview.fastest_speed, 120.0)); + REQUIRE_EQ(overview.fastest_day_key, 20260919); +} + +// 速度分子只数可读字符(cjk + latin):标点、数字、其他加量都不提速。 +TEST_CASE(stats_overview_speed_ignores_digits_punct_and_other) +{ + const std::vector quiet = {Row(kToday, 30, 30, 0, 0, 0, 60'000)}; + const std::vector noisy = {Row(kToday, 30, 30, 500, 500, 500, 60'000)}; + const Overview quiet_overview = MsimeStats::ComputeOverview(quiet, {}, kToday); + const Overview noisy_overview = MsimeStats::ComputeOverview(noisy, {}, kToday); + + REQUIRE(NearlyEqual(noisy_overview.today_speed, 60.0)); + REQUIRE(NearlyEqual(noisy_overview.today_speed, quiet_overview.today_speed)); + REQUIRE(NearlyEqual(noisy_overview.average_speed, quiet_overview.average_speed)); + REQUIRE(NearlyEqual(noisy_overview.fastest_speed, quiet_overview.fastest_speed)); + // 卡片与最高日仍按总字符,不受速度口径影响。 + REQUIRE_EQ(noisy_overview.today_chars, 30 + 30 + 500 * 3); + REQUIRE_EQ(noisy_overview.best_day_chars, 30 + 30 + 500 * 3); + + // 中文/英文加量才让速度上升:120 readable / 1 min = 120/min。 + const std::vector more = {Row(kToday, 60, 60, 500, 500, 500, 60'000)}; + const Overview more_overview = MsimeStats::ComputeOverview(more, {}, kToday); + REQUIRE(NearlyEqual(more_overview.today_speed, 120.0)); + REQUIRE(NearlyEqual(more_overview.average_speed, 120.0)); + REQUIRE(NearlyEqual(more_overview.fastest_speed, 120.0)); +} + +// 最高日是产量指标(按总字符),与速度指标(按 cjk + latin)是两个口径。 +TEST_CASE(stats_overview_best_day_is_production_not_speed) +{ + const std::vector rows = { + // 9/18:标点多、总字多,拿下最高日。 + Row(20260918, 10, 0, 0, 200, 0, 60'000), + // 9/19:可读字符多,拿下最快日。 + Row(20260919, 100, 0, 0, 0, 0, 60'000), + }; + const Overview overview = MsimeStats::ComputeOverview(rows, {}, kToday); + + REQUIRE_EQ(overview.best_day_key, 20260918); + REQUIRE_EQ(overview.best_day_chars, 210); + REQUIRE_EQ(overview.fastest_day_key, 20260919); + REQUIRE(NearlyEqual(overview.fastest_speed, 100.0)); +} + +TEST_CASE(stats_overview_speeds_with_no_active_time) +{ + const std::vector rows = {Row(kToday, 42)}; + const Overview overview = MsimeStats::ComputeOverview(rows, {}, kToday); + REQUIRE_EQ(overview.today_speed, 0.0); + REQUIRE_EQ(overview.average_speed, 0.0); + REQUIRE_EQ(overview.fastest_speed, 0.0); + REQUIRE_EQ(overview.today_chars, 42); +} + +TEST_CASE(stats_overview_daily_is_sorted_and_copied) +{ + std::vector input = {Row(kToday, 1), Row(20260918, 2), Row(20260919, 3)}; + const Overview overview = MsimeStats::ComputeOverview(input, {}, kToday); + REQUIRE_EQ(input[0].day_key, kToday); + REQUIRE_EQ(overview.daily[0].day_key, 20260918); + REQUIRE_EQ(overview.daily[2].day_key, kToday); +} + +TEST_CASE(stats_overview_history_ends_before_today) +{ + const std::vector rows = {Row(20260901, 10), Row(20260902, 10)}; + const Overview overview = MsimeStats::ComputeOverview(rows, {}, kToday); + REQUIRE_EQ(overview.today_chars, 0); + REQUIRE_EQ(overview.current_streak, 0); + REQUIRE_EQ(overview.longest_streak, 2); +} diff --git a/server/tests/src/test_stats_store.cpp b/server/tests/src/test_stats_store.cpp new file mode 100644 index 00000000..c4e67509 --- /dev/null +++ b/server/tests/src/test_stats_store.cpp @@ -0,0 +1,727 @@ +#include "tests/includes/test_framework.h" +#include "tests/includes/test_utf8_path.h" + +#include "statistics/stats_overview.h" +#include "statistics/stats_store.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +namespace fs = std::filesystem; +using MsimeStats::Batch; +using MsimeStats::DailyRow; +using MsimeStats::DayDelta; +using MsimeStats::HourDelta; +using MsimeStats::HourlyRow; +using MsimeStats::Retention; +using MsimeStats::Store; + +// The directory name is deliberately non-ASCII: every store path crosses the +// wide/narrow boundary into SQLite, and the profile directory can contain +// Chinese on a real machine. +class ScopedTempDir +{ + public: + ScopedTempDir() + { + root_ = fs::temp_directory_path() / L"msime-统计测试-存储"; + std::error_code ec; + fs::remove_all(root_, ec); + fs::create_directories(root_, ec); + REQUIRE(!ec); + } + + ~ScopedTempDir() + { + std::error_code ec; + fs::remove_all(root_, ec); + } + + ScopedTempDir(const ScopedTempDir &) = delete; + ScopedTempDir &operator=(const ScopedTempDir &) = delete; + + const fs::path &Root() const + { + return root_; + } + + private: + fs::path root_; +}; + +std::unique_ptr OpenStore(const ScopedTempDir &temp, fs::path &db_path) +{ + db_path = temp.Root() / L"stats.db"; + std::string error; + std::unique_ptr store = Store::Open(db_path, error); + REQUIRE(store != nullptr); + return store; +} + +int DayKey(int year, int month, int day) +{ + return year * 10000 + month * 100 + day; +} + +bool HasDay(const std::vector &rows, int day_key) +{ + return std::any_of(rows.begin(), rows.end(), [day_key](const DailyRow &row) { return row.day_key == day_key; }); +} + +bool HasHour(const std::vector &rows, int day_key, int hour) +{ + return std::any_of(rows.begin(), rows.end(), + [day_key, hour](const HourlyRow &row) { return row.day_key == day_key && row.hour == hour; }); +} + +class RawDb +{ + public: + explicit RawDb(const fs::path &path) + { + if (sqlite3_open_v2(test::Utf8(path).c_str(), &db_, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) + { + if (db_ != nullptr) + { + sqlite3_close(db_); + db_ = nullptr; + } + throw std::runtime_error("Requirement failed: raw sqlite connection"); + } + } + + ~RawDb() + { + if (db_ != nullptr) + { + sqlite3_close(db_); + } + } + + RawDb(const RawDb &) = delete; + RawDb &operator=(const RawDb &) = delete; + + sqlite3 *get() const + { + return db_; + } + + private: + sqlite3 *db_ = nullptr; +}; +} // namespace + +TEST_CASE(stats_store_upsert_accumulates_and_maintains_first_day) +{ + ScopedTempDir temp; + fs::path db_path; + std::unique_ptr store = OpenStore(temp, db_path); + + std::string error; + int first = 0; + bool present = false; + REQUIRE(store->FirstDay(first, present, error)); + REQUIRE(!present); + + Batch first_batch; + first_batch.days.push_back(DayDelta{20260920, 10, 2, 0, 0, 0, 5000}); + first_batch.days.push_back(DayDelta{20260918, 1, 0, 1, 0, 0, 1000}); + first_batch.hours.push_back(HourDelta{20260920, 9, 12, 5000}); + REQUIRE(store->Upsert(first_batch, error)); + + Batch second_batch; + second_batch.days.push_back(DayDelta{20260920, 5, 0, 0, 1, 0, 2000}); + second_batch.days.push_back(DayDelta{20260901, 0, 0, 0, 0, 3, 300}); + second_batch.hours.push_back(HourDelta{20260920, 9, 6, 2000}); + second_batch.hours.push_back(HourDelta{20260920, 10, 6, 2000}); + REQUIRE(store->Upsert(second_batch, error)); + + std::vector daily; + REQUIRE(store->DailyRows(daily, error)); + REQUIRE_EQ(daily.size(), 3u); + // Rows come back ascending. + REQUIRE_EQ(daily[0].day_key, 20260901); + REQUIRE_EQ(daily[2].day_key, 20260920); + REQUIRE_EQ(daily[2].cjk, 15); + REQUIRE_EQ(daily[2].latin, 2); + REQUIRE_EQ(daily[2].punct, 1); + REQUIRE_EQ(daily[2].active_ms, 7000); + + REQUIRE(store->FirstDay(first, present, error)); + REQUIRE(present); + REQUIRE_EQ(first, 20260901); + + std::vector hourly; + REQUIRE(store->HourlyRows(20260920, hourly, error)); + REQUIRE_EQ(hourly.size(), 2u); + REQUIRE_EQ(hourly[0].hour, 9); + REQUIRE_EQ(hourly[0].chars, 18); + REQUIRE_EQ(hourly[1].hour, 10); + REQUIRE_EQ(hourly[1].chars, 6); +} + +// SQLite must not invent the parent directory: a broken DataDir has to surface as +// a failed open with a message, so the caller can tell "no statistics yet" (absent +// file) apart from "the data directory is unusable" (absent parent). +TEST_CASE(stats_store_open_without_parent_directory_fails) +{ + ScopedTempDir temp; + const fs::path missing = temp.Root() / L"absent" / L"stats.db"; + + std::string error; + std::unique_ptr store = Store::Open(missing, error); + REQUIRE(store == nullptr); + REQUIRE(!error.empty()); + REQUIRE(!fs::exists(missing)); +} + +TEST_CASE(stats_store_upsert_empty_batch_is_noop) +{ + ScopedTempDir temp; + fs::path db_path; + std::unique_ptr store = OpenStore(temp, db_path); + + std::string error; + REQUIRE(store->Upsert(Batch{}, error)); + int first = 0; + bool present = false; + REQUIRE(store->FirstDay(first, present, error)); + REQUIRE(!present); +} + +TEST_CASE(stats_store_persistence_across_reopen) +{ + ScopedTempDir temp; + const fs::path db_path = temp.Root() / L"stats.db"; + + { + std::string error; + std::unique_ptr store = Store::Open(db_path, error); + REQUIRE(store != nullptr); + Batch batch; + batch.days.push_back(DayDelta{20260920, 42, 0, 0, 0, 0, 1234}); + REQUIRE(store->Upsert(batch, error)); + } + + std::string error; + std::unique_ptr reopened = Store::Open(db_path, error); + REQUIRE(reopened != nullptr); + std::vector daily; + REQUIRE(reopened->DailyRows(daily, error)); + REQUIRE_EQ(daily.size(), 1u); + REQUIRE_EQ(daily[0].cjk, 42); + REQUIRE_EQ(daily[0].active_ms, 1234); + + std::string value; + bool present = false; + REQUIRE(reopened->Meta(MsimeStats::kStoreSchemaVersion, value, present, error)); + REQUIRE(present); + REQUIRE_EQ(value, std::string("1")); +} + +TEST_CASE(stats_store_clear_through_boundaries) +{ + const int today = DayKey(2026, 9, 20); + struct Case + { + Retention retention; + int keep_days; + }; + const std::vector cases = { + {Retention::Days30, 30}, + {Retention::Days90, 90}, + {Retention::Days180, 180}, + {Retention::Days365, 365}, + }; + + for (const Case &test_case : cases) + { + ScopedTempDir temp; + fs::path db_path; + std::unique_ptr store = OpenStore(temp, db_path); + + // One row per day across a window wider than any retention, seeded in a + // single batch to keep the test quick. + Batch seed; + seed.days.reserve(400); + seed.hours.reserve(400); + for (int index = 0; index < 400; ++index) + { + const int key = MsimeStats::AddDays(today, -index); + seed.days.push_back(DayDelta{key, 1, 0, 0, 0, 0, 1000}); + seed.hours.push_back(HourDelta{key, 12, 1, 1000}); + } + std::string error; + REQUIRE(store->Upsert(seed, error)); + + int cutoff = 0; + REQUIRE(MsimeStats::RetentionCutoff(test_case.retention, today, cutoff)); + int64_t removed = 0; + REQUIRE(store->ClearThrough(cutoff, removed, error)); + REQUIRE_EQ(removed, static_cast(400 - test_case.keep_days)); + + const int kept = MsimeStats::AddDays(today, -(test_case.keep_days - 1)); + int first = 0; + bool present = false; + REQUIRE(store->FirstDay(first, present, error)); + REQUIRE(present); + REQUIRE_EQ(first, kept); + + // The boundary day survives, the day before it does not. + std::vector daily; + REQUIRE(store->DailyRows(daily, error)); + REQUIRE(HasDay(daily, kept)); + REQUIRE(!HasDay(daily, MsimeStats::AddDays(kept, -1))); + + // Hour buckets follow the same boundary. + std::vector hourly; + REQUIRE(store->HourlyRows(kept, hourly, error)); + REQUIRE(HasHour(hourly, kept, 12)); + hourly.clear(); + REQUIRE(store->HourlyRows(MsimeStats::AddDays(kept, -1), hourly, error)); + REQUIRE(hourly.empty()); + } +} + +TEST_CASE(stats_store_clear_forever_deletes_nothing) +{ + ScopedTempDir temp; + fs::path db_path; + std::unique_ptr store = OpenStore(temp, db_path); + + const int today = DayKey(2026, 9, 20); + Batch seed; + seed.days.reserve(500); + for (int index = 0; index < 500; ++index) + { + seed.days.push_back(DayDelta{MsimeStats::AddDays(today, -index), 1, 0, 0, 0, 0, 0}); + } + std::string error; + REQUIRE(store->Upsert(seed, error)); + + int cutoff = 0; + REQUIRE(!MsimeStats::RetentionCutoff(Retention::Forever, today, cutoff)); + std::vector rows; + REQUIRE(store->DailyRows(rows, error)); + REQUIRE_EQ(rows.size(), 500u); +} + +TEST_CASE(stats_store_clear_to_empty_resets_first_day) +{ + ScopedTempDir temp; + fs::path db_path; + std::unique_ptr store = OpenStore(temp, db_path); + + const int today = DayKey(2026, 9, 20); + Batch seed; + seed.days.push_back(DayDelta{today, 5, 0, 0, 0, 0, 0}); + seed.hours.push_back(HourDelta{today, 8, 5, 0}); + std::string error; + REQUIRE(store->Upsert(seed, error)); + + int cutoff = 0; + REQUIRE(MsimeStats::RetentionCutoff(Retention::Days30, today, cutoff)); + int64_t removed = 0; + REQUIRE(store->ClearThrough(cutoff, removed, error)); + REQUIRE_EQ(removed, 0); + + REQUIRE(store->ClearThrough(MsimeStats::AddDays(today, 1), removed, error)); + REQUIRE_EQ(removed, 1); + + int first = 0; + bool present = false; + REQUIRE(store->FirstDay(first, present, error)); + REQUIRE(!present); + + std::vector rows; + REQUIRE(store->DailyRows(rows, error)); + REQUIRE(rows.empty()); + std::vector hourly; + REQUIRE(store->HourlyRows(today, hourly, error)); + REQUIRE(hourly.empty()); +} + +// The privacy contract: the on-disk schema must not be able to store text. +TEST_CASE(stats_store_schema_has_no_text_columns) +{ + ScopedTempDir temp; + fs::path db_path; + std::unique_ptr store = OpenStore(temp, db_path); + RawDb raw(db_path); + + sqlite3_stmt *statement = nullptr; + REQUIRE(sqlite3_prepare_v2(raw.get(), + "SELECT name FROM pragma_table_info('stats_daily') " + "UNION ALL SELECT name FROM pragma_table_info('stats_hourly')", + -1, &statement, nullptr) == SQLITE_OK); + const std::vector allowed = {"day_key", "hour", "cjk", "latin", "digit", + "punct", "other", "chars", "active_ms"}; + int step = SQLITE_OK; + while ((step = sqlite3_step(statement)) == SQLITE_ROW) + { + const char *name = reinterpret_cast(sqlite3_column_text(statement, 0)); + REQUIRE(name != nullptr); + REQUIRE(std::find(allowed.begin(), allowed.end(), std::string(name)) != allowed.end()); + } + REQUIRE_EQ(step, SQLITE_DONE); + sqlite3_finalize(statement); + + // WAL is a database property, so a second connection can assert it too. + statement = nullptr; + REQUIRE(sqlite3_prepare_v2(raw.get(), "PRAGMA journal_mode", -1, &statement, nullptr) == SQLITE_OK); + REQUIRE_EQ(sqlite3_step(statement), SQLITE_ROW); + const char *mode = reinterpret_cast(sqlite3_column_text(statement, 0)); + REQUIRE_EQ(std::string(mode != nullptr ? mode : ""), std::string("wal")); + sqlite3_finalize(statement); +} + +// busy_timeout must make a write wait for a foreign writer instead of failing +// immediately; with WAL both processes can otherwise work concurrently. +TEST_CASE(stats_store_waits_for_a_cross_process_writer) +{ + ScopedTempDir temp; + fs::path db_path; + std::unique_ptr store = OpenStore(temp, db_path); + + sqlite3 *blocker = nullptr; + REQUIRE(sqlite3_open_v2(test::Utf8(db_path).c_str(), &blocker, SQLITE_OPEN_READWRITE, nullptr) == SQLITE_OK); + REQUIRE(sqlite3_exec(blocker, "BEGIN IMMEDIATE", nullptr, nullptr, nullptr) == SQLITE_OK); + + std::atomic started{false}; + bool upsert_ok = false; + std::string upsert_error; + int64_t waited_ms = 0; + std::thread writer([&]() { + started.store(true); + const auto begin = std::chrono::steady_clock::now(); + Batch batch; + batch.days.push_back(DayDelta{DayKey(2026, 9, 20), 1, 0, 0, 0, 0, 0}); + upsert_ok = store->Upsert(batch, upsert_error); + waited_ms = + std::chrono::duration_cast(std::chrono::steady_clock::now() - begin).count(); + }); + while (!started.load()) + { + std::this_thread::yield(); + } + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + REQUIRE(sqlite3_exec(blocker, "COMMIT", nullptr, nullptr, nullptr) == SQLITE_OK); + writer.join(); + + REQUIRE(upsert_ok); + REQUIRE(waited_ms >= 200); + sqlite3_close(blocker); + + std::vector rows; + std::string error; + REQUIRE(store->DailyRows(rows, error)); + REQUIRE_EQ(rows.size(), 1u); +} + +TEST_CASE(stats_store_overview_reads_through_queries) +{ + ScopedTempDir temp; + fs::path db_path; + std::unique_ptr store = OpenStore(temp, db_path); + + const int today = DayKey(2026, 9, 20); + Batch batch; + batch.days.push_back(DayDelta{20260919, 10, 0, 0, 0, 0, 60'000}); + batch.days.push_back(DayDelta{today, 20, 5, 0, 0, 0, 120'000}); + batch.hours.push_back(HourDelta{today, 14, 25, 120'000}); + std::string error; + REQUIRE(store->Upsert(batch, error)); + + std::vector daily; + std::vector hourly; + REQUIRE(store->DailyRows(daily, error)); + REQUIRE(store->HourlyRows(today, hourly, error)); + const MsimeStats::Overview overview = MsimeStats::ComputeOverview(daily, hourly, today); + REQUIRE(overview.has_data); + REQUIRE_EQ(overview.days, 2); + REQUIRE_EQ(overview.total_chars, 35); + REQUIRE_EQ(overview.today_chars, 25); + REQUIRE_EQ(overview.current_streak, 2); + REQUIRE_EQ(overview.today_hourly[14], 25); +} + +TEST_CASE(stats_store_parse_retention) +{ + Retention retention = Retention::Forever; + REQUIRE(MsimeStats::ParseRetention("30d", retention)); + REQUIRE(retention == Retention::Days30); + REQUIRE(MsimeStats::ParseRetention("90d", retention)); + REQUIRE(retention == Retention::Days90); + REQUIRE(MsimeStats::ParseRetention("180d", retention)); + REQUIRE(retention == Retention::Days180); + REQUIRE(MsimeStats::ParseRetention("365d", retention)); + REQUIRE(retention == Retention::Days365); + REQUIRE(MsimeStats::ParseRetention("forever", retention)); + REQUIRE(retention == Retention::Forever); + REQUIRE(!MsimeStats::ParseRetention("2d", retention)); + REQUIRE(!MsimeStats::ParseRetention("", retention)); +} + +// The retention marker is the whole idempotence mechanism: the first call of a +// day runs the policy, every later call that day only reads the marker, and a +// new day runs it again. No timer is involved. +TEST_CASE(stats_store_retention_runs_once_per_local_day) +{ + ScopedTempDir temp; + fs::path db_path; + std::unique_ptr store = OpenStore(temp, db_path); + + const int today = DayKey(2026, 9, 20); + Batch seed; + seed.days.reserve(400); + for (int index = 0; index < 400; ++index) + { + seed.days.push_back(DayDelta{MsimeStats::AddDays(today, -index), 1, 0, 0, 0, 0, 1000}); + } + std::string error; + REQUIRE(store->Upsert(seed, error)); + // Yesterday's marker: today has not run yet. + REQUIRE( + store->SetMeta(MsimeStats::kStoreMetaLastRetentionDay, std::to_string(MsimeStats::AddDays(today, -1)), error)); + + MsimeStats::RetentionOutcome outcome; + REQUIRE(MsimeStats::RunDailyRetention(*store, Retention::Days30, today, outcome, error)); + REQUIRE(!outcome.already_ran_today); + REQUIRE_EQ(outcome.removed_days, static_cast(400 - 30)); + + std::string marker; + bool present = false; + REQUIRE(store->Meta(MsimeStats::kStoreMetaLastRetentionDay, marker, present, error)); + REQUIRE(present); + REQUIRE_EQ(marker, std::to_string(today)); + + // A second call on the same day must not delete anything - not even a row + // that just arrived with an expired day key. + Batch late; + late.days.push_back(DayDelta{MsimeStats::AddDays(today, -200), 1, 0, 0, 0, 0, 1000}); + REQUIRE(store->Upsert(late, error)); + REQUIRE(MsimeStats::RunDailyRetention(*store, Retention::Days30, today, outcome, error)); + REQUIRE(outcome.already_ran_today); + REQUIRE_EQ(outcome.removed_days, 0); + std::vector rows; + REQUIRE(store->DailyRows(rows, error)); + REQUIRE(HasDay(rows, MsimeStats::AddDays(today, -200))); + + // The next local day runs the policy again and clears the late row. + const int tomorrow = MsimeStats::AddDays(today, 1); + REQUIRE(MsimeStats::RunDailyRetention(*store, Retention::Days30, tomorrow, outcome, error)); + REQUIRE(!outcome.already_ran_today); + rows.clear(); + REQUIRE(store->DailyRows(rows, error)); + REQUIRE(!HasDay(rows, MsimeStats::AddDays(today, -200))); +} + +// Forever is not "skip the day": it still records the marker, so a later +// switch to a finite window starts cleaning from the next write. +TEST_CASE(stats_store_retention_forever_records_day_without_deleting) +{ + ScopedTempDir temp; + fs::path db_path; + std::unique_ptr store = OpenStore(temp, db_path); + + const int today = DayKey(2026, 9, 20); + Batch seed; + seed.days.reserve(500); + for (int index = 0; index < 500; ++index) + { + seed.days.push_back(DayDelta{MsimeStats::AddDays(today, -index), 1, 0, 0, 0, 0, 0}); + } + std::string error; + REQUIRE(store->Upsert(seed, error)); + + MsimeStats::RetentionOutcome outcome; + REQUIRE(MsimeStats::RunDailyRetention(*store, Retention::Forever, today, outcome, error)); + REQUIRE(!outcome.already_ran_today); + REQUIRE_EQ(outcome.removed_days, 0); + + std::string marker; + bool present = false; + REQUIRE(store->Meta(MsimeStats::kStoreMetaLastRetentionDay, marker, present, error)); + REQUIRE(present); + REQUIRE_EQ(marker, std::to_string(today)); + + std::vector rows; + REQUIRE(store->DailyRows(rows, error)); + REQUIRE_EQ(rows.size(), 500u); + + REQUIRE(MsimeStats::RunDailyRetention(*store, Retention::Forever, today, outcome, error)); + REQUIRE(outcome.already_ran_today); + REQUIRE_EQ(outcome.removed_days, 0); +} + +// The window includes today and the days back to today-(days-1); the first day +// outside it is removed, together with its hour buckets and the first_day key. +TEST_CASE(stats_store_retention_boundary_keeps_today_and_window_start) +{ + ScopedTempDir temp; + fs::path db_path; + std::unique_ptr store = OpenStore(temp, db_path); + + const int today = DayKey(2026, 9, 20); + const int window_start = MsimeStats::AddDays(today, -29); + const int expired = MsimeStats::AddDays(today, -30); + Batch seed; + seed.days.push_back(DayDelta{expired, 1, 0, 0, 0, 0, 0}); + seed.days.push_back(DayDelta{window_start, 1, 0, 0, 0, 0, 0}); + seed.days.push_back(DayDelta{today, 1, 0, 0, 0, 0, 0}); + seed.hours.push_back(HourDelta{expired, 9, 1, 0}); + seed.hours.push_back(HourDelta{window_start, 9, 1, 0}); + std::string error; + REQUIRE(store->Upsert(seed, error)); + + MsimeStats::RetentionOutcome outcome; + REQUIRE(MsimeStats::RunDailyRetention(*store, Retention::Days30, today, outcome, error)); + REQUIRE_EQ(outcome.removed_days, 1); + + std::vector rows; + REQUIRE(store->DailyRows(rows, error)); + REQUIRE(HasDay(rows, window_start)); + REQUIRE(HasDay(rows, today)); + REQUIRE(!HasDay(rows, expired)); + + std::vector hourly; + REQUIRE(store->HourlyRows(window_start, hourly, error)); + REQUIRE(HasHour(hourly, window_start, 9)); + hourly.clear(); + REQUIRE(store->HourlyRows(expired, hourly, error)); + REQUIRE(hourly.empty()); + + int first = 0; + bool present = false; + REQUIRE(store->FirstDay(first, present, error)); + REQUIRE(present); + REQUIRE_EQ(first, window_start); +} + +// A failed cleanup must not advance the marker, otherwise the stale rows would +// survive until the next calendar day before the retry. +TEST_CASE(stats_store_retention_failure_keeps_marker_stale) +{ + ScopedTempDir temp; + fs::path db_path; + std::unique_ptr store = OpenStore(temp, db_path); + + const int today = DayKey(2026, 9, 20); + const int yesterday = MsimeStats::AddDays(today, -1); + Batch seed; + seed.days.push_back(DayDelta{MsimeStats::AddDays(today, -100), 1, 0, 0, 0, 0, 0}); + seed.days.push_back(DayDelta{today, 1, 0, 0, 0, 0, 0}); + std::string error; + REQUIRE(store->Upsert(seed, error)); + REQUIRE(store->SetMeta(MsimeStats::kStoreMetaLastRetentionDay, std::to_string(yesterday), error)); + + // Break the table the cleanup deletes from: ClearThrough fails and the + // marker must keep yesterday's value, so the next write retries. + { + RawDb raw(db_path); + REQUIRE(sqlite3_exec(raw.get(), "DROP TABLE stats_daily", nullptr, nullptr, nullptr) == SQLITE_OK); + } + + MsimeStats::RetentionOutcome outcome; + REQUIRE(!MsimeStats::RunDailyRetention(*store, Retention::Days30, today, outcome, error)); + REQUIRE(!error.empty()); + + std::string marker; + bool present = false; + REQUIRE(store->Meta(MsimeStats::kStoreMetaLastRetentionDay, marker, present, error)); + REQUIRE(present); + REQUIRE_EQ(marker, std::to_string(yesterday)); +} + +// The explicit "clear all statistics" action wipes both tables and both meta +// keys, so the overview falls back to the empty state. +TEST_CASE(stats_store_clear_all_resets_rows_and_meta) +{ + ScopedTempDir temp; + fs::path db_path; + std::unique_ptr store = OpenStore(temp, db_path); + + const int today = DayKey(2026, 9, 20); + Batch seed; + seed.days.push_back(DayDelta{MsimeStats::AddDays(today, -2), 1, 0, 0, 0, 0, 0}); + seed.days.push_back(DayDelta{MsimeStats::AddDays(today, -1), 1, 0, 0, 0, 0, 0}); + seed.days.push_back(DayDelta{today, 1, 0, 0, 0, 0, 0}); + seed.hours.push_back(HourDelta{today, 8, 1, 0}); + std::string error; + REQUIRE(store->Upsert(seed, error)); + REQUIRE(store->SetMeta(MsimeStats::kStoreMetaLastRetentionDay, std::to_string(today), error)); + + int64_t removed = 0; + REQUIRE(store->ClearAll(removed, error)); + REQUIRE_EQ(removed, 3); + + std::vector daily; + REQUIRE(store->DailyRows(daily, error)); + REQUIRE(daily.empty()); + std::vector hourly; + REQUIRE(store->HourlyRows(today, hourly, error)); + REQUIRE(hourly.empty()); + + int first = 0; + bool present = false; + REQUIRE(store->FirstDay(first, present, error)); + REQUIRE(!present); + std::string marker; + REQUIRE(store->Meta(MsimeStats::kStoreMetaLastRetentionDay, marker, present, error)); + REQUIRE(!present); + // schema_version is written on open and is not user data; it must survive. + REQUIRE(store->Meta(MsimeStats::kStoreSchemaVersion, marker, present, error)); + REQUIRE(present); + + // Clearing an already empty database is a success that removed nothing. + REQUIRE(store->ClearAll(removed, error)); + REQUIRE_EQ(removed, 0); +} + +// A marker corrupted by a hand edit must not stall the policy forever: a value +// that is not a positive decimal day key is treated as "never ran", so the +// cleanup runs once and rewrites a valid key. +TEST_CASE(stats_store_retention_malformed_marker_self_repairs) +{ + ScopedTempDir temp; + fs::path db_path; + std::unique_ptr store = OpenStore(temp, db_path); + + const int today = DayKey(2026, 9, 20); + Batch seed; + seed.days.push_back(DayDelta{MsimeStats::AddDays(today, -100), 1, 0, 0, 0, 0, 0}); + seed.days.push_back(DayDelta{today, 1, 0, 0, 0, 0, 0}); + std::string error; + REQUIRE(store->Upsert(seed, error)); + + MsimeStats::RetentionOutcome outcome; + std::string marker; + bool present = false; + for (const char *bad : {"not-a-day", "-5", "20260920x", ""}) + { + REQUIRE(store->SetMeta(MsimeStats::kStoreMetaLastRetentionDay, bad, error)); + REQUIRE(MsimeStats::RunDailyRetention(*store, Retention::Days30, today, outcome, error)); + REQUIRE(!outcome.already_ran_today); + REQUIRE(store->Meta(MsimeStats::kStoreMetaLastRetentionDay, marker, present, error)); + REQUIRE(present); + REQUIRE_EQ(marker, std::to_string(today)); + } + + // The first repair run applied the policy; the day outside the window is gone. + std::vector rows; + REQUIRE(store->DailyRows(rows, error)); + REQUIRE(!HasDay(rows, MsimeStats::AddDays(today, -100))); + REQUIRE(HasDay(rows, today)); +} diff --git a/ui-html/webview2/settings/ime-settings/index.html b/ui-html/webview2/settings/ime-settings/index.html index d3ca21f7..fb63896c 100644 --- a/ui-html/webview2/settings/ime-settings/index.html +++ b/ui-html/webview2/settings/ime-settings/index.html @@ -51,6 +51,7 @@
+
diff --git a/ui-html/webview2/settings/ime-settings/public/assets/sidebar/stats.svg b/ui-html/webview2/settings/ime-settings/public/assets/sidebar/stats.svg new file mode 100644 index 00000000..92dcb3a4 --- /dev/null +++ b/ui-html/webview2/settings/ime-settings/public/assets/sidebar/stats.svg @@ -0,0 +1,3 @@ + + + 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 0db0620f..c5e8c7f4 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 @@ -330,6 +330,17 @@ function applyConfigData(data: Record, target?: string): void { ); }); } + if (applies('stats')) { + const enabled = typeof data?.statistics?.enabled === 'boolean' ? (data.statistics.enabled as boolean) : undefined; + const retention = typeof data?.statistics?.retention === 'string' ? (data.statistics.retention as string) : undefined; + if (enabled !== undefined || retention !== undefined) { + void import('./stats').then((module) => { + if (data !== lastSnapshot) return; + if (enabled !== undefined) module.applyStatisticsEnabled(enabled); + if (retention !== undefined) module.applyStatisticsRetention(retention); + }); + } + } if (applies('shortcut')) { void import('./shortcut').then((module) => { if (data === lastSnapshot) module.applyShortcutConfig(data?.keybindings); }); } diff --git a/ui-html/webview2/settings/ime-settings/src/modules/sidebar.ts b/ui-html/webview2/settings/ime-settings/src/modules/sidebar.ts index 9495fde2..616c9e28 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/sidebar.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/sidebar.ts @@ -55,6 +55,10 @@ const setupLoaders: Record Promise> = { 'floating-toolbar': async () => { (await import('./floating-toolbar')).setupFloatingToolbar(); }, + // 统计模块按需加载:查询只在用户打开该页时发起,不进 BACKGROUND_MODULES。 + stats: async () => { + (await import('./stats')).setupStats(); + }, 'about-settings': async () => { (await import('./about-settings')).setupAboutSettings(); }, diff --git a/ui-html/webview2/settings/ime-settings/src/modules/stats.test.ts b/ui-html/webview2/settings/ime-settings/src/modules/stats.test.ts new file mode 100644 index 00000000..a955d10f --- /dev/null +++ b/ui-html/webview2/settings/ime-settings/src/modules/stats.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; +import { + addDays, + calendarLevel, + calendarStart, + computeCalendarCellSize, + computeCalendarWeeks, + dateToDayKey, + dayKeyToDate, + detailRows, + formatDayKey, + formatDuration, + formatNumber, + formatSpeed, + pad2, + readableOf, + retentionValueOf, + RETENTION_VALUES, + speedOf, + totalOf +} from './stats'; +import type { StatsDailyRow } from './stats'; + +function row(dayKey: number, cjk: number, activeMs = 0): StatsDailyRow { + return { dayKey, cjk, latin: 0, digit: 0, punct: 0, other: 0, activeMs }; +} + +describe('日期与格式化', () => { + it('pad2 补零', () => { + expect(pad2(0)).toBe('00'); + expect(pad2(7)).toBe('07'); + expect(pad2(12)).toBe('12'); + }); + + it('formatDayKey 输出 YYYY-MM-DD,0 显示占位符', () => { + expect(formatDayKey(20250105)).toBe('2025-01-05'); + expect(formatDayKey(19991231)).toBe('1999-12-31'); + expect(formatDayKey(0)).toBe('-'); + }); + + it('dayKey 与 Date 往返无损', () => { + expect(dayKeyToDate(20250105).getTime()).toBe(new Date(2025, 0, 5).getTime()); + expect(dateToDayKey(new Date(2025, 0, 5))).toBe(20250105); + expect(dateToDayKey(dayKeyToDate(20241231))).toBe(20241231); + }); + + it('addDays 跨月跨界', () => { + expect(dateToDayKey(addDays(new Date(2025, 0, 31), 1))).toBe(20250201); + expect(dateToDayKey(addDays(new Date(2025, 2, 1), -1))).toBe(20250228); + }); + + it('totalOf 汇总五类字符', () => { + const full: StatsDailyRow = { dayKey: 20250101, cjk: 1, latin: 2, digit: 3, punct: 4, other: 5, activeMs: 0 }; + expect(totalOf(full)).toBe(15); + expect(totalOf(row(20250101, 7))).toBe(7); + }); + + it('readableOf 只数中文与英文', () => { + const full: StatsDailyRow = { dayKey: 20250101, cjk: 1, latin: 2, digit: 3, punct: 4, other: 5, activeMs: 0 }; + expect(readableOf(full)).toBe(3); + expect(readableOf(row(20250101, 7))).toBe(7); + }); + + it('speedOf 只按中文+英文计算,标点/数字不影响速度', () => { + const quiet: StatsDailyRow = { dayKey: 20250101, cjk: 30, latin: 30, digit: 0, punct: 0, other: 0, activeMs: 60_000 }; + const noisy: StatsDailyRow = { + dayKey: 20250101, + cjk: 30, + latin: 30, + digit: 500, + punct: 500, + other: 500, + activeMs: 60_000 + }; + expect(speedOf(quiet)).toBe(60); + expect(speedOf(noisy)).toBe(60); + expect(speedOf({ ...quiet, cjk: 60, latin: 60 })).toBe(120); + expect(speedOf({ ...quiet, activeMs: 0 })).toBe(0); + }); + + it('formatNumber 千分位', () => { + expect(formatNumber(0)).toBe('0'); + expect(formatNumber(1234)).toBe('1,234'); + expect(formatNumber(12.6)).toBe('13'); + }); + + it('formatDuration 分档', () => { + expect(formatDuration(0)).toBe('0 分钟'); + expect(formatDuration(30_000)).toBe('不足 1 分钟'); + expect(formatDuration(5 * 60_000)).toBe('5.0 分钟'); + expect(formatDuration(45 * 60_000)).toBe('45 分钟'); + expect(formatDuration(90 * 60_000)).toBe('1 小时 30 分'); + }); + + it('formatSpeed 取整并带单位', () => { + expect(formatSpeed(0)).toBe('0 字/分'); + expect(formatSpeed(61.6)).toBe('62 字/分'); + }); +}); + +describe('热力图分档', () => { + it('无记录或历史最高为 0 时是 0 档', () => { + expect(calendarLevel(0, 100)).toBe(0); + expect(calendarLevel(10, 0)).toBe(0); + }); + + it('按占最高日比例分五档,边界取低档', () => { + expect(calendarLevel(1, 100)).toBe(1); + expect(calendarLevel(25, 100)).toBe(1); + expect(calendarLevel(26, 100)).toBe(2); + expect(calendarLevel(50, 100)).toBe(2); + expect(calendarLevel(51, 100)).toBe(3); + expect(calendarLevel(75, 100)).toBe(3); + expect(calendarLevel(76, 100)).toBe(4); + expect(calendarLevel(100, 100)).toBe(4); + expect(calendarLevel(200, 100)).toBe(4); + }); +}); + +describe('热力图窗口', () => { + it('宽度不足时保底 53 周(约 12 个月)', () => { + expect(computeCalendarWeeks(0)).toBe(53); + expect(computeCalendarWeeks(400)).toBe(53); + expect(computeCalendarWeeks(53 * 15)).toBe(53); + }); + + it('更宽的容器容纳更多周,且有上限', () => { + expect(computeCalendarWeeks(54 * 15)).toBe(54); + expect(computeCalendarWeeks(100 * 15)).toBe(100); + expect(computeCalendarWeeks(100_000)).toBe(200); + }); + + it('窄容器自动缩小格子以容纳 53 周', () => { + expect(computeCalendarCellSize(612, 53)).toBe(9); + expect(computeCalendarCellSize(795, 53)).toBe(13); + expect(computeCalendarCellSize(1600, 106)).toBe(13); + expect(computeCalendarCellSize(0, 53)).toBe(9); + expect(computeCalendarCellSize(300, 53)).toBe(9); + }); + + it('起点向前对齐到周一且覆盖约 53 周', () => { + const today = new Date(2025, 0, 15); // 周三 + const start = calendarStart(today, 53); + expect(start.getDay()).toBe(1); + const coveredDays = Math.round((today.getTime() - start.getTime()) / 86_400_000); + expect(coveredDays).toBeGreaterThanOrEqual(53 * 7 - 7); + expect(coveredDays).toBeLessThanOrEqual(53 * 7 + 5); + }); + + it('起点不晚于覆盖窗口起点,并对齐到周一', () => { + const today = new Date(2025, 0, 15); // 周三 + const start = calendarStart(today, 1); // today-6 天是周四,回退到周一 + expect(start.getDay()).toBe(1); + expect(dateToDayKey(start)).toBe(20250106); + }); + + it('渲染列数最多比 weeks 多一列,格子必须按 weeks+1 计算', () => { + const base = new Date(2025, 0, 12); // 周日 + for (let offset = 0; offset < 7; offset++) { + const today = addDays(base, offset); + const start = calendarStart(today, 53); + const columns = Math.floor(Math.round((today.getTime() - start.getTime()) / 86_400_000) / 7) + 1; + expect(columns).toBeGreaterThanOrEqual(53); + expect(columns).toBeLessThanOrEqual(54); + } + }); +}); + +describe('按日明细', () => { + const daily = Array.from({ length: 40 }, (_, index) => row(20250101 + index, index)); + + it('只取最近 30 天并按日期倒序', () => { + const rows = detailRows(daily, 30); + expect(rows).toHaveLength(30); + expect(rows[0].dayKey).toBe(daily[daily.length - 1].dayKey); + expect(rows[rows.length - 1].dayKey).toBe(daily[daily.length - 30].dayKey); + }); + + it('记录不足 30 天时全部返回', () => { + const rows = detailRows(daily.slice(0, 3), 30); + expect(rows.map((item) => item.dayKey)).toEqual([daily[2].dayKey, daily[1].dayKey, daily[0].dayKey]); + }); +}); + +describe('保留策略回填', () => { + it('契约枚举内的值原样返回', () => { + for (const value of RETENTION_VALUES) { + expect(retentionValueOf(value)).toBe(value); + } + }); + + it('未知值返回 null,不覆盖下拉', () => { + expect(retentionValueOf('2d')).toBeNull(); + expect(retentionValueOf('')).toBeNull(); + expect(retentionValueOf('FOREVER')).toBeNull(); + expect(retentionValueOf('30')).toBeNull(); + }); +}); diff --git a/ui-html/webview2/settings/ime-settings/src/modules/stats.ts b/ui-html/webview2/settings/ime-settings/src/modules/stats.ts new file mode 100644 index 00000000..eefc0a9e --- /dev/null +++ b/ui-html/webview2/settings/ime-settings/src/modules/stats.ts @@ -0,0 +1,587 @@ +import { onHostMessage } from '../utils/host-messages'; +import { serializeHostMessage } from '../../../../shared/messages'; +import type { ServerMessage, SettingsMessage } from '../../../../shared/messages'; +import { applyToggleState, setupToggleButton } from './shared'; +import { updateConfig } from './config-sync'; + +type StatsRequest = Extract['data']; +type StatsResponse = Extract; +type StatsOverview = NonNullable; +export type StatsDailyRow = StatsOverview['daily'][number]; + +export type RetentionValue = 'forever' | '30d' | '90d' | '180d' | '365d'; + +export const RETENTION_VALUES: readonly RetentionValue[] = ['forever', '30d', '90d', '180d', '365d']; +const STATISTICS_ENABLED_PATH = 'statistics.enabled'; +const STATISTICS_RETENTION_PATH = 'statistics.retention'; + +/** 热力图列宽必须与 stats.css 里的默认值保持一致,否则自适应列数会算错。 */ +const CALENDAR_CELL_PX = 13; +const CALENDAR_GAP_PX = 2; +const CALENDAR_COLUMN_PX = CALENDAR_CELL_PX + CALENDAR_GAP_PX; +/** 窄容器放不下 53 周时格子最多缩到 9px,保证默认窗口下不出现横向滚动。 */ +const CALENDAR_MIN_CELL_PX = 9; +/** 热力图至少覆盖近 12 个月。 */ +const CALENDAR_MIN_WEEKS = 53; +/** 超宽窗口也不渲染数年空网格。 */ +const CALENDAR_MAX_WEEKS = 200; +/** 按日明细只展示最近 30 天(overview.daily 是全量历史)。 */ +const DETAIL_DAYS = 30; +const STATUS_TIMEOUT_MS = 4000; + +let statisticsEnabled = false; +let lastOverview: StatsOverview | null = null; +let calendarWeeks = CALENDAR_MIN_WEEKS; +let calendarCellPx = CALENDAR_CELL_PX; +let calendarObserver: ResizeObserver | null = null; +let requestCounter = 0; +let statusTimer: number | null = null; +const pendingRequests = new Map(); + +// ------------------------------------------------------------------ 纯函数(单测覆盖) + +export function pad2(value: number): string { + return value < 10 ? `0${value}` : String(value); +} + +export function formatDayKey(dayKey: number): string { + if (!dayKey) { + return '-'; + } + const year = Math.floor(dayKey / 10000); + const month = Math.floor(dayKey / 100) % 100; + const day = dayKey % 100; + return `${year}-${pad2(month)}-${pad2(day)}`; +} + +export function dayKeyToDate(dayKey: number): Date { + const year = Math.floor(dayKey / 10000); + const month = Math.floor(dayKey / 100) % 100; + const day = dayKey % 100; + return new Date(year, month - 1, day); +} + +export function dateToDayKey(date: Date): number { + return date.getFullYear() * 10000 + (date.getMonth() + 1) * 100 + date.getDate(); +} + +export function addDays(date: Date, days: number): Date { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +} + +export function totalOf(row: StatsDailyRow): number { + return row.cjk + row.latin + row.digit + row.punct + row.other; +} + +/** + * 速度分子只数可读字符(中文 + 英文);数字、标点和其他不计入, + * 与 server/src/statistics/stats_types.h 的 DailyRow::SpeedChars 同口径。 + */ +export function readableOf(row: StatsDailyRow): number { + return row.cjk + row.latin; +} + +/** 按日速度:可读字符数 ÷ 活跃分钟。 */ +export function speedOf(row: StatsDailyRow): number { + return row.activeMs > 0 ? (readableOf(row) / row.activeMs) * 60000 : 0; +} + +export function formatNumber(value: number): string { + return Math.round(value).toLocaleString('zh-CN'); +} + +export function formatDuration(ms: number): string { + if (!ms || ms <= 0) { + return '0 分钟'; + } + const minutes = ms / 60000; + if (minutes < 1) { + return '不足 1 分钟'; + } + if (minutes < 60) { + return `${minutes < 10 ? minutes.toFixed(1) : Math.round(minutes)} 分钟`; + } + const hours = Math.floor(minutes / 60); + const rest = Math.round(minutes % 60); + return `${hours} 小时 ${rest} 分`; +} + +export function formatSpeed(charsPerMinute: number): string { + // 单位按可读字符(中+英)计,标点/数字不计入速度。 + return `${Math.round(charsPerMinute)} 字/分`; +} + +/** 五级色阶:0 = 无记录,1-4 按当日字数占历史最高日的比例分档。 */ +export function calendarLevel(chars: number, maxChars: number): number { + if (chars <= 0 || maxChars <= 0) { + return 0; + } + const ratio = chars / maxChars; + if (ratio <= 0.25) { + return 1; + } + if (ratio <= 0.5) { + return 2; + } + if (ratio <= 0.75) { + return 3; + } + return 4; +} + +/** 按容器宽度算出热力图能容纳的周数(保底 12 个月,超宽窗口有上限)。 */ +export function computeCalendarWeeks(containerWidth: number): number { + const weeks = Math.floor((containerWidth + CALENDAR_GAP_PX) / CALENDAR_COLUMN_PX); + return Math.max(CALENDAR_MIN_WEEKS, Math.min(CALENDAR_MAX_WEEKS, weeks)); +} + +/** 容器放不下 weeks 列时按比例缩小格子;宽裕时用满 13px,不再放大。 */ +export function computeCalendarCellSize(containerWidth: number, weeks: number): number { + const available = containerWidth - CALENDAR_GAP_PX * (weeks - 1); + const cell = Math.floor(available / weeks); + return Math.max(CALENDAR_MIN_CELL_PX, Math.min(CALENDAR_CELL_PX, cell)); +} + +/** 热力图起点:覆盖 weeks 周并向前对齐到周一,保证每列恰好一周。 */ +export function calendarStart(today: Date, weeks: number): Date { + const start = addDays(today, -(weeks * 7 - 1)); + return addDays(start, -((start.getDay() + 6) % 7)); +} + +/** 明细列表:取最近 days 天并按日期倒序(新的在上)。 */ +export function detailRows(daily: StatsDailyRow[], days: number): StatsDailyRow[] { + return daily.slice(-days).reverse(); +} + +/** 配置回填的守卫:只接受契约枚举内的值,其余一律丢弃。 */ +export function retentionValueOf(value: string): RetentionValue | null { + return (RETENTION_VALUES as readonly string[]).includes(value) ? (value as RetentionValue) : null; +} + +// ------------------------------------------------------------------ DOM 工具 + +function byId(id: string): T | null { + return document.getElementById(id) as T | null; +} + +function setHidden(id: string, hidden: boolean): void { + const element = byId(id); + if (element) { + element.hidden = hidden; + } +} + +function el( + tag: K, + className?: string, + text?: string +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) { + node.className = className; + } + if (text !== undefined) { + node.textContent = text; + } + return node; +} + +// ------------------------------------------------------------------ 渲染 + +function renderEmptyState(): void { + const hasData = lastOverview?.hasData === true; + setHidden('statsNoDataEmpty', !(statisticsEnabled && !hasData)); + setHidden('statsDisabledEmpty', statisticsEnabled); + const disabledText = byId('statsDisabledText'); + if (disabledText) { + disabledText.textContent = hasData + ? '统计当前已关闭,历史数据仍保留在下方;重新开启后继续记录。' + : '开启后这里会显示输入字数、速度与时段分布。统计只保存在本机,不记录输入内容,也不联网。'; + } +} + +function renderOverview(overview: StatsOverview): void { + lastOverview = overview; + renderEmptyState(); + const hasData = overview.hasData; + setHidden('statsContent', !hasData); + if (!hasData) { + return; + } + renderCards(overview); + renderSpeed(overview); + renderCalendar(overview); + renderHourly(overview); + renderCategories(overview); + renderDetails(overview); +} + +function statCard(title: string, value: string, unit: string, note: string): HTMLElement { + const node = el('div', 'stats-card'); + const valueRow = el('div', 'stats-card-value'); + valueRow.append(el('strong', undefined, value), el('span', 'stats-card-unit', unit)); + node.append(el('span', 'stats-card-title', title), valueRow, el('span', 'stats-card-note', note)); + return node; +} + +function renderCards(overview: StatsOverview): void { + const container = byId('statsCards'); + if (!container) { + return; + } + container.replaceChildren( + statCard('今日输入', formatNumber(overview.todayChars), '字', `活跃 ${formatDuration(overview.todayActiveMs)}`), + statCard('累计输入', formatNumber(overview.totalChars), '字', `共记录 ${overview.days} 天`), + statCard('日均输入', formatNumber(overview.averagePerDay), '字', `自 ${formatDayKey(overview.firstDayKey)}`), + statCard('连续输入', formatNumber(overview.currentStreak), '天', `历史最长 ${overview.longestStreak} 天`) + ); +} + +function renderSpeed(overview: StatsOverview): void { + const grid = byId('statsSpeedGrid'); + if (grid) { + const entries: Array<[string, number, string]> = [ + ['今日速度', overview.todaySpeed, formatDuration(overview.todayActiveMs)], + ['平均速度', overview.averageSpeed, formatDuration(overview.totalActiveMs)], + ['最快速度', overview.fastestSpeed, overview.fastestDayKey ? formatDayKey(overview.fastestDayKey) : '暂无'] + ]; + const fragment = document.createDocumentFragment(); + for (const [label, speed, note] of entries) { + const item = el('div', 'stats-speed-item'); + item.append( + el('span', 'stats-speed-label', label), + el('strong', 'stats-speed-value', formatSpeed(speed)), + el('span', 'stats-speed-note', note) + ); + fragment.append(item); + } + grid.replaceChildren(fragment); + } + const best = byId('statsBestDay'); + if (best) { + best.textContent = overview.bestDayKey + ? `最高日:${formatDayKey(overview.bestDayKey)} · ${formatNumber(overview.bestDayChars)} 字` + : '最高日:暂无记录'; + } +} + +function renderCalendar(overview: StatsOverview): void { + const months = byId('statsCalendarMonths'); + const grid = byId('statsCalendarGrid'); + if (!months || !grid) { + return; + } + + const daily = new Map(); + let maxChars = 1; + for (const row of overview.daily ?? []) { + daily.set(row.dayKey, row); + maxChars = Math.max(maxChars, totalOf(row)); + } + + // 固定窗口:不按首条记录收缩,区间外没有数据时格子自然显示为空。 + const today = dayKeyToDate(overview.todayDayKey); + const monthFragment = document.createDocumentFragment(); + const gridFragment = document.createDocumentFragment(); + let weekStart = calendarStart(today, calendarWeeks); + while (weekStart.getTime() <= today.getTime()) { + // 只在列内出现某月 1 日时标注月份,保证标签与该月的列对齐。 + const label = el('span', 'stats-month-label'); + for (let i = 0; i < 7; i++) { + const date = addDays(weekStart, i); + if (date.getDate() === 1) { + label.textContent = `${date.getMonth() + 1}月`; + break; + } + } + monthFragment.append(label); + + for (let i = 0; i < 7; i++) { + const date = addDays(weekStart, i); + const dayKey = dateToDayKey(date); + const row = daily.get(dayKey); + const chars = row ? totalOf(row) : 0; + const cell = el('i', `stats-cell lv${calendarLevel(chars, maxChars)}`); + if (date.getTime() > today.getTime()) { + cell.classList.add('out'); + } + cell.title = `${formatDayKey(dayKey)}:${chars > 0 ? `${formatNumber(chars)} 字` : '无记录'}`; + gridFragment.append(cell); + } + weekStart = addDays(weekStart, 7); + } + + months.replaceChildren(monthFragment); + grid.replaceChildren(gridFragment); +} + +function renderHourly(overview: StatsOverview): void { + const container = byId('statsHourly'); + if (!container) { + return; + } + const values = overview.todayHourly ?? []; + const max = Math.max(1, ...values); + const fragment = document.createDocumentFragment(); + for (let hour = 0; hour < 24; hour++) { + const value = values[hour] ?? 0; + const column = el('div', 'stats-hour-col'); + const track = el('div', 'stats-hour-track'); + const bar = el('div', value > 0 ? 'stats-hour-bar' : 'stats-hour-bar is-empty'); + bar.style.height = `${Math.max(2, Math.round((value / max) * 100))}%`; + bar.title = `${pad2(hour)} 时:${formatNumber(value)} 字`; + track.append(bar); + column.append(track, el('span', 'stats-hour-label', hour % 3 === 0 ? String(hour) : '')); + fragment.append(column); + } + container.replaceChildren(fragment); +} + +function renderCategories(overview: StatsOverview): void { + const container = byId('statsCategories'); + if (!container) { + return; + } + const categories = overview.categories; + const total = categories.cjk + categories.latin + categories.digit + categories.punct + categories.other; + const rows: Array<[string, number]> = [ + ['中文', categories.cjk], + ['英文', categories.latin], + ['数字', categories.digit], + ['标点', categories.punct], + ['其他', categories.other] + ]; + const fragment = document.createDocumentFragment(); + rows.forEach(([name, value], index) => { + const row = el('div', 'stats-cat-row'); + const track = el('div', 'stats-cat-track'); + const bar = el('div', `stats-cat-bar k${index}`); + bar.style.width = total > 0 ? `${(value / total) * 100}%` : '0%'; + track.append(bar); + const percent = total > 0 ? Math.round((value / total) * 100) : 0; + row.append(el('span', 'stats-cat-name', name), track, el('span', 'stats-cat-stat', `${formatNumber(value)} 字 · ${percent}%`)); + fragment.append(row); + }); + container.replaceChildren(fragment); +} + +function renderDetails(overview: StatsOverview): void { + const container = byId('statsDetails'); + if (!container) { + return; + } + const table = el('table', 'stats-detail-table'); + const head = el('thead'); + const headRow = el('tr'); + for (const title of ['日期', '字数', '中文', '英文', '数字', '标点', '其他', '活跃', '速度']) { + headRow.append(el('th', undefined, title)); + } + head.append(headRow); + + const body = el('tbody'); + for (const row of detailRows(overview.daily ?? [], DETAIL_DAYS)) { + const tr = el('tr'); + tr.append(el('td', 'mono', formatDayKey(row.dayKey))); + tr.append(el('td', 'mono strong', formatNumber(totalOf(row)))); + for (const value of [row.cjk, row.latin, row.digit, row.punct, row.other]) { + tr.append(el('td', 'mono dim', formatNumber(value))); + } + tr.append(el('td', undefined, formatDuration(row.activeMs))); + tr.append(el('td', undefined, formatSpeed(speedOf(row)))); + body.append(tr); + } + table.append(head, body); + container.replaceChildren(table); +} + +// ---------------------------------------------------------------- 请求与反馈 + +function showStatus(message: string, ok: boolean): void { + const status = byId('statsStatus'); + if (!status) { + return; + } + status.textContent = message; + status.classList.toggle('is-error', !ok); + status.hidden = false; + if (statusTimer !== null) { + window.clearTimeout(statusTimer); + statusTimer = null; + } + if (ok) { + statusTimer = window.setTimeout(() => { + status.hidden = true; + statusTimer = null; + }, STATUS_TIMEOUT_MS); + } +} + +function setClearStatus(message: string, isError: boolean): void { + const status = byId('statsClearStatus'); + if (!status) { + return; + } + status.textContent = message; + status.classList.toggle('is-error', isError); +} + +function post(action: StatsRequest['action']): void { + const requestId = `stats-${++requestCounter}`; + pendingRequests.set(requestId, action); + window.chrome?.webview?.postMessage(serializeHostMessage({ type: 'statsRequest', data: { requestId, action } })); +} + +function handleResponse(response: StatsResponse, action: StatsRequest['action']): void { + if (!response.ok) { + if (action === 'clearAll') { + setClearStatus(response.message || '清空统计数据失败', true); + } else { + showStatus(response.message || '统计操作失败', false); + } + return; + } + if (response.overview) { + renderOverview(response.overview); + } + if (action === 'openDirectory') { + showStatus(response.message || '已打开数据目录', true); + } else if (action === 'clearAll') { + const removed = response.removedDays ?? 0; + setClearStatus(response.message || (removed > 0 ? `已删除 ${removed} 天的记录` : '没有需要清理的数据'), false); + } +} + +/** 「清空统计数据」:破坏性操作,确认后才发请求;响应里的空态概览直接刷新页面。 */ +function onClearAll(): void { + if (!window.confirm('确定要清空全部统计数据吗?今天与历史的记录都会被删除,删除后无法恢复。')) { + return; + } + setClearStatus('正在清空统计数据…', false); + post('clearAll'); +} + +/** 「自动清理」下拉:选中即保存到 config.toml,Server 下次写库时按新策略执行。 */ +function onRetentionChange(): void { + const select = byId('statsRetention'); + if (!select) { + return; + } + const retention = retentionValueOf(select.value); + if (!retention) { + return; + } + updateConfig(STATISTICS_RETENTION_PATH, retention); + if (retention === 'forever') { + setClearStatus('已保存:永久保留,不会自动删除任何记录。', false); + return; + } + const label = select.selectedOptions[0]?.textContent ?? retention; + setClearStatus(`已保存自动清理策略:${label};Server 跨天后首次写入统计时执行清理。`, false); +} + +function setupCalendarResize(): void { + const box = byId('statsCalendar'); + const root = byId('stats-settings'); + if (!box || !root || typeof ResizeObserver === 'undefined') { + return; + } + const applyMetrics = () => { + const width = box.clientWidth; + const nextWeeks = computeCalendarWeeks(width); + // 起点向前对齐到周一后,实际渲染列数在「今天不是周日」时会比 weeks 多一列; + // 按 weeks + 1 计算格子尺寸才能保证热图不出现横向滚动条。 + const nextCell = computeCalendarCellSize(width, nextWeeks + 1); + if (nextWeeks === calendarWeeks && nextCell === calendarCellPx) { + return; + } + const previousWeeks = calendarWeeks; + calendarWeeks = nextWeeks; + calendarCellPx = nextCell; + root.style.setProperty('--stats-cal-cell', `${nextCell}px`); + if (!lastOverview) { + return; + } + // 新列在左侧追加,按宽度差平移滚动位置,保持用户正在看的日期不跳。 + const previousScroll = box.scrollLeft; + renderCalendar(lastOverview); + box.scrollLeft = Math.max(0, previousScroll + (nextWeeks - previousWeeks) * CALENDAR_COLUMN_PX); + }; + calendarObserver?.disconnect(); + calendarObserver = new ResizeObserver(applyMetrics); + calendarObserver.observe(box); + applyMetrics(); +} + +/** + * 统计不是推送模型,只在「重新看到统计页」时补一次查询。设置窗口关闭只是隐藏 + * (文档与已加载的模块都保留),侧栏切走再切回也不会重跑 setup,所以这两种路径 + * 都要在这里刷新,否则用户打开开关、打字、再回来看不到新计数。 + */ +function setupOverviewRefresh(): void { + const root = byId('stats-settings'); + if (root && typeof IntersectionObserver !== 'undefined') { + let visible = false; + let firstShow = true; + new IntersectionObserver((entries) => { + const nowVisible = entries.some((entry) => entry.isIntersecting); + if (nowVisible && !visible) { + // 首次显示紧跟 setupStats 的初始请求,不重复查询。 + if (!firstShow) post('overview'); + firstShow = false; + } + visible = nowVisible; + }).observe(root); + } + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && byId('stats')?.style.display !== 'none') { + post('overview'); + } + }); +} + +/** 配置快照回填:statistics.enabled 的 boolean 守卫在 config-sync 里,这里只应用。 */ +export function applyStatisticsEnabled(enabled: boolean): void { + statisticsEnabled = enabled; + applyToggleState('statisticsEnabledToggleBtn', enabled); + renderEmptyState(); +} + +/** 配置快照回填:只接受契约枚举内的保留策略,非法值不改动下拉。 */ +export function applyStatisticsRetention(value: string): void { + const retention = retentionValueOf(value); + const select = byId('statsRetention'); + if (select && retention) { + select.value = retention; + } +} + +export function setupStats(): void { + setupToggleButton('statisticsEnabledToggleBtn', (active) => { + statisticsEnabled = active; + updateConfig(STATISTICS_ENABLED_PATH, active); + renderEmptyState(); + }); + byId('statsEnableButton')?.addEventListener('click', () => { + applyStatisticsEnabled(true); + updateConfig(STATISTICS_ENABLED_PATH, true); + }); + byId('statsRetention')?.addEventListener('change', onRetentionChange); + byId('statsClearButton')?.addEventListener('click', onClearAll); + byId('statsOpenDirectoryButton')?.addEventListener('click', () => post('openDirectory')); + + setupCalendarResize(); + setupOverviewRefresh(); + + onHostMessage('statsResponse', (payload) => { + const action = pendingRequests.get(payload.requestId); + if (!action) { + return; + } + pendingRequests.delete(payload.requestId); + handleResponse(payload, action); + }); + + post('overview'); +} diff --git a/ui-html/webview2/settings/ime-settings/src/partials/sidebar.html b/ui-html/webview2/settings/ime-settings/src/partials/sidebar.html index eb2bda5e..48edf8d2 100644 --- a/ui-html/webview2/settings/ime-settings/src/partials/sidebar.html +++ b/ui-html/webview2/settings/ime-settings/src/partials/sidebar.html @@ -16,6 +16,7 @@
实用功能
AI 辅助
悬浮工具栏
+
统计
帮助
关于
反馈
diff --git a/ui-html/webview2/settings/ime-settings/src/partials/stats.html b/ui-html/webview2/settings/ime-settings/src/partials/stats.html new file mode 100644 index 00000000..b6bd710a --- /dev/null +++ b/ui-html/webview2/settings/ime-settings/src/partials/stats.html @@ -0,0 +1,130 @@ +
+
统计
+ + +
+
+
+
启用输入统计
+
只在本机记录输入字数与活跃时长,不记录输入内容,不联网;默认关闭
+
+
+
+
+
+
+ + + + +
+
统计未启用
+
开启后这里会显示输入字数、速度与时段分布。统计只保存在本机,不记录输入内容,也不联网。
+ +
+ + + + + + + +
+
+
+
数据清理
+
自动清理按保留策略删除超期记录,在 Server 跨天后首次写入统计时执行;「清空统计数据」会删除全部记录(包括今天),删除后无法恢复
+
+
+
+ + + 选中即保存 +
+
+ + 删除全部记录且无法恢复 +
+
+
+ + +
+
数据位置与隐私
+
+ 统计数据保存在本机数据目录的 stats.db(SQLite)中,只包含每天每类字符的数量与活跃时长, + 没有任何文本内容;不联网、不上传。关闭开关后停止记录,历史数据继续保留。 +
+
+ +
+
+
diff --git a/ui-html/webview2/settings/ime-settings/src/styles/deferred.css b/ui-html/webview2/settings/ime-settings/src/styles/deferred.css index 85e05952..ddda3723 100644 --- a/ui-html/webview2/settings/ime-settings/src/styles/deferred.css +++ b/ui-html/webview2/settings/ime-settings/src/styles/deferred.css @@ -10,4 +10,5 @@ @import './modules/ai-settings.css'; @import './modules/document-settings.css'; @import './modules/shortcut.css'; +@import './modules/stats.css'; @import './components/credential-test.css'; diff --git a/ui-html/webview2/settings/ime-settings/src/styles/modules/stats.css b/ui-html/webview2/settings/ime-settings/src/styles/modules/stats.css new file mode 100644 index 00000000..956ae718 --- /dev/null +++ b/ui-html/webview2/settings/ime-settings/src/styles/modules/stats.css @@ -0,0 +1,469 @@ +/* 统计模块样式。配色全部走设置页 CSS 变量,热力图五级色阶用 color-mix 在 + accent 上叠透明度(先写 rgba 兜底,不支持 color-mix 的引擎退到固定紫色)。 */ + +#stats-settings [hidden] { + display: none !important; +} + +.stats-description { + color: var(--text-muted); + font-size: 12px; + margin-top: 5px; + line-height: 1.5; +} + +.stats-note { + color: var(--text-subtle); + font-size: 12px; +} + +/* 粘在内容区顶部:无论用户滚到统计页哪个位置,错误提示都可见。 */ +.stats-status { + position: sticky; + top: 0; + z-index: 20; + margin-bottom: 18px; + padding: 9px 14px; + border-radius: 8px; + border: 1px solid var(--accent-soft-border); + background: var(--toast-bg); + color: var(--toast-text); + box-shadow: var(--card-shadow); + font-size: 12.5px; +} + +.stats-status.is-error { + border-color: rgba(255, 119, 119, 0.35); + background: rgba(255, 119, 119, 0.12); + color: var(--danger-text); +} + +/* ------------------------------------------------------------------- empty */ + +.stats-empty { + text-align: center; + padding: 36px 24px; +} + +.stats-empty-title { + font-size: 15px; + font-weight: 550; + color: var(--text-color); + margin-bottom: 8px; +} + +.stats-empty-text { + color: var(--text-muted); + font-size: 12.5px; + line-height: 1.6; + max-width: 520px; + margin: 0 auto 16px; +} + +.stats-btn { + font: inherit; + font-size: 13px; + padding: 7px 16px; + border-radius: 6px; + border: 1px solid var(--control-border); + background: var(--button-secondary-bg); + color: var(--button-secondary-text); + cursor: pointer; + transition: background 0.2s, border-color 0.2s, color 0.2s; +} + +.stats-btn:hover { + background: var(--button-secondary-hover); +} + +.stats-btn.primary { + background: var(--accent-color); + border-color: var(--accent-color); + color: #fff; +} + +.stats-btn.primary:hover { + background: var(--accent-strong); +} + +.stats-btn.danger { + color: var(--danger-text); + border-color: rgba(255, 119, 119, 0.35); +} + +.stats-btn.danger:hover { + color: var(--danger-hover); + border-color: rgba(255, 119, 119, 0.55); +} + +/* ------------------------------------------------------------------- cards */ + +.stats-cards { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 18px; +} + +.stats-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: var(--card-shadow); + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.stats-card-title { + color: var(--text-muted); + font-size: 12.5px; +} + +.stats-card-value { + display: flex; + align-items: baseline; + gap: 4px; + min-width: 0; +} + +.stats-card-value strong { + font-size: 24px; + font-weight: 600; + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; +} + +.stats-card-unit { + color: var(--text-muted); + font-size: 12px; +} + +.stats-card-note { + color: var(--text-subtle); + font-size: 12px; + min-height: 18px; +} + +/* ------------------------------------------------------------------- speed */ + +.stats-speed-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 12px; +} + +.stats-speed-item { + background: var(--surface-subtle); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 14px; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.stats-speed-label { + color: var(--text-muted); + font-size: 12.5px; +} + +.stats-speed-value { + font-size: 19px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.stats-speed-note { + color: var(--text-subtle); + font-size: 12px; +} + +.stats-best-day { + margin-top: 12px; + color: var(--text-secondary); + font-size: 12.5px; +} + +/* ---------------------------------------------------------------- calendar */ + +.stats-calendar-scroll { + overflow-x: auto; + padding-bottom: 4px; + margin-top: 12px; +} + +.stats-calendar-months { + display: grid; + grid-auto-flow: column; + grid-auto-columns: var(--stats-cal-cell, 13px); + gap: 2px; + width: max-content; +} + +.stats-month-label { + font-size: 10px; + color: var(--text-subtle); + white-space: nowrap; + overflow: visible; + min-width: var(--stats-cal-cell, 13px); + line-height: 15px; +} + +.stats-calendar-grid { + display: grid; + grid-auto-flow: column; + grid-auto-columns: var(--stats-cal-cell, 13px); + grid-template-rows: repeat(7, var(--stats-cal-cell, 13px)); + gap: 2px; + width: max-content; +} + +.stats-cell { + display: block; + width: var(--stats-cal-cell, 13px); + height: var(--stats-cal-cell, 13px); + border-radius: 3px; + background: var(--surface-sunken); +} + +.stats-cell.lv0 { + background: var(--surface-sunken); +} + +.stats-cell.lv1 { + background: rgba(142, 140, 216, 0.28); + background: color-mix(in srgb, var(--accent-color) 28%, transparent); +} + +.stats-cell.lv2 { + background: rgba(142, 140, 216, 0.48); + background: color-mix(in srgb, var(--accent-color) 48%, transparent); +} + +.stats-cell.lv3 { + background: rgba(142, 140, 216, 0.7); + background: color-mix(in srgb, var(--accent-color) 70%, transparent); +} + +.stats-cell.lv4 { + background: rgba(142, 140, 216, 0.95); + background: color-mix(in srgb, var(--accent-color) 95%, transparent); +} + +.stats-cell.out { + background: transparent; + outline: 1px dashed var(--border-color); +} + +.stats-legend { + display: flex; + align-items: center; + gap: 4px; + margin-top: 10px; + color: var(--text-subtle); + font-size: 11.5px; +} + +/* ------------------------------------------------------------ split panels */ + +.stats-split { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + gap: 18px; +} + +.stats-split > .section { + min-width: 0; +} + +/* ------------------------------------------------------------------ hourly */ + +.stats-hourly { + display: flex; + align-items: flex-end; + gap: 3px; + height: 130px; + margin-top: 12px; +} + +.stats-hour-col { + flex: 1; + height: 100%; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; +} + +.stats-hour-track { + width: 100%; + flex: 1; + display: flex; + align-items: flex-end; +} + +.stats-hour-bar { + width: 100%; + border-radius: 3px 3px 1px 1px; + background: var(--accent-color); + opacity: 0.85; +} + +.stats-hour-bar.is-empty { + background: var(--surface-sunken); + opacity: 1; +} + +.stats-hour-label { + height: 15px; + font-size: 10px; + color: var(--text-subtle); + line-height: 15px; +} + +/* -------------------------------------------------------------- categories */ + +.stats-categories { + display: flex; + flex-direction: column; + gap: 9px; + margin-top: 12px; +} + +.stats-cat-row { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; +} + +.stats-cat-name { + color: var(--text-secondary); + font-size: 12.5px; +} + +.stats-cat-track { + height: 10px; + border-radius: 999px; + background: var(--surface-sunken); + overflow: hidden; +} + +.stats-cat-bar { + height: 100%; + border-radius: 999px; + min-width: 2px; +} + +.stats-cat-bar.k0 { + background: var(--accent-color); +} + +.stats-cat-bar.k1 { + background: #4a90d9; +} + +.stats-cat-bar.k2 { + background: #d99b3c; +} + +.stats-cat-bar.k3 { + background: #9a7ad1; +} + +.stats-cat-bar.k4 { + background: #98a2b8; +} + +.stats-cat-stat { + text-align: right; + color: var(--text-subtle); + font-size: 12px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* ----------------------------------------------------------------- details */ + +.stats-details { + overflow-x: auto; + margin-top: 12px; +} + +.stats-detail-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; +} + +.stats-detail-table th { + text-align: left; + font-weight: 500; + color: var(--text-subtle); + padding: 6px 8px; + border-bottom: 1px solid var(--table-border); + white-space: nowrap; +} + +.stats-detail-table td { + padding: 6px 8px; + border-bottom: 1px solid var(--table-border); + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.stats-detail-table tbody tr:last-child td { + border-bottom: 0; +} + +.stats-detail-table .mono { + font-family: Consolas, "Cascadia Mono", monospace; +} + +.stats-detail-table .strong { + color: var(--text-color); + font-weight: 600; +} + +.stats-detail-table .dim { + color: var(--text-subtle); +} + +/* ------------------------------------------------------------ clear/status */ + +.stats-clear-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + flex-wrap: wrap; +} + +.stats-clear-status { + margin-top: 10px; + min-height: 18px; + font-size: 12.5px; + color: var(--text-muted); +} + +.stats-clear-status.is-error { + color: var(--danger-text); +} + +@media (max-width: 820px) { + .stats-cards, + .stats-speed-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .stats-split { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/ui-html/webview2/settings/ime-settings/src/utils/common-utils.ts b/ui-html/webview2/settings/ime-settings/src/utils/common-utils.ts index 989d451c..8baa787d 100644 --- a/ui-html/webview2/settings/ime-settings/src/utils/common-utils.ts +++ b/ui-html/webview2/settings/ime-settings/src/utils/common-utils.ts @@ -22,6 +22,7 @@ export async function loadHTML(url: string): Promise { const MODULE_IDS = [ 'floating-toolbar', + 'stats', 'appearance', 'input', 'helpcode', diff --git a/ui-html/webview2/shared/messages.ts b/ui-html/webview2/shared/messages.ts index eb6de240..474769a7 100644 --- a/ui-html/webview2/shared/messages.ts +++ b/ui-html/webview2/shared/messages.ts @@ -18,6 +18,7 @@ export type ClientMessage = | { "type": "configUpdate"; "protocolVersion"?: 1; "data": { "path": string; "value": string | number | boolean } } | { "type": "apiCredentialTest"; "protocolVersion"?: 1; "data": { "requestId": string; "service": "translation.tencent" | "translation.niutrans" | "translation.custom" | "voice.asr" | "voice.polish" | "ai.assistant"; "config": { [key: string]: unknown } } } | { "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": "statsRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "action": "overview" | "clearAll" | "openDirectory" } } | { "type": "openKeyboardPanel"; "protocolVersion"?: 1 } | { "type": "candidate"; "protocolVersion"?: 1; "data": number } | { "type": "delete"; "protocolVersion"?: 1; "data": number } @@ -52,7 +53,8 @@ export type ServerMessage = | { "type": "windowState"; "protocolVersion"?: 1; "data": { "isMaximized": boolean } } | { "type": "maxButtonEvent"; "protocolVersion"?: 1; "data": { "event": "enter" | "leave" | "down" | "up" | "click" } } | { "type": "apiCredentialTestResult"; "protocolVersion"?: 1; "requestId": string; "ok": boolean; "message": string } - | { "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 }; + | { "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 } + | { "type": "statsResponse"; "protocolVersion"?: 1; "requestId": string; "action": string; "ok": boolean; "message": string; "overview"?: { "hasData": boolean; "firstDayKey": number; "lastDayKey": number; "days": number; "totalChars": number; "totalActiveMs": number; "todayDayKey": number; "todayChars": number; "todayActiveMs": number; "averagePerDay": number; "currentStreak": number; "longestStreak": number; "bestDayKey": number; "bestDayChars": number; "todaySpeed": number; "averageSpeed": number; "fastestSpeed": number; "fastestDayKey": number; "categories": { "cjk": number; "latin": number; "digit": number; "punct": number; "other": number }; "todayHourly": Array; "daily": Array<{ "dayKey": number; "cjk": number; "latin": number; "digit": number; "punct": number; "other": number; "activeMs": number }> }; "removedDays"?: number; [key: string]: unknown }; export type SettingsMessage = | { "type": "configRequest"; "protocolVersion"?: 1 } | { "type": "skinCatalogRequest"; "protocolVersion"?: 1 } @@ -70,6 +72,7 @@ export type SettingsMessage = | { "type": "configUpdate"; "protocolVersion"?: 1; "data": { "path": string; "value": string | number | boolean } } | { "type": "apiCredentialTest"; "protocolVersion"?: 1; "data": { "requestId": string; "service": "translation.tencent" | "translation.niutrans" | "translation.custom" | "voice.asr" | "voice.polish" | "ai.assistant"; "config": { [key: string]: unknown } } } | { "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": "statsRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "action": "overview" | "clearAll" | "openDirectory" } } | { "type": "openKeyboardPanel"; "protocolVersion"?: 1 }; declare global { var MsimeProtocol: { diff --git a/ui-html/webview2/shared/schema.js b/ui-html/webview2/shared/schema.js index 957d58ed..e39c8897 100644 --- a/ui-html/webview2/shared/schema.js +++ b/ui-html/webview2/shared/schema.js @@ -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"]},"restartServer":{"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}},"apiCredentialTest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"service":{"type":"string","enum":["translation.tencent","translation.niutrans","translation.custom","voice.asr","voice.polish","ai.assistant"]},"config":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"required":["requestId","service","config"],"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}},"candidateWheel":{"surfaces":["candidate"],"data":{"type":"integer","minimum":-32768,"maximum":32767}},"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":{"type":"string","enum":["puncEn","puncCn"]}}},"server":{"configSnapshot":{"data":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"windowState":{"data":{"type":"object","properties":{"isMaximized":{"type":"boolean"}},"required":["isMaximized"],"additionalProperties":false}},"maxButtonEvent":{"data":{"type":"object","properties":{"event":{"type":"string","enum":["enter","leave","down","up","click"]}},"required":["event"],"additionalProperties":false}},"apiCredentialTestResult":{"properties":{"requestId":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"}},"required":["requestId","ok","message"],"additionalProperties":false},"dictionaryResponse":{"properties":{"requestId":{"type":"string"},"action":{"type":"string"},"dictionary":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"},"rows":{"type":"array","items":{"type":"object","properties":{"word":{"type":"string"},"code":{"type":"string"},"display":{"type":"string"},"weight":{"type":"integer"}},"required":["word"],"additionalProperties":false}},"content":{"type":"string"},"filename":{"type":"string"},"offset":{"type":"integer","minimum":0},"hasMore":{"type":"boolean"}},"required":["requestId","ok","message","rows"],"additionalProperties":true}}}; + const schema = {"version":1,"client":{"configRequest":{"surfaces":["settings"]},"skinCatalogRequest":{"surfaces":["settings"]},"openSkinDirectory":{"surfaces":["settings"]},"openHandwritingPanel":{"surfaces":["settings"]},"restartServer":{"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}},"apiCredentialTest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"service":{"type":"string","enum":["translation.tencent","translation.niutrans","translation.custom","voice.asr","voice.polish","ai.assistant"]},"config":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"required":["requestId","service","config"],"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}},"statsRequest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"action":{"type":"string","enum":["overview","clearAll","openDirectory"]}},"required":["requestId","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}},"candidateWheel":{"surfaces":["candidate"],"data":{"type":"integer","minimum":-32768,"maximum":32767}},"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":{"type":"string","enum":["puncEn","puncCn"]}}},"server":{"configSnapshot":{"data":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"windowState":{"data":{"type":"object","properties":{"isMaximized":{"type":"boolean"}},"required":["isMaximized"],"additionalProperties":false}},"maxButtonEvent":{"data":{"type":"object","properties":{"event":{"type":"string","enum":["enter","leave","down","up","click"]}},"required":["event"],"additionalProperties":false}},"apiCredentialTestResult":{"properties":{"requestId":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"}},"required":["requestId","ok","message"],"additionalProperties":false},"dictionaryResponse":{"properties":{"requestId":{"type":"string"},"action":{"type":"string"},"dictionary":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"},"rows":{"type":"array","items":{"type":"object","properties":{"word":{"type":"string"},"code":{"type":"string"},"display":{"type":"string"},"weight":{"type":"integer"}},"required":["word"],"additionalProperties":false}},"content":{"type":"string"},"filename":{"type":"string"},"offset":{"type":"integer","minimum":0},"hasMore":{"type":"boolean"}},"required":["requestId","ok","message","rows"],"additionalProperties":true},"statsResponse":{"properties":{"requestId":{"type":"string"},"action":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"},"overview":{"type":"object","properties":{"hasData":{"type":"boolean"},"firstDayKey":{"type":"integer"},"lastDayKey":{"type":"integer"},"days":{"type":"integer"},"totalChars":{"type":"integer"},"totalActiveMs":{"type":"integer"},"todayDayKey":{"type":"integer"},"todayChars":{"type":"integer"},"todayActiveMs":{"type":"integer"},"averagePerDay":{"type":"number"},"currentStreak":{"type":"integer"},"longestStreak":{"type":"integer"},"bestDayKey":{"type":"integer"},"bestDayChars":{"type":"integer"},"todaySpeed":{"type":"number"},"averageSpeed":{"type":"number"},"fastestSpeed":{"type":"number"},"fastestDayKey":{"type":"integer"},"categories":{"type":"object","properties":{"cjk":{"type":"integer"},"latin":{"type":"integer"},"digit":{"type":"integer"},"punct":{"type":"integer"},"other":{"type":"integer"}},"required":["cjk","latin","digit","punct","other"],"additionalProperties":false},"todayHourly":{"type":"array","items":{"type":"integer"}},"daily":{"type":"array","items":{"type":"object","properties":{"dayKey":{"type":"integer"},"cjk":{"type":"integer"},"latin":{"type":"integer"},"digit":{"type":"integer"},"punct":{"type":"integer"},"other":{"type":"integer"},"activeMs":{"type":"integer"}},"required":["dayKey","cjk","latin","digit","punct","other","activeMs"],"additionalProperties":false}}},"required":["hasData","firstDayKey","lastDayKey","days","totalChars","totalActiveMs","todayDayKey","todayChars","todayActiveMs","averagePerDay","currentStreak","longestStreak","bestDayKey","bestDayChars","todaySpeed","averageSpeed","fastestSpeed","fastestDayKey","categories","todayHourly","daily"],"additionalProperties":false},"removedDays":{"type":"integer"}},"required":["requestId","action","ok","message"],"additionalProperties":true}}}; root.MsimeMessageSchema = schema; if (typeof module !== 'undefined' && module.exports) module.exports = schema; })(globalThis); diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 640eb9a8..9322d190 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -74,6 +74,7 @@ include_directories("./src/IPC") include_directories("./src/Key") include_directories("./src/LanguageBar") include_directories("./src/Register") +include_directories("./src/Statistics") include_directories("./src/Tf") include_directories("./src/Thread") include_directories("./src/UI") @@ -148,6 +149,12 @@ add_library(${PROJECT_NAME} SHARED "./src/Register/Register.cpp" "./src/Register/RegKey.cpp" "./src/Register/RegKey.h" + # Statistics + "./src/Statistics/char_classify.cpp" + "./src/Statistics/char_classify.h" + "./src/Statistics/stats_collector.cpp" + "./src/Statistics/stats_collector.h" + "./src/Statistics/stats_passthrough.h" # Tf "./src/Tf/TfInputProcessorProfile.cpp" "./src/Tf/TfInputProcessorProfile.h" diff --git a/windows/src/Composition/Composition.cpp b/windows/src/Composition/Composition.cpp index 0a86f840..de3df78b 100644 --- a/windows/src/Composition/Composition.cpp +++ b/windows/src/Composition/Composition.cpp @@ -2,12 +2,15 @@ #include "Globals.h" #include "MetasequoiaIME.h" #include "CompositionProcessorEngine.h" +#include #include #include #include #include #include "FanyDefines.h" #include "Ipc.h" +#include "char_classify.h" +#include "stats_collector.h" namespace { @@ -130,6 +133,12 @@ bool AreCaretModifiersPhysicallyDown() } return false; } + +bool IsHighSurrogateUnit(wchar_t unit) +{ + const uint32_t codeUnit = static_cast(static_cast(unit)); + return codeUnit >= 0xD800 && codeUnit <= 0xDBFF; +} } // namespace WCHAR CMetasequoiaIME::_GetPrecedingDocumentChar(TfEditCookie ec, _In_ ITfContext *pContext) @@ -1161,6 +1170,12 @@ STDAPI CMetasequoiaIME::OnCompositionTerminated(TfEditCookie ecWrite, _In_ ITfCo // a composition created later. ITfComposition *terminatedComposition = pComposition; terminatedComposition->AddRef(); + + // The host-forced termination keeps the text in the document (see the + // Do NOT SetText(empty) note below), so this is a real commit and must be + // counted before any teardown runs. + _CaptureCompositionStats(ecWrite, terminatedComposition); + _pComposition->Release(); _pComposition = nullptr; _voiceCompositionActive = false; @@ -1228,6 +1243,7 @@ BOOL CMetasequoiaIME::_IsComposing() void CMetasequoiaIME::_SetComposition(_In_ ITfComposition *pComposition) { _pComposition = pComposition; + _compositionStatsCaptured.store(false, std::memory_order_release); uint64_t nextEpoch = _compositionEpoch.fetch_add(1, std::memory_order_acq_rel) + 1; if (nextEpoch == 0) { @@ -1235,6 +1251,79 @@ void CMetasequoiaIME::_SetComposition(_In_ ITfComposition *pComposition) } } +//+--------------------------------------------------------------------------- +// +// _CaptureCompositionStats +// +// Reads the committed text of a terminating composition and queues one +// statistics event. Called from the two composition exits; the first observer +// wins because _TerminateComposition can re-enter OnCompositionTerminated for +// the same composition. This function only reads: it never writes the range, +// swallows no HRESULT and changes no teardown ordering. Any failure is silent. +//---------------------------------------------------------------------------- + +void CMetasequoiaIME::_CaptureCompositionStats(TfEditCookie ec, _In_ ITfComposition *pComposition) +{ + if (_compositionStatsCaptured.exchange(true, std::memory_order_acq_rel)) + { + return; + } + if (pComposition == nullptr) + { + return; + } + + ITfRange *pRange = nullptr; + if (FAILED(pComposition->GetRange(&pRange)) || pRange == nullptr) + { + return; + } + + // Hosts may hand back fewer units than requested, so read in small blocks + // and walk the range start forward. A high surrogate at the end of a full + // block is left unconsumed so the next read can still pair it; a trailing + // isolated one comes back through a short read and counts as other. + // Statistics never read an unbounded document range. + constexpr size_t kBlockUnits = 256; + constexpr size_t kMaxCaptureUnits = 64 * 1024; + wchar_t buffer[kBlockUnits] = {}; + size_t totalCaptured = 0; + MsimeStats::CharClassCounts counts; + while (totalCaptured < kMaxCaptureUnits) + { + const size_t request = (std::min)(kBlockUnits, kMaxCaptureUnits - totalCaptured); + ULONG fetched = 0; + const HRESULT readResult = SafeRangeGetText(pRange, ec, 0, buffer, static_cast(request), &fetched); + if (FAILED(readResult) || fetched == 0) + { + break; + } + + size_t consume = fetched; + if (fetched == request && IsHighSurrogateUnit(buffer[fetched - 1])) + { + --consume; + } + if (consume == 0) + { + break; // only possible at the capture cap; never spin on one unit + } + + counts.Add(MsimeStats::ClassifyText(buffer, consume)); + totalCaptured += consume; + + LONG shifted = 0; + if (FAILED(SafeRangeShiftStart(pRange, ec, static_cast(consume), &shifted)) || + shifted != static_cast(consume)) + { + break; + } + } + + pRange->Release(); + MsimeStats::QueueStatisticsEvent(counts); +} + //+--------------------------------------------------------------------------- // // _AddComposingAndChar @@ -1342,6 +1431,15 @@ HRESULT CMetasequoiaIME::_AddCharAndFinalize(TfEditCookie ec, _In_ ITfContext *p hr = SafeRangeSetText(tfSelection.range, ec, 0, pstrAddString->Get(), (LONG)pstrAddString->GetLength()); if (hr == S_OK) { + // Direct write with no composition: this text never passes through + // _TerminateComposition / OnCompositionTerminated, so it is the third + // and last commit exit and must be counted here. + if (_pComposition == nullptr) + { + MsimeStats::QueueStatisticsEvent( + MsimeStats::ClassifyText(pstrAddString->Get(), static_cast(pstrAddString->GetLength()))); + } + // Update the selection, we'll make it an insertion point just past // the inserted text. tfSelection.range->Collapse(ec, TF_ANCHOR_END); diff --git a/windows/src/Composition/EndComposition.cpp b/windows/src/Composition/EndComposition.cpp index 91234c78..1e3681d9 100644 --- a/windows/src/Composition/EndComposition.cpp +++ b/windows/src/Composition/EndComposition.cpp @@ -104,6 +104,11 @@ void CMetasequoiaIME::_TerminateComposition(TfEditCookie ec, _In_ ITfContext *pC ownerContext->AddRef(); } + // Read the committed text before any COM call below can change or end + // the composition. The cancel path has already wiped the range, so a + // cancelled composition classifies to zero and does not count. + _CaptureCompositionStats(ec, terminatingComposition); + // remove the display attribute from the composition range. _ClearCompositionDisplayAttributes(ec, pContext, terminatingComposition); diff --git a/windows/src/IME/MetasequoiaIME.cpp b/windows/src/IME/MetasequoiaIME.cpp index ac9a2739..7789a583 100644 --- a/windows/src/IME/MetasequoiaIME.cpp +++ b/windows/src/IME/MetasequoiaIME.cpp @@ -507,6 +507,8 @@ CMetasequoiaIME::CMetasequoiaIME() _deferredKeyDrainPosted = false; _serverUnavailableFallbackActive = false; _backspaceHoldArmed = false; + _passthroughStatsVirtualKey = 0; + _passthroughStatsMessageTime = 0; _shiftHotkeyArmed = false; _ctrlHotkeyArmed = false; _modifierHotkeyExpire = {}; diff --git a/windows/src/IME/MetasequoiaIME.h b/windows/src/IME/MetasequoiaIME.h index 7917b884..865404dd 100644 --- a/windows/src/IME/MetasequoiaIME.h +++ b/windows/src/IME/MetasequoiaIME.h @@ -245,6 +245,10 @@ class CMetasequoiaIME : public ITfTextInputProcessorEx, void _SetComposition(_In_ ITfComposition *pComposition); void _TerminateComposition(TfEditCookie ec, _In_ ITfContext *pContext, BOOL isCalledFromDeactivate = FALSE); void _SaveCompositionContext(_In_ ITfContext *pContext); + // Reads the committed text of a terminating composition and queues one + // statistics event for it. Safe to call from both composition exits; only + // the first observer captures, and any failure is silent. + void _CaptureCompositionStats(TfEditCookie ec, _In_ ITfComposition *pComposition); // key event handlers for composition/candidate/phrase common objects. HRESULT _HandleComplete(TfEditCookie ec, _In_ ITfContext *pContext); @@ -510,6 +514,11 @@ class CMetasequoiaIME : public ITfTextInputProcessorEx, _In_opt_ const WCHAR *translatedWch, _In_opt_ const UINT *modifiersDown, _Out_ WCHAR *classifiedWch, _Out_ UINT *classifiedCode, _Out_ _KEYSTROKE_STATE *keyState); + // Counts one printable character handed back to the application + // (Statistics/stats_passthrough.h). keyboardKnownEnabled is true when the + // caller already proved the keyboard is live through a non-zero + // _IsKeyEaten out-char; otherwise the disabled compartment is queried here. + void _NotePassthroughStatistics(UINT virtualKey, WCHAR wch, bool keyboardKnownEnabled); bool _QueueDeferredKeyDown(_In_ ITfContext *pContext, WPARAM wParam, LPARAM lParam, WCHAR translatedWch, UINT modifiersDown, const _KEYSTROKE_STATE &keyState); bool _QueueDeferredPreservedKey(_In_ ITfContext *pContext, REFGUID preservedKey); @@ -766,6 +775,10 @@ class CMetasequoiaIME : public ITfTextInputProcessorEx, std::atomic _expectedWorkerFocusToken; std::atomic _acknowledgedWorkerFocusToken; std::atomic _compositionEpoch; + // De-duplicates the two composition exits that can observe the same commit + // (_TerminateComposition can re-enter OnCompositionTerminated). Reset when + // the next composition is created; see _CaptureCompositionStats. + std::atomic _compositionStatsCaptured{false}; std::wstring _voiceCompositionAssemble; UINT _voiceCompositionAssembleMsg = 0; wchar_t _voiceCompositionAssembleGeneration = 0; @@ -812,6 +825,12 @@ class CMetasequoiaIME : public ITfTextInputProcessorEx, // almost every edit, which would disarm the guard mid-hold. bool _backspaceHoldArmed; + // De-duplicates the passthrough statistics probe: OnTestKeyDown can be + // called more than once for one key event, and a repeated (virtual key, + // message time) pair is that same event. Auto-repeats carry fresh times. + UINT _passthroughStatsVirtualKey; + LONG _passthroughStatsMessageTime; + // Bare Shift/Ctrl toggle arming (Weasel-style: release within timeout). bool _shiftHotkeyArmed; bool _ctrlHotkeyArmed; diff --git a/windows/src/Key/KeyEventSink.cpp b/windows/src/Key/KeyEventSink.cpp index 003cd3c9..90f5a37c 100644 --- a/windows/src/Key/KeyEventSink.cpp +++ b/windows/src/Key/KeyEventSink.cpp @@ -5,6 +5,8 @@ #include "CompositionProcessorEngine.h" #include "KeyHandlerEditSession.h" #include "KeyRepeatGuard.h" +#include "stats_collector.h" +#include "stats_passthrough.h" #include "Compartment.h" #include "MetasequoiaIMEBaseStructure.h" #include @@ -1607,6 +1609,65 @@ bool CMetasequoiaIME::_ClassifyDeferredKeyDown(_In_ ITfContext *pContext, WPARAM return false; } +//+--------------------------------------------------------------------------- +// +// _NotePassthroughStatistics +// +// Counts one printable character that this tip hands back to the application. +// The three composition commit exits never see these keys -- the host inserts +// them -- so this is the only capture point for half-width digits, the symbols +// outside the punctuation table and English-mode letters (stats_passthrough.h). +// Observation only: the eaten result, the deferred queue and the edit path stay +// untouched, and every failure mode is a dropped count. +//---------------------------------------------------------------------------- + +void CMetasequoiaIME::_NotePassthroughStatistics(UINT virtualKey, WCHAR wch, bool keyboardKnownEnabled) +{ + const LONG messageTime = GetMessageTime(); + if (virtualKey == _passthroughStatsVirtualKey && messageTime == _passthroughStatsMessageTime) + { + // The system can query the same key event more than once (a host may + // also call the keystroke manager directly); only the first pass counts. + return; + } + + if (wch == L'\0') + { + // The keyboard-closed early return in _IsKeyEaten leaves its out-char + // blank even though the key reaches the application; widen it from the + // layout here. Keys that genuinely produce no character keep the zero + // and are dropped by the printable check below. + wch = ConvertVKey(virtualKey); + } + + // The same physical-state read _IsKeyEaten uses for application-owned + // combinations. Shift deliberately does not participate: it is what makes + // uppercase letters and the shifted symbol row their own characters. + const UINT modifiers = CaptureIpcModifiers(); + const bool ctrlDown = (modifiers & 0b00000010u) != 0; + const bool altDown = (modifiers & 0b00000100u) != 0; + const bool winDown = (GetAsyncKeyState(VK_LWIN) & 0x8000) != 0 || (GetAsyncKeyState(VK_RWIN) & 0x8000) != 0; + + // A non-zero out-char from _IsKeyEaten passed that function's own + // keyboard-disabled check, so the compartment query is only needed when the + // caller could not prove the keyboard was live. _ClassifyDeferredKeyDown + // fills its out-char before that check, so its two callers never set + // keyboardKnownEnabled. Self-generated SendInput never reaches here: + // OnTestKeyDown rejects it up front, and eaten is false by construction + // because this only runs on the uneaten exits. + const bool keyboardDisabled = !keyboardKnownEnabled && _IsKeyboardDisabled() != FALSE; + const bool counted = MsimeStats::ShouldCountPassthroughChar(wch, /*eaten=*/false, /*selfGenerated=*/false, + keyboardDisabled, ctrlDown, altDown, winDown); + if (!counted) + { + return; + } + + _passthroughStatsVirtualKey = virtualKey; + _passthroughStatsMessageTime = messageTime; + MsimeStats::QueueStatisticsEvent(MsimeStats::ClassifyText(&wch, 1)); +} + //+--------------------------------------------------------------------------- // // ITfKeyEventSink::OnTestKeyDown @@ -1665,6 +1726,9 @@ STDAPI CMetasequoiaIME::OnTestKeyDown(ITfContext *pContext, WPARAM wParam, LPARA deferredWch = ConvertVKey(static_cast(wParam)); deferredCode = VKeyFromVKPacketAndWchar(static_cast(wParam), deferredWch); _NoteKeyForSmartPunctuation(deferredCode, deferredWch, false, FUNCTION_NONE); + // _ClassifyDeferredKeyDown is not reached on this exit and ConvertVKey + // fills the char without checking the keyboard state. + _NotePassthroughStatistics(static_cast(wParam), deferredWch, false); *pIsEaten = FALSE; return S_OK; } @@ -1696,6 +1760,13 @@ STDAPI CMetasequoiaIME::OnTestKeyDown(ITfContext *pContext, WPARAM wParam, LPARA // Classify always fills code/wch before failing. Track rejection even // when the key is handed back to the app (typical for VK_BACK). _NoteKeyForSmartPunctuation(deferredCode, deferredWch, *pIsEaten ? true : false, deferredState.Function); + if (!*pIsEaten) + { + // The deferred classifier fills its out-char before its own + // keyboard-disabled check, and not every exit runs that check, so + // the char proves nothing about the keyboard state. + _NotePassthroughStatistics(static_cast(wParam), deferredWch, false); + } return S_OK; } @@ -1716,6 +1787,13 @@ STDAPI CMetasequoiaIME::OnTestKeyDown(ITfContext *pContext, WPARAM wParam, LPARA // rejection state is tracked here rather than in the eaten-key path. _NoteKeyForSmartPunctuation(code, wch, *pIsEaten ? true : false, KeystrokeState.Function); + if (!*pIsEaten) + { + // A half-width digit or a symbol outside the tables lands here: the tip + // let it through, so the host will insert it outside every commit exit. + _NotePassthroughStatistics(static_cast(wParam), wch, wch != L'\0'); + } + DebugTsfIssue47(L"test-keydown-classified", FANY_IME_NO_REQUEST_ID, code, wch, KeystrokeState.Category, KeystrokeState.Function, *pIsEaten ? 1 : 0, _IsComposing(), _pCompositionProcessorEngine ? _pCompositionProcessorEngine->GetVirtualKeyLength() : 0, S_OK); diff --git a/windows/src/Statistics/char_classify.cpp b/windows/src/Statistics/char_classify.cpp new file mode 100644 index 00000000..82a50720 --- /dev/null +++ b/windows/src/Statistics/char_classify.cpp @@ -0,0 +1,147 @@ +#include "char_classify.h" +#include + +namespace +{ +void SaturatingIncrement(uint16_t &value) +{ + if (value != UINT16_MAX) + { + ++value; + } +} + +void SaturatingAdd(uint16_t &value, uint16_t count) +{ + if (static_cast(value) + count > UINT16_MAX) + { + value = UINT16_MAX; + } + else + { + value = static_cast(value + count); + } +} + +// CJK ideographs count as Chinese: Unified Ideographs, Extension A, the +// compatibility block and the supplementary-plane ideograph range +// (Extensions B-F plus the compatibility supplement). +bool IsCjk(uint32_t codePoint) +{ + return (codePoint >= 0x4E00 && codePoint <= 0x9FFF) || (codePoint >= 0x3400 && codePoint <= 0x4DBF) || + (codePoint >= 0xF900 && codePoint <= 0xFAFF) || (codePoint >= 0x20000 && codePoint <= 0x2FA1F); +} + +bool IsAsciiLetter(uint32_t codePoint) +{ + return (codePoint >= L'A' && codePoint <= L'Z') || (codePoint >= L'a' && codePoint <= L'z'); +} + +bool IsDigit(uint32_t codePoint) +{ + return (codePoint >= L'0' && codePoint <= L'9') || (codePoint >= 0xFF10 && codePoint <= 0xFF19); +} + +bool IsHighSurrogate(uint32_t codeUnit) +{ + return codeUnit >= 0xD800 && codeUnit <= 0xDBFF; +} + +bool IsLowSurrogate(uint32_t codeUnit) +{ + return codeUnit >= 0xDC00 && codeUnit <= 0xDFFF; +} +} // namespace + +namespace MsimeStats +{ +uint32_t CharClassCounts::Total() const +{ + return static_cast(cjk) + static_cast(latin) + static_cast(digit) + + static_cast(punct) + static_cast(other); +} + +void CharClassCounts::Add(const CharClassCounts &counts) +{ + SaturatingAdd(cjk, counts.cjk); + SaturatingAdd(latin, counts.latin); + SaturatingAdd(digit, counts.digit); + SaturatingAdd(punct, counts.punct); + SaturatingAdd(other, counts.other); +} + +CharClassCounts ClassifyText(const wchar_t *text, size_t length) +{ + CharClassCounts counts; + if (text == nullptr) + { + return counts; + } + + for (size_t index = 0; index < length; ++index) + { + const wchar_t unit = text[index]; + const uint32_t codeUnit = static_cast(static_cast(unit)); + uint32_t codePoint = codeUnit; + + if (IsHighSurrogate(codeUnit)) + { + if (index + 1 >= length) + { + SaturatingIncrement(counts.other); // unpaired high surrogate + continue; + } + const uint32_t lowUnit = static_cast(static_cast(text[index + 1])); + if (!IsLowSurrogate(lowUnit)) + { + SaturatingIncrement(counts.other); // unpaired high surrogate + continue; + } + codePoint = 0x10000 + ((codeUnit - 0xD800) << 10) + (lowUnit - 0xDC00); + ++index; // the pair is one user-perceived character + } + else if (IsLowSurrogate(codeUnit)) + { + SaturatingIncrement(counts.other); // unpaired low surrogate + continue; + } + + if (IsCjk(codePoint)) + { + SaturatingIncrement(counts.cjk); + continue; + } + if (IsAsciiLetter(codePoint)) + { + SaturatingIncrement(counts.latin); + continue; + } + if (IsDigit(codePoint)) + { + SaturatingIncrement(counts.digit); + continue; + } + if (codePoint > 0xFFFF) + { + // Supplementary non-CJK (emoji, symbols, historic scripts). The + // character-type API below is BMP-only, and per the design the + // supplementary plane does not contribute to punctuation. + SaturatingIncrement(counts.other); + continue; + } + + // Punctuation and spaces come from the Unicode character type instead + // of a hand-written whitelist: fullwidth marks, ideographic space and + // the like all have to follow the API (A3 sampling conclusion). + WORD typeFlags = 0; + if (GetStringTypeW(CT_CTYPE1, &unit, 1, &typeFlags) && (typeFlags & (C1_PUNCT | C1_SPACE)) != 0) + { + SaturatingIncrement(counts.punct); + continue; + } + SaturatingIncrement(counts.other); + } + + return counts; +} +} // namespace MsimeStats diff --git a/windows/src/Statistics/char_classify.h b/windows/src/Statistics/char_classify.h new file mode 100644 index 00000000..c7e76d07 --- /dev/null +++ b/windows/src/Statistics/char_classify.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +namespace MsimeStats +{ +// User-perceived character counts for one committed-text event. The wire format +// keeps each class in a 16-bit field, so accumulation saturates instead of +// wrapping; the capture path also caps a single commit well below that limit. +struct CharClassCounts +{ + uint16_t cjk = 0; + uint16_t latin = 0; + uint16_t digit = 0; + uint16_t punct = 0; + uint16_t other = 0; + + uint32_t Total() const; + // Saturating addition, used when a long commit is read in blocks. + void Add(const CharClassCounts &counts); +}; + +// Classifies UTF-16 text into the five statistics classes. A surrogate pair +// counts as one character; an unpaired surrogate counts as `other`. This runs +// inside the TSF process on purpose: sending text over the statistics pipe +// would turn it into a per-keystroke stream and invalidate the privacy +// promise, so only the resulting counts ever leave the process. +CharClassCounts ClassifyText(const wchar_t *text, size_t length); +} // namespace MsimeStats diff --git a/windows/src/Statistics/stats_collector.cpp b/windows/src/Statistics/stats_collector.cpp new file mode 100644 index 00000000..a2a92f5b --- /dev/null +++ b/windows/src/Statistics/stats_collector.cpp @@ -0,0 +1,167 @@ +#include "stats_collector.h" +#include +#include +#include +#include + +// from Server.cpp +void DllAddRef(); +void DllRelease(); + +namespace +{ +// The queue is produced from TSF edit-session threads and consumed by the +// threadpool flush. SRWLOCK plus atomics are used instead of C++ static +// objects on purpose: the DLL loads into host processes with +// /Zc:threadSafeInit-, so nothing here may rely on dynamic initialization. +SRWLOCK g_queueLock = SRWLOCK_INIT; +MsimeStats::StatsEventQueue g_queue; +std::atomic g_flushInFlight{false}; +std::atomic g_sessionIdResolved{false}; +std::atomic g_sessionId{0}; + +void CALLBACK FlushStatisticsEvents(PTP_CALLBACK_INSTANCE, PVOID); + +DWORD CurrentSessionId() +{ + // Named pipes are machine-global; the prefix plus the decimal session id + // keeps fast user switching and RDP sessions apart. Both ends must compute + // this the same way (see FANY_IME_STATS_PIPE_NAME_PREFIX). + if (!g_sessionIdResolved.load(std::memory_order_acquire)) + { + DWORD sessionId = 0; + ProcessIdToSessionId(GetCurrentProcessId(), &sessionId); + g_sessionId.store(sessionId, std::memory_order_relaxed); + g_sessionIdResolved.store(true, std::memory_order_release); + } + return g_sessionId.load(std::memory_order_acquire); +} + +std::wstring BuildStatsPipeName() +{ + return std::wstring(FANY_IME_STATS_PIPE_NAME_PREFIX) + std::to_wstring(CurrentSessionId()); +} + +bool SendStatsBatch(const FanyImeStatsBatchHeader &header, const FanyImeStatsEvent *events, size_t eventCount) +{ + const std::wstring pipeName = BuildStatsPipeName(); + HANDLE pipe = INVALID_HANDLE_VALUE; + for (int attempt = 0; attempt < 2; ++attempt) + { + pipe = CreateFileW(pipeName.c_str(), GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, + SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION, nullptr); + if (pipe != INVALID_HANDLE_VALUE) + { + break; + } + if (GetLastError() != ERROR_PIPE_BUSY || !WaitNamedPipeW(pipeName.c_str(), 20)) + { + break; + } + } + if (pipe == INVALID_HANDLE_VALUE) + { + // The Server is not listening (or it just went away). Statistics are not + // critical data: drop the batch instead of retrying or buffering on + // disk. The caller adds the loss to dropped_count. + return false; + } + + // The queue capacity bounds a batch, so the whole frame fits on the stack + // (28 + 256 * 24 = 6172 bytes, far below the 16 KiB frame limit). + unsigned char frame[sizeof(FanyImeStatsBatchHeader) + + MsimeStats::StatsEventQueue::kCapacity * sizeof(FanyImeStatsEvent)] = {}; + const size_t frameBytes = sizeof(header) + eventCount * sizeof(FanyImeStatsEvent); + std::memcpy(frame, &header, sizeof(header)); + if (eventCount != 0) + { + std::memcpy(frame + sizeof(header), events, eventCount * sizeof(FanyImeStatsEvent)); + } + + DWORD bytesWritten = 0; + const BOOL writeResult = WriteFile(pipe, frame, static_cast(frameBytes), &bytesWritten, nullptr); + CloseHandle(pipe); + return writeResult && bytesWritten == frameBytes; +} + +void CALLBACK FlushStatisticsEvents(PTP_CALLBACK_INSTANCE, PVOID) +{ + FanyImeStatsEvent batch[MsimeStats::StatsEventQueue::kCapacity] = {}; + for (;;) + { + FanyImeStatsBatchHeader header; + size_t batchCount = 0; + { + AcquireSRWLockExclusive(&g_queueLock); + batchCount = g_queue.PopBatch(batch, MsimeStats::StatsEventQueue::kCapacity); + header.event_count = static_cast(batchCount); + header.payload_bytes = static_cast(batchCount * sizeof(FanyImeStatsEvent)); + header.dropped_count = g_queue.TakeDroppedCount(); + header.source_process_id = GetCurrentProcessId(); + ReleaseSRWLockExclusive(&g_queueLock); + } + + if (batchCount != 0 && !SendStatsBatch(header, batch, batchCount)) + { + AcquireSRWLockExclusive(&g_queueLock); + // The failed frame carried these events and the drop counter with + // it; put both back so the next successful frame reports the loss. + g_queue.AddDroppedCount(header.dropped_count + static_cast(batchCount)); + ReleaseSRWLockExclusive(&g_queueLock); + } + + bool queueDrained = false; + { + AcquireSRWLockExclusive(&g_queueLock); + if (g_queue.Size() == 0) + { + // Producers that pushed while this flush ran were held back by + // the in-flight flag; the queue is empty now, so re-open it. + g_flushInFlight.store(false, std::memory_order_relaxed); + queueDrained = true; + } + ReleaseSRWLockExclusive(&g_queueLock); + } + if (queueDrained) + { + break; + } + } + DllRelease(); +} +} // namespace + +void MsimeStats::QueueStatisticsEvent(const CharClassCounts &counts) +{ + if (counts.Total() == 0) + { + return; + } + + FILETIME now = {}; + GetSystemTimeAsFileTime(&now); + FanyImeStatsEvent event; + event.timestamp_utc_ft = (static_cast(now.dwHighDateTime) << 32) | now.dwLowDateTime; + event.cjk = counts.cjk; + event.latin = counts.latin; + event.digit = counts.digit; + event.punct = counts.punct; + event.other = counts.other; + + AcquireSRWLockExclusive(&g_queueLock); + g_queue.Push(event); + if (!g_flushInFlight.load(std::memory_order_relaxed)) + { + // In-memory enqueue plus one threadpool submission: the connection + // attempt and the write happen off the host's commit path, and a + // running flush keeps the flag set until it has drained the queue. + g_flushInFlight.store(true, std::memory_order_relaxed); + DllAddRef(); + if (!TrySubmitThreadpoolCallback(FlushStatisticsEvents, nullptr, nullptr)) + { + g_flushInFlight.store(false, std::memory_order_relaxed); + DllRelease(); + } + } + ReleaseSRWLockExclusive(&g_queueLock); +} diff --git a/windows/src/Statistics/stats_collector.h b/windows/src/Statistics/stats_collector.h new file mode 100644 index 00000000..91c31747 --- /dev/null +++ b/windows/src/Statistics/stats_collector.h @@ -0,0 +1,91 @@ +#pragma once + +#include "../../../engine/contracts/windows_ipc.h" +#include "char_classify.h" +#include +#include +#include + +namespace MsimeStats +{ +// Fixed-capacity FIFO between the TSF capture path and the asynchronous pipe +// sender. It deliberately has no merge, no bucketing and no timing state: the +// Server owns every aggregate. When the queue is full the oldest event is +// evicted and that loss is reported through the frame's dropped_count. Access +// must be serialized by the caller. +class StatsEventQueue +{ + public: + static constexpr size_t kCapacity = 256; + + // Returns false when the oldest event had to be evicted to make room. + bool Push(const FanyImeStatsEvent &event); + // Moves up to maxCount oldest events into out; returns how many were taken. + size_t PopBatch(FanyImeStatsEvent *out, size_t maxCount); + // Events dropped since the previous call (queue overflow, send failures). + uint32_t TakeDroppedCount(); + void AddDroppedCount(uint32_t count); + size_t Size() const + { + return _count; + } + + private: + FanyImeStatsEvent _events[kCapacity] = {}; + size_t _head = 0; + size_t _count = 0; + uint32_t _droppedCount = 0; +}; + +// Called from the TSF capture paths (composition commits and passthrough +// keys). Events with no characters are ignored. The event is appended to the +// in-memory queue and an asynchronous flush is triggered immediately; no local +// time, bucketing or active-time logic runs here, and the host's commit path +// never touches the pipe. +void QueueStatisticsEvent(const CharClassCounts &counts); +} // namespace MsimeStats + +inline bool MsimeStats::StatsEventQueue::Push(const FanyImeStatsEvent &event) +{ + if (_count == kCapacity) + { + // Overwrite the oldest event and rotate the head: the queue stays full + // and the newest event is always retained. + _events[_head] = event; + _head = (_head + 1) % kCapacity; + AddDroppedCount(1); + return false; + } + _events[(_head + _count) % kCapacity] = event; + ++_count; + return true; +} + +inline size_t MsimeStats::StatsEventQueue::PopBatch(FanyImeStatsEvent *out, size_t maxCount) +{ + if (out == nullptr) + { + return 0; + } + const size_t taken = (std::min)(maxCount, _count); + for (size_t index = 0; index < taken; ++index) + { + out[index] = _events[(_head + index) % kCapacity]; + } + _head = (_head + taken) % kCapacity; + _count -= taken; + return taken; +} + +inline uint32_t MsimeStats::StatsEventQueue::TakeDroppedCount() +{ + const uint32_t dropped = _droppedCount; + _droppedCount = 0; + return dropped; +} + +inline void MsimeStats::StatsEventQueue::AddDroppedCount(uint32_t count) +{ + const uint64_t sum = static_cast(_droppedCount) + count; + _droppedCount = sum > UINT32_MAX ? UINT32_MAX : static_cast(sum); +} diff --git a/windows/src/Statistics/stats_passthrough.h b/windows/src/Statistics/stats_passthrough.h new file mode 100644 index 00000000..64152fb8 --- /dev/null +++ b/windows/src/Statistics/stats_passthrough.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +// The three composition commit exits only see text this tip writes into the +// document. A key it does not consume is inserted by the host itself, so +// half-width digits and the symbols outside the punctuation table never reach +// those exits -- the digit class stayed at zero before this hook existed +// (2026-09). This predicate decides whether one such key is counted, and it +// stays pure so the rules can be unit tested without a TSF host. +// +// The count is a key-time prediction, not an edit confirmation: a read-only +// document, a full maxlength or an application-level shortcut leaves the key +// counted anyway. That approximation is a product decision (design 2.4); the +// filters below only drop keys that certainly are not a character the user +// typed (modifiers, functional/dead keys, no focused edit context). + +namespace MsimeStats +{ +inline bool ShouldCountPassthroughChar(wchar_t wch, bool eaten, bool selfGenerated, bool keyboardDisabled, + bool ctrlDown, bool altDown, bool winDown) +{ + // Shift stays allowed on purpose: it is what makes uppercase letters and + // the shifted symbol row their own characters. Functional keys and dead + // keys produce no printable wch, so the range check -- not a name list -- + // keeps Enter/Tab/Backspace and friends out. 0x7F is DEL, the one control + // character that sits above the printable range. + return !eaten && !selfGenerated && !keyboardDisabled && !ctrlDown && !altDown && !winDown && wch >= 0x20 && + wch != 0x7F; +} +} // namespace MsimeStats diff --git a/windows/tests/CMakeLists.txt b/windows/tests/CMakeLists.txt index 9231d894..204adf62 100644 --- a/windows/tests/CMakeLists.txt +++ b/windows/tests/CMakeLists.txt @@ -15,3 +15,10 @@ add_executable(test_key_repeat_guard key_repeat_guard.cpp) target_include_directories(test_key_repeat_guard PRIVATE ../src) target_compile_features(test_key_repeat_guard PRIVATE cxx_std_17) add_test(NAME key_repeat_guard COMMAND test_key_repeat_guard) + +# The classifier and the event queue are compiled into this target directly: +# they must stay free of TSF and DLL lifecycle dependencies. +add_executable(test_statistics statistics.cpp ../src/Statistics/char_classify.cpp) +target_include_directories(test_statistics PRIVATE ../src) +target_compile_features(test_statistics PRIVATE cxx_std_17) +add_test(NAME statistics COMMAND test_statistics) diff --git a/windows/tests/statistics.cpp b/windows/tests/statistics.cpp new file mode 100644 index 00000000..d05ef6ce --- /dev/null +++ b/windows/tests/statistics.cpp @@ -0,0 +1,215 @@ +#include "Statistics/char_classify.h" +#include "Statistics/stats_collector.h" +#include "Statistics/stats_passthrough.h" + +#include +#include +#include + +namespace +{ +int gFirstFailure = 0; + +void Check(int caseId, bool condition) +{ + // Do not use assert: Release builds must execute these checks too. + if (!condition && gFirstFailure == 0) + { + gFirstFailure = caseId; + } +} + +void ExpectClassification(int caseId, const wchar_t *text, size_t length, uint16_t cjk, uint16_t latin, uint16_t digit, + uint16_t punct, uint16_t other) +{ + const MsimeStats::CharClassCounts actual = MsimeStats::ClassifyText(text, length); + const bool matches = actual.cjk == cjk && actual.latin == latin && actual.digit == digit && actual.punct == punct && + actual.other == other; + Check(caseId, matches); +} + +FanyImeStatsEvent MakeEvent(uint64_t timestamp, uint16_t cjk) +{ + FanyImeStatsEvent event; + event.timestamp_utc_ft = timestamp; + event.cjk = cjk; + return event; +} + +void TestClassification() +{ + using MsimeStats::ClassifyText; + + Check(1, ClassifyText(nullptr, 8).Total() == 0); + Check(2, ClassifyText(L"ignored", 0).Total() == 0); + + ExpectClassification(3, L"\u4F60\u597D", 2, 2, 0, 0, 0, 0); // ideographs + ExpectClassification(4, L"abcXYZ", 6, 0, 6, 0, 0, 0); // ASCII letters + ExpectClassification(5, L"0123456789", 10, 0, 0, 10, 0, 0); // ASCII digits + ExpectClassification(6, L"\uFF0C\u3002\uFF01\uFF1F", 4, 0, 0, 0, 4, 0); // fullwidth punctuation + ExpectClassification(7, L" ", 1, 0, 0, 0, 1, 0); // ASCII space + ExpectClassification(8, L"\u3000", 1, 0, 0, 0, 1, 0); // ideographic space + ExpectClassification(9, L"\uFF10\uFF11\uFF12", 3, 0, 0, 3, 0, 0); // fullwidth digits + ExpectClassification(10, L"\uFF21", 1, 0, 0, 0, 0, 1); // fullwidth Latin letter + ExpectClassification(11, L"\uF900", 1, 1, 0, 0, 0, 0); // compatibility ideograph + + // U+20000 (CJK Extension B) is a surrogate pair and must count once as CJK. + const wchar_t extensionB[] = {static_cast(0xD840), static_cast(0xDC00)}; + ExpectClassification(12, extensionB, 2, 1, 0, 0, 0, 0); + + // Emoji: one user-perceived character, classified as other. + const wchar_t emoji[] = {static_cast(0xD83D), static_cast(0xDE00)}; + ExpectClassification(13, emoji, 2, 0, 0, 0, 0, 1); + + const wchar_t loneHigh[] = {static_cast(0xD83D)}; + const wchar_t loneLow[] = {static_cast(0xDE00)}; + ExpectClassification(14, loneHigh, 1, 0, 0, 0, 0, 1); + ExpectClassification(15, loneLow, 1, 0, 0, 0, 0, 1); + + const wchar_t highThenAscii[] = {static_cast(0xD83D), L'a'}; + ExpectClassification(16, highThenAscii, 2, 0, 1, 0, 0, 1); + + const wchar_t mixed[] = {L'\u4F60', + L'\u597D', + L'\uFF0C', + L'a', + L'b', + L'c', + L'1', + L'2', + L'3', + static_cast(0xD83D), + static_cast(0xDE00)}; + const MsimeStats::CharClassCounts mixedCounts = ClassifyText(mixed, 11); + Check(17, mixedCounts.cjk == 2 && mixedCounts.latin == 3 && mixedCounts.digit == 3 && mixedCounts.punct == 1 && + mixedCounts.other == 1); + Check(18, mixedCounts.Total() == 10); + + // Counts are 16-bit on the wire: a run longer than 65535 characters must + // saturate instead of wrapping back to zero. + const std::wstring longRun(70000, L'\u4E2D'); + const MsimeStats::CharClassCounts saturated = ClassifyText(longRun.data(), longRun.size()); + Check(19, saturated.cjk == 0xFFFF && saturated.Total() == 0xFFFF); + + MsimeStats::CharClassCounts sum; + sum.Add(mixedCounts); + sum.Add(mixedCounts); + Check(20, sum.Total() == 20 && sum.cjk == 4 && sum.other == 2); +} + +void TestPassthroughPredicate() +{ + using MsimeStats::ShouldCountPassthroughChar; + + // A plain half-width digit in Chinese mode with no modifiers is the case + // this predicate exists for: the tip does not eat it, the host inserts it, + // and no commit exit ever sees it (digit stayed at zero before this hook). + Check(301, ShouldCountPassthroughChar(L'1', false, false, false, false, false, false)); + + // The printable range boundaries. Functional keys arrive with a control + // character from ToUnicode (Enter 0x0D, Tab 0x09, Backspace 0x08, Esc + // 0x1B) or with 0, so the range check -- not a key name list -- drops them. + Check(302, !ShouldCountPassthroughChar(L'\0', false, false, false, false, false, false)); + Check(303, !ShouldCountPassthroughChar(static_cast(0x1F), false, false, false, false, false, false)); + Check(304, ShouldCountPassthroughChar(static_cast(0x20), false, false, false, false, false, false)); + Check(305, ShouldCountPassthroughChar(static_cast(0x7E), false, false, false, false, false, false)); + Check(306, !ShouldCountPassthroughChar(static_cast(0x7F), false, false, false, false, false, false)); + Check(307, !ShouldCountPassthroughChar(L'\r', false, false, false, false, false, false)); + Check(308, !ShouldCountPassthroughChar(L'\t', false, false, false, false, false, false)); + Check(309, !ShouldCountPassthroughChar(L'\b', false, false, false, false, false, false)); + Check(310, !ShouldCountPassthroughChar(L'\x1B', false, false, false, false, false, false)); + + // Every single filter blocks the count on its own. + Check(311, !ShouldCountPassthroughChar(L'a', true, false, false, false, false, false)); // eaten + Check(312, !ShouldCountPassthroughChar(L'a', false, true, false, false, false, false)); // self-generated + Check(313, !ShouldCountPassthroughChar(L'a', false, false, true, false, false, false)); // keyboard disabled + Check(314, !ShouldCountPassthroughChar(L'a', false, false, false, true, false, false)); // Ctrl + Check(315, !ShouldCountPassthroughChar(L'a', false, false, false, false, true, false)); // Alt + Check(316, !ShouldCountPassthroughChar(L'a', false, false, false, false, false, true)); // Win + + // Shift is deliberately not a parameter: uppercase letters and the + // shifted symbol row still count (the character already carries Shift). + Check(317, ShouldCountPassthroughChar(L'A', false, false, false, false, false, false)); + Check(318, ShouldCountPassthroughChar(L'!', false, false, false, false, false, false)); + Check(319, ShouldCountPassthroughChar(L' ', false, false, false, false, false, false)); + Check(320, ShouldCountPassthroughChar(L'+', false, false, false, false, false, false)); + + // A non-ASCII keyboard layout still produces one countable character. + Check(321, ShouldCountPassthroughChar(L'\u4F60', false, false, false, false, false, false)); + + // The symbols this hook recovers outside the punctuation table classify as + // punct, which is what the digit fix must show in the aggregate. + ExpectClassification(322, L"+-=/|", 5, 0, 0, 0, 5, 0); +} + +void TestEventQueue() +{ + MsimeStats::StatsEventQueue queue; + FanyImeStatsEvent popped[MsimeStats::StatsEventQueue::kCapacity] = {}; + + Check(101, queue.PopBatch(popped, MsimeStats::StatsEventQueue::kCapacity) == 0); + Check(102, queue.PopBatch(nullptr, 4) == 0); + + for (uint64_t index = 0; index < MsimeStats::StatsEventQueue::kCapacity; ++index) + { + Check(103, queue.Push(MakeEvent(index, 1))); + } + Check(104, queue.Size() == MsimeStats::StatsEventQueue::kCapacity); + + // One event more than the capacity: the oldest is evicted, the newest is + // retained, and the loss is counted exactly once. + Check(105, !queue.Push(MakeEvent(1000, 5))); + Check(106, queue.Size() == MsimeStats::StatsEventQueue::kCapacity); + Check(107, queue.TakeDroppedCount() == 1); + Check(108, queue.TakeDroppedCount() == 0); + + const size_t taken = queue.PopBatch(popped, MsimeStats::StatsEventQueue::kCapacity); + Check(109, taken == MsimeStats::StatsEventQueue::kCapacity); + Check(110, popped[0].timestamp_utc_ft == 1); // event 0 was evicted + Check(111, popped[taken - 1].timestamp_utc_ft == 1000); + Check(112, queue.Size() == 0); + + for (uint64_t index = 0; index < 3; ++index) + { + queue.Push(MakeEvent(index + 10, 1)); + } + Check(113, queue.PopBatch(popped, 2) == 2); + Check(114, popped[0].timestamp_utc_ft == 10 && popped[1].timestamp_utc_ft == 11); + Check(115, queue.Size() == 1); + Check(116, queue.PopBatch(popped, MsimeStats::StatsEventQueue::kCapacity) == 1); + Check(117, popped[0].timestamp_utc_ft == 12); + + // The drop counter saturates instead of wrapping. + queue.AddDroppedCount(0xFFFFFFFFu); + queue.AddDroppedCount(7); + Check(118, queue.TakeDroppedCount() == 0xFFFFFFFFu); +} + +void TestWireContract() +{ + Check(201, sizeof(FanyImeStatsBatchHeader) == 28); + Check(202, sizeof(FanyImeStatsEvent) == 24); + Check(203, offsetof(FanyImeStatsEvent, timestamp_utc_ft) == 0); + Check(204, offsetof(FanyImeStatsEvent, cjk) == 8); + Check(205, FANY_IME_STATS_MAGIC == 0x54415453); + Check(206, FANY_IME_STATS_VERSION == 1); + + const FanyImeStatsBatchHeader header; + Check(207, header.magic == FANY_IME_STATS_MAGIC && header.version == FANY_IME_STATS_VERSION); + Check(208, header.header_size == 28 && header.payload_bytes == 0 && header.event_count == 0 && + header.dropped_count == 0 && header.source_process_id == 0); + + // A completely full queue has to fit into one frame. + Check(209, sizeof(FanyImeStatsBatchHeader) + MsimeStats::StatsEventQueue::kCapacity * sizeof(FanyImeStatsEvent) <= + FANY_IME_STATS_MAX_FRAME_BYTES); +} +} // namespace + +int main() +{ + TestWireContract(); + TestClassification(); + TestPassthroughPredicate(); + TestEventQueue(); + return gFirstFailure; +}