PowerToys 本地化开发指南:从 LocProject.json、lcl 文件到 rc 转换与卫星程序集打包
本篇基于 PowerToys 本地化开发文档 展开,系统讲解 PowerToys 的 CDPX 流水线本地化机制、LocProject.json 配置、C++/C#/UWP 三类项目的本地化启用步骤、lcl 文件格式与安全防护机制,以及如何把本地化资源(C++ 的 rc 字符串表、UWP 的 resources.pri、C# 的卫星程序集)正确接入 MSI 安装包。读完本文,你将能够在 PowerToys 中为新模块启用本地化、为字符串建立可翻译的资源文件,并理解本地化产物从流水线到安装包的完整流转链路。
一、本地化体系总览
PowerToys 的本地化围绕一个核心原则:代码中不允许硬编码 UI 显示字符串,所有可展示文本必须来自资源文件。按项目类型分为三条资源链路:
| 项目类型 | 资源格式 | 本地化产物 | 打包方式 |
|---|---|---|---|
| C#(.NET) | Resources.resx |
各语言卫星程序集 langId\ProjName.resources.dll |
卫星 dll 需额外加入 MSI |
| C++ | Resources.resx(转换源)→ 生成的 .rc/.h |
字符串表编译进 dll/exe 本身 | 无需额外文件 |
| UWP | Resources.resw |
编译进 resources.pri |
无需额外文件 |
从仓库结构可以印证这套体系:C# 模块的英文资源位于模块根目录,例如 ActionRunner 的 Resources.resx 与 KeyboardManagerEditor 的 Resources.resx;UWP 风格模块则使用 Strings\en-us\Resources.resw,例如 ShortcutGuide.Ui 的 Resources.resw 与 cmdpal UI 的 Resources.resw。
二、流水线上的本地化(CDPX)
2.1 build-localization 步骤与 LocProject.json
本地化步骤在 CDPX 流水线中先于解决方案构建执行:它运行 build-localization 脚本,调用 Localization.XLoc 包为所有启用了本地化的项目生成 resx 文件。Localization.XLoc 在仓库根目录运行,扫描每一个 LocProject.json 文件。每个本地化项目的项目根目录下都有这样一份 LocProject.json,它描述:
- 英文 resx 源文件位置;
- 本地化的语言集合;
- 生成后的本地化 resx 文件复制到的输出路径;
- 以及其他参数,例如语言 ID 是以目录名还是文件名形式体现在输出路径中。
一个典型的 LocProject.json(项目位于 src\path,英文资源在 resources\Resources.resx):
{
"Projects": [
{
"LanguageSet": "Azure_Languages",
"LocItems": [
{
"SourceFile": "src\\path\\resources\\Resources.resx",
"CopyOption": "LangIDOnName",
"OutputPath": "src\\path\\resources"
}
]
}
]
}
字段说明:
| 字段 | 含义 |
|---|---|
LanguageSet |
语言集合名称,Azure_Languages 表示使用 Azure 语言集(含 27 种语言) |
SourceFile |
英文 resx 源文件(相对仓库根目录的路径) |
CopyOption |
语言 ID 放置方式:LangIDOnName(拼进文件名)或 LangIDOnFolder(作为目录) |
OutputPath |
生成的各语言 resx 复制到的目录 |
当 CDPX 流水线运行且英文 resx 发生变更时,本地化团队会收到通知。对每个启用本地化的项目,会在 LocProject.json 同目录生成一个 loc 文件夹(例如 Microsoft.Launcher 模块下的 loc 目录),其中按语言建立子目录,子目录下再按 LocProject.json 中 OutputPath 对应的嵌套路径组织,每个目录中有一个 lcl 文件。lcl 文件包含英文资源及其对应语言的译文,详见 第四节。resx 文件生成后,会在 Build PowerToys 步骤中被用于构建各模块的本地化版本。
2.2 restore-localization 与网络隔离
本地化脚本依赖特定的 NuGet 包,因此 build-localization 之前必须先运行 restore-localization 脚本安装所需包。该脚本必须放在流水线的 restore 阶段执行,因为 CDPX 流水线在 build 阶段处于网络隔离状态,无法在线还原包;还原时使用流水线中配置的 Toolset 包源完成。
2.3 IsPipeline 变量与 MSI 的联动
C# 项目的本地化资源 dll 只在流水线构建时才被加入 MSI。判断方式是检查 IsPipeline 变量是否已定义——该变量在流水线构建安装器之前被设置。之所以需要这个开关,是因为本地化 resx 文件只存在于流水线上:本地开发者机器上没有这些文件,若安装器工程无条件引用它们,本地构建安装器项目会直接失败。
2.4 当前仓库中的本地化流水线入口
当前仓库快照中,本地化流水线的入口是 loc.yml。该流水线:
- 采用定时触发(
cron: "0 3 * * 2-6",太平洋工作时间每周一至五结束后于 03:00 UTC 运行),且always: false,即仅在代码有变更时执行; - 通过
MicrosoftTDBuild.tdbuild-task(Touchdown Build)任务把资源文件推送到本地化团队,资源匹配路径为:
resourceFilePath: |
src\**\Resources.resx
src\**\Resource.resx
src\**\Resources.resw
pseudoSetting: Included表示包含伪本地化(pseudo)输出,便于在未获得真实译文前验证多语言布局;- 输出目录
LocOutput会被打包为LocOutput.tar.gz发布为流水线工件,方便排查本地化输出问题。
从这份配置可以确认:本地化系统的输入即仓库中所有模块的英文 Resources.resx / Resource.resx / Resources.resw,与第二、三节的资源约定完全一致。
三、为新项目启用本地化
第一步对所有类型相同:在项目根目录创建 LocProject.json(格式见 2.1 节)。把本地化文件加入 MSI 的步骤见第六节。
3.1 C++ 项目:resx 到 rc 的转换链
C++ 项目原生不支持 resx,而是使用 .rc + resource.h。由于 CDPX 流水线不支持直接本地化 rc 文件(其替代方案是直接从二进制翻译资源,难以维护),PowerToys 采用了一条自定义转换链:以 resx 为本地化源,再用脚本把各语言 resx 转换为带字符串表的 rc 文件与 resource.h。
第一步:把已有字符串表转成 resx。 如果项目已有 .rc 文件,把字符串表拷贝到一个单独的 txt 文件,然后运行 convert-stringtable-to-resx.ps1 脚本。该脚本对输入格式要求较严格:每行必须是 IDS_ResName L"ResourceValue" 形式,IDS_ResName 与 L"..." 之间可以有任意多个空格。脚本将其转换为 resgen 工具可识别的格式后再转成 resx。转换过程中资源名从全大写改为标题式(Title Case),并去掉 IDS_ 前缀。转义字符可能需要手工处理,例如 .rc 中双引号写作 "",转 resx 前需替换为单个 "。
第二步:拆分 base 文件并挂接构建事件。 resx 生成后,把现有 rc 和 h 文件重命名为 ProjName.base.rc 与 resource.base.h;在 rc 文件中删除需要本地化的字符串表,在 h 文件中删除所有对应本地化资源的 #define。然后在 C++ 工程的 vcxproj 中添加如下构建事件:
<Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild">
<Exec LogStandardErrorAsError="false" Command="powershell -NonInteractive -executionpolicy Unrestricted -NoProfile $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h ProjName.base.rc ProjName.rc" />
</Target>
第三步:理解 convert-resx-to-rc.ps1 的生成逻辑。 convert-resx-to-rc.ps1 接收 5 个必填参数(resx 所在目录、base 头文件名、目标头文件名、base rc 文件名、目标 rc 文件名)和 1 个可选参数(资源起始 ID,默认 101,见脚本 第 18-26 行)。它的处理流程:
- 递归遍历目录中所有
.resx文件,从文件名或父目录名解析语言代码(脚本 第 95-118 行;对zh-CN这类"语言+地区"形式会回退匹配纯语言zh); - 用
resgen把 resx 转成 rc 所需的字符串表格式,资源名恢复为IDS_前缀 + 全大写(还原为原始命名),字符串中的"一律转义为""以避免构建错误; - 资源
#define声明从 101 起依次编号,且只根据其中一种语言生成一次(避免重复编号); - 每种语言的字符串表按如下格式追加到 rc 文件:
#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)
LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
STRINGTABLE
BEGIN
strings
END
#endif
关键限制:语言代码表是硬编码的。 由于没有 API 可以从流水线给出的 langId 反查 AFX_TARG_*、LANG_*、SUBLANG_* 值,脚本在 第 48-76 行 维护了一个语言哈希表,覆盖 en、zh-Hans/zh-CN、cs、hu、pl、ro、sk、bg、ru、ca、de、es、fr、it、nl、nb-NO、pt-BR、eu-ES、tr、he、ar、ja、ko、sv、pt-PT、zh-Hant/zh-TW 等语言。若未来本地化团队新增语言,必须同步更新这个哈希表,否则脚本会输出 Unknown language 警告并跳过该语言。要确定某个语言的代码,可以在 Resource View 中右键字符串表选 Insert Copy 并选择对应语言,工具会自动生成所需代码供参考。
生成物写入 Generated Files 目录(该目录被 .gitignore 忽略,且文件头部带有"auto-generated"警告注释)。因此:
- 这两个生成文件内部的
#include需要多加一层..\; - 使用
resource.h的代码要写成#include "Generated Files\resource.h"; - base 文件加入 vcxproj 时应改为不参与构建的
<None>项,避免与生成物冲突:
<None Include="Resources.resx" />
多工程共享 rc/resource.h 的情况:有些 rc/resource.h 被多个项目共用(例如 Keyboard Manager)。此时把构建事件提升到目录级 Directory.Build.targets,保证任何项目开始构建前 rc 文件已生成。仓库中现成的例子是 keyboardmanager 的 Directory.Build.targets:
<Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild">
<Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 ..\dll resource.base.h resource.h KeyboardManager.base.rc KeyboardManager.rc" />
</Target>
消费字符串:C++ 侧统一使用 GET_RESOURCE_STRING(resource_id) 宏读取字符串表,该宏定义在 src/common/utils/resources.h 第 210-211 行:
#define GET_RESOURCE_STRING(resource_id) get_resource_string(resource_id, reinterpret_cast<HINSTANCE>(&__ImageBase), L#resource_id)
#define GET_RESOURCE_STRING_FALLBACK(resource_id, fallback) get_resource_string(resource_id, reinterpret_cast<HINSTANCE>(&__ImageBase), fallback)
GET_RESOURCE_STRING_FALLBACK 在资源缺失时提供回退字符串,是本地化资源尚未就绪时的稳健选择。
3.2 C# 项目:直接纳入 resx
C# 项目原生支持 resx,唯一要做的是把生成的各语言 resx 纳入构建:
- .NET Core 项目:自动包含,
csproj无需改动; - 其他项目:在 csproj 中加一行:
<EmbeddedResource Include="Properties\Resources.*.resx" />
两个已知注意事项:
- 带本地化资源构建时可能出现警告
Referenced assembly 'mscorlib.dll' targets a different processor,这是 Visual Studio 的已知 bug,可忽略; - XAML 资源迁移到 resx:若项目原来用 XAML
System.String资源,最简迁移路径是把资源改成=分隔的纯文本(手工全局替换或脚本),再用resgen转为 resx。例如把
<system:String x:Key="wox_plugin_calculator_plugin_name">Calculator</system:String>
<system:String x:Key="wox_plugin_calculator_plugin_description">Allows to do mathematical calculations.(Try 5*3-2 in Wox)</system:String>
<system:String x:Key="wox_plugin_calculator_not_a_number">Not a number (NaN)</system:String>
改写为
wox_plugin_calculator_plugin_name=Calculator
wox_plugin_calculator_plugin_description=Allows to do mathematical calculations.(Try 5*3-2 in Wox)
wox_plugin_calculator_not_a_number=Not a number (NaN)
然后在 Developer Command Prompt for VS 中运行 resgen 转成 resx。resx 加入工程并配置资源生成器后,代码中对字符串的引用要改为 Properties.Resources.resName,替换掉原来的自定义 API。
3.3 UWP 项目:resw 通配包含
UWP 项目期望 resw 文件(格式与 resx 几乎相同),但文件组织形式不同:必须位于 fullLangId\Resources.resw 路径下。因此要把 csproj 中单语言的包含:
<PRIResource Include="Strings\en-us\Resources.resw" />
替换为通配形式,以纳入流水线生成的全部语言目录:
<PRIResource Include="Strings\*\Resources.resw" />
当前仓库中 cmdpal UI、PowerDisplay、RegistryPreview 等模块均采用这种 Strings\en-us\Resources.resw 布局。
四、lcl 文件格式与防失效机制
lcl 文件包含英文 resx 中的全部资源;若某条资源已有译文,则一并附上。一条资源的 lcl 条目形如:
<Item ItemId=";EditKeyboard_WindowName" ItemType="0;.resx" PsrId="211" Leaf="true">
<Str Cat="Text">
<Val><![CDATA[Remap keys]]></Val>
<Tgt Cat="Text" Stat="Loc" Orig="New">
<Val><![CDATA[Remapper des touches]]></Val>
</Tgt>
</Str>
<Disp Icon="Str" />
</Item>
结构要点:
<Val>(<Str>直属)是英文原文;<Tgt>元素是译文容器,Stat="Loc"表示已翻译,Orig="New"表示新字符串。lcl 文件的初始提交中只有英文,没有<Tgt>元素;- 条目结构对应 KeyboardManagerEditor 的 Resources.resx 中
EditKeyboard_WindowName这样的资源键。
防失效(fail-safe)机制:CDPX 本地化系统对 lcl 文件做一致性检查——若 <Val><![CDATA[*]]></Val> 中的英文字符串与英文 Resources.resx 中的值不一致,则该条译文不会被复制到本地化 resx 中。这样设计的目的是:当英文资源被修改后,过期的旧译文不会被加载,程序会回退使用英文原文,等待本地化团队更新译文。这决定了上游团队的实践约束:修改英文 resx 字符串时,旧译文会自动失效回退为英文,不会出现"旧译文配新语境"的错乱。
五、LEGO 本地化 PR 的常见合并问题
LEGO PR(本地化团队提交的批量翻译 PR)一次只更新部分字符串,多个 PR 可能同时修改同一批文件,从而产生合并冲突。大多数冲突会在 GitHub 上明确显示,但偶尔会出现"坏合并":文件表面合并成功,实际格式已损坏,例如单个资源出现两个 <Tgt> 元素。排查与修复手段:
- 按第四节的 lcl 条目格式校正损坏文件,确保每条资源至多一个
<Tgt>; - 每个 LEGO PR 都应跑一遍 build farm,若本地化步骤报错,检查对应项目的 resx/lcl 文件是否存在残留冲突标记或重复元素。
六、为新项目启用本地化 MSI
6.1 C++ 与 UWP:无需额外操作
C++ 项目的所有资源编译进 dll/exe 本身,UWP 项目的资源进入 resources.pri(未本地化的项目同样有该文件),因此这两类项目不产生需要额外加入 MSI 的本地化文件。
验证 UWP 资源是否成功写入 resources.pri 的方法:
- 打开
Developer Command Prompt for VS; - 进入 pri 文件所在目录,运行:
makepri.exe dump /if .\resources.pri
- 检查生成的
resources.pri.xml,其末尾包含各语言的资源候选项,例如:
<NamedResource name="GeneralSettings_RunningAsAdminText" uri="ms-resource://f4f787a5-f0ae-47a9-be89-5408b1dd2b47/Resources/GeneralSettings_RunningAsAdminText">
<Candidate qualifiers="Language-FR" type="String">
<Value>Running as administrator</Value>
</Candidate>
<Candidate qualifiers="Language-EN-US" isDefault="true" type="String">
<Value>Running as administrator</Value>
</Candidate>
</NamedResource>
6.2 C#:卫星程序集加入 MSI
C# 项目构建时会为每种语言生成卫星程序集:项目 ProjName 会产出 langId\ProjName.resources.dll(langId 格式与 lcl 文件一致)。这些卫星 dll 必须加入 MSI,但只能来自流水线构建的解决方案——本地机器上没有本地化 resx,无条件引用会导致本地安装器构建失败。
做法是在 installer 目录下的 Product.wxs 中,把项目目录名加入受 IsPipeline 检查控制的本地化资源列表,并按以下模式为项目创建资源组件:
<Component Id="ProjName_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)ProjNameInstallFolder">
<File Id="ProjName_$(var.IdSafeLanguage)_File" Source="$(var.BinX64Dir)modules\ProjName\$(var.Language)\ProjName.resources.dll" />
</Component>
两个配套要点:
- 签名:确保新增 dll 被流水线签名。当前所有
*.resources.dll形式的程序集都在流水线签名清单中; - 时机:卫星 dll 的 MSI 组件应在本地化团队完成 lcl 文件初始提交之后再加——否则流水线上不存在任何 resx 可用来生成 dll,流水线会失败。
七、字符串使用规范(Working With Strings)
要支持本地化,代码中不得出现硬编码的 UI 显示字符串,必须通过资源文件取字符串。
7.1 C++
用 StringTable 资源存储字符串,用 resource.h 存储与字符串绑定的 ID,配合 Visual Studio 资源编辑器维护:
resource.h(XXX 必须唯一,通常取最后一个字符串 ID + 1):
#define IDS_MODULE_DISPLAYNAME XXX
资源定义脚本 validmodulename.rc:
STRINGTABLE
BEGIN
IDS_MODULE_DISPLAYNAME L"Module Name"
END
代码中消费:
#include <common.h>
std::wstring s = GET_RESOURCE_STRING(IDS_MODULE_DISPLAYNAME);
7.2 C#
用 XML 资源文件(.resx)存储 UI 字符串,用 ResourceManager 消费:
<data name="ValidUIDisplayString" xml:space="preserve">
<value>Description to be displayed on UI.</value>
<comment>This text is displayed when XYZ button clicked.</comment>
</data>
手工消费:
System.Resources.ResourceManager manager = new System.Resources.ResourceManager(baseName, assembly);
string validUIDisplayString = manager.GetString("ValidUIDisplayString", resourceCulture);
若资源文件由 Visual Studio 生成,直接使用自动生成的 Resources.Designer.cs 封装的 Resources 类即可:
string validUIDisplayString = Resources.ValidUIDisplayString;
八、小结
PowerToys 的本地化体系可以归纳为一条主链:英文 resx/resw 是唯一的本地化源头 → CDPX 流水线通过 LocProject.json 与 Localization.XLoc 生成 loc 目录下的 lcl 文件 → 本地化团队在 lcl 中补充译文 → 流水线生成各语言 resx/resw → C# 产出卫星 dll(经 IsPipeline 检查加入 MSI)、C++ 经 convert-resx-to-rc.ps1 生成本地化 rc/resource.h 编译进二进制、UWP 汇入 resources.pri。开发者的日常职责落在两端:一端是按第三、七节的规范建立可翻译资源(新模块配 LocProject.json、C++ 项目挂接 GenerateResourceFiles 构建事件、UWP 用通配 PRIResource);另一端是理解 lcl 防失效机制与 LEGO PR 冲突处理,保证英文字符串变更与译文更新之间的安全衔接。需要扩展支持语言时,记得同步维护 convert-resx-to-rc.ps1 中硬编码的语言代码表。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00