Single-assembly multi-language Windows Forms deployment (ILMerge and satellite assemblies / localization) - possible?

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!


Solution 1:

The only way I can see this working is by creating a class that derives from ResourceManager and then overriding the InternalGetResourceSet and GetResourceFileName methods. From there, you should be able to override where resources are obtained, given a CultureInfo instance.

Solution 2:

A different approach:

1) add your resource.DLLs as embededed resources in your project.

2) add an event handler for AppDomain.CurrentDomain.ResourceResolve This handler will fire when a resource cannot be found.

      internal static System.Reflection.Assembly CurrentDomain_ResourceResolve(object sender, ResolveEventArgs args)
            {
                try
                {
                    if (args.Name.StartsWith("your.resource.namespace"))
                    {
                        return LoadResourcesAssyFromResource(System.Threading.Thread.CurrentThread.CurrentUICulture, "name of your the resource that contains dll");
                    }
                    return null;
                }
                catch (Exception ex)
                {
                    return null;
                }
            }

3) Now you have to implement LoadResourceAssyFromResource something like

    private Assembly LoadResourceAssyFromResource( Culture culture, ResourceName resName)
            {
                        //var x = Assembly.GetExecutingAssembly().GetManifestResourceNames();

                        using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resName))
                        {
                            if (stream == null)
                            {
                                //throw new Exception("Could not find resource: " + resourceName);
                                return null;
                            }

                            Byte[] assemblyData = new Byte[stream.Length];

                            stream.Read(assemblyData, 0, assemblyData.Length);

                            var ass = Assembly.Load(assemblyData);

                            return ass;
                        }
            }