A contributor who only wants to try a change should not have to install vcpkg, Boost, Inno Setup and the Windows SDK. The Dev build workflow builds, packages and signs on the runner, and the artifact is a full installer plus its signing certificate. Three ways in: a feature-branch push builds automatically; a pull_request run gives reviewers and fork contributors an installer from the check page (pull_request deliberately, not pull_request_target -- building PR code executes scripts from that code, and pull_request_target would hand those scripts a writable token); workflow_dispatch stays for picking a single component or rebuilding without a push. develop, main and release-please maintenance branches never trigger it. The certificate is minted per run by installer/sign-dev-build.ps1 with the fixed subject CN=Metasequoia IME Dev Build Code Signing; the private key lives only in the runner's memory. installer/import-dev-trust.ps1 ships in the artifact for the user side: remove the previous certificate by subject, import the new one, so repeated builds do not grow the trust stores. The workflow reuses ci.yml's vcpkg cache scopes and sccache, downloads the locked dictionaries, embeds the uiAccess manifest and passes the dev version through MSIME_PRODUCT_VERSION_OVERRIDE so the about page shows the full dev string. Artifacts expire in 7 days. ci.yml remains the merge gate; nothing here creates a release or touches the update manifest.
18 KiB
AGENTS.md — 水杉输入法 Windows 端
组织级边界与跨平台规则见 组织 AGENTS.md。本文件规定本仓的目录职责、组件之间的边界,以及产品级的构建与发布约定。
本仓默认分支是 develop,日常改动从 develop 切分支并合回 develop;main 是发布分支,只在发版时由维护者从 develop 合入,release.yml 也只监听 main。特性分支直接提到 main 会被 Branch guard 拦下。规则见组织 AGENTS.md 的分支模型。
Windows 端的全部一方源码在本仓。合仓改变的是仓库数量,不是运行时结构:DLL 与 Server 仍是两个进程、隔着版本化的命名管道;ui/ 仍是不依赖输入法业务的通用 GUI 库。不要因为它们现在在同一棵树里,就在组件之间直接互相 include 或共享全局状态。
仓库地图
| 目录 | 职责 | 本目录的规则 |
|---|---|---|
windows/ |
TSF 文本服务 DLL:按键预判、焦点、edit session、管道客户端 | windows/AGENTS.md |
server/ |
常驻后端:候选状态、配置、词库、管道服务,以及候选窗、悬浮工具栏、托盘菜单和设置窗口(均由 msimeui 以 Direct2D 绘制) |
server/AGENTS.md |
engine/ |
输入引擎:输入会话、候选查询、辅助码、跨进程契约、语音模块 | engine/AGENTS.md |
ui/ |
通用 Win32 / Direct2D / DirectWrite 控件库 msimeui |
ui/AGENTS.md |
installer/ |
收集产物、自签名、Inno Setup 打包 | installer/README.md |
log/ |
日志采集库 | — |
skins/ |
外部皮肤合集,每个子目录一个 skin.toml 皮肤包;除素材授权未核实的外随安装包装到数据目录,同名且改过的旧版改名 .bak 保留 |
skins/README.md |
examples/skin-examples/ |
外部皮肤制作样例、字段说明、预览页与安装脚本;本目录单独采用 MIT 授权 | examples/skin-examples/README.md |
experiments/tsf-edit-control/ |
TSF 编辑控件实验工程,不参与产品构建 | — |
vendor/ |
submodule:opencc、cpp-pinyin |
上游仓库 |
scripts/、tests/、docs/ |
产品级构建与发布脚本、组合验证、产品文档 | 本文件 |
改一个组件之前先确认它有没有自己的 AGENTS.md,那份比本文件更具体。
组件之间的边界
- 协议的唯一来源是
engine/contracts/。IPC 线格式、opcode、语音分帧和设置窗口的消息定义(engine/contracts/webview/,名字是历史遗留)都在那里,windows/和server/各自引用同一份头文件。不要在任何一侧重新定义或复制一份。 ui/不许反向依赖产品。它不读 Server 配置、IPC、引擎、词库或全局输入状态;业务通过数据和回调接入。ui/scripts/check-boundary.py在 CI 里检查已知的反向依赖,新增依赖会红。- 界面只有 Direct2D 一套。候选窗、悬浮工具栏、托盘菜单的 HWND、尺寸、位置、DPI、Z-order 和绘制在
server/src/window/(presenter 用ui/的msimeui控件);设置窗口是独立进程server/src/settings/,界面在server/src/settings/native/,用到的图片资源在server/assets/settings/,安装到数据目录的assets\settings\。WebView2 与ui-html/已移除,不要再引入浏览器内核或网页前端。 - 候选与输入状态的权威在 Server 和引擎,界面只负责展示和发出用户动作,不要在 presenter 或设置窗口里复制状态机。
- 新增配置项必须同时写进出厂模板
installer/default_config/config.default.toml。 这个文件是安装包真正打进去的那一份,Server 升级时用MergeConfigIntoTemplate以它为骨架重建用户配置:模板里没有的键会被丢弃。只在ime_config.cpp里加读取和默认值,代码本身能跑,但用户在设置页改过的值会在下次升级时静默消失,而且没有任何编译期或运行期报错。同一个提交里要一并更新的还有server/assets/config/config.toml(开发和 CI 测试用的工作配置),以及设置页的配置读写映射——服务端进程内已不再内嵌设置宿主,读写映射只剩独立设置进程server/src/settings/settings_app.cpp一处,其中BuildConfigMessage里configSnapshot的字段列表和ApplyConfigUpdate里path == "..."的分发两边都要加,漏掉任一边的表现是「设置页能点,重开就回退」。 engine/是本仓的一等代码,不是 vendored 第三方。 需要为 Windows 改引擎就直接改,和改server/一样评审和测试,不用绕上游。唯一例外是engine/下的ngram/kenlm/、ngram/octagram/darts.h、utfcpp/和voice/third_party/,那几个是上游副本,保留原格式与许可;darts.h按精确路径单独排除,同目录的octagram_gram.{h,cpp}是我们的代码,仍受格式门禁管。googlepinyinime-rev/已与上游解绑,可以直接改,但保留它原有的格式与许可,本地改动记在 UPSTREAM.md。来源与裁剪清单见 engine/UPSTREAM.md。
Server 当前仍使用引擎的兼容 InputSession,尚未迁移公共 Session facade。
辅助码筛选与候选提示都按会话配置,禁止用全局默认码表驱动活动会话。
RuntimePaths::legacy() 在适配器创建时捕获现有安装布局;完整资源包和用户数据代际切换仍待接入。
构建
用户想编译验证但没装工具链时,优先用 dev-build workflow 而不是让用户装 vcpkg:特性分支 push 自动触发,PR 会自动跑一次,或 gh workflow run dev-build.yml --ref <分支> -f component=all 手动触发,然后 gh run watch 等它,gh run download <runId> -n dev-installer 拿安装包与证书,安装步骤见 CONTRIBUTING.md。develop 和 main 不触发;版本号带 -dev.<日期>.<commit>,与正式发布隔离。
每个组件是独立的 CMake 工程,各自带 vcpkg.json 和 preset,从仓库根目录指定 -S:
cmake -S windows -B windows/build -A x64 ... # TSF DLL
cmake -S server -B server/build-release -A x64 ... # Server
cmake -S ui -B ui/build -A x64 # GUI 框架
没有顶层聚合的 CMakeLists.txt,这是有意的:Windows tip 用静态 CRT、Server 用动态 CRT,两者的 vcpkg manifest 和编译选项互不兼容,合成一个 build tree 会互相污染。server/ 通过 add_subdirectory 把 ../ui 的 msimeui 和 ../engine 拉进自己的构建,这是仅有的跨组件构建引用。
引擎不需要任何初始化步骤,普通 clone 即可;vendor/ 下的 opencc 和 cpp-pinyin 仍是 submodule,构建 windows/ 或 server/ 前先 git submodule update --init --recursive。
引擎头文件的引用方式两侧不同:Server 按 engine/... 前缀引用(#include "engine/core/input_session.h"),因为 server/CMakeLists.txt 把仓库根放进了 include path,不要用相对路径回跳,那会把调用方绑死在自己的目录深度上;TSF DLL(windows/)没有仓库根(include path 只有 ./src/* 平铺项),契约头用相对路径 ../../../engine/contracts/... 引用——在 windows/ 里写 engine/... 前缀编不过。
scripts/format.sh 覆盖 server/、windows/、ui/、log/ 和 engine/(排除引擎里的第三方副本和生成的头文件),CI 用 --check 卡格式。
格式化会卡 CI,提交前务必自查,注意两个坑:
-
版本必须是 clang-format
18.1.8。format.sh把版本钉死在这个值并从 PyPI 装,不同版本的默认排版不一致,用系统自带的 clang-format 本地过了、CI 仍可能红。quality.yml的Check formatting步骤只跑 C++(server/windows/ui/log/engine)。 -
format.sh在 Windows 上跑不起来:它假设 venv 是 Unix 布局(bin/pip、bin/clang-format),而 Windows 的python -m venv生成的是Scripts/,脚本会以No such file or directory退出。在 Windows 上改了 C++ 就直接调 18.1.8 的 clang-format 自查,绕过这个脚本:# 确认版本是 18.1.8(可用 pip 装:pip install clang-format==18.1.8) clang-format --version # 对本次改动的 C++ 文件做等价于 CI 的 --check clang-format --dry-run --Werror --style=file <改动的 .cpp/.h 文件...> # 要就地修复去掉 --dry-run --Werror,加 -i--style=file会就近找每个子树自己的.clang-format(server/、engine/voice/等各有一份,列宽等规则不同),别用固定--style覆盖。
产品输入清单
product-lock.json 只记录仍来自仓外的东西,现在只剩一项:词库 Release 的 tag、source commit 和每个产物的 SHA256。引擎、Server、GUI 框架和安装器都不在清单里——它们是本仓的目录,本仓的一个 commit 就已经把它们钉住了。辅助码在 engine/helpcode/,同理。
- 引擎没有
repositories条目,这是有意的。它不再有 gitlink,锁里写一个上游 commit 就等于声称这棵树仍等于上游——第一个 Windows 专门化的改动落地时这句话就假了,而且没有任何 CI 能发现。来源改记在 engine/UPSTREAM.md。 refresh不解析任何浮动源码引用,词库取自指定 tag。词库清单里记录了构建它的 commit 和当时工作树是否干净,verify-assets一并校验——摘要只能证明字节是评审过的字节,证明不了它来自一个能重建的源。- 发布门禁
verify-published要求清单里每个提交都能从各自仓库默认分支到达,只在发布路径执行,不进 CI。理由见 docs/product-release.md。
正式发布(CI)
本地测试打包见 windows/AGENTS.md 的构建与验证。对外发布走 .github/workflows/release.yml,产出的就是历来挂在本仓 Release 上的 MetasequoiaIME_Setup_v<版本>.exe。
版本号由 release-please 管理,真源是根目录的 version.txt。从 main 往后是全自动的:把 develop 合进 main,只要这批提交里有 fix: 或 feat:,最后就会有一个签好名的安装包挂在 Release 上,中间没有任何一步等人。发版的决定点因此是那次提升本身——合进 develop 不产生版本号,也不消耗签名额度。
这一整条链跑在那次 push 触发的同一个 run 里:
- release-please 先用
RELEASE_PLEASE_TOKEN补建已有合并版本的元数据,再用该凭据把这次 push 的提交刷进 release PR,触发正常的 PR 检查。 land-release-pr.sh等待该 PR 精确 head 的pull_requestCI,绿了就用GITHUB_TOKEN合并它。不能以手动派发检查替代 PR 检查:后者才会进入 PR 的检查汇总并满足门禁。- release-please 再跑一次,用
RELEASE_PLEASE_TOKEN只创建 draft release 和 tag(skip-github-pull-request: true)。维护 PR 的调用只写版本分支(skip-github-release: true)。 - 构建、签名、把 exe 挂上去、draft 转正式。
合并用的是 GITHUB_TOKEN,而用该 token 推的提交不会触发 workflow —— 这是设计依赖的性质,不是要绕开的限制:它保证一次 push 只有一个 Release run,不会再冒出第二个卡在 concurrency 上。手工点 merge 那个 PR 也能得到同样结果,只是提前了一个 run。
每次可发布的合并都花掉一次签名额度,这正是之前的手动设计要避免的事。取舍的理由记在 docs/product-release.md。
workflow_dispatch 保留下来,它同时是修复通道和发布频道的入口:run 在建出 draft 之后失败时,拿那个 tag 重跑即可。同一个 tag 重复触发会被 validate-draft-release.sh 挡下——发布之后它就不是 draft 了,不会重复发布同一个版本。
两个频道由触发方式决定,publish-release.sh 据此选状态(#167):
| 频道 | 触发 | 发布状态 | 标题 |
|---|---|---|---|
| 自动构建 | push | --prerelease |
v<版本>-beta(自动构建),说明里带提示;稳定 tag 保留为 draft |
| 发布 | workflow_dispatch |
--prerelease=false --latest |
v<版本> |
GitHub 没有自定义频道,只有 Latest / Pre-release / Draft 三种状态,所以这里用 prerelease 标志承载「自动、未经挑选」,而不是承载「内测」。产品仍在内测这件事写在 release 说明和 docs/installation.md 里——那才是该做稳定性声明的地方,而这个标志同时还得用来分隔频道,兼不了两份职责。
windows/src/IME/MetasequoiaIME.rc 的 FILEVERSION / PRODUCTVERSION 不由 release-please 直接改(它是逗号和点号两种写法),而是由 scripts/apply_version.py 从 version.txt 注入,release 构建在 configure 之前执行:
python .\scripts\apply_version.py # 从 version.txt 写入版本资源
python .\scripts\apply_version.py --check # 只检查是否与 version.txt 一致
(scripts/apply_version.py 在仓根,改的是 windows/ 下的版本资源。)
release workflow 里的每一段 shell 都抽在 scripts/ci/ 下,workflow 本身只负责编排和传参:
| 脚本 | 用途 |
|---|---|
validate-draft-release.sh |
手动触发时校验 tag 是未发布 draft、指向不可变 commit、且与 version.txt 一致 |
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 产物缺文件 |
embed-server-manifest.ps1 |
在上传和签名之前把 uiAccess=true manifest 注入 Server PE,并读回验证 |
check-tsf-dll.ps1 |
确认 TSF DLL 产出 |
download-dictionaries.sh |
从产品锁指定仓库的 dict-* release 拉词库并校验 SHA256 |
detect-release-signing.ps1 |
判定签名模式(WINDOWS_SIGNING_PROVIDER=signpath 走 SignPath,未设置走证书),决定产物后缀 |
sign-binaries.ps1 |
用仓库 secret 里的真证书签名 uiAccess Server 和最终安装包 |
signpath-files.ps1 |
SignPath 路径用:只把本项目自己的 EXE/DLL 暂存成签名请求的 artifact,签完校验签名后放回;清单与 installer/signpath/ 下的 artifact configuration 保持一致 |
install-inno-language.ps1 |
补 runner 上缺失的 ChineseSimplified.isl,按 commit + SHA256 固定。装到真正的 Inno Setup 安装目录,不是 Chocolatey shim 旁边 |
check-inno-language.ps1 |
CI 用:编译一个只含 [Languages] 的探针脚本,让 ISCC 自己回答语言文件放对没有 |
name-installer-asset.ps1 |
定最终产物名、算校验和、写 step summary |
revalidate-draft-release.sh |
发布前复查 draft 仍指向被构建的那个 commit |
publish-release.sh |
上传产物、追加说明、发布 |
合仓之前 CI 要把五个仓 checkout 到历史目录名下,Prepare-PackageFiles.ps1 才能不改一行地跑。现在源目录名由 workflow 显式传参(-TsfDirectory windows -ServerDirectory server -NoticesDirectory . -HelpCodeDirectory engine/helpcode),只有词库还落在仓根的 MetasequoiaImeDict/,用的是那个参数的默认值。改动这些脚本里的产物路径时,要连同 release workflow 一起核对。
词库不在 CI 里现建,从产品锁指定仓库的 dict-* release 下载并校验 SHA256。建库流水线不在本仓——它随引擎的专门化一并裁掉了(见 engine/UPSTREAM.md),现有 MSIME-Dict release 作为不可变旧产物保留;换发布源要在 product_lock.py 里明确评审,不是改个 tag 就能悄悄完成的事。词库改了要先在上游把新词库发布出来,再发 Windows 版本;通过 scripts/product_lock.py refresh --dictionary-tag <tag> 更新产品锁并评审摘要变更;发布构建不能临时覆盖词库版本。
签名沿用「有证书就签、没有就发未签名版」的策略:配置了 WINDOWS_SIGNING_CERTIFICATE_BASE64 和 WINDOWS_SIGNING_CERTIFICATE_PASSWORD 两个 secret,或 runner 用户证书存储中配置的 thumbprint 可用时,用 signtool 签 uiAccess Server 和最终安装包;没配置时产物名带 -unsigned 后缀,并在 release 说明里写明 uiAccess 不会生效。Server 的 uiAccess=true manifest 必须在上传和签名之前由 scripts/ci/embed-server-manifest.ps1 注入并验证;Sign-PackageBinaries-Local.ps1 使用本机自签名证书,只用于本地验证,CI 不会调用它。
改走 SignPath 要把仓库变量 WINDOWS_SIGNING_PROVIDER 设为 signpath,并配齐 SIGNPATH_ORGANIZATION_ID、SIGNPATH_PROJECT_SLUG(msime-windows)、SIGNPATH_SIGNING_POLICY_SLUG 三个变量和组织 secret SIGNPATH_TOKEN,缺任何一项 detect-release-signing.ps1 都会让发布失败,不会退回证书或未签名。SignPath 项目里还要有 slug 为 payload 和 installer 的两个 artifact configuration,内容照抄 installer/signpath/ 下的同名 XML,那两份文件是评审过的副本,SignPath 只读它自己后台里的那份。SignPath 的开源计划只接受「签名请求之前的每个 job 都跑在 GitHub 托管 runner 上」的请求,所以设为 signpath 时 tsf、server、package 三个 job 从自托管的发布机换到 windows-2025,依赖和编译缓存沿用 ci.yml 的做法(同一个 vcpkg 缓存 scope,Server 走 Ninja Multi-Config 加 sccache);证书路径仍跑在发布机上,那里才有证书。
提交
提交信息用 type(scope): 摘要,scope 用目录名(windows、server、engine、ui、installer)。不要添加 Co-Authored-By、Generated with 或其他 AI 生成标记。