|
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 |
|
+ } |
|
+} |
|
|