Skip to content

Instantly share code, notes, and snippets.

@amorphobia
Created September 19, 2026 09:17
Show Gist options
  • Select an option

  • Save amorphobia/126496fad525976da49165abd0a069d2 to your computer and use it in GitHub Desktop.

Select an option

Save amorphobia/126496fad525976da49165abd0a069d2 to your computer and use it in GitHub Desktop.
Rabbit and librime-ahk support rime plugin DLL loader

两个 patch 说明

这次改动分为两个独立 patch:

  • rabbit.patch:修改 Rabbit 主仓库。
  • librime-ahk.patch:修改 Lib/librime-ahk 子模块。

两者需要同时应用,但代码职责彼此分离。

一、librime-ahk.patch

该 patch 为 librime-ahk 增加 Windows 外部插件自动发现能力。

1. 新增 rime_plugins.ahk

新增 RimePluginLoader,主要功能包括:

  • 扫描插件目录中的 *.dll;
  • 默认使用实际加载的 rime.dll 同目录下的:
rime-plugins
  • 支持调用者传入自定义插件目录;
  • 根据文件名推导 module 名称:
librime-lua.dll   -> lua
rime-octagram.dll -> octagram
rime-foo-bar.dll  -> foo_bar
  • 检查 DLL 的 PE 位宽;
  • 跳过位宽不匹配的 DLL;
  • 使用 Windows LoadLibraryW 加载插件;
  • 通过 RimeApi.find_module() 验证插件是否成功注册 module;
  • 跳过重复路径和重复 module;
  • 记录成功加载和跳过的插件;
  • 保持 DLL handle 到进程结束,避免 librime 中的 module 指针悬空。

加载 DLL 只负责触发 module 注册,不会主动初始化 module。后续由 append_modules() 将 module 名称加入 RimeTraits.modules,再由 librime 自己执行 module 初始化。

2. rime_api.ahk

新增:

RimeApi.get_loaded_dll_path()
RimeApi.get_module_path(module_handle)

通过 GetModuleFileNameW 获取实际加载的 rime.dll 路径,而不是根据脚本路径推断。

因此默认插件目录跟随实际加载的 DLL:

C:\Rime\bin\rime.dll
C:\Rime\bin\rime-plugins\

3. Module 初始化顺序

插件加载流程为:

加载 rime.dll
    ↓
加载 rime-plugins/*.dll
    ↓
插件 DLL 注册 module
    ↓
将 module 加入 RimeTraits.modules
    ↓
RimeInitialize / RimeDeployerInitialize
    ↓
librime 初始化 module

这样保持了 librime 自身的 module 生命周期管理。

4. 测试和异常处理

patch 同时修改了测试入口和测试运行器:

  • 测试入口增加顶层 try/catch;
  • 未捕获异常会输出到标准输出;
  • 输出失败时尝试写入临时诊断文件;
  • 新增插件名称、默认路径、module 去重、PE 位宽检测等测试;
  • 新增官方 sample plugin 集成测试入口。

这遵守了仓库中 AutoHotkey 测试必须具备可观察异常边界的规则。

5. 官方 sample plugin 构建脚本

新增:

tools/build_librime_sample.ps1

功能包括:

  • 克隆官方 librime;
  • 使用 Python venv;
  • 使用 MSVC + Ninja;
  • 不打开 Visual Studio IDE;
  • 使用命令行 Build Tools;
  • 构建官方 rime-sample.dll;
  • 只复制 plugin DLL,不复制 rime.dll;
  • 支持 x86/x64;
  • 支持指定 Python、Boost、工作目录和 librime ref。

构建工作区中仍会保留匹配的 librime:

<workspace>\librime\build\bin\rime.dll

集成测试通过环境变量指定:

$env:LIBRIME_TEST_RIME_DLL = "C:\path\to\rime.dll"
$env:LIBRIME_TEST_PLUGIN_DIR = "C:\path\to\plugin-directory"

需要注意,官方 sample plugin 可能依赖官方 rime.dll 未导出的 C++ 符号,因此测试时应使用与 plugin 匹配的 librime 构建结果,不能默认认为它能配合官方预编译 rime.dll。

二、rabbit.patch

该 patch 只修改 Rabbit 主仓库,使 Rabbit 使用 librime-ahk 的插件 loader。

1. 引入插件 loader

RabbitCommon.ahk 新增:

#Include librime-ahk\rime_plugins.ahk

并增加:

RabbitLoadPlugins(rime, traits, base_module := "default")

该函数负责:

  1. 创建 RimePluginLoader;
  2. 使用默认插件目录扫描 DLL;
  3. 将成功注册的 module 加入 traits;
  4. 返回加载结果。

Rabbit 当前使用默认路径,不额外配置插件目录。

2. 前端初始化流程

RabbitFrontendRuntime 在 librime 初始化前加载插件:

创建 traits
    ↓
加载 plugins
    ↓
RimeSetup
    ↓
RimeInitialize

插件 module 会在正常的 RimeInitialize() 流程中初始化。

3. Deployer 初始化流程

RabbitDeployerContext 也会加载插件,并将 deployer 作为基础 module 组:

创建 deployer traits
    ↓
加载 plugins
    ↓
RimeSetup
    ↓
RimeDeployerInitialize(traits)

同时将原来的:

this.rime.deployer_initialize(0)

改为:

this.rime.deployer_initialize(this.traits)

这样 deployer 初始化也能获得包含插件 module 的 traits。

4. 单元测试适配

RabbitFrontendRuntimeTest.ahk 中的测试 probe 覆盖了:

LoadPlugins(*)

避免 Rabbit 前端运行时单元测试实际加载本机插件,保持测试隔离。

三、应用顺序

在 Rabbit 根目录执行:

git apply librime-ahk.patch
git apply rabbit.patch

如果分别操作,也可以先进入子模块:

cd Lib/librime-ahk
git apply ..\..\librime-ahk.patch

cd ..\..
git apply rabbit.patch

两个 patch 都不包含生成的 DLL、rime 数据或临时测试文件。

四、当前边界

本次实现:

  • 只负责 DLL 发现、位宽检查、加载和 module 注册;
  • 不验证完整 C++ ABI;
  • 不验证插件依赖的所有导出符号;
  • 不卸载已经加载的插件 DLL;
  • Rabbit 使用默认的 rime.dll 同目录 rime-plugins 路径;
  • 插件 module 仍由 librime 的 LoadModules() 负责初始化和结束。

因此,Windows plugin 是否能真正运行,仍取决于 plugin 与实际 rime.dll 之间的导出符号和 ABI 是否兼容。

diff --git a/.gitignore b/.gitignore
index 57cca53..d15acf5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
*/rime/installation.yaml
*/rime/user.yaml
tests/junit.xml
+tests/junit-*.xml
tests/*.log
*.dll
*.exe
diff --git a/README.md b/README.md
index 4f296e9..b2b029b 100644
--- a/README.md
+++ b/README.md
@@ -10,3 +10,59 @@ Wraps [RIME](https://github.com/rime/librime) API for easy use in [AutoHotkey](h
- AutoHotkey v2
- librime 1.8.5+
+
+## External plugins on Windows
+
+Include `rime_plugins.ahk` when the caller needs to load externally built
+Rime modules on Windows:
+
+```ahk
+#Include rime_plugins.ahk
+
+rime := RimeApi(rime_path)
+loader := RimePluginLoader(rime)
+traits := RimeTraits()
+rime.setup(traits)
+
+plugins := loader.load() ; defaults to the loaded rime.dll directory\rime-plugins
+loader.append_modules(traits, "default", plugins)
+rime.initialize(traits)
+```
+
+Pass an explicit directory to `load(path)` to override the default. Use
+`"deployer"` as the second argument to `append_modules()` when initializing a
+deployer-only traits list. The loader only loads DLLs and registers their
+modules; the subsequent `initialize()` call keeps module initialization and
+finalization inside librime's own module manager. Successfully loaded DLLs are
+kept loaded for the process lifetime because librime has no public module
+unregistration API.
+
+## Build and test the official librime sample plugin locally
+
+From a Visual Studio Build Tools Developer PowerShell or Developer Command
+Prompt, run the helper below. It clones the official librime repository into
+a temporary workspace, uses the official MSVC/Ninja build parameters, and
+builds librime's own standalone `rime-sample.dll` plugin. It copies only the
+plugin DLL to the output directory; it does not package `rime.dll`.
+
+```powershell
+pwsh -File tools\build_librime_sample.ps1 -Architecture x64
+```
+
+The helper creates a workspace-local Python venv by default. Use
+`-PythonExecutable` if the bootstrap interpreter is not named `python`, or
+`-PythonVenvDirectory` to choose the venv location.
+
+The sample integration test accepts the matching librime DLL and plugin directory through
+`LIBRIME_TEST_RIME_DLL` and `LIBRIME_TEST_PLUGIN_DIR`:
+
+```powershell
+$env:LIBRIME_TEST_RIME_DLL = "C:\path\to\rime.dll"
+$env:LIBRIME_TEST_PLUGIN_DIR = "C:\path\to\output"
+& "C:\Program Files\AutoHotkey\v2\AutoHotkey64.exe" `
+ /ErrorStdOut tests\rime_sample_test_main.ahk
+```
+
+The official sample plugin registers the `sample` module. The build workspace
+contains the matching `rime.dll` under `librime\build\bin` while the requested
+output directory contains only `rime-sample.dll`.
diff --git a/rime_api.ahk b/rime_api.ahk
index a04e54b..ccc16bb 100644
--- a/rime_api.ahk
+++ b/rime_api.ahk
@@ -715,6 +715,28 @@ class RimeApi extends RimeApiStruct {
}
}
+ static get_loaded_dll_path() {
+ if !RimeApi.rimeDll
+ return ""
+ return RimeApi.get_module_path(RimeApi.rimeDll)
+ }
+
+ static get_module_path(module_handle) {
+ local capacity := 260, module_buffer, length
+ while capacity <= 32768 {
+ module_buffer := Buffer(capacity * 2, 0)
+ length := DllCall(
+ "GetModuleFileNameW", "Ptr", module_handle, "Ptr", module_buffer, "UInt", capacity, "UInt"
+ )
+ if !length
+ return ""
+ if length < capacity - 1
+ return StrGet(module_buffer, length, "UTF-16")
+ capacity *= 2
+ }
+ return ""
+ }
+
; Keep the DLL loaded until process exit so every RimeApi instance shares one module reference.
static rimeDll := 0
static notification_callback := 0
diff --git a/tests/rime_test_main.ahk b/tests/rime_test_main.ahk
index 8784909..0284395 100644
--- a/tests/rime_test_main.ahk
+++ b/tests/rime_test_main.ahk
@@ -1,6 +1,7 @@
#Include test_runner.ahk
#Include ..\rime_api.ahk
+#Include ..\rime_plugins.ahk
#Include ..\rime_levers_api.ahk
#Include ..\utils\yaml.ahk
@@ -186,6 +187,107 @@ class RimeApiDllLoadingTests {
EnvSet("LIBRIME_LIB_DIR", original_librime_lib_dir)
}
}
+
+ Test_LoadedDllPathIsActualFile() {
+ local api := RimeApi({})
+ TestRunner.Assert(FileExist(RimeApi.get_loaded_dll_path()))
+ }
+}
+
+class RimePluginTests {
+ Test_PluginNameConversion() {
+ TestRunner.Assert(RimePluginLoader.plugin_name_of("librime-lua.dll") == "lua")
+ TestRunner.Assert(RimePluginLoader.plugin_name_of("rime-octagram.dll") == "octagram")
+ TestRunner.Assert(RimePluginLoader.plugin_name_of("rime-foo-bar.dll") == "foo_bar")
+ TestRunner.Assert(RimePluginLoader.plugin_name_of("custom.dll") == "custom")
+ }
+
+ Test_DefaultPluginPathUsesRimeDirectory() {
+ TestRunner.Assert(
+ RimePluginLoader.plugins_path_from("C:/Rime/bin/rime.dll") == "C:\Rime\bin\rime-plugins"
+ )
+ TestRunner.Assert(
+ RimePluginLoader.plugins_path_from("C:/Rime/bin/rime.dll", "D:/plugins") == "D:\plugins"
+ )
+ }
+
+ Test_AppendModulesPreservesDefaultGroup() {
+ local traits := RimeTraits(), result := RimePluginLoadResult("plugins")
+ result.loaded.Push({module_name: "lua"})
+ result.loaded.Push({module_name: "octagram"})
+
+ local modules := RimePluginLoader({}).append_modules(traits, "default", result)
+ TestRunner.Assert(modules.Length == 3)
+ TestRunner.Assert(modules[1] == "default")
+ TestRunner.Assert(modules[2] == "lua")
+ TestRunner.Assert(modules[3] == "octagram")
+ }
+
+ Test_AppendModulesDoesNotDuplicateExistingModules() {
+ local traits := RimeTraits()
+ traits.modules := ["default", "lua"]
+ local result := RimePluginLoadResult("plugins")
+ result.loaded.Push({module_name: "lua"})
+ result.loaded.Push({module_name: "octagram"})
+
+ local modules := RimePluginLoader({}).append_modules(traits, "default", result)
+ TestRunner.Assert(modules.Length == 3)
+ TestRunner.Assert(modules[1] == "default")
+ TestRunner.Assert(modules[2] == "lua")
+ TestRunner.Assert(modules[3] == "octagram")
+ }
+
+ Test_ReadPeBits() {
+ local root := A_Temp . "\rime-plugin-tests-" . A_TickCount . "-" . Random(1, 1000000)
+ local path := root . "\image.dll"
+ local data := Buffer(0x80, 0), file
+ try {
+ DirCreate(root)
+ NumPut("UShort", 0x5a4d, data, 0)
+ NumPut("UInt", 0x40, data, 0x3c)
+ NumPut("UInt", 0x4550, data, 0x40)
+ NumPut("UShort", 0x8664, data, 0x44)
+ NumPut("UShort", 0x20b, data, 0x58)
+ file := FileOpen(path, "w")
+ file.RawWrite(data)
+ file.Close()
+ TestRunner.Assert(RimePluginLoader.read_pe_bits(path) == 64)
+ } finally {
+ if IsSet(file) && file
+ file.Close()
+ if DirExist(root)
+ DirDelete(root, true)
+ }
+ }
+
+ Test_LoadSkipsIncompatibleDlls() {
+ local root := A_Temp . "\rime-plugin-tests-" . A_TickCount . "-" . Random(1, 1000000)
+ local path := root . "\rime-incompatible.dll"
+ local data := Buffer(0x80, 0), file
+ local foreign_machine := A_PtrSize = 8 ? 0x14c : 0x8664
+ local foreign_magic := A_PtrSize = 8 ? 0x10b : 0x20b
+ try {
+ DirCreate(root)
+ NumPut("UShort", 0x5a4d, data, 0)
+ NumPut("UInt", 0x40, data, 0x3c)
+ NumPut("UInt", 0x4550, data, 0x40)
+ NumPut("UShort", foreign_machine, data, 0x44)
+ NumPut("UShort", foreign_magic, data, 0x58)
+ file := FileOpen(path, "w")
+ file.RawWrite(data)
+ file.Close()
+
+ local result := RimePluginLoader({}).load(root)
+ TestRunner.Assert(result.loaded.Length == 0)
+ TestRunner.Assert(result.skipped.Length == 1)
+ TestRunner.Assert(result.skipped[1].reason == "wrong_architecture")
+ } finally {
+ if IsSet(file) && file
+ file.Close()
+ if DirExist(root)
+ DirDelete(root, true)
+ }
+ }
}
Class RimeApiTests {
@@ -319,8 +421,13 @@ Class RimeApiTests {
}
}
-results := TestRunner.Run(RimeStringTests, RimeNullTerminatedStringArrayTests, RimeTraitsTests,
- RimeCandidatePreviewTests, RimeYamlTests, RimeApiDllLoadingTests, RimeApiTests)
-failures := TestRunner.WriteJUnit(results, A_ScriptDir "\junit.xml")
-TestRunner.Print(results, "*")
-ExitApp(failures ? 1 : 0)
+try {
+ results := TestRunner.Run(RimeStringTests, RimeNullTerminatedStringArrayTests, RimeTraitsTests,
+ RimeCandidatePreviewTests, RimeYamlTests, RimeApiDllLoadingTests, RimePluginTests, RimeApiTests)
+ failures := TestRunner.WriteJUnit(results, A_ScriptDir "\junit.xml")
+ TestRunner.Print(results, "*")
+ ExitApp(failures ? 1 : 0)
+} catch as err {
+ TestRunner.ReportUnhandledException(err)
+ ExitApp(1)
+}
diff --git a/tests/test_runner.ahk b/tests/test_runner.ahk
index 61b94ce..b47ca29 100644
--- a/tests/test_runner.ahk
+++ b/tests/test_runner.ahk
@@ -84,9 +84,41 @@ class TestRunner {
line := Format("{} {}::{}", label, result.className, result.methodName)
if !result.passed
line .= " - " result.error
- FileAppend(line "`n", path)
+ this.WriteOutput(line "`n", path)
+ }
+ this.WriteOutput(Format("{} tests, {} failed`n", results.Length, this.FailureCount(results)), path)
+ }
+
+ static ReportUnhandledException(err) {
+ local message := "Uncaught exception: " . err.Message . "`n at " . err.What . "`n " . err.Line
+ . "`nStack:`n" . err.Stack . "`n"
+ local diagnostic_path := A_Temp . "\\librime-ahk-test-error-" . A_TickCount . ".log"
+ try {
+ FileAppend(message, "*")
+ return
+ } catch {
+ try {
+ FileAppend(message . "Diagnostic file: " . diagnostic_path . "`n", diagnostic_path, "UTF-8")
+ } catch {
+ OutputDebug(message)
+ }
+ }
+ }
+
+ static WriteOutput(text, path := "*") {
+ try {
+ FileAppend(text, path)
+ } catch as err {
+ if path != "*"
+ throw err
+
+ local diagnostic_path := A_Temp . "\\librime-ahk-test-output-" . A_TickCount . ".log"
+ try {
+ FileAppend(text . "Diagnostic file: " . diagnostic_path . "`n", diagnostic_path, "UTF-8")
+ } catch {
+ OutputDebug(text)
+ }
}
- FileAppend(Format("{} tests, {} failed`n", results.Length, this.FailureCount(results)), path)
}
static FailureCount(results) {
diff --git a/rime_plugins.ahk b/rime_plugins.ahk
new file mode 100644
index 0000000..4723477
--- /dev/null
+++ b/rime_plugins.ahk
@@ -0,0 +1,283 @@
+/*
+ * Copyright (c) 2023 - 2026 Xuesong Peng <pengxuesong.cn@gmail.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#Include rime_api.ahk
+
+class RimePluginLoadResult {
+ __New(plugins_path := "") {
+ this.plugins_path := plugins_path
+ this.loaded := []
+ this.skipped := []
+ }
+
+ module_names {
+ get {
+ local names := [], seen := Map(), plugin
+ seen.CaseSense := "Off"
+ for plugin in this.loaded {
+ if !seen.Has(plugin.module_name) {
+ seen[plugin.module_name] := true
+ names.Push(plugin.module_name)
+ }
+ }
+ return names
+ }
+ }
+}
+
+class RimePluginLoader {
+ ; Keep loaded DLLs for the process lifetime. librime's module registry keeps
+ ; pointers to statically registered modules and exposes no unregister API.
+ static plugins_by_path := Map()
+ static plugins_by_module := Map()
+
+ __New(rime := 0) {
+ this.rime := rime ? rime : RimeApi()
+ }
+
+ /**
+ * Load all DLLs in the plugin directory and retain their registered modules.
+ *
+ * Loading a DLL only registers its module. Call append_modules() before
+ * RimeApi.initialize() or RimeApi.deployer_initialize() so librime's own
+ * ModuleManager performs initialization and finalization.
+ */
+ load(plugins_path := "") {
+ local resolved_path := this.resolve_plugins_path(plugins_path)
+ local result := RimePluginLoadResult(resolved_path)
+
+ if !resolved_path || !DirExist(resolved_path)
+ return result
+
+ Loop Files, resolved_path . "\*.dll", "F" {
+ this.load_plugin(A_LoopFileFullPath, result)
+ }
+ return result
+ }
+
+ /**
+ * Add loaded plugin module names to a traits object.
+ *
+ * If the caller did not provide a module list, the base group preserves
+ * librime's normal initialization path: "default" for the frontend or
+ * "deployer" for deployment-only use.
+ */
+ append_modules(traits, base_module := "default", result := 0) {
+ local names := result ? result.module_names : this.loaded_module_names()
+ if !names.Length
+ return traits.modules
+
+ local has_modules := !!NumGet(traits, RimeTraits.modules_offset, "Ptr")
+ local modules := has_modules ? traits.modules : []
+ if !has_modules && base_module
+ modules.Push(base_module)
+
+ local seen := Map(), module_name
+ seen.CaseSense := "Off"
+ for module_name in modules
+ seen[module_name] := true
+ for module_name in names {
+ if !seen.Has(module_name) {
+ seen[module_name] := true
+ modules.Push(module_name)
+ }
+ }
+ traits.modules := modules
+ return modules
+ }
+
+ resolve_plugins_path(plugins_path := "") {
+ return RimePluginLoader.plugins_path_from(RimeApi.get_loaded_dll_path(), plugins_path)
+ }
+
+ loaded_module_names() {
+ local names := [], module_key, entry
+ for module_key, entry in RimePluginLoader.plugins_by_module
+ names.Push(entry.module_name)
+ return names
+ }
+
+ load_plugin(plugin_path, result) {
+ local module_name := RimePluginLoader.plugin_name_of(plugin_path)
+ local path_key := RimePluginLoader.path_key(plugin_path)
+ local module_key := RimePluginLoader.module_key(module_name)
+ local entry, handle, module
+
+ if RimePluginLoader.plugins_by_path.Has(path_key) {
+ entry := RimePluginLoader.plugins_by_path[path_key]
+ if entry.registered
+ result.loaded.Push(entry)
+ else
+ result.skipped.Push({
+ path: plugin_path,
+ module_name: module_name,
+ reason: entry.reason,
+ error: entry.error
+ })
+ return
+ }
+
+ if RimePluginLoader.plugins_by_module.Has(module_key) {
+ result.skipped.Push({
+ path: plugin_path,
+ module_name: module_name,
+ reason: "duplicate_module"
+ })
+ return
+ }
+
+ if RimePluginLoader.read_pe_bits(plugin_path) != A_PtrSize * 8 {
+ result.skipped.Push({
+ path: plugin_path,
+ module_name: module_name,
+ reason: "wrong_architecture"
+ })
+ return
+ }
+
+ handle := RimePluginLoader.load_library(plugin_path)
+ if !handle {
+ result.skipped.Push({
+ path: plugin_path,
+ module_name: module_name,
+ reason: "load_failed"
+ })
+ return
+ }
+
+ ; Keep the reference even when registration validation fails. Unloading
+ ; a DLL can leave librime's module registry with a dangling pointer.
+ entry := {
+ path: plugin_path,
+ module_name: module_name,
+ handle: handle,
+ registered: false,
+ reason: "module_not_found",
+ error: ""
+ }
+ RimePluginLoader.plugins_by_path[path_key] := entry
+
+ try {
+ module := this.rime.find_module(module_name)
+ if !RimePluginLoader.valid_module(module_name, module) {
+ entry.reason := "invalid_module"
+ result.skipped.Push({
+ path: plugin_path,
+ module_name: module_name,
+ reason: entry.reason
+ })
+ return
+ }
+
+ entry.registered := true
+ entry.reason := ""
+ RimePluginLoader.plugins_by_module[module_key] := entry
+ result.loaded.Push(entry)
+ } catch Error as err {
+ entry.reason := "find_module_failed"
+ entry.error := err.Message
+ result.skipped.Push({
+ path: plugin_path,
+ module_name: module_name,
+ reason: entry.reason,
+ error: entry.error
+ })
+ }
+ }
+
+ static plugins_path_from(rime_dll_path, plugins_path := "") {
+ if plugins_path
+ return StrReplace(plugins_path, "/", "\")
+ if !rime_dll_path
+ return ""
+
+ rime_dll_path := StrReplace(rime_dll_path, "/", "\")
+ local rime_dir
+ SplitPath(rime_dll_path, , &rime_dir)
+ return rime_dir . "\rime-plugins"
+ }
+
+ static plugin_name_of(plugin_path) {
+ local name
+ SplitPath(plugin_path, , , , &name)
+ if SubStr(name, 1, 8) = "librime-"
+ name := SubStr(name, 9)
+ else if SubStr(name, 1, 5) = "rime-"
+ name := SubStr(name, 6)
+ return StrReplace(name, "-", "_")
+ }
+
+ static valid_module(module_name, module) {
+ if !module
+ return false
+ if module.data_size + A_IntSize < RimeModule.initialize_offset + A_PtrSize
+ return false
+ if !module.module_name || module.module_name !== module_name
+ return false
+ return !!NumGet(module, RimeModule.initialize_offset, "Ptr")
+ }
+
+ static read_pe_bits(path) {
+ local file, data, pe_offset, optional_magic, machine
+ try {
+ file := FileOpen(path, "r")
+ data := Buffer(0x40)
+ if file.RawRead(data) != data.Size || NumGet(data, 0, "UShort") != 0x5a4d
+ return 0
+ pe_offset := NumGet(data, 0x3c, "UInt")
+ if pe_offset < 0x40 || pe_offset + 0x1a > file.Length
+ return 0
+ file.Pos := pe_offset
+ data := Buffer(0x1a)
+ if file.RawRead(data) != data.Size || NumGet(data, 0, "UInt") != 0x00004550
+ return 0
+ } catch Error {
+ return 0
+ } finally {
+ if IsSet(file) && file
+ file.Close()
+ }
+
+ machine := NumGet(data, 4, "UShort")
+ optional_magic := NumGet(data, 0x18, "UShort")
+ if machine = 0x14c && optional_magic = 0x10b
+ return 32
+ if machine = 0x8664 && optional_magic = 0x20b
+ return 64
+ return 0
+ }
+
+ static load_library(path) {
+ ; Missing dependencies must return failure rather than show a loader dialog.
+ local previous_mode := DllCall("SetErrorMode", "UInt", 0x8001, "UInt")
+ try {
+ return DllCall("LoadLibraryW", "WStr", path, "Ptr")
+ } catch Error {
+ return 0
+ } finally {
+ DllCall("SetErrorMode", "UInt", previous_mode)
+ }
+ }
+
+ static path_key(path) {
+ return StrLower(StrReplace(path, "/", "\"))
+ }
+
+ static module_key(module_name) {
+ return StrLower(module_name)
+ }
+}
diff --git a/tests/rime_sample_test_main.ahk b/tests/rime_sample_test_main.ahk
new file mode 100644
index 0000000..f86145b
--- /dev/null
+++ b/tests/rime_sample_test_main.ahk
@@ -0,0 +1,75 @@
+#Requires AutoHotkey v2.0
+
+#Include test_runner.ahk
+#Include ..\rime_api.ahk
+#Include ..\rime_plugins.ahk
+
+class RimeSamplePluginTests {
+ Begin() {
+ local rime_path := EnvGet("LIBRIME_TEST_RIME_DLL")
+ local plugin_directory := EnvGet("LIBRIME_TEST_PLUGIN_DIR")
+ if !rime_path
+ rime_path := A_ScriptDir . "\..\rime.dll"
+ if !plugin_directory
+ throw Error("LIBRIME_TEST_PLUGIN_DIR is required.")
+ if !FileExist(rime_path)
+ throw Error("The test librime DLL does not exist: " . rime_path)
+ if !DirExist(plugin_directory)
+ throw Error("The test plugin directory does not exist: " . plugin_directory)
+
+ this.rime := RimeApi(rime_path)
+ this.loader := RimePluginLoader(this.rime)
+ this.plugin_directory := plugin_directory
+ this.test_directory := A_Temp . "\rime-sample-test-" . A_TickCount . "-" . Random(1, 1000000)
+ DirCreate(this.test_directory)
+ }
+
+ Test_LoadsOfficialSampleAndInitializesItThroughLibrime() {
+ local result := this.loader.load(this.plugin_directory)
+ TestRunner.Assert(result.loaded.Length == 1, "Expected the official sample DLL to load.")
+ TestRunner.Assert(result.module_names.Length == 1)
+ TestRunner.Assert(result.module_names[1] == "sample")
+ TestRunner.Assert(!!this.rime.find_module("sample"), "The sample module was not registered.")
+
+ local traits := RimeTraits()
+ traits.shared_data_dir := this.test_directory
+ traits.user_data_dir := this.test_directory
+ traits.prebuilt_data_dir := this.test_directory
+ traits.staging_dir := this.test_directory
+ traits.app_name := "rime.librime-ahk.sample-test"
+ this.rime.setup(traits)
+ this.loader.append_modules(traits, "default", result)
+ TestRunner.Assert(traits.modules.Length == 2)
+ TestRunner.Assert(traits.modules[1] == "default")
+ TestRunner.Assert(traits.modules[2] == "sample")
+
+ this.rime.initialize(traits)
+ this.initialized := true
+ TestRunner.Assert(!!this.rime.find_module("sample"), "The sample module disappeared after initialization.")
+ }
+
+ End() {
+ if this.HasOwnProp("initialized") && this.initialized {
+ this.rime.finalize()
+ }
+ if this.HasOwnProp("test_directory") && DirExist(this.test_directory) {
+ DirDelete(this.test_directory, true)
+ }
+ if this.HasOwnProp("rime") {
+ this.DeleteProp("rime")
+ }
+ if this.HasOwnProp("loader") {
+ this.DeleteProp("loader")
+ }
+ }
+}
+
+try {
+ results := TestRunner.Run(RimeSamplePluginTests)
+ failures := TestRunner.WriteJUnit(results, A_ScriptDir . "\junit-sample.xml")
+ TestRunner.Print(results, "*")
+ ExitApp(failures ? 1 : 0)
+} catch as err {
+ TestRunner.ReportUnhandledException(err)
+ ExitApp(1)
+}
diff --git a/tools/build_librime_sample.ps1 b/tools/build_librime_sample.ps1
new file mode 100644
index 0000000..db1b433
--- /dev/null
+++ b/tools/build_librime_sample.ps1
@@ -0,0 +1,261 @@
+param(
+ [ValidateSet("x86", "x64")]
+ [string]$Architecture = "x64",
+ [string]$WorkspaceDirectory = "",
+ [string]$OutputDirectory = "",
+ [string]$BoostRoot = "",
+ [string]$LibrimeRef = "master",
+ [string]$PythonExecutable = "python",
+ [string]$PythonVenvDirectory = "",
+ [switch]$SkipDependencyBuild
+)
+
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+
+function Invoke-Native {
+ param(
+ [Parameter(Mandatory)]
+ [string]$FilePath,
+ [Parameter(Mandatory)]
+ [string[]]$ArgumentList,
+ [Parameter(Mandatory)]
+ [string]$WorkingDirectory
+ )
+
+ Push-Location -LiteralPath $WorkingDirectory
+ try {
+ & $FilePath @ArgumentList
+ if ($LASTEXITCODE -ne 0) {
+ throw "Command failed with exit code $LASTEXITCODE`: $FilePath $($ArgumentList -join ' ')"
+ }
+ } finally {
+ Pop-Location
+ }
+}
+
+function Invoke-Batch {
+ param(
+ [Parameter(Mandatory)]
+ [string]$BatchPath,
+ [string[]]$ArgumentList = @(),
+ [Parameter(Mandatory)]
+ [string]$WorkingDirectory
+ )
+
+ $batchArguments = @("/d", "/c", "call", $BatchPath) + $ArgumentList
+ Invoke-Native -FilePath "cmd.exe" -ArgumentList $batchArguments -WorkingDirectory $WorkingDirectory
+}
+
+function Ensure-Clone {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Repository,
+ [Parameter(Mandatory)]
+ [string]$Ref,
+ [Parameter(Mandatory)]
+ [string]$Path,
+ [switch]$Recursive
+ )
+
+ if (Test-Path -LiteralPath (Join-Path $Path ".git")) {
+ Invoke-Native -FilePath "git.exe" -ArgumentList @("fetch", "--depth", "1", "origin", $Ref) -WorkingDirectory $Path
+ Invoke-Native -FilePath "git.exe" -ArgumentList @("checkout", "--detach", "FETCH_HEAD") -WorkingDirectory $Path
+ if ($Recursive) {
+ Invoke-Native -FilePath "git.exe" -ArgumentList @("submodule", "update", "--init", "--recursive") -WorkingDirectory $Path
+ }
+ return
+ }
+
+ if (Test-Path -LiteralPath $Path) {
+ throw "The existing workspace path is not a Git repository: $Path"
+ }
+
+ $cloneArguments = @("clone", "--depth", "1", "--branch", $Ref)
+ if ($Recursive) {
+ $cloneArguments += "--recursive"
+ }
+ $cloneArguments += @($Repository, $Path)
+ Invoke-Native -FilePath "git.exe" -ArgumentList $cloneArguments -WorkingDirectory (Split-Path -Parent $Path)
+}
+
+function Restore-EnvironmentVariable {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Name,
+ [Parameter(Mandatory)]
+ [hashtable]$Saved
+ )
+
+ if ($Saved.ContainsKey($Name)) {
+ Set-Item -Path ("Env:" + $Name) -Value $Saved[$Name]
+ } else {
+ Remove-Item -Path ("Env:" + $Name) -ErrorAction SilentlyContinue
+ }
+}
+
+function Install-Boost {
+ param(
+ [Parameter(Mandatory)]
+ [string]$LibrimePath,
+ [Parameter(Mandatory)]
+ [string]$BoostPath
+ )
+
+ $boostEngine = Join-Path $BoostPath "tools\build\src\engine\b2.exe"
+ if (!(Test-Path -LiteralPath $boostEngine)) {
+ try {
+ Invoke-Batch -BatchPath ".\install-boost.bat" -WorkingDirectory $LibrimePath
+ } catch {
+ Write-Warning "Official install-boost.bat failed; retrying Boost bootstrap with the active MSVC toolchain."
+ }
+ }
+
+ if (!(Test-Path -LiteralPath $boostEngine)) {
+ if (!(Test-Path -LiteralPath (Join-Path $BoostPath "tools\build\src\engine\bootstrap.bat"))
+ || !(Test-Path -LiteralPath (Join-Path $BoostPath "libs"))) {
+ throw "Boost sources are incomplete and bootstrap could not produce b2.exe: $BoostPath"
+ }
+
+ Invoke-Batch -BatchPath ".\bootstrap.bat" -ArgumentList @("msvc") -WorkingDirectory $BoostPath
+ }
+
+ if (!(Test-Path -LiteralPath (Join-Path $BoostPath "b2.exe"))) {
+ throw "Boost bootstrap did not produce b2.exe: $BoostPath"
+ }
+
+ Invoke-Batch -BatchPath ".\b2.exe" -ArgumentList @("headers") -WorkingDirectory $BoostPath
+}
+
+if (!$WorkspaceDirectory) {
+ $WorkspaceDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "librime-ahk-sample-$Architecture"
+}
+if (!$OutputDirectory) {
+ $OutputDirectory = Join-Path $WorkspaceDirectory "output"
+}
+if (!$PythonVenvDirectory) {
+ $PythonVenvDirectory = Join-Path $WorkspaceDirectory ".venv"
+}
+
+$WorkspaceDirectory = [System.IO.Path]::GetFullPath($WorkspaceDirectory)
+$OutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory)
+$PythonVenvDirectory = [System.IO.Path]::GetFullPath($PythonVenvDirectory)
+$librimeDirectory = Join-Path $WorkspaceDirectory "librime"
+$buildDirectory = Join-Path $librimeDirectory "build"
+$defaultBoostRoot = Join-Path $librimeDirectory "deps\boost-1.89.0"
+
+$savedEnvironment = @{}
+foreach ($name in @("RIME_ROOT", "BOOST_ROOT", "CC", "CXX", "CMAKE_GENERATOR", "CMAKE_PREFIX_PATH", "PATH")) {
+ $environmentItem = Get-Item -Path ("Env:" + $name) -ErrorAction SilentlyContinue
+ if ($environmentItem) {
+ $savedEnvironment[$name] = $environmentItem.Value
+ }
+}
+
+try {
+ if (!(Get-Command "cl.exe" -ErrorAction SilentlyContinue)) {
+ throw "cl.exe was not found. Run this script from a Visual Studio Build Tools Developer PowerShell or Command Prompt."
+ }
+ if (!(Get-Command "cmake.exe" -ErrorAction SilentlyContinue)) {
+ throw "cmake.exe was not found. Install CMake and add it to PATH."
+ }
+ if (!(Get-Command "ninja.exe" -ErrorAction SilentlyContinue)) {
+ throw "ninja.exe was not found. Install Ninja and add it to PATH."
+ }
+ $pythonCommand = Get-Command $PythonExecutable -ErrorAction SilentlyContinue
+ if (!$pythonCommand) {
+ throw "Python was not found: $PythonExecutable"
+ }
+
+ New-Item -ItemType Directory -Path $WorkspaceDirectory -Force | Out-Null
+ Ensure-Clone -Repository "https://github.com/rime/librime.git" -Ref $LibrimeRef -Path $librimeDirectory -Recursive
+
+ $venvPython = Join-Path $PythonVenvDirectory "Scripts\python.exe"
+ if (!(Test-Path -LiteralPath $venvPython)) {
+ & $pythonCommand.Source -m venv $PythonVenvDirectory
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed to create Python venv with $($pythonCommand.Source)."
+ }
+ }
+ if (!(Test-Path -LiteralPath $venvPython)) {
+ throw "Python venv was created without an interpreter: $venvPython"
+ }
+ $venvScripts = Split-Path -Parent $venvPython
+ $env:PATH = $venvScripts + ";" + $env:PATH
+
+ if ($BoostRoot) {
+ $resolvedBoostRoot = (Resolve-Path -LiteralPath $BoostRoot).Path
+ if (!(Test-Path -LiteralPath (Join-Path $resolvedBoostRoot "boost"))) {
+ throw "Boost headers were not found under BOOST_ROOT: $resolvedBoostRoot"
+ }
+ } else {
+ Install-Boost -LibrimePath $librimeDirectory -BoostPath $defaultBoostRoot
+ $resolvedBoostRoot = $defaultBoostRoot
+ }
+
+ if (!(Test-Path -LiteralPath (Join-Path $resolvedBoostRoot "boost"))) {
+ throw "Boost headers were not found under BOOST_ROOT: $resolvedBoostRoot"
+ }
+
+ $env:RIME_ROOT = $librimeDirectory
+ $env:BOOST_ROOT = $resolvedBoostRoot
+ $env:CC = "cl"
+ $env:CXX = "cl"
+ $env:CMAKE_GENERATOR = "Ninja"
+ Remove-Item -Path "Env:CMAKE_PREFIX_PATH" -ErrorAction SilentlyContinue
+
+ $envContent = @(
+ "set RIME_ROOT=%CD%",
+ "set BOOST_ROOT=$resolvedBoostRoot",
+ "set CXX=cl",
+ "set CC=cl",
+ "set CMAKE_GENERATOR=Ninja"
+ ) -join "`r`n"
+ Set-Content -LiteralPath (Join-Path $librimeDirectory "env.bat") -Value $envContent -Encoding ascii
+
+ if ($SkipDependencyBuild) {
+ if (!(Test-Path -LiteralPath (Join-Path $librimeDirectory "bin"))
+ || !(Test-Path -LiteralPath (Join-Path $librimeDirectory "include"))
+ || !(Test-Path -LiteralPath (Join-Path $librimeDirectory "lib"))
+ || !(Test-Path -LiteralPath (Join-Path $librimeDirectory "share"))) {
+ throw "-SkipDependencyBuild was specified, but the librime dependency artifacts are incomplete."
+ }
+ } else {
+ Invoke-Batch -BatchPath ".\build.bat" -ArgumentList @("deps") -WorkingDirectory $librimeDirectory
+ }
+
+ # Match librime's Windows CI: Ninja drives cl.exe directly and does not require the IDE.
+ $cmakeArguments = @(
+ "-S", ".",
+ "-B", "build",
+ "-G", "Ninja",
+ "-DCMAKE_INSTALL_PREFIX:PATH=dist",
+ "-DCMAKE_BUILD_TYPE:STRING=Release",
+ "-DPython3_EXECUTABLE:FILEPATH=$venvPython",
+ "-DENABLE_LOGGING:BOOL=ON",
+ "-DBUILD_TEST:BOOL=ON",
+ "-DBUILD_STATIC:BOOL=ON",
+ "-DBUILD_SHARED_LIBS:BOOL=ON",
+ "-DBUILD_MERGED_PLUGINS:BOOL=OFF",
+ "-DBUILD_SAMPLE:BOOL=ON",
+ "-DBUILD_SEPARATE_LIBS:BOOL=ON",
+ "-DRIME_PLUGINS_DIR:STRING=rime-plugins"
+ )
+ Invoke-Native -FilePath "cmake.exe" -ArgumentList $cmakeArguments -WorkingDirectory $librimeDirectory
+ Invoke-Native -FilePath "cmake.exe" -ArgumentList @("--build", "build", "--config", "Release", "--target", "rime-sample") -WorkingDirectory $librimeDirectory
+
+ $pluginCandidates = @(Get-ChildItem -LiteralPath $buildDirectory -Filter "rime-sample.dll" -File -Recurse)
+ if ($pluginCandidates.Count -ne 1) {
+ throw "Expected exactly one rime-sample.dll below $buildDirectory, found $($pluginCandidates.Count)."
+ }
+
+ New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
+ $pluginPath = Join-Path $OutputDirectory "rime-sample.dll"
+ Copy-Item -LiteralPath $pluginCandidates[0].FullName -Destination $pluginPath -Force
+ Write-Output "Built standalone official librime sample plugin: $pluginPath"
+ Write-Output "Matching librime DLL: $(Join-Path $buildDirectory 'bin\rime.dll')"
+} finally {
+ foreach ($name in @("RIME_ROOT", "BOOST_ROOT", "CC", "CXX", "CMAKE_GENERATOR", "CMAKE_PREFIX_PATH", "PATH")) {
+ Restore-EnvironmentVariable -Name $name -Saved $savedEnvironment
+ }
+}
diff --git a/Lib/RabbitCommon.ahk b/Lib/RabbitCommon.ahk
index 84b5f31..14a4eae 100644
--- a/Lib/RabbitCommon.ahk
+++ b/Lib/RabbitCommon.ahk
@@ -29,6 +29,7 @@ global RABBIT_VERSION := "dev"
#Include librime-ahk\rime_api.ahk
#Include librime-ahk\rime_levers_api.ahk
+#Include librime-ahk\rime_plugins.ahk
global AHK_NOTIFYICON := 0x404
global WM_LBUTTONDOWN := 0x201
@@ -106,6 +107,13 @@ RabbitCreateTraits() {
return traits
}
+RabbitLoadPlugins(rime, traits, base_module := "default") {
+ local loader := RimePluginLoader(rime)
+ local result := loader.load()
+ loader.append_modules(traits, base_module, result)
+ return result
+}
+
RabbitUserDataPath() {
local size, path
if FileExist(A_ScriptDir . "\.portable") {
diff --git a/Lib/RabbitDeployerContext.ahk b/Lib/RabbitDeployerContext.ahk
index 81bba21..d139d62 100644
--- a/Lib/RabbitDeployerContext.ahk
+++ b/Lib/RabbitDeployerContext.ahk
@@ -42,8 +42,9 @@ class RabbitDeployerContext {
}
try {
this.traits := RabbitCreateTraits()
+ RabbitLoadPlugins(this.rime, this.traits, "deployer")
this.rime.setup(this.traits)
- this.rime.deployer_initialize(0)
+ this.rime.deployer_initialize(this.traits)
this.rime_initialized := true
} catch {
this.mutex.Close()
diff --git a/Lib/RabbitFrontendRuntime.ahk b/Lib/RabbitFrontendRuntime.ahk
index fcf66c3..7870dca 100644
--- a/Lib/RabbitFrontendRuntime.ahk
+++ b/Lib/RabbitFrontendRuntime.ahk
@@ -44,6 +44,7 @@ class RabbitFrontendRuntime {
}
try {
this.context.traits := this.CreateTraits()
+ this.LoadPlugins(this.context.traits)
this.rime.setup(this.context.traits)
this.rime.set_notification_handler(this.notification_callback, 0)
this.rime.initialize(this.context.traits)
@@ -136,6 +137,10 @@ class RabbitFrontendRuntime {
return RabbitCreateTraits()
}
+ LoadPlugins(traits) {
+ return RabbitLoadPlugins(this.rime, traits)
+ }
+
RunStartupMaintenance(maintenance, first_run, first_install_callback) {
if maintenance != RABBIT_NO_MAINTENANCE {
RabbitUpdateMaintenanceTrayIcon()
diff --git a/tests/unit/RabbitFrontendRuntimeTest.ahk b/tests/unit/RabbitFrontendRuntimeTest.ahk
index 9a6d2ca..ec1f39c 100644
--- a/tests/unit/RabbitFrontendRuntimeTest.ahk
+++ b/tests/unit/RabbitFrontendRuntimeTest.ahk
@@ -93,6 +93,9 @@ class RabbitFrontendRuntimeProbe extends RabbitFrontendRuntime {
return 1
}
+ LoadPlugins(*) {
+ }
+
RunStartupMaintenance(*) {
return true
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment