14 Commits
Author SHA1 Message Date
fanlusky bc37ac9a9e feat(installer): 安装时可自定义数据目录位置
词库、用户配置和皮肤此前固定在 %LOCALAPPDATA%\metasequoiaime,整包几百 MB
全压在 C 盘。安装向导新增一页,允许把这部分数据放到任意盘。

程序本体仍固定装在 Program Files:MetasequoiaImeServer.exe 带 uiAccess=true,
只有在受信任目录里这个标志才生效。

运行期的权威来源是 HKLM\Software\Metasequoia\MetasequoiaIME 的 DataDir。
Server、TSF DLL 和引擎三个组件各自按同一顺序解析:METASEQUOIA_IME_DATA_DIR
环境变量 -> 该注册表值 -> %LOCALAPPDATA%\metasequoiaime。三处各持一份实现,
因为组件边界规则禁止 windows/ 与 server/ 互相 include。32 位 TSF DLL 用
KEY_WOW64_64KEY 读,避开 WOW64 重定向。

安全相关的两个约束:

- 安装器在数据目录里写 .metasequoiaime-data 标记。覆盖安装的清理和卸载的整目录
  删除只在看到这个标记(或目录就是历史默认位置)时才执行——用户可能把数据目录
  指到一个本来就有自己文件的文件夹。
- 选择页与 /DATADIR= 走同一套校验:拒绝相对路径、盘符根目录、不存在的盘、程序
  目录内部,以及 Windows/Program Files/各 AppData/用户主目录本身。

升级时改了位置,PrepareToInstall 在停掉输入法进程后用 robocopy /MOVE 搬运用户
拥有的数据(msime_user.db 及其 WAL/SHM/journal、config.toml、config.base.toml、
skins\),词库和前端资源由本次安装重新写入。跨卷移动目录 MoveFile 会失败,所以用
robocopy。用户词库搬运失败会中止安装:否则新版本对着空的 msime_user.db 启动,
ssPostInstall 的回放只记一句 skipped,用户的自造词就静悄悄没了。

测试侧新增 METASEQUOIA_IME_CONFIG_DIR,只改 config.toml 的位置而不搬词库。原先
靠重定向 LOCALAPPDATA 做隔离的办法在装了正式版的机器上已不再成立——注册表值
的优先级高于它。
2026-09-17 22:49:19 +08:00
fanlusky a7779d2b05 refactor(engine): 将 engine 从 submodule 内嵌为仓内一等组件
`vendor/MetasequoiaImeEngine` submodule 改为 `engine/` 目录,导入的是
product-lock.json 原本钉住的提交 c810d201,逐文件核对过 blob,因此这是一次
搬迁而不是引擎升级。引擎自己的嵌套 submodule(googlepinyinime-rev、utfcpp、
miniaudio)按各自 gitlink 一并展开,来源与提交见 engine/UPSTREAM.md。

Windows 专门化裁掉了不属于本产品的部分:whisper.cpp 与本地推理 provider、
macOS 示例宿主、整个 dictionary/ 建库流水线、上游 CI 与 release-please 配置。
词库仍按 product-lock.json 在构建时取发布产物。

引擎不再进 product-lock.json:没有 gitlink 就没有可比对的东西,锁里写一个上游
commit 等于宣称这棵树仍等于上游,而第一个 Windows 专门化改动落地时这句话就假了。
verify-contracts / verify-checkout 随之移除,verify-published 保留。

CI 与工具链:引擎源码并入根 scripts/format.sh(删掉引擎自己的那份,避免两套排除
列表分叉);引擎 voice.yml 里的 silero_vad.onnx 摘要校验移到本仓 lock job;
engine-bump-triage 工作流与其测试删除;gitsubmodule 的 dependabot 频率由 daily
改为 monthly。

README、README.en、AGENTS.md、CONTRIBUTING、docs/product-release.md 及各子目录
文档同步说明新的位置与边界。
2026-09-12 03:40:48 +08:00
Evan 2fb4560d99 fix(release): use beta tags for automatic builds (#252)
* fix(release): separate ci snapshots from stable tags

* fix(release): use beta tags for automatic builds
2026-09-07 17:56:34 +09:00
Evan Hu 969f131358 chore(ci): 切到 develop 分支模型
日常改动合 develop,main 只在发版时从 develop 合入。新增 Branch guard 工作流拦下以特性分支为 head 提到 main 的 PR,功能 CI 与 CodeQL 同时监听 develop 和 main,release 工作流仍只监听 main。
2026-09-06 20:19:37 +09:00
Evan 598b641dd1 docs: 校正签名配额的前提,README 加 badge 与 star 趋势
四处文档把「签名证书次数有限」当作设计理由,其中 docs/product-release.md 据此建议把若干修复攒成
一个版本以省额度。证书实际允许每月 3000 次签名,这个项目的发布频率远低于该量级,前提不成立。
改为如实说明,并把攒版本的真实理由(发布节奏与 release notes 的碎片化)写清楚——那个考量仍然
成立,只是和配额无关。
2026-09-06 12:43:16 +09:00
Evan ee4334dddd fix(ci): attach automatic release checks to the pull request (#175)
* ci(codeql): 避免版本元数据 PR 产生待人工批准的扫描

* fix(ci): attach release validation to the pull request
2026-09-06 11:10:05 +09:00
Evan 947b5e9fa2 ci(release): 分离版本分支与历史 tag 的令牌权限 (#171) 2026-09-06 10:45:17 +09:00
Evan 2562026a2a fix: 修复审计发现的候选页竞态、管道权限、TSF 空指针与皮肤 CSS 逃逸等八项缺陷 (#169)
* fix(server): publish candidate pages as immutable snapshots

Global::candidate_ui is a plain global with no lock. The IPC worker rebuilds and destroys its vectors while the UI thread paints from them, and the handoff is an asynchronous PostMessage, so the worker returns to its loop and pops the next task without waiting. A second keystroke, or an async english.db candidate arrival, could free the page_views buffer and its strings while FillItemsFromUi held a reference into it. ShowItemContextMenu reads the same vectors from a mouse callback with no message handoff at all.

The worker now copies each finished page into an immutable snapshot and publishes it at the single point where the rebuild completes; every UI-thread reader loads that snapshot once and reads only its own copy. PostMessage is deliberately left alone: the worker also serves UI-originated tasks, so a synchronous handoff would invite a cross-thread deadlock.

* fix(server): bound the aux and diagnostic pipe reads

Both listeners serve a single pipe instance inline and polled a NOWAIT handle for as long as the process ran, breaking only on a read that succeeded or failed with something other than ERROR_NO_DATA. A client that connected and never wrote held the only instance forever, so the loop never returned to ConnectNamedPipe and no later client was accepted, permanently losing the langbar right-click channel and the terminal-deactivation teardown fallback.

Give both loops the same deadline the hello reads already use and go back to accepting when it expires.

* fix(server): snapshot the voice config and replace config.toml atomically

GetConfiguredVoiceInput handed out a reference to a global that LoadImeConfig rewrites from the IPC worker thread, clearing and refilling two std::map members and around twenty strings. The voice control thread and the low-level keyboard hook, which copies the struct on every keystroke, walked those nodes as they were freed. LoadImeConfig now parses into a local and publishes it in one step under a process-local shared_mutex, and the getter returns a snapshot by value. The file lock is deliberately not reused for readers: it is a named cross-process mutex with a five second wait, and taking it in a keyboard hook would stall typing system-wide.

WriteConfiguredValue also reopened the live config.toml with ios::trunc, so a crash mid-write destroyed the user settings and could leave the file unparseable for good. It now goes through WriteFileTextAtomically, which this file already uses elsewhere. That makes the directory entry swap atomic; it does not flush, so it is not a durability guarantee.

* fix(ipc): restrict the server pipes to the owning account

The pipe DACL granted Everyone FILE_ALL_ACCESS. That bit set includes FILE_CREATE_PIPE_INSTANCE, so any local account could add an instance to a name this server already owned and answer part of the connections in its place. The names are machine-global with no per-user component, and the TSF client opened them with no SQOS, so a squatter could also impersonate the connecting thread.

Build the descriptor from the process owner SID and admit only LocalSystem, that account, and app containers with connect-only rights so UWP text input still works; fail pipe creation outright when the SID cannot be resolved rather than falling back to something permissive. Pass FILE_FLAG_FIRST_PIPE_INSTANCE on the first instance of each name so a squatted name fails loudly, and request SECURITY_IDENTIFICATION on the client side.

Authenticating the peer process after connect is a larger change to the handshake and is not attempted here.

* fix(tsf): guard the null focus paths and unadvise the compartment sink

_CandidateChangeNotification treated ITfThreadMgr::GetFocus and ITfDocumentMgr::GetTop as if they could only fail through a FAILED HRESULT. Both return S_OK with a null out-pointer when nothing has focus or the document stack is empty, and the results were dereferenced unconditionally, so an external UILess host calling Finalize while focus moved away crashed the host application. The sibling _RequestCancelComposition in the same class already checks both.

CLangBarItemButton::CleanUp only unregistered the compartment inside the SUCCEEDED branch of a CoCreateInstance, then deleted the sink regardless. This TIP registers COM-less activation support, where that call does fail, and the sink was freed while TSF still held it advised. Unadvising never needed the thread manager, so it now runs unconditionally before the deletes.

* fix(ui): drop the cached visuals when the scene is replaced

Window caches focused, hovered and captured Visual pointers into the scene tree, and SetScene destroyed that tree without clearing them. Toggling the input mode rebuilds the floating toolbar scene, so the next mouse move dispatched a virtual call through a freed object.

Clear all three before the assignment, and release the capture only when this window actually holds it: an adopted HWND may never have called SetCapture, and capturedVisual_ is set even when it did not.

* fix(settings): scope external skin CSS through the CSS parser

injectScopedSkinCss concatenated a skin toolbar.css into an @scope block as text. Block nesting is textual, so a leading close brace in the skin ended the block early and the rest styled the whole settings window. The host validates only the skin file name, never the body, and candidatePreviewCss interpolated unvalidated colour strings into rule bodies the same way.

Create the scope rule empty and re-insert every rule parsed out of the skin as a child of it, so nothing lands outside however the braces are balanced, and accept a colour only in the plain notations we actually render.

* fix(release): gate the dictionary source commit on its default branch

verify_published iterated only data["repositories"], but the dictionary block pins a source_commit exactly like they do. The manifest check merely proves the release agrees with the lock about that commit, which a branch commit satisfies just as happily, so a dictionary built from an unmerged branch shipped with the provenance the gate exists to refuse.

Run the dictionary through the same loop under its own name, resolving its repository from the lock. A retired dictionary repository that no longer answers is reported rather than failing the release: its published artifacts are immutable history, and being unable to re-check them is not the same as finding them unmerged.
2026-09-06 10:31:48 +09:00
Evan ab0d98b719 ci(release): publish every releasable merge to main automatically (#165)
A push to main carrying a fix or a feat now ends with a published installer, with no human merging the release pull request and no manual workflow_dispatch in between.

release-please only cuts a release for a release pull request that was already merged when it ran, so the prepare job now invokes it twice: once to refresh the pull request against the commits this push brought, and once after land-release-pr.sh has merged it, to turn that merge into the draft the rest of the run builds and publishes. Merging before the first invocation would instead release everything except the push that triggered the run.

land-release-pr.sh is check-release-pr.sh plus the merge it deliberately used to skip. It 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; the release is created and built inside the run that merged it. It merges the exact commit CI proved via --match-head-commit, then waits for that merge commit to be reachable from main so the second release-please invocation does not quietly find nothing to release.

resolve-auto-release.sh picks which of the two invocations reported the release to build, preferring the second when both did, and applies the invariants validate-draft-release.sh gives the manual path: the release is tagged at a full commit SHA, and the tag agrees with the version.

workflow_dispatch stays as a repair path for a run that failed after creating its draft.

This spends one of the signing certificate's limited signatures on every releasable merge, which is what the previous manual design existed to avoid. docs/product-release.md records the trade, including why the queueing this can produce is not answered by cancel-in-progress.
2026-09-06 10:25:11 +09:00
Evan Hu fc0c996e05 chore(dict): follow the dictionary release to its semver tag
The dictionary release was republished as dict-v1.0.0 so that dictionary tags stop using calendar versions. The release was renamed rather than rebuilt, so the assets are byte-identical and every SHA256 in product-lock.json is unchanged; only the tag string moves. Downloads under the old tag now return 404, so this pin has to follow.

The runbook example and the fixture standing in for a current release move to the semver form too. LEGACY_DICTIONARY_TAG and the retired-repository fixture keep their calendar names because they identify historical releases that stay immutable.
2026-09-06 08:47:38 +09:00
Evan Hu b56bb8d3ad docs: record that the helpcodes and the dictionary source moved into the engine
MSIME-HelpCode is no longer a product input and the dictionary now ships from MSIME-Engine, so the readme, the agent notes, the release documentation and the issue templates stop naming a repository the product does not consume.
2026-09-06 04:13:48 +09:00
Evan Hu 05036a763d docs: describe one repository instead of six
The product README was under windows/ after the move, but it was never a component readme: it is the download page, the feature guide and the contribution guide for the whole product, so it goes back to the root and windows/ gets a real component readme.

AGENTS.md at the root now carries the repository map, the component boundaries that used to be cross-repository rules, and the release process, since the scripts that process drives live at the root. The component files keep what is genuinely theirs and say which paths they are relative to. References that named sibling repositories now name sibling directories, and the lock documentation records that Server, the pages, the GUI framework and the installer are no longer inputs to pin.
2026-09-06 02:40:51 +09:00
Evan Hu a81bc47c71 feat(product): require locked commits to be on their default branch at release
The lock pins immutable commits, but nothing checked that anybody merged them. Today it pins server 40d85a4 and engine 9a4b0c6, both of which exist only on architecture/product-contracts branches, and a release built from this commit would have shipped and attested to a Server that never reached main. The release job's existing provenance check covers only the TSF commit being released, not the inputs the lock names.

verify-published compares each locked commit against its own repository's default branch and accepts only behind or identical, the two states that mean it is an ancestor. ahead and diverged both mean the commit sits on something nobody merged.

It runs in the release path and deliberately not in product-ci.yml. A pull request legitimately locks branch commits while one change lands across several repositories at once, which is exactly the situation this branch is in; enforcing reachability in pull request CI would deadlock the landing it exists to protect. At release time the judgement inverts, because an input that never reached its default branch is an input nobody reviewed.
2026-09-05 23:18:03 +09:00
Evan Hu 487011c802 feat(product): lock release inputs and share negotiated IPC contracts 2026-09-05 22:32:21 +09:00