With a little help from Scott Guthrie, David Ebbo, and David Neal I finally got a working implementation to override the location of the App_Themes folder in ASP.NET 2.0!
They each helped point me on the right track and with a little experimentation success was achieved at last.
What I found was themes really want to come from a folder beneath the App_Themes folder and you can’t tell it to look for them in a different folder so you have to fool it into thinking it is getting the theme files from a folder in the App_Themes folder but really get them from your custom location.
I am transitioning my mojoPortal website framework project from the 1.1 to the 2.0 ASP.NET runtime and trying to adapt the ASP.NET theme to be just one part of a mojoPortal “skin”, which also includes a MasterPage, a CSS stylesheet, and any supporting images. mojoPortal supports hosting multiple sites on one web installation and one database so I want to keep “skins” independent between sites and store them in a site-specific location like ~/Data/Sites[siteid]/skins/skinname/
It’s no problem to store MasterPages, stylesheets, and images anywhere you like but Themes are a bit stubborn to relocate.
At first, I emailed Scott Guthrie asking him about how to tackle this problem and he pulled in David Ebbo who pointed out his blog post about implementing a VirtualPathProvider
Here is the process I went through and how I finally fooled it into working.
After looking into inheriting from VirtualPathProvider I got the idea that I could override the directory for the App_Themes folder by implementing
public override VirtualDirectory GetDirectory(string virtualDir) using code like this:
if (virtualDir.Contains("App_Themes"))
{
SiteSettings siteSettings = SiteSettings.GetCurrent();
if (siteSettings != null)
{
return base.GetDirectory(
virtualDir.Replace("App_Themes", "Data/Sites/"
+ siteSettings.SiteID.ToString()
+ "/skins/"));
}
}
return base.GetDirectory(virtualDir);
That would have been much too easy of course but it is checking that the directory we are feeding it is the App_Themes directory and it knows if we re-route to a different directory by overriding this method and it won’t let us do that.
So I was off the tracks until I got to work the next day and told David Neal about my difficulties. He said the implementations he has seen of VirtualPathProvider override at the point where it returns a file stream.
This got me looking at the VirtualPathProvider public static Stream OpenFile(string virtualPath), but it did not support an override so I tried implementing it by hiding the base class method but setting a breakpoint, my code was never called so that didn’t work either.
Then I got to looking at the VirtualPathProvider public override VirtualFile GetFile(string virtualPath) method. I found that by implementing a custom VirtualFile and returning that from the VirtualPathProvider I could make it load the theme.skin file from my special folder.
As it turns out, you have to create a folder under the App_Themes folder and put a default theme in there. We will not actually use this theme but it has to be there because we are trying to fool it into thinking it is using this theme and it is not so easily fooled.
It checks that the Theme we are fooling it into believing it is using exists. We have to fake it out at the last minute and feed it different files, not folders. I created a folder named default and put a file in there named theme.skin and a file named theme.css
Here is the code for this method:
public override VirtualFile GetFile(string virtualPath)
{
if (virtualPath.Contains("App_Themes/default"))
{
return new mojoThemeVirtualFile(virtualPath);
}
return base.GetFile(virtualPath);
}
mojoThemeVirtualFile.cs looks like this:
using System;
using System.Collections.Generic;
using System.Text;
using System.Web;
using System.Web.Util;
using System.Web.Hosting;
using System.IO;
using mojoPortal.Business;
namespace mojoPortal.Web
{
public class mojoThemeVirtualFile : VirtualFile
{
private String pathToFile;
public mojoThemeVirtualFile(String virtualPath)
: base(virtualPath)
{
pathToFile = virtualPath;
}
public override Stream Open()
{
SiteSettings siteSettings = SiteSettings.GetCurrent();
if (siteSettings != null)
{
pathToFile = pathToFile.Replace("App_Themes/default", "Data/Sites/"
+ siteSettings.SiteID.ToString()
+ "/skins/" + siteSettings.Skin);
}
SiteUtils.ResetThemeCache();
String filePath = HttpContext.Current.Server.MapPath(pathToFile);
return File.Open(filePath, FileMode.Open);
}
}
As with MasterPages, the Theme for a page can only be set in the OnPreInit event, so my page overrides that method like this:
override protected void OnPreInit(EventArgs e)
{
base.OnPreInit(e);
if (!this.DesignMode)
{
if (HttpContext.Current != null)
{
siteSettings = (SiteSettings)HttpContext.Current.Items["SiteSettings"];
SiteUtils.SetMasterPage(this, siteSettings);
this.Theme = "default";
}
}
base.OnPreInit(e);
if (!this.DesignMode)
{
if (HttpContext.Current != null)
{
siteSettings = (SiteSettings)HttpContext.Current.Items["SiteSettings"];
SiteUtils.SetMasterPage(this, siteSettings);
this.Theme = "default";
}
}
The SetMasterPage function is setting it to a layout.Master file stored in ~/Data/Sites[siteid]/skins/skinname folder
The Theme is always being set to default because we are trying to fool it into thinking it is using that Theme, and that Theme does exists to help the masquerade, but our VirtualFile is actually opening it from the location of our choosing based on SiteSettings and that was the goal we are trying to achieve.
Now a mojoPortal skin can contain all its files in a single site-specific folder and consists of:
- layout.Master
- theme.skin
- style.css
and any supporting images or resource files.
This is just what I was trying to achieve!
Another point worth mentioning is that once it loads the theme it is cached, so I needed a way to invalidate the cache if the Theme setting stored in my SiteSettings object was changed. On the page where I save SiteSettings, I added a few lines after saving to reset the cache like this:
String oldSkin = ViewState["skin"].ToString();
if (oldSkin != siteSettings.Skin)
{
SiteUtils.ResetThemeCache();
}
When the page first loads I store the current skin name in ViewState then on save I check if it is different than before and if so reset the cache by updating a text file in the file system that I set as the CacheDependency in the VirtualPathProvider:
public override System.Web.Caching.CacheDependency GetCacheDependency(string virtualPath, System.Collections.IEnumerable virtualPathDependencies, DateTime utcStart)
{
if (virtualPath.Contains("App_Themes/default"))
{
String pathToDependencyFile = SiteUtils.GetPathToThemeCacheDependencyFile();
if(pathToDependencyFile != null)
{
return new CacheDependency(pathToDependencyFile);
}
}
return base.GetCacheDependency(virtualPath, virtualPathDependencies, utcStart);
}
All my SiteUtils.ResetThemeCache(); function does is set the modified time of the file to the current time which invalidates the cache.
public static void ResetThemeCache()
{
String pathToCacheDependencyFile = GetPathToThemeCacheDependencyFile();
if (pathToCacheDependencyFile != null)
{
if (File.Exists(pathToCacheDependencyFile))
{
File.SetLastWriteTimeUtc(pathToCacheDependencyFile, DateTime.Now);
}
else
{
StreamWriter streamWriter = File.CreateText(pathToCacheDependencyFile);
streamWriter.Close();
}
}
public static String GetPathToThemeCacheDependencyFile()
{
string pathToCacheDependencyFile = null;
if (HttpContext.Current != null)
{
SiteSettings siteSettings = SiteSettings.GetCurrent();
pathToCacheDependencyFile = HttpContext.Current.Server.MapPath(
"~/Data/Sites/" + siteSettings.SiteID.ToString() + "/themecachedependecy.config");
}
return pathToCacheDependencyFile;
}
If you ever have a need to get your Themes from an alternate location, hope this post helps.