I have an existing localized WPF application, and my localizations are stored in a bunch of .resx files, and accessed through the ".Designer.cs" files generated by the default resx custom tool. Each supported language has its own version of every .resx file. It works perfectly fine, but i have to recompile the application everytime we want to adjust the translations, which is not the most practical thing to do once the application has been shipped to multiple customers.
My application gets published in PublishSingleFile mode, and my setup adds some configuration files along with it. The user is expected to access to the configuration files at some point, so i'd like to keep that directory as clean as possible.
It seems that the .NET way to do that is through satellite assemblies, but their interaction with published apps and the PublishSingleFile option is not very well documented.
How can one go about it ?
I made a test project on github to try and solve that. There is a tag for the base project, and different tags for the steps described in (the original version of) this answer. None of this is too complicated, but in order to make everything work there are quite a few steps. The steps described in this answer are based on that project.
It's a very basic WPF app with 1 windows and a couple controls, 2 resource files Resources.resx
and Errors.resx
, in a Properties
subfolder, and their translations in french and german into .{culture}.resx
files (so 6 files in total). There's a button to switch the UI from english to french, then to french from german, and from german back to english.
Before we get to explaining how to do it, here are a few things to consider :
resgen.exe
and al.exe
. AFAIK, the version used does not matter too much, i think i was able to make it work with the .net framework 2 version of these files, at some point.C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\
resgen.exe
x64\al.exe
<- Make sure you use the x64 version if you compile in x64Let's take this step by step.
None/Do not copy
Resources.resx
and Errors.resx
Errors.Designer.cs
and Resources.Designer.cs
.resources
files from your default language .resx
.
set resgen = "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\ResGen.exe"
resgen Properties\Resources.resx /str:cs,$(ProjectName).Properties,Resources /publicClass
resgen Properties\Errors.resx /str:cs,$(ProjectName).Properties,Errors /publicClass
Resources.resources
and Errors.resources
in your Properties
folder. Ignore the warning, everything is generated just fine.Resources.resources
and Errors.resources
' properties to Embedded Resource/Do not copy
In the pre-build event, add the following lines
echo "fr-FR"
%resgen% Properties\Errors.fr-FR.resx
%resgen% Properties\Resources.fr-FR.resx
echo "de-DE"
%resgen% Properties\Errors.de-DE.resx
%resgen% Properties\Resources.de-DE.resx
echo "en-US"
echo F|xcopy Properties\Errors.resources Properties\Errors.en-US.resources /Y
echo F|xcopy Properties\Resources.resources Properties\Resources.en-US.resources /Y
Build once. The pre-build event will generate .resources files for both files, for all 3 languages.
Set all .resources
files' properties to Embedded Resource/Do not copy
Build again, visual studio will now generate satellite assemblies for all 3 languages.
The "neutral" .resources
file gets embedded in the application's dll. If no satellite assembly is found, the texts will be translated based on that file. In order to modify the default translations, we would have to recompile the application's dll, by rebuilding the entire application. However, cutlure-specific translations have been embedded into satellite assemblies, which can be compiled and shipped individually, without having to touch the application.
The pre-build event does the following :
.resources
files for the neutral culture, while automatically creating a .cs
file which maps each resource string to a static property for easy use ("strongly typed resources", just like the .Designer.cs
files automatically created by the default Custom Tool for .resx
files)..resources
files for the french and german cultures..resources
files into english .resources
files..resources
files specific to that culture into that assembly.The application has a button that switches the culture. To test that the satellite assemblies work, you can simply remove one culture, say de-DE, and check that it translates to french but reverts to neutral (english) when german is selected.
A more thorough way to test it would be to generate new satellite assemblies. You can make a script for that.
.resx
files. Do not build again.updateDll.bat
) to generate the satellite assemblies. The following assumes we are building and testing in Debug|x64
.
set resgen="C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\ResGen.exe"
set al="C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\x64\al.exe"
%resgen% Properties\Resources.resx
%resgen% Properties\Errors.resx
%resgen% Properties\Resources.fr-FR.resx
%resgen% Properties\Errors.fr-FR.resx
%resgen% Properties\Resources.de-DE.resx
%resgen% Properties\Errors.de-DE.resx
%al% -target:lib -embed:Properties\Resources.resources,SatelliteLocDemo.Properties.Resources.en-US.resources -embed:Properties\Errors.resources,SatelliteLocDemo.Properties.Errors.en-US.resources -template:bin\x64\Debug\SatelliteLocDemo.dll -culture:en-US -out:bin\x64\Debug\en-US\SatelliteLocDemo.resources.dll
%al% -target:lib -embed:Properties\Resources.fr-FR.resources,SatelliteLocDemo.Properties.Resources.fr-FR.resources -embed:Properties\Errors.fr-FR.resources,SatelliteLocDemo.Properties.Errors.fr-FR.resources -template:bin\x64\Debug\SatelliteLocDemo.dll -culture:fr-FR -out:bin\x64\Debug\fr-FR\SatelliteLocDemo.resources.dll
%al% -target:lib -embed:Properties\Resources.de-DE.resources,SatelliteLocDemo.Properties.Resources.de-DE.resources -embed:Properties\Errors.de-DE.resources,SatelliteLocDemo.Properties.Errors.de-DE.resources -template:bin\x64\Debug\SatelliteLocDemo.dll -culture:de-DE -out:bin\x64\Debug\de-DE\SatelliteLocDemo.resources.dll
Having a folder for each language next to your application can look pretty bad when the user is expected to interact with that folder (for editing configuration files for example). We will instead put all translations in a single Languages
directory, to keep things clean.
Don't let Visual Studio generate satellite assemblies
.culture.resources
files from the solution (keep the neutral ones, Resources.resources
and Errors.resources
, so that the application's assembly remains bundled with a default translation)..resources
files in the pre-build event. Keep only the culture-neutral ones (remove everything except the first 3 lines).updateDll.bat
. The .culture.resources
files will be generated into the obj\
folder. We do not need one for the english language, the en-US satellite assembly will be generated directly from the neutral .resources
files.
set resgen="C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\ResGen.exe"
set al="C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\x64\al.exe"
echo "Compile resx"
SET resourcesPath="obj\$(PlatformName)\$(ConfigurationName)\Properties"
if not exist %resourcesPath% mkdir %resourcesPath%
%resgen% Properties\Resources.fr-FR.resx %resourcesPath%\Resources.fr-FR.resources
%resgen% Properties\Resources.de-DE.resx %resourcesPath%\Resources.de-DE.resources
%resgen% Properties\Errors.fr-FR.resx %resourcesPath%\Errors.fr-FR.resources
%resgen% Properties\Errors.de-DE.resx %resourcesPath%\Errors.de-DE.resources
echo "en-US"
SET enusPath="$(TargetDir)\Languages\en-US"
if not exist %enusPath% mkdir %enusPath%
%al% -target:lib -embed:Properties\Resources.resources,$(ProjectName).Properties.Resources.en-US.resources -embed:Properties\Errors.resources,$(ProjectName).Properties.Errors.en-US.resources -template:$(TargetPath) -culture:en-US -platform:x64 -out:%enusPath%\$(TargetName).resources.dll
echo "fr-FR"
SET frfrPath="$(TargetDir)\Languages\fr-FR"
if not exist %frfrPath% mkdir %frfrPath%
%al% -target:lib -embed:%resourcesPath%\Resources.fr-FR.resources,$(ProjectName).Properties.Resources.fr-FR.resources -embed:%resourcesPath%\Errors.fr-FR.resources,$(ProjectName).Properties.Errors.fr-FR.resources -template:$(TargetPath) -culture:fr-FR -platform:x64 -out:%frfrPath%\$(TargetName).resources.dll
echo "de-DE"
SET dedePath="$(TargetDir)\Languages\de-DE"
if not exist %dedePath% mkdir %dedePath%
%al% -target:lib -embed:%resourcesPath%\Resources.de-DE.resources,$(ProjectName).Properties.Resources.de-DE.resources -embed:%resourcesPath%\Errors.de-DE.resources,$(ProjectName).Properties.Errors.de-DE.resources -template:$(TargetPath) -culture:de-DE -platform:x64 -out:%dedePath%\$(TargetName).resources.dll
Tell the resource manager to look for satellite assemblies in the Languages
folder. We will need to do that in code.
App.xaml.cs
, in the App
constructor, handle the AppDomain.AssemblyResolve
event for SatelliteLocDemo.resources:
public App()
{
AppDomain.CurrentDomain.AssemblyResolve += this.CurrentDomain_AssemblyResolve;
}
private Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
{
try
{
if (args.Name != null && args.Name.StartsWith("SatelliteLocDemo.resources"))
{
string assemblyPath = $"{AppDomain.CurrentDomain.BaseDirectory}\\Languages\\{Thread.CurrentThread.CurrentUICulture.Name}\\SatelliteLocDemo.resources.dll";
Assembly assembly = Assembly.LoadFrom(assemblyPath);
return assembly;
}
return null;
}
catch (Exception e)
{
Trace.WriteLine($"Error loading translations for {args.Name}");
Trace.WriteLine(e);
return null;
}
}
Delete the bin\
directory, build, and test your app. If using the updateDll.bat
script to generate the satellite assemblies, you'll have to adapt it to the new structure, or generate everything elsewhere and copy-paste the satellite assemblies into the Languages folder.
When publishing your app, you need to publish the satellite assemblies aswell. I suppose you could generate all the satellite assemblies in the pre-build event, directly into your projet's structure, and set their properties to Content/Copy if newer
. This would copy them both into your build directory and into your publish directory. This won't work well with PublishSingleFile
(or maybe it would work with ExcludeFromSingleFile
, maybe not), so i chose a different way.
We will add a script on the Publish
event. This one is not accessible from Visual Studio directly, you have to set it in your .csproj
file manually. Simply add the following line at the end, after your PostBuild
section :
<Target Name="PublishLanguages" AfterTargets="Publish">
<ItemGroup>
<LangFiles Include="$(OutDir)\Languages\**\*.*" />
</ItemGroup>
<Exec Command="echo Publishing Language files" />
<Copy SourceFiles="@(LangFiles)" DestinationFiles="@(LangFiles->'$(PublishDir)\Languages\%(RecursiveDir)%(Filename)%(Extension)')" />
</Target>
Add the publish profile for the app
bin\publish
Publish and test your app. The Languages
folder should be present in bin\publish
.
Enable PublishSingleFile. For .NET6, that's all you have to do, you can ignore the rest of this section. For .NET core 3.1, the published application no longer finds your satellite assemblies, because the build is extracted to a temp directory but the satellite assemblies remain in their original directory.
Modify the AssemblyResolve
event callback to look for satellite assemblies next to the published .exe instead of the temp location
CurrentDomain_AssemblyResolve
, replace the call to AppDomain.CurrentDomain.BaseDirectory
with a method based on Process.GetCurrentProcess().MainModule
:public static string GetBasePath()
{
using ProcessModule processModule = Process.GetCurrentProcess().MainModule;
return Path.GetDirectoryName(processModule?.FileName)!;
}
Delete your bin\
directory, then publish again and test your app and translations. Now finally everything's good !