How do i make satellite assemblies work with my WPF app in PublishSingleFile

760 views Asked by At

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 ?

1

There are 1 answers

0
cboittin On BEST ANSWER

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 :

  • We will use 2 programs that are part of the .NET SDK : 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.
  • The location of these files on your system may vary. I used the ones in 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 x64
  • We use PublishSingleFile to avoid having a huge mess in our app's folder, so we'd like to avoid having 20 folders in there if the app is localized in 20 languages.
  • In order to see what resources are embedded in what assembly, it can be useful to inspect assemblies, for example with ILSpy.

Let's take this step by step.

Step 1: Create satellite assemblies with VS

  1. While keeping your translation data intact, remove the default configuration for handling .resx files
    • Set all resx files' properties to None/Do not copy
    • Remove the custom Tool from Resources.resx and Errors.resx
    • Delete Errors.Designer.cs and Resources.Designer.cs
  2. Generate .resources files from your default language .resx.
    • Set the pre-build event to (the path to your resgen command might differ):
      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
      
    • Try building the application. It will create the files Resources.resources and Errors.resources in your Properties folder. Ignore the warning, everything is generated just fine.
    • Set Resources.resources and Errors.resources' properties to Embedded Resource/Do not copy
    • Rebuild the application. The program can't find the french and german translations, but has the english defaults embedded into it.
  3. Have Visual Studio generate satellite assemblies
    • 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.

Explanation

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 :

  • Generate .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).
  • Generate .resources files for the french and german cultures.
  • Copy the culture-neutral .resources files into english .resources files.
  • By setting those to Embedded resources, visual studio will automatically:
    • Embed the culture-neutral resources into the application's dll, so that texts are always translated even if the satellite assemblies can't be found.
    • Create a satellite assembly for each culture that it finds, and embed the .resources files specific to that culture into that assembly.
    • Therefore, we end up with 3 satellite assemblies, for the en-US, fr-FR, and de-DE cultures.

Testing the satellite assemblies

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.

  1. Build the application
  2. Modify the translations directly in your .resx files. Do not build again.
  3. Make a script (in the github project, it's called 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 
    
  4. Run the script, then navigate to your build folder and run your application from the explorer (if you run from VS, it will first rebuild the entire application).

Step 2: Create the satellite assemblies manually and clean up the program's directory

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.

  1. Don't let Visual Studio generate satellite assemblies

    • Remove all 6 .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).
    • Avoid creating the culture-specific .resources files in the pre-build event. Keep only the culture-neutral ones (remove everything except the first 3 lines).
    • Generate the satellite assemblies manually in a post-build event, similarly to how we did it with 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
      
  2. Tell the resource manager to look for satellite assemblies in the Languages folder. We will need to do that in code.

    • In your 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;
          }
      }
      
  3. 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.

Step 3: Publish and PublishSingleFile

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.

  1. 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>
    
  2. Add the publish profile for the app

    • Folder, into bin\publish
    • Framework-dependent, Debug, win-x64, readytorun
    • Do not set it to single-file yet.
  3. Publish and test your app. The Languages folder should be present in bin\publish.

  4. 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.

  5. Modify the AssemblyResolve event callback to look for satellite assemblies next to the published .exe instead of the temp location

    • In 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)!;
    }
    
  6. Delete your bin\ directory, then publish again and test your app and translations. Now finally everything's good !