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

5

62

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!

Preponderant answered 23/12, 2009 at 12:51 Comment(7)
After some more (relatively obscessive) searching, I have found some promising snippets: neowin.net/forum/lofiversion/index.php/t625641.html social.msdn.microsoft.com/Forums/en/vsx/thread/… It looks like creating a custom ResourceManager will be the way to go - I'm off to decompile the default ResourceManager (Reflector to the rescue!) to see if I can better understand what it does / how it works.Preponderant
Hello. I need to do exactly the same. I've added this new class and replaced in a designer file, but I'm getting NullReferenceException at line: this.outputPath.Properties.AutoHeight = ((bool)(resources.GetObject("outputPath.Properties.AutoHeight"))); (resources is a SingleAssemblyComponentResourceManager) outputPath - is a DevExpress control. Even after merging all DevExpress assemblies.Chuppah
Do you know what's actually Null? is it the "resources" object that's not actually initialized? Not sure this is the best forum for me to help you work on your code, but feel free to send me details at stuff at klerks dot biz.Preponderant
It works before merging if you return base.InternalGetResourceSet(culture, createIfNotExists, tryParents); when store == null or rs == nullSnelling
@Wouter: thanks for making me feel like an idiot! :) - I'll play with this tonight and update the question/codePreponderant
Thanks again @Wouter, this is a MUCH nicer/cleaner result!Preponderant
Nice solution, thank you. I would recommend to use Fody.Ionad instead of fart.exe. It should work as long as code in other assemblies do not use ResourceManager property directlyItagaki
S
27

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.

Serpent answered 23/12, 2009 at 20:15 Comment(1)
thanks, I was eventually able to make it work, I added my code above as yours was technically the answer. Note, the only method that actually needed to be overridden was "InternalGetResourceSet". I would have liked to only modify it as required, but the existing code made extensive use of other internal and/or private framewoprk code, it was easier to rip ity all out and implement local assembly loading only.Preponderant
H
4

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;
                        }
            }
Hostile answered 17/2, 2016 at 22:31 Comment(1)
This seems to be the approach that this nuget package is doing for anyone wanting a more turnkey solution: github.com/MarcStan/resource-embedderErastian
D
1

Posted as answer since comments didn't provide enough space:

I couldn't find resources for neutral cultures (en instead of en-US) with the OPs solution. So I extended InternalGetResourceSet with a lookup for neutral cultures which did the job for me. With this you can now also locate resources which do not define the region. This is actually the same behaviour that the normal resourceformatter will show when not ILMerging the resource files.

//Try looking for the neutral culture if the specific culture was not found
if (store == null && !culture.IsNeutralCulture)
{
    resourceFileName = GetResourceFileName(culture.Parent);

    store = this.MainAssembly.GetManifestResourceStream(
                    this._contextTypeInfo, resourceFileName);
}

This results in the following code for the SingleAssemblyComponentResourceManager

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);

            //Try looking for the neutral culture if the specific culture was not found
            if (store == null && !culture.IsNeutralCulture)
            {
                resourceFileName = GetResourceFileName(culture.Parent);

                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);
            }
        }
    }
}
Devlin answered 4/3, 2015 at 13:11 Comment(0)
S
0

I have a suggestion for part of your problem. Specifically, a solution to the step of updating .Designer.cs files to replace ComponentResourceManager with SingleAssemblyComponentResourceManager.

  1. Move the InitializeComponent() method out of .Designer.cs and into the implementation file (include the #region). Visual Studio will continue to auto generate that section, with no problems as far as I can tell.

  2. Use a C# alias at the top of the implementation file so that ComponentResourceManager is aliased to SingleAssemblyComponentResourceManager.

Unfortunately, I didn't get to test this fully. We found a different solution to our problem and so moved on. I hope it helps you though.

Stranglehold answered 18/3, 2011 at 15:58 Comment(1)
Thanks for the suggestion, it's an interesting one (I did not know that you could move the InitializeComponent method and have the designer keep working)! Unfortunately, every time the designer replaces the "ComponentResourceManager" reference, it uses the fully-qualified type name "System.ComponentModel.ComponentResourceManager", so the aliasing doesn't seem to help. Thanks again for the tip though!Preponderant
R
0

Just a thought.

You did the step and created your SingleAssemblyComponentResourceManager

So why do you take the pain to include your satellite assemblies in the ilmerged Assembly?

You could add the ResourceName.es.resx itself as a binary file to another resource in your project.

Than you could rewrite your code

       store = this.MainAssembly.GetManifestResourceStream(
            this._contextTypeInfo, resourceFileName);

//If we found the appropriate resources in the local assembly
if (store != null)
{
    rs = new ResourceSet(store);

with this code (not tested but should work)

// we expect the "main" resource file to have a binary resource
// with name of the local (linked at compile time of course)
// which points to the localized resource
var content = Properties.Resources.ResourceManager.GetObject("es");
if (content != null)
{
    using (var stream = new MemoryStream(content))
    using (var reader = new ResourceReader(stream))
    {
        rs = new ResourceSet(reader);
    }
}

This should render the effort to include the sattelite assembiles in the ilmerge process obsolete.

Rorry answered 6/2, 2014 at 15:35 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.