Merge branch 'main' into ci/self-hosted-runner-signing

This commit is contained in:
Evan authored and GitHub committed 2026-09-06 16:55:18 +09:00
commit ef55ff67bb
241 files changed
+7211 -5170

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Bulk reformats. `git blame --ignore-revs-file .git-blame-ignore-revs` skips these, and
# `git config blame.ignoreRevsFile .git-blame-ignore-revs` makes it the default for this checkout.
# style: 用 .clang-format 重排 server / windows / ui / log
42238a2af693fcd0dd80354b2b450c6647647dd2
+16 -22
View File
@@ -2,6 +2,7 @@ name: Bug 反馈 / Bug Report
description: 报告水杉输入法的错误或异常行为。Report a bug or unexpected behavior.
title: "[Bug] "
labels: ["bug"]
type: Bug
body:
- type: markdown
attributes:
@@ -18,14 +19,13 @@ body:
description: 问题主要出现在哪个部分?Which part is primarily affected?
multiple: true
options:
- TSF 前端 / MSIME-Windows
- Server / 引擎服务 / MSIME-Server
- 设置界面 / Settings
- 候选窗 UI / Candidate UI
- 词典 / MSIME-Engine/dictionary
- 辅助码 / MSIME-Engine
- 候选窗 / Candidate window
- 词典 / Dictionary and Lexicon
- 辅助码 / Help Code
- 设置 / Settings
- 安装器 / Installer
- 文档 / 官网
- 引擎服务 / Engine
- 文档 / Docs
- 不确定 / Not sure
- 其他 / Other
validations:
@@ -33,10 +33,10 @@ body:
- type: textarea
id: description
attributes:
label: 问题描述 / Description
description: 发生了什么?期望是什么?What happened, and what did you expect?
label: 问题与行为 / Problem & Actual behavior
description: 发生了什么?What happened?
placeholder: |
简要说明问题现象与预期行为。
简要说明问题现象与行为。
validations:
required: true
- type: textarea
@@ -57,13 +57,6 @@ body:
description: 正常情况下应该怎样?What should happen instead?
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际行为 / Actual Behavior
description: 实际观察到了什么?What actually happened?
validations:
required: true
- type: input
id: version
attributes:
@@ -77,7 +70,7 @@ body:
attributes:
label: 系统环境 / OS
description: Windows 版本与版本号。Windows edition and version.
placeholder: e.g. Windows 11 24H2 / Windows 10 Pro 22H2
placeholder: e.g. Windows 11 Pro 24H2 / Windows 10 Pro 22H2
validations:
required: true
- type: dropdown
@@ -85,8 +78,9 @@ body:
attributes:
label: 输入方案 / Input Scheme
options:
- 小鹤双拼 / Xiaohe Shuangpin
- 全拼 / Quanpin
- 全拼 / Full Pinyin
- 双拼 / Double Pinyin
- 五笔 / Wubi
- 其他 / Other
- 不适用 / N/A
validations:
@@ -106,9 +100,9 @@ body:
- type: textarea
id: logs
attributes:
label: 日志 / 截图 / 录屏(可选)
label: 日志(可选)
description: |
可粘贴相关日志,或附上截图 / 录屏链接。
可粘贴相关日志。
Paste relevant logs, or attach screenshots / recordings.
render: shell
validations:
+14 -4
View File
@@ -1,11 +1,21 @@
blank_issues_enabled: false
contact_links:
# First on purpose. Blank issues are disabled, so this chooser is the only path someone who found
# a vulnerability sees, and without this row the most natural next action is to file a public bug
# -- which is exactly what SECURITY.md forbids. Private vulnerability reporting is enabled on this
# repository, so the advisory form below is a working channel, not just a document to read.
- name: 安全漏洞 / Security vulnerability
url: https://github.com/metasequoiaime/MSIME-Windows/security/advisories/new
about: 疑似漏洞请私下上报,不要提交公开 issue。Report privately; do not open a public issue.
- name: 组词引擎与词库问题 / Engine and dictionary
url: https://github.com/metasequoiaime/MSIME-Engine/issues
about: 选字顺序、词库缺词等属于共享引擎与词库,请到对应仓库反馈。Candidate order and missing words belong to the shared engine.
- name: Telegram 群组 / Telegram Group
url: https://t.me/msimegroup
about: 使用问题、讨论与日常交流。Questions and discussions.
- name: QQ 群 / QQ Group
url: https://github.com/metasequoiaime/MSIME-Windows
about: "QQ 群号 829919142(见仓库简介)。QQ Group: 829919142."
url: https://github.com/metasequoiaime/MSIME-Windows#readme
about: "QQ 群号 829919142(手动搜索群号加入)。QQ Group: 829919142."
- name: 项目文档 / Documentation
url: https://github.com/metasequoiaime/MSIME-Docs
about: 水杉输入法文档仓库。Docs for Metasequoia IME.
url: https://msime.app/docs/
about: 水杉输入法用户文档。User documentation for Metasequoia IME.
+8 -8
View File
@@ -2,6 +2,7 @@ name: 功能建议 / Feature Request
description: 为水杉输入法提出新功能或改进建议。Propose a new feature or improvement.
title: "[Feat] "
labels: ["enhancement"]
type: Feature
body:
- type: markdown
attributes:
@@ -18,14 +19,13 @@ body:
description: 该功能主要涉及哪个部分?Which part does this mainly involve?
multiple: true
options:
- TSF 前端 / MSIME-Windows
- Server / 引擎服务 / MSIME-Server
- 设置界面 / Settings
- 候选窗 UI / Candidate UI
- 词典 / MSIME-Engine/dictionary
- 辅助码 / MSIME-Engine
- 候选窗 / Candidate window
- 词典 / Dictionary and Lexicon
- 辅助码 / Help Code
- 设置 / Settings
- 安装器 / Installer
- 文档 / 官网
- 引擎服务 / Engine
- 文档 / Docs
- 不确定 / Not sure
- 其他 / Other
validations:
@@ -33,7 +33,7 @@ body:
- type: textarea
id: problem
attributes:
label: 要解决的问题 / Problem
label: 问题与动机 / Problem & Motivation
description: 当前缺少什么,或现有体验哪里不够好?What problem does this solve?
placeholder: |
例如:无法…… / 使用某功能时很麻烦……
+18 -13
View File
@@ -4,29 +4,25 @@ on:
push:
branches: [main]
pull_request:
# A release-please pull request only rewrites these three files, and its pull_request run would
# be created in action_required and never execute: GITHUB_TOKEN may not start workflows, so
# anything it triggers waits for a human. Skipping it here keeps an unapprovable run out of the
# checks list. The release workflow dispatches this workflow on the release branch instead,
# which is the one trigger GITHUB_TOKEN is allowed to fire, so the version bump still gets a
# full build and the checks land on the same commit.
paths-ignore:
- version.txt
- CHANGELOG.md
- .release-please-manifest.json
workflow_dispatch:
merge_group:
permissions:
contents: read
concurrency:
group: ci-${{ github.event.pull_request.head.ref || github.ref_name }}
group: ci-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
uses: ./.github/workflows/quality.yml
lock:
name: Product inputs
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
# The dictionary validator is compared against the engine's copy of it, so this job needs the
# submodule's contents and not just its gitlink.
@@ -37,6 +33,10 @@ jobs:
run: python3 scripts/check-dictionary-contract.py
- name: Test rejection of changed or mismatched inputs
run: python3 -m unittest discover -s tests -p 'test_product_lock.py'
- name: Test local environment setup in a moved checkout
run: python3 -m unittest discover -s tests -p 'test_prepare_env.py'
- name: Test the automatic release CI gate
run: python3 -m unittest discover -s tests -p 'test_release_pr_ci.py'
- name: Read the reviewed lock
id: lock
run: |
@@ -46,6 +46,7 @@ jobs:
windows:
name: Windows ${{ matrix.name }}
runs-on: windows-2025
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
@@ -86,7 +87,7 @@ jobs:
shell: pwsh
run: cmake --build windows/build --config Release --parallel
- name: Test the wire contract on this architecture
run: ctest --test-dir windows/build -C Release --output-on-failure
run: ctest --no-tests=error --test-dir windows/build -C Release --output-on-failure
# Architecture-independent, so it runs once rather than on both matrix legs.
- name: Check the installer language file lands where ISCC looks
@@ -157,7 +158,7 @@ jobs:
run: cmake --build ui/build --config Release --parallel
- name: Test
shell: pwsh
run: ctest --test-dir ui/build -C Release --verbose --timeout 60
run: ctest --no-tests=error --test-dir ui/build -C Release --verbose --timeout 60
pages:
name: Settings page
@@ -213,3 +214,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Test full and light package contents without registering or installing
run: ./installer/tests/package-files.ps1
- name: Stop local packaging after failed component builds
run: ./installer/tests/build-failures.ps1
- name: Test local Server test entrypoints in a moved checkout
run: ./tests/server-test-entrypoints.ps1
+37
View File
@@ -0,0 +1,37 @@
name: CodeQL
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '11 3 * * 1'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: codeql-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: CodeQL / ${{ matrix.language }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [actions, c-cpp, javascript-typescript, python]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
with:
languages: ${{ matrix.language }}
# C/C++ source analysis complements, rather than replaces, native build CI.
build-mode: none
- uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
with:
category: '/language:${{ matrix.language }}'
+66 -11
View File
@@ -12,16 +12,21 @@ on:
paths:
- 'vendor/MetasequoiaImeEngine'
# Read-only by default, matching every other workflow here. The write scopes this job needs to merge
# the bump are granted on the job instead, so a second job added later starts from the same floor
# rather than inheriting write.
permissions:
contents: write
pull-requests: write
contents: read
jobs:
triage:
if: github.actor == 'dependabot[bot]'
name: Classify the engine range
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
env:
SUBMODULE: vendor/MetasequoiaImeEngine
ENGINE: metasequoiaime/MSIME-Engine
@@ -36,9 +41,11 @@ jobs:
- name: Resolve the engine range
id: range
env:
BASE_REF: ${{ github.base_ref }}
run: |
set -euo pipefail
base=$(git rev-parse "origin/${{ github.base_ref }}:$SUBMODULE")
base=$(git rev-parse "origin/$BASE_REF:$SUBMODULE")
head=$(git rev-parse "HEAD:$SUBMODULE")
echo "base=$base" >> "$GITHUB_OUTPUT"
echo "head=$head" >> "$GITHUB_OUTPUT"
@@ -46,17 +53,47 @@ jobs:
- name: Classify the changed files
id: classify
env:
BASE: ${{ steps.range.outputs.base }}
HEAD: ${{ steps.range.outputs.head }}
run: |
set -euo pipefail
base='${{ steps.range.outputs.base }}'
head='${{ steps.range.outputs.head }}'
base="$BASE"
head="$HEAD"
if [ "$base" = "$head" ]; then
echo "verdict=inert" >> "$GITHUB_OUTPUT"
exit 0
fi
# Fail closed on anything but a plain fast-forward. The compare API answers with an empty
# file list whenever head is already an ancestor of base, which the emptiness test below
# would otherwise read as "nothing suspicious changed" and auto-merge — silently accepting
# a downgrade that reverts engine source the frontend depends on.
status=$(gh api "repos/$ENGINE/compare/$base...$head" --jq '.status')
echo "compare status: $status"
if [ "$status" != "ahead" ]; then
echo "verdict=review" >> "$GITHUB_OUTPUT"
delimiter="EOF_$(openssl rand -hex 16)"
{
echo "reason<<$delimiter"
echo "引擎范围不是单纯的快进(compare status: $status),需要人工确认。"
echo "$delimiter"
} >> "$GITHUB_OUTPUT"
exit 0
fi
files=$(gh api --paginate "repos/$ENGINE/compare/$base...$head" --jq '.files[].filename')
echo "改动文件:"; echo "$files" | sed 's/^/ /'
if [ -z "$files" ]; then
echo "verdict=review" >> "$GITHUB_OUTPUT"
delimiter="EOF_$(openssl rand -hex 16)"
{
echo "reason<<$delimiter"
echo "compare 没有返回任何文件,无法判定这次 bump 是否安全。"
echo "$delimiter"
} >> "$GITHUB_OUTPUT"
exit 0
fi
# Provably inert: documentation, CI wiring, dependency manifests, licence text. Everything
# else - sources, build files, and bare paths such as a nested submodule pointer - needs a
@@ -68,10 +105,13 @@ jobs:
echo "verdict=inert" >> "$GITHUB_OUTPUT"
else
echo "verdict=review" >> "$GITHUB_OUTPUT"
# A random delimiter: the file names come from another repository, so a path that
# happened to be the literal delimiter could otherwise close the block early.
delimiter="EOF_$(openssl rand -hex 16)"
{
echo 'reason<<EOF'
echo "reason<<$delimiter"
echo "$suspicious"
echo EOF
echo "$delimiter"
} >> "$GITHUB_OUTPUT"
fi
@@ -90,18 +130,33 @@ jobs:
fi
- name: Enable auto-merge
if: steps.classify.outputs.verdict == 'inert' && steps.lock.outputs.locked == 'yes'
if: steps.classify.outputs.verdict == 'inert'
env:
BASE: ${{ steps.range.outputs.base }}
HEAD: ${{ steps.range.outputs.head }} && steps.lock.outputs.locked == 'yes'
run: |
set -euo pipefail
gh pr merge --auto --squash "$PR_URL"
gh pr comment "$PR_URL" --body "引擎 \`${{ steps.range.outputs.base }}\` → \`${{ steps.range.outputs.head }}\` 的改动全部落在文档、CI 配置和依赖清单里,没有源码、构建文件或嵌套 submodule 指针。已开启自动合并,CI 通过后自行合入。"
body="$RUNNER_TEMP/engine-bump-comment.md"
printf '引擎 `%s` → `%s` 的改动全部落在文档、CI 配置和依赖清单里,没有源码、构建文件或嵌套 submodule 指针。已开启自动合并,CI 通过后自行合入。\n' "$BASE" "$HEAD" > "$body"
gh pr comment "$PR_URL" --body-file "$body"
# Every value below reaches the shell through the environment, never through ${{ }}
# interpolation. REASON is a list of file names from another repository: spliced into the
# command text, a path containing a single quote would have closed the quoting and run as a
# command, on a runner holding a contents:write token for this repository.
- name: Hold for review
if: steps.classify.outputs.verdict == 'review'
env:
BASE: ${{ steps.range.outputs.base }}
HEAD: ${{ steps.range.outputs.head }}
REASON: ${{ steps.classify.outputs.reason }}
run: |
set -euo pipefail
gh pr edit "$PR_URL" --add-label "needs-review" || true
gh pr comment "$PR_URL" --body "$(printf '引擎 `%s` → `%s` 的改动里有需要人看一眼的部分,未开启自动合并:\n\n```\n%s\n```\n\n源码、`CMakeLists.txt` 或嵌套 submodule 指针的变化都可能改变行为,而前端 CI 未必覆盖得到——`helpcode_enabled()` 与 `handle_character()` 的语义变更就是这样漏过去的。' '${{ steps.range.outputs.base }}' '${{ steps.range.outputs.head }}' '${{ steps.classify.outputs.reason }}')"
body="$RUNNER_TEMP/engine-bump-comment.md"
printf '引擎 `%s` → `%s` 的改动里有需要人看一眼的部分,未开启自动合并:\n\n```\n%s\n```\n\n源码、`CMakeLists.txt` 或嵌套 submodule 指针的变化都可能改变行为,而前端 CI 未必覆盖得到——`helpcode_enabled()` 与 `handle_character()` 的语义变更就是这样漏过去的。\n' "$BASE" "$HEAD" "$REASON" > "$body"
gh pr comment "$PR_URL" --body-file "$body"
- name: Ask for the lock to follow
if: steps.lock.outputs.locked == 'no'
+50
View File
@@ -0,0 +1,50 @@
name: Repository quality
# Called by CI so release-please's workflow_dispatch receives these checks too.
on:
workflow_call:
permissions:
contents: read
jobs:
workflows:
name: Workflow validation
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: '1.26'
cache: false
- name: Install actionlint
run: go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
- name: Validate workflows and embedded shell scripts
env:
# Ignore style suggestions, but fail on shell correctness warnings and errors.
SHELLCHECK_OPTS: --severity=warning
run: actionlint -color
# Part of "Workflow validation" rather than a job of its own: it is a two-second grep, and this
# job is already a required check, so the guard starts protecting main immediately.
- name: Reject developer-specific paths in build inputs
run: bash scripts/ci/check-developer-paths.sh
# Formatting is platform independent, so it runs here on Ubuntu rather than costing a Windows
# runner. Cheap and early: a formatting slip fails in seconds instead of after the full compile.
- name: Check formatting
run: scripts/format.sh --check
dependencies:
name: Dependency review
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# This job must not skip. It is one of main's required status checks, and a required check that reports "skipped" blocks a merge instead of passing it. Guarding it on the pull_request event left the release pull request unmergeable by its own automation: ci.yml ignores that pull request by path and the release workflow dispatches ci.yml on the release branch instead, so this job saw workflow_dispatch and skipped, and land-release-pr.sh got "the base branch policy prohibits the merge" after everything else came back green.
#
# A pull_request event supplies both refs on its own. Every other trigger has to be told what to compare, and comparing the default branch against the checked-out commit is the same question the pull request asks.
- uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4
with:
fail-on-severity: high
base-ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.repository.default_branch }}
head-ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+98 -19
View File
@@ -1,10 +1,12 @@
name: Release
# Building and publishing only ever happens on workflow_dispatch. The code signing certificate
# has a limited number of signatures, so a release costs something real and is not something a merge
# to main should spend on its own. The push trigger only keeps release-please's pull request current
# and green; merging that pull request creates the draft, and dispatching this workflow against the
# draft's tag is what builds, signs and publishes it.
# Releasing is automatic. A push to main that carries a fix or a feat ends with a published installer, without anybody merging or dispatching anything: this workflow refreshes release-please's pull request, tests it, merges it, turns the merge into a draft release, then builds, signs and publishes that draft.
#
# The whole cycle runs inside the one run that the original push started. land-release-pr.sh merges with GITHUB_TOKEN, whose pushes do not start workflow runs, so the merge cannot produce a second Release run queued behind this one on the concurrency group.
#
# This spends a code signing signature on every releasable merge to main. The certificate allows 3000 signatures a month, so the quota is not what constrains this; what it spends is release hygiene -- see docs/product-release.md.
#
# workflow_dispatch stays, and is now a repair path rather than the normal one. It builds an existing draft, which is what a run that failed after creating the draft leaves behind.
on:
push:
branches:
@@ -27,38 +29,83 @@ jobs:
prepare:
name: Prepare release metadata
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
# actions: write lets check-release-pr.sh dispatch ci.yml on the release branch, which is the
# only trigger GITHUB_TOKEN is allowed to fire.
actions: write
# Read the PR-triggered run; merging stays scoped to GITHUB_TOKEN.
actions: read
contents: write
pull-requests: write
outputs:
# Never true on push: see the comment above the triggers. release-please still creates the
# draft release there, it just does not get built until somebody asks for it.
should_publish: ${{ github.event_name == 'workflow_dispatch' }}
tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || steps.release.outputs.tag_name }}
version: ${{ github.event_name == 'workflow_dispatch' && steps.requested_release.outputs.version || steps.release.outputs.version }}
target_sha: ${{ github.event_name == 'workflow_dispatch' && steps.requested_release.outputs.target_sha || steps.release.outputs.sha }}
should_publish: ${{ github.event_name == 'workflow_dispatch' || steps.target.outputs.release_created == 'true' }}
tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || steps.target.outputs.tag_name }}
version: ${{ github.event_name == 'workflow_dispatch' && steps.requested_release.outputs.version || steps.target.outputs.version }}
target_sha: ${{ github.event_name == 'workflow_dispatch' && steps.requested_release.outputs.target_sha || steps.target.outputs.target_sha }}
steps:
- name: Check out release automation
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Prepare release
# Separate release metadata from PR updates to preserve release-please ordering.
# Recover an already-merged release before calculating the next PR, matching release-please's
# normal releases-then-pull-requests ordering.
- name: Require the release metadata credential
if: ${{ github.event_name == 'push' }}
id: release
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
run: |
if [[ -z "$RELEASE_TOKEN" ]]; then
echo '::error::RELEASE_PLEASE_TOKEN must allow contents and workflows writes to create release tags.'
exit 1
fi
- name: Recover releases from previously merged pull requests
if: ${{ github.event_name == 'push' }}
id: release_pr
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
skip-github-pull-request: true
- name: Test the release pull request
- name: Refresh the release pull request
if: ${{ github.event_name == 'push' }}
id: release_update
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5
with:
# A trusted credential starts normal PR checks without manual workflow approval.
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
skip-github-release: true
- name: Test and merge the release pull request
if: ${{ github.event_name == 'push' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: bash scripts/ci/check-release-pr.sh
run: bash scripts/ci/land-release-pr.sh
- name: Create the release for the merged pull request
if: ${{ github.event_name == 'push' }}
id: release
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5
with:
# Creating a tag at a historical workflow-changing commit needs workflows:write.
# Only GITHUB_TOKEN merges to main, preventing a second Release run.
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
skip-github-pull-request: true
- name: Resolve the release this run publishes
if: ${{ github.event_name == 'push' }}
id: target
env:
FIRST_CREATED: ${{ steps.release_pr.outputs.release_created }}
FIRST_TAG: ${{ steps.release_pr.outputs.tag_name }}
FIRST_VERSION: ${{ steps.release_pr.outputs.version }}
FIRST_SHA: ${{ steps.release_pr.outputs.sha }}
SECOND_CREATED: ${{ steps.release.outputs.release_created }}
SECOND_TAG: ${{ steps.release.outputs.tag_name }}
SECOND_VERSION: ${{ steps.release.outputs.version }}
SECOND_SHA: ${{ steps.release.outputs.sha }}
run: bash scripts/ci/resolve-auto-release.sh
- name: Validate requested draft release
if: ${{ github.event_name == 'workflow_dispatch' }}
@@ -74,6 +121,7 @@ jobs:
needs: prepare
if: ${{ needs.prepare.outputs.should_publish == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 60
outputs:
dictionary_tag: ${{ steps.lock.outputs.dictionary_tag }}
steps:
@@ -264,6 +312,11 @@ jobs:
timeout-minutes: 60
permissions:
contents: write
# Build provenance. Unlike Authenticode this costs nothing and needs no certificate, and the
# Sigstore transparency log it writes to is outside this organisation's control, so it is the
# one integrity signal a repository admin cannot forge by swapping a release asset.
id-token: write
attestations: write
env:
DICTIONARY_TAG: ${{ needs.dependencies.outputs.dictionary_tag }}
# download-dictionaries.sh stages MetasequoiaImeDict/out under this root, and
@@ -391,6 +444,11 @@ jobs:
installer/app_data/product-manifest.json
if-no-files-found: error
- name: Attest the installer's build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: ${{ steps.asset.outputs.asset_path }}
- name: Revalidate the draft release target
shell: bash
env:
@@ -411,4 +469,25 @@ jobs:
ASSET_SHA256: ${{ steps.asset.outputs.asset_sha256 }}
SIGNING_ENABLED: ${{ steps.signing.outputs.signing_enabled }}
PRODUCT_MANIFEST: installer/app_data/product-manifest.json
RELEASE_TRIGGER: ${{ github.event_name }}
run: bash scripts/ci/publish-release.sh
# msime.app/update.json is what the settings page's "检查更新" reads, and MSIME-Web only
# regenerated it from a */30 cron. Scheduled events on public repositories are delayed heavily
# -- measured runs sat roughly two hours apart -- so the site advertised an old version for
# hours after every release. MSIME-Web already declares a repository_dispatch trigger for
# exactly this and nothing was ever sending it.
#
# This runs after publishing on purpose. If the dispatch fails the release is already out and
# correct; what the failure means is that the website is now stale and someone has to act, so
# it is reported as a failure rather than swallowed.
- name: Refresh the download manifest on msime.app
shell: bash
env:
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
run: |
if [[ -z "$GH_TOKEN" ]]; then
echo '::error::RELEASE_PLEASE_TOKEN is required to notify MSIME-Web; msime.app/update.json will stay on the previous version until its cron catches up.'
exit 1
fi
gh api --method POST repos/metasequoiaime/MSIME-Web/dispatches -f event_type=update-manifest
+25
View File
@@ -0,0 +1,25 @@
# Canonical names and addresses for `git shortlog`, `git log --use-mailmap` and GitHub's
# contributor graph. Nothing here rewrites history: the commits keep the identity they were
# authored with, this file only tells git which of them are the same person.
#
# Two people account for almost all of it, each appearing under several names:
# - fanlusky, also committing as fanlumaster and "Lu Fan(SonnyCalcr)"
# - houko, also committing as "Evan" and "Evan Hu", from two addresses
#
# Add new aliases here rather than rewriting history. Three product repositories pin the engine
# submodule by SHA, so any rewrite invalidates those pins.
fanlusky <1730976608@qq.com>
fanlusky <1730976608@qq.com> <1730976608@gmail.com>
fanlusky <1730976608@qq.com> fanlumaster <1730976608@qq.com>
fanlusky <1730976608@qq.com> fanlumaster <1730976608@gmail.com>
fanlusky <1730976608@qq.com> Lu Fan(SonnyCalcr) <1730976608@qq.com>
fanlusky <1730976608@qq.com> SonnyCalcr <1730976608@qq.com>
Evan Hu <suzukaze.haduki@gmail.com>
Evan Hu <suzukaze.haduki@gmail.com> Evan <suzukaze.haduki@gmail.com>
Evan Hu <suzukaze.haduki@gmail.com> Evan <e-hu@unext.jp>
Evan Hu <suzukaze.haduki@gmail.com> Evan Hu <e-hu@unext.jp>
Neptrue Lin <29837162+Neptrue-Lin@users.noreply.github.com>
Neptrue Lin <29837162+Neptrue-Lin@users.noreply.github.com> Neptrue_Lin <29837162+Neptrue-Lin@users.noreply.github.com>
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.1.2"
".": "0.3.1"
}
+26 -6
View File
@@ -28,6 +28,10 @@ Windows 端的全部一方源码在本仓。**合仓改变的是仓库数量,
- **候选与输入状态的权威在 Server 和引擎**,页面只负责展示和发出用户动作,不要在网页侧复制状态机。
- `ui-html/webview2/shared/` 是 Engine web 契约的副本,由 `ui-html/scripts/sync-contracts.py` 生成,CI 用 `--check` 验证它和 submodule 一致。手改这个目录会被 CI 拦下来,改契约要改 Engine。
Server 当前仍使用 Engine 的兼容 `InputSession`,尚未迁移公共 `Session` facade。
辅助码筛选与候选提示都按会话配置,禁止用全局默认码表驱动活动会话。
`RuntimePaths::legacy()` 在适配器创建时捕获现有安装布局;完整资源包和用户数据代际切换仍待接入。
## 构建
每个组件是独立的 CMake 工程,各自带 `vcpkg.json` 和 preset,从仓库根目录指定 `-S`:
@@ -54,14 +58,29 @@ cmake -S ui -B ui/build -A x64 # GUI 框架
本地测试打包见 [windows/AGENTS.md](windows/AGENTS.md) 的构建与验证。对外发布走 `.github/workflows/release.yml`,产出的就是历来挂在本仓 Release 上的 `MetasequoiaIME_Setup_v<版本>.exe`。
版本号由 release-please 管理,真源是根目录的 `version.txt`。**发布是手动的**:签名证书的签名次数有限,一次发布是实打实的开销,不该由「合了个 PR」这种事替你花掉。
版本号由 release-please 管理,真源是根目录的 `version.txt`。**发布是全自动的**:往 `main` 合一个带 `fix:` 或 `feat:` 的 PR,最后就会有一个签好名的安装包挂在 Release 上,中间没有任何一步等人。
往 `main` 推提交后只发生两件事:release-please 更新那个 release PR,`check-release-pr.sh` 给它挂上一次通过的 CI(`GITHUB_TOKEN` 开不出 workflow run,必须用 `workflow_dispatch` 顶上,否则开着 required status check 的 release PR 永远合不了)。想发版时:
这一整条链跑在那次 push 触发的同一个 run 里:
1. 合并 release PR —— 这一步只生成 draft release 和 tag,不构建、不签名。
2. 手动触发 `Release` workflow,`tag` 填那个 draft 的 tag —— 这一步才构建、签名、发布。
1. release-please 先用 `RELEASE_PLEASE_TOKEN` 补建已有合并版本的元数据,再用该凭据把这次 push 的提交刷进 release PR,触发正常的 PR 检查。
2. `land-release-pr.sh` 等待该 PR 精确 head 的 `pull_request` CI,绿了就用 `GITHUB_TOKEN` 合并它。不能以手动派发检查替代 PR 检查:后者才会进入 PR 的检查汇总并满足门禁。
3. release-please 再跑一次,用 `RELEASE_PLEASE_TOKEN` 只创建 draft release 和 tag(`skip-github-pull-request: true`)。维护 PR 的调用只写版本分支(`skip-github-release: true`)。
4. 构建、签名、把 exe 挂上去、draft 转正式。
同一个 tag 重复触发会被 `validate-draft-release.sh` 挡下:发布之后它就不是 draft 了,不会二次消耗签名次数。
合并用的是 `GITHUB_TOKEN`,而用该 token 推的提交不会触发 workflow —— 这是设计依赖的性质,不是要绕开的限制:它保证一次 push 只有一个 Release run,不会再冒出第二个卡在 concurrency 上。手工点 merge 那个 PR 也能得到同样结果,只是提前了一个 run。
**每次可发布的合并都花掉一次签名额度**,这正是之前的手动设计要避免的事。取舍的理由记在 [docs/product-release.md](docs/product-release.md)。
`workflow_dispatch` 保留下来,它同时是修复通道和发布频道的入口:run 在建出 draft 之后失败时,拿那个 tag 重跑即可。同一个 tag 重复触发会被 `validate-draft-release.sh` 挡下——发布之后它就不是 draft 了,不会重复发布同一个版本。
**两个频道由触发方式决定,`publish-release.sh` 据此选状态**([#167](https://github.com/metasequoiaime/MSIME-Windows/issues/167)):
| 频道 | 触发 | 发布状态 | 标题 |
|---|---|---|---|
| 自动构建 | push | `--prerelease` | `v<版本>(自动构建)`,说明里带提示 |
| 发布 | `workflow_dispatch` | `--prerelease=false --latest` | `v<版本>` |
GitHub 没有自定义频道,只有 Latest / Pre-release / Draft 三种状态,所以这里用 `prerelease` 标志承载「自动、未经挑选」,而不是承载「内测」。产品仍在内测这件事写在 release 说明和 [docs/installation.md](docs/installation.md) 里——那才是该做稳定性声明的地方,而这个标志同时还得用来分隔频道,兼不了两份职责。
`windows/src/IME/MetasequoiaIME.rc` 的 `FILEVERSION` / `PRODUCTVERSION` 不由 release-please 直接改(它是逗号和点号两种写法),而是由 `scripts/apply_version.py` 从 `version.txt` 注入,release 构建在 configure 之前执行:
@@ -77,7 +96,8 @@ release workflow 里的每一段 shell 都抽在 `scripts/ci/` 下,workflow
| 脚本 | 用途 |
|---|---|
| `validate-draft-release.sh` | 手动触发时校验 tag 是未发布 draft、指向不可变 commit、且与 `version.txt` 一致 |
| `check-release-pr.sh` | 给 release PR 挂上一次通过的 CI,不合并 |
| `land-release-pr.sh` | 给 release PR 挂上一次通过的 CI,绿了就合并,并等合并提交在 `main` 上可见 |
| `resolve-auto-release.sh` | 从两次 release-please 调用里选出本次要构建的 release,并做和手动路径同样的不可变性校验 |
| `verify-commit-on-main.sh` | 拒绝构建不在 main 历史里的 commit |
| `install-boost.ps1` | 装 Server 链接但未声明的 Boost,triplet 必须是 static-md |
| `check-server-binaries.ps1` | 提前拦住 Server 产物缺文件 |
+54
View File
@@ -1,5 +1,59 @@
# Changelog
## [0.3.1](https://github.com/metasequoiaime/MSIME-Windows/compare/v0.3.0...v0.3.1) (2026-09-06)
### Bug Fixes
* **server:** revalidate queued online candidate generations on apply ([9785ecd](https://github.com/metasequoiaime/MSIME-Windows/commit/9785ecd4bf1a2c8f1056682d18fd184ff6aad733))
## [0.3.0](https://github.com/metasequoiaime/MSIME-Windows/compare/v0.2.1...v0.3.0) (2026-09-06)
### Features
* **release:** separate the automatic build channel from the release channel ([2d51c53](https://github.com/metasequoiaime/MSIME-Windows/commit/2d51c5383925ce8174a60f5527cbda5ff03b7d9f))
* **release:** separate the automatic build channel from the release channel ([d0e7cfd](https://github.com/metasequoiaime/MSIME-Windows/commit/d0e7cfdbdfaae9e5c296c83e58707932128c9b71))
## [0.2.1](https://github.com/metasequoiaime/MSIME-Windows/compare/v0.2.0...v0.2.1) (2026-09-06)
### Bug Fixes
* **server:** adopt isolated engine sessions and helpcode annotations ([#178](https://github.com/metasequoiaime/MSIME-Windows/issues/178)) ([2b8bf93](https://github.com/metasequoiaime/MSIME-Windows/commit/2b8bf937d96e5ff80e19f97de5be594802d90b5d))
## [0.2.0](https://github.com/metasequoiaime/MSIME-Windows/compare/v0.1.4...v0.2.0) (2026-09-06)
### Features
* **release:** mark automatically built releases in the title and notes ([fd9236f](https://github.com/metasequoiaime/MSIME-Windows/commit/fd9236f175a629a33d611b3748781d3623e1c497))
* **release:** mark automatically built releases in the title and notes ([568fd37](https://github.com/metasequoiaime/MSIME-Windows/commit/568fd3742cdbbb6c466f16fb3c4a9707e3590f52))
### Bug Fixes
* **ci:** attach automatic release checks to the pull request ([#175](https://github.com/metasequoiaime/MSIME-Windows/issues/175)) ([ee4334d](https://github.com/metasequoiaime/MSIME-Windows/commit/ee4334dddd29b0ab6bd4ff6c887e41a716000f5f))
* **ci:** stop the dependency review from skipping the release pull request ([cc5af53](https://github.com/metasequoiaime/MSIME-Windows/commit/cc5af5356d084255170fd67e88d674a8de3a933f))
* **ci:** stop the engine bump triage from executing engine file names ([#174](https://github.com/metasequoiaime/MSIME-Windows/issues/174)) ([5a4484f](https://github.com/metasequoiaime/MSIME-Windows/commit/5a4484feea2cd735eae061472983905897863892))
* 修复审计发现的候选页竞态、管道权限、TSF 空指针与皮肤 CSS 逃逸等八项缺陷 ([#169](https://github.com/metasequoiaime/MSIME-Windows/issues/169)) ([2562026](https://github.com/metasequoiaime/MSIME-Windows/commit/2562026a2a1c393e387fcd8ccd2dc30024a44463))
## [0.1.4](https://github.com/metasequoiaime/MSIME-Windows/compare/v0.1.3...v0.1.4) (2026-09-06)
### Bug Fixes
* **server:** 将本地测试入口接回合仓后的主工程 ([0baaf9a](https://github.com/metasequoiaime/MSIME-Windows/commit/0baaf9a1d0e572fd9a7fa63db934fc365df65f46))
* **server:** 补齐合仓后的本地测试和开发入口 ([2297ef4](https://github.com/metasequoiaime/MSIME-Windows/commit/2297ef421a2205148afc2d1ef4a3b3f8b8dcf351))
## [0.1.3](https://github.com/metasequoiaime/MSIME-Windows/compare/v0.1.2...v0.1.3) (2026-09-06)
### Bug Fixes
* **build:** finish local tooling migration to the monorepo ([8155380](https://github.com/metasequoiaime/MSIME-Windows/commit/81553802ba3f6e0e0ef1e4768fbb94398d2f3658))
* **build:** finish local tooling migration to the monorepo ([b2d736c](https://github.com/metasequoiaime/MSIME-Windows/commit/b2d736cd797d5ebce0a8c5e14b4c7c690d77275f))
## [0.1.2](https://github.com/metasequoiaime/MSIME-Windows/compare/v0.1.1...v0.1.2) (2026-09-05)
+100
View File
@@ -0,0 +1,100 @@
# 参与 MSIME-Windows 开发
组织级的通用约定(行为准则、PR 要求、安全上报)见[组织贡献指南](https://github.com/metasequoiaime/.github/blob/main/CONTRIBUTING.md)。这份文档只讲本仓特有的东西:环境、怎么把它编出来、改哪一层、以及提交前要跑什么。
想找活干的话先看[招募开源开发者](https://github.com/metasequoiaime/.github/blob/main/RECRUITING.md)和 [good first issue](https://github.com/metasequoiaime/MSIME-Windows/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)。不写代码也有事情可做:词库条目、文档、图标资源、兼容性测试都在 [no-code](https://github.com/metasequoiaime/MSIME-Windows/issues?q=is%3Aissue+is%3Aopen+label%3Ano-code) 标签下。
## 环境
- Windows 10 / 11
- Visual Studio 2026(需要 C++ 桌面开发工作负载)
- CMake 3.25+
- vcpkg,并把 `VCPKG_ROOT` 设进环境变量
- Python 3.10+
- Inno Setup 6.6+(只有要打安装包时才需要)
Boost 按 `-DBoost_ROOT=...` → 环境变量 `BOOST_ROOT` → `%USERPROFILE%\scoop\apps\boost\current` 的顺序解析。preset 里的 vcpkg 路径取自 `$env{VCPKG_ROOT}`,不需要改文件。
## 取代码
```powershell
git clone --recursive https://github.com/metasequoiaime/MSIME-Windows.git
cd MSIME-Windows
```
忘了 `--recursive` 的话补一句 `git submodule update --init --recursive`。`vendor/MetasequoiaImeEngine` 是跨平台输入引擎,缺了它任何一个 C++ 组件都编不过。
词库不在版本库里,构建时从 MSIME-Engine 的 `dict-*` release 按锁定的 tag 和摘要取用:
```powershell
python scripts/product_lock.py fetch-dictionaries --staging-root .
```
## 构建
没有顶层聚合的 `CMakeLists.txt`,这是有意的:TSF DLL 用静态 CRT、Server 用动态 CRT,两者的 vcpkg manifest 与编译选项互不兼容,合成一个 build tree 会互相污染。每个组件是独立的 CMake 工程,从仓库根目录指定 `-S`:
```powershell
cmake -S windows -B windows/build64 -A x64 # TSF 文本服务 DLL
cmake -S server -B server/build -A x64 # Server
cmake -S ui -B ui/build -A x64 # 自研 GUI 框架 msimeui
```
`server/` 通过 `add_subdirectory(../ui ...)` 把 `msimeui` 拉进自己的构建,这是唯一的跨组件构建引用。
每个组件也各自带了本地脚本,比手敲 cmake 省事:
```powershell
.\windows\scripts\prepare_env.py # 生成 .clangd 并把 preset 指向本机 vcpkg/Boost
.\windows\scripts\lcompile.ps1 64 # Debug;换 32 编 32 位
.\server\scripts\lcompile.ps1
```
各组件更细的说明在各自的 README 与 AGENTS.md 里,那两份文件写给人和 AI 都一样有效:
- [`windows/AGENTS.md`](windows/AGENTS.md) — TSF 层的构建、注册与**手工回归清单**(这份清单是本仓最难替代的知识,改 TSF 前先读)
- [`server/README.md`](server/README.md) — Server 的开发构建与本地词库准备
- [`ui/README.md`](ui/README.md) — GUI 框架的构建与边界约束
- [`installer/README.md`](installer/README.md) — 打包
- [`AGENTS.md`](AGENTS.md) — 跨组件契约:协议只有一份来源,改哪边要同时改哪边
## 打一个可安装的包
从仓库根开始,严格按顺序。任一步失败先修,不要拿旧产物继续打包:
```powershell
Set-Location .\windows
.\scripts\lcompile-release-both.ps1
Set-Location ..\server
.\scripts\lcompile-release.ps1
Set-Location ..\installer
.\Prepare-PackageFiles.ps1 -RepoRoot .. -TsfDirectory windows -ServerDirectory server -UiHtmlDirectory ui-html -NoticesDirectory .
.\Sign-PackageBinaries-Local.ps1
.\Compile-Installer.ps1
```
`Sign-PackageBinaries-Local.ps1` 用的是本机测试证书,只用于内部安装验证;不要把本机证书指纹或私钥提交进仓库。
## 改动前要知道的几条边界
- **协议的唯一来源是 Engine 的 `contracts/`。** IPC 线格式、opcode、语音分帧和 WebView 消息定义都在 `vendor/MetasequoiaImeEngine/contracts/`,两侧引用同一份头文件,不要在任何一侧另写一份。
- **`ui/` 不许反向依赖产品。** 它不读 Server 配置、IPC、引擎、词库或全局输入状态。`ui/scripts/check-boundary.py` 在 CI 里检查。
- **窗口归 `server/`,页面归 `ui-html/`。** 改消息 `type`、JSON 字段或页面导出的 JS 函数时两侧一起改。
- **`ui-html/webview2/shared/` 是生成物**,由 `ui-html/scripts/sync-contracts.py` 从 submodule 同步,CI 用 `--check` 校验。要改契约就去改 Engine。
- **候选与输入状态的权威在 Server 和引擎**,页面只负责展示。
## 提交前
```powershell
ctest --test-dir server/build --output-on-failure # Server 单元测试
python -m unittest discover -s tests # 产品级组合验证
bash scripts/ci/check-developer-paths.sh # 构建输入里不许出现个人目录路径
```
改了 TSF 层就照 [`windows/AGENTS.md`](windows/AGENTS.md#构建与验证) 的手工回归清单跑一遍。输入法很难靠读代码判断对错,**PR 里必须写清你怎么验证的**:系统版本、宿主程序、操作步骤、观察到的结果、跑过的测试命令。
`git status --short` 确认只暂存本次任务涉及的路径,不要 `git add -A`。提交信息用 `type(scope): 摘要`,不要加 `Co-Authored-By` 或任何 AI 生成标记。
不要在 PR、Issue、截图或日志里泄露 API Key。仓库开了 secret scanning 和 push protection,但那是最后一道防线不是第一道。
+39
View File
@@ -0,0 +1,39 @@
# Privacy
Metasequoia IME for Windows converts keystrokes, composition text and candidates locally, using the shared C++ engine and the SQLite dictionaries installed under `%LOCALAPPDATA%\metasequoiaime\`. Local conversion never contacts the network. This document describes the optional online features that do, exactly what each one sends, and how to turn each one off.
Every claim below is checkable against this repository. Where a default matters, the file and key that set it are named.
## Network features
**Cloud candidates are enabled by default.** `general.cloud_candidates` is `true` in the shipped `installer/default_config/config.default.toml`, and `server/src/config/ime_config.cpp` also defaults to `true` when the key is absent. While a composition is active, the typed spelling is sent over HTTPS to Google's input-tools service (`https://inputtools.google.com/request`) to fetch one extra candidate. Google receives that spelling, the IP address and standard request metadata. No committed text, dictionary content, learned frequencies, settings or account data are sent. This is the one online feature that needs no credential, so it is the one that is actually live on a fresh install. Turn it off in 设置 → 输入 → 云候选, or set `cloud_candidates = false` under `[general]` in `%LOCALAPPDATA%\metasequoiaime\config.toml`.
**AI candidate suggestions require a token you supply.** `[ai_assistant] enabled` ships as `true`, but the shipped token values are placeholders and the runtime rejects placeholders, so no request is made until a real token is entered. Once configured, the segmented spelling and the surrounding composition context are sent to the provider's Chat Completions endpoint — DeepSeek, OpenAI, SiliconFlow or Groq, per `[ai_assistant] provider`. That provider's own privacy terms then apply. Set `enabled = false` to disable it outright.
**Candidate translation requires credentials you supply.** `[tencent_tmt] enabled` ships as `true` with placeholder credentials, so nothing is sent until real Tencent Cloud keys are entered. Once configured, candidate text that the local dictionaries could not gloss is sent to `https://tmt.tencentcloudapi.com`. Translations are display metadata and never change the text that is committed.
**Voice input requires a token you supply.** `[voice_input] voice_input` ships as `true` with a placeholder ASR token, so the hotkeys do nothing over the network until a real token is entered. Once configured, recorded audio is uploaded to the configured endpoint — Doubao (`wss://openspeech.bytedance.com/...`), OpenAI, SiliconFlow or Groq. If text polishing is enabled, the returned transcript is additionally sent to the configured Chat Completions endpoint.
**The update check runs only when you press the button.** 设置 → 关于 → 检查更新 fetches `https://msime.app/update.json` and compares the version. There is no background or scheduled update check, and the request carries no identifier beyond the IP address and a cache-busting timestamp.
## 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.
## Local data
Settings live in `%LOCALAPPDATA%\metasequoiaime\config.toml`. Learned word frequencies and user-created words are stored in `%LOCALAPPDATA%\metasequoiaime\msime_user.db`. Diagnostic logs are written to `%LOCALAPPDATA%\metasequoiaime\log\` and record process lifecycle and error conditions, not typed content. Clipboard history is disabled by default (`clipboard_history = false`); when enabled it records copied text locally and is cleared as soon as it is switched off.
**API tokens are stored in plain text in `config.toml`.** This differs from the other platforms — macOS and iOS use the Keychain, and Linux uses the desktop Secret Service — and it means anything able to read that file can read the tokens. Use provider tokens scoped to this purpose, and treat the file as a secret when backing up or syncing the profile directory.
All of these files sit in the current user's profile and are protected only by that account's permissions.
## Uninstall
Uninstalling deletes `%LOCALAPPDATA%\metasequoiaime` and `%PROGRAMDATA%\metasequoiaime` in full, including the user dictionary, settings and any installed skins. Back up `msime_user.db` first if you want to keep learned words. An in-place upgrade behaves differently: it preserves `msime_user.db`, `config.toml`, `config.base.toml` and the `skins` directory, and replaces everything else.
## General
Metasequoia IME does not sell or share personal data, and installing or using it does not create an account. If a future release adds another network-backed feature, this notice must be updated before that feature ships.
Security or privacy concerns should be reported privately as described in the organisation [SECURITY.md](https://github.com/metasequoiaime/.github/blob/main/SECURITY.md).
+56
View File
@@ -0,0 +1,56 @@
# Metasequoia IME for Windows
[中文 README](README.md) · [Website](https://msime.app) · [Docs](https://msime.app/docs/)
<!-- badges:start -->
[![CI](https://img.shields.io/github/actions/workflow/status/metasequoiaime/MSIME-Windows/ci.yml?branch=main&label=CI)](https://github.com/metasequoiaime/MSIME-Windows/actions/workflows/ci.yml)
[![CodeQL](https://img.shields.io/github/actions/workflow/status/metasequoiaime/MSIME-Windows/codeql.yml?branch=main&label=CodeQL)](https://github.com/metasequoiaime/MSIME-Windows/actions/workflows/codeql.yml)
[![Release](https://img.shields.io/github/v/release/metasequoiaime/MSIME-Windows?include_prereleases&label=release)](https://github.com/metasequoiaime/MSIME-Windows/releases)
[![Downloads](https://img.shields.io/github/downloads/metasequoiaime/MSIME-Windows/total?label=downloads)](https://github.com/metasequoiaime/MSIME-Windows/releases)
[![License](https://img.shields.io/github/license/metasequoiaime/MSIME-Windows)](LICENSE)
[![Stars](https://img.shields.io/github/stars/metasequoiaime/MSIME-Windows?style=flat)](https://github.com/metasequoiaime/MSIME-Windows/stargazers)
<!-- badges:end -->
A free and open-source Chinese and Japanese input method for Windows 10 and 11, built on TSF with no legacy IMM32 path.
This repository is the Windows product: the TSF text service DLL, the resident server, a self-written native GUI framework, the HTML surfaces and the installer. The conversion engine is shared with the macOS, iOS and Linux frontends and lives in [MSIME-Engine](https://github.com/metasequoiaime/MSIME-Engine).
**This is a public beta.** Expect rough edges and read the release notes before upgrading.
## Install
Download the installer from [Releases](https://github.com/metasequoiaime/MSIME-Windows/releases) or the [download page](https://msime.app/download/), run it, then switch with `Win + Space`.
Current builds are **not code signed**. SmartScreen will block them and uiAccess is unavailable, which means the candidate window cannot float above elevated applications. Verify the download with the SHA256 published on the download page.
You need the **Microsoft Visual C++ 2015–2022 Redistributable (x64)**. The server and settings app are 64-bit; the x86 redistributable is a separate package and does not substitute for it. If you cannot switch to the IME, the server keeps exiting, or the settings window closes immediately, install that first.
## What it does
- Chinese input: full pinyin, double pinyin (Xiaohe / Ziranma / Shoudao / Microsoft), Wubi 86
- Japanese input: romaji, with hiragana, katakana and dictionary candidates; also available as a temporary mode from Chinese
- Helpcode (形码) filtering on pinyin schemes: five tables, single- and double-code
- 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
- 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
## Privacy
An input method sees every keystroke, so the boundaries are stated explicitly in [PRIVACY.md](PRIVACY.md): which features reach the network, what each one sends, what the defaults are, and how to turn each off.
Two things worth knowing up front. **Cloud candidates are on by default** — the composition spelling goes to Google's input-tools service, and it is the only network feature that works without you supplying a credential. **API tokens are stored in plain text** in `config.toml`, unlike macOS and Linux which use the Keychain and Secret Service.
There is no telemetry, analytics or crash reporting of any kind.
## Building and contributing
[CONTRIBUTING.md](CONTRIBUTING.md) has the environment, the build order and what to run before opening a pull request. Contributions do not have to be code — dictionary entries, documentation, icons and compatibility testing all count, and the [recruiting page](https://github.com/metasequoiaime/.github/blob/main/RECRUITING.md) lists them by area.
Note that most issues, documentation and code comments are in Chinese. English pull requests and issues are welcome.
## Licence
GPL-3.0. See [LICENSE](LICENSE) and [THIRD_PARTY_NOTICES.txt](THIRD_PARTY_NOTICES.txt).
+30 -9
View File
@@ -1,5 +1,16 @@
# 水杉输入法
[English README](README.en.md)
<!-- badges:start -->
[![CI](https://img.shields.io/github/actions/workflow/status/metasequoiaime/MSIME-Windows/ci.yml?branch=main&label=CI)](https://github.com/metasequoiaime/MSIME-Windows/actions/workflows/ci.yml)
[![CodeQL](https://img.shields.io/github/actions/workflow/status/metasequoiaime/MSIME-Windows/codeql.yml?branch=main&label=CodeQL)](https://github.com/metasequoiaime/MSIME-Windows/actions/workflows/codeql.yml)
[![Release](https://img.shields.io/github/v/release/metasequoiaime/MSIME-Windows?include_prereleases&label=release)](https://github.com/metasequoiaime/MSIME-Windows/releases)
[![Downloads](https://img.shields.io/github/downloads/metasequoiaime/MSIME-Windows/total?label=downloads)](https://github.com/metasequoiaime/MSIME-Windows/releases)
[![License](https://img.shields.io/github/license/metasequoiaime/MSIME-Windows)](LICENSE)
[![Stars](https://img.shields.io/github/stars/metasequoiaime/MSIME-Windows?style=flat)](https://github.com/metasequoiaime/MSIME-Windows/stargazers)
<!-- badges:end -->
一款开源的自由 Windows 输入法,面向 Windows 10 / Windows 11,采用纯 TSF 方案。
## 下载与安装
@@ -34,6 +45,8 @@ Issue 已经逐条核过并按可接手程度打了标签,可以直接筛:
写代码、写文档、整理词库、测试 Bug、制作教程,都是非常有价值的开源贡献。可以纯手写,也可以借助 AI;但请认真检查和测试,并注意不要在 PR、Issue、截图或日志中泄露 API Key 等敏感信息。完整的贡献约定见[贡献指南](https://github.com/metasequoiaime/.github/blob/main/CONTRIBUTING.md)。
**要动代码的话,本仓的环境要求、从源码构建、打包和提交前检查全部写在 [CONTRIBUTING.md](CONTRIBUTING.md) 里。**
## 项目结构
Windows 端的全部一方源码都在本仓,一次 clone 就能拿到完整产品。DLL 与 Server 仍是两个进程,只是不再是两个仓库。
@@ -54,7 +67,7 @@ Windows 端的全部一方源码都在本仓,一次 clone 就能拿到完整
- [MSIME-Engine](https://github.com/metasequoiaime/MSIME-Engine): 跨平台输入引擎,Windows / macOS / Linux 前端共用,在本仓是 `vendor/MetasequoiaImeEngine` submodule。辅助码和词库源数据也在其中。
- 词库:按固定 tag 和摘要在构建时从 MSIME-Engine 的 `dict-*` release 取用。已归档的 [MSIME-Dict](https://github.com/metasequoiaime/MSIME-Dict) 只读保留,其已发布的 release 仍可用。
- 语音输入:共享采集在 MSIME-Engine 的 `voice/`。独立工具 [MetasequoiaVoiceInput](https://github.com/metasequoiaime/MetasequoiaVoiceInput) 已归档,其已发布的 release 仍可下载。
- 语音输入:公共录音、WAV 编码与识别/润色协议位于 MSIME-Engine 的 `voice/`。独立工具 [MetasequoiaVoiceInput](https://github.com/metasequoiaime/MetasequoiaVoiceInput) 已归档,其已发布的 release 仍可下载。
## 功能简介
@@ -301,25 +314,25 @@ Server 启动时会同时启动 Watchdog。服务意外退出后,Watchdog 会
## 软件截图
<img src="https://i.imgur.com/1gDQFix.png" width="750">
<img src="docs/images/candidate-window-1.png" width="750">
<img src="https://i.imgur.com/MxRcKYT.png" width="750">
<img src="docs/images/candidate-window-2.png" width="750">
<img src="https://i.imgur.com/5MG33lH.png" width="750">
<img src="docs/images/candidate-window-3.png" width="750">
<img src="https://i.imgur.com/gnJwoVa.png" width="750">
<img src="docs/images/candidate-window-4.png" width="750">
<img src="https://i.imgur.com/wBwpuUT.png" width="750">
<img src="docs/images/candidate-window-5.png" width="750">
<img src="https://i.imgur.com/VcjIaeV.png" width="750">
<img src="docs/images/candidate-window-6.png" width="750">
<!-- 暗色 -->
<img src="https://i.imgur.com/YgxjjEz.png" width="780">
<img src="docs/images/settings-dark.png" width="780">
<!-- 亮色 -->
<img src="https://i.imgur.com/dkXHbPm.png" width="780">
<img src="docs/images/settings-light.png" width="780">
## 开源协议
@@ -339,3 +352,11 @@ GPL-3.0。
### 一些其他输入法相关的资源
- 微软双拼方案来源:<https://www.bilibili.com/opus/550351671882601523>
<!-- star-history:start -->
## Star History
<a href="https://star-history.com/#metasequoiaime/MSIME-Windows&Date">
<img src="https://api.star-history.com/svg?repos=metasequoiaime/MSIME-Windows&type=Date" alt="Star History Chart" width="600">
</a>
<!-- star-history:end -->
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

+10 -4
View File
@@ -1,7 +1,6 @@
# 安装水杉输入法
> 适用版本:v0.0.9.2(2026 年 8 月 25 日发布,内测版)
> 若你安装的是更新版本,请以 [Releases](https://github.com/metasequoiaime/MSIME-Windows/releases) 中的说明为准。
> 本文描述当前内测版的安装流程。具体版本号、下载链接与该版本的已知问题一律以 [Releases](https://github.com/metasequoiaime/MSIME-Windows/releases) 页面为准——这里不再写死版本号,避免文档落后于实际发布。
## 目录
@@ -25,9 +24,16 @@
## 下载
1. 打开 [Releases 页面](https://github.com/metasequoiaime/MSIME-Windows/releases)。
2. 下载最新的安装包 `MetasequoiaIME_Setup_v<版本号>.exe`(例如 `MetasequoiaIME_Setup_v0.0.9.2.exe`)。
2. 下载带 **Latest** 徽章那个版本的 `MetasequoiaIME_Setup_v<版本号>.exe`。
> 注意:当前发布版本均标记为 **Pre-release(内测版)**,功能与稳定性可能变动,重要环境建议先在虚拟机或非主力机试用。
Releases 页面上有两类发布:
| 频道 | 页面上的样子 | 来源 | 谁该用 |
|---|---|---|---|
| 发布 | 正常发布,最新的一个带 **Latest** 徽章 | 人工挑选后发布 | **一般都用这个** |
| 自动构建 | 标记 **Pre-release**,标题带「(自动构建)」 | 每次合并到 `main` 后由 CI 自动发布,未经人工挑选 | 只在需要某个尚未进入发布频道的修复、且能接受未经验证的改动时用 |
> 注意:**两个频道当前都还是内测阶段**,版本号仍在 `0.x`,功能与稳定性可能变动,重要环境建议先在虚拟机或非主力机试用。「发布」频道的含义是「有人挑过」,不是「已经稳定」。
> 参与内测与反馈:[Telegram 群](https://t.me/msimegroup)、QQ 群 829919142。
## 安装与注意事项
+20 -2
View File
@@ -21,9 +21,21 @@ python scripts/product_lock.py validate
## 重建与追溯
手动 Release 只接受已有 draft tag,读取该提交内的清单。变更词库需要新的源码提交和产品版本,不允许临时覆盖词库 tag。没有清单的历史版本不能用新流水线宣称可复现重建。
自动路径构建的是 release-please 刚刚建出的那个 draft,`resolve-auto-release.sh` 要求它钉在完整 commit SHA 上;手动 Release 只接受已有 draft tag,由 `validate-draft-release.sh` 做同样的校验。两条路径都只读该提交内的清单。变更词库需要新的源码提交和产品版本,不允许临时覆盖词库 tag。没有清单的历史版本不能用新流水线宣称可复现重建。
发布前 `product_lock.py verify-published` 要求清单里每个提交都能从各自仓库的默认分支到达。这一条只在发布路径上执行,不进 `ci.yml`:引擎改动和本仓改动同时落地时,PR 里锁住引擎的分支提交是正常且必要的,在 PR CI 上强制会把它本该保护的那次落地直接锁死。到了发布这一刻判断相反——没进默认分支的输入就是没人合过的输入,锁文件为它背书等于宣称了一次并不存在的评审。
## 发布节奏
发布是全自动的:`main` 上每一次带 `fix:` / `feat:` 的合并都会走完构建、签名、发布。
**签名额度不是约束。** 证书允许每月 3000 次签名,而这个项目一个月的发布次数远低于这个量级。此前把「省签名次数」当作攒版本理由的说法不成立,不要据此调整合并策略。
**真正花掉的是发布节奏本身。** 消耗版本号的决定权交给了「有没有人合了一个 `fix:`」,不再是「有没有人想发版」。把若干修复攒成一个版本、以及「这次不值得发」这类判断,在自动路径上没有落点——用户会看到一串只差一两个提交的版本,release notes 也会碎。要控制这一点,办法是合并时把提交类型写成不触发版本递增的(`chore:` / `docs:` / `refactor:` / `test:`),或者把若干修复合进同一个 PR 让它们落在同一次 push 上。
**换的是什么。** 从合并到安装包可下载之间不再有人工环节,也就不会出现改动躺在 draft 里没人发的情况。
**排队。** Release runs 按 `concurrency: release-${{ github.ref }}` 串行且不取消。一轮完整的自动发布 = 等 release PR 的 CI(十几分钟)+ 构建签名打包(二十分钟上下)。合并频率高于这个节奏时队列只会越排越长,每个排队的 run 到头来都发一个版本。真出现这种情况,该调的是合并节奏或提交类型,不是把 `cancel-in-progress` 打开——那会在发布中途砍掉 run,留下挂着半个产物的 draft。
发布前 `product_lock.py verify-published` 要求清单里每个提交都能从各自仓库的默认分支到达,词库的 `source_commit` 一并检查——它和 Engine 提交一样钉住仓外的输入,而摘要与词库清单只能证明这批数据出自锁文件写着的那个提交,证明不了那个提交有人合过。唯一的例外是已退休的词库仓库:它的历史 Release 是不可变的既有产物,仓库归档或删除后无从追溯默认分支,此时只记录不拦截。这一条只在发布路径上执行,不进 `ci.yml`:引擎改动和本仓改动同时落地时,PR 里锁住引擎的分支提交是正常且必要的,在 PR CI 上强制会把它本该保护的那次落地直接锁死。到了发布这一刻判断相反——没进默认分支的输入就是没人合过的输入,锁文件为它背书等于宣称了一次并不存在的评审。
安装包携带 `%LOCALAPPDATA%/metasequoiaime/product-manifest.json`,Release 同时附加同名文件。其中包含本仓的发布提交、全部锁定输入和清单文件摘要。签名时间、工具链与 runner 更新仍可能改变二进制字节;此机制保证产品源码与数据组合确定,不承诺安装包逐字节一致。
@@ -34,3 +46,9 @@ python -m unittest discover -s tests -p 'test_product_lock.py'
python scripts/product_lock.py verify-contracts .
python scripts/product_lock.py fetch-dictionaries --staging-root /tmp/msime-product-data
```
## 发布元数据令牌
版本 PR 的创建和更新使用 `RELEASE_PLEASE_TOKEN`,使正常 `pull_request` CI 自动运行并进入 PR 检查汇总。`land-release-pr.sh` 等待精确 head 的 PR CI 通过,再用 `GITHUB_TOKEN` 合并。维护 PR 的 release-please 调用设置 `skip-github-release: true`。它前后各有一次 `skip-github-pull-request: true` 的元数据调用,使用组织已有的 `RELEASE_PLEASE_TOKEN`:先补建已经手动合并的版本,再处理本次自动合并的版本,保持 release-please 原有的先发布、后计算下一版本的顺序。
后者需要仓库内容和 workflow 写权限(经典 PAT 的 `repo` + `workflow`,或等价的细粒度权限)。主分支先前包含 workflow 变更时,`GITHUB_TOKEN` 创建历史提交的 tag 可能被 Git refs API 拒绝。该令牌只更新版本分支,不用于合并;主分支仍由 `GITHUB_TOKEN` 写入,因此不会额外触发一次 Release。缺少令牌时提前报出配置错误。
@@ -7,10 +7,10 @@
"generator": "Visual Studio 17 2022",
"binaryDir": "${sourceDir}/build",
"environment": {
"VCPKG_ROOT": "C:/Users/SonnyCalcr/scoop/apps/vcpkg/current"
"VCPKG_ROOT": "$penv{VCPKG_ROOT}"
},
"cacheVariables": {
"CMAKE_TOOLCHAIN_FILE": "C:/Users/SonnyCalcr/scoop/apps/vcpkg/current/scripts/buildsystems/vcpkg.cmake"
"CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake"
}
}
],
+14 -6
View File
@@ -2,17 +2,17 @@
param(
[string]$TargetVersion = '0.0.1',
[string]$RepoRoot = (Split-Path -Parent $PSScriptRoot),
# Component paths are relative to RepoRoot. CI and local product scripts pass windows/,
# server/ and ui-html/ explicitly; the standalone historical names remain valid overrides.
[string]$TsfDirectory = 'MetasequoiaImeTsf',
[string]$ServerDirectory = 'MetasequoiaImeServer',
[string]$UiHtmlDirectory = 'MetasequoiaImeUiHtml',
# Component paths are relative to RepoRoot and default to the consolidated layout.
# Historical or custom layouts remain available through explicit overrides.
[string]$TsfDirectory = 'windows',
[string]$ServerDirectory = 'server',
[string]$UiHtmlDirectory = 'ui-html',
[string]$HelpCodeDirectory = 'vendor/MetasequoiaImeEngine/helpcode',
[string]$DictionaryDirectory = 'MetasequoiaImeDict',
# THIRD_PARTY_NOTICES.txt used to sit next to the tip's sources. In the consolidated repository
# the notice covers the whole product and lives at the root, one level above windows/, so where
# to read it is no longer answered by where the tip is.
[string]$NoticesDirectory = $TsfDirectory,
[string]$NoticesDirectory = '.',
[switch]$Light
)
@@ -52,6 +52,7 @@ $pinyinTable = Join-Path $RepoRoot (Join-Path $ServerDirectory 'assets\tables\pi
$helpcodeSource = Join-Path $RepoRoot (Join-Path $HelpCodeDirectory 'helpcodes')
$appIcon = Join-Path $RepoRoot (Join-Path $ServerDirectory 'src\resource\MetasequoiaIME.ico')
$thirdPartyNotices = Join-Path $RepoRoot (Join-Path $NoticesDirectory 'THIRD_PARTY_NOTICES.txt')
$license = Join-Path $RepoRoot 'LICENSE'
$dictionaryDb = Join-Path $RepoRoot (Join-Path $DictionaryDirectory 'out\msime.db')
$dictionaryManifest = Join-Path $RepoRoot (Join-Path $DictionaryDirectory 'out\dictionary-manifest.json')
$japaneseModel = Join-Path $RepoRoot (Join-Path $DictionaryDirectory 'out\dict_japanese.dat')
@@ -67,6 +68,7 @@ Assert-PathExists -LiteralPath $tsf64Release -Description '64 位 TSF Release DL
Assert-PathExists -LiteralPath $serverConfig -Description 'Server config.toml'
Assert-PathExists -LiteralPath $appIcon -Description '应用图标'
Assert-PathExists -LiteralPath $thirdPartyNotices -Description '第三方声明 THIRD_PARTY_NOTICES.txt'
Assert-PathExists -LiteralPath $license -Description '许可证 LICENSE'
Assert-PathExists -LiteralPath (Join-Path $webviewRoot 'shared') -Description '共享 WebView 消息契约'
Assert-PathExists -LiteralPath (Join-Path $webviewRoot 'candwnd') -Description '候选窗 HTML 目录'
Assert-PathExists -LiteralPath (Join-Path $webviewRoot 'ftb') -Description '悬浮工具栏 HTML 目录'
@@ -174,6 +176,12 @@ Copy-Item -LiteralPath $appIcon -Destination (Join-Path $PSScriptRoot 'Metasequo
# rime-ice is GPL-3.0 and requires attribution, and its content forms the bulk of msime.db, so the
# notice has to reach the user's disk rather than only exist in the source repository.
Copy-Item -LiteralPath $thirdPartyNotices -Destination (Join-Path $PSScriptRoot 'THIRD_PARTY_NOTICES.txt') -Force
# GPLv3 sections 4 and 6 require a copy of the licence to reach whoever receives the program, and the
# packaged product includes third-party GPL-3.0 dictionary data. macOS and Linux already install the
# licence text (CMakeLists.txt in MSIME-Apple and MSIME-Linux); Windows is the platform that actually
# ships at volume and was the only one omitting it. THIRD_PARTY_NOTICES.txt does not cover this: it
# points at "the LICENSE file" without carrying the GPL text itself.
Copy-Item -LiteralPath $license -Destination (Join-Path $PSScriptRoot 'LICENSE.txt') -Force
$targetIss = Join-Path $PSScriptRoot 'msime_setup.iss'
Assert-PathExists -LiteralPath $targetIss -Description '安装脚本'
+1 -1
View File
@@ -13,7 +13,7 @@
## CI 契约
根 `.github/workflows/release.yml` **不加修改地调用本目录的 `Prepare-PackageFiles.ps1` 和 `Compile-Installer.ps1`**。源目录名是 `Prepare-PackageFiles.ps1` 的参数,不是写死在脚本里的字面量:TSF、Server 和页面仍支持历史目录默认值;当前 workflow 与 `tests/package-files.ps1` 均显式使用 `windows/`、`server/`、`ui-html/` 和仓根授权文件。辅助码默认读取 `vendor/MetasequoiaImeEngine/helpcode/`,词库缓存位于 `MetasequoiaImeDict/out/`。
根 `.github/workflows/release.yml` **不加修改地调用本目录的 `Prepare-PackageFiles.ps1` 和 `Compile-Installer.ps1`**。源目录名是 `Prepare-PackageFiles.ps1` 的参数,不是写死在脚本里的字面量:默认值为 `windows/`、`server/`、`ui-html/` 和仓根授权文件;旧目录或自定义布局通过参数覆盖。回归检查同时执行默认路径和显式参数的打包。辅助码默认读取 `vendor/MetasequoiaImeEngine/helpcode/`,词库缓存位于 `MetasequoiaImeDict/out/`。
合仓后 release workflow 传的是本仓的目录名,辅助码随固定 Engine gitlink 检出,词库按产品锁下载到仓根缓存:
+8
View File
@@ -47,6 +47,8 @@ DisableProgramGroupPage=yes
OutputDir=Output
OutputBaseFilename=MetasequoiaIME_Setup_v{#MyAppVersion}{#MyOutputSuffix}
SetupIconFile={#MySourceRoot}\MetasequoiaIME.ico
; 安装向导中显示 GPLv3 正文。Prepare-PackageFiles.ps1 会把仓库根的 LICENSE 拷成这个文件。
LicenseFile={#MySourceRoot}\LICENSE.txt
Compression=lzma2
SolidCompression=yes
; 安装和卸载界面自动跟随 Windows 的浅色/深色模式。
@@ -83,6 +85,12 @@ Source: "{#MySourceRoot}\MetasequoiaIME.ico"; \
Source: "{#MySourceRoot}\THIRD_PARTY_NOTICES.txt"; \
DestDir: "{commonpf64}\metasequoiaime"; Flags: ignoreversion
; GPLv3 第 4、6 条要求分发时向接收者提供许可证副本,而 THIRD_PARTY_NOTICES.txt 只是指向
; "the LICENSE file"、本身不含 GPL 正文。macOS 与 Linux 的 CMake 安装规则早已随包装入许可证,
; Windows 是唯一大规模分发却漏掉这一步的平台。
Source: "{#MySourceRoot}\LICENSE.txt"; \
DestDir: "{commonpf64}\metasequoiaime"; Flags: ignoreversion
; TSF DLL 使用版本独立目录,避免升级时覆盖仍被进程加载的 DLL。
Source: "{#MySourceRoot}\tsf_dll\32\*.dll"; \
DestDir: "{commonpf32}\metasequoiaime\{code:GetVersionDir}"; \
+4 -1
View File
@@ -30,7 +30,10 @@ Push-Location (Join-Path $repoRoot 'server')
try { & $serverCompile } finally { Pop-Location }
Push-Location $settingsDir
try { pnpm run build } finally { Pop-Location }
try {
pnpm run build
if ($LASTEXITCODE -ne 0) { throw "Settings page build failed ($LASTEXITCODE)" }
} finally { Pop-Location }
& (Join-Path $PSScriptRoot 'Prepare-PackageFiles.ps1') -Light -RepoRoot $repoRoot -TsfDirectory windows -ServerDirectory server -UiHtmlDirectory ui-html -NoticesDirectory .
& (Join-Path $PSScriptRoot 'Sign-PackageBinaries-Local.ps1')
+5 -2
View File
@@ -1,4 +1,4 @@
# 本地测试安装流程:编译相邻源码仓库(若存在)→ 收集安装文件 →
# 本地测试安装流程:编译本仓组件→ 收集安装文件 →
# 本机自签名 → Inno Setup 打包 → 签安装包 → 启动安装程序。
#
# 不使用任何预置证书。签名脚本会在本机生成并复用自签名测试证书。
@@ -29,7 +29,10 @@ Push-Location (Join-Path $repoRoot 'server')
try { & $serverCompile } finally { Pop-Location }
Push-Location $settingsDir
try { pnpm run build } finally { Pop-Location }
try {
pnpm run build
if ($LASTEXITCODE -ne 0) { throw "Settings page build failed ($LASTEXITCODE)" }
} finally { Pop-Location }
& (Join-Path $PSScriptRoot 'Prepare-PackageFiles.ps1') -RepoRoot $repoRoot -TsfDirectory windows -ServerDirectory server -UiHtmlDirectory ui-html -NoticesDirectory .
& (Join-Path $PSScriptRoot 'Sign-PackageBinaries-Local.ps1')
+70
View File
@@ -0,0 +1,70 @@
# Exercise the real orchestration with synthetic command failures; never sign or install.
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$source = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
$fixture = Join-Path ([IO.Path]::GetTempPath()) ('msime build failures ' + [Guid]::NewGuid())
$originalLocation = Get-Location
try {
foreach ($component in @('windows', 'server')) {
$scripts = Join-Path $fixture "$component/scripts"
New-Item -ItemType Directory -Force $scripts | Out-Null
Copy-Item (Join-Path $source "$component/scripts/lcompile-release.ps1") $scripts
}
function global:cmake {
$global:MsimeBuildCalls += 1
$global:LASTEXITCODE = if ($global:MsimeBuildCalls -eq $global:MsimeFailAt) { 17 } else { 0 }
}
foreach ($component in @('windows', 'server')) {
foreach ($failure in @(1, 2)) {
$global:MsimeBuildCalls = 0
$global:MsimeFailAt = $failure
$rejected = $false
try { & (Join-Path $fixture "$component/scripts/lcompile-release.ps1") }
catch { $rejected = $_.Exception.Message -match '(configure|build) failed \(17\)' }
if (-not $rejected) { throw "$component did not reject failed build stage $failure" }
if ($global:MsimeBuildCalls -ne $failure) { throw 'Continued after a failed native command' }
}
}
function global:Invoke-MsimeManifestProbe {
$global:MsimeManifestArguments = @($args)
$global:LASTEXITCODE = $global:MsimeManifestExit
}
$global:MsimeFailAt = 0
$global:MsimeManifestExit = 0
& (Join-Path $fixture 'server/scripts/lcompile-release.ps1') -ManifestTool Invoke-MsimeManifestProbe
$binary = Join-Path $fixture 'server/build-release/bin/Release/MetasequoiaImeServer.exe'
if ($global:MsimeManifestArguments.Count -ne 3 -or $global:MsimeManifestArguments[2] -ne "-outputresource:$binary;1") {
throw 'Manifest resource id or path with spaces was split into separate commands'
}
$global:MsimeManifestExit = 31
$rejected = $false
try { & (Join-Path $fixture 'server/scripts/lcompile-release.ps1') -ManifestTool Invoke-MsimeManifestProbe }
catch { $rejected = $_.Exception.Message -eq 'Server manifest embedding failed (31)' }
if (-not $rejected) { throw 'Failed manifest embedding was accepted' }
Remove-Item Function:/cmake
# Each installer entry must stop before staging/signing when the settings build fails.
foreach ($relative in @('windows/scripts/lcompile-release-both.ps1', 'server/scripts/lcompile-release.ps1')) {
[IO.File]::WriteAllText((Join-Path $fixture $relative), '# successful synthetic component build')
}
$settings = Join-Path $fixture 'ui-html/webview2/settings/ime-settings'
New-Item -ItemType Directory -Force $settings | Out-Null
[IO.File]::WriteAllText((Join-Path $settings 'package.json'), '{}')
$installer = Join-Path $fixture 'installer'
New-Item -ItemType Directory -Force $installer | Out-Null
[IO.File]::WriteAllText((Join-Path $installer 'Prepare-PackageFiles.ps1'), "throw 'Reached package staging after failure'")
function global:pnpm { $global:LASTEXITCODE = 23 }
foreach ($entry in @('test.ps1', 'test-light.ps1')) {
Copy-Item (Join-Path $source "installer/$entry") $installer
$rejected = $false
try { & (Join-Path $installer $entry) }
catch { $rejected = $_.Exception.Message -eq 'Settings page build failed (23)' }
if (-not $rejected) { throw "$entry continued after the settings build failed" }
}
$global:LASTEXITCODE = 0
Write-Host 'Configure/build failures stop local packaging before staging, signing or installation'
} finally {
Set-Location $originalLocation
Remove-Item Function:/cmake, Function:/pnpm, Function:/Invoke-MsimeManifestProbe -ErrorAction SilentlyContinue
Remove-Variable MsimeBuildCalls, MsimeFailAt, MsimeManifestExit, MsimeManifestArguments -Scope Global -ErrorAction SilentlyContinue
if (Test-Path $fixture) { Remove-Item $fixture -Recurse -Force }
}
+3 -2
View File
@@ -19,6 +19,7 @@ try {
'windows/build32-release/Release/MetasequoiaImeTsf.dll',
'windows/build64-release/Release/MetasequoiaImeTsf.dll',
'THIRD_PARTY_NOTICES.txt',
'LICENSE',
'server/assets/config/config.toml',
'server/src/resource/MetasequoiaIME.ico',
'vendor/MetasequoiaImeEngine/helpcode/helpcodes/helpcode.txt',
@@ -37,10 +38,10 @@ try {
$english = Join-Path $fixture 'MetasequoiaImeDict/out/english.db'
python -c "import sqlite3,sys; sqlite3.connect(sys.argv[1]).execute('CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER,PRIMARY KEY(word,display))')" $english
if ($LASTEXITCODE -ne 0) { throw 'Failed to create packaging fixture' }
& (Join-Path $installer 'Prepare-PackageFiles.ps1') -RepoRoot $fixture -TsfDirectory windows -ServerDirectory server -UiHtmlDirectory ui-html -NoticesDirectory . -TargetVersion '2026.9.1'
& (Join-Path $installer 'Prepare-PackageFiles.ps1') -RepoRoot $fixture -TargetVersion '2026.9.1'
foreach ($file in @('app_data/html/webview2/shared/runtime.js', 'app_data/dictionary-manifest.json',
'tsf_dll/32/MetasequoiaImeTsf.dll', 'tsf_dll/64/MetasequoiaImeTsf.dll',
'app_data/helpcodes/helpcode.txt', 'THIRD_PARTY_NOTICES.txt')) {
'app_data/helpcodes/helpcode.txt', 'THIRD_PARTY_NOTICES.txt', 'LICENSE.txt')) {
if (-not (Test-Path (Join-Path $installer $file))) { throw "Missing packaged file: $file" }
}
if (Test-Path (Join-Path $installer 'server_exe/MetasequoiaImeServerTests.exe')) { throw 'Packaged a test executable' }
+2 -2
View File
@@ -6,10 +6,10 @@
"generator": "Visual Studio 18 2026",
"binaryDir": "${sourceDir}/build",
"environment": {
"VCPKG_ROOT": "C:/Users/SonnyCalcr/scoop/apps/vcpkg/current"
"VCPKG_ROOT": "$penv{VCPKG_ROOT}"
},
"cacheVariables": {
"CMAKE_TOOLCHAIN_FILE": "C:/Users/SonnyCalcr/scoop/apps/vcpkg/current/scripts/buildsystems/vcpkg.cmake",
"CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
"CMAKE_BUILD_TYPE": "Debug"
}
}
+2 -2
View File
@@ -6,10 +6,10 @@
"generator": "Visual Studio 18 2026",
"binaryDir": "${sourceDir}/build",
"environment": {
"VCPKG_ROOT": "C:/Users/SonnyCalcr/scoop/apps/vcpkg/current"
"VCPKG_ROOT": "$penv{VCPKG_ROOT}"
},
"cacheVariables": {
"CMAKE_TOOLCHAIN_FILE": "C:/Users/SonnyCalcr/scoop/apps/vcpkg/current/scripts/buildsystems/vcpkg.cmake",
"CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
"CMAKE_BUILD_TYPE": "Debug"
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
"repositories": {
"engine": {
"repository": "metasequoiaime/MSIME-Engine",
"commit": "cc21e42916cf93ce43051fec474e87eeeba305bf"
"commit": "8eb4b4e1ea448aa552eda773fd43d40daba34394"
}
},
"dictionary": {
+1 -1
View File
@@ -11,7 +11,7 @@
"prerelease": true,
"force-tag-creation": true,
"pull-request-header": "This PR prepares the next Metasequoia IME for Windows release.",
"pull-request-footer": "Merging this PR creates a draft release. Nothing is built or signed until somebody runs the Release workflow manually against that draft's tag: the signing certificate allows a limited number of signatures, so a release is a deliberate act rather than a consequence of merging."
"pull-request-footer": "The Release workflow merges this PR itself once CI is green on it, then builds, signs and publishes the release. Merging it by hand does the same thing, one run earlier. Every release spends one code signing signature; the certificate allows 3000 a month, so that is not a constraint."
}
}
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
# Fail if a build input names somebody's home directory.
#
# CI never noticed these because it does not use the presets: ci.yml and release.yml pass
# -DCMAKE_TOOLCHAIN_FILE explicitly, and release.yml even carries a comment saying the presets point
# at a developer machine and cannot be used. So the paths only broke people following the README,
# which is exactly the population this repository is trying to recruit.
#
# Editor configuration is deliberately out of scope: the .clangd files are generated per machine by
# each component's scripts/prepare_env.py and are expected to hold absolute local paths.
cd "$(git rev-parse --show-toplevel)"
pattern='(C:[\\/]+[Uu]sers[\\/]+|/Users/|/home/)[A-Za-z0-9_.-]+'
# Listed into a file rather than an array: mapfile is bash 4+, and macOS still ships bash 3.2, so a
# contributor running this locally would otherwise get a confusing "command not found".
candidates=$(git ls-files \
'*.cmake' \
'CMakeLists.txt' \
'**/CMakeLists.txt' \
'*CMakePresets.json' \
'*CMakeUserPresets.json' \
'*.md' \
| grep -v '^vendor/' || true)
if [[ -z "$candidates" ]]; then
echo 'No build inputs to check.'
exit 0
fi
if matches=$(printf '%s\n' "$candidates" | tr '\n' '\0' | xargs -0 grep -InE "$pattern"); then
echo 'Build inputs must not hardcode a developer home directory.'
echo 'Use $env{VCPKG_ROOT} in presets, an environment variable with a documented fallback in CMake, and a relative link in Markdown.'
echo
echo "$matches"
exit 1
fi
echo 'No developer-specific paths in build inputs.'
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env bash
# Get a passing check onto the pull request release-please just opened, and stop there.
#
# This script used to merge that pull request too, which is what made releases automatic. Releases
# are manual now: the code signing certificate has a limited number of signatures, and an automatic
# release spends one on every merge to main whether or not anybody wanted a build. Merging the
# release pull request is a deliberate human act, and building and signing the resulting draft is a
# second one (workflow_dispatch on release.yml).
#
# Getting a check onto the pull request is still automatic, and still awkward. GITHUB_TOKEN may not
# start workflows, so the pull_request run is created in action_required and never executes; a push
# to the branch does not produce a run at all. workflow_dispatch is the exception GitHub allows, so
# dispatch CI on the release branch and wait for it. The checks land on the pull request's head
# commit, which is what lets required_status_checks stay on for this repository (MSIME-Windows#121)
# without leaving the release pull request permanently unmergeable.
#
# The pull request is found by branch rather than from release-please's payload, which only reports
# a pull request as created once but rewrites that branch on every later push to main.
#
# Requires GH_TOKEN and GH_REPO.
set -euo pipefail
branch=$(gh api "repos/$GH_REPO/git/matching-refs/heads/release-please--" \
--jq '.[0].ref // empty' | sed 's|^refs/heads/||')
if [[ -z "$branch" ]]; then
echo "No release branch exists; nothing to test."
exit 0
fi
number=$(gh pr list --repo "$GH_REPO" --head "$branch" --state open --limit 1 \
--json number --jq '.[0].number // empty')
if [[ -z "$number" ]]; then
echo "Release branch $branch has no open pull request; nothing to test."
exit 0
fi
pr_json=$(gh pr view "$number" --repo "$GH_REPO" \
--json state,isDraft,headRefName,headRefOid,baseRefName)
state=$(jq -er .state <<< "$pr_json")
is_draft=$(jq -r .isDraft <<< "$pr_json")
head_branch=$(jq -er .headRefName <<< "$pr_json")
head_sha=$(jq -er .headRefOid <<< "$pr_json")
base_branch=$(jq -er .baseRefName <<< "$pr_json")
if [[ "$state" != OPEN || "$is_draft" != false || "$base_branch" != main ]]; then
echo "Release pull request #$number is not an open, non-draft pull request against main." >&2
exit 1
fi
if [[ "$head_branch" != release-please--* ]]; then
echo "Release pull request #$number has an unexpected head branch: $head_branch" >&2
exit 1
fi
echo "Dispatching CI for release pull request #$number at $head_sha"
gh workflow run ci.yml --repo "$GH_REPO" --ref "$head_branch"
# The run does not appear immediately, and gh has no way to ask for the run a dispatch created, so
# poll for one on this branch at this exact commit.
run_id=""
for _ in $(seq 1 150); do
run_id=$(gh run list --repo "$GH_REPO" --workflow ci.yml --branch "$head_branch" \
--event workflow_dispatch --limit 20 --json databaseId,headSha \
--jq "map(select(.headSha == \"$head_sha\")) | first | .databaseId // empty")
[[ -n "$run_id" ]] && break
sleep 2
done
if [[ -z "$run_id" ]]; then
echo "Timed out waiting for CI to start for release pull request #$number at $head_sha." >&2
exit 1
fi
gh run watch "$run_id" --repo "$GH_REPO" --exit-status --interval 15
echo "Release pull request #$number is green at $head_sha and ready to merge when you want a release."
+3 -3
View File
@@ -1,12 +1,12 @@
# Install the Boost packages MSIME-Server links but does not declare.
# Install the Boost packages server/ links but does not declare.
#
# Kept in step with metasequoiaime/MSIME-Server's own CI. Boost is linked statically but is not in
# Kept in step with metasequoiaime/the Server job in this repository. Boost is linked statically but is not in
# its vcpkg.json, so it cannot come from the manifest. The triplet has to be static-md, not static:
# Boost_USE_STATIC_LIBS ON asks for static Boost libraries while the rest of the project builds
# against the dynamic CRT, and plain x64-windows-static would switch Boost to the static CRT and
# fail to link with LNK2038 RuntimeLibrary mismatches.
#
# Appends BOOST_ROOT to GITHUB_ENV, which is the override MSIME-Server's CMakeLists.txt supports.
# Appends BOOST_ROOT to GITHUB_ENV, which is the override server/'s CMakeLists.txt supports.
$ErrorActionPreference = 'Stop'
Push-Location $env:VCPKG_INSTALLATION_ROOT
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Get a passing check onto the pull request release-please just opened, then merge it.
#
# Merging here is what makes releases automatic. The merge writes the version bump and the CHANGELOG onto main, and the release-please invocation that runs after this script in release.yml turns that commit into the draft release the rest of the run builds, signs and publishes. Nothing in the pipeline waits for a human any more.
#
# The merge deliberately uses GITHUB_TOKEN. A push made with that token does not start a workflow run, so merging here does not produce a second Release run racing this one through the concurrency group: the release is created and built inside the same run that merged it. A personal access token would break that property rather than improve it.
#
# release-please updates the PR with RELEASE_PLEASE_TOKEN so ordinary pull_request CI starts.
# Wait for that run at the exact head: workflow_dispatch checks can be absent from the PR rollup
# even when their SHA matches, leaving required checks unsatisfied. Do not bypass those checks.
#
# The pull request is found by branch rather than from release-please's payload, which only reports a pull request as created once but rewrites that branch on every later push to main.
#
# Requires GH_TOKEN and GH_REPO.
set -euo pipefail
branch=$(gh api "repos/$GH_REPO/git/matching-refs/heads/release-please--" \
--jq '.[0].ref // empty' | sed 's|^refs/heads/||')
if [[ -z "$branch" ]]; then
echo "No release branch exists; nothing to land."
exit 0
fi
number=$(gh pr list --repo "$GH_REPO" --head "$branch" --state open --limit 1 \
--json number --jq '.[0].number // empty')
if [[ -z "$number" ]]; then
echo "Release branch $branch has no open pull request; nothing to land."
exit 0
fi
pr_json=$(gh pr view "$number" --repo "$GH_REPO" \
--json state,isDraft,headRefName,headRefOid,baseRefName)
state=$(jq -er .state <<< "$pr_json")
is_draft=$(jq -r .isDraft <<< "$pr_json")
head_branch=$(jq -er .headRefName <<< "$pr_json")
head_sha=$(jq -er .headRefOid <<< "$pr_json")
base_branch=$(jq -er .baseRefName <<< "$pr_json")
if [[ "$state" != OPEN || "$is_draft" != false || "$base_branch" != main ]]; then
echo "Release pull request #$number is not an open, non-draft pull request against main." >&2
exit 1
fi
if [[ "$head_branch" != release-please--* ]]; then
echo "Release pull request #$number has an unexpected head branch: $head_branch" >&2
exit 1
fi
echo "Waiting for PR CI for release pull request #$number at $head_sha"
# The pull_request run does not appear immediately; poll for this branch at this exact commit.
run_id=""
for _ in $(seq 1 150); do
run_id=$(gh run list --repo "$GH_REPO" --workflow ci.yml --branch "$head_branch" \
--event pull_request --limit 20 --json databaseId,headSha \
--jq "map(select(.headSha == \"$head_sha\")) | first | .databaseId // empty")
[[ -n "$run_id" ]] && break
sleep 2
done
if [[ -z "$run_id" ]]; then
echo "Timed out waiting for CI to start for release pull request #$number at $head_sha." >&2
exit 1
fi
gh run watch "$run_id" --repo "$GH_REPO" --exit-status --interval 15
# Merge the commit that CI just proved, not whatever the branch has drifted to. release-please
# rewrites this branch on every push to main, and the concurrency group only serialises Release
# runs, so nothing here rules out the branch moving while CI was running.
echo "Release pull request #$number is green at $head_sha; merging it."
gh pr merge "$number" --repo "$GH_REPO" --merge --match-head-commit "$head_sha"
# release-please reads main through the API, and the merge commit is not reliably visible there the
# moment gh returns. Wait for main to actually carry it, so the invocation after this script sees
# the release commit instead of quietly finding nothing to release.
merge_sha=$(gh pr view "$number" --repo "$GH_REPO" --json mergeCommit --jq '.mergeCommit.oid // empty')
if [[ -z "$merge_sha" ]]; then
echo "Release pull request #$number reports no merge commit after merging." >&2
exit 1
fi
for _ in $(seq 1 30); do
status=$(gh api "repos/$GH_REPO/compare/$merge_sha...main" --jq .status 2>/dev/null || echo unknown)
if [[ "$status" == identical || "$status" == ahead ]]; then
echo "Merged release pull request #$number as $merge_sha, now on main."
exit 0
fi
sleep 2
done
echo "Merge commit $merge_sha did not become reachable from main in time." >&2
exit 1
+21 -3
View File
@@ -1,14 +1,32 @@
#!/usr/bin/env bash
# Attach the installer to the draft release, append the artifact details to its notes and publish.
#
# GitHub has no notion of a release channel, so the two kinds of release this pipeline produces are carried by the two states it does have (MSIME-Windows#167). An automatic per-merge build is a prerelease; a build somebody deliberately dispatched is an ordinary release, and the newest one carries the Latest badge. Users therefore see two lists rather than one list with two kinds of title in it, and /releases/latest finally resolves.
#
# The prerelease flag means "automatic and uncurated" here, not "beta". The product being 内测 is stated in the notes and in docs/installation.md, which is where a claim about stability belongs; it is not something the flag can express while the flag is also the only thing separating the channels.
#
# Requires GH_TOKEN, GH_REPO, TAG_NAME, ASSET_PATH, ASSET_NAME, ASSET_SHA256, SIGNING_ENABLED,
# DICTIONARY_TAG.
# DICTIONARY_TAG, PRODUCT_MANIFEST, RELEASE_TRIGGER.
set -euo pipefail
gh release upload "$TAG_NAME" "$ASSET_PATH" "$PRODUCT_MANIFEST" --repo "$GH_REPO" --clobber
if [[ "$RELEASE_TRIGGER" == push ]]; then
# Braces are required: bash takes the full-width bracket that follows as part of the name otherwise.
title="${TAG_NAME}(自动构建)"
banner='本版本由 CI 在合并到 `main` 后自动构建发布,未经人工挑选,标记为 Pre-release。想要经过挑选的版本,请下载页面上带 Latest 徽章的那个。'
channel=(--prerelease)
else
title="$TAG_NAME"
banner=''
channel=(--prerelease=false --latest)
fi
gh release view "$TAG_NAME" --repo "$GH_REPO" --json body --jq .body > notes.md
{
if [[ -n "$banner" ]]; then
printf '\n> %s\n' "$banner"
fi
printf '\n---\n\n'
printf '| Field | Value |\n| --- | --- |\n'
printf '| Installer | `%s` |\n' "$ASSET_NAME"
@@ -20,5 +38,5 @@ gh release view "$TAG_NAME" --repo "$GH_REPO" --json body --jq .body > notes.md
fi
} >> notes.md
gh release edit "$TAG_NAME" --repo "$GH_REPO" --draft=false --prerelease --notes-file notes.md
echo "Published $TAG_NAME with $ASSET_NAME"
gh release edit "$TAG_NAME" --repo "$GH_REPO" --draft=false "${channel[@]}" --title "$title" --notes-file notes.md
echo "Published $TAG_NAME as '$title' (${channel[*]}) with $ASSET_NAME"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Decide which release an automatic run publishes, out of the two release-please invocations in the prepare job.
#
# FIRST_* comes from the invocation that recovers releases for pull requests already merged when the run started, which is the one that fires when a human merged the release pull request by hand instead of letting land-release-pr.sh do it. SECOND_* comes from the invocation after this run merged the pull request itself. On any given push at most one of them normally reports a release. The invocation between them only refreshes the pull request and creates no release, so it is not consulted here.
#
# When both do report one, the push carried a merged release pull request and further releasable commits at once. The second release is the newer version and supersedes the first, so it is the one that gets built; the first is left as a draft for a manual dispatch to pick up, which is the only case where the automatic path still leaves something behind.
#
# The manual path gets its immutability guarantees from validate-draft-release.sh. Nothing validates release-please's own outputs, so the same two invariants are checked here: the commit is a full SHA rather than a branch name, and the tag agrees with the version.
#
# Requires FIRST_CREATED, FIRST_TAG, FIRST_VERSION, FIRST_SHA and the matching SECOND_* values.
# Writes release_created, tag_name, version and target_sha to GITHUB_OUTPUT.
set -euo pipefail
if [[ "${SECOND_CREATED:-}" == true ]]; then
tag=$SECOND_TAG
version=$SECOND_VERSION
target_sha=$SECOND_SHA
if [[ "${FIRST_CREATED:-}" == true ]]; then
echo "::warning::Both release-please invocations created a release. Building $SECOND_TAG; $FIRST_TAG stays a draft until somebody dispatches it."
fi
elif [[ "${FIRST_CREATED:-}" == true ]]; then
tag=$FIRST_TAG
version=$FIRST_VERSION
target_sha=$FIRST_SHA
else
printf 'release_created=false\n' >> "$GITHUB_OUTPUT"
echo "No release was created on this push; nothing to build."
exit 0
fi
if [[ ! "$target_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "Release $tag must be tagged at an immutable full commit SHA, got '$target_sha'." >&2
exit 1
fi
if [[ "$tag" != "v$version" ]]; then
echo "Release tag $tag does not match version $version." >&2
exit 1
fi
{
printf 'release_created=true\n'
printf 'tag_name=%s\n' "$tag"
printf 'version=%s\n' "$version"
printf 'target_sha=%s\n' "$target_sha"
} >> "$GITHUB_OUTPUT"
echo "Publishing $tag (version $version) at $target_sha"
+18
View File
@@ -24,6 +24,24 @@ if [[ ! "$target_sha" =~ ^[0-9a-f]{40}$ ]]; then
exit 1
fi
# The target has to be on main. Without this, anyone able to create a draft release could point it
# at an arbitrary commit -- a fork's branch, an abandoned branch, a commit that never passed review --
# and dispatch this workflow to get that code compiled and signed into a published installer.
#
# The check belongs here rather than in the build jobs. This runs in the prepare job, which checks
# out the default branch, so both this script and the answer it gets come from trusted code. The
# equivalent check in the package job runs after the untrusted commit is already checked out, which
# means the script performing it is itself supplied by the commit under test; the TSF job, which
# compiles, had no such check at all. Those remain as defence in depth, not as the gate.
#
# compare/main...<sha> reports "behind" when the commit is an ancestor of main and "identical" when
# it is main's tip. Anything else means it is not in main's history.
comparison=$(gh api "repos/$GH_REPO/compare/main...$target_sha" --jq .status)
if [[ "$comparison" != "behind" && "$comparison" != "identical" ]]; then
echo "Draft release target $target_sha is not in main's history (compare status: $comparison)." >&2
exit 1
fi
version=$(gh api "repos/$GH_REPO/contents/version.txt?ref=$target_sha" --jq .content | base64 --decode | tr -d '[:space:]')
if [[ "$TAG_NAME" != "v$version" ]]; then
echo "Release $TAG_NAME does not match version.txt ($version)." >&2
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
# Formatting has to be reproducible across contributor machines and CI, so the
# formatter version is pinned here and installed from PyPI instead of being
# taken from whatever the host toolchain happens to ship. Apple, Debian and
# LLVM upstream all disagree about clang-format defaults between releases.
#
# Ported from MSIME-Linux, which has run this in CI since before the
# consolidation. Each component keeps its own .clang-format; --style=file picks
# up the nearest one, so server/, windows/, ui/ and log/ stay independent.
clang_format_version=18.1.8
project_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
venv_root=${METASEQUOIA_CLANG_FORMAT_VENV:-"${TMPDIR:-/tmp}/metasequoia-clang-format-$clang_format_version"}
clang_format="$venv_root/bin/clang-format"
if [[ ! -x "$clang_format" ]]; then
python3 -m venv "$venv_root"
"$venv_root/bin/pip" install --quiet "clang-format==$clang_format_version"
fi
mode=${1:---write}
case "$mode" in
--check) clang_format_arguments=(--dry-run --Werror) ;;
--write) clang_format_arguments=(-i) ;;
*)
echo "Usage: ${BASH_SOURCE[0]} [--check|--write]" >&2
exit 2
;;
esac
cd "$project_root"
# --others --exclude-standard so a newly written file that has not been staged
# yet is still formatted. Without it the script reports a clean tree while
# skipping exactly the file being worked on.
#
# vendor/ and experiments/ are excluded: the first is third-party, the second is
# a scratch project that is not part of the shipped product.
git ls-files --cached --others --exclude-standard \
'server/*.cpp' 'server/*.h' \
'windows/*.cpp' 'windows/*.h' \
'ui/*.cpp' 'ui/*.h' \
'log/*.cpp' 'log/*.h' \
| grep -v '/vendor/' \
| sort -u \
| xargs "$clang_format" "${clang_format_arguments[@]}" --style=file
+15 -2
View File
@@ -133,16 +133,29 @@ def verify_contracts(directory: Path, data: dict) -> None:
def verify_published(data: dict) -> None:
"""Every locked commit has to be reachable from its own repository's default branch.
"""Every locked commit has to be reachable from its own repository's default branch, the dictionary source commit included.
This is a release gate rather than a validate rule, deliberately. A pull request legitimately locks branch commits while one change lands across several repositories at once, and enforcing this in pull request CI would deadlock the very landing it exists to protect. At release time the situation is the opposite: an input that never reached its default branch is an input nobody merged, and shipping it makes the lock attest to a review that did not happen.
"""
for name, entry in data["repositories"].items():
# The dictionary sits outside repositories but pins a commit exactly like they do, and the
# manifest check only proves the release agrees with the lock about it, which a branch commit
# does just as happily. So it goes through the same gate under its own name.
dictionary = data["dictionary"]
entries = {**data["repositories"],
"dictionary": {"repository": dictionary["repository"], "commit": dictionary["source_commit"]}}
for name, entry in entries.items():
repository, commit = entry["repository"], entry["commit"]
try:
default = api(f"repos/{repository}")["default_branch"]
status = api(f"repos/{repository}/compare/{default}...{commit}")["status"]
except subprocess.CalledProcessError as error:
# A release that shipped from the retired dictionary repository is an immutable historical
# artefact that validate still accepts, and an archived or deleted repository answers
# nothing about its default branch. Being unable to re-check that history is not the same
# as finding it unmerged, so it is reported rather than turned into a release failure.
if name == "dictionary" and repository != DICTIONARY_REPOSITORY:
print(f"{name}: {commit[:12]} predates {DICTIONARY_REPOSITORY} and {repository} no longer answers")
continue
raise ValueError(f"{name}: cannot resolve {commit} in {repository}") from error
# behind and identical both mean the locked commit is an ancestor of the default branch.
# ahead and diverged mean it sits on something that was never merged into it.
+1 -1
View File
@@ -52,7 +52,7 @@ Boost 是静态链接但没有写进 `vcpkg.json`,所以只能用 classic 模
## 生成文件
`scripts/prepare_env.py` 只生成两个文件:根目录的 `.clangd`(从 `scripts/config_files/.clangd` 模板展开),以及 `tests/CMakePresets.json`(从 `scripts/config_files/tests/CMakePresets.json` 展开,那个文件不入版本库,模板是唯一的一份)。`CMakePresets.json` 是**原地**改写 `VCPKG_ROOT` 与 `CMAKE_TOOLCHAIN_FILE`,不再从模板覆盖。
`scripts/prepare_env.py` 只生成本目录的 `.clangd`(从 `scripts/config_files/.clangd` 模板展开)。测试使用 Server 主工程及同一套 preset,不再生成独立的 `tests/CMakePresets.json`。`CMakePresets.json` 是**原地**改写 `VCPKG_ROOT` 与 `CMAKE_TOOLCHAIN_FILE`,不再从模板覆盖。
`CMakeLists.txt` 和 `tests/CMakeLists.txt` 不再由脚本生成,直接改就行。这两个文件曾经也是从 `scripts/config_files/` 覆盖过来的,但模板早已落后到 147 行对 400 行、以及一个无关的通用 `WinCppTemplate`,跑一次脚本就会毁掉构建,因此模板已删除。
+10 -6
View File
@@ -12,12 +12,16 @@ include(CTest)
set(MSIME_VENDOR_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../vendor")
# Boost
# The author's machine default keeps working untouched; local setups can
# override via the BOOST_ROOT environment variable.
if(DEFINED ENV{BOOST_ROOT})
set(Boost_ROOT "$ENV{BOOST_ROOT}")
else()
set(Boost_ROOT "C:/Users/sonnycalcr/scoop/apps/boost/current")
# An explicit -DBoost_ROOT wins, then BOOST_ROOT from the environment, and only then the scoop
# layout under the current user's profile. The fallback used to name one developer's home directory,
# which meant anyone else without BOOST_ROOT set got a configure failure pointing at a path that
# does not exist on their machine.
if(NOT DEFINED Boost_ROOT)
if(DEFINED ENV{BOOST_ROOT})
set(Boost_ROOT "$ENV{BOOST_ROOT}")
elseif(DEFINED ENV{USERPROFILE} AND EXISTS "$ENV{USERPROFILE}/scoop/apps/boost/current")
set(Boost_ROOT "$ENV{USERPROFILE}/scoop/apps/boost/current")
endif()
endif()
set (Boost_USE_STATIC_LIBS ON)
find_package(Boost REQUIRED COMPONENTS locale json)
+4 -4
View File
@@ -6,10 +6,10 @@
"generator": "Visual Studio 18 2026",
"binaryDir": "${sourceDir}/build",
"environment": {
"VCPKG_ROOT": "C:/Users/sonnycalcr/scoop/apps/vcpkg/current/"
"VCPKG_ROOT": "$penv{VCPKG_ROOT}"
},
"cacheVariables": {
"CMAKE_TOOLCHAIN_FILE": "C:/Users/sonnycalcr/scoop/apps/vcpkg/current/scripts/buildsystems/vcpkg.cmake",
"CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
"CMAKE_BUILD_TYPE": "Debug",
"VCPKG_TARGET_TRIPLET": "x64-windows"
}
@@ -19,10 +19,10 @@
"generator": "Visual Studio 18 2026",
"binaryDir": "${sourceDir}/build-release",
"environment": {
"VCPKG_ROOT": "C:/Users/sonnycalcr/scoop/apps/vcpkg/current/"
"VCPKG_ROOT": "$penv{VCPKG_ROOT}"
},
"cacheVariables": {
"CMAKE_TOOLCHAIN_FILE": "C:/Users/sonnycalcr/scoop/apps/vcpkg/current/scripts/buildsystems/vcpkg.cmake",
"CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
"CMAKE_BUILD_TYPE": "Release",
"VCPKG_TARGET_TRIPLET": "x64-windows"
}
+28 -3
View File
@@ -11,6 +11,7 @@ This is the server end of [MSIME-Windows](https://github.com/metasequoiaime/MSIM
- vcpkg
- Python3.10+
- Boost
- Node.js and pnpm 10 (settings page build)
Make sure vcpkg and Boost are installed by **Scoop**.
@@ -23,18 +24,40 @@ git clone --recursive https://github.com/metasequoiaime/MSIME-Windows.git
cd MSIME-Windows
python scripts/product_lock.py fetch-dictionaries --staging-root .
if ($LASTEXITCODE -ne 0) { throw 'Dictionary verification failed' }
$devData = Join-Path $PWD 'build/dev-data'
$devLocalData = Join-Path $PWD 'build/dev-local'
$devData = Join-Path $devLocalData 'metasequoiaime'
New-Item -ItemType Directory -Force $devData | Out-Null
Copy-Item MetasequoiaImeDict/out/* $devData -Force
Copy-Item vendor/MetasequoiaImeEngine/helpcode/helpcodes $devData -Recurse -Force
Copy-Item server/assets/tables/* $devData -Force
Copy-Item server/assets/config/config.toml $devData -Force
Push-Location ui-html/webview2/settings/ime-settings
try {
pnpm install --frozen-lockfile
if ($LASTEXITCODE -ne 0) { throw 'Settings dependencies failed' }
pnpm build
if ($LASTEXITCODE -ne 0) { throw 'Settings page build failed' }
} finally { Pop-Location }
$devWebview = Join-Path $devData 'html/webview2'
New-Item -ItemType Directory -Force $devWebview | Out-Null
foreach ($part in @('shared', 'candwnd', 'ftb', 'menu')) {
Copy-Item "ui-html/webview2/$part" $devWebview -Recurse -Force
}
$devSettings = Join-Path $devWebview 'settings/ime-settings'
New-Item -ItemType Directory -Force $devSettings | Out-Null
Copy-Item ui-html/webview2/settings/ime-settings/dist $devSettings -Recurse -Force
python server/scripts/prepare_env.py
if ($LASTEXITCODE -ne 0) { throw 'Server environment setup failed' }
$env:LOCALAPPDATA = $devLocalData
$env:METASEQUOIA_IME_DATA_DIR = $devData
cd server
python scripts/prepare_env.py
```
This uses the same locked data as CI and a separate development data directory.
Use a dedicated PowerShell terminal. These steps stage the same locked data as CI
and the current product pages in a separate development directory. Both
`LOCALAPPDATA` (Windows config/pages) and `METASEQUOIA_IME_DATA_DIR` (Engine data)
are set for that terminal; setting only the latter would still use installed
Windows configuration and pages. Close the terminal after development.
Dictionary source changes belong in Engine's `dictionary/`; use its root `build_profile.py`
to build data instead of calling internal stages in the archived Dict repository.
@@ -99,3 +122,5 @@ The server process includes a few built-in global shortcuts that are useful duri
## Shared voice providers
The server links Engine VoiceCapture and Voice. WAV encoding and batch transcription/polish codecs come from the public library; Windows keeps provider configuration, SiliconFlow padding/retry behavior and Doubao streaming. Batch uploads are bounded to 20 MiB and responses to 1 MiB. Failed or empty polish responses retain the original transcript.
本地 Release 构建脚本可通过 `-ManifestTool <mt.exe 路径>` 使用其他 Windows SDK;未指定时沿用现有 SDK 路径。配置、编译或 manifest 嵌入失败都会中止,避免安装器收集旧产物。
+3
View File
@@ -11,6 +11,9 @@ CompileFlags:
"-I@ENGINE_ROOT@",
# utfcpp includes
"-I@UTFCPP@",
"-I@VOICE_INCLUDE@",
"-I@MINIAUDIO@",
"-I@GUI_INCLUDE@",
# WebView2 includes
"-I@WEBVIEW2@",
"-I@WIL@",
@@ -1,17 +0,0 @@
{
"version": 2,
"configurePresets": [
{
"name": "vcpkg",
"generator": "Visual Studio 17 2022",
"binaryDir": "${sourceDir}/build",
"environment": {
"VCPKG_ROOT": "C:/Users/19833/scoop/apps/vcpkg/current/"
},
"cacheVariables": {
"CMAKE_TOOLCHAIN_FILE": "C:/Users/19833/scoop/apps/vcpkg/current/scripts/buildsystems/vcpkg.cmake",
"CMAKE_BUILD_TYPE": "Debug"
}
}
]
}
+18 -42
View File
@@ -1,42 +1,18 @@
#
# generate, compile and run exe files
#
function getExePathFromCMakeLists([string]$buildDir, [string]$config) {
$content = Get-Content -Raw -Path "./CMakeLists.txt"
$exePath = ""
foreach ($line in $content -split "`n") {
if ($line -match 'set\(MY_EXECUTABLE_NAME[^\"]*\"([^\"]+)\"') {
$exeName = $matches[1]
$exePath = Join-Path -Path $buildDir -ChildPath ("bin/{0}/{1}.exe" -f $config, $exeName)
break
}
}
return $exePath
}
$currentDirectory = Get-Location
$cmakeListsPath = Join-Path -Path $currentDirectory -ChildPath "CMakeLists.txt"
if (-not (Test-Path $cmakeListsPath)) {
Write-Host("No CMakeLists.txt in current directory, please check.")
return
}
Write-Host "Start generating and compiling..."
$buildFolderPath = ".\build-release"
$presetName = "vcpkg-release"
$buildConfig = "Release"
if (-not (Test-Path $buildFolderPath)) {
New-Item -ItemType Directory -Path $buildFolderPath | Out-Null
Write-Host "build-release folder created."
}
# cmake -G "Visual Studio 18 2026" -A x64 -S . -B ./build-release
cmake --preset=$presetName
if ($LASTEXITCODE -eq 0) {
cmake --build $buildFolderPath --config $buildConfig
. "C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\mt.exe" -manifest .\MetasequoiaImeServer.manifest -outputresource:.\build-release\bin\Release\MetasequoiaImeServer.exe; 1
}
# Configure and build the Server; do not reuse old binaries after a failed build.
[CmdletBinding()]
param([string]$ManifestTool = 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\mt.exe')
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$buildDirectory = Join-Path $projectRoot 'build-release'
Push-Location $projectRoot
try {
cmake --preset=vcpkg-release
if ($LASTEXITCODE -ne 0) { throw "Server configure failed ($LASTEXITCODE)" }
cmake --build $buildDirectory --config Release
if ($LASTEXITCODE -ne 0) { throw "Server build failed ($LASTEXITCODE)" }
# Keep the local SDK selection; the complete resource argument must reach mt.exe as one value.
$binary = Join-Path $buildDirectory 'bin/Release/MetasequoiaImeServer.exe'
& $ManifestTool `
-manifest (Join-Path $projectRoot 'MetasequoiaImeServer.manifest') "-outputresource:$binary;1"
if ($LASTEXITCODE -ne 0) { throw "Server manifest embedding failed ($LASTEXITCODE)" }
} finally { Pop-Location }
+3 -21
View File
@@ -73,6 +73,9 @@ clangd_substitutions = {
"@VCPKG_INCLUDE@": vcpkg_include_path,
"@ENGINE_ROOT@": engine_path,
"@UTFCPP@": utfcpp_path,
"@VOICE_INCLUDE@": engine_path + "/voice/include",
"@MINIAUDIO@": engine_path + "/voice/third_party/miniaudio",
"@GUI_INCLUDE@": monorepo_root_path + "/ui/include",
"@WEBVIEW2@": webview2_path,
"@WIL@": wil_path,
"@BOOST@": boost_path,
@@ -113,24 +116,3 @@ content = re.sub(
)
with open(CMakePresets_file, "w", encoding="utf-8") as f:
f.write(content)
#
# project_root/tests/CMakePresets.json
#
# This one is genuinely generated: the file is not tracked, so the template is
# the only copy.
#
Tests_CMakePresets_file = os.path.join(
MetasequoiaImeServer_root_path, "scripts", "config_files", "tests", "CMakePresets.json"
)
Tests_CMakePresets_output_file = os.path.join(
MetasequoiaImeServer_root_path, "tests", "CMakePresets.json"
)
with open(Tests_CMakePresets_file, "r", encoding="utf-8") as f:
lines = f.readlines()
lines[8] = f' "VCPKG_ROOT": "{vcpkg_root}/"\n'
lines[11] = (
f' "CMAKE_TOOLCHAIN_FILE": "{vcpkg_root}/scripts/buildsystems/vcpkg.cmake",\n'
)
with open(Tests_CMakePresets_output_file, "w", encoding="utf-8") as f:
f.writelines(lines)
+39 -19
View File
@@ -50,13 +50,14 @@ std::string Fetch(const AiAssistant::Request &request, uint64_t generation)
nlohmann::json input = {{"segmented_pinyin", request.pinyin_segments},
{"context", request.context},
{"candidate_limit", config.candidate_limit}};
nlohmann::json body = {{"model", config.model},
{"stream", false},
{"temperature", 0.2},
{"max_tokens", 512},
{"response_format", {{"type", "json_object"}}},
{"messages", {{{"role", "system"}, {"content", config.prompt}},
{{"role", "user"}, {"content", input.dump()}}}}};
nlohmann::json body = {
{"model", config.model},
{"stream", false},
{"temperature", 0.2},
{"max_tokens", 512},
{"response_format", {{"type", "json_object"}}},
{"messages",
{{{"role", "system"}, {"content", config.prompt}}, {{"role", "user"}, {"content", input.dump()}}}}};
if (config.provider == "deepseek")
{
// DeepSeek thinking can add hundreds of reasoning tokens and noticeably delay
@@ -68,7 +69,8 @@ std::string Fetch(const AiAssistant::Request &request, uint64_t generation)
(void)0;
CURL *curl = curl_easy_init();
if (!curl) return {};
if (!curl)
return {};
std::string response;
const std::string authorization = "Authorization: Bearer " + config.token;
curl_slist *headers = nullptr;
@@ -130,7 +132,8 @@ void WorkerLoop()
{
std::unique_lock lock(g_mutex);
g_cv.wait(lock, [&] { return !g_running || g_generation.load() != observed; });
if (!g_running) break;
if (!g_running)
break;
observed = g_generation.load();
auto request = g_latest;
const auto cached = g_candidate_cache.find(BuildCacheKey(request));
@@ -145,15 +148,18 @@ void WorkerLoop()
continue;
}
auto target = g_last_input + kIdleDelay;
while (g_running && g_cv.wait_until(lock, target, [&] { return !g_running || g_generation.load() != observed; }))
while (g_running &&
g_cv.wait_until(lock, target, [&] { return !g_running || g_generation.load() != observed; }))
{
if (!g_running) return;
if (!g_running)
return;
observed = g_generation.load();
request = g_latest;
target = g_last_input + kIdleDelay;
}
lock.unlock();
if (request.identity.empty()) continue;
if (request.identity.empty())
continue;
const std::string candidate = Fetch(request, observed);
if (!candidate.empty() && g_generation.load() == observed && g_callback)
{
@@ -172,7 +178,8 @@ namespace AiAssistant
{
void Start(ApplyCallback callback)
{
if (g_running) return;
if (g_running)
return;
curl_global_init(CURL_GLOBAL_DEFAULT);
g_callback = std::move(callback);
g_running = true;
@@ -180,18 +187,31 @@ void Start(ApplyCallback callback)
}
void Stop()
{
if (!g_running) return;
if (!g_running)
return;
g_running = false;
g_cv.notify_all();
if (g_worker.joinable()) g_worker.join();
{ std::lock_guard lock(g_mutex); g_candidate_cache.clear(); }
if (g_worker.joinable())
g_worker.join();
{
std::lock_guard lock(g_mutex);
g_candidate_cache.clear();
}
curl_global_cleanup();
}
void OnInputChanged(Request request)
{
{ std::lock_guard lock(g_mutex); g_latest = std::move(request); g_last_input = std::chrono::steady_clock::now(); ++g_generation;
(void)0; }
{
std::lock_guard lock(g_mutex);
g_latest = std::move(request);
g_last_input = std::chrono::steady_clock::now();
++g_generation;
(void)0;
}
g_cv.notify_one();
}
void Clear() { OnInputChanged({}); }
void Clear()
{
OnInputChanged({});
}
} // namespace AiAssistant
+7 -5
View File
@@ -41,7 +41,10 @@ class NamedMutex
CloseHandle(handle_);
}
explicit operator bool() const { return locked_; }
explicit operator bool() const
{
return locked_;
}
private:
HANDLE handle_ = nullptr;
@@ -65,8 +68,8 @@ std::string WideToUtf8(const std::wstring &text)
{
if (text.empty())
return {};
const int size = WideCharToMultiByte(CP_UTF8, 0, text.c_str(), static_cast<int>(text.size()), nullptr, 0, nullptr,
nullptr);
const int size =
WideCharToMultiByte(CP_UTF8, 0, text.c_str(), static_cast<int>(text.size()), nullptr, 0, nullptr, nullptr);
if (size <= 0)
return {};
std::string result(static_cast<size_t>(size), '\0');
@@ -366,8 +369,7 @@ bool WriteEnabledFlag(bool enabled)
const size_t comment = text.find('#', value_begin);
if (comment != std::string::npos && comment < value_end)
value_end = comment;
while (value_end > value_begin &&
(text[value_end - 1] == ' ' || text[value_end - 1] == '\t'))
while (value_end > value_begin && (text[value_end - 1] == ' ' || text[value_end - 1] == '\t'))
--value_end;
text.replace(value_begin, value_end - value_begin, replacement);
replaced = true;
+3 -3
View File
@@ -144,9 +144,9 @@ void ApplyTranslatedGroup(const std::vector<EnglishIme::TranslationQuery> &group
}
void TranslateGroup(const TencentTmt::Credentials &credentials, const CustomTranslation::Config &custom,
bool use_custom, const std::vector<EnglishIme::TranslationQuery> &group,
const std::string &source, const std::string &target, const std::string &target_language,
const std::string &provider_scope, std::vector<EnglishIme::TranslationResult> &out)
bool use_custom, const std::vector<EnglishIme::TranslationQuery> &group, const std::string &source,
const std::string &target, const std::string &target_language, const std::string &provider_scope,
std::vector<EnglishIme::TranslationResult> &out)
{
std::vector<std::string> texts;
texts.reserve(group.size());
+11 -14
View File
@@ -41,10 +41,10 @@ bool Sha256(std::string_view data, unsigned char out[32])
if (BCryptOpenAlgorithmProvider(&alg, BCRYPT_SHA256_ALGORITHM, nullptr, 0) != 0)
return false;
const NTSTATUS created = BCryptCreateHash(alg, &hash, nullptr, 0, nullptr, 0, 0);
const NTSTATUS hashed =
created == 0 ? BCryptHashData(hash, reinterpret_cast<PUCHAR>(const_cast<char *>(data.data())),
static_cast<ULONG>(data.size()), 0)
: created;
const NTSTATUS hashed = created == 0
? BCryptHashData(hash, reinterpret_cast<PUCHAR>(const_cast<char *>(data.data())),
static_cast<ULONG>(data.size()), 0)
: created;
const NTSTATUS finished = hashed == 0 ? BCryptFinishHash(hash, out, 32, 0) : hashed;
if (hash)
BCryptDestroyHash(hash);
@@ -62,10 +62,10 @@ bool HmacSha256(std::string_view key, std::string_view data, unsigned char out[3
const NTSTATUS created =
BCryptCreateHash(alg, &hash, nullptr, 0, reinterpret_cast<PUCHAR>(const_cast<char *>(key.data())),
static_cast<ULONG>(key.size()), 0);
const NTSTATUS hashed =
created == 0 ? BCryptHashData(hash, reinterpret_cast<PUCHAR>(const_cast<char *>(data.data())),
static_cast<ULONG>(data.size()), 0)
: created;
const NTSTATUS hashed = created == 0
? BCryptHashData(hash, reinterpret_cast<PUCHAR>(const_cast<char *>(data.data())),
static_cast<ULONG>(data.size()), 0)
: created;
const NTSTATUS finished = hashed == 0 ? BCryptFinishHash(hash, out, 32, 0) : hashed;
if (hash)
BCryptDestroyHash(hash);
@@ -134,10 +134,7 @@ std::vector<std::string> TextTranslateBatch(const Credentials &credentials, cons
!CloudTranslation::IsUsableSecret(credentials.secret_key) || source.empty() || target.empty())
return results;
nlohmann::json body = {{"Source", source},
{"Target", target},
{"ProjectId", 0},
{"SourceTextList", texts}};
nlohmann::json body = {{"Source", source}, {"Target", target}, {"ProjectId", 0}, {"SourceTextList", texts}};
const std::string payload = body.dump();
const time_t timestamp = std::time(nullptr);
const std::string authorization = Tc3Authorization(credentials, payload, timestamp);
@@ -155,8 +152,8 @@ std::vector<std::string> TextTranslateBatch(const Credentials &credentials, cons
const std::string action = std::string("X-TC-Action: ") + kAction;
const std::string ts = std::string("X-TC-Timestamp: ") + std::to_string(timestamp);
const std::string version = std::string("X-TC-Version: ") + kVersion;
const std::string region = std::string("X-TC-Region: ") +
(credentials.region.empty() ? "ap-guangzhou" : credentials.region);
const std::string region =
std::string("X-TC-Region: ") + (credentials.region.empty() ? "ap-guangzhou" : credentials.region);
const std::string auth = "Authorization: " + authorization;
headers = curl_slist_append(headers, content_type.c_str());
headers = curl_slist_append(headers, host.c_str());
+9 -1
View File
@@ -94,7 +94,15 @@ bool ShouldPersistGloss(const std::string &key, const std::string &formatted)
bool IsUsableSecret(const std::string &value)
{
const std::string trimmed = TrimSecret(value);
return !trimmed.empty() && trimmed.find("<YOUR_OWN_") != 0;
if (trimmed.empty())
return false;
// Same rule as VoiceInput::IsPlaceholderToken, and same reason: config.default.toml ships
// "<YOUR_TENCENT_SECRET_ID>", which the old "<YOUR_OWN_" prefix test accepted as a real secret. With
// tencent_tmt.enabled defaulting to true that made a fresh install send candidate text to
// tmt.tencentcloudapi.com signed with the placeholder.
if (trimmed.front() == '<' && trimmed.back() == '>')
return false;
return trimmed.find("FAKESECRET_") != 0;
}
std::string TrimSecret(const std::string &value)
+200 -159
View File
@@ -17,6 +17,7 @@
#include <mutex>
#include <optional>
#include <set>
#include <shared_mutex>
#include <sstream>
#include <vector>
#include "utils/common_utils.h"
@@ -117,7 +118,11 @@ std::string g_theme_emoji = "follow";
std::string g_theme_screen_keyboard = "follow";
std::string g_theme_handwriting = "follow";
std::string g_theme_voice = "follow";
VoiceInputConfig g_voice_input;
// LoadImeConfig rewrites g_voice_input from the IPC worker thread while the voice control thread and the low-level
// keyboard hook read it, so every access goes through this process-local lock. Unlike ConfigFileLock it is never held
// across file I/O or IPC: a hook that blocks on a cross-process wait would stall typing system-wide.
std::shared_mutex g_voice_input_mutex;
VoiceInputConfig g_voice_input; // guarded by g_voice_input_mutex
bool g_persist_asr_token_slot = false;
bool g_persist_polish_token_slot = false;
AiAssistantConfig g_ai_assistant;
@@ -127,6 +132,18 @@ FrequencyAdjustmentConfig g_frequency_adjustment;
std::filesystem::path g_config_path;
std::optional<std::filesystem::file_time_type> g_config_last_write_time;
VoiceInputConfig SnapshotVoiceInput()
{
std::shared_lock<std::shared_mutex> lock(g_voice_input_mutex);
return g_voice_input;
}
void PublishVoiceInput(VoiceInputConfig voice)
{
std::unique_lock<std::shared_mutex> lock(g_voice_input_mutex);
g_voice_input = std::move(voice);
}
std::string NormalizeSmallWindowUiBackend(const std::string &value)
{
if (UiBackendPolicy::Resolve(UiBackendPolicy::Surface::Candidate, value) == UiBackendPolicy::Backend::WebView2)
@@ -796,8 +813,7 @@ bool LoadImeConfig()
const std::string layout = tbl["appearance"]["candidate_window_layout"].value_or(std::string("vertical"));
g_candidate_window_layout = layout == "horizontal" ? "horizontal" : "vertical";
g_candidate_window_follow_cursor = tbl["appearance"]["candidate_window_follow_cursor"].value_or(true);
g_ui_backend = NormalizeSmallWindowUiBackend(
tbl["appearance"]["ui_backend"].value_or(std::string("d2d")));
g_ui_backend = NormalizeSmallWindowUiBackend(tbl["appearance"]["ui_backend"].value_or(std::string("d2d")));
const std::string skin = tbl["appearance"]["candidate_skin"].value_or(std::string("fluent"));
g_candidate_skin = IsValidCandidateSkinId(skin) ? skin : "fluent";
{
@@ -832,100 +848,99 @@ bool LoadImeConfig()
g_tsf_preedit_style = GlobalSettings::normalizeTsfPreeditStyle(tsf_preedit_style);
GlobalSettings::setTsfPreeditStyle(g_tsf_preedit_style);
}
g_voice_input.enabled = tbl["voice_input"]["voice_input"].value_or(true);
g_voice_input.hotkey_ralt = tbl["voice_input"]["hotkey_ralt"].value_or(true);
g_voice_input.hotkey_ctrl_f9 = tbl["voice_input"]["hotkey_ctrl_f9"].value_or(true);
g_voice_input.hotkey_ctrl_win = tbl["voice_input"]["hotkey_ctrl_win"].value_or(false);
g_voice_input.hotkey_rctrl_ralt = tbl["voice_input"]["hotkey_rctrl_ralt"].value_or(false);
g_voice_input.hotkey_hold_space_lock = tbl["voice_input"]["hotkey_hold_space_lock"].value_or(true);
g_voice_input.asr_provider = tbl["voice_input"]["asr_provider"].value_or(std::string("doubao"));
g_voice_input.asr_app_key = tbl["voice_input"]["asr_app_key"].value_or(std::string());
g_voice_input.asr_token = tbl["voice_input"]["asr_token"].value_or(std::string());
g_voice_input.asr_tokens.clear();
// Parse into a local and publish it in one step, so readers never observe half-rewritten strings or maps.
VoiceInputConfig voice;
voice.enabled = tbl["voice_input"]["voice_input"].value_or(true);
voice.hotkey_ralt = tbl["voice_input"]["hotkey_ralt"].value_or(true);
voice.hotkey_ctrl_f9 = tbl["voice_input"]["hotkey_ctrl_f9"].value_or(true);
voice.hotkey_ctrl_win = tbl["voice_input"]["hotkey_ctrl_win"].value_or(false);
voice.hotkey_rctrl_ralt = tbl["voice_input"]["hotkey_rctrl_ralt"].value_or(false);
voice.hotkey_hold_space_lock = tbl["voice_input"]["hotkey_hold_space_lock"].value_or(true);
voice.asr_provider = tbl["voice_input"]["asr_provider"].value_or(std::string("doubao"));
voice.asr_app_key = tbl["voice_input"]["asr_app_key"].value_or(std::string());
voice.asr_token = tbl["voice_input"]["asr_token"].value_or(std::string());
for (const auto provider : VoiceInput::AsrProviders())
{
const std::string id(provider);
g_voice_input.asr_tokens[id] =
voice.asr_tokens[id] =
VoiceInput::UsableToken(tbl["voice_input"][VoiceInput::AsrTokenSlotKey(id)].value_or(std::string()));
}
{
const std::string provider = VoiceInput::NormalizeProviderId(g_voice_input.asr_provider);
std::string &stored = g_voice_input.asr_tokens[provider];
if (VoiceInput::IsPlaceholderToken(stored) && !VoiceInput::IsPlaceholderToken(g_voice_input.asr_token))
const std::string provider = VoiceInput::NormalizeProviderId(voice.asr_provider);
std::string &stored = voice.asr_tokens[provider];
if (VoiceInput::IsPlaceholderToken(stored) && !VoiceInput::IsPlaceholderToken(voice.asr_token))
{
stored = g_voice_input.asr_token;
stored = voice.asr_token;
g_persist_asr_token_slot = true;
}
g_voice_input.asr_token = stored;
voice.asr_token = stored;
}
g_voice_input.asr_endpoint = tbl["voice_input"]["asr_endpoint"].value_or(
g_voice_input.asr_provider == "doubao"
? std::string("wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async")
: std::string("https://api.siliconflow.cn/v1/audio/transcriptions"));
if (g_voice_input.asr_provider == "doubao" &&
g_voice_input.asr_endpoint == "https://api.siliconflow.cn/v1/audio/transcriptions")
voice.asr_endpoint = tbl["voice_input"]["asr_endpoint"].value_or(
voice.asr_provider == "doubao" ? std::string("wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async")
: std::string("https://api.siliconflow.cn/v1/audio/transcriptions"));
if (voice.asr_provider == "doubao" &&
voice.asr_endpoint == "https://api.siliconflow.cn/v1/audio/transcriptions")
{
g_voice_input.asr_endpoint = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async";
voice.asr_endpoint = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async";
}
g_voice_input.asr_resource_id =
voice.asr_resource_id =
tbl["voice_input"]["asr_resource_id"].value_or(std::string("volc.seedasr.sauc.duration"));
g_voice_input.doubao_enable_itn = tbl["voice_input"]["doubao_enable_itn"].value_or(true);
g_voice_input.doubao_enable_punc = tbl["voice_input"]["doubao_enable_punc"].value_or(true);
g_voice_input.doubao_enable_ddc = tbl["voice_input"]["doubao_enable_ddc"].value_or(false);
g_voice_input.doubao_boosting_table_id = tbl["voice_input"]["doubao_boosting_table_id"].value_or(std::string());
g_voice_input.asr_model = tbl["voice_input"]["asr_model"].value_or(std::string());
g_voice_input.polish_provider = tbl["voice_input"]["polish_provider"].value_or(std::string("siliconflow"));
g_voice_input.polish_token = tbl["voice_input"]["polish_token"].value_or(std::string());
g_voice_input.polish_tokens.clear();
voice.doubao_enable_itn = tbl["voice_input"]["doubao_enable_itn"].value_or(true);
voice.doubao_enable_punc = tbl["voice_input"]["doubao_enable_punc"].value_or(true);
voice.doubao_enable_ddc = tbl["voice_input"]["doubao_enable_ddc"].value_or(false);
voice.doubao_boosting_table_id = tbl["voice_input"]["doubao_boosting_table_id"].value_or(std::string());
voice.asr_model = tbl["voice_input"]["asr_model"].value_or(std::string());
voice.polish_provider = tbl["voice_input"]["polish_provider"].value_or(std::string("siliconflow"));
voice.polish_token = tbl["voice_input"]["polish_token"].value_or(std::string());
for (const auto provider : VoiceInput::PolishProviders())
{
const std::string id(provider);
g_voice_input.polish_tokens[id] =
voice.polish_tokens[id] =
VoiceInput::UsableToken(tbl["voice_input"][VoiceInput::PolishTokenSlotKey(id)].value_or(std::string()));
}
{
const std::string provider = VoiceInput::NormalizeProviderId(g_voice_input.polish_provider);
std::string &stored = g_voice_input.polish_tokens[provider];
if (VoiceInput::IsPlaceholderToken(stored) && !VoiceInput::IsPlaceholderToken(g_voice_input.polish_token))
const std::string provider = VoiceInput::NormalizeProviderId(voice.polish_provider);
std::string &stored = voice.polish_tokens[provider];
if (VoiceInput::IsPlaceholderToken(stored) && !VoiceInput::IsPlaceholderToken(voice.polish_token))
{
stored = g_voice_input.polish_token;
stored = voice.polish_token;
g_persist_polish_token_slot = true;
}
g_voice_input.polish_token = stored;
voice.polish_token = stored;
}
g_voice_input.polish_endpoint = tbl["voice_input"]["polish_endpoint"].value_or(
voice.polish_endpoint = tbl["voice_input"]["polish_endpoint"].value_or(
std::string("https://api.siliconflow.cn/v1/chat/completions"));
g_voice_input.polish_model = tbl["voice_input"]["polish_model"].value_or(std::string());
g_voice_input.polish_prompt = tbl["voice_input"]["polish_prompt"].value_or(std::string());
g_voice_input.polish_prompt_id = tbl["voice_input"]["polish_prompt_id"].value_or(std::string("cleanup"));
if (g_voice_input.polish_prompt_id == "custom")
g_voice_input.polish_prompt_id = "custom_1";
if (g_voice_input.polish_prompt_id != "cleanup" && g_voice_input.polish_prompt_id != "faithful" &&
g_voice_input.polish_prompt_id != "zh2en" && g_voice_input.polish_prompt_id != "casual" &&
g_voice_input.polish_prompt_id != "custom_1" && g_voice_input.polish_prompt_id != "custom_2" &&
g_voice_input.polish_prompt_id != "custom_3")
voice.polish_model = tbl["voice_input"]["polish_model"].value_or(std::string());
voice.polish_prompt = tbl["voice_input"]["polish_prompt"].value_or(std::string());
voice.polish_prompt_id = tbl["voice_input"]["polish_prompt_id"].value_or(std::string("cleanup"));
if (voice.polish_prompt_id == "custom")
voice.polish_prompt_id = "custom_1";
if (voice.polish_prompt_id != "cleanup" && voice.polish_prompt_id != "faithful" &&
voice.polish_prompt_id != "zh2en" && voice.polish_prompt_id != "casual" &&
voice.polish_prompt_id != "custom_1" && voice.polish_prompt_id != "custom_2" &&
voice.polish_prompt_id != "custom_3")
{
g_voice_input.polish_prompt_id = "cleanup";
voice.polish_prompt_id = "cleanup";
}
g_voice_input.polish_prompt_custom_1 = tbl["voice_input"]["polish_prompt_custom_1"].value_or(std::string());
if (g_voice_input.polish_prompt_custom_1.empty())
g_voice_input.polish_prompt_custom_1 = g_voice_input.polish_prompt;
g_voice_input.polish_prompt_custom_2 = tbl["voice_input"]["polish_prompt_custom_2"].value_or(std::string());
g_voice_input.polish_prompt_custom_3 = tbl["voice_input"]["polish_prompt_custom_3"].value_or(std::string());
g_voice_input.language = tbl["voice_input"]["language"].value_or(std::string("zh-cn"));
voice.polish_prompt_custom_1 = tbl["voice_input"]["polish_prompt_custom_1"].value_or(std::string());
if (voice.polish_prompt_custom_1.empty())
voice.polish_prompt_custom_1 = voice.polish_prompt;
voice.polish_prompt_custom_2 = tbl["voice_input"]["polish_prompt_custom_2"].value_or(std::string());
voice.polish_prompt_custom_3 = tbl["voice_input"]["polish_prompt_custom_3"].value_or(std::string());
voice.language = tbl["voice_input"]["language"].value_or(std::string("zh-cn"));
// notification_sound is retained as a fallback for configs written by older versions.
const bool legacy_notification_sound = tbl["voice_input"]["notification_sound"].value_or(true);
g_voice_input.start_sound = tbl["voice_input"]["start_sound"].value_or(legacy_notification_sound);
g_voice_input.end_sound = tbl["voice_input"]["end_sound"].value_or(legacy_notification_sound);
g_voice_input.mute_system_audio = tbl["voice_input"]["mute_system_audio"].value_or(false);
g_voice_input.polish_text = tbl["voice_input"]["polish_text"].value_or(false);
g_voice_input.stream_inline_preedit = tbl["voice_input"]["stream_inline_preedit"].value_or(false);
g_voice_input.commit_mode = tbl["voice_input"]["commit_mode"].value_or(std::string("tsf"));
if (g_voice_input.commit_mode != "tsf" && g_voice_input.commit_mode != "sendinput" &&
g_voice_input.commit_mode != "ctrl_v")
voice.start_sound = tbl["voice_input"]["start_sound"].value_or(legacy_notification_sound);
voice.end_sound = tbl["voice_input"]["end_sound"].value_or(legacy_notification_sound);
voice.mute_system_audio = tbl["voice_input"]["mute_system_audio"].value_or(false);
voice.polish_text = tbl["voice_input"]["polish_text"].value_or(false);
voice.stream_inline_preedit = tbl["voice_input"]["stream_inline_preedit"].value_or(false);
voice.commit_mode = tbl["voice_input"]["commit_mode"].value_or(std::string("tsf"));
if (voice.commit_mode != "tsf" && voice.commit_mode != "sendinput" && voice.commit_mode != "ctrl_v")
{
g_voice_input.commit_mode = "tsf";
voice.commit_mode = "tsf";
}
PublishVoiceInput(std::move(voice));
g_ai_assistant.enabled = tbl["ai_assistant"]["enabled"].value_or(false);
g_ai_assistant.provider =
VoiceInput::NormalizeProviderId(tbl["ai_assistant"]["provider"].value_or(std::string("deepseek")));
@@ -1018,14 +1033,9 @@ bool WriteConfiguredValue(const std::string &section, const std::string &key, co
return false;
}
std::ofstream output(g_config_path, std::ios::binary | std::ios::trunc);
if (!output)
{
return false;
}
output.write(text.data(), static_cast<std::streamsize>(text.size()));
output.close();
if (!output)
// Write a temp file and rename it so a crash can never leave the user with a truncated config. This only makes the
// directory entry swap atomic: there is no FlushFileBuffers, so a power loss can still lose the contents.
if (!WriteFileTextAtomically(g_config_path, text))
{
return false;
}
@@ -1037,27 +1047,29 @@ bool WriteConfiguredValue(const std::string &section, const std::string &key, co
void PersistSeededVoiceInputTokenSlots()
{
VoiceInputConfig voice = SnapshotVoiceInput();
if (g_persist_asr_token_slot)
{
const std::string id = VoiceInput::NormalizeProviderId(g_voice_input.asr_provider);
const std::string id = VoiceInput::NormalizeProviderId(voice.asr_provider);
const std::string key = VoiceInput::AsrTokenSlotKey(id);
if (!key.empty())
WriteConfiguredValue("voice_input", key, EscapeTomlBasicString(g_voice_input.asr_tokens[id]));
WriteConfiguredValue("voice_input", key, EscapeTomlBasicString(voice.asr_tokens[id]));
g_persist_asr_token_slot = false;
}
if (g_persist_polish_token_slot)
{
const std::string id = VoiceInput::NormalizeProviderId(g_voice_input.polish_provider);
const std::string id = VoiceInput::NormalizeProviderId(voice.polish_provider);
const std::string key = VoiceInput::PolishTokenSlotKey(id);
if (!key.empty())
WriteConfiguredValue("voice_input", key, EscapeTomlBasicString(g_voice_input.polish_tokens[id]));
WriteConfiguredValue("voice_input", key, EscapeTomlBasicString(voice.polish_tokens[id]));
g_persist_polish_token_slot = false;
}
}
void MigrateLegacyVoiceInputConfig()
{
if (!g_voice_input.asr_token.empty())
VoiceInputConfig voice = SnapshotVoiceInput();
if (!voice.asr_token.empty())
return;
const std::filesystem::path legacy_path =
std::filesystem::path(CommonUtils::get_local_appdata_path()) / "MetasequoiaVoiceInput" / "config.toml";
@@ -1078,22 +1090,20 @@ void MigrateLegacyVoiceInputConfig()
target = value;
};
migrate_string("asr_provider", legacy["asr_api"]["provider"].value_or(std::string("siliconflow")),
g_voice_input.asr_provider);
migrate_string("asr_token", asr_token, g_voice_input.asr_token);
migrate_string("asr_endpoint", legacy["asr_api"]["endpoint"].value_or(g_voice_input.asr_endpoint),
g_voice_input.asr_endpoint);
voice.asr_provider);
migrate_string("asr_token", asr_token, voice.asr_token);
migrate_string("asr_endpoint", legacy["asr_api"]["endpoint"].value_or(voice.asr_endpoint), voice.asr_endpoint);
migrate_string("polish_provider", legacy["polish_api"]["provider"].value_or(std::string("siliconflow")),
g_voice_input.polish_provider);
migrate_string("polish_token", legacy["polish_api"]["token"].value_or(std::string()),
g_voice_input.polish_token);
migrate_string("polish_endpoint", legacy["polish_api"]["endpoint"].value_or(g_voice_input.polish_endpoint),
g_voice_input.polish_endpoint);
migrate_string("language", legacy["settings"]["language"].value_or(std::string("zh-cn")),
g_voice_input.language);
voice.polish_provider);
migrate_string("polish_token", legacy["polish_api"]["token"].value_or(std::string()), voice.polish_token);
migrate_string("polish_endpoint", legacy["polish_api"]["endpoint"].value_or(voice.polish_endpoint),
voice.polish_endpoint);
migrate_string("language", legacy["settings"]["language"].value_or(std::string("zh-cn")), voice.language);
const bool notification_sound = legacy["settings"]["notification_sound"].value_or(true);
migrate_bool("start_sound", notification_sound, g_voice_input.start_sound);
migrate_bool("end_sound", notification_sound, g_voice_input.end_sound);
migrate_bool("polish_text", legacy["settings"]["polish_text"].value_or(false), g_voice_input.polish_text);
migrate_bool("start_sound", notification_sound, voice.start_sound);
migrate_bool("end_sound", notification_sound, voice.end_sound);
migrate_bool("polish_text", legacy["settings"]["polish_text"].value_or(false), voice.polish_text);
PublishVoiceInput(std::move(voice));
}
catch (const toml::parse_error &)
{
@@ -1214,29 +1224,31 @@ void InitImeConfig()
if (LoadImeConfig())
{
MigrateLegacyVoiceInputConfig();
if (VoiceInput::NormalizeProviderId(g_voice_input.asr_provider) == "siliconflow" &&
g_voice_input.asr_model == "TeleAI/TeleSpeechASR")
VoiceInputConfig voice = SnapshotVoiceInput();
if (VoiceInput::NormalizeProviderId(voice.asr_provider) == "siliconflow" &&
voice.asr_model == "TeleAI/TeleSpeechASR")
{
const std::string model = VoiceInput::DefaultAsrModel("siliconflow");
if (WriteConfiguredValue("voice_input", "asr_model", EscapeTomlBasicString(model)))
g_voice_input.asr_model = model;
voice.asr_model = model;
}
{
const std::string asr_id = VoiceInput::NormalizeProviderId(g_voice_input.asr_provider);
if (VoiceInput::IsPlaceholderToken(g_voice_input.asr_tokens[asr_id]) &&
!VoiceInput::IsPlaceholderToken(g_voice_input.asr_token))
const std::string asr_id = VoiceInput::NormalizeProviderId(voice.asr_provider);
if (VoiceInput::IsPlaceholderToken(voice.asr_tokens[asr_id]) &&
!VoiceInput::IsPlaceholderToken(voice.asr_token))
{
g_voice_input.asr_tokens[asr_id] = g_voice_input.asr_token;
voice.asr_tokens[asr_id] = voice.asr_token;
g_persist_asr_token_slot = true;
}
const std::string polish_id = VoiceInput::NormalizeProviderId(g_voice_input.polish_provider);
if (VoiceInput::IsPlaceholderToken(g_voice_input.polish_tokens[polish_id]) &&
!VoiceInput::IsPlaceholderToken(g_voice_input.polish_token))
const std::string polish_id = VoiceInput::NormalizeProviderId(voice.polish_provider);
if (VoiceInput::IsPlaceholderToken(voice.polish_tokens[polish_id]) &&
!VoiceInput::IsPlaceholderToken(voice.polish_token))
{
g_voice_input.polish_tokens[polish_id] = g_voice_input.polish_token;
voice.polish_tokens[polish_id] = voice.polish_token;
g_persist_polish_token_slot = true;
}
}
PublishVoiceInput(std::move(voice));
PersistSeededVoiceInputTokenSlots();
#ifdef FANY_DEBUG
(void)0;
@@ -2576,9 +2588,9 @@ bool SetConfiguredThemeVoice(const std::string &theme)
return SetSurfaceThemeValue("theme_voice", theme, g_theme_voice);
}
const VoiceInputConfig &GetConfiguredVoiceInput()
VoiceInputConfig GetConfiguredVoiceInput()
{
return g_voice_input;
return SnapshotVoiceInput();
}
bool SetConfiguredVoiceInputString(const std::string &key, const std::string &value)
@@ -2591,10 +2603,15 @@ bool SetConfiguredVoiceInputString(const std::string &key, const std::string &va
value != "casual" && value != "custom_1" && value != "custom_2" && value != "custom_3")
return false;
const auto persist = [](const std::string &toml_key, const std::string &toml_value, std::string &target) {
// Fields are addressed by pointer-to-member rather than by a bare reference because the store has to happen under
// the writer lock, while WriteConfiguredValue does file I/O and a cross-process wait that must never run with that
// lock held.
const auto persist = [](const std::string &toml_key, const std::string &toml_value,
std::string VoiceInputConfig::*field) {
if (!WriteConfiguredValue("voice_input", toml_key, EscapeTomlBasicString(toml_value)))
return false;
target = toml_value;
std::unique_lock<std::shared_mutex> lock(g_voice_input_mutex);
g_voice_input.*field = toml_value;
return true;
};
@@ -2606,9 +2623,14 @@ bool SetConfiguredVoiceInputString(const std::string &key, const std::string &va
const std::string id = VoiceInput::NormalizeProviderId(provider);
if (!WriteConfiguredValue("voice_input", key, EscapeTomlBasicString(value)))
return false;
g_voice_input.asr_tokens[id] = value;
if (VoiceInput::NormalizeProviderId(g_voice_input.asr_provider) == id)
persist("asr_token", value, g_voice_input.asr_token);
std::string asr_provider;
{
std::unique_lock<std::shared_mutex> lock(g_voice_input_mutex);
g_voice_input.asr_tokens[id] = value;
asr_provider = g_voice_input.asr_provider;
}
if (VoiceInput::NormalizeProviderId(asr_provider) == id)
persist("asr_token", value, &VoiceInputConfig::asr_token);
return true;
}
if (key.rfind("polish_token_", 0) == 0)
@@ -2619,84 +2641,101 @@ bool SetConfiguredVoiceInputString(const std::string &key, const std::string &va
const std::string id = VoiceInput::NormalizeProviderId(provider);
if (!WriteConfiguredValue("voice_input", key, EscapeTomlBasicString(value)))
return false;
g_voice_input.polish_tokens[id] = value;
if (VoiceInput::NormalizeProviderId(g_voice_input.polish_provider) == id)
persist("polish_token", value, g_voice_input.polish_token);
std::string polish_provider;
{
std::unique_lock<std::shared_mutex> lock(g_voice_input_mutex);
g_voice_input.polish_tokens[id] = value;
polish_provider = g_voice_input.polish_provider;
}
if (VoiceInput::NormalizeProviderId(polish_provider) == id)
persist("polish_token", value, &VoiceInputConfig::polish_token);
return true;
}
std::string *target = nullptr;
std::string VoiceInputConfig::*target = nullptr;
if (key == "asr_provider")
target = &g_voice_input.asr_provider;
target = &VoiceInputConfig::asr_provider;
else if (key == "asr_app_key")
target = &g_voice_input.asr_app_key;
target = &VoiceInputConfig::asr_app_key;
else if (key == "asr_token")
target = &g_voice_input.asr_token;
target = &VoiceInputConfig::asr_token;
else if (key == "asr_endpoint")
target = &g_voice_input.asr_endpoint;
target = &VoiceInputConfig::asr_endpoint;
else if (key == "asr_resource_id")
target = &g_voice_input.asr_resource_id;
target = &VoiceInputConfig::asr_resource_id;
else if (key == "doubao_boosting_table_id")
target = &g_voice_input.doubao_boosting_table_id;
target = &VoiceInputConfig::doubao_boosting_table_id;
else if (key == "asr_model")
target = &g_voice_input.asr_model;
target = &VoiceInputConfig::asr_model;
else if (key == "polish_provider")
target = &g_voice_input.polish_provider;
target = &VoiceInputConfig::polish_provider;
else if (key == "polish_token")
target = &g_voice_input.polish_token;
target = &VoiceInputConfig::polish_token;
else if (key == "polish_endpoint")
target = &g_voice_input.polish_endpoint;
target = &VoiceInputConfig::polish_endpoint;
else if (key == "polish_model")
target = &g_voice_input.polish_model;
target = &VoiceInputConfig::polish_model;
else if (key == "polish_prompt_id")
target = &g_voice_input.polish_prompt_id;
target = &VoiceInputConfig::polish_prompt_id;
else if (key == "polish_prompt")
target = &g_voice_input.polish_prompt;
target = &VoiceInputConfig::polish_prompt;
else if (key == "polish_prompt_custom_1")
target = &g_voice_input.polish_prompt_custom_1;
target = &VoiceInputConfig::polish_prompt_custom_1;
else if (key == "polish_prompt_custom_2")
target = &g_voice_input.polish_prompt_custom_2;
target = &VoiceInputConfig::polish_prompt_custom_2;
else if (key == "polish_prompt_custom_3")
target = &g_voice_input.polish_prompt_custom_3;
target = &VoiceInputConfig::polish_prompt_custom_3;
else if (key == "language")
target = &g_voice_input.language;
target = &VoiceInputConfig::language;
else if (key == "commit_mode")
target = &g_voice_input.commit_mode;
target = &VoiceInputConfig::commit_mode;
if (!target || !WriteConfiguredValue("voice_input", key, EscapeTomlBasicString(value)))
return false;
*target = value;
{
std::unique_lock<std::shared_mutex> lock(g_voice_input_mutex);
g_voice_input.*target = value;
}
if (key == "polish_prompt_custom_1")
{
WriteConfiguredValue("voice_input", "polish_prompt", EscapeTomlBasicString(""));
std::unique_lock<std::shared_mutex> lock(g_voice_input_mutex);
g_voice_input.polish_prompt.clear();
}
if (key == "asr_token")
{
const std::string slot = VoiceInput::AsrTokenSlotKey(g_voice_input.asr_provider);
const std::string asr_provider = SnapshotVoiceInput().asr_provider;
const std::string slot = VoiceInput::AsrTokenSlotKey(asr_provider);
if (!slot.empty())
{
const std::string id = VoiceInput::NormalizeProviderId(g_voice_input.asr_provider);
g_voice_input.asr_tokens[id] = value;
const std::string id = VoiceInput::NormalizeProviderId(asr_provider);
{
std::unique_lock<std::shared_mutex> lock(g_voice_input_mutex);
g_voice_input.asr_tokens[id] = value;
}
WriteConfiguredValue("voice_input", slot, EscapeTomlBasicString(value));
}
}
else if (key == "polish_token")
{
const std::string slot = VoiceInput::PolishTokenSlotKey(g_voice_input.polish_provider);
const std::string polish_provider = SnapshotVoiceInput().polish_provider;
const std::string slot = VoiceInput::PolishTokenSlotKey(polish_provider);
if (!slot.empty())
{
const std::string id = VoiceInput::NormalizeProviderId(g_voice_input.polish_provider);
g_voice_input.polish_tokens[id] = value;
const std::string id = VoiceInput::NormalizeProviderId(polish_provider);
{
std::unique_lock<std::shared_mutex> lock(g_voice_input_mutex);
g_voice_input.polish_tokens[id] = value;
}
WriteConfiguredValue("voice_input", slot, EscapeTomlBasicString(value));
}
}
else if (key == "asr_provider")
{
persist("asr_token", VoiceInput::ResolveAsrToken(g_voice_input), g_voice_input.asr_token);
persist("asr_token", VoiceInput::ResolveAsrToken(SnapshotVoiceInput()), &VoiceInputConfig::asr_token);
}
else if (key == "polish_provider")
{
persist("polish_token", VoiceInput::ResolvePolishToken(g_voice_input), g_voice_input.polish_token);
persist("polish_token", VoiceInput::ResolvePolishToken(SnapshotVoiceInput()), &VoiceInputConfig::polish_token);
}
return true;
}
@@ -2857,47 +2896,49 @@ bool SetConfiguredClipboardHistoryEnabled(bool enabled)
bool SetConfiguredVoiceInputBool(const std::string &key, bool value)
{
bool *target = nullptr;
bool VoiceInputConfig::*target = nullptr;
if (key == "voice_input")
target = &g_voice_input.enabled;
target = &VoiceInputConfig::enabled;
else if (key == "hotkey_ralt")
target = &g_voice_input.hotkey_ralt;
target = &VoiceInputConfig::hotkey_ralt;
else if (key == "hotkey_ctrl_f9")
target = &g_voice_input.hotkey_ctrl_f9;
target = &VoiceInputConfig::hotkey_ctrl_f9;
else if (key == "hotkey_ctrl_win")
target = &g_voice_input.hotkey_ctrl_win;
target = &VoiceInputConfig::hotkey_ctrl_win;
else if (key == "hotkey_rctrl_ralt")
target = &g_voice_input.hotkey_rctrl_ralt;
target = &VoiceInputConfig::hotkey_rctrl_ralt;
else if (key == "hotkey_hold_space_lock")
target = &g_voice_input.hotkey_hold_space_lock;
target = &VoiceInputConfig::hotkey_hold_space_lock;
else if (key == "start_sound")
target = &g_voice_input.start_sound;
target = &VoiceInputConfig::start_sound;
else if (key == "end_sound")
target = &g_voice_input.end_sound;
target = &VoiceInputConfig::end_sound;
else if (key == "mute_system_audio")
target = &g_voice_input.mute_system_audio;
target = &VoiceInputConfig::mute_system_audio;
else if (key == "doubao_enable_itn")
target = &g_voice_input.doubao_enable_itn;
target = &VoiceInputConfig::doubao_enable_itn;
else if (key == "doubao_enable_punc")
target = &g_voice_input.doubao_enable_punc;
target = &VoiceInputConfig::doubao_enable_punc;
else if (key == "doubao_enable_ddc")
target = &g_voice_input.doubao_enable_ddc;
target = &VoiceInputConfig::doubao_enable_ddc;
else if (key == "notification_sound")
{
// Accept settings pages from older installations during a rolling update.
if (!WriteConfiguredValue("voice_input", key, value ? "true" : "false"))
return false;
std::unique_lock<std::shared_mutex> lock(g_voice_input_mutex);
g_voice_input.start_sound = value;
g_voice_input.end_sound = value;
return true;
}
else if (key == "polish_text")
target = &g_voice_input.polish_text;
target = &VoiceInputConfig::polish_text;
else if (key == "stream_inline_preedit")
target = &g_voice_input.stream_inline_preedit;
target = &VoiceInputConfig::stream_inline_preedit;
if (!target || !WriteConfiguredValue("voice_input", key, value ? "true" : "false"))
return false;
*target = value;
std::unique_lock<std::shared_mutex> lock(g_voice_input_mutex);
g_voice_input.*target = value;
return true;
}
+3 -1
View File
@@ -313,7 +313,9 @@ bool SetConfiguredThemeVoice(const std::string &theme);
// Resolve effective "dark" / "light" for a surface override ("follow" | "dark" | "light").
std::string ResolveConfiguredTheme(const std::string &surface_theme);
bool IsSystemAppsLightTheme();
const VoiceInputConfig &GetConfiguredVoiceInput();
// Returns a snapshot by value: the backing config is rewritten by the IPC worker thread while the voice control thread
// and the low-level keyboard hook read it, so callers must never hold a reference into it.
VoiceInputConfig GetConfiguredVoiceInput();
bool SetConfiguredVoiceInputString(const std::string &key, const std::string &value);
bool SetConfiguredVoiceInputBool(const std::string &key, bool value);
const AiAssistantConfig &GetConfiguredAiAssistant();
+2 -2
View File
@@ -30,8 +30,8 @@ opencc::SimpleConverter *Converter()
{
const auto resources = ResourceDirectory();
if (!resources.empty())
converter = std::make_unique<opencc::SimpleConverter>(
"s2t.json", std::vector<std::string>{resources.u8string()});
converter = std::make_unique<opencc::SimpleConverter>("s2t.json",
std::vector<std::string>{resources.u8string()});
}
catch (...)
{
+1 -1
View File
@@ -7,4 +7,4 @@ namespace ChineseConverter
// Converts one candidate lazily. On configuration/resource errors, returns the
// original text so candidate input remains available.
std::string ToTraditional(const std::string &text);
}
} // namespace ChineseConverter
+145 -116
View File
@@ -129,8 +129,9 @@ void StrokeRect(DeviceResources &resources, const RectF &rect, const D2D1_COLOR_
auto *brush = resources.GetSolidColorBrush(color);
if (target && brush)
{
target->DrawRoundedRectangle(D2D1::RoundedRect(D2D1::RectF(rect.x, rect.y, rect.x + rect.width,
rect.y + rect.height), radius, radius), brush, width);
target->DrawRoundedRectangle(
D2D1::RoundedRect(D2D1::RectF(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height), radius, radius),
brush, width);
}
}
@@ -142,7 +143,7 @@ void DrawText(DeviceResources &resources, const std::wstring &text, const RectF
auto *target = resources.GetRenderTarget();
auto *factory = resources.GetDWriteFactory();
auto *format = resources.GetTextFormat(font, size, weight, alignment, DWRITE_PARAGRAPH_ALIGNMENT_CENTER,
DWRITE_WORD_WRAPPING_NO_WRAP);
DWRITE_WORD_WRAPPING_NO_WRAP);
auto *brush = resources.GetSolidColorBrush(color);
if (!target || !factory || !format || !brush || text.empty())
{
@@ -323,15 +324,24 @@ float EstimateFlowGroupHeight(size_t itemCount, float gridWidth)
const wchar_t *IconForCategory(const std::wstring &title)
{
if (title.find(L"Smileys") != std::wstring::npos) return L"\U0001F600";
if (title.find(L"People") != std::wstring::npos) return L"\U0001F9D1";
if (title.find(L"Animals") != std::wstring::npos) return L"\U0001F43E";
if (title.find(L"Food") != std::wstring::npos) return L"\U0001F355";
if (title.find(L"Travel") != std::wstring::npos) return L"\U0001F697";
if (title.find(L"Activities") != std::wstring::npos) return L"\U0001F389";
if (title.find(L"Objects") != std::wstring::npos) return L"\U0001F4A1";
if (title.find(L"Symbols") != std::wstring::npos) return L"\u2764";
if (title.find(L"Flags") != std::wstring::npos) return L"\U0001F3F3";
if (title.find(L"Smileys") != std::wstring::npos)
return L"\U0001F600";
if (title.find(L"People") != std::wstring::npos)
return L"\U0001F9D1";
if (title.find(L"Animals") != std::wstring::npos)
return L"\U0001F43E";
if (title.find(L"Food") != std::wstring::npos)
return L"\U0001F355";
if (title.find(L"Travel") != std::wstring::npos)
return L"\U0001F697";
if (title.find(L"Activities") != std::wstring::npos)
return L"\U0001F389";
if (title.find(L"Objects") != std::wstring::npos)
return L"\U0001F4A1";
if (title.find(L"Symbols") != std::wstring::npos)
return L"\u2764";
if (title.find(L"Flags") != std::wstring::npos)
return L"\U0001F3F3";
return L"\u263A";
}
@@ -392,8 +402,8 @@ std::wstring DisplayNameForItem(const std::wstring &keywords, const std::wstring
return fallback;
}
float MeasureTextWidth(DeviceResources &resources, const std::wstring &text, float fontSize,
DWRITE_FONT_WEIGHT weight, const wchar_t *fontFamily = L"Segoe UI")
float MeasureTextWidth(DeviceResources &resources, const std::wstring &text, float fontSize, DWRITE_FONT_WEIGHT weight,
const wchar_t *fontFamily = L"Segoe UI")
{
auto *factory = resources.GetDWriteFactory();
auto *format = resources.GetTextFormat(fontFamily, fontSize, weight, DWRITE_TEXT_ALIGNMENT_LEADING,
@@ -403,8 +413,8 @@ float MeasureTextWidth(DeviceResources &resources, const std::wstring &text, flo
return 0.0f;
}
Microsoft::WRL::ComPtr<IDWriteTextLayout> layout;
if (FAILED(factory->CreateTextLayout(text.c_str(), static_cast<UINT32>(text.size()), format, 1000.0f, 64.0f,
&layout)))
if (FAILED(
factory->CreateTextLayout(text.c_str(), static_cast<UINT32>(text.size()), format, 1000.0f, 64.0f, &layout)))
{
return 0.0f;
}
@@ -433,8 +443,8 @@ TextSize MeasureTextSize(DeviceResources &resources, const std::wstring &text, f
return {};
}
Microsoft::WRL::ComPtr<IDWriteTextLayout> layout;
if (FAILED(factory->CreateTextLayout(text.c_str(), static_cast<UINT32>(text.size()), format, 1000.0f, 64.0f,
&layout)))
if (FAILED(
factory->CreateTextLayout(text.c_str(), static_cast<UINT32>(text.size()), format, 1000.0f, 64.0f, &layout)))
{
return {};
}
@@ -509,8 +519,8 @@ void DrawLongTextInCell(DeviceResources &resources, const std::wstring &text, co
{
constexpr float kMinFontSize = 9.0f;
float fontSize = kLongTextFontSize;
const bool cacheHit = cachedFontSize && cachedCellWidth && *cachedFontSize > 0.0f &&
std::abs(*cachedCellWidth - cell.width) <= 0.5f;
const bool cacheHit =
cachedFontSize && cachedCellWidth && *cachedFontSize > 0.0f && std::abs(*cachedCellWidth - cell.width) <= 0.5f;
if (cacheHit)
{
fontSize = *cachedFontSize;
@@ -531,9 +541,9 @@ void DrawLongTextInCell(DeviceResources &resources, const std::wstring &text, co
auto *target = resources.GetRenderTarget();
auto *factory = resources.GetDWriteFactory();
auto *format = resources.GetTextFormat(L"Segoe UI", fontSize, DWRITE_FONT_WEIGHT_NORMAL,
DWRITE_TEXT_ALIGNMENT_CENTER, DWRITE_PARAGRAPH_ALIGNMENT_CENTER,
DWRITE_WORD_WRAPPING_NO_WRAP);
auto *format =
resources.GetTextFormat(L"Segoe UI", fontSize, DWRITE_FONT_WEIGHT_NORMAL, DWRITE_TEXT_ALIGNMENT_CENTER,
DWRITE_PARAGRAPH_ALIGNMENT_CENTER, DWRITE_WORD_WRAPPING_NO_WRAP);
auto *brush = resources.GetSolidColorBrush(color);
if (!target || !factory || !format || !brush || text.empty())
{
@@ -665,8 +675,8 @@ void DrawClipboardHoverTooltip(DeviceResources &resources, const std::wstring &r
return;
const float tipWidth = std::clamp(metrics.width + kPadX * 2.0f, 72.0f, maxBoxWidth);
const float tipHeight = std::clamp(metrics.height + kPadY * 2.0f, kFontSize + kPadY * 2.0f,
maxTextHeight + kPadY * 2.0f);
const float tipHeight =
std::clamp(metrics.height + kPadY * 2.0f, kFontSize + kPadY * 2.0f, maxTextHeight + kPadY * 2.0f);
float tipX = anchor.x + (anchor.width - tipWidth) * 0.5f;
tipX = std::clamp(tipX, panel.x + 12.0f, panel.x + panel.width - tipWidth - 12.0f);
@@ -684,9 +694,8 @@ void DrawClipboardHoverTooltip(DeviceResources &resources, const std::wstring &r
const D2D1_COLOR_F tipBg = lightTheme ? D2D1::ColorF(0x2B2B33, 0.96f) : D2D1::ColorF(0x1C1C22, 0.96f);
FillRect(resources, tipRect, tipBg, 10.0f);
const D2D1_RECT_F textRect =
D2D1::RectF(tipRect.x + kPadX, tipRect.y + kPadY, tipRect.x + tipRect.width - kPadX,
tipRect.y + tipRect.height - kPadY);
const D2D1_RECT_F textRect = D2D1::RectF(tipRect.x + kPadX, tipRect.y + kPadY, tipRect.x + tipRect.width - kPadX,
tipRect.y + tipRect.height - kPadY);
target->PushAxisAlignedClip(textRect, D2D1_ANTIALIAS_MODE_PER_PRIMITIVE);
target->DrawTextLayout(D2D1::Point2F(textRect.left, textRect.top), layout.Get(), brush,
D2D1_DRAW_TEXT_OPTIONS_CLIP);
@@ -914,8 +923,7 @@ void EmojiPanel::LoadSymbolCatalog()
sqlite3_busy_timeout(db, 3000);
sqlite3_stmt *stmt = nullptr;
const char *sql =
"SELECT symbol, category, parent_category, keywords FROM symbol_catalog ORDER BY sort_order";
const char *sql = "SELECT symbol, category, parent_category, keywords FROM symbol_catalog ORDER BY sort_order";
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK)
{
sqlite3_close(db);
@@ -993,8 +1001,7 @@ void EmojiPanel::Arrange(const RectF &finalRect)
{
// Leave room on the left for the magnifying-glass chrome (Windows-style search).
const RectF search = SearchRect();
const RectF textArea = {search.x + 46.0f, search.y + 4.0f, search.width - 58.0f,
search.height - 8.0f};
const RectF textArea = {search.x + 46.0f, search.y + 4.0f, search.width - 58.0f, search.height - 8.0f};
const RectF searchViewportRect = ToViewportRect(textArea);
searchBox_->MeasureInLayout({searchViewportRect.width, searchViewportRect.height});
searchBox_->ArrangeInLayout(searchViewportRect);
@@ -1074,8 +1081,7 @@ RectF EmojiPanel::ToViewportRect(const RectF &designRect) const
PointF EmojiPanel::ToDesignPoint(const PointF &viewportPoint) const
{
return {(viewportPoint.x - viewportBounds_.x) / kPanelScale,
(viewportPoint.y - viewportBounds_.y) / kPanelScale};
return {(viewportPoint.x - viewportBounds_.x) / kPanelScale, (viewportPoint.y - viewportBounds_.y) / kPanelScale};
}
RectF EmojiPanel::ContentViewportRect() const
@@ -1164,7 +1170,7 @@ const EmojiPanel::SymbolTab *EmojiPanel::ActiveSymbolTab() const
}
std::vector<const EmojiPanel::Item *> EmojiPanel::CollectPreviewItems(const std::vector<Group> &groups,
size_t limit) const
size_t limit) const
{
std::vector<const Item *> items;
items.reserve(limit);
@@ -1183,7 +1189,7 @@ std::vector<const EmojiPanel::Item *> EmojiPanel::CollectPreviewItems(const std:
}
std::vector<const EmojiPanel::Item *> EmojiPanel::CollectDiversePreviewItems(const std::vector<Group> &groups,
size_t limit) const
size_t limit) const
{
std::vector<const Item *> items;
if (limit == 0 || groups.empty())
@@ -1312,10 +1318,9 @@ void EmojiPanel::SyncClipboardState(bool forceReload)
ClipboardMonitor::Stop();
}
const bool changed =
forceReload || enabled != clipboardEnabled_ || items.size() != clipboardItems_.size() ||
!std::equal(items.begin(), items.end(), clipboardItems_.begin(),
[](const Item &left, const Item &right) { return left.text == right.text; });
const bool changed = forceReload || enabled != clipboardEnabled_ || items.size() != clipboardItems_.size() ||
!std::equal(items.begin(), items.end(), clipboardItems_.begin(),
[](const Item &left, const Item &right) { return left.text == right.text; });
if (!changed)
return;
@@ -1370,7 +1375,7 @@ void EmojiPanel::EnsureDisplayLayout() const
const bool searching = !query.empty();
const float flowGridWidth = std::max(bounds_.width - kGridLeft - kGridRightPad, kCellSize * 2.0f);
auto appendGroup = [this, flowGridWidth](std::wstring title, std::vector<const Item *> items, Page moreTarget,
bool showMore) {
bool showMore) {
LayoutGroup layout;
layout.title = std::move(title);
layout.items = std::move(items);
@@ -1680,8 +1685,8 @@ void EmojiPanel::EnsureFlowLayout(DeviceResources &resources) const
{
const TextSize measured =
MeasureTextSize(resources, item->text, kLongTextFontSize, DWRITE_FONT_WEIGHT_NORMAL);
layoutWidth = MeasureTextLayoutWidth(resources, item->text, kLongTextFontSize,
DWRITE_FONT_WEIGHT_NORMAL);
layoutWidth =
MeasureTextLayoutWidth(resources, item->text, kLongTextFontSize, DWRITE_FONT_WEIGHT_NORMAL);
layoutWidth = std::max(layoutWidth, measured.width);
item->cachedFlowTextWidth = layoutWidth;
item->hasCachedFlowTextWidth = true;
@@ -2110,8 +2115,8 @@ void EmojiPanel::ActivateMore(size_t layoutIndex)
{
EnterPage(Page::Emoji, recentItems_.empty() ? 1 : 0);
}
else if (target == Page::Kaomoji || target == Page::Symbols || target == Page::Sticker ||
target == Page::Gif || target == Page::Clipboard)
else if (target == Page::Kaomoji || target == Page::Symbols || target == Page::Sticker || target == Page::Gif ||
target == Page::Clipboard)
{
EnterPage(target, 0);
}
@@ -2168,8 +2173,8 @@ void EmojiPanel::Render(DeviceResources &resources)
target->SetTransform(D2D1::Matrix3x2F::Scale(kPanelScale, kPanelScale) * oldTransform);
FillRect(resources, bounds_, background);
DrawText(resources, L"Emoji and more", {bounds_.x + 24.0f, bounds_.y, 240.0f, kHeaderHeight},
18.0f, text, L"Segoe UI", DWRITE_TEXT_ALIGNMENT_LEADING, DWRITE_FONT_WEIGHT_SEMI_BOLD);
DrawText(resources, L"Emoji and more", {bounds_.x + 24.0f, bounds_.y, 240.0f, kHeaderHeight}, 18.0f, text,
L"Segoe UI", DWRITE_TEXT_ALIGNMENT_LEADING, DWRITE_FONT_WEIGHT_SEMI_BOLD);
const RectF close = CloseRect();
if (closeHovered_ || closePressed_)
@@ -2185,8 +2190,8 @@ void EmojiPanel::Render(DeviceResources &resources)
searchFocused ? (lightTheme_ ? D2D1::ColorF(0x9A62AD, 0.75f) : D2D1::ColorF(0xD88BDE, 0.70f))
: D2D1::ColorF(lightTheme_ ? 0xD0D0D8 : 0x3A3A44);
StrokeRect(resources, searchChrome, searchBorder, 10.0f, searchFocused ? 2.0f : 1.0f);
DrawText(resources, L"\uE721", {searchChrome.x + 6.0f, searchChrome.y, 40.0f, searchChrome.height},
24.0f, mutedText, L"Segoe MDL2 Assets", DWRITE_TEXT_ALIGNMENT_CENTER);
DrawText(resources, L"\uE721", {searchChrome.x + 6.0f, searchChrome.y, 40.0f, searchChrome.height}, 24.0f,
mutedText, L"Segoe MDL2 Assets", DWRITE_TEXT_ALIGNMENT_CENTER);
if (InDetailPage())
{
@@ -2223,20 +2228,24 @@ void EmojiPanel::Render(DeviceResources &resources)
DWRITE_FONT_WEIGHT_NORMAL, true);
if (emojiSubTab_ == index)
{
FillRect(resources,
{rect.x + (rect.width - 22.0f) * 0.5f, bounds_.y + 121.0f, 22.0f, 3.0f},
accent, 2.0f);
FillRect(resources, {rect.x + (rect.width - 22.0f) * 0.5f, bounds_.y + 121.0f, 22.0f, 3.0f}, accent,
2.0f);
}
}
}
else
{
const wchar_t *title = L"";
if (page_ == Page::Sticker) title = L"Sticker";
else if (page_ == Page::Gif) title = L"GIF";
else if (page_ == Page::Kaomoji) title = L"Kaomoji";
else if (page_ == Page::Symbols) title = L"Symbols";
else if (page_ == Page::Clipboard) title = L"剪贴板";
if (page_ == Page::Sticker)
title = L"Sticker";
else if (page_ == Page::Gif)
title = L"GIF";
else if (page_ == Page::Kaomoji)
title = L"Kaomoji";
else if (page_ == Page::Symbols)
title = L"Symbols";
else if (page_ == Page::Clipboard)
title = L"剪贴板";
DrawText(resources, title, {bounds_.x + 70.0f, bounds_.y + kNavTop, 280.0f, kNavHeight},
page_ == Page::Clipboard ? kClipboardTitleFontSize : 18.0f, text,
page_ == Page::Clipboard ? CjkUiFont() : L"Segoe UI", DWRITE_TEXT_ALIGNMENT_LEADING,
@@ -2255,9 +2264,8 @@ void EmojiPanel::Render(DeviceResources &resources)
tabIcons_.DrawTabIcon(resources, static_cast<EmojiPanelIcons::Tab>(index), rect, lightTheme_);
if (static_cast<size_t>(page_) == index)
{
FillRect(resources,
{rect.x + (rect.width - 24.0f) * 0.5f, bounds_.y + 122.0f, 24.0f, 2.0f},
accent, 1.0f);
FillRect(resources, {rect.x + (rect.width - 24.0f) * 0.5f, bounds_.y + 122.0f, 24.0f, 2.0f}, accent,
1.0f);
}
}
}
@@ -2273,15 +2281,16 @@ void EmojiPanel::Render(DeviceResources &resources)
}
EnsureFlowLayout(resources);
const RectF viewport = ContentViewportRect();
target->PushAxisAlignedClip(D2D1::RectF(viewport.x, viewport.y, viewport.x + viewport.width,
viewport.y + viewport.height), D2D1_ANTIALIAS_MODE_PER_PRIMITIVE);
target->PushAxisAlignedClip(
D2D1::RectF(viewport.x, viewport.y, viewport.x + viewport.width, viewport.y + viewport.height),
D2D1_ANTIALIAS_MODE_PER_PRIMITIVE);
auto *emojiFormat = resources.GetTextFormat(L"Segoe UI Emoji", kEmojiFontSize, DWRITE_FONT_WEIGHT_NORMAL,
DWRITE_TEXT_ALIGNMENT_CENTER, DWRITE_PARAGRAPH_ALIGNMENT_CENTER,
DWRITE_WORD_WRAPPING_NO_WRAP);
auto *titleFormat = resources.GetTextFormat(L"Segoe UI", 18.0f, DWRITE_FONT_WEIGHT_SEMI_BOLD,
DWRITE_TEXT_ALIGNMENT_LEADING, DWRITE_PARAGRAPH_ALIGNMENT_CENTER,
DWRITE_WORD_WRAPPING_NO_WRAP);
auto *titleFormat =
resources.GetTextFormat(L"Segoe UI", 18.0f, DWRITE_FONT_WEIGHT_SEMI_BOLD, DWRITE_TEXT_ALIGNMENT_LEADING,
DWRITE_PARAGRAPH_ALIGNMENT_CENTER, DWRITE_WORD_WRAPPING_NO_WRAP);
auto *textBrush = resources.GetSolidColorBrush(text);
const float contentOriginY = viewport.y - scrollOffset_;
@@ -2299,7 +2308,8 @@ void EmojiPanel::Render(DeviceResources &resources)
}
const RectF titleRect = {bounds_.x + 24.0f, groupTop, bounds_.width - 88.0f, group.titleHeight};
if (!group.title.empty() && VerticallyIntersects(titleRect.y, titleRect.y + titleRect.height, viewTop, viewBottom))
if (!group.title.empty() &&
VerticallyIntersects(titleRect.y, titleRect.y + titleRect.height, viewTop, viewBottom))
{
DrawFormattedText(resources, group.title, titleRect, titleFormat, textBrush, false);
}
@@ -2328,8 +2338,8 @@ void EmojiPanel::Render(DeviceResources &resources)
if (!VerticallyIntersects(cell.y, cell.y + cell.height, viewTop, viewBottom))
continue;
const size_t flatIndex = group.firstFlatIndex + indexInGroup;
const bool active = flatIndex == selectedItem_ || flatIndex == hoveredItem_ ||
flatIndex == pressedItem_;
const bool active =
flatIndex == selectedItem_ || flatIndex == hoveredItem_ || flatIndex == pressedItem_;
FillRect(resources, cell,
active ? (flatIndex == pressedItem_ ? pressed : selected)
: D2D1::ColorF(lightTheme_ ? 0xFFFFFF : 0x2B2B33),
@@ -2449,9 +2459,10 @@ void EmojiPanel::Render(DeviceResources &resources)
{bounds_.x + 40.0f, bounds_.y + kContentTop + 96.0f, bounds_.width - 80.0f, 44.0f},
kClipboardHintFontSize, mutedText, DWRITE_TEXT_ALIGNMENT_CENTER, DWRITE_FONT_WEIGHT_NORMAL);
const RectF enable = EnableClipboardButtonRect();
const D2D1_COLOR_F purple = enableClipboardPressed_ ? (lightTheme_ ? D2D1::ColorF(0x7A3E91) : D2D1::ColorF(0xB06CBC))
: enableClipboardHovered_ ? (lightTheme_ ? D2D1::ColorF(0xB07CC4) : D2D1::ColorF(0xE2A8E8))
: accent;
const D2D1_COLOR_F purple =
enableClipboardPressed_ ? (lightTheme_ ? D2D1::ColorF(0x7A3E91) : D2D1::ColorF(0xB06CBC))
: enableClipboardHovered_ ? (lightTheme_ ? D2D1::ColorF(0xB07CC4) : D2D1::ColorF(0xE2A8E8))
: accent;
FillRect(resources, enable, purple, 12.0f);
DrawText(resources, L"开启剪贴板", enable, kClipboardButtonFontSize, D2D1::ColorF(0xFFFFFF), CjkUiFont(),
DWRITE_TEXT_ALIGNMENT_CENTER, DWRITE_FONT_WEIGHT_SEMI_BOLD);
@@ -2478,8 +2489,8 @@ void EmojiPanel::Render(DeviceResources &resources)
else if (page_ == Page::Symbols && symbolGroups_.empty())
emptyText = L"Symbol catalog not found";
DrawText(resources, emptyText,
{bounds_.x + 30.0f, bounds_.y + kContentTop + 50.0f, bounds_.width - 60.0f, 64.0f},
emptySize, mutedText, emptyFont, DWRITE_TEXT_ALIGNMENT_CENTER);
{bounds_.x + 30.0f, bounds_.y + kContentTop + 50.0f, bounds_.width - 60.0f, 64.0f}, emptySize,
mutedText, emptyFont, DWRITE_TEXT_ALIGNMENT_CENTER);
}
target->PopAxisAlignedClip();
@@ -2498,13 +2509,12 @@ void EmojiPanel::Render(DeviceResources &resources)
constexpr float kToastFontSize = 20.0f * kPanelScale;
const RectF panel = ToViewportRect(bounds_);
const float textWidth = MeasureTextWidth(resources, toastText_, kToastFontSize, DWRITE_FONT_WEIGHT_SEMI_BOLD);
const float width = std::min(std::max(textWidth + kToastPadX * 2.0f, 96.0f * kPanelScale),
panel.width - 40.0f * kPanelScale);
const float width =
std::min(std::max(textWidth + kToastPadX * 2.0f, 96.0f * kPanelScale), panel.width - 40.0f * kPanelScale);
const RectF toast = {panel.x + (panel.width - width) * 0.5f,
panel.y + panel.height - (kToastBottomPad + kToastHeight) * kPanelScale,
width, kToastHeight * kPanelScale};
const D2D1_COLOR_F toastBg =
lightTheme_ ? D2D1::ColorF(0x2B2B33, 0.94f) : D2D1::ColorF(0x3A3A44, 0.96f);
panel.y + panel.height - (kToastBottomPad + kToastHeight) * kPanelScale, width,
kToastHeight * kPanelScale};
const D2D1_COLOR_F toastBg = lightTheme_ ? D2D1::ColorF(0x2B2B33, 0.94f) : D2D1::ColorF(0x3A3A44, 0.96f);
const D2D1_COLOR_F toastFg = D2D1::ColorF(0xF5F5F7);
FillRect(resources, toast, toastBg, toast.height * 0.5f);
DrawText(resources, toastText_, toast, kToastFontSize, toastFg, L"Segoe UI", DWRITE_TEXT_ALIGNMENT_CENTER,
@@ -2524,8 +2534,7 @@ void EmojiPanel::Render(DeviceResources &resources)
const float tipOriginY = ContentViewportRect().y - scrollOffset_;
for (const auto &group : layoutGroups_)
{
if (tooltipItem_ < group.firstFlatIndex ||
tooltipItem_ >= group.firstFlatIndex + group.items.size())
if (tooltipItem_ < group.firstFlatIndex || tooltipItem_ >= group.firstFlatIndex + group.items.size())
{
continue;
}
@@ -2546,10 +2555,10 @@ void EmojiPanel::Render(DeviceResources &resources)
{
const std::wstring &tipFont = ThemeManager::GetCurrent().textInputFontFamily;
const float tooltipFontSize = kTooltipFontSize * kPanelScale;
const float textWidth = MeasureTextWidth(resources, tip, tooltipFontSize,
DWRITE_FONT_WEIGHT_NORMAL, tipFont.c_str());
const float tipWidth = std::min(textWidth + kTooltipPadX * 2.0f * kPanelScale,
panel.width - 24.0f * kPanelScale);
const float textWidth =
MeasureTextWidth(resources, tip, tooltipFontSize, DWRITE_FONT_WEIGHT_NORMAL, tipFont.c_str());
const float tipWidth =
std::min(textWidth + kTooltipPadX * 2.0f * kPanelScale, panel.width - 24.0f * kPanelScale);
const float tipHeight = (kTooltipFontSize + kTooltipPadY * 2.0f + 6.0f) * kPanelScale;
float tipX = anchor.x + (anchor.width - tipWidth) * 0.5f;
tipX = std::clamp(tipX, panel.x + 12.0f * kPanelScale,
@@ -2577,13 +2586,24 @@ void EmojiPanel::Render(DeviceResources &resources)
}
}
bool EmojiPanel::HitTest(const PointF &point) const { return Contains(bounds_, point); }
bool EmojiPanel::IsFocusable() const { return true; }
void EmojiPanel::OnFocusChanged(bool focused) { focused_ = focused; InvalidateVisual(); }
bool EmojiPanel::HitTest(const PointF &point) const
{
return Contains(bounds_, point);
}
bool EmojiPanel::IsFocusable() const
{
return true;
}
void EmojiPanel::OnFocusChanged(bool focused)
{
focused_ = focused;
InvalidateVisual();
}
bool EmojiPanel::OnMouseDown(const POINT &point, WPARAM)
{
if (!window_) return false;
if (!window_)
return false;
const PointF dip = ToDesignPoint(window_->ClientPixelsToDips(point));
const RectF scrollbarThumb = ScrollbarThumbRect();
const RectF scrollbarHitRect = {scrollbarThumb.x - 4.0f, scrollbarThumb.y, scrollbarThumb.width + 8.0f,
@@ -2604,10 +2624,9 @@ bool EmojiPanel::OnMouseDown(const POINT &point, WPARAM)
backPressed_ = !closePressed_ && HitBack(dip);
enableClipboardPressed_ = !closePressed_ && !backPressed_ && HitEnableClipboardButton(dip);
pressedMainTab_ = (closePressed_ || backPressed_ || enableClipboardPressed_) ? kInvalidIndex : HitMainTab(dip);
pressedSubTab_ =
(closePressed_ || backPressed_ || enableClipboardPressed_ || pressedMainTab_ != kInvalidIndex)
? kInvalidIndex
: HitEmojiSubTab(dip);
pressedSubTab_ = (closePressed_ || backPressed_ || enableClipboardPressed_ || pressedMainTab_ != kInvalidIndex)
? kInvalidIndex
: HitEmojiSubTab(dip);
pressedMore_ = (closePressed_ || backPressed_ || enableClipboardPressed_ || pressedMainTab_ != kInvalidIndex ||
pressedSubTab_ != kInvalidIndex)
? kInvalidIndex
@@ -2617,18 +2636,19 @@ bool EmojiPanel::OnMouseDown(const POINT &point, WPARAM)
pressedSubTab_ != kInvalidIndex || pressedMore_ != kInvalidIndex)
? kInvalidIndex
: HitClipboardDelete(dip);
pressedItem_ = (closePressed_ || backPressed_ || enableClipboardPressed_ || pressedMainTab_ != kInvalidIndex ||
pressedSubTab_ != kInvalidIndex || pressedMore_ != kInvalidIndex ||
pressedClipboardDelete_ != kInvalidIndex)
? kInvalidIndex
: HitItem(dip);
pressedItem_ =
(closePressed_ || backPressed_ || enableClipboardPressed_ || pressedMainTab_ != kInvalidIndex ||
pressedSubTab_ != kInvalidIndex || pressedMore_ != kInvalidIndex || pressedClipboardDelete_ != kInvalidIndex)
? kInvalidIndex
: HitItem(dip);
InvalidateVisual();
return true;
}
bool EmojiPanel::OnMouseUp(const POINT &point, WPARAM)
{
if (!window_) return false;
if (!window_)
return false;
const PointF dip = ToDesignPoint(window_->ClientPixelsToDips(point));
if (scrollbarDragging_)
{
@@ -2693,13 +2713,15 @@ bool EmojiPanel::OnMouseUp(const POINT &point, WPARAM)
pressedClipboardDelete_ = kInvalidIndex;
pressedItem_ = kInvalidIndex;
InvalidateVisual();
if (close) PostMessageW(window_->GetHandle(), WM_CLOSE, 0, 0);
if (close)
PostMessageW(window_->GetHandle(), WM_CLOSE, 0, 0);
return true;
}
bool EmojiPanel::OnMouseMove(const POINT &point, WPARAM)
{
if (!window_) return false;
if (!window_)
return false;
const PointF dip = ToDesignPoint(window_->ClientPixelsToDips(point));
if (scrollbarDragging_)
{
@@ -2723,14 +2745,14 @@ bool EmojiPanel::OnMouseMove(const POINT &point, WPARAM)
const size_t more = (close || back || enableClipboard || mainTab != kInvalidIndex || subTab != kInvalidIndex)
? kInvalidIndex
: HitMoreButton(dip);
const size_t deleted =
(close || back || enableClipboard || mainTab != kInvalidIndex || subTab != kInvalidIndex || more != kInvalidIndex)
? kInvalidIndex
: HitClipboardDelete(dip);
const size_t item =
(close || back || enableClipboard || mainTab != kInvalidIndex || subTab != kInvalidIndex || more != kInvalidIndex)
? kInvalidIndex
: HitItem(dip);
const size_t deleted = (close || back || enableClipboard || mainTab != kInvalidIndex || subTab != kInvalidIndex ||
more != kInvalidIndex)
? kInvalidIndex
: HitClipboardDelete(dip);
const size_t item = (close || back || enableClipboard || mainTab != kInvalidIndex || subTab != kInvalidIndex ||
more != kInvalidIndex)
? kInvalidIndex
: HitItem(dip);
if (closeHovered_ != close || backHovered_ != back || enableClipboardHovered_ != enableClipboard ||
hoveredMainTab_ != mainTab || hoveredSubTab_ != subTab || hoveredMore_ != more || hoveredItem_ != item ||
hoveredClipboardDelete_ != deleted)
@@ -2788,9 +2810,12 @@ bool EmojiPanel::OnKeyDown(WPARAM key, LPARAM)
return false;
}
const size_t count = DisplayItemCount();
if (count == 0) return false;
if (key == VK_LEFT && selectedItem_ > 0) --selectedItem_;
else if (key == VK_RIGHT && selectedItem_ + 1 < count) ++selectedItem_;
if (count == 0)
return false;
if (key == VK_LEFT && selectedItem_ > 0)
--selectedItem_;
else if (key == VK_RIGHT && selectedItem_ + 1 < count)
++selectedItem_;
else if (key == VK_UP)
{
if (IsFlowFlatIndex(selectedItem_))
@@ -2815,10 +2840,14 @@ bool EmojiPanel::OnKeyDown(WPARAM key, LPARAM)
selectedItem_ = std::min(selectedItem_ + columns, count - 1);
}
}
else if (key == VK_HOME) selectedItem_ = 0;
else if (key == VK_END) selectedItem_ = count - 1;
else if (key == VK_RETURN || key == VK_SPACE) ActivateItem(selectedItem_);
else return false;
else if (key == VK_HOME)
selectedItem_ = 0;
else if (key == VK_END)
selectedItem_ = count - 1;
else if (key == VK_RETURN || key == VK_SPACE)
ActivateItem(selectedItem_);
else
return false;
EnsureItemVisible(selectedItem_);
InvalidateVisual();
return true;
+5 -8
View File
@@ -31,8 +31,7 @@ D2D1_COLOR_F IconFillColor(bool lightTheme)
}
} // namespace
bool EmojiPanelIcons::DrawTabIcon(DeviceResources &resources, Tab tab, const RectF &designRect,
bool lightTheme) const
bool EmojiPanelIcons::DrawTabIcon(DeviceResources &resources, Tab tab, const RectF &designRect, bool lightTheme) const
{
const auto index = static_cast<size_t>(tab);
if (index >= static_cast<size_t>(Tab::Count))
@@ -42,10 +41,9 @@ bool EmojiPanelIcons::DrawTabIcon(DeviceResources &resources, Tab tab, const Rec
ID2D1RenderTarget *target = resources.GetRenderTarget();
IDWriteFactory *factory = resources.GetDWriteFactory();
IDWriteTextFormat *format =
resources.GetTextFormat(kFluentIconsFont, kIconFontSize, DWRITE_FONT_WEIGHT_NORMAL,
DWRITE_TEXT_ALIGNMENT_CENTER, DWRITE_PARAGRAPH_ALIGNMENT_CENTER,
DWRITE_WORD_WRAPPING_NO_WRAP);
IDWriteTextFormat *format = resources.GetTextFormat(kFluentIconsFont, kIconFontSize, DWRITE_FONT_WEIGHT_NORMAL,
DWRITE_TEXT_ALIGNMENT_CENTER, DWRITE_PARAGRAPH_ALIGNMENT_CENTER,
DWRITE_WORD_WRAPPING_NO_WRAP);
ID2D1SolidColorBrush *brush = resources.GetSolidColorBrush(IconFillColor(lightTheme));
if (!target || !factory || !format || !brush)
{
@@ -59,8 +57,7 @@ bool EmojiPanelIcons::DrawTabIcon(DeviceResources &resources, Tab tab, const Rec
return false;
}
target->DrawTextLayout(D2D1::Point2F(designRect.x, designRect.y), layout.Get(), brush,
D2D1_DRAW_TEXT_OPTIONS_CLIP);
target->DrawTextLayout(D2D1::Point2F(designRect.x, designRect.y), layout.Get(), brush, D2D1_DRAW_TEXT_OPTIONS_CLIP);
return true;
}
} // namespace msimeui
+11 -15
View File
@@ -84,9 +84,8 @@ HRESULT EnsureFactories()
}
if (!g_dwrite)
{
const HRESULT hr =
DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory),
reinterpret_cast<IUnknown **>(g_dwrite.GetAddressOf()));
const HRESULT hr = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory),
reinterpret_cast<IUnknown **>(g_dwrite.GetAddressOf()));
if (FAILED(hr))
{
return hr;
@@ -94,9 +93,9 @@ HRESULT EnsureFactories()
}
if (!g_title_format)
{
const HRESULT hr = g_dwrite->CreateTextFormat(
L"Segoe UI", nullptr, DWRITE_FONT_WEIGHT_SEMI_BOLD, DWRITE_FONT_STYLE_NORMAL,
DWRITE_FONT_STRETCH_NORMAL, 18.0f, L"zh-cn", &g_title_format);
const HRESULT hr =
g_dwrite->CreateTextFormat(L"Segoe UI", nullptr, DWRITE_FONT_WEIGHT_SEMI_BOLD, DWRITE_FONT_STYLE_NORMAL,
DWRITE_FONT_STRETCH_NORMAL, 18.0f, L"zh-cn", &g_title_format);
if (FAILED(hr))
{
return hr;
@@ -106,9 +105,9 @@ HRESULT EnsureFactories()
}
if (!g_hint_format)
{
const HRESULT hr = g_dwrite->CreateTextFormat(
L"Segoe UI", nullptr, DWRITE_FONT_WEIGHT_NORMAL, DWRITE_FONT_STYLE_NORMAL,
DWRITE_FONT_STRETCH_NORMAL, 13.0f, L"zh-cn", &g_hint_format);
const HRESULT hr =
g_dwrite->CreateTextFormat(L"Segoe UI", nullptr, DWRITE_FONT_WEIGHT_NORMAL, DWRITE_FONT_STYLE_NORMAL,
DWRITE_FONT_STRETCH_NORMAL, 13.0f, L"zh-cn", &g_hint_format);
if (FAILED(hr))
{
return hr;
@@ -203,8 +202,7 @@ void PaintSplash()
const float x1 = cx + kSpinnerRadiusDip * std::cos(start + sweep);
const float y1 = cy + kSpinnerRadiusDip * std::sin(start + sweep);
sink->BeginFigure(D2D1::Point2F(x0, y0), D2D1_FIGURE_BEGIN_HOLLOW);
sink->AddArc(D2D1::ArcSegment(D2D1::Point2F(x1, y1),
D2D1::SizeF(kSpinnerRadiusDip, kSpinnerRadiusDip), 0.0f,
sink->AddArc(D2D1::ArcSegment(D2D1::Point2F(x1, y1), D2D1::SizeF(kSpinnerRadiusDip, kSpinnerRadiusDip), 0.0f,
D2D1_SWEEP_DIRECTION_CLOCKWISE, D2D1_ARC_SIZE_SMALL));
sink->EndFigure(D2D1_FIGURE_END_OPEN);
sink->Close();
@@ -213,8 +211,7 @@ void PaintSplash()
for (int i = 0; i < 3; ++i)
{
const float pulse =
0.45f + 0.55f * (0.5f + 0.5f * std::sin(g_phase * 2.2f - static_cast<float>(i) * 0.85f));
const float pulse = 0.45f + 0.55f * (0.5f + 0.5f * std::sin(g_phase * 2.2f - static_cast<float>(i) * 0.85f));
const float r = 2.2f * pulse;
const float dx = static_cast<float>(i - 1) * 10.0f;
g_rt->FillEllipse(D2D1::Ellipse(D2D1::Point2F(cx + dx, cy + kSpinnerRadiusDip + 22.0f), r, r),
@@ -296,8 +293,7 @@ LRESULT CALLBACK SplashWndProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l
return 0;
}
break;
case WM_PAINT:
{
case WM_PAINT: {
PAINTSTRUCT ps{};
BeginPaint(hwnd, &ps);
PaintSplash();
+5 -4
View File
@@ -13,7 +13,9 @@ namespace
class SvgPathParser
{
public:
SvgPathParser(std::string path, ID2D1GeometrySink *sink) : path_(std::move(path)), sink_(sink) {}
SvgPathParser(std::string path, ID2D1GeometrySink *sink) : path_(std::move(path)), sink_(sink)
{
}
bool Parse()
{
@@ -187,9 +189,8 @@ class SvgPathParser
}
EnsureFigure();
const D2D1_POINT_2F control =
lastControlValid_
? D2D1_POINT_2F{2.0f * currentX_ - lastControlX_, 2.0f * currentY_ - lastControlY_}
: D2D1_POINT_2F{currentX_, currentY_};
lastControlValid_ ? D2D1_POINT_2F{2.0f * currentX_ - lastControlX_, 2.0f * currentY_ - lastControlY_}
: D2D1_POINT_2F{currentX_, currentY_};
sink_->AddQuadraticBezier({control, *end});
lastControlX_ = control.x;
lastControlY_ = control.y;
+2 -2
View File
@@ -50,8 +50,8 @@ void WorkerLoop()
}
auto candidates = metasequoia::local_modes::query_emoji(
input, scheme, metasequoia::path_from_utf8(g_db_path.c_str()),
kMixedCandidateLimit, GetShuangpinProfile(GetConfiguredShuangpinSchema()))
input, scheme, metasequoia::path_from_utf8(g_db_path.c_str()), kMixedCandidateLimit,
GetShuangpinProfile(GetConfiguredShuangpinSchema()))
.candidates;
if (!g_running || g_generation.load() != observed_generation)
{
+2 -2
View File
@@ -117,8 +117,8 @@ void WorkerLoop()
const size_t min_prefix = dedicated_mode ? 1 : (std::max)(size_t{1}, mixed_min_prefix);
if (prefix.size() < min_prefix)
{
if (dedicated_mode && !input.empty() && g_running &&
g_generation.load() == observed_generation && g_apply_callback)
if (dedicated_mode && !input.empty() && g_running && g_generation.load() == observed_generation &&
g_apply_callback)
{
g_apply_callback({}, input, observed_generation);
}
+50 -2
View File
@@ -1,5 +1,7 @@
#pragma once
#include <algorithm>
#include <memory>
#include <mutex>
#include <string>
#include <string_view>
#include <unordered_set>
@@ -219,6 +221,53 @@ struct CandidateUiState
};
inline CandidateUiState candidate_ui;
// candidate_ui belongs to the IPC worker thread, which rebuilds and destroys its vectors while the UI thread paints;
// the handoff is an asynchronous PostMessage, so nothing serialises the two. The worker therefore copies each finished
// page into one of these immutable snapshots and publishes it atomically, and every UI-thread reader (candidate
// presenter, its mouse callbacks, the WebView2 payload) loads a snapshot once and reads only from that copy.
struct CandidatePageSnapshot
{
std::vector<CandidateViewItem> page_views;
std::vector<std::wstring> page_words;
std::wstring candidate_string;
int selected_index_in_page = 0;
// page_count mirrors current_page_count() and page_item_count mirrors cur_page_item_cnt. Both are carried because
// the height estimate prefers the derived count and only falls back to the rendered one.
int page_count = 0;
int page_item_count = 0;
};
using CandidatePageSnapshotPtr = std::shared_ptr<const CandidatePageSnapshot>;
inline std::mutex &CandidatePageSnapshotMutex()
{
static std::mutex mutex;
return mutex;
}
inline CandidatePageSnapshotPtr &CandidatePageSnapshotStorage()
{
static CandidatePageSnapshotPtr snapshot = std::make_shared<const CandidatePageSnapshot>();
return snapshot;
}
inline CandidatePageSnapshotPtr LoadCandidatePageSnapshot()
{
std::lock_guard lock(CandidatePageSnapshotMutex());
return CandidatePageSnapshotStorage();
}
inline void PublishCandidatePageSnapshot(CandidatePageSnapshotPtr snapshot)
{
std::lock_guard lock(CandidatePageSnapshotMutex());
CandidatePageSnapshotStorage() = std::move(snapshot);
}
inline void ClearCandidatePageSnapshot()
{
PublishCandidatePageSnapshot(std::make_shared<const CandidatePageSnapshot>());
}
//
// 云候选
//
@@ -257,8 +306,7 @@ constexpr std::string_view Cand = "cand";
inline bool isKnownTsfPreeditStyle(std::string_view style)
{
return style == TsfPreeditStyle::Raw || style == TsfPreeditStyle::Pinyin ||
style == TsfPreeditStyle::Empty;
return style == TsfPreeditStyle::Raw || style == TsfPreeditStyle::Pinyin || style == TsfPreeditStyle::Empty;
}
inline std::string normalizeTsfPreeditStyle(std::string_view style)
@@ -23,13 +23,17 @@ bool Contains(const RectF &r, const PointF &p)
return p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;
}
D2D1_RECT_F D2DRect(const RectF &r) { return D2D1::RectF(r.x, r.y, r.x + r.width, r.y + r.height); }
D2D1_RECT_F D2DRect(const RectF &r)
{
return D2D1::RectF(r.x, r.y, r.x + r.width, r.y + r.height);
}
void Fill(DeviceResources &resources, const RectF &rect, D2D1_COLOR_F color, float radius = 0.0f)
{
auto *target = resources.GetRenderTarget();
auto *brush = resources.GetSolidColorBrush(color);
if (!target || !brush) return;
if (!target || !brush)
return;
if (radius > 0.0f)
target->FillRoundedRectangle(D2D1::RoundedRect(D2DRect(rect), radius, radius), brush);
else
@@ -40,7 +44,8 @@ void Outline(DeviceResources &resources, const RectF &rect, D2D1_COLOR_F color,
{
auto *target = resources.GetRenderTarget();
auto *brush = resources.GetSolidColorBrush(color);
if (!target || !brush) return;
if (!target || !brush)
return;
if (radius > 0.0f)
target->DrawRoundedRectangle(D2D1::RoundedRect(D2DRect(rect), radius, radius), brush, width);
else
@@ -48,11 +53,12 @@ void Outline(DeviceResources &resources, const RectF &rect, D2D1_COLOR_F color,
}
void Text(DeviceResources &resources, const std::wstring &text, const RectF &rect, float size, D2D1_COLOR_F color,
DWRITE_TEXT_ALIGNMENT alignment = DWRITE_TEXT_ALIGNMENT_CENTER, DWRITE_FONT_WEIGHT weight = DWRITE_FONT_WEIGHT_NORMAL)
DWRITE_TEXT_ALIGNMENT alignment = DWRITE_TEXT_ALIGNMENT_CENTER,
DWRITE_FONT_WEIGHT weight = DWRITE_FONT_WEIGHT_NORMAL)
{
auto *target = resources.GetRenderTarget();
auto *format = resources.GetTextFormat(L"Noto Sans SC", size, weight, alignment,
DWRITE_PARAGRAPH_ALIGNMENT_CENTER, DWRITE_WORD_WRAPPING_NO_WRAP);
auto *format = resources.GetTextFormat(L"Noto Sans SC", size, weight, alignment, DWRITE_PARAGRAPH_ALIGNMENT_CENTER,
DWRITE_WORD_WRAPPING_NO_WRAP);
auto *brush = resources.GetSolidColorBrush(color);
if (target && format && brush)
target->DrawTextW(text.c_str(), static_cast<UINT32>(text.size()), format, D2DRect(rect), brush,
@@ -63,7 +69,8 @@ void DrawCloseIcon(DeviceResources &resources, const RectF &rect, D2D1_COLOR_F c
{
auto *target = resources.GetRenderTarget();
auto *brush = resources.GetSolidColorBrush(color);
if (!target || !brush) return;
if (!target || !brush)
return;
const float cx = rect.x + rect.width * 0.5f;
const float cy = rect.y + rect.height * 0.5f;
const float half = std::min(rect.width, rect.height) * 0.19f;
@@ -74,18 +81,22 @@ void DrawCloseIcon(DeviceResources &resources, const RectF &rect, D2D1_COLOR_F c
void DrawInkStroke(DeviceResources &resources, const std::vector<PointF> &points, ID2D1Brush *brush)
{
auto *target = resources.GetRenderTarget();
if (!target || !brush || points.size() < 2) return;
if (!target || !brush || points.size() < 2)
return;
Microsoft::WRL::ComPtr<ID2D1Factory> factory;
target->GetFactory(&factory);
Microsoft::WRL::ComPtr<ID2D1PathGeometry> geometry;
Microsoft::WRL::ComPtr<ID2D1GeometrySink> sink;
if (!factory || FAILED(factory->CreatePathGeometry(&geometry)) || FAILED(geometry->Open(&sink))) return;
if (!factory || FAILED(factory->CreatePathGeometry(&geometry)) || FAILED(geometry->Open(&sink)))
return;
sink->BeginFigure({points.front().x, points.front().y}, D2D1_FIGURE_BEGIN_FILLED);
for (size_t i = 1; i < points.size(); ++i) sink->AddLine({points[i].x, points[i].y});
for (size_t i = 1; i < points.size(); ++i)
sink->AddLine({points[i].x, points[i].y});
sink->EndFigure(D2D1_FIGURE_END_OPEN);
if (FAILED(sink->Close())) return;
if (FAILED(sink->Close()))
return;
D2D1_STROKE_STYLE_PROPERTIES properties = {};
properties.startCap = D2D1_CAP_STYLE_ROUND;
@@ -95,7 +106,8 @@ void DrawInkStroke(DeviceResources &resources, const std::vector<PointF> &points
properties.miterLimit = 10.0f;
properties.dashStyle = D2D1_DASH_STYLE_SOLID;
Microsoft::WRL::ComPtr<ID2D1StrokeStyle> strokeStyle;
if (FAILED(factory->CreateStrokeStyle(properties, nullptr, 0, &strokeStyle))) return;
if (FAILED(factory->CreateStrokeStyle(properties, nullptr, 0, &strokeStyle)))
return;
target->DrawGeometry(geometry.Get(), brush, 4.0f, strokeStyle.Get());
}
@@ -118,13 +130,15 @@ bool LooksLikeChineseRecognizer(const winrt::hstring &name)
bool ContainsCjk(const std::wstring &text)
{
return std::any_of(text.begin(), text.end(), [](wchar_t ch) {
return (ch >= 0x3400 && ch <= 0x4DBF) || (ch >= 0x4E00 && ch <= 0x9FFF) ||
(ch >= 0xF900 && ch <= 0xFAFF);
return (ch >= 0x3400 && ch <= 0x4DBF) || (ch >= 0x4E00 && ch <= 0x9FFF) || (ch >= 0xF900 && ch <= 0xFAFF);
});
}
} // namespace
SizeF HandwritingPanel::Measure(const SizeF &availableSize) { return availableSize; }
SizeF HandwritingPanel::Measure(const SizeF &availableSize)
{
return availableSize;
}
void HandwritingPanel::Arrange(const RectF &finalRect)
{
@@ -142,8 +156,7 @@ void HandwritingPanel::RebuildLayout()
const float leftWidth = contentWidth * 0.43f;
canvasRect_ = {bounds_.x + pad, top, leftWidth, leftWidth};
const float bottom = canvasRect_.y + canvasRect_.height;
resultsRect_ = {canvasRect_.x + canvasRect_.width + 28.0f, top,
std::max(1.0f, contentWidth - leftWidth - 28.0f),
resultsRect_ = {canvasRect_.x + canvasRect_.width + 28.0f, top, std::max(1.0f, contentWidth - leftWidth - 28.0f),
std::max(1.0f, bounds_.y + bounds_.height - pad - top)};
undoRect_ = {canvasRect_.x, bottom + 18.0f, 112.0f, 42.0f};
clearRect_ = {undoRect_.x + undoRect_.width + 12.0f, undoRect_.y, 112.0f, 42.0f};
@@ -153,8 +166,7 @@ void HandwritingPanel::RebuildLayout()
const float gridTop = resultsRect_.y + 42.0f;
for (size_t i = 0; i < 12; ++i)
candidateRects_.push_back({resultsRect_.x + static_cast<float>(i % 4) * (cellWidth + gap),
gridTop + static_cast<float>(i / 4) * (cellWidth + gap),
cellWidth, cellWidth});
gridTop + static_cast<float>(i / 4) * (cellWidth + gap), cellWidth, cellWidth});
}
void HandwritingPanel::Render(DeviceResources &resources)
@@ -170,8 +182,8 @@ void HandwritingPanel::Render(DeviceResources &resources)
Fill(resources, bounds_, background);
Fill(resources, headerRect_, background);
Text(resources, L"\u6c34\u6749\u624b\u5199\u8bc6\u522b\u677f",
{headerRect_.x + 14.0f, headerRect_.y, 220.0f, headerRect_.height}, 14.0f, text,
DWRITE_TEXT_ALIGNMENT_LEADING, DWRITE_FONT_WEIGHT_NORMAL);
{headerRect_.x + 14.0f, headerRect_.y, 220.0f, headerRect_.height}, 14.0f, text, DWRITE_TEXT_ALIGNMENT_LEADING,
DWRITE_FONT_WEIGHT_NORMAL);
if (closeHovered_ || closePressed_)
Fill(resources, closeRect_, closePressed_ ? surfaceHover : surface, 5.0f);
DrawCloseIcon(resources, closeRect_, text);
@@ -182,7 +194,8 @@ void HandwritingPanel::Render(DeviceResources &resources)
auto *inkBrush = resources.GetSolidColorBrush(text);
if (target && inkBrush)
{
for (const auto &stroke : strokes_) DrawInkStroke(resources, stroke, inkBrush);
for (const auto &stroke : strokes_)
DrawInkStroke(resources, stroke, inkBrush);
}
if (strokes_.empty())
Text(resources, L"\u8bf7\u5728\u8fd9\u91cc\u4e66\u5199", canvasRect_, 18.0f, mutedText);
@@ -193,16 +206,15 @@ void HandwritingPanel::Render(DeviceResources &resources)
{
const bool hovered = i == hoveredCandidate_;
Fill(resources, candidateRects_[i], hovered ? surfaceHover : surface, 6.0f);
Outline(resources, candidateRects_[i], hovered ? accent : border,
hovered ? 2.0f : 1.0f, 5.0f);
Outline(resources, candidateRects_[i], hovered ? accent : border, hovered ? 2.0f : 1.0f, 5.0f);
if (i < candidates_.size())
{
// The window is sized in physical pixels while Direct2D lays out in DIPs. Keep glyphs
// proportional to the actual cell so high-DPI displays cannot clip the candidates.
const float lengthScale = candidates_[i].size() > 1 ? static_cast<float>(candidates_[i].size()) : 1.0f;
const float fontSize = std::clamp(
std::min(candidateRects_[i].height * 0.52f, (candidateRects_[i].width - 10.0f) / lengthScale),
13.0f, 34.0f);
std::min(candidateRects_[i].height * 0.52f, (candidateRects_[i].width - 10.0f) / lengthScale), 13.0f,
34.0f);
const RectF textRect = {candidateRects_[i].x + 3.0f, candidateRects_[i].y + 2.0f,
candidateRects_[i].width - 6.0f, candidateRects_[i].height - 4.0f};
Text(resources, candidates_[i], textRect, fontSize, text);
@@ -219,18 +231,23 @@ void HandwritingPanel::Render(DeviceResources &resources)
Text(resources, L"\u2715 \u91cd\u5199", clearRect_, 15.0f, text);
}
bool HandwritingPanel::HitTest(const PointF &point) const { return Contains(bounds_, point); }
bool HandwritingPanel::HitTest(const PointF &point) const
{
return Contains(bounds_, point);
}
size_t HandwritingPanel::HitCandidate(const PointF &point) const
{
for (size_t i = 0; i < candidateRects_.size() && i < candidates_.size(); ++i)
if (Contains(candidateRects_[i], point)) return i;
if (Contains(candidateRects_[i], point))
return i;
return kInvalid;
}
bool HandwritingPanel::OnMouseDown(const POINT &point, WPARAM)
{
if (!window_) return false;
if (!window_)
return false;
const PointF p = window_->ClientPixelsToDips(point);
closePressed_ = Contains(closeRect_, p);
if (closePressed_)
@@ -250,7 +267,8 @@ bool HandwritingPanel::OnMouseDown(const POINT &point, WPARAM)
bool HandwritingPanel::OnMouseMove(const POINT &point, WPARAM)
{
if (!window_) return false;
if (!window_)
return false;
const PointF p = window_->ClientPixelsToDips(point);
const bool closeHover = Contains(closeRect_, p);
const size_t hover = HitCandidate(p);
@@ -276,7 +294,8 @@ bool HandwritingPanel::OnMouseMove(const POINT &point, WPARAM)
bool HandwritingPanel::OnMouseUp(const POINT &point, WPARAM)
{
if (!window_) return false;
if (!window_)
return false;
const PointF p = window_->ClientPixelsToDips(point);
const bool close = closePressed_ && Contains(closeRect_, p);
closePressed_ = false;
@@ -287,16 +306,20 @@ bool HandwritingPanel::OnMouseUp(const POINT &point, WPARAM)
else if (drawing_)
{
drawing_ = false;
if (strokes_.back().size() < 2) strokes_.pop_back();
if (strokes_.back().size() < 2)
strokes_.pop_back();
Recognize();
}
else if (pressedCandidate_ != kInvalid && pressedCandidate_ == HitCandidate(p)) CopyCandidate(pressedCandidate_);
else if (pressedCandidate_ != kInvalid && pressedCandidate_ == HitCandidate(p))
CopyCandidate(pressedCandidate_);
else if (Contains(undoRect_, p))
{
if (!strokes_.empty()) strokes_.pop_back();
if (!strokes_.empty())
strokes_.pop_back();
Recognize();
}
else if (Contains(clearRect_, p)) Clear();
else if (Contains(clearRect_, p))
Clear();
pressedCandidate_ = kInvalid;
InvalidateVisual();
return true;
@@ -385,13 +408,15 @@ void HandwritingPanel::Recognize()
if (!error.empty())
hint_ = L"Windows Ink \u8bc6\u522b\u5931\u8d25\uff1a" + error;
else
hint_ = candidates_.empty() ? L"\u672a\u8bc6\u522b\u5230\u5185\u5bb9\uff0c\u8bf7\u786e\u8ba4\u5df2\u5b89\u88c5\u4e2d\u6587\u624b\u5199\u5305"
hint_ = candidates_.empty() ? L"\u672a\u8bc6\u522b\u5230\u5185\u5bb9\uff0c\u8bf7\u786e\u8ba4\u5df2\u5b89\u88c5"
L"\u4e2d\u6587\u624b\u5199\u5305"
: L"\u70b9\u51fb\u5019\u9009\u7ed3\u679c\u5373\u53ef\u590d\u5236";
}
void HandwritingPanel::CopyCandidate(size_t index)
{
if (index >= candidates_.size() || !window_ || !OpenClipboard(window_->GetHandle())) return;
if (index >= candidates_.size() || !window_ || !OpenClipboard(window_->GetHandle()))
return;
EmptyClipboard();
const SIZE_T bytes = (candidates_[index].size() + 1) * sizeof(wchar_t);
HGLOBAL memory = GlobalAlloc(GMEM_MOVEABLE, bytes);
@@ -402,9 +427,11 @@ void HandwritingPanel::CopyCandidate(size_t index)
{
memcpy(destination, candidates_[index].c_str(), bytes);
GlobalUnlock(memory);
if (SetClipboardData(CF_UNICODETEXT, memory)) memory = nullptr;
if (SetClipboardData(CF_UNICODETEXT, memory))
memory = nullptr;
}
if (memory) GlobalFree(memory);
if (memory)
GlobalFree(memory);
}
CloseClipboard();
hint_ = L"\u5df2\u590d\u5236\uff1a" + candidates_[index];
@@ -417,5 +444,8 @@ void HandwritingPanel::Clear()
hint_ = L"\u5df2\u6e05\u7a7a\uff0c\u8bf7\u91cd\u65b0\u4e66\u5199";
}
HCURSOR HandwritingPanel::GetCursor() const { return LoadCursor(nullptr, drawing_ ? IDC_CROSS : IDC_HAND); }
HCURSOR HandwritingPanel::GetCursor() const
{
return LoadCursor(nullptr, drawing_ ? IDC_CROSS : IDC_HAND);
}
} // namespace msimeui
@@ -10,7 +10,9 @@ namespace msimeui
class HandwritingPanel final : public Visual
{
public:
explicit HandwritingPanel(bool lightTheme) : lightTheme_(lightTheme) {}
explicit HandwritingPanel(bool lightTheme) : lightTheme_(lightTheme)
{
}
SizeF Measure(const SizeF &availableSize) override;
void Arrange(const RectF &finalRect) override;
void Render(DeviceResources &resources) override;
+6 -3
View File
@@ -12,10 +12,13 @@
int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int nCmdShow)
{
CommonUtils::SingleInstanceGuard single_instance(L"Local\\MetasequoiaImeHandwritingPanel.SingleInstance");
if (!single_instance.is_valid()) return -1;
if (single_instance.already_running()) return 0;
if (!single_instance.is_valid())
return -1;
if (single_instance.already_running())
return 0;
if (!msimeui::Application::Initialize()) return -1;
if (!msimeui::Application::Initialize())
return -1;
const bool lightTheme = SurfaceThemeConfig::IsLight("theme_handwriting");
msimeui::Theme theme;
-1
View File
@@ -1,4 +1,3 @@
#pragma once
#define IDI_MSIMEUI_ICON 101
+2 -6
View File
@@ -84,17 +84,13 @@ class ActiveClientState
// the inactive epoch only for the client that created it; client id zero
// alone is insufficient because another client may have activated and
// suspended in the meantime (an inactive-state ABA).
uint64_t terminal_deactivation_epoch(uint64_t client_id,
uint64_t transition_epoch = 0) const
uint64_t terminal_deactivation_epoch(uint64_t client_id, uint64_t transition_epoch = 0) const
{
if (transition_epoch != 0)
{
return transition_epoch;
}
return client_id != 0 && active_client_id_ == 0 &&
inactive_owner_client_id_ == client_id
? epoch_
: 0;
return client_id != 0 && active_client_id_ == 0 && inactive_owner_client_id_ == client_id ? epoch_ : 0;
}
ActiveClientTransition snapshot() const
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
#include <metasequoia/session.h>
namespace FanyImeIpc
{
struct AsyncRequestOrigin
{
std::uint64_t client_id = 0;
std::uint64_t activation_epoch = 0;
std::uint64_t generation = 0;
std::string input;
std::optional<metasequoia::OnlineQuery> engine_query = std::nullopt;
bool matches(std::string_view requested_input, std::uint64_t requested_generation) const
{
return client_id != 0 && activation_epoch != 0 && generation == requested_generation &&
input == requested_input;
}
};
} // namespace FanyImeIpc
+9 -11
View File
@@ -100,14 +100,13 @@ inline void NormalizeMixedCandidateOrder(std::vector<WordItem> &items, size_t lo
for (auto &candidate : kaomoji_candidates)
items.push_back(std::move(candidate));
const auto promoted_english = std::max_element(
items.begin(), items.end(),
[](const WordItem &left, const WordItem &right) { return left.weight < right.weight; });
const auto promoted_english =
std::max_element(items.begin(), items.end(),
[](const WordItem &left, const WordItem &right) { return left.weight < right.weight; });
if (promoted_english != items.end() && promoted_english->source == CandidateSource::EnglishDictionary &&
promoted_english->fixed_position == 0 &&
std::count_if(items.begin(), items.end(), [&](const WordItem &item) {
return item.weight == promoted_english->weight;
}) == 1)
promoted_english->fixed_position == 0 && std::count_if(items.begin(), items.end(), [&](const WordItem &item) {
return item.weight == promoted_english->weight;
}) == 1)
{
WordItem candidate = std::move(*promoted_english);
items.erase(promoted_english);
@@ -127,10 +126,9 @@ inline void NormalizeMixedCandidateOrder(std::vector<WordItem> &items, size_t lo
++candidate;
}
}
std::stable_sort(fixed_english_candidates.begin(), fixed_english_candidates.end(),
[](const WordItem &left, const WordItem &right) {
return left.fixed_position < right.fixed_position;
});
std::stable_sort(
fixed_english_candidates.begin(), fixed_english_candidates.end(),
[](const WordItem &left, const WordItem &right) { return left.fixed_position < right.fixed_position; });
for (auto &candidate : fixed_english_candidates)
insert_at(static_cast<size_t>(candidate.fixed_position - 1), std::move(candidate));
}
+5 -10
View File
@@ -17,12 +17,9 @@ enum class HanCharacterEdge
inline bool IsHanCodePoint(char32_t code_point)
{
return code_point == 0x3007 ||
(code_point >= 0x3400 && code_point <= 0x4DBF) ||
(code_point >= 0x4E00 && code_point <= 0x9FFF) ||
(code_point >= 0xF900 && code_point <= 0xFAFF) ||
(code_point >= 0x20000 && code_point <= 0x2FA1F) ||
(code_point >= 0x30000 && code_point <= 0x323AF);
return code_point == 0x3007 || (code_point >= 0x3400 && code_point <= 0x4DBF) ||
(code_point >= 0x4E00 && code_point <= 0x9FFF) || (code_point >= 0xF900 && code_point <= 0xFAFF) ||
(code_point >= 0x20000 && code_point <= 0x2FA1F) || (code_point >= 0x30000 && code_point <= 0x323AF);
}
inline std::optional<std::string> ExtractHanCharacter(std::string_view text, HanCharacterEdge edge)
@@ -84,11 +81,9 @@ inline std::optional<std::string> ExtractHanCharacter(std::string_view text, Han
return result;
}
inline std::wstring HighlightedCandidateText(const std::vector<std::wstring> &page_words,
int selected_index_in_page)
inline std::wstring HighlightedCandidateText(const std::vector<std::wstring> &page_words, int selected_index_in_page)
{
if (selected_index_in_page < 0 ||
static_cast<size_t>(selected_index_in_page) >= page_words.size())
if (selected_index_in_page < 0 || static_cast<size_t>(selected_index_in_page) >= page_words.size())
{
return {};
}
+1 -2
View File
@@ -45,8 +45,7 @@ class CandidateUiOwnerState
bool matches(const CandidateUiOwner &owner) const
{
return owner && owner_.client_id == owner.client_id &&
owner_.activation_epoch == owner.activation_epoch;
return owner && owner_.client_id == owner.client_id && owner_.activation_epoch == owner.activation_epoch;
}
private:
+157 -133
View File
@@ -15,6 +15,7 @@
#include <unordered_map>
#include "Ipc.h"
#include "ipc/candidate_selection_policy.h"
#include "ipc/async_request_origin.h"
#include "ipc/candidate_ui_owner.h"
#include "ipc/candidate_text_policy.h"
#include "ipc/focus_session_policy.h"
@@ -158,7 +159,6 @@ bool BuildTranslationQuery(const WordItem &item, EnglishIme::TranslationQuery &q
return false;
}
bool IsUiLessMode()
{
return g_activate_uiless || g_session_uiless;
@@ -306,17 +306,15 @@ bool IsJianpinCompositionActive(const std::string &raw)
bool IsJianpinInput(const std::string &raw)
{
return IsJianpinCompositionActive(raw) && raw.size() > 1 &&
std::all_of(raw.begin() + 1, raw.end(), [](unsigned char ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
});
std::all_of(raw.begin() + 1, raw.end(),
[](unsigned char ch) { return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); });
}
bool IsYModeCompositionActive(const std::string &raw)
{
return g_y_mode_triggered && !raw.empty() && raw.front() == 'Y' &&
std::all_of(raw.begin() + 1, raw.end(), [](unsigned char ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
});
std::all_of(raw.begin() + 1, raw.end(),
[](unsigned char ch) { return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); });
}
bool IsYModeInput(const std::string &raw)
@@ -327,8 +325,8 @@ bool IsYModeInput(const std::string &raw)
bool IsShiftLetterSpecialModeTriggered()
{
return g_quick_phrase_triggered || g_unicode_mode_triggered || g_date_time_mode_triggered ||
g_emoji_mode_triggered || g_kaomoji_mode_triggered || g_jianpin_mode_triggered ||
g_y_mode_triggered || g_r_mode_triggered;
g_emoji_mode_triggered || g_kaomoji_mode_triggered || g_jianpin_mode_triggered || g_y_mode_triggered ||
g_r_mode_triggered;
}
void ClearSpecialModeTriggers()
@@ -348,9 +346,8 @@ void ClearSpecialModeTriggers()
// be interpreted as normal pinyin.
bool IsSpecialModeCompositionActive(const std::string &raw)
{
return IsQuickPhraseCompositionActive(raw) || IsUnicodeCompositionActive(raw) ||
IsDateTimeCompositionActive(raw) || IsEmojiCompositionActive(raw) ||
IsKaomojiCompositionActive(raw) || IsJianpinCompositionActive(raw) ||
return IsQuickPhraseCompositionActive(raw) || IsUnicodeCompositionActive(raw) || IsDateTimeCompositionActive(raw) ||
IsEmojiCompositionActive(raw) || IsKaomojiCompositionActive(raw) || IsJianpinCompositionActive(raw) ||
IsYModeCompositionActive(raw);
}
@@ -405,13 +402,7 @@ bool ReadExactPipeMessageUntil(HANDLE pipe, void *destination, DWORD destination
return false;
}
struct AsyncRequestOrigin
{
uint64_t client_id = 0;
uint64_t activation_epoch = 0;
uint64_t generation = 0;
std::string input;
};
using AsyncRequestOrigin = FanyImeIpc::AsyncRequestOrigin;
std::mutex g_async_request_mutex;
uint64_t g_cloud_generation = 0;
@@ -544,6 +535,8 @@ void UpdateCloudInput(const std::string &input, uint64_t client_id = 0, uint64_t
g_cloud_request_origin = effective_input.empty()
? AsyncRequestOrigin{}
: AsyncRequestOrigin{client_id, activation_epoch, g_cloud_generation, effective_input};
if (!effective_input.empty() && g_inputSession)
g_cloud_request_origin.engine_query = g_inputSession->online_query();
}
void UpdateEnglishInput(const std::string &input, uint64_t client_id = 0, uint64_t activation_epoch = 0,
@@ -564,9 +557,8 @@ void UpdateEmojiInput(const std::string &input, uint64_t client_id = 0, uint64_t
std::lock_guard lock(g_async_request_mutex);
EmojiIme::OnInputChanged(input, g_inputSession ? g_inputSession->current_scheme_type() : SchemeType::Quanpin);
++g_emoji_generation;
g_emoji_request_origin = input.empty()
? AsyncRequestOrigin{}
: AsyncRequestOrigin{client_id, activation_epoch, g_emoji_generation, input};
g_emoji_request_origin = input.empty() ? AsyncRequestOrigin{}
: AsyncRequestOrigin{client_id, activation_epoch, g_emoji_generation, input};
}
void UpdateKaomojiInput(const std::string &input, uint64_t client_id = 0, uint64_t activation_epoch = 0)
@@ -610,12 +602,14 @@ void UpdateAiInput(const std::string &identity, uint64_t client_id = 0, uint64_t
++g_ai_generation;
g_ai_request_origin =
usable ? AsyncRequestOrigin{client_id, activation_epoch, g_ai_generation, identity} : AsyncRequestOrigin{};
if (usable)
g_ai_request_origin.engine_query = g_inputSession->online_query();
}
AsyncRequestOrigin FindCloudRequestOrigin(const std::string &input, uint64_t generation)
{
std::lock_guard lock(g_async_request_mutex);
if (g_cloud_request_origin.generation == generation && g_cloud_request_origin.input == input)
if (g_cloud_request_origin.matches(input, generation))
{
return g_cloud_request_origin;
}
@@ -655,7 +649,7 @@ AsyncRequestOrigin FindKaomojiRequestOrigin(const std::string &input, uint64_t g
AsyncRequestOrigin FindAiRequestOrigin(const std::string &input, uint64_t generation)
{
std::lock_guard lock(g_async_request_mutex);
if (g_ai_request_origin.generation == generation && g_ai_request_origin.input == input)
if (g_ai_request_origin.matches(input, generation))
return g_ai_request_origin;
return {};
}
@@ -773,8 +767,8 @@ bool IsPagingKey(UINT keycode)
bool IsCandidateNavigationKey(UINT keycode)
{
return keycode == VK_OEM_MINUS || keycode == VK_OEM_PLUS || keycode == VK_OEM_COMMA || keycode == VK_OEM_PERIOD ||
keycode == VK_OEM_4 || keycode == VK_OEM_6 || keycode == VK_TAB || keycode == VK_PRIOR || keycode == VK_NEXT ||
keycode == VK_UP || keycode == VK_DOWN;
keycode == VK_OEM_4 || keycode == VK_OEM_6 || keycode == VK_TAB || keycode == VK_PRIOR ||
keycode == VK_NEXT || keycode == VK_UP || keycode == VK_DOWN;
}
bool ApplyCompositionEditKey(UINT keycode, WCHAR wch)
@@ -925,7 +919,7 @@ std::string BuildCurrentCandidatePage()
if (show_helpcodes && item.source != CandidateSource::EnglishDictionary &&
item.source != CandidateSource::QuickPhrase && item.source != CandidateSource::Emoji &&
item.source != CandidateSource::Kaomoji && item.source != CandidateSource::Generated)
view.annotation = HelpcodeUtils::compute_helpcodes(item.word, uppercase_all_helpcodes);
view.annotation = g_inputSession->get_helpcode_annotation(item.word, uppercase_all_helpcodes);
if (item.source == CandidateSource::CloudSuggestion)
view.badge = " ☁️";
else if (item.source == CandidateSource::AiSuggestion)
@@ -966,8 +960,7 @@ std::string BuildCurrentCandidatePage()
void PrepareCandidateTranslationRequest()
{
const bool japanese =
g_inputSession && g_inputSession->current_scheme_type() == SchemeType::JapaneseRomaji;
const bool japanese = g_inputSession && g_inputSession->current_scheme_type() == SchemeType::JapaneseRomaji;
const bool enabled = GetConfiguredCandidateTranslationsEnabled() &&
GetConfiguredCandidateWindowLayout() == "vertical" && !IsUiLessMode() && !japanese;
auto &ui = Global::candidate_ui;
@@ -1008,19 +1001,30 @@ void PrepareCandidateTranslationRequest()
EnglishIme::RequestTranslations(std::move(queries), GetConfiguredTencentTmt().target_language == "en");
}
// Copy the page the worker just finished into an immutable snapshot for the UI thread. This is the single publish
// point: set_items() and clear_page() are only intermediate steps of a rebuild, so publishing there would hand the UI a
// half-built page.
void PublishBuiltCandidatePage(const std::wstring &candidate_string)
{
const auto &ui = Global::candidate_ui;
auto snapshot = std::make_shared<Global::CandidatePageSnapshot>();
snapshot->page_views = ui.page_views;
snapshot->page_words = ui.page_words;
snapshot->candidate_string = candidate_string;
snapshot->selected_index_in_page = ui.selected_index_in_page;
snapshot->page_count = ui.current_page_count();
snapshot->page_item_count = ui.cur_page_item_cnt;
Global::PublishCandidatePageSnapshot(std::move(snapshot));
}
void RefreshCandidatePageUi(bool show_window)
{
PrepareCandidateTranslationRequest();
const std::string candidate_string = BuildCurrentCandidatePage();
if (IsUiLessMode())
{
// Host-drawn UI wants plain words (Microsoft IME style), not helpcodes.
::WriteDataToSharedMemory(BuildUiLessCandidatePageW(), true);
}
else
{
::WriteDataToSharedMemory(string_to_wstring(candidate_string), true);
}
// Host-drawn UI wants plain words (Microsoft IME style), not helpcodes.
const std::wstring published = IsUiLessMode() ? BuildUiLessCandidatePageW() : string_to_wstring(candidate_string);
::WriteDataToSharedMemory(published, true);
PublishBuiltCandidatePage(published);
CAND_DIAG_LOGF(L"candidate UI refreshed show={} uiless={} items={} page_words={} selected={} page={} "
L"serialized_units={}",
show_window, IsUiLessMode(), Global::candidate_ui.items.size(),
@@ -1049,8 +1053,8 @@ void LogPipeConnectResult(const wchar_t *pipe_name, BOOL connected)
void LogPipeReadFailure(const wchar_t *pipe_name, DWORD bytes_read)
{
const DWORD gle = GetLastError();
FANY_IPC_LOGF(L"[msime]: [ipc] {} ReadFile failed or returned empty: gle={}, bytes_read={}", pipe_name,
gle, bytes_read);
FANY_IPC_LOGF(L"[msime]: [ipc] {} ReadFile failed or returned empty: gle={}, bytes_read={}", pipe_name, gle,
bytes_read);
CAND_DIAG_LOGF(L"pipe read failure name={} gle={} bytes={}", pipe_name, gle, bytes_read);
}
@@ -1315,6 +1319,7 @@ struct Task
std::string ai_candidate;
std::string ai_identity;
uint64_t ai_generation = 0;
std::optional<metasequoia::OnlineQuery> online_query;
std::vector<WordItem> english_candidates;
std::string english_input;
uint64_t english_generation = 0;
@@ -1346,8 +1351,8 @@ struct ScopedServerKeyLatency
const ULONGLONG elapsed_ms = GetTickCount64() - started_at_ms;
if (elapsed_ms >= 8)
{
DIAG_LOGF(L"[key-latency] side=server stage=handle request={} client={} epoch={} elapsed_ms={}",
request_id, client_id, activation_epoch, elapsed_ms);
DIAG_LOGF(L"[key-latency] side=server stage=handle request={} client={} epoch={} elapsed_ms={}", request_id,
client_id, activation_epoch, elapsed_ms);
}
}
};
@@ -1406,8 +1411,8 @@ std::pair<std::string, std::string> RankingKeysForCandidate(const WordItem &item
{
if (IsWubiRankingScheme())
{
const std::string key = item.pinyin.empty() && g_inputSession ? g_inputSession->get_pinyin_sequence()
: item.pinyin;
const std::string key =
item.pinyin.empty() && g_inputSession ? g_inputSession->get_pinyin_sequence() : item.pinyin;
return {key, item.pinyin};
}
const std::string context_key = CurrentRankingContextKey();
@@ -1421,11 +1426,12 @@ void PrepareCandidateList(uint64_t client_id, uint64_t activation_epoch);
void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t request_id);
void ClearState();
void ProcessSelectionKey(UINT keycode, uint64_t client_id, uint64_t activation_epoch, int forced_index_in_page = -1);
void ApplyCloudCandidate(const std::string &candidate, const std::string &pinyin, uint64_t generation);
void ApplyAiCandidate(const std::string &candidate, const std::string &identity, uint64_t generation);
void ApplyCloudCandidate(const std::string &candidate, const std::string &pinyin, uint64_t generation,
const std::optional<metasequoia::OnlineQuery> &query);
void ApplyAiCandidate(const std::string &candidate, const std::string &identity, uint64_t generation,
const std::optional<metasequoia::OnlineQuery> &query);
void ApplyEnglishCandidates(std::vector<WordItem> candidates, const std::string &input, uint64_t generation);
void ApplyCandidateTranslations(std::vector<EnglishIme::TranslationResult> results, uint64_t generation,
bool merge);
void ApplyCandidateTranslations(std::vector<EnglishIme::TranslationResult> results, uint64_t generation, bool merge);
void ApplyEmojiCandidates(std::vector<WordItem> candidates, const std::string &input, uint64_t generation);
void ApplyKaomojiCandidates(std::vector<WordItem> candidates, const std::string &input, uint64_t generation);
void EnqueueStoreUserPhraseTask(const std::string &pinyin, const std::string &word, bool pinyin_is_canonical = false);
@@ -1506,8 +1512,8 @@ void WorkerThread()
case TaskType::HideCandidate: {
::ReadDataFromNamedPipe(0b100000);
CAND_DIAG_LOGF(L"task HideCandidate client={} epoch={} request={}", task.client_id,
task.activation_epoch, task.pipe_data.request_id);
CAND_DIAG_LOGF(L"task HideCandidate client={} epoch={} request={}", task.client_id, task.activation_epoch,
task.pipe_data.request_id);
PostMessage(::global_hwnd, WM_HIDE_MAIN_WINDOW, 0, 0);
/* 清理状态 */
ClearState();
@@ -1533,8 +1539,7 @@ void WorkerThread()
}
case TaskType::ImeKeyEvent: {
const ULONGLONG queue_elapsed_ms =
task.enqueued_at_ms == 0 ? 0 : GetTickCount64() - task.enqueued_at_ms;
const ULONGLONG queue_elapsed_ms = task.enqueued_at_ms == 0 ? 0 : GetTickCount64() - task.enqueued_at_ms;
if (queue_elapsed_ms >= 8)
{
DIAG_LOGF(L"[key-latency] side=server stage=queue request={} client={} epoch={} elapsed_ms={}",
@@ -1566,12 +1571,12 @@ void WorkerThread()
}
case TaskType::ApplyCloudCandidate: {
ApplyCloudCandidate(task.cloud_candidate, task.cloud_pinyin, task.cloud_generation);
ApplyCloudCandidate(task.cloud_candidate, task.cloud_pinyin, task.cloud_generation, task.online_query);
break;
}
case TaskType::ApplyAiCandidate: {
ApplyAiCandidate(task.ai_candidate, task.ai_identity, task.ai_generation);
ApplyAiCandidate(task.ai_candidate, task.ai_identity, task.ai_generation, task.online_query);
break;
}
@@ -1611,9 +1616,9 @@ void WorkerThread()
}
case TaskType::LearnEnteredEnglishWord: {
(void)user_dictionary::learn_entered_english_word(
CommonUtils::get_ime_data_path() + "\\english.db", user_dictionary::default_user_db_path(),
task.session_word);
(void)user_dictionary::learn_entered_english_word(CommonUtils::get_ime_data_path() + "\\english.db",
user_dictionary::default_user_db_path(),
task.session_word);
break;
}
@@ -1985,6 +1990,7 @@ void EnqueueCloudCandidate(const std::string &candidate, const std::string &piny
task.cloud_candidate = candidate;
task.cloud_pinyin = pinyin;
task.cloud_generation = generation;
task.online_query = origin.engine_query;
task.client_id = origin.client_id;
task.activation_epoch = origin.activation_epoch;
taskQueue.push(std::move(task));
@@ -2004,6 +2010,7 @@ void EnqueueAiCandidate(const std::string &candidate, const std::string &identit
task.ai_candidate = candidate;
task.ai_identity = identity;
task.ai_generation = generation;
task.online_query = origin.engine_query;
task.client_id = origin.client_id;
task.activation_epoch = origin.activation_epoch;
taskQueue.push(std::move(task));
@@ -2104,7 +2111,8 @@ void EnqueueStoreUserPhraseTask(const std::string &pinyin, const std::string &wo
void EnqueueLearnEnteredEnglishWordTask(const std::string &word)
{
if (word.empty()) return;
if (word.empty())
return;
{
std::lock_guard lock(queueMutex);
Task task;
@@ -2792,8 +2800,7 @@ void RegisteredPipeMonitorThread(HANDLE clientPipe, UINT pipeRole, uint64_t hand
hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::SmartPunctuationChanged,
GetConfiguredSmartPunctuationEnabled() ? L"1" : L"0");
SendToTsfWorkerThreadClientViaNamedpipe(
hello.client_id,
Global::DataFromServerMsgTypeToTsfWorkerThread::SmartPunctuationRepeatToChineseChanged,
hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::SmartPunctuationRepeatToChineseChanged,
GetConfiguredSmartPunctuationRepeatToChineseEnabled() ? L"1" : L"0");
SendToTsfWorkerThreadClientViaNamedpipe(
hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::PairedPunctuationChanged,
@@ -2801,12 +2808,12 @@ void RegisteredPipeMonitorThread(HANDLE clientPipe, UINT pipeRole, uint64_t hand
SendToTsfWorkerThreadClientViaNamedpipe(
hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::MicrosoftShuangpinChanged,
GetConfiguredShuangpinSchema() == "microsoft" ? L"1" : L"0");
SendToTsfWorkerThreadClientViaNamedpipe(
hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::InputModeChanged,
GetConfiguredInputMode() == "japanese" ? L"1" : L"0");
SendToTsfWorkerThreadClientViaNamedpipe(
hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::CapsLockChanged,
GetServerCapsLockState() != 0 ? L"1" : L"0");
SendToTsfWorkerThreadClientViaNamedpipe(hello.client_id,
Global::DataFromServerMsgTypeToTsfWorkerThread::InputModeChanged,
GetConfiguredInputMode() == "japanese" ? L"1" : L"0");
SendToTsfWorkerThreadClientViaNamedpipe(hello.client_id,
Global::DataFromServerMsgTypeToTsfWorkerThread::CapsLockChanged,
GetServerCapsLockState() != 0 ? L"1" : L"0");
SendToTsfWorkerThreadClientViaNamedpipe(
hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::TsfDiagnosticLogChanged,
GetConfiguredTsfDiagnosticLogEnabled() ? L"1" : L"0");
@@ -2886,9 +2893,12 @@ void AuxPipeEventListenerLoopThread()
DWORD pipeMode = PIPE_READMODE_MESSAGE | PIPE_NOWAIT;
if (SetNamedPipeHandleState(listeningPipe, &pipeMode, nullptr, nullptr))
{
// Polling a NOWAIT server handle keeps shutdown bounded even
// if a client connects and never sends its auxiliary message.
while (pipe_running)
// Polling a NOWAIT server handle keeps shutdown bounded even if a client connects and never sends its
// auxiliary message. The deadline is what frees the single Aux instance in that case: without it the
// poll spins forever, the loop never returns to ConnectNamedPipe, and no later client
// (LangbarRightClick, TerminalDeactivation) is ever accepted.
const auto deadline = std::chrono::steady_clock::now() + kPipeHelloTimeout;
while (pipe_running && std::chrono::steady_clock::now() < deadline)
{
readResult = ReadFile(listeningPipe, buffer, sizeof(buffer), &bytesRead, nullptr);
if (readResult || GetLastError() != ERROR_NO_DATA)
@@ -3014,10 +3024,13 @@ void TsfDiagnosticPipeEventListenerLoopThread()
DWORD pipeMode = PIPE_READMODE_MESSAGE | PIPE_NOWAIT;
if (SetNamedPipeHandleState(listeningPipe, &pipeMode, nullptr, nullptr))
{
while (pipe_running)
// Same bound as the Aux pipe: a client that connects without ever writing must not hold the single
// diagnostic instance for the process lifetime.
const auto deadline = std::chrono::steady_clock::now() + kPipeHelloTimeout;
while (pipe_running && std::chrono::steady_clock::now() < deadline)
{
readResult = ReadFile(listeningPipe, frame.data(), static_cast<DWORD>(frame.size()),
&bytesRead, nullptr);
readResult =
ReadFile(listeningPipe, frame.data(), static_cast<DWORD>(frame.size()), &bytesRead, nullptr);
if (readResult || GetLastError() != ERROR_NO_DATA)
{
break;
@@ -3031,31 +3044,27 @@ void TsfDiagnosticPipeEventListenerLoopThread()
{
memcpy(&header, frame.data(), sizeof(header));
ULONG clientProcessId = 0;
const bool clientMatches =
GetNamedPipeClientProcessId(listeningPipe, &clientProcessId) &&
clientProcessId == header.source_process_id;
const bool frameValid =
clientMatches && header.magic == FANY_IME_TSF_DIAGNOSTIC_MAGIC &&
header.version == FANY_IME_TSF_DIAGNOSTIC_VERSION &&
header.header_size == sizeof(header) && header.record_count != 0 &&
header.payload_bytes != 0 && (header.payload_bytes % sizeof(wchar_t)) == 0 &&
sizeof(header) + header.payload_bytes == bytesRead;
const bool clientMatches = GetNamedPipeClientProcessId(listeningPipe, &clientProcessId) &&
clientProcessId == header.source_process_id;
const bool frameValid = clientMatches && header.magic == FANY_IME_TSF_DIAGNOSTIC_MAGIC &&
header.version == FANY_IME_TSF_DIAGNOSTIC_VERSION &&
header.header_size == sizeof(header) && header.record_count != 0 &&
header.payload_bytes != 0 && (header.payload_bytes % sizeof(wchar_t)) == 0 &&
sizeof(header) + header.payload_bytes == bytesRead;
if (frameValid && GetConfiguredTsfDiagnosticLogEnabled())
{
std::wstring payload(header.payload_bytes / sizeof(wchar_t), L'\0');
memcpy(payload.data(), frame.data() + sizeof(header), header.payload_bytes);
if (header.dropped_count != 0)
{
DiagnosticLog::Write(fmt::format(
L"[tsf-log] source_pid={} dropped_records={}", header.source_process_id,
header.dropped_count));
DiagnosticLog::Write(fmt::format(L"[tsf-log] source_pid={} dropped_records={}",
header.source_process_id, header.dropped_count));
}
size_t start = 0;
while (start < payload.size())
{
const size_t end = payload.find(L'\n', start);
const size_t length =
end == std::wstring::npos ? payload.size() - start : end - start;
const size_t length = end == std::wstring::npos ? payload.size() - start : end - start;
if (length != 0)
{
DiagnosticLog::Write(payload.substr(start, length));
@@ -3180,8 +3189,7 @@ void PrepareCandidateList(uint64_t client_id, uint64_t activation_epoch)
}
if (!g_english_input_mode && !IsSpecialModeCompositionActive(current_input) &&
GetConfiguredEmojiMixedInputEnabled() &&
(scheme == SchemeType::Quanpin || scheme == SchemeType::Shuangpin) &&
GetConfiguredEmojiMixedInputEnabled() && (scheme == SchemeType::Quanpin || scheme == SchemeType::Shuangpin) &&
!GlobalIme::composition.creating_word.active)
{
UpdateEmojiInput(current_input, client_id, activation_epoch);
@@ -3192,8 +3200,7 @@ void PrepareCandidateList(uint64_t client_id, uint64_t activation_epoch)
}
if (!g_english_input_mode && !IsSpecialModeCompositionActive(current_input) &&
GetConfiguredKaomojiMixedInputEnabled() &&
(scheme == SchemeType::Quanpin || scheme == SchemeType::Shuangpin) &&
GetConfiguredKaomojiMixedInputEnabled() && (scheme == SchemeType::Quanpin || scheme == SchemeType::Shuangpin) &&
!GlobalIme::composition.creating_word.active)
{
UpdateKaomojiInput(current_input, client_id, activation_epoch);
@@ -3204,11 +3211,14 @@ void PrepareCandidateList(uint64_t client_id, uint64_t activation_epoch)
}
}
void ApplyCloudCandidate(const std::string &candidate, const std::string &pinyin, uint64_t generation)
void ApplyCloudCandidate(const std::string &candidate, const std::string &pinyin, uint64_t generation,
const std::optional<metasequoia::OnlineQuery> &query)
{
if (!GetConfiguredCloudCandidatesEnabled())
return;
(void)generation;
// A callback can become stale after enqueueing, while earlier key tasks run.
if (FindCloudRequestOrigin(pinyin, generation).client_id == 0 || !g_inputSession)
return;
if (candidate.empty())
return;
@@ -3223,26 +3233,37 @@ void ApplyCloudCandidate(const std::string &candidate, const std::string &pinyin
if (Global::candidate_ui.items.empty())
return;
if (!query)
return;
auto &items = Global::candidate_ui.items;
// Same word already visible (dict / prior cloud): keep page and skip re-cache.
if (std::any_of(items.begin(), items.end(), [&](const WordItem &item) { return item.word == candidate; }))
{
Global::cloud_candidate = {true, candidate, cloud_query_state.committed_pinyin};
return;
}
// The engine checks the original session and composition before caching the result.
if (!g_inputSession->apply_online_candidate(*query, candidate, CandidateSource::CloudSuggestion))
return;
const auto &engine_candidates = g_inputSession->get_candidates();
const auto accepted = std::find_if(engine_candidates.begin(), engine_candidates.end(), [&](const WordItem &item) {
return item.word == candidate && item.source == CandidateSource::CloudSuggestion;
});
if (accepted == engine_candidates.end())
return;
// Replace any previous cloud suggestion with the new unique text.
items.erase(std::remove_if(items.begin(), items.end(),
[](const WordItem &item) { return item.source == CandidateSource::CloudSuggestion; }),
items.end());
size_t insert_index = items.size() >= 1 ? 1 : 0;
items.insert(items.begin() + insert_index, WordItem(pinyin, candidate, 1, CandidateSource::CloudSuggestion));
items.insert(items.begin() + insert_index, *accepted);
const bool preserve_single_kana_pair =
g_inputSession->current_scheme_type() == SchemeType::JapaneseRomaji &&
japanese::IsSingleKanaConversion(japanese::ConvertRomaji(g_inputSession->get_pinyin_sequence()));
FanyImeIpc::NormalizeMixedCandidateOrder(items, preserve_single_kana_pair ? 2 : 1);
g_inputSession->cache_dynamic_candidate(cloud_query_state.cache_key, candidate, CandidateSource::CloudSuggestion);
Global::cloud_candidate = {true, candidate, cloud_query_state.committed_pinyin};
Global::candidate_ui.item_total_count = static_cast<int>(items.size());
@@ -3252,8 +3273,11 @@ void ApplyCloudCandidate(const std::string &candidate, const std::string &pinyin
RefreshCandidatePageUi(true);
}
void ApplyAiCandidate(const std::string &candidate, const std::string &identity, uint64_t generation)
void ApplyAiCandidate(const std::string &candidate, const std::string &identity, uint64_t generation,
const std::optional<metasequoia::OnlineQuery> &engine_query)
{
if (!engine_query || FindAiRequestOrigin(identity, generation).client_id == 0)
return;
const bool enabled = GetConfiguredAiAssistant().enabled;
const bool has_session = static_cast<bool>(g_inputSession);
const bool non_pinyin = has_session && g_inputSession->current_scheme_type() != SchemeType::Quanpin &&
@@ -3273,21 +3297,25 @@ void ApplyAiCandidate(const std::string &candidate, const std::string &identity,
// reset page_index / re-cache. This stops cache-hit reapply from breaking paging.
if (std::any_of(items.begin(), items.end(), [&](const WordItem &item) { return item.word == candidate; }))
{
Global::ai_candidate = {true, candidate, query.committed_pinyin};
(void)0;
return;
}
if (!g_inputSession->apply_online_candidate(*engine_query, candidate, CandidateSource::AiSuggestion))
return;
const auto &engine_candidates = g_inputSession->get_candidates();
const auto accepted = std::find_if(engine_candidates.begin(), engine_candidates.end(), [&](const WordItem &item) {
return item.word == candidate && item.source == CandidateSource::AiSuggestion;
});
if (accepted == engine_candidates.end())
return;
// Only replace prior AI rows when inserting a genuinely new suggestion text.
items.erase(std::remove_if(items.begin(), items.end(),
[](const WordItem &item) { return item.source == CandidateSource::AiSuggestion; }),
items.end());
const size_t insert_index = std::min<size_t>(2, items.size());
const std::string typed_pinyin = query.cache_key.empty() ? query.committed_pinyin : query.cache_key;
items.insert(items.begin() + insert_index,
WordItem(typed_pinyin, candidate, 1, CandidateSource::AiSuggestion, identity));
items.insert(items.begin() + insert_index, *accepted);
FanyImeIpc::NormalizeMixedCandidateOrder(items);
g_inputSession->cache_dynamic_candidate(typed_pinyin, candidate, CandidateSource::AiSuggestion);
(void)0;
Global::ai_candidate = {true, candidate, query.committed_pinyin};
Global::candidate_ui.item_total_count = static_cast<int>(items.size());
@@ -3410,10 +3438,9 @@ void ApplyCandidateTranslations(std::vector<EnglishIme::TranslationResult> resul
g_candidate_translation_glosses[TranslationIdentity({result.key, result.direction})] = std::move(gloss);
else if (!merge)
{
const bool cloud_translatable =
result.direction == EnglishIme::TranslationDirection::EnglishToChinese
? CloudTranslation::IsCloudTranslatableEnglish(result.key)
: CloudTranslation::IsCloudTranslatableChinese(result.key);
const bool cloud_translatable = result.direction == EnglishIme::TranslationDirection::EnglishToChinese
? CloudTranslation::IsCloudTranslatableEnglish(result.key)
: CloudTranslation::IsCloudTranslatableChinese(result.key);
if (cloud_translatable)
misses.push_back({result.key, result.direction});
}
@@ -3427,7 +3454,8 @@ void ApplyCandidateTranslations(std::vector<EnglishIme::TranslationResult> resul
void ApplyEmojiCandidates(std::vector<WordItem> candidates, const std::string &input, uint64_t generation)
{
if (!GetConfiguredEmojiMixedInputEnabled() || !EmojiIme::IsCurrent(input, generation) || g_inputSession == nullptr ||
if (!GetConfiguredEmojiMixedInputEnabled() || !EmojiIme::IsCurrent(input, generation) ||
g_inputSession == nullptr ||
(g_inputSession->current_scheme_type() != SchemeType::Quanpin &&
g_inputSession->current_scheme_type() != SchemeType::Shuangpin) ||
g_inputSession->get_pinyin_sequence_with_cases() != input || GlobalIme::composition.creating_word.active)
@@ -3549,9 +3577,8 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques
const std::string input_before_key =
g_inputSession ? g_inputSession->get_pinyin_sequence_with_cases() : std::string{};
const bool shift_only = (Global::ModifiersDown & 0b00000111u) == 0b00000001u;
const bool chinese_scheme = g_inputSession &&
(g_inputSession->current_scheme_type() == SchemeType::Quanpin ||
g_inputSession->current_scheme_type() == SchemeType::Shuangpin);
const bool chinese_scheme = g_inputSession && (g_inputSession->current_scheme_type() == SchemeType::Quanpin ||
g_inputSession->current_scheme_type() == SchemeType::Shuangpin);
if (Global::Keycode == VK_RETURN && !input_before_key.empty())
{
std::string english_word;
@@ -3582,9 +3609,9 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques
if (chinese_scheme && !g_english_input_mode && GetConfiguredYModeEnabled() && input_before_key.empty() &&
Global::Keycode == 'Y' && Global::Wch == L'Y' && shift_only)
g_y_mode_triggered = true;
const bool r_mode_trigger_key =
chinese_scheme && !g_english_input_mode && GetConfiguredRModeEnabled() && input_before_key.empty() &&
Global::Keycode == 'R' && Global::Wch == L'R' && shift_only;
const bool r_mode_trigger_key = chinese_scheme && !g_english_input_mode && GetConfiguredRModeEnabled() &&
input_before_key.empty() && Global::Keycode == 'R' && Global::Wch == L'R' &&
shift_only;
if (r_mode_trigger_key)
{
g_r_mode_original_session = g_inputSession;
@@ -3618,9 +3645,8 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques
const bool is_unicode_plus = unicode_composition_active && Global::Keycode == VK_OEM_PLUS && Global::Wch == L'+';
const bool is_composition_edit_key =
Global::Keycode == VK_LEFT || Global::Keycode == VK_RIGHT || Global::Keycode == VK_BACK ||
Global::Keycode == VK_DELETE ||
(Global::Keycode >= 'A' && Global::Keycode <= 'Z') || is_manual_pinyin_separator ||
is_microsoft_shuangpin_ing_key || is_unicode_hex_digit || is_unicode_plus;
Global::Keycode == VK_DELETE || (Global::Keycode >= 'A' && Global::Keycode <= 'Z') ||
is_manual_pinyin_separator || is_microsoft_shuangpin_ing_key || is_unicode_hex_digit || is_unicode_plus;
const bool should_forward_key_to_session = !is_commit_with_highlighted_candidate_punctuation && !is_selection_key &&
!is_paging_key && !is_composition_edit_key;
@@ -3637,9 +3663,9 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques
auto &ui = Global::candidate_ui;
ui.selected_text = FanyImeIpc::HighlightedCandidateText(ui.page_words, ui.selected_index_in_page);
const bool is_word_to_character_key =
(Global::Wch == L'[' || Global::Wch == L']') && GetConfiguredWordToCharacterEnabled() &&
!GetConfiguredPagingBracketsEnabled();
const bool is_word_to_character_key = (Global::Wch == L'[' || Global::Wch == L']') &&
GetConfiguredWordToCharacterEnabled() &&
!GetConfiguredPagingBracketsEnabled();
WordItem highlighted_item;
if (is_word_to_character_key && ResolveCandidateItem(ui.selected_index_in_page + 1, highlighted_item))
{
@@ -3664,8 +3690,7 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques
/* 先处理一下通用的按键,包括所有可能的按键,如普通的拼音字符按键、空格、Tab
* 等等,然后再在下面处理其中的特殊的按键 */
const bool r_mode_prefix_backspace =
g_r_mode_triggered && Global::Keycode == VK_BACK && input_before_key.empty();
const bool r_mode_prefix_backspace = g_r_mode_triggered && Global::Keycode == VK_BACK && input_before_key.empty();
if (r_mode_prefix_backspace)
{
ClearState();
@@ -3751,13 +3776,12 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques
UpdateCloudInput(cloud_query_state.query_text, client_id, activation_epoch);
}
const bool ai_eligible =
!g_english_input_mode &&
!IsSpecialModeCompositionActive(g_inputSession->get_pinyin_sequence_with_cases()) &&
(g_inputSession->current_scheme_type() == SchemeType::Quanpin ||
g_inputSession->current_scheme_type() == SchemeType::Shuangpin) &&
g_inputSession->is_all_complete_pure_pinyin() &&
!g_inputSession->has_active_helpcode() && !GlobalIme::composition.creating_word.active;
const bool ai_eligible = !g_english_input_mode &&
!IsSpecialModeCompositionActive(g_inputSession->get_pinyin_sequence_with_cases()) &&
(g_inputSession->current_scheme_type() == SchemeType::Quanpin ||
g_inputSession->current_scheme_type() == SchemeType::Shuangpin) &&
g_inputSession->is_all_complete_pure_pinyin() && !g_inputSession->has_active_helpcode() &&
!GlobalIme::composition.creating_word.active;
if (!suppress_async_lookup)
{
UpdateAiInput(ai_eligible ? g_inputSession->get_pinyin_segmentation() : std::string{}, client_id,
@@ -3767,9 +3791,9 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques
//
// 普通的拼音字符,发送 preedit 到 TSF 端
//
if (FanyImeIpc::ShouldSendCompositionReply(
Global::Keycode >= 'A' && Global::Keycode <= 'Z', is_manual_pinyin_separator,
is_microsoft_shuangpin_ing_key, is_unicode_hex_digit, is_unicode_plus))
if (FanyImeIpc::ShouldSendCompositionReply(Global::Keycode >= 'A' && Global::Keycode <= 'Z',
is_manual_pinyin_separator, is_microsoft_shuangpin_ing_key,
is_unicode_hex_digit, is_unicode_plus))
{
if (IsUiLessMode())
{
@@ -4022,6 +4046,7 @@ void ClearState()
// Drop published candidates before any in-flight FineTuneWindow callback
// can re-inflate an empty-preedit + stale-candidate view.
Global::CandidateString.clear();
Global::ClearCandidatePageSnapshot();
Global::candidate_ui.set_items({});
// Hide synchronously from the caller's perspective: clear the shown flag
// first so async FineTuneWindow callbacks refuse to resurrect the window,
@@ -4099,8 +4124,7 @@ void ProcessSelectionKey(UINT keycode, uint64_t client_id, uint64_t activation_e
std::string curWordPinyin = curWordItem.pinyin;
if (curWordItem.source == CandidateSource::EnglishDictionary ||
curWordItem.source == CandidateSource::QuickPhrase || curWordItem.source == CandidateSource::Emoji ||
curWordItem.source == CandidateSource::Kaomoji ||
curWordItem.source == CandidateSource::Generated)
curWordItem.source == CandidateSource::Kaomoji || curWordItem.source == CandidateSource::Generated)
{
if (curWordItem.source == CandidateSource::EnglishDictionary && isNeedUpdateWeight)
{
+1 -2
View File
@@ -4,8 +4,7 @@
namespace FanyImeIpc
{
constexpr bool CanSendFocusSessionReady(uint64_t client_id, uint64_t activation_epoch,
uint64_t focus_token)
constexpr bool CanSendFocusSessionReady(uint64_t client_id, uint64_t activation_epoch, uint64_t focus_token)
{
return client_id != 0 && activation_epoch != 0 && focus_token != 0;
}
+4 -6
View File
@@ -19,8 +19,7 @@ inline constexpr uint32_t kKeyModifierMask = kModifierShift | kModifierControl |
constexpr bool IsEnglishModeToggleKey(uint32_t keycode, uint32_t modifiers_down)
{
return keycode == static_cast<uint32_t>('E') &&
(modifiers_down & kKeyModifierMask) == kEnglishModeToggleModifiers;
return keycode == static_cast<uint32_t>('E') && (modifiers_down & kKeyModifierMask) == kEnglishModeToggleModifiers;
}
// The TSF side treats numpad digits exactly like the corresponding candidate
@@ -37,8 +36,7 @@ constexpr uint32_t NormalizeNumpadDigitKey(uint32_t keycode)
// Server must reset every backend without producing a reply.
constexpr bool IsBackendIndependentCompositionResetKey(uint32_t keycode)
{
return keycode == kVirtualKeyShift || keycode == kVirtualKeyEscape ||
keycode == kVirtualKeyLeftShift ||
return keycode == kVirtualKeyShift || keycode == kVirtualKeyEscape || keycode == kVirtualKeyLeftShift ||
keycode == kVirtualKeyRightShift;
}
@@ -67,7 +65,7 @@ constexpr bool ShouldSendCompositionReply(bool is_alpha_key, bool is_manual_piny
bool is_microsoft_shuangpin_ing_key, bool is_unicode_hex_digit,
bool is_unicode_plus)
{
return is_alpha_key || is_manual_pinyin_separator || is_microsoft_shuangpin_ing_key ||
is_unicode_hex_digit || is_unicode_plus;
return is_alpha_key || is_manual_pinyin_separator || is_microsoft_shuangpin_ing_key || is_unicode_hex_digit ||
is_unicode_plus;
}
} // namespace FanyImeIpc
+123 -150
View File
@@ -29,56 +29,6 @@
#define FANY_IPC_LOG_RAW(message) ((void)0)
#define FANY_IPC_LOGF(...) ((void)0)
#define LOW_INTEGRITY_SDDL_SACL \
SDDL_SACL \
SDDL_DELIMINATOR \
SDDL_ACE_BEGIN \
SDDL_MANDATORY_LABEL \
SDDL_SEPERATOR \
SDDL_SEPERATOR \
SDDL_NO_WRITE_UP \
SDDL_SEPERATOR \
SDDL_SEPERATOR \
SDDL_SEPERATOR \
SDDL_ML_LOW \
SDDL_ACE_END
#define LOCAL_SYSTEM_FILE_ACCESS \
SDDL_ACE_BEGIN \
SDDL_ACCESS_ALLOWED \
SDDL_SEPERATOR \
SDDL_SEPERATOR \
SDDL_FILE_ALL \
SDDL_SEPERATOR \
SDDL_SEPERATOR \
SDDL_SEPERATOR \
SDDL_LOCAL_SYSTEM \
SDDL_ACE_END
#define EVERYONE_FILE_ACCESS \
SDDL_ACE_BEGIN \
SDDL_ACCESS_ALLOWED \
SDDL_SEPERATOR \
SDDL_SEPERATOR \
SDDL_FILE_ALL \
SDDL_SEPERATOR \
SDDL_SEPERATOR \
SDDL_SEPERATOR \
SDDL_EVERYONE \
SDDL_ACE_END
#define ALL_APP_PACKAGES_FILE_ACCESS \
SDDL_ACE_BEGIN \
SDDL_ACCESS_ALLOWED \
SDDL_SEPERATOR \
SDDL_SEPERATOR \
SDDL_FILE_ALL \
SDDL_SEPERATOR \
SDDL_SEPERATOR \
SDDL_SEPERATOR \
SDDL_ALL_APP_PACKAGES \
SDDL_ACE_END
static HANDLE hMapFile = nullptr;
static void *pBuf;
static FanyImeSharedMemoryData *sharedData;
@@ -262,21 +212,65 @@ void LogPipeWriteTarget(const wchar_t *pipe_name, uint64_t client_id, UINT msg_t
msg_type, payload);
}
// The account the server process runs as. It owns every pipe name the server publishes, and besides LocalSystem it is
// the only account the pipe DACL admits.
std::wstring GetProcessUserSidString()
{
HANDLE token = nullptr;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token))
{
return std::wstring();
}
DWORD needed = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &needed);
std::wstring sid;
std::vector<BYTE> token_user(needed);
if (needed != 0 && GetTokenInformation(token, TokenUser, token_user.data(), needed, &needed))
{
LPWSTR sid_text = nullptr;
if (ConvertSidToStringSidW(reinterpret_cast<TOKEN_USER *>(token_user.data())->User.Sid, &sid_text))
{
sid.assign(sid_text);
LocalFree(sid_text);
}
}
CloseHandle(token);
return sid;
}
HANDLE CreateNamedPipeInstance(const wchar_t *pipe_name, DWORD out_buffer_size, DWORD in_buffer_size)
{
// Names this process has already published. Only the very first instance of a name may carry
// FILE_FLAG_FIRST_PIPE_INSTANCE, which is what turns a name some other process got to first into a loud
// CreateNamedPipe failure instead of a silent second instance answering half of the client connections. A failed
// creation is deliberately not recorded, so a squatted name keeps failing instead of eventually joining.
static std::mutex owned_names_mutex;
static std::vector<std::wstring> owned_names;
const std::wstring sddl = FanyImeIpc::BuildPipeSecurityDescriptorSddl(GetProcessUserSidString());
if (sddl.empty())
{
SetLastError(ERROR_ACCESS_DENIED);
return INVALID_HANDLE_VALUE;
}
PSECURITY_DESCRIPTOR pd = nullptr;
SECURITY_ATTRIBUTES sa = {};
ConvertStringSecurityDescriptorToSecurityDescriptor(
LOW_INTEGRITY_SDDL_SACL SDDL_DACL SDDL_DELIMINATOR LOCAL_SYSTEM_FILE_ACCESS EVERYONE_FILE_ACCESS
ALL_APP_PACKAGES_FILE_ACCESS,
SDDL_REVISION_1, &pd, NULL);
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(), SDDL_REVISION_1, &pd, NULL))
{
return INVALID_HANDLE_VALUE;
}
sa.nLength = sizeof(SECURITY_ATTRIBUTES);
sa.lpSecurityDescriptor = pd;
sa.bInheritHandle = FALSE;
HANDLE pipe = CreateNamedPipe( //
pipe_name, //
PIPE_ACCESS_DUPLEX, //
const std::lock_guard<std::mutex> owned_names_guard(owned_names_mutex);
const bool owns_name = std::find(owned_names.begin(), owned_names.end(), pipe_name) != owned_names.end();
HANDLE pipe = CreateNamedPipe( //
pipe_name, //
PIPE_ACCESS_DUPLEX | (owns_name ? 0u : FILE_FLAG_FIRST_PIPE_INSTANCE), //
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES, //
out_buffer_size, //
@@ -285,6 +279,11 @@ HANDLE CreateNamedPipeInstance(const wchar_t *pipe_name, DWORD out_buffer_size,
&sa //
);
if (pipe != INVALID_HANDLE_VALUE && !owns_name)
{
owned_names.emplace_back(pipe_name);
}
if (pd)
{
LocalFree(pd);
@@ -319,8 +318,7 @@ bool ConfigureReversePipeForBoundedWrites(HANDLE pipe)
return SetNamedPipeHandleState(pipe, &mode, nullptr, nullptr) != FALSE;
}
template <size_t N>
bool CopyPipeText(wchar_t (&destination)[N], const std::wstring &source)
template <size_t N> bool CopyPipeText(wchar_t (&destination)[N], const std::wstring &source)
{
if (source.size() >= N)
{
@@ -394,12 +392,12 @@ int InitIpc()
//
for (size_t i = 0; i < FANY_IME_EVENT_ARRAY.size(); ++i)
{
hEvents[i] = CreateEventW( //
nullptr, //
FALSE, //
FALSE, // Auto reset
hEvents[i] = CreateEventW( //
nullptr, //
FALSE, //
FALSE, // Auto reset
FANY_IME_EVENT_ARRAY[i].c_str() //
); //
); //
if (!hEvents[i])
{
// Error handling
@@ -438,10 +436,10 @@ HANDLE CreateToTsfNamedPipeInstance()
HANDLE CreateToTsfWorkerThreadNamedPipeInstance()
{
return CreateNamedPipeInstance(FANY_IME_TO_TSF_WORKER_THREAD_NAMED_PIPE,
static_cast<DWORD>(sizeof(FanyImeNamedpipeDataToTsfWorkerThread) *
FANY_IME_TO_TSF_WORKER_PIPE_FRAME_CAPACITY),
static_cast<DWORD>(sizeof(FanyImePipeHello)));
return CreateNamedPipeInstance(
FANY_IME_TO_TSF_WORKER_THREAD_NAMED_PIPE,
static_cast<DWORD>(sizeof(FanyImeNamedpipeDataToTsfWorkerThread) * FANY_IME_TO_TSF_WORKER_PIPE_FRAME_CAPACITY),
static_cast<DWORD>(sizeof(FanyImePipeHello)));
}
//
@@ -534,12 +532,12 @@ int InitNamedPipe()
//
for (size_t i = 0; i < FANY_IME_EVENT_PIPE_ARRAY.size(); ++i)
{
hPipeEvents[i] = CreateEventW( //
nullptr, //
FALSE, //
FALSE, // Auto reset
FANY_IME_EVENT_PIPE_ARRAY[i].c_str() //
); //
hPipeEvents[i] = CreateEventW( //
nullptr, //
FALSE, //
FALSE, // Auto reset
FANY_IME_EVENT_PIPE_ARRAY[i].c_str() //
); //
if (!hPipeEvents[i])
{
// Error handling
@@ -551,12 +549,12 @@ int InitNamedPipe()
for (size_t i = 0; i < FANY_IME_EVENT_PIPE_TO_TSF_WORKER_THREAD_ARRAY.size(); ++i)
{
hWorkerPipeEvents[i] = CreateEventW( //
nullptr, //
FALSE, //
FALSE, // Auto reset
FANY_IME_EVENT_PIPE_TO_TSF_WORKER_THREAD_ARRAY[i].c_str() //
); //
hWorkerPipeEvents[i] = CreateEventW( //
nullptr, //
FALSE, //
FALSE, // Auto reset
FANY_IME_EVENT_PIPE_TO_TSF_WORKER_THREAD_ARRAY[i].c_str() //
); //
if (!hWorkerPipeEvents[i])
{
// Error handling
@@ -673,8 +671,7 @@ uint64_t BeginPipeClientHandler(HANDLE pipe)
}
HANDLE controlPipe = INVALID_HANDLE_VALUE;
if (!DuplicateHandle(GetCurrentProcess(), pipe, GetCurrentProcess(), &controlPipe, 0, FALSE,
DUPLICATE_SAME_ACCESS))
if (!DuplicateHandle(GetCurrentProcess(), pipe, GetCurrentProcess(), &controlPipe, 0, FALSE, DUPLICATE_SAME_ACCESS))
{
return 0;
}
@@ -806,8 +803,8 @@ uint64_t RegisterToTsfPipeClient(uint64_t client_id, HANDLE pipe)
}
if (!ConfigureReversePipeForBoundedWrites(pipe))
{
FANY_IPC_LOGF(L"[msime]: [ipc] failed to configure to-tsf PIPE_NOWAIT: client_id={}, gle={}",
client_id, GetLastError());
FANY_IPC_LOGF(L"[msime]: [ipc] failed to configure to-tsf PIPE_NOWAIT: client_id={}, gle={}", client_id,
GetLastError());
return 0;
}
@@ -845,8 +842,7 @@ uint64_t RegisterToTsfPipeClient(uint64_t client_id, HANDLE pipe)
session.to_tsf_pipe = std::move(replacedEndpoint);
session.to_tsf_registration_id = replacedRegistrationId;
session.to_tsf_ready = replacedReady;
if (session.main_pipe == INVALID_HANDLE_VALUE && !session.to_tsf_pipe &&
!session.to_tsf_worker_thread_pipe)
if (session.main_pipe == INVALID_HANDLE_VALUE && !session.to_tsf_pipe && !session.to_tsf_worker_thread_pipe)
{
g_pipe_clients.erase(client_id);
}
@@ -878,8 +874,8 @@ uint64_t RegisterToTsfWorkerThreadPipeClient(uint64_t client_id, HANDLE pipe)
}
if (!ConfigureReversePipeForBoundedWrites(pipe))
{
FANY_IPC_LOGF(L"[msime]: [ipc] failed to configure to-tsf-worker PIPE_NOWAIT: client_id={}, gle={}",
client_id, GetLastError());
FANY_IPC_LOGF(L"[msime]: [ipc] failed to configure to-tsf-worker PIPE_NOWAIT: client_id={}, gle={}", client_id,
GetLastError());
return 0;
}
@@ -916,8 +912,7 @@ uint64_t RegisterToTsfWorkerThreadPipeClient(uint64_t client_id, HANDLE pipe)
session.to_tsf_worker_thread_pipe = std::move(replaced_endpoint);
session.to_tsf_worker_thread_registration_id = replaced_registration_id;
session.to_tsf_worker_thread_ready = replaced_ready;
if (session.main_pipe == INVALID_HANDLE_VALUE && !session.to_tsf_pipe &&
!session.to_tsf_worker_thread_pipe)
if (session.main_pipe == INVALID_HANDLE_VALUE && !session.to_tsf_pipe && !session.to_tsf_worker_thread_pipe)
{
g_pipe_clients.erase(client_id);
}
@@ -925,8 +920,7 @@ uint64_t RegisterToTsfWorkerThreadPipeClient(uint64_t client_id, HANDLE pipe)
}
if (failed_endpoint)
{
LogPipeWriteFailure(L"to-tsf-worker-ready",
Global::DataFromServerMsgTypeToTsfWorkerThread::PipeReady,
LogPipeWriteFailure(L"to-tsf-worker-ready", Global::DataFromServerMsgTypeToTsfWorkerThread::PipeReady,
write_error, bytes_written, sizeof(FanyImeNamedpipeDataToTsfWorkerThread));
// Preserve the registration_id == 0 close contract for the handler.
(void)failed_endpoint->ReleaseHandle();
@@ -975,8 +969,7 @@ PipeClientUnregisterResult UnregisterPipeClientHandle(uint64_t client_id, UINT p
it->second.focus_token = 0;
}
else if (pipe_role == FanyImePipeRole::ToTsf && it->second.to_tsf_pipe &&
it->second.to_tsf_pipe->handle() == pipe &&
it->second.to_tsf_registration_id == registration_id)
it->second.to_tsf_pipe->handle() == pipe && it->second.to_tsf_registration_id == registration_id)
{
result.removed = true;
endpoints_to_shutdown.push_back(std::move(it->second.to_tsf_pipe));
@@ -1049,9 +1042,8 @@ bool IsPipeClientRegistrationCurrent(uint64_t client_id, UINT pipe_role, uint64_
return false;
}
PipeClientActivation ActivatePipeClient(uint64_t client_id, uint64_t main_registration_id,
bool wait_for_reverse_pipe, uint64_t focus_token,
bool update_focus_token)
PipeClientActivation ActivatePipeClient(uint64_t client_id, uint64_t main_registration_id, bool wait_for_reverse_pipe,
uint64_t focus_token, bool update_focus_token)
{
std::unique_lock lock(g_pipe_clients_mutex);
const auto routeReady = [&] {
@@ -1060,10 +1052,8 @@ PipeClientActivation ActivatePipeClient(uint64_t client_id, uint64_t main_regist
it->second.main_pipe != INVALID_HANDLE_VALUE &&
it->second.main_registration_id == main_registration_id && it->second.to_tsf_pipe &&
it->second.to_tsf_registration_id != 0 && it->second.to_tsf_ready &&
it->second.to_tsf_worker_thread_pipe &&
it->second.to_tsf_worker_thread_registration_id != 0 &&
it->second.to_tsf_worker_thread_ready &&
!it->second.outbound_state.is_dirty();
it->second.to_tsf_worker_thread_pipe && it->second.to_tsf_worker_thread_registration_id != 0 &&
it->second.to_tsf_worker_thread_ready && !it->second.outbound_state.is_dirty();
};
if (wait_for_reverse_pipe && !routeReady())
@@ -1076,8 +1066,7 @@ PipeClientActivation ActivatePipeClient(uint64_t client_id, uint64_t main_regist
}
auto &session = g_pipe_clients.find(client_id)->second;
if ((update_focus_token && focus_token == 0) ||
(!update_focus_token && session.focus_token == 0))
if ((update_focus_token && focus_token == 0) || (!update_focus_token && session.focus_token == 0))
{
// Explicit activation must introduce a nonzero focus token, and an
// implicit key fallback may only reuse a token from an already fenced
@@ -1085,8 +1074,7 @@ PipeClientActivation ActivatePipeClient(uint64_t client_id, uint64_t main_regist
// on the worker pipe.
return {};
}
const FanyImeIpc::ActiveClientTransition previous =
g_active_client_state.snapshot();
const FanyImeIpc::ActiveClientTransition previous = g_active_client_state.snapshot();
// Do not clear the displaced client's already-acknowledged focus token.
// TextInputHost.exe temporarily becomes the active TSF client for Win+.,
// but Windows does not reliably send the original application another
@@ -1095,16 +1083,14 @@ PipeClientActivation ActivatePipeClient(uint64_t client_id, uint64_t main_regist
// must be allowed to reuse that previously fenced token for implicit
// activation. Explicit suspension/deactivation and pipe invalidation still
// clear the owning session's token in their respective paths.
const bool new_focus_session =
update_focus_token && session.focus_token != focus_token;
const bool new_focus_session = update_focus_token && session.focus_token != focus_token;
if (update_focus_token)
{
session.focus_token = focus_token;
}
const FanyImeIpc::ActiveClientTransition transition =
new_focus_session && previous.client_id == client_id
? g_active_client_state.renew(client_id)
: g_active_client_state.activate(client_id);
const FanyImeIpc::ActiveClientTransition transition = new_focus_session && previous.client_id == client_id
? g_active_client_state.renew(client_id)
: g_active_client_state.activate(client_id);
if (transition.client_id != 0)
{
session.inactive_focus_token = 0;
@@ -1130,8 +1116,7 @@ uint64_t DeactivatePipeClient(uint64_t client_id, uint64_t main_registration_id)
return epoch;
}
uint64_t DeactivatePipeClientByFocusToken(uint64_t client_id,
uint64_t focus_token)
uint64_t DeactivatePipeClientByFocusToken(uint64_t client_id, uint64_t focus_token)
{
std::lock_guard lock(g_pipe_clients_mutex);
const auto it = g_pipe_clients.find(client_id);
@@ -1140,13 +1125,11 @@ uint64_t DeactivatePipeClientByFocusToken(uint64_t client_id,
return 0;
}
const FanyImeIpc::ActiveClientTransition active =
g_active_client_state.snapshot();
const uint64_t inactive_epoch =
g_active_client_state.terminal_deactivation_epoch(client_id);
if (!FanyImeIpc::CanApplyTerminalDeactivationFallback(
client_id, focus_token, active.client_id, it->second.focus_token,
inactive_epoch != 0, it->second.inactive_focus_token))
const FanyImeIpc::ActiveClientTransition active = g_active_client_state.snapshot();
const uint64_t inactive_epoch = g_active_client_state.terminal_deactivation_epoch(client_id);
if (!FanyImeIpc::CanApplyTerminalDeactivationFallback(client_id, focus_token, active.client_id,
it->second.focus_token, inactive_epoch != 0,
it->second.inactive_focus_token))
{
return 0;
}
@@ -1164,12 +1147,10 @@ uint64_t DeactivatePipeClientByFocusToken(uint64_t client_id,
return inactive_epoch;
}
uint64_t ResolvePipeClientTerminalDeactivationEpoch(uint64_t client_id,
uint64_t transition_epoch)
uint64_t ResolvePipeClientTerminalDeactivationEpoch(uint64_t client_id, uint64_t transition_epoch)
{
std::lock_guard lock(g_pipe_clients_mutex);
return g_active_client_state.terminal_deactivation_epoch(client_id,
transition_epoch);
return g_active_client_state.terminal_deactivation_epoch(client_id, transition_epoch);
}
PipeClientActivation GetActivePipeClient()
@@ -1253,12 +1234,11 @@ void ShutdownPipeClients()
}
std::unique_lock handlerLock(g_pipe_handlers_mutex);
const bool handlersStopped = g_pipe_handlers_cv.wait_for(
handlerLock, std::chrono::seconds(3), [] { return g_active_pipe_handler_count == 0; });
const bool handlersStopped = g_pipe_handlers_cv.wait_for(handlerLock, std::chrono::seconds(3),
[] { return g_active_pipe_handler_count == 0; });
if (!handlersStopped)
{
FANY_IPC_LOGF(L"[msime]: [ipc] timed out waiting for {} pipe client handlers",
g_active_pipe_handler_count);
FANY_IPC_LOGF(L"[msime]: [ipc] timed out waiting for {} pipe client handlers", g_active_pipe_handler_count);
}
}
@@ -1362,16 +1342,16 @@ int ReadDataFromNamedPipe(UINT read_flag)
if (read_flag >> 5 & 1u)
{
const int length = std::clamp(namedpipeData.pinyin_length, 0,
static_cast<int>(std::size(namedpipeData.pinyin_string)) - 1);
const int length =
std::clamp(namedpipeData.pinyin_length, 0, static_cast<int>(std::size(namedpipeData.pinyin_string)) - 1);
Global::PinyinString.assign(namedpipeData.pinyin_string, static_cast<size_t>(length));
}
return 0;
}
bool SendToTsfClientViaNamedpipe(uint64_t client_id, uint64_t activation_epoch, UINT msg_type,
uint64_t request_id, const std::wstring &pipeData)
bool SendToTsfClientViaNamedpipe(uint64_t client_id, uint64_t activation_epoch, UINT msg_type, uint64_t request_id,
const std::wstring &pipeData)
{
FanyImeNamedpipeDataToTsf packet = {};
packet.msg_type = msg_type;
@@ -1466,12 +1446,9 @@ bool SendWorkerPackets(uint64_t client_id, uint64_t activation_epoch, bool requi
{
std::lock_guard lock(g_pipe_clients_mutex);
auto it = g_pipe_clients.find(client_id);
const bool route_is_current =
!require_active || g_active_client_state.matches(client_id, activation_epoch);
if (client_id != 0 && route_is_current && it != g_pipe_clients.end() &&
it->second.to_tsf_worker_thread_pipe &&
it->second.to_tsf_worker_thread_registration_id != 0 &&
it->second.to_tsf_worker_thread_ready)
const bool route_is_current = !require_active || g_active_client_state.matches(client_id, activation_epoch);
if (client_id != 0 && route_is_current && it != g_pipe_clients.end() && it->second.to_tsf_worker_thread_pipe &&
it->second.to_tsf_worker_thread_registration_id != 0 && it->second.to_tsf_worker_thread_ready)
{
endpoint = it->second.to_tsf_worker_thread_pipe;
write_succeeded = true;
@@ -1529,8 +1506,8 @@ bool SendWorkerPacket(uint64_t client_id, uint64_t activation_epoch, bool requir
packet.msg_type = msg_type;
if (!CopyPipeText(packet.data, pipe_data))
{
FANY_IPC_LOGF(L"[msime]: [ipc] to-tsf-worker payload too long: client_id={}, msg_type={}, chars={}",
client_id, msg_type, pipe_data.size());
FANY_IPC_LOGF(L"[msime]: [ipc] to-tsf-worker payload too long: client_id={}, msg_type={}, chars={}", client_id,
msg_type, pipe_data.size());
return false;
}
LogPipeWriteTarget(L"to-tsf-worker", client_id, msg_type, pipe_data);
@@ -1544,12 +1521,9 @@ bool SendWorkerPacket(uint64_t client_id, uint64_t activation_epoch, bool requir
{
std::lock_guard lock(g_pipe_clients_mutex);
auto it = g_pipe_clients.find(client_id);
const bool route_is_current =
!require_active || g_active_client_state.matches(client_id, activation_epoch);
if (client_id != 0 && route_is_current && it != g_pipe_clients.end() &&
it->second.to_tsf_worker_thread_pipe &&
it->second.to_tsf_worker_thread_registration_id != 0 &&
it->second.to_tsf_worker_thread_ready)
const bool route_is_current = !require_active || g_active_client_state.matches(client_id, activation_epoch);
if (client_id != 0 && route_is_current && it != g_pipe_clients.end() && it->second.to_tsf_worker_thread_pipe &&
it->second.to_tsf_worker_thread_registration_id != 0 && it->second.to_tsf_worker_thread_ready)
{
endpoint = it->second.to_tsf_worker_thread_pipe;
write_succeeded = endpoint->Write(&packet, sizeof(packet), bytes_written, write_error);
@@ -1600,14 +1574,13 @@ bool SendWorkerPacket(uint64_t client_id, uint64_t activation_epoch, bool requir
}
} // namespace
bool SendToTsfWorkerThreadClientViaNamedpipe(uint64_t client_id, UINT msg_type,
const std::wstring &pipeData)
bool SendToTsfWorkerThreadClientViaNamedpipe(uint64_t client_id, UINT msg_type, const std::wstring &pipeData)
{
return SendWorkerPacket(client_id, 0, false, msg_type, pipeData);
}
bool SendToTsfWorkerThreadClientViaNamedpipe(uint64_t client_id, uint64_t activation_epoch,
UINT msg_type, const std::wstring &pipeData)
bool SendToTsfWorkerThreadClientViaNamedpipe(uint64_t client_id, uint64_t activation_epoch, UINT msg_type,
const std::wstring &pipeData)
{
return SendWorkerPacket(client_id, activation_epoch, true, msg_type, pipeData);
}
+27
View File
@@ -11,6 +11,33 @@
#include "MetasequoiaImeEngine/contracts/windows_ipc.h"
namespace FanyImeIpc
{
// A pipe client only connects and exchanges frames. FILE_CREATE_PIPE_INSTANCE shares its bit with FILE_APPEND_DATA,
// so plain FILE_GENERIC_WRITE would also let the holder add an instance to a pipe name this server already owns and
// answer connections in the server's place.
inline constexpr DWORD kPipeClientAccessMask =
(FILE_GENERIC_READ | FILE_GENERIC_WRITE) & ~static_cast<DWORD>(FILE_CREATE_PIPE_INSTANCE);
static_assert(kPipeClientAccessMask == 0x0012019Bu,
"The connect-only pipe mask must stay equal to the hexadecimal form spelled in the SDDL below");
// The pipe namespace is machine-global and shared across sessions, so a DACL that grants Everyone lets any local
// account both connect to the server and add instances to its pipe names. The DACL is therefore limited to
// LocalSystem, the account this server runs as, and app containers, the last of which get connect-only rights so UWP
// text input still reaches the server. The low mandatory label keeps low integrity and app container hosts able to
// write to the pipe. Returns an empty string when the owner SID is unknown; the caller must fail pipe creation instead
// of falling back to a permissive descriptor.
inline std::wstring BuildPipeSecurityDescriptorSddl(const std::wstring &owner_sid)
{
if (owner_sid.empty())
{
return std::wstring();
}
return L"S:(ML;;NW;;;LW)D:(A;;FA;;;SY)(A;;FA;;;" + owner_sid + L")(A;;0x12019b;;;AC)";
}
} // namespace FanyImeIpc
inline constexpr DWORD FANY_IME_TO_TSF_PIPE_FRAME_CAPACITY = 64;
inline constexpr DWORD FANY_IME_TO_TSF_WORKER_PIPE_FRAME_CAPACITY = 32;
inline HANDLE hPipe = INVALID_HANDLE_VALUE;
+1 -2
View File
@@ -7,8 +7,7 @@ namespace FanyImeIpc
// Every IPC frame is one message. A successful Win32 call which reports zero
// or a partial byte count (notably PIPE_NOWAIT with a full message-pipe buffer)
// did not publish that frame and must be handled as a transport failure.
constexpr bool IsCompleteMessageFrameWrite(bool write_succeeded, uint32_t bytes_written,
uint32_t expected_bytes)
constexpr bool IsCompleteMessageFrameWrite(bool write_succeeded, uint32_t bytes_written, uint32_t expected_bytes)
{
return write_succeeded && expected_bytes != 0 && bytes_written == expected_bytes;
}
@@ -7,10 +7,9 @@ namespace FanyImeIpc
// The session-less Aux fallback may only terminate the exact focus session
// whose Main-pipe ClientDeactivated write failed. This prevents a delayed
// fallback from hiding the toolbar after the same PID/TID has reactivated.
constexpr bool CanApplyTerminalDeactivationFallback(
uint64_t client_id, uint64_t focus_token, uint64_t active_client_id,
uint64_t active_focus_token, bool is_inactive_owner,
uint64_t inactive_focus_token)
constexpr bool CanApplyTerminalDeactivationFallback(uint64_t client_id, uint64_t focus_token, uint64_t active_client_id,
uint64_t active_focus_token, bool is_inactive_owner,
uint64_t inactive_focus_token)
{
if (client_id == 0 || focus_token == 0)
{
@@ -20,7 +19,6 @@ constexpr bool CanApplyTerminalDeactivationFallback(
{
return active_focus_token == focus_token;
}
return active_client_id == 0 && is_inactive_owner &&
inactive_focus_token == focus_token;
return active_client_id == 0 && is_inactive_owner && inactive_focus_token == focus_token;
}
} // namespace FanyImeIpc
+2 -2
View File
@@ -50,8 +50,8 @@ void WorkerLoop()
}
auto candidates = metasequoia::local_modes::query_kaomoji(
input, scheme, metasequoia::path_from_utf8(g_db_path.c_str()),
kMixedCandidateLimit, GetShuangpinProfile(GetConfiguredShuangpinSchema()))
input, scheme, metasequoia::path_from_utf8(g_db_path.c_str()), kMixedCandidateLimit,
GetShuangpinProfile(GetConfiguredShuangpinSchema()))
.candidates;
if (!g_running || g_generation.load() != observed_generation)
{
+134 -54
View File
@@ -40,7 +40,8 @@ void FillRounded(DeviceResources &resources, const RectF &rect, const D2D1_COLOR
if (target && brush)
{
target->FillRoundedRectangle(
D2D1::RoundedRect(D2D1::RectF(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height), radius, radius), brush);
D2D1::RoundedRect(D2D1::RectF(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height), radius, radius),
brush);
}
}
@@ -49,7 +50,7 @@ void DrawLabel(DeviceResources &resources, const std::wstring &text, const RectF
{
auto *target = resources.GetRenderTarget();
auto *format = resources.GetTextFormat(L"Segoe UI", size, DWRITE_FONT_WEIGHT_NORMAL, alignment,
DWRITE_PARAGRAPH_ALIGNMENT_CENTER, DWRITE_WORD_WRAPPING_NO_WRAP);
DWRITE_PARAGRAPH_ALIGNMENT_CENTER, DWRITE_WORD_WRAPPING_NO_WRAP);
auto *brush = resources.GetSolidColorBrush(color);
if (target && format && brush)
{
@@ -137,29 +138,74 @@ void PushKeyboardInput(std::array<INPUT, kMaxKeyStrokeInputs> &inputs, size_t &c
KeyboardPanel::KeyboardPanel(bool lightTheme) : lightTheme_(lightTheme)
{
rows_ = {
{{L"`", L"~", 1.0f, VK_OEM_3}, {L"1", L"!", 1.0f, L'1'}, {L"2", L"@", 1.0f, L'2'}, {L"3", L"#", 1.0f, L'3'},
{L"4", L"$", 1.0f, L'4'}, {L"5", L"%", 1.0f, L'5'}, {L"6", L"^", 1.0f, L'6'}, {L"7", L"&", 1.0f, L'7'},
{L"8", L"*", 1.0f, L'8'}, {L"9", L"(", 1.0f, L'9'}, {L"0", L")", 1.0f, L'0'}, {L"-", L"_", 1.0f, VK_OEM_MINUS},
{L"=", L"+", 1.0f, VK_OEM_PLUS}, {L"Backspace", L"", 1.9f, VK_BACK}},
{{L"Tab", L"", 1.5f, VK_TAB}, {L"q", L"Q", 1.0f, L'Q'}, {L"w", L"W", 1.0f, L'W'}, {L"e", L"E", 1.0f, L'E'},
{L"r", L"R", 1.0f, L'R'}, {L"t", L"T", 1.0f, L'T'}, {L"y", L"Y", 1.0f, L'Y'}, {L"u", L"U", 1.0f, L'U'},
{L"i", L"I", 1.0f, L'I'}, {L"o", L"O", 1.0f, L'O'}, {L"p", L"P", 1.0f, L'P'}, {L"[", L"{", 1.0f, VK_OEM_4},
{L"]", L"}", 1.0f, VK_OEM_6}, {L"\\", L"|", 1.4f, VK_OEM_5}},
{{L"Caps Lock", L"", 1.85f, VK_CAPITAL, {}, true}, {L"a", L"A", 1.0f, L'A'}, {L"s", L"S", 1.0f, L'S'},
{L"d", L"D", 1.0f, L'D'}, {L"f", L"F", 1.0f, L'F'}, {L"g", L"G", 1.0f, L'G'}, {L"h", L"H", 1.0f, L'H'},
{L"j", L"J", 1.0f, L'J'}, {L"k", L"K", 1.0f, L'K'}, {L"l", L"L", 1.0f, L'L'}, {L";", L":", 1.0f, VK_OEM_1},
{L"'", L"\"", 1.0f, VK_OEM_7}, {L"Enter", L"", 2.0f, VK_RETURN}},
{{L"Shift", L"", 2.35f, VK_SHIFT, {}, true}, {L"z", L"Z", 1.0f, L'Z'}, {L"x", L"X", 1.0f, L'X'},
{L"c", L"C", 1.0f, L'C'}, {L"v", L"V", 1.0f, L'V'}, {L"b", L"B", 1.0f, L'B'}, {L"n", L"N", 1.0f, L'N'},
{L"m", L"M", 1.0f, L'M'}, {L",", L"<", 1.0f, VK_OEM_COMMA}, {L".", L">", 1.0f, VK_OEM_PERIOD},
{L"/", L"?", 1.0f, VK_OEM_2}, {L"Shift", L"", 2.15f, VK_SHIFT, {}, true}},
{{L"Ctrl", L"", 1.25f, VK_CONTROL, {}, true}, {L"Win", L"", 1.25f, VK_LWIN, {}, true},
{L"Alt", L"", 1.25f, VK_MENU, {}, true}, {L"Space", L" ", 6.7f, VK_SPACE}, {L"Alt", L"", 1.25f, VK_MENU, {}, true},
{L"Win", L"", 1.25f, VK_LWIN, {}, true}, {L"Del", L"", 1.25f, VK_DELETE}, {L"Ctrl", L"", 1.25f, VK_CONTROL, {}, true}},
{{L"`", L"~", 1.0f, VK_OEM_3},
{L"1", L"!", 1.0f, L'1'},
{L"2", L"@", 1.0f, L'2'},
{L"3", L"#", 1.0f, L'3'},
{L"4", L"$", 1.0f, L'4'},
{L"5", L"%", 1.0f, L'5'},
{L"6", L"^", 1.0f, L'6'},
{L"7", L"&", 1.0f, L'7'},
{L"8", L"*", 1.0f, L'8'},
{L"9", L"(", 1.0f, L'9'},
{L"0", L")", 1.0f, L'0'},
{L"-", L"_", 1.0f, VK_OEM_MINUS},
{L"=", L"+", 1.0f, VK_OEM_PLUS},
{L"Backspace", L"", 1.9f, VK_BACK}},
{{L"Tab", L"", 1.5f, VK_TAB},
{L"q", L"Q", 1.0f, L'Q'},
{L"w", L"W", 1.0f, L'W'},
{L"e", L"E", 1.0f, L'E'},
{L"r", L"R", 1.0f, L'R'},
{L"t", L"T", 1.0f, L'T'},
{L"y", L"Y", 1.0f, L'Y'},
{L"u", L"U", 1.0f, L'U'},
{L"i", L"I", 1.0f, L'I'},
{L"o", L"O", 1.0f, L'O'},
{L"p", L"P", 1.0f, L'P'},
{L"[", L"{", 1.0f, VK_OEM_4},
{L"]", L"}", 1.0f, VK_OEM_6},
{L"\\", L"|", 1.4f, VK_OEM_5}},
{{L"Caps Lock", L"", 1.85f, VK_CAPITAL, {}, true},
{L"a", L"A", 1.0f, L'A'},
{L"s", L"S", 1.0f, L'S'},
{L"d", L"D", 1.0f, L'D'},
{L"f", L"F", 1.0f, L'F'},
{L"g", L"G", 1.0f, L'G'},
{L"h", L"H", 1.0f, L'H'},
{L"j", L"J", 1.0f, L'J'},
{L"k", L"K", 1.0f, L'K'},
{L"l", L"L", 1.0f, L'L'},
{L";", L":", 1.0f, VK_OEM_1},
{L"'", L"\"", 1.0f, VK_OEM_7},
{L"Enter", L"", 2.0f, VK_RETURN}},
{{L"Shift", L"", 2.35f, VK_SHIFT, {}, true},
{L"z", L"Z", 1.0f, L'Z'},
{L"x", L"X", 1.0f, L'X'},
{L"c", L"C", 1.0f, L'C'},
{L"v", L"V", 1.0f, L'V'},
{L"b", L"B", 1.0f, L'B'},
{L"n", L"N", 1.0f, L'N'},
{L"m", L"M", 1.0f, L'M'},
{L",", L"<", 1.0f, VK_OEM_COMMA},
{L".", L">", 1.0f, VK_OEM_PERIOD},
{L"/", L"?", 1.0f, VK_OEM_2},
{L"Shift", L"", 2.15f, VK_SHIFT, {}, true}},
{{L"Ctrl", L"", 1.25f, VK_CONTROL, {}, true},
{L"Win", L"", 1.25f, VK_LWIN, {}, true},
{L"Alt", L"", 1.25f, VK_MENU, {}, true},
{L"Space", L" ", 6.7f, VK_SPACE},
{L"Alt", L"", 1.25f, VK_MENU, {}, true},
{L"Win", L"", 1.25f, VK_LWIN, {}, true},
{L"Del", L"", 1.25f, VK_DELETE},
{L"Ctrl", L"", 1.25f, VK_CONTROL, {}, true}},
};
}
SizeF KeyboardPanel::Measure(const SizeF &availableSize) { return availableSize; }
SizeF KeyboardPanel::Measure(const SizeF &availableSize)
{
return availableSize;
}
void KeyboardPanel::Arrange(const RectF &finalRect)
{
@@ -172,12 +218,14 @@ void KeyboardPanel::RebuildLayout()
{
const float contentTop = bounds_.y + kHeaderHeight;
const float availableHeight = std::max(bounds_.height - kHeaderHeight - kOuterPadding, 1.0f);
const float rowHeight = (availableHeight - kGap * static_cast<float>(rows_.size() - 1)) / static_cast<float>(rows_.size());
const float rowHeight =
(availableHeight - kGap * static_cast<float>(rows_.size() - 1)) / static_cast<float>(rows_.size());
for (size_t rowIndex = 0; rowIndex < rows_.size(); ++rowIndex)
{
auto &row = rows_[rowIndex];
float totalWeight = 0.0f;
for (const auto &key : row) totalWeight += key.weight;
for (const auto &key : row)
totalWeight += key.weight;
const float availableWidth = bounds_.width - kOuterPadding * 2.0f - kGap * static_cast<float>(row.size() - 1);
const float unit = availableWidth / totalWeight;
float x = bounds_.x + kOuterPadding;
@@ -200,9 +248,10 @@ void KeyboardPanel::Render(DeviceResources &resources)
const D2D1_COLOR_F keyActive = D2D1::ColorF(lightTheme_ ? 0xD7D0E0 : 0x535866);
const D2D1_COLOR_F keyPressed = D2D1::ColorF(lightTheme_ ? 0xC7C9D0 : 0x666A77);
FillRounded(resources, bounds_, background, 8.0f);
DrawLabel(resources, L"Touch keyboard", {bounds_.x + 10.0f, bounds_.y, 160.0f, kHeaderHeight}, 12.0f,
headerText, DWRITE_TEXT_ALIGNMENT_LEADING);
if (closeHovered_ || closePressed_) FillRounded(resources, closeRect_, keyHover, 4.0f);
DrawLabel(resources, L"Touch keyboard", {bounds_.x + 10.0f, bounds_.y, 160.0f, kHeaderHeight}, 12.0f, headerText,
DWRITE_TEXT_ALIGNMENT_LEADING);
if (closeHovered_ || closePressed_)
FillRounded(resources, closeRect_, keyHover, 4.0f);
DrawCloseIcon(resources, closeRect_, text);
size_t flatIndex = 0;
@@ -223,7 +272,10 @@ void KeyboardPanel::Render(DeviceResources &resources)
}
}
bool KeyboardPanel::HitTest(const PointF &point) const { return Contains(bounds_, point); }
bool KeyboardPanel::HitTest(const PointF &point) const
{
return Contains(bounds_, point);
}
size_t KeyboardPanel::HitKey(const PointF &point) const
{
@@ -232,7 +284,8 @@ size_t KeyboardPanel::HitKey(const PointF &point) const
{
for (const auto &key : row)
{
if (Contains(key.rect, point)) return index;
if (Contains(key.rect, point))
return index;
++index;
}
}
@@ -241,7 +294,8 @@ size_t KeyboardPanel::HitKey(const PointF &point) const
bool KeyboardPanel::OnMouseDown(const POINT &point, WPARAM)
{
if (!window_) return false;
if (!window_)
return false;
RememberInputTargetWindow();
const PointF dip = window_->ClientPixelsToDips(point);
closePressed_ = Contains(closeRect_, dip);
@@ -252,21 +306,25 @@ bool KeyboardPanel::OnMouseDown(const POINT &point, WPARAM)
bool KeyboardPanel::OnMouseUp(const POINT &point, WPARAM)
{
if (!window_) return false;
if (!window_)
return false;
const PointF dip = window_->ClientPixelsToDips(point);
const bool close = closePressed_ && Contains(closeRect_, dip);
const size_t hit = HitKey(dip);
if (pressedKey_ != kInvalidIndex && hit == pressedKey_) ActivateKey(hit);
if (pressedKey_ != kInvalidIndex && hit == pressedKey_)
ActivateKey(hit);
pressedKey_ = kInvalidIndex;
closePressed_ = false;
InvalidateVisual();
if (close) PostMessageW(window_->GetHandle(), WM_CLOSE, 0, 0);
if (close)
PostMessageW(window_->GetHandle(), WM_CLOSE, 0, 0);
return true;
}
bool KeyboardPanel::OnMouseMove(const POINT &point, WPARAM)
{
if (!window_) return false;
if (!window_)
return false;
const PointF dip = window_->ClientPixelsToDips(point);
const bool close = Contains(closeRect_, dip);
const size_t key = close ? kInvalidIndex : HitKey(dip);
@@ -288,11 +346,16 @@ void KeyboardPanel::OnMouseLeave()
bool KeyboardPanel::IsKeyActive(const Key &key) const
{
if (key.virtualKey == VK_SHIFT) return shiftActive_;
if (key.virtualKey == VK_CAPITAL) return capsActive_;
if (key.virtualKey == VK_CONTROL) return ctrlActive_;
if (key.virtualKey == VK_MENU) return altActive_;
if (key.virtualKey == VK_LWIN) return winActive_;
if (key.virtualKey == VK_SHIFT)
return shiftActive_;
if (key.virtualKey == VK_CAPITAL)
return capsActive_;
if (key.virtualKey == VK_CONTROL)
return ctrlActive_;
if (key.virtualKey == VK_MENU)
return altActive_;
if (key.virtualKey == VK_LWIN)
return winActive_;
return false;
}
@@ -340,14 +403,20 @@ void KeyboardPanel::ActivateKey(size_t target)
{
for (auto &key : row)
{
if (index++ != target) continue;
if (index++ != target)
continue;
if (key.modifier)
{
if (key.virtualKey == VK_SHIFT) shiftActive_ = !shiftActive_;
else if (key.virtualKey == VK_CAPITAL) capsActive_ = !capsActive_;
else if (key.virtualKey == VK_CONTROL) ctrlActive_ = !ctrlActive_;
else if (key.virtualKey == VK_MENU) altActive_ = !altActive_;
else if (key.virtualKey == VK_LWIN) winActive_ = !winActive_;
if (key.virtualKey == VK_SHIFT)
shiftActive_ = !shiftActive_;
else if (key.virtualKey == VK_CAPITAL)
capsActive_ = !capsActive_;
else if (key.virtualKey == VK_CONTROL)
ctrlActive_ = !ctrlActive_;
else if (key.virtualKey == VK_MENU)
altActive_ = !altActive_;
else if (key.virtualKey == VK_LWIN)
winActive_ = !winActive_;
}
else
{
@@ -357,7 +426,8 @@ void KeyboardPanel::ActivateKey(size_t target)
const bool useShift = shiftedLabel || (letterKey && (capsActive_ != shiftActive_));
const bool includeStickyModifiers = !IsImeCommitKey(key.virtualKey);
SendKeyStroke(key.virtualKey, useShift && includeStickyModifiers, includeStickyModifiers);
if (shiftActive_) shiftActive_ = false;
if (shiftActive_)
shiftActive_ = false;
}
return;
}
@@ -376,19 +446,26 @@ void KeyboardPanel::SendKeyStroke(WORD virtualKey, bool withShift, bool includeS
size_t count = 0;
const ULONG_PTR extraInfo = GetMessageExtraInfo();
if (includeStickyModifiers && ctrlActive_) PushKeyboardInput(inputs, count, VK_CONTROL, 0, extraInfo);
if (includeStickyModifiers && altActive_) PushKeyboardInput(inputs, count, VK_MENU, 0, extraInfo);
if (includeStickyModifiers && winActive_) PushKeyboardInput(inputs, count, VK_LWIN, 0, extraInfo);
if (withShift) PushKeyboardInput(inputs, count, VK_SHIFT, 0, extraInfo);
if (includeStickyModifiers && ctrlActive_)
PushKeyboardInput(inputs, count, VK_CONTROL, 0, extraInfo);
if (includeStickyModifiers && altActive_)
PushKeyboardInput(inputs, count, VK_MENU, 0, extraInfo);
if (includeStickyModifiers && winActive_)
PushKeyboardInput(inputs, count, VK_LWIN, 0, extraInfo);
if (withShift)
PushKeyboardInput(inputs, count, VK_SHIFT, 0, extraInfo);
PushKeyboardInput(inputs, count, virtualKey, KeyboardEventFlags(virtualKey, false), extraInfo);
PushKeyboardInput(inputs, count, virtualKey, KeyboardEventFlags(virtualKey, true), extraInfo);
if (withShift) PushKeyboardInput(inputs, count, VK_SHIFT, KEYEVENTF_KEYUP, extraInfo);
if (withShift)
PushKeyboardInput(inputs, count, VK_SHIFT, KEYEVENTF_KEYUP, extraInfo);
if (includeStickyModifiers && winActive_)
PushKeyboardInput(inputs, count, VK_LWIN, KEYEVENTF_KEYUP | KEYEVENTF_EXTENDEDKEY, extraInfo);
if (includeStickyModifiers && altActive_) PushKeyboardInput(inputs, count, VK_MENU, KEYEVENTF_KEYUP, extraInfo);
if (includeStickyModifiers && ctrlActive_) PushKeyboardInput(inputs, count, VK_CONTROL, KEYEVENTF_KEYUP, extraInfo);
if (includeStickyModifiers && altActive_)
PushKeyboardInput(inputs, count, VK_MENU, KEYEVENTF_KEYUP, extraInfo);
if (includeStickyModifiers && ctrlActive_)
PushKeyboardInput(inputs, count, VK_CONTROL, KEYEVENTF_KEYUP, extraInfo);
if (count > 0)
{
@@ -396,5 +473,8 @@ void KeyboardPanel::SendKeyStroke(WORD virtualKey, bool withShift, bool includeS
}
}
HCURSOR KeyboardPanel::GetCursor() const { return LoadCursor(nullptr, IDC_ARROW); }
HCURSOR KeyboardPanel::GetCursor() const
{
return LoadCursor(nullptr, IDC_ARROW);
}
} // namespace msimeui
+6 -3
View File
@@ -12,10 +12,13 @@
int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int nCmdShow)
{
CommonUtils::SingleInstanceGuard single_instance(L"Local\\MetasequoiaImeKeyboardPanel.SingleInstance");
if (!single_instance.is_valid()) return -1;
if (single_instance.already_running()) return 0;
if (!single_instance.is_valid())
return -1;
if (single_instance.already_running())
return 0;
if (!msimeui::Application::Initialize()) return -1;
if (!msimeui::Application::Initialize())
return -1;
const bool lightTheme = SurfaceThemeConfig::IsLight("theme_screen_keyboard");
msimeui::Theme theme;
+22 -17
View File
@@ -31,21 +31,25 @@ void StartWatchdogIfNeeded(const char *command_line)
// The Watchdog passes this marker when it owns the Server lifecycle.
// A Server revived directly by TSF has no marker and restores the
// Watchdog, preserving crash recovery after the six-keystroke fallback.
if (command_line && std::strstr(command_line, "--watchdog-managed")) return;
if (command_line && std::strstr(command_line, "--watchdog-managed"))
return;
wchar_t server_path[MAX_PATH]{};
if (!GetModuleFileNameW(nullptr, server_path, MAX_PATH)) return;
if (!GetModuleFileNameW(nullptr, server_path, MAX_PATH))
return;
std::wstring watchdog_path(server_path);
const size_t separator = watchdog_path.find_last_of(L"\\/");
if (separator == std::wstring::npos) return;
if (separator == std::wstring::npos)
return;
watchdog_path.replace(separator + 1, std::wstring::npos, L"MetasequoiaImeWatchdog.exe");
if (GetFileAttributesW(watchdog_path.c_str()) == INVALID_FILE_ATTRIBUTES) return;
if (GetFileAttributesW(watchdog_path.c_str()) == INVALID_FILE_ATTRIBUTES)
return;
std::wstring command = L"\"" + watchdog_path + L"\"";
STARTUPINFOW startup_info{sizeof(startup_info)};
PROCESS_INFORMATION process_info{};
if (CreateProcessW(watchdog_path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
nullptr, &startup_info, &process_info))
if (CreateProcessW(watchdog_path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr,
&startup_info, &process_info))
{
CloseHandle(process_info.hThread);
CloseHandle(process_info.hProcess);
@@ -111,10 +115,10 @@ int CALLBACK WinMain(_In_ HINSTANCE hInstance, _In_ HINSTANCE hPrevInstance, _In
g_inputSession = CreateInputSessionFromConfig();
const std::string configured_backend = DescribeConfiguredInputSessionBackendFromConfig();
const std::string effective_backend = DescribeEffectiveInputSessionBackendFromConfig();
const std::string session_summary = fmt::format(
"Input session backend configured={}, effective={}, scheme={}",
configured_backend.empty() ? "legacy" : configured_backend, effective_backend,
SchemeTypeToString(g_inputSession->current_scheme_type()));
const std::string session_summary =
fmt::format("Input session backend configured={}, effective={}, scheme={}",
configured_backend.empty() ? "legacy" : configured_backend, effective_backend,
SchemeTypeToString(g_inputSession->current_scheme_type()));
(void)0;
#ifdef FANY_DEBUG
(void)0;
@@ -149,13 +153,14 @@ int CALLBACK WinMain(_In_ HINSTANCE hInstance, _In_ HINSTANCE hPrevInstance, _In
AiAssistant::Start([](const std::string &candidate, const std::string &identity, uint64_t generation) {
FanyNamedPipe::EnqueueAiCandidate(candidate, identity, generation);
});
EnglishIme::Start(CommonUtils::get_ime_data_path() + "\\english.db",
[](std::vector<WordItem> candidates, const std::string &input, uint64_t generation) {
FanyNamedPipe::EnqueueEnglishCandidates(std::move(candidates), input, generation);
},
[](std::vector<EnglishIme::TranslationResult> results, uint64_t generation) {
FanyNamedPipe::EnqueueCandidateTranslations(std::move(results), generation);
});
EnglishIme::Start(
CommonUtils::get_ime_data_path() + "\\english.db",
[](std::vector<WordItem> candidates, const std::string &input, uint64_t generation) {
FanyNamedPipe::EnqueueEnglishCandidates(std::move(candidates), input, generation);
},
[](std::vector<EnglishIme::TranslationResult> results, uint64_t generation) {
FanyNamedPipe::EnqueueCandidateTranslations(std::move(results), generation);
});
CloudTranslation::Start(CommonUtils::get_ime_data_path() + "\\english.db",
[](std::vector<EnglishIme::TranslationResult> results, uint64_t generation) {
FanyNamedPipe::EnqueueCandidateTranslations(std::move(results), generation, true);
+36 -3
View File
@@ -2,7 +2,8 @@
#include "config/ime_config.h"
#include "MetasequoiaImeEngine/common/helpcode_utils.h"
EngineInputSession::EngineInputSession(SchemeType scheme, const ShuangpinProfile &profile) : session_(scheme, profile)
EngineInputSession::EngineInputSession(SchemeType scheme, const ShuangpinProfile &profile)
: paths_(metasequoia::RuntimePaths::legacy()), session_(scheme, profile, paths_)
{
ApplyConfiguration();
}
@@ -11,8 +12,21 @@ void EngineInputSession::ApplyConfiguration()
{
const auto scheme = session_.scheme();
if (scheme == SchemeType::Quanpin || scheme == SchemeType::Shuangpin)
HelpcodeUtils::select_helpcode_schema(scheme == SchemeType::Quanpin ? GetConfiguredQuanpinHelpcodeSchema()
: GetConfiguredShuangpinHelpcodeSchema());
{
const auto &schema = scheme == SchemeType::Quanpin ? GetConfiguredQuanpinHelpcodeSchema()
: GetConfiguredShuangpinHelpcodeSchema();
if (schema != helpcode_schema_)
{
// Keep filtering and annotations on this session's captured resource layout.
// Applying unchanged settings on each key must not reload the tables.
auto keymap = HelpcodeUtils::load_helpcode_keymap(paths_.resources, schema);
if (session_.set_helpcode_schema(schema))
{
helpcode_schema_ = schema;
helpcode_keymap_ = std::move(keymap);
}
}
}
session_.set_shuangpin_helpcode_enabled(GetConfiguredShuangpinHelpcodeEnabled());
session_.set_quanpin_helpcode_enabled(GetConfiguredQuanpinHelpcodeEnabled());
session_.set_quanpin_autocorrect_enabled(GetConfiguredQuanpinAutocorrectEnabled());
@@ -154,9 +168,28 @@ IInputSession::CloudQueryState EngineInputSession::get_cloud_query_state() const
return session_.get_cloud_query_state();
}
std::optional<metasequoia::OnlineQuery> EngineInputSession::online_query() const
{
return session_.online_query();
}
bool EngineInputSession::apply_online_candidate(const metasequoia::OnlineQuery &query, std::string candidate,
CandidateSource source)
{
return session_.apply_online_candidate(query, std::move(candidate), source);
}
IInputSession::CreatingWordProgress EngineInputSession::update_creating_word_progress(
const std::string &current_pinyin, const std::string &current_word, const std::string &selected_word,
const SelectionTransition &selection_transition) const
{
return session_.update_creating_word_progress(current_pinyin, current_word, selected_word, selection_transition);
}
std::string EngineInputSession::get_helpcode_annotation(const std::string &word, bool uppercase_all) const
{
const auto scheme = session_.scheme();
if (!helpcode_keymap_ || (scheme != SchemeType::Quanpin && scheme != SchemeType::Shuangpin))
return {};
return HelpcodeUtils::compute_helpcodes(word, uppercase_all, helpcode_keymap_.get());
}
@@ -1,6 +1,7 @@
#pragma once
#include "input_session.h"
#include "MetasequoiaImeEngine/common/helpcode_utils.h"
#include "MetasequoiaImeEngine/core/input_session.h"
class EngineInputSession : public IInputSession
@@ -19,6 +20,7 @@ class EngineInputSession : public IInputSession
const std::vector<WordItem> &get_candidates() const override;
bool expand_initial_candidates() override;
std::string get_helpcode_annotation(const std::string &word, bool uppercase_all) const override;
std::optional<WordItem> find_candidate(const std::string &key, const std::string &value) override;
const std::string &get_pinyin_sequence() const override;
@@ -42,6 +44,9 @@ class EngineInputSession : public IInputSession
const std::string &selected_word,
const std::string &selected_canonical_pinyin) override;
CloudQueryState get_cloud_query_state() const override;
std::optional<metasequoia::OnlineQuery> online_query() const override;
bool apply_online_candidate(const metasequoia::OnlineQuery &query, std::string candidate,
CandidateSource source) override;
CreatingWordProgress update_creating_word_progress(const std::string &current_pinyin,
const std::string &current_word,
const std::string &selected_word,
@@ -49,5 +54,8 @@ class EngineInputSession : public IInputSession
private:
void ApplyConfiguration();
const metasequoia::RuntimePaths paths_;
metasequoia::InputSession session_;
std::string helpcode_schema_;
HelpcodeUtils::SharedKeymap helpcode_keymap_;
};
+11 -10
View File
@@ -28,6 +28,7 @@ class IInputSession
virtual const std::vector<WordItem> &get_candidates() const = 0;
virtual bool expand_initial_candidates() = 0;
virtual std::string get_helpcode_annotation(const std::string &word, bool uppercase_all) const = 0;
virtual std::optional<WordItem> find_candidate(const std::string &, const std::string &)
{
return std::nullopt;
@@ -49,15 +50,15 @@ class IInputSession
virtual int store_user_phrase_from_canonical_pinyin(std::string pinyin, std::string word) = 0;
virtual int pin_candidate(std::string pinyin, std::string word) = 0;
virtual int remove_candidate(std::string pinyin, std::string word) = 0;
virtual int cache_dynamic_candidate(const std::string &pinyin, const std::string &word,
CandidateSource source) = 0;
virtual SelectionTransition advance_composition_after_selection(
const std::string &selected_pinyin,
const std::string &selected_word,
const std::string &selected_canonical_pinyin) = 0;
virtual int cache_dynamic_candidate(const std::string &pinyin, const std::string &word, CandidateSource source) = 0;
virtual SelectionTransition advance_composition_after_selection(const std::string &selected_pinyin,
const std::string &selected_word,
const std::string &selected_canonical_pinyin) = 0;
virtual CloudQueryState get_cloud_query_state() const = 0;
virtual CreatingWordProgress update_creating_word_progress(const std::string &current_pinyin,
const std::string &current_word,
const std::string &selected_word,
const SelectionTransition &selection_transition) const = 0;
virtual std::optional<metasequoia::OnlineQuery> online_query() const = 0;
virtual bool apply_online_candidate(const metasequoia::OnlineQuery &query, std::string candidate,
CandidateSource source) = 0;
virtual CreatingWordProgress update_creating_word_progress(
const std::string &current_pinyin, const std::string &current_word, const std::string &selected_word,
const SelectionTransition &selection_transition) const = 0;
};
File diff suppressed because it is too large. Load diff
+10 -5
View File
@@ -10,7 +10,8 @@ namespace SettingsDictionary::Paging
inline int Integer(const boost::json::object &request, const char *key, int fallback, int maximum)
{
const auto *value = request.if_contains(key);
if (!value || !value->is_int64()) return fallback;
if (!value || !value->is_int64())
return fallback;
return static_cast<int>((std::max)(int64_t{0}, (std::min)(value->as_int64(), int64_t{maximum})));
}
inline int Limit(const boost::json::object &request)
@@ -33,15 +34,19 @@ inline boost::json::object Read(sqlite3_stmt *statement, const boost::json::obje
const auto *value = sqlite3_column_text(statement, column);
return value ? reinterpret_cast<const char *>(value) : "";
};
rows.push_back({{english ? "word" : "code", text(0)}, {english ? "display" : "word", text(1)},
rows.push_back({{english ? "word" : "code", text(0)},
{english ? "display" : "word", text(1)},
{"weight", sqlite3_column_int(statement, 2)}});
}
if (status != SQLITE_DONE)
return {{"ok", false}, {"message", "查询失败,请重试"}, {"rows", json::array{}}};
const bool more = rows.size() > static_cast<size_t>(Limit(request));
if (more) rows.pop_back();
return {{"ok", true}, {"message", rows.empty() ? "没有找到词条" : "查询成功"},
{"rows", std::move(rows)}, {"hasMore", more},
if (more)
rows.pop_back();
return {{"ok", true},
{"message", rows.empty() ? "没有找到词条" : "查询成功"},
{"rows", std::move(rows)},
{"hasMore", more},
{"offset", Integer(request, "offset", 0, 2147483647)}};
}
} // namespace SettingsDictionary::Paging
+42 -14
View File
@@ -10,17 +10,23 @@
// owner thread. Stop drains accepted work; no detached threads outlive the owner.
class SerialTaskQueue
{
public:
public:
using Completion = std::function<void()>;
using Task = std::function<Completion()>;
SerialTaskQueue(Completion notify, Completion error, Completion cleanup = {})
: notify_(std::move(notify)), error_(std::move(error)), cleanup_(std::move(cleanup)),
thread_([this] { Run(); }) {}
~SerialTaskQueue() { Stop(); thread_.join(); }
: notify_(std::move(notify)), error_(std::move(error)), cleanup_(std::move(cleanup)), thread_([this] { Run(); })
{
}
~SerialTaskQueue()
{
Stop();
thread_.join();
}
bool Submit(Task task)
{
std::lock_guard<std::mutex> lock(mutex_);
if (stopping_) return false;
if (stopping_)
return false;
tasks_.push_back(std::move(task));
ready_.notify_one();
return true;
@@ -39,10 +45,16 @@ public:
void Drain(bool deliver = true)
{
std::deque<Completion> completed;
{ std::lock_guard<std::mutex> lock(mutex_); completed.swap(completed_); }
if (deliver) for (auto &completion : completed) completion();
{
std::lock_guard<std::mutex> lock(mutex_);
completed.swap(completed_);
}
if (deliver)
for (auto &completion : completed)
completion();
}
private:
private:
void Run()
{
for (;;)
@@ -51,19 +63,35 @@ private:
{
std::unique_lock<std::mutex> lock(mutex_);
ready_.wait(lock, [this] { return stopping_ || !tasks_.empty(); });
if (tasks_.empty()) break;
task = std::move(tasks_.front()); tasks_.pop_front();
if (tasks_.empty())
break;
task = std::move(tasks_.front());
tasks_.pop_front();
}
Completion result;
try { result = task(); } catch (...) { result = error_; }
try
{
result = task();
}
catch (...)
{
result = error_;
}
if (result)
{
{ std::lock_guard<std::mutex> lock(mutex_); completed_.push_back(std::move(result)); }
{
std::lock_guard<std::mutex> lock(mutex_);
completed_.push_back(std::move(result));
}
notify_();
}
}
if (cleanup_) cleanup_();
{ std::lock_guard<std::mutex> lock(mutex_); stopped_ = true; }
if (cleanup_)
cleanup_();
{
std::lock_guard<std::mutex> lock(mutex_);
stopped_ = true;
}
notify_();
}
Completion notify_, error_, cleanup_;
+348 -202
View File
@@ -79,7 +79,6 @@ std::wstring g_last_config_message;
bool g_worker_com_initialized = false; // worker-only
void CloseSettings(HWND hwnd);
nlohmann::json CandidateColorsToJson(const CandidateSkinCatalog::CandidateColors &colors)
{
nlohmann::json json = nlohmann::json::object();
@@ -104,7 +103,8 @@ nlohmann::json CandidateColorsToJson(const CandidateSkinCatalog::CandidateColors
void ShowAboutSection()
{
if (!g_webview) return;
if (!g_webview)
return;
g_webview->ExecuteScript(LR"JS((() => {
const selectAbout = () => {
const item = document.querySelector('.sidebar .item[data-target="about-settings"]');
@@ -121,7 +121,8 @@ void ShowAboutSection()
if (selectAbout()) clearInterval(retry);
}, 50);
setTimeout(() => clearInterval(retry), 10000);
})())JS", nullptr);
})())JS",
nullptr);
}
void ApplyWindowActivationAppearance()
@@ -132,8 +133,7 @@ void ApplyWindowActivationAppearance()
// Keep this effect in the native host so it follows the real top-level
// activation state rather than DOM focus. Only title-bar foreground
// elements are muted; the page and title-bar backgrounds remain intact.
const wchar_t *script = g_window_active
? LR"JS((() => {
const wchar_t *script = g_window_active ? LR"JS((() => {
document.getElementById('metasequoia-window-inactive-overlay')?.remove();
const id = 'metasequoia-window-activation-style';
if (!document.getElementById(id)) {
@@ -156,7 +156,7 @@ void ApplyWindowActivationAppearance()
}
document.documentElement.classList.remove('metasequoia-window-inactive');
})())JS"
: LR"JS((() => {
: LR"JS((() => {
document.getElementById('metasequoia-window-inactive-overlay')?.remove();
const id = 'metasequoia-window-activation-style';
if (!document.getElementById(id)) {
@@ -244,7 +244,8 @@ void ResetTitlebarHoverAfterVisibilityChange()
window.__metasequoiaReleaseTooltipReset = release;
window.addEventListener('pointermove', release, {capture: true, once: true});
window.addEventListener('pointerdown', release, {capture: true, once: true});
})())JS", nullptr);
})())JS",
nullptr);
}
std::wstring BuildConfigMessage(bool refresh_skin_catalog)
@@ -255,8 +256,8 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog)
const TencentTmtConfig &tencent_tmt = GetConfiguredTencentTmt();
const FrequencyAdjustmentConfig &frequency = GetConfiguredFrequencyAdjustment();
const FloatingToolbarItemsConfig &toolbar = GetConfiguredFloatingToolbarItems();
const std::filesystem::path skins_root = std::filesystem::path(CommonUtils::get_local_appdata_path_w()) /
GlobalIme::AppName / L"skins";
const std::filesystem::path skins_root =
std::filesystem::path(CommonUtils::get_local_appdata_path_w()) / GlobalIme::AppName / L"skins";
if (refresh_skin_catalog)
{
g_candidate_skin_catalog = CandidateSkinCatalog::Scan(skins_root);
@@ -269,23 +270,23 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog)
{
for (const auto &skin : g_candidate_skin_catalog->packages)
{
external_skins.push_back({{"id", skin.id},
{"name", skin.name},
{"version", skin.version},
{"author", skin.author},
{"description", skin.description},
{"base", skin.base},
{"toolbarStylesheet", skin.toolbarStylesheet},
{"preview", skin.preview},
{"layouts", skin.layouts},
{"themes", skin.themes},
{"minWidthDip", skin.minWidthDip},
{"decorationTopDip", skin.decorationTopDip},
{"decorationWidthDip", skin.decorationWidthDip},
{"candidate",
{{"dark", CandidateColorsToJson(skin.dark)},
{"light", CandidateColorsToJson(skin.light)}}},
{"compatible", CandidateSkinCatalog::Supports(skin, skin_layout, skin_theme)}});
external_skins.push_back(
{{"id", skin.id},
{"name", skin.name},
{"version", skin.version},
{"author", skin.author},
{"description", skin.description},
{"base", skin.base},
{"toolbarStylesheet", skin.toolbarStylesheet},
{"preview", skin.preview},
{"layouts", skin.layouts},
{"themes", skin.themes},
{"minWidthDip", skin.minWidthDip},
{"decorationTopDip", skin.decorationTopDip},
{"decorationWidthDip", skin.decorationWidthDip},
{"candidate",
{{"dark", CandidateColorsToJson(skin.dark)}, {"light", CandidateColorsToJson(skin.light)}}},
{"compatible", CandidateSkinCatalog::Supports(skin, skin_layout, skin_theme)}});
}
}
nlohmann::json skin_issues = nlohmann::json::array();
@@ -348,7 +349,8 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog)
{"switch_language_ctrl", GetConfiguredSwitchLanguageCtrlEnabled()},
{"switch_language_ctrl_alt_space", GetConfiguredSwitchLanguageCtrlAltSpaceEnabled()}}},
{"frequency_adjustment",
{{"mode", frequency.mode}, {"trigger_count", frequency.trigger_count},
{{"mode", frequency.mode},
{"trigger_count", frequency.trigger_count},
{"linear_step", frequency.linear_step}}},
{"utility",
{{"unicode_mode", GetConfiguredUnicodeModeEnabled()},
@@ -394,26 +396,34 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog)
{"candidate_skin_directory", skins_root.u8string()}}},
{"voice_input",
{{"enabled", voice.enabled},
{"hotkey_ralt", voice.hotkey_ralt}, {"hotkey_ctrl_f9", voice.hotkey_ctrl_f9},
{"hotkey_ctrl_win", voice.hotkey_ctrl_win}, {"hotkey_rctrl_ralt", voice.hotkey_rctrl_ralt},
{"hotkey_ralt", voice.hotkey_ralt},
{"hotkey_ctrl_f9", voice.hotkey_ctrl_f9},
{"hotkey_ctrl_win", voice.hotkey_ctrl_win},
{"hotkey_rctrl_ralt", voice.hotkey_rctrl_ralt},
{"hotkey_hold_space_lock", voice.hotkey_hold_space_lock},
{"asr_provider", voice.asr_provider}, {"asr_app_key", voice.asr_app_key},
{"asr_token", voice.asr_token}, {"asr_tokens", voice.asr_tokens},
{"asr_endpoint", voice.asr_endpoint}, {"asr_resource_id", voice.asr_resource_id},
{"asr_provider", voice.asr_provider},
{"asr_app_key", voice.asr_app_key},
{"asr_token", voice.asr_token},
{"asr_tokens", voice.asr_tokens},
{"asr_endpoint", voice.asr_endpoint},
{"asr_resource_id", voice.asr_resource_id},
{"doubao_enable_itn", voice.doubao_enable_itn},
{"doubao_enable_punc", voice.doubao_enable_punc},
{"doubao_enable_ddc", voice.doubao_enable_ddc},
{"doubao_boosting_table_id", voice.doubao_boosting_table_id},
{"asr_model", voice.asr_model},
{"polish_provider", voice.polish_provider},
{"polish_token", voice.polish_token}, {"polish_tokens", voice.polish_tokens}, {"polish_endpoint", voice.polish_endpoint},
{"polish_token", voice.polish_token},
{"polish_tokens", voice.polish_tokens},
{"polish_endpoint", voice.polish_endpoint},
{"polish_model", voice.polish_model},
{"polish_prompt_id", voice.polish_prompt_id},
{"polish_prompt", voice.polish_prompt},
{"polish_prompt_custom_1", voice.polish_prompt_custom_1},
{"polish_prompt_custom_2", voice.polish_prompt_custom_2},
{"polish_prompt_custom_3", voice.polish_prompt_custom_3},
{"language", voice.language}, {"start_sound", voice.start_sound},
{"language", voice.language},
{"start_sound", voice.start_sound},
{"end_sound", voice.end_sound},
{"mute_system_audio", voice.mute_system_audio},
{"notification_sound", voice.start_sound && voice.end_sound},
@@ -421,14 +431,23 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog)
{"stream_inline_preedit", voice.stream_inline_preedit},
{"commit_mode", voice.commit_mode}}},
{"ai_assistant",
{{"enabled", ai.enabled}, {"provider", ai.provider}, {"token", ai.token},
{"tokens", ai.tokens}, {"endpoint", ai.endpoint}, {"model", ai.model},
{"candidate_limit", ai.candidate_limit}, {"prompt", ai.prompt},
{"prompt_id", ai.prompt_id}, {"prompt_custom_1", ai.prompt_custom_1},
{"prompt_custom_2", ai.prompt_custom_2}, {"prompt_custom_3", ai.prompt_custom_3}}},
{{"enabled", ai.enabled},
{"provider", ai.provider},
{"token", ai.token},
{"tokens", ai.tokens},
{"endpoint", ai.endpoint},
{"model", ai.model},
{"candidate_limit", ai.candidate_limit},
{"prompt", ai.prompt},
{"prompt_id", ai.prompt_id},
{"prompt_custom_1", ai.prompt_custom_1},
{"prompt_custom_2", ai.prompt_custom_2},
{"prompt_custom_3", ai.prompt_custom_3}}},
{"tencent_tmt",
{{"secret_id", tencent_tmt.secret_id}, {"secret_key", tencent_tmt.secret_key},
{"region", tencent_tmt.region}, {"target_language", tencent_tmt.target_language}}},
{{"secret_id", tencent_tmt.secret_id},
{"secret_key", tencent_tmt.secret_key},
{"region", tencent_tmt.region},
{"target_language", tencent_tmt.target_language}}},
{"helpcode",
{{"shuangpin_helpcode", GetConfiguredShuangpinHelpcodeEnabled()},
{"shuangpin_helpcode_schema", GetConfiguredShuangpinHelpcodeSchema()},
@@ -462,7 +481,8 @@ SerialTaskQueue::Completion ConfigCompletion(bool refresh_skin_catalog = false)
void PostConfig(bool refresh_skin_catalog = false)
{
if (!g_worker || g_closing) return;
if (!g_worker || g_closing)
return;
g_worker->Submit([refresh_skin_catalog] { return ConfigCompletion(refresh_skin_catalog); });
}
@@ -572,16 +592,15 @@ bool ApplyConfigUpdate(const json::object &data)
if (path == "general.floating_toolbar_scale")
{
const json::value &value = data.at("value");
const double scale =
value.is_double() ? value.as_double() : static_cast<double>(value.as_int64());
const double scale = value.is_double() ? value.as_double() : static_cast<double>(value.as_int64());
return SetConfiguredFloatingToolbarScale(scale);
}
if (path == "general.floating_toolbar_font_size")
return SetConfiguredFloatingToolbarFontSize(static_cast<int>(data.at("value").as_int64()));
constexpr std::string_view toolbar_prefix = "general.floating_toolbar_";
if (path.rfind(toolbar_prefix, 0) == 0)
return SetConfiguredFloatingToolbarItemEnabled(
path.substr(toolbar_prefix.size()), json::value_to<bool>(data.at("value")));
return SetConfiguredFloatingToolbarItemEnabled(path.substr(toolbar_prefix.size()),
json::value_to<bool>(data.at("value")));
if (path == "general.cn_en_mixed_input")
return SetConfiguredEnglishCandidatesEnabled(json::value_to<bool>(data.at("value")));
if (path == "general.candidate_translations")
@@ -648,23 +667,27 @@ bool ApplyConfigUpdate(const json::object &data)
{
return SetConfiguredVoiceInputBool(key, json::value_to<bool>(value));
}
if (value.is_string()) return SetConfiguredVoiceInputString(key, json::value_to<std::string>(value));
if (value.is_string())
return SetConfiguredVoiceInputString(key, json::value_to<std::string>(value));
return false;
}
if (path.rfind("ai_assistant.", 0) == 0)
{
const std::string key = path.substr(13);
const json::value &value = data.at("value");
if (value.is_bool()) return SetConfiguredAiAssistantBool(key, json::value_to<bool>(value));
if (value.is_string()) return SetConfiguredAiAssistantString(key, json::value_to<std::string>(value));
if (value.is_int64()) return SetConfiguredAiAssistantInt(key, static_cast<int>(value.as_int64()));
if (value.is_bool())
return SetConfiguredAiAssistantBool(key, json::value_to<bool>(value));
if (value.is_string())
return SetConfiguredAiAssistantString(key, json::value_to<std::string>(value));
if (value.is_int64())
return SetConfiguredAiAssistantInt(key, static_cast<int>(value.as_int64()));
return false;
}
if (path.rfind("tencent_tmt.", 0) == 0)
{
const json::value &value = data.at("value");
return value.is_string() && SetConfiguredTencentTmtString(
path.substr(std::string("tencent_tmt.").size()), json::value_to<std::string>(value));
return value.is_string() && SetConfiguredTencentTmtString(path.substr(std::string("tencent_tmt.").size()),
json::value_to<std::string>(value));
}
if (path == "helpcode.show_sp_helpcode_in_candidate_window")
return SetConfiguredShowShuangpinHelpcodeInCandidateWindow(json::value_to<bool>(data.at("value")));
@@ -683,20 +706,29 @@ bool ApplyConfigUpdate(const json::object &data)
int HitTestName(const std::string &name)
{
if (name == "left") return HTLEFT;
if (name == "right") return HTRIGHT;
if (name == "top") return HTTOP;
if (name == "bottom") return HTBOTTOM;
if (name == "left-top") return HTTOPLEFT;
if (name == "right-top") return HTTOPRIGHT;
if (name == "left-bottom") return HTBOTTOMLEFT;
if (name == "right-bottom") return HTBOTTOMRIGHT;
if (name == "left")
return HTLEFT;
if (name == "right")
return HTRIGHT;
if (name == "top")
return HTTOP;
if (name == "bottom")
return HTBOTTOM;
if (name == "left-top")
return HTTOPLEFT;
if (name == "right-top")
return HTTOPRIGHT;
if (name == "left-bottom")
return HTBOTTOMLEFT;
if (name == "right-bottom")
return HTBOTTOMRIGHT;
return HTCLIENT;
}
bool CopyTextToClipboard(HWND hwnd, const std::wstring &text)
{
if (!OpenClipboard(hwnd)) return false;
if (!OpenClipboard(hwnd))
return false;
EmptyClipboard();
const SIZE_T bytes = (text.size() + 1) * sizeof(wchar_t);
@@ -730,7 +762,8 @@ bool CopyTextToClipboard(HWND hwnd, const std::wstring &text)
void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
{
if (g_closing || !g_worker) return;
if (g_closing || !g_worker)
return;
wil::unique_cotaskmem_string raw;
if (FAILED(args->TryGetWebMessageAsString(&raw)) || !raw)
return;
@@ -762,10 +795,14 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
else if (type == "windowControl")
{
const std::string command = json::value_to<std::string>(value.at("data"));
if (command == "minimize") ShowWindow(hwnd, SW_MINIMIZE);
else if (command == "maximize") ShowWindow(hwnd, SW_MAXIMIZE);
else if (command == "restore") ShowWindow(hwnd, SW_RESTORE);
else if (command == "close") CloseSettings(hwnd);
if (command == "minimize")
ShowWindow(hwnd, SW_MINIMIZE);
else if (command == "maximize")
ShowWindow(hwnd, SW_MAXIMIZE);
else if (command == "restore")
ShowWindow(hwnd, SW_RESTORE);
else if (command == "close")
CloseSettings(hwnd);
}
else if (type == "maximizeButtonRect")
{
@@ -773,9 +810,11 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
const double x = data.if_contains("x") ? json::value_to<double>(*data.if_contains("x")) : 0.0;
const double y = data.if_contains("y") ? json::value_to<double>(*data.if_contains("y")) : 0.0;
const double width = data.if_contains("width") ? json::value_to<double>(*data.if_contains("width")) : 0.0;
const double height = data.if_contains("height") ? json::value_to<double>(*data.if_contains("height")) : 0.0;
const double height =
data.if_contains("height") ? json::value_to<double>(*data.if_contains("height")) : 0.0;
double scale = data.if_contains("dpr") ? json::value_to<double>(*data.if_contains("dpr")) : 0.0;
if (scale <= 0.0) scale = static_cast<double>(GetDpiForWindow(hwnd)) / 96.0;
if (scale <= 0.0)
scale = static_cast<double>(GetDpiForWindow(hwnd)) / 96.0;
g_maximize_button_rect = {static_cast<LONG>(std::lround(x * scale)),
static_cast<LONG>(std::lround(y * scale)),
static_cast<LONG>(std::lround((x + width) * scale)),
@@ -799,11 +838,12 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
}
else if (type == "openSkinDirectory")
{
const std::filesystem::path skins = std::filesystem::path(CommonUtils::get_local_appdata_path_w()) /
GlobalIme::AppName / L"skins";
const std::filesystem::path skins =
std::filesystem::path(CommonUtils::get_local_appdata_path_w()) / GlobalIme::AppName / L"skins";
std::error_code ec;
std::filesystem::create_directories(skins, ec);
if (!ec) ShellExecuteW(hwnd, L"open", skins.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
if (!ec)
ShellExecuteW(hwnd, L"open", skins.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
}
else if (type == "configUpdate")
{
@@ -813,7 +853,8 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
auto completion = ConfigCompletion();
return [saved, completion = std::move(completion)] {
completion();
if (!saved) MessageBoxW(g_settings_hwnd, L"设置保存失败,请重试。", L"水杉输入法", MB_OK | MB_ICONWARNING);
if (!saved)
MessageBoxW(g_settings_hwnd, L"设置保存失败,请重试。", L"水杉输入法", MB_OK | MB_ICONWARNING);
};
});
}
@@ -840,8 +881,15 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
const auto data = value.at("data").as_object();
g_worker->Submit([data]() -> SerialTaskQueue::Completion {
json::value response;
try { response = SettingsDictionary::HandleRequest(data); }
catch (...) { response = json::object{{"ok", false}, {"message", "词库操作失败,请重试"}, {"rows", json::array{}}}; }
try
{
response = SettingsDictionary::HandleRequest(data);
}
catch (...)
{
response =
json::object{{"ok", false}, {"message", "词库操作失败,请重试"}, {"rows", json::array{}}};
}
auto &object = response.as_object();
object["type"] = "dictionaryResponse";
object["requestId"] = data.at("requestId");
@@ -852,7 +900,8 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
throw std::runtime_error("Invalid dictionary response");
auto message = string_to_wstring(json::serialize(response));
return [message = std::move(message)] {
if (g_webview) g_webview->PostWebMessageAsJson(message.c_str());
if (g_webview)
g_webview->PostWebMessageAsJson(message.c_str());
};
});
}
@@ -866,14 +915,18 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
HRESULT EnsureCompositionTree(HWND hwnd)
{
HRESULT hr = DCompositionCreateDevice(nullptr, __uuidof(IDCompositionDevice),
reinterpret_cast<void **>(g_dcomp_device.GetAddressOf()));
if (FAILED(hr)) return hr;
reinterpret_cast<void **>(g_dcomp_device.GetAddressOf()));
if (FAILED(hr))
return hr;
hr = g_dcomp_device->CreateTargetForHwnd(hwnd, TRUE, &g_dcomp_target);
if (FAILED(hr)) return hr;
if (FAILED(hr))
return hr;
hr = g_dcomp_device->CreateVisual(&g_dcomp_root);
if (FAILED(hr)) return hr;
if (FAILED(hr))
return hr;
hr = g_dcomp_target->SetRoot(g_dcomp_root.Get());
if (FAILED(hr)) return hr;
if (FAILED(hr))
return hr;
return g_dcomp_device->Commit();
}
@@ -885,8 +938,10 @@ HRESULT OnControllerCreated(HWND hwnd, HRESULT result, ICoreWebView2CompositionC
return FAILED(result) ? result : E_FAIL;
}
g_composition_controller = controller;
if (FAILED(g_composition_controller.As(&g_controller))) return E_NOINTERFACE;
if (FAILED(g_controller->get_CoreWebView2(&g_webview)) || !g_webview) return E_FAIL;
if (FAILED(g_composition_controller.As(&g_controller)))
return E_NOINTERFACE;
if (FAILED(g_controller->get_CoreWebView2(&g_webview)) || !g_webview)
return E_FAIL;
ComPtr<ICoreWebView2Settings> settings;
if (SUCCEEDED(g_webview->get_Settings(&settings)))
@@ -905,13 +960,13 @@ HRESULT OnControllerCreated(HWND hwnd, HRESULT result, ICoreWebView2CompositionC
const std::filesystem::path assets = std::filesystem::path(CommonUtils::get_local_appdata_path_w()) /
GlobalIme::AppName / "html/webview2/settings/ime-settings/dist";
g_webview3->SetVirtualHostNameToFolderMapping(L"imesettings", assets.c_str(),
COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW);
const std::filesystem::path skins = std::filesystem::path(CommonUtils::get_local_appdata_path_w()) /
GlobalIme::AppName / L"skins";
COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW);
const std::filesystem::path skins =
std::filesystem::path(CommonUtils::get_local_appdata_path_w()) / GlobalIme::AppName / L"skins";
std::error_code ec;
std::filesystem::create_directories(skins, ec);
g_webview3->SetVirtualHostNameToFolderMapping(L"candidate-skins", skins.c_str(),
COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW);
COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW);
}
if (SUCCEEDED(g_controller.As(&g_controller2)))
{
@@ -919,9 +974,11 @@ HRESULT OnControllerCreated(HWND hwnd, HRESULT result, ICoreWebView2CompositionC
}
HRESULT hr = EnsureCompositionTree(hwnd);
if (FAILED(hr)) return hr;
if (FAILED(hr))
return hr;
hr = g_composition_controller->put_RootVisualTarget(g_dcomp_root.Get());
if (FAILED(hr)) return hr;
if (FAILED(hr))
return hr;
g_dcomp_device->Commit();
RECT bounds{};
@@ -930,41 +987,43 @@ HRESULT OnControllerCreated(HWND hwnd, HRESULT result, ICoreWebView2CompositionC
EventRegistrationToken token{};
g_webview->add_ContentLoading(
Callback<ICoreWebView2ContentLoadingEventHandler>(
[](ICoreWebView2 *, ICoreWebView2ContentLoadingEventArgs *) -> HRESULT {
g_last_config_message.clear();
g_webview_content_started = true;
SettingsSplash::Dismiss();
return S_OK;
}).Get(),
Callback<ICoreWebView2ContentLoadingEventHandler>([](ICoreWebView2 *,
ICoreWebView2ContentLoadingEventArgs *) -> HRESULT {
g_last_config_message.clear();
g_webview_content_started = true;
SettingsSplash::Dismiss();
return S_OK;
}).Get(),
&token);
g_webview->add_NavigationCompleted(
Callback<ICoreWebView2NavigationCompletedEventHandler>(
[hwnd](ICoreWebView2 *, ICoreWebView2NavigationCompletedEventArgs *args) -> HRESULT {
BOOL success = FALSE;
args->get_IsSuccess(&success);
SettingsSplash::Dismiss();
if (success)
Callback<ICoreWebView2NavigationCompletedEventHandler>([hwnd](ICoreWebView2 *,
ICoreWebView2NavigationCompletedEventArgs *args)
-> HRESULT {
BOOL success = FALSE;
args->get_IsSuccess(&success);
SettingsSplash::Dismiss();
if (success)
{
PostWindowState(hwnd);
PostConfig();
ApplyWindowActivationAppearance();
SetFocus(hwnd);
if (g_open_about_on_ready)
{
PostWindowState(hwnd);
PostConfig();
ApplyWindowActivationAppearance();
SetFocus(hwnd);
if (g_open_about_on_ready)
{
ShowAboutSection();
g_open_about_on_ready = false;
}
ShowAboutSection();
g_open_about_on_ready = false;
}
return S_OK;
}).Get(),
}
return S_OK;
}).Get(),
&token);
g_webview->add_WebMessageReceived(
Callback<ICoreWebView2WebMessageReceivedEventHandler>(
[hwnd](ICoreWebView2 *, ICoreWebView2WebMessageReceivedEventArgs *args) -> HRESULT {
HandleWebMessage(hwnd, args);
return S_OK;
}).Get(),
Callback<ICoreWebView2WebMessageReceivedEventHandler>([hwnd](ICoreWebView2 *,
ICoreWebView2WebMessageReceivedEventArgs *args)
-> HRESULT {
HandleWebMessage(hwnd, args);
return S_OK;
}).Get(),
&token);
return g_webview->Navigate(L"https://imesettings/index.html");
}
@@ -975,13 +1034,12 @@ void InitWebView(HWND hwnd)
std::error_code ec;
std::filesystem::create_directories(user_data, ec);
auto options = Make<CoreWebView2EnvironmentOptions>();
options->put_AdditionalBrowserArguments(
L"--disable-features=TranslateUI "
L"--disable-background-networking "
L"--disable-default-apps "
L"--disable-sync "
L"--disable-prompt-on-repost "
L"--no-first-run");
options->put_AdditionalBrowserArguments(L"--disable-features=TranslateUI "
L"--disable-background-networking "
L"--disable-default-apps "
L"--disable-sync "
L"--disable-prompt-on-repost "
L"--no-first-run");
CreateCoreWebView2EnvironmentWithOptions(
nullptr, user_data.c_str(), options.Get(),
Callback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>(
@@ -1002,63 +1060,114 @@ void InitWebView(HWND hwnd)
Callback<ICoreWebView2CreateCoreWebView2CompositionControllerCompletedHandler>(
[hwnd](HRESULT controller_result, ICoreWebView2CompositionController *controller) -> HRESULT {
return OnControllerCreated(hwnd, controller_result, controller);
}).Get());
}).Get());
})
.Get());
})
.Get());
}
UINT32 MouseKeys(WPARAM w_param)
{
UINT32 keys = COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_NONE;
if (w_param & MK_LBUTTON) keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_LEFT_BUTTON;
if (w_param & MK_RBUTTON) keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_RIGHT_BUTTON;
if (w_param & MK_MBUTTON) keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_MIDDLE_BUTTON;
if (w_param & MK_SHIFT) keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_SHIFT;
if (w_param & MK_CONTROL) keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_CONTROL;
if (w_param & MK_XBUTTON1) keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_X_BUTTON1;
if (w_param & MK_XBUTTON2) keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_X_BUTTON2;
if (w_param & MK_LBUTTON)
keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_LEFT_BUTTON;
if (w_param & MK_RBUTTON)
keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_RIGHT_BUTTON;
if (w_param & MK_MBUTTON)
keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_MIDDLE_BUTTON;
if (w_param & MK_SHIFT)
keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_SHIFT;
if (w_param & MK_CONTROL)
keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_CONTROL;
if (w_param & MK_XBUTTON1)
keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_X_BUTTON1;
if (w_param & MK_XBUTTON2)
keys |= COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS_X_BUTTON2;
return keys;
}
bool ForwardMouse(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_param)
{
if (!g_composition_controller) return false;
if (!g_composition_controller)
return false;
COREWEBVIEW2_MOUSE_EVENT_KIND kind{};
UINT32 data = 0;
switch (message)
{
case WM_MOUSEMOVE: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_MOVE; break;
case WM_LBUTTONDOWN: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_DOWN; SetFocus(hwnd); break;
case WM_LBUTTONUP: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_UP; break;
case WM_LBUTTONDBLCLK: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_DOUBLE_CLICK; break;
case WM_RBUTTONDOWN: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_RIGHT_BUTTON_DOWN; break;
case WM_RBUTTONUP: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_RIGHT_BUTTON_UP; break;
case WM_RBUTTONDBLCLK: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_RIGHT_BUTTON_DOUBLE_CLICK; break;
case WM_MBUTTONDOWN: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_MIDDLE_BUTTON_DOWN; break;
case WM_MBUTTONUP: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_MIDDLE_BUTTON_UP; break;
case WM_MBUTTONDBLCLK: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_MIDDLE_BUTTON_DOUBLE_CLICK; break;
case WM_XBUTTONDOWN: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_X_BUTTON_DOWN; data = GET_XBUTTON_WPARAM(w_param); break;
case WM_XBUTTONUP: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_X_BUTTON_UP; data = GET_XBUTTON_WPARAM(w_param); break;
case WM_XBUTTONDBLCLK: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_X_BUTTON_DOUBLE_CLICK; data = GET_XBUTTON_WPARAM(w_param); break;
case WM_MOUSEWHEEL: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_WHEEL; data = GET_WHEEL_DELTA_WPARAM(w_param); break;
case WM_MOUSEHWHEEL: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_HORIZONTAL_WHEEL; data = GET_WHEEL_DELTA_WPARAM(w_param); break;
case WM_MOUSELEAVE: kind = COREWEBVIEW2_MOUSE_EVENT_KIND_LEAVE; break;
default: return false;
case WM_MOUSEMOVE:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_MOVE;
break;
case WM_LBUTTONDOWN:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_DOWN;
SetFocus(hwnd);
break;
case WM_LBUTTONUP:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_UP;
break;
case WM_LBUTTONDBLCLK:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_LEFT_BUTTON_DOUBLE_CLICK;
break;
case WM_RBUTTONDOWN:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_RIGHT_BUTTON_DOWN;
break;
case WM_RBUTTONUP:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_RIGHT_BUTTON_UP;
break;
case WM_RBUTTONDBLCLK:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_RIGHT_BUTTON_DOUBLE_CLICK;
break;
case WM_MBUTTONDOWN:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_MIDDLE_BUTTON_DOWN;
break;
case WM_MBUTTONUP:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_MIDDLE_BUTTON_UP;
break;
case WM_MBUTTONDBLCLK:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_MIDDLE_BUTTON_DOUBLE_CLICK;
break;
case WM_XBUTTONDOWN:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_X_BUTTON_DOWN;
data = GET_XBUTTON_WPARAM(w_param);
break;
case WM_XBUTTONUP:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_X_BUTTON_UP;
data = GET_XBUTTON_WPARAM(w_param);
break;
case WM_XBUTTONDBLCLK:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_X_BUTTON_DOUBLE_CLICK;
data = GET_XBUTTON_WPARAM(w_param);
break;
case WM_MOUSEWHEEL:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_WHEEL;
data = GET_WHEEL_DELTA_WPARAM(w_param);
break;
case WM_MOUSEHWHEEL:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_HORIZONTAL_WHEEL;
data = GET_WHEEL_DELTA_WPARAM(w_param);
break;
case WM_MOUSELEAVE:
kind = COREWEBVIEW2_MOUSE_EVENT_KIND_LEAVE;
break;
default:
return false;
}
POINT point{GET_X_LPARAM(l_param), GET_Y_LPARAM(l_param)};
if (message == WM_MOUSEWHEEL || message == WM_MOUSEHWHEEL) ScreenToClient(hwnd, &point);
if (message == WM_MOUSEWHEEL || message == WM_MOUSEHWHEEL)
ScreenToClient(hwnd, &point);
if (message == WM_MOUSEMOVE)
{
TRACKMOUSEEVENT track{sizeof(track), TME_LEAVE, hwnd, 0};
TrackMouseEvent(&track);
}
g_composition_controller->SendMouseInput(kind,
static_cast<COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS>(MouseKeys(w_param)), data, point);
g_composition_controller->SendMouseInput(
kind, static_cast<COREWEBVIEW2_MOUSE_EVENT_VIRTUAL_KEYS>(MouseKeys(w_param)), data, point);
return true;
}
bool IsMaximizeButton(HWND hwnd, POINT screen_point)
{
if (!g_has_maximize_button_rect) return false;
if (!g_has_maximize_button_rect)
return false;
ScreenToClient(hwnd, &screen_point);
return PtInRect(&g_maximize_button_rect, screen_point) != FALSE;
}
@@ -1077,22 +1186,28 @@ int TopNonClientInset(HWND hwnd)
void ActivateWindow(HWND hwnd)
{
if (g_closing) return;
if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE);
else ShowWindow(hwnd, SW_SHOW);
if (g_closing)
return;
if (IsIconic(hwnd))
ShowWindow(hwnd, SW_RESTORE);
else
ShowWindow(hwnd, SW_SHOW);
SetForegroundWindow(hwnd);
SetFocus(hwnd);
}
void CloseSettings(HWND hwnd)
{
if (g_closing) return;
if (g_closing)
return;
g_closing = true;
KillTimer(hwnd, kConfigReloadTimer);
SettingsSplash::Dismiss();
ShowWindow(hwnd, SW_HIDE);
if (g_worker) g_worker->Stop();
else DestroyWindow(hwnd);
if (g_worker)
g_worker->Stop();
else
DestroyWindow(hwnd);
}
LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_param)
@@ -1118,22 +1233,23 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
return result;
}
break;
case WM_NCHITTEST:
{
case WM_NCHITTEST: {
const LRESULT result = DefWindowProcW(hwnd, message, w_param, l_param);
POINT point{GET_X_LPARAM(l_param), GET_Y_LPARAM(l_param)};
return result == HTCLIENT && IsMaximizeButton(hwnd, point) ? HTMAXBUTTON : result;
}
case WM_MOVE:
case WM_MOVING:
if (g_controller) g_controller->NotifyParentWindowPositionChanged();
if (SettingsSplash::IsVisible()) SettingsSplash::SyncToOwner();
if (g_controller)
g_controller->NotifyParentWindowPositionChanged();
if (SettingsSplash::IsVisible())
SettingsSplash::SyncToOwner();
break;
case WM_SETFOCUS:
if (g_controller) g_controller->MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC);
if (g_controller)
g_controller->MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC);
break;
case WM_ACTIVATE:
{
case WM_ACTIVATE: {
const bool active = LOWORD(w_param) != WA_INACTIVE;
if (g_window_active != active)
{
@@ -1150,12 +1266,22 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
}
break;
}
case WM_MOUSEMOVE: case WM_MOUSELEAVE:
case WM_LBUTTONDOWN: case WM_LBUTTONUP: case WM_LBUTTONDBLCLK:
case WM_RBUTTONDOWN: case WM_RBUTTONUP: case WM_RBUTTONDBLCLK:
case WM_MBUTTONDOWN: case WM_MBUTTONUP: case WM_MBUTTONDBLCLK:
case WM_XBUTTONDOWN: case WM_XBUTTONUP: case WM_XBUTTONDBLCLK:
case WM_MOUSEWHEEL: case WM_MOUSEHWHEEL:
case WM_MOUSEMOVE:
case WM_MOUSELEAVE:
case WM_LBUTTONDOWN:
case WM_LBUTTONUP:
case WM_LBUTTONDBLCLK:
case WM_RBUTTONDOWN:
case WM_RBUTTONUP:
case WM_RBUTTONDBLCLK:
case WM_MBUTTONDOWN:
case WM_MBUTTONUP:
case WM_MBUTTONDBLCLK:
case WM_XBUTTONDOWN:
case WM_XBUTTONUP:
case WM_XBUTTONDBLCLK:
case WM_MOUSEWHEEL:
case WM_MOUSEHWHEEL:
if (ForwardMouse(hwnd, message, w_param, l_param))
return (message == WM_XBUTTONDOWN || message == WM_XBUTTONUP || message == WM_XBUTTONDBLCLK) ? TRUE : 0;
break;
@@ -1170,12 +1296,15 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
}
}
break;
case WM_NCMOUSEMOVE:
{
case WM_NCMOUSEMOVE: {
POINT point{GET_X_LPARAM(l_param), GET_Y_LPARAM(l_param)};
if (IsMaximizeButton(hwnd, point))
{
if (!g_maximize_button_hover) { g_maximize_button_hover = true; PostMaximizeButtonEvent("enter"); }
if (!g_maximize_button_hover)
{
g_maximize_button_hover = true;
PostMaximizeButtonEvent("enter");
}
TRACKMOUSEEVENT track{sizeof(track), TME_LEAVE | TME_NONCLIENT, hwnd, 0};
TrackMouseEvent(&track);
return 0;
@@ -1183,11 +1312,14 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
break;
}
case WM_NCMOUSELEAVE:
if (g_maximize_button_hover) { g_maximize_button_hover = false; PostMaximizeButtonEvent("leave"); }
if (g_maximize_button_hover)
{
g_maximize_button_hover = false;
PostMaximizeButtonEvent("leave");
}
break;
case WM_NCLBUTTONDOWN:
case WM_NCLBUTTONUP:
{
case WM_NCLBUTTONUP: {
POINT point{GET_X_LPARAM(l_param), GET_Y_LPARAM(l_param)};
if (IsMaximizeButton(hwnd, point))
{
@@ -1196,8 +1328,7 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
}
break;
}
case WM_ERASEBKGND:
{
case WM_ERASEBKGND: {
RECT client{};
GetClientRect(hwnd, &client);
const bool light = g_settings_light;
@@ -1206,15 +1337,13 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
DeleteObject(brush);
return 1;
}
case WM_GETMINMAXINFO:
{
case WM_GETMINMAXINFO: {
auto *info = reinterpret_cast<MINMAXINFO *>(l_param);
const UINT dpi = GetDpiForWindow(hwnd);
info->ptMinTrackSize = {MulDiv(600, dpi, 96), MulDiv(400, dpi, 96)};
return 0;
}
case WM_SIZE:
{
case WM_SIZE: {
const bool minimized = w_param == SIZE_MINIMIZED;
if (minimized || g_window_minimized)
ResetTitlebarHoverAfterVisibilityChange();
@@ -1226,7 +1355,8 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
g_controller->put_Bounds(bounds);
PostWindowState(hwnd);
}
if (SettingsSplash::IsVisible()) SettingsSplash::SyncToOwner();
if (SettingsSplash::IsVisible())
SettingsSplash::SyncToOwner();
break;
}
case WM_TIMER:
@@ -1235,11 +1365,18 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
g_reload_pending = true;
g_worker->Submit([] {
SerialTaskQueue::Completion completion;
try { if (ReloadImeConfigIfChanged()) completion = ConfigCompletion(); }
catch (...) { /* Retry on the next timer tick; do not poison the queue. */ }
try
{
if (ReloadImeConfigIfChanged())
completion = ConfigCompletion();
}
catch (...)
{ /* Retry on the next timer tick; do not poison the queue. */
}
return [completion = std::move(completion)] {
g_reload_pending = false;
if (completion) completion();
if (completion)
completion();
};
});
}
@@ -1248,7 +1385,8 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
if (g_worker)
{
g_worker->Drain(!g_closing);
if (g_closing && g_worker->Stopped()) DestroyWindow(hwnd);
if (g_closing && g_worker->Stopped())
DestroyWindow(hwnd);
}
return 0;
case WM_CLOSE:
@@ -1256,7 +1394,8 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
return 0;
case WM_DESTROY:
g_closing = true;
if (g_worker) g_worker->Stop();
if (g_worker)
g_worker->Stop();
KillTimer(hwnd, kConfigReloadTimer);
SettingsSplash::Dismiss();
g_webview.Reset();
@@ -1278,10 +1417,12 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_
g_open_about_on_ready = command_line && std::wstring(command_line).find(L"--about") != std::wstring::npos;
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
const HRESULT com_result = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
if (FAILED(com_result)) return 1;
if (FAILED(com_result))
return 1;
CommonUtils::SingleInstanceGuard single_instance(kSingleInstanceMutex);
if (!single_instance.is_valid()) return 1;
if (!single_instance.is_valid())
return 1;
if (single_instance.already_running())
{
HWND existing = FindWindowW(kWindowClass, nullptr);
@@ -1302,20 +1443,26 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_
window_class.hCursor = LoadCursorW(nullptr, IDC_ARROW);
window_class.hbrBackground = reinterpret_cast<HBRUSH>(COLOR_WINDOW + 1);
window_class.lpszClassName = kWindowClass;
if (!RegisterClassExW(&window_class)) return 1;
if (!RegisterClassExW(&window_class))
return 1;
const UINT dpi = GetDpiForSystem();
const int width = MulDiv(900, dpi, 96);
const int height = MulDiv(710, dpi, 96);
const int x = (GetSystemMetrics(SM_CXSCREEN) - width) / 2;
const int y = (GetSystemMetrics(SM_CYSCREEN) - height) / 2;
HWND hwnd = CreateWindowExW(0, kWindowClass, kWindowTitle, WS_OVERLAPPEDWINDOW,
x, y, width, height, nullptr, nullptr, instance, nullptr);
if (!hwnd) return 1;
HWND hwnd = CreateWindowExW(0, kWindowClass, kWindowTitle, WS_OVERLAPPEDWINDOW, x, y, width, height, nullptr,
nullptr, instance, nullptr);
if (!hwnd)
return 1;
g_settings_hwnd = hwnd;
g_worker = std::make_unique<SerialTaskQueue>([hwnd] { PostMessageW(hwnd, kWorkerCompleted, 0, 0); },
g_worker = std::make_unique<SerialTaskQueue>(
[hwnd] { PostMessageW(hwnd, kWorkerCompleted, 0, 0); },
[] { MessageBoxW(g_settings_hwnd, L"设置操作失败,请重试。", L"水杉输入法", MB_OK | MB_ICONWARNING); },
[] { if (g_worker_com_initialized) CoUninitialize(); });
[] {
if (g_worker_com_initialized)
CoUninitialize();
});
g_worker->Submit([] {
g_worker_com_initialized = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
InitImeConfig();
@@ -1333,8 +1480,7 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_
// CreateWindow sends the initial WM_NCCALCSIZE before the DWM/custom-frame
// attributes above are installed. Force a second frame calculation now;
// otherwise the native caption remains until the first minimize/restore.
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
SetTimer(hwnd, kConfigReloadTimer, 300, nullptr);
InitWebView(hwnd);
+4 -4
View File
@@ -32,8 +32,7 @@ bool OpenSiblingApplication(const wchar_t *executable_name, const wchar_t *windo
}
module_path.resize(length);
const std::filesystem::path application_path =
std::filesystem::path(module_path).parent_path() / executable_name;
const std::filesystem::path application_path = std::filesystem::path(module_path).parent_path() / executable_name;
const HINSTANCE result = ShellExecuteW(nullptr, L"open", application_path.c_str(), nullptr,
application_path.parent_path().c_str(), SW_SHOWNORMAL);
return reinterpret_cast<INT_PTR>(result) > 32;
@@ -44,7 +43,7 @@ bool CloseApplication(const wchar_t *window_class)
const HWND application_window = FindWindowW(window_class, nullptr);
return !application_window || PostMessageW(application_window, WM_CLOSE, 0, 0) != FALSE;
}
}
} // namespace
bool OpenSettingsApplication()
{
@@ -62,7 +61,8 @@ bool OpenSettingsAboutApplication()
std::wstring module_path(32768, L'\0');
const DWORD length = GetModuleFileNameW(nullptr, module_path.data(), static_cast<DWORD>(module_path.size()));
if (length == 0 || length >= module_path.size()) return false;
if (length == 0 || length >= module_path.size())
return false;
module_path.resize(length);
const std::filesystem::path application_path =
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff. Show more