I have a simple Windows Forms (C#, .NET 2.0) application, built with Visual Studio 2008.
I would like to support multiple UI languages, and using the "Localizable" property of the form, and culture-specific .resx files, the localization aspect works seamlessly and easily. Visual Studio automatically compiles the culture-specific resx files into satellite assemblies, so in my compiled application folder there are culture-specific subfolders containing these satellite assemblies.
I would like to have the application be deployed (copied into place) as a single assembly, and yet retain the ability to contain multiple sets of culture-specific resources.
Using ILMerge (or ILRepack), I can merge the satellite assemblies into the main executable assembly, but the standard .NET ResourceManager fallback mechanisms do not find the culture-specific resources that were compiled into the main assembly.
Interestingly, if I take my merged (executable) assembly and place copies of it into the culture-specific subfolders, then everything works! Similarly, I can see the main and culture-specific resources in the merged assemby when I use Reflector (or ILSpy). But copying the main assembly into culture-specific subfolders defeats the purpose of the merging anyway - I really need there to be just a single copy of the single assembly...
I'm wondering whether there is any way to hijack or influence the ResourceManager fallback mechanisms to look for the culture-specific resources in the same assembly rather than in the GAC and culture-named subfolders. I see the fallback mechanism described in the following articles, but no clue as to how it would be modified: BCL Team Blog Article on ResourceManager.
Does anyone have any idea? This seems to be a relatively frequent question online (for example, another question here on Stack Overflow: "ILMerge and localized resource assemblies"), but I have not found any authoritative answer anywhere.
UPDATE 1: Basic Solution
Following casperOne's recommendation below, I was finally able to make this work.
I'm putting the solution code here in the question because casperOne provided the only answer, I don't want to add my own.
I was able to get it to work by pulling the guts out of the Framework resource-finding fallback mechanisms implemented in the "InternalGetResourceSet" method and making our same-assembly search the first mechanism used. If the resource is not found in the current assembly, then we call the base method to initiate the default search mechanisms (thanks to @Wouter's comment below).
To do this, I derived the "ComponentResourceManager" class, and overrode just one method (and re-implemented a private framework method):
class SingleAssemblyComponentResourceManager :
System.ComponentModel.ComponentResourceManager
{
private Type _contextTypeInfo;
private CultureInfo _neutralResourcesCulture;
public SingleAssemblyComponentResourceManager(Type t)
: base(t)
{
_contextTypeInfo = t;
}
protected override ResourceSet InternalGetResourceSet(CultureInfo culture,
bool createIfNotExists, bool tryParents)
{
ResourceSet rs = (ResourceSet)this.ResourceSets[culture];
if (rs == null)
{
Stream store = null;
string resourceFileName = null;
//lazy-load default language (without caring about duplicate assignment in race conditions, no harm done);
if (this._neutralResourcesCulture == null)
{
this._neutralResourcesCulture =
GetNeutralResourcesLanguage(this.MainAssembly);
}
// if we're asking for the default language, then ask for the
// invariant (non-specific) resources.
if (_neutralResourcesCulture.Equals(culture))
culture = CultureInfo.InvariantCulture;
resourceFileName = GetResourceFileName(culture);
store = this.MainAssembly.GetManifestResourceStream(
this._contextTypeInfo, resourceFileName);
//If we found the appropriate resources in the local assembly
if (store != null)
{
rs = new ResourceSet(store);
//save for later.
AddResourceSet(this.ResourceSets, culture, ref rs);
}
else
{
rs = base.InternalGetResourceSet(culture, createIfNotExists, tryParents);
}
}
return rs;
}
//private method in framework, had to be re-specified here.
private static void AddResourceSet(Hashtable localResourceSets,
CultureInfo culture, ref ResourceSet rs)
{
lock (localResourceSets)
{
ResourceSet objA = (ResourceSet)localResourceSets[culture];
if (objA != null)
{
if (!object.Equals(objA, rs))
{
rs.Dispose();
rs = objA;
}
}
else
{
localResourceSets.Add(culture, rs);
}
}
}
}
To actually use this class, you need to replace the System.ComponentModel.ComponentResourceManager in the "XXX.Designer.cs" files created by Visual Studio - and you will need to do this every time you change the designed form - Visual Studio replaces that code automatically. (The problem was discussed in "Customize Windows Forms Designer to use MyResourceManager", I did not find a more elegant solution - I use fart.exe in a pre-build step to auto-replace.)
UPDATE 2: Another Practical Consideration - more than 2 languages
At the time I reported the solution above, I was actually only supporting two languages, and ILMerge was doing a fine job of merging my satellite assembly into the final merged assembly.
Recently I started working on a similar project where there are multiple secondary languages, and therefore multiple satellite assemblies, and ILMerge was doing something very strange: Instead of merging the multiple satellite assemblies I had requested, it was merging the first satellite assembly in multiple times!
eg command-line:
"c:\Program Files\Microsoft\ILMerge\ILMerge.exe" /t:exe /out:%1SomeFinalProg.exe %1InputProg.exe %1es\InputProg.resources.dll %1fr\InputProg.resources.dll
With that command-line, I was getting the following sets of resources in the merged assembly (observed with ILSpy decompiler):
InputProg.resources
InputProg.es.resources
InputProg.es.resources <-- Duplicated!
After some playing around, I ended up realizing this is just a bug in ILMerge when it encounters multiple files with the same name in a single command-line call. The solution is simply to merge each satellite assembly in a different command-line call:
"c:\Program Files\Microsoft\ILMerge\ILMerge.exe" /t:exe /out:%1TempProg.exe %1InputProg.exe %1es\InputProg.resources.dll
"c:\Program Files\Microsoft\ILMerge\ILMerge.exe" /t:exe /out:%1SomeFinalProg.exe %1TempProg.exe %1fr\InputProg.resources.dll
When I do this, the resulting resources in the final assembly are correct:
InputProg.resources
InputProg.es.resources
InputProg.fr.resources
So finally, in case this helps clarify, here's a complete post-build batch file:
"%ProgramFiles%\Microsoft\ILMerge\ILMerge.exe" /t:exe /out:%1TempProg.exe %1InputProg.exe %1es\InputProg.resources.dll
IF %ERRORLEVEL% NEQ 0 GOTO END
"%ProgramFiles%\Microsoft\ILMerge\ILMerge.exe" /t:exe /out:%1SomeFinalProg.exe %1TempProg.exe %1fr\InputProg.resources.dll
IF %ERRORLEVEL% NEQ 0 GOTO END
del %1InputProg.exe
del %1InputProg.pdb
del %1TempProg.exe
del %1TempProg.pdb
del %1es\*.* /Q
del %1fr\*.* /Q
:END
UPDATE 3: ILRepack
Another quick note - One of the things that bothered me with ILMerge was that it is an additional proprietary Microsoft tool, not installed by default with Visual Studio, and therefore an extra dependency that makes it that little bit harder for a third party to get started with my open-source projects.
I recently discovered ILRepack, an open-source (Apache 2.0) equivalent that so far works just as well for me (drop-in replacement), and can be freely distributed with your project sources.
I hope this helps someone out there!
return base.InternalGetResourceSet(culture, createIfNotExists, tryParents);
whenstore == null
orrs == null
– Snelling