NuGet with MEF

Now that we have the basics of installing and uninstalling NuGet packages, the next step is integrating the contents of those packages into our application.

Comicster has two kinds of extensions: Skins and Plug-Ins. A skin is just a loose XAML file and any associated content, while a plug-in is one or more assemblies containing classes that implement one or more exported interfaces from Comicster.Core.DLL.

The Plug-Ins Class

To keep all the extensions in one place, Comicster defines a PlugIns class that it only tracks a single instance of. The public face of the class looks like this:

public class PlugIns
{
    public PlugIns(IPackageManager packageManager)
    {
        ...
    }

    [ImportMany(AllowRecomposition = true)]
    public ObservableCollection<ICollectionReader> Readers { get; private set; }

    [ImportMany(AllowRecomposition = true)]
    public ObservableCollection<ICollectionReaderWriter> Writers { get; private set; }

    [ImportMany(AllowRecomposition = true)]
    public ObservableCollection<ITool> Tools { get; private set; }

    [ImportMany(AllowRecomposition = true)]
    public ObservableCollection<ICatalog> Catalogs { get; private set; }

    [ImportMany(AllowRecomposition = true)]
    public ObservableCollection<ISearch> Searches { get; private set; }

    public ObservableCollection<Skin> Skins { get; private set; }

    public event EventHandler Composed;
}

... and we register it with Autofac like this:

builder.RegisterType<PlugIns>().SingleInstance();

So we're making use of MEF's ImportMany attribute here, and we're giving it the ability to "recompose" each collection at runtime. Essentially that means that any changes to the MEF CompositionContainer will automatically add or remove components from each of those collections.

NuGet Package Events

You'll notice that the PlugIns class takes a dependency on IPackageManager. When we resolve an instance of PlugIns, Autofac passes an implementation of IPackageManager, also registered as single instance. That means that inside our constructor we can subscribe to some key events to do with packages:

_packageManager.PackageInstalled += _packageManager_PackageInstalled;
_packageManager.PackageUninstalled += _packageManager_PackageUninstalled;

Now we're getting somewhere! Whenever a package is installed or uninstalled, the PlugIns class will be notified!

Package Installation

Let's have a look at what happens at install time:

void _packageManager_PackageInstalled(object sender, PackageOperationEventArgs e)
{
    AddDirectoryCatalog(e.Package);
    Skins.AddRange(_packageManager.PathResolver.GetSkinsFor(e.Package));

    OnComposed(EventArgs.Empty);
}

Ok, so we're calling a helper method, registering any skins that might be in the package, and then triggering the Composed event, which gets handled in other parts of the app. The first method takes care of registering any new assemblies with MEF:

void AddDirectoryCatalog(IPackage package)
{
    var libDir = Path.Combine(_packageManager.PathResolver.GetInstallPath(package), "lib");
    if (Directory.Exists(libDir)) _catalog.Catalogs.Add(new DirectoryCatalog(libDir));
}

If you're familiar with NuGet you'll know that assemblies live in a "lib" folder inside the package. Here we're simply adding a new DirectoryCatalog pointing to that folder. MEF will scan the folder for assemblies that export types we need (in this case, plug-in types) and add them to our collections.

Now let's look at the other thing that might happen as a package is installed. We're using an extension method called GetSkinsFor(package) which looks like this:

public static IEnumerable<Skin> GetSkinsFor(this IPackagePathResolver resolver, IPackage package)
{
    return from path in package.GetContentFiles().Select(f => Path.Combine(resolver.GetInstallPath(package), f.Path))
            where path.EndsWith("skin.xaml", StringComparison.OrdinalIgnoreCase)
            select new Skin(Path.GetFileName(Path.GetDirectoryName(path)), new Uri(path, UriKind.Absolute));
}

That's a LINQ query which looks for "skin.xaml" file in folders under the package's "Content" folder, and constructs an instance of our internal Skin type for each one.

Package Removal

With me so far? If so, the stuff that happens when a package is removed shouldn't come as any great surprise:

void _packageManager_PackageUninstalled(object sender, PackageOperationEventArgs e)
{
    var libDir = Path.Combine(e.InstallPath, "lib");
    foreach (var catalog in _catalog.Catalogs.OfType<DirectoryCatalog>().Where(c => e.FileSystem.PathsMatch(c.FullPath, libDir)))
    {
        _catalog.Catalogs.Remove(catalog);
    }

    foreach (var skin in _packageManager.PathResolver.GetSkinsFor(e.Package))
    {
        Skins.RemoveAll(s => s.Uri == skin.Uri);
    }
    OnComposed(EventArgs.Empty);
}

So we're looking for any DirectoryCatalog instances in our MEF catalogs that match the path of the package that just got uninstalled, and removing them so that their plug-in classes are "unregistered". Then we're doing the same thing for the skins.

Caveats

There are a few problems with this approach, and the #1 problem is that Comicster still has the assemblies loaded when the packages are uninstalled, so NuGet can't delete the folders from disk. There's a way around that using a custom AppDomain with shadow copying enabled, but it's a bit of a learning curve so I haven't tried it yet.

Likewise when you update a package, the old version will still be loaded. I've gotten around that for now by notifying the user that they should restart Comicster when any package is updated.

What do you think? MEF-heads might be able to suggest a cleaner approach, but this is working quite well for me. If I get to the AppDomain stuff I will post that too!

nuget mef mvvm comicster
Posted by: Matt Hamilton
Last revised: 15 Oct, 2024 09:51 PM History

Trackbacks

Comments

No comments yet. Be the first!

No new comments are allowed on this post.