28 January 2009

Environment.GetCommandLineArgs backslash and double-quote issue

Just noticed that System.Environment.GetCommandLineArgs() handles command line arguments that contain a backslash (\) followed by a double-quote in a strange way.

Microsoft documentation says:
"If a double quotation mark follows two or an even number of backslashes, each proceeding backslash pair is replaced with one backslash and the double quotation mark is removed. If a double quotation mark follows an odd number of backslashes, including just one, each preceding pair is replaced with one backslash and the remaining backslash is removed; however, in this case the double quotation mark is not removed."

This becomes an issue when you use double-quotes to delimit parameters that may contain spaces. If a parameter is a directory such as C:\test\ then it will be quoted as "C:\test\" but it will be returned by GetCommandLineArgs() as C:\test" ie with a double-quote instead of a backslash.

In my case, I was able to add a space between the \ and the double-quote. Then Trim() in the receiving app.

The full command line is available in the Environment.CommandLine property.

Also: a command line argument such as a"b"c is returned as abc by GetCommandLineArgs().

20 January 2009

Custom VS Web Setup Projects

A Web Setup project installs an ASP.NET web application on a Microsoft Internet Information Services (IIS) web server. The simplest web application might consist of Default.aspx and Default.aspx.cs files; a simple web setup project would install these files into a directory of the installer's choice and mark that directory as an IIS application, eg so that the web app runs at http://localhost/installdir/

The Visual Studio 2008 Web Setup project looked like a good starting point for distributing a new version of my web application (with web service and a Forms app that tests the web service). I just needed to customise it to:
  • Add links to the web app in the Start Menu (eg to http://localhost/installdir/default.aspx)
  • Add links to the web app on the install wizard finished page
  • Set NTFS write permissions for the web app directory
  • [Later] Make the MSI install work in Vista

Little did I know what a task I had set myself. Perhaps buying an installation tool might have been worthwhile... Follow me, as I battle to achieve these modest aims.

My software is aimed at individual users and to run on servers. Running as an ASP.NET web app, it could fulfil both target systems. I want to have Start Menu links so that individuals have a means to access the web site easily. The Start Menu would also have a shortcut to a Windows Forms exe that would test the web servce.

Basic set up: Files, Program Files and Start Menu

First, make sure that you are targetting the right version of ASP.NET - you can choose this at the top right of the VS "Add New Project" dialog. Once your project is created, look at the project properties. Click on [Prerequisites] and make sure that your chosen base version is selected. I selected ".NET Framework 2.0 (x86)" and "Windows Installer 3.1".

Next, add the project output from your web app project. View the File System and add in your aspx pages (but not .aspx.cs), Global.asax and release Web.Config files, together with any other files you want in the web app directory. Make sure that the bin directory has all the needed assembly DLLs.

My Windows Forms application had best go in the Program Files directory, so I added a "Program Files Folder" special folder to the setup File System. I created a "Product" sub-folder for my software, and added the Forms app output to this folder. [Later on, I also put my custom installer to this folder.]

I added a "User's Start Menu" special folder to the setup File System. I added "Programs" then another "Product" folder inside. I want to add my URL links to the web app in here, but I had to do that in my custom installer. However I did create a shortcut to the Forms app. I had wanted to pass the actual web app URL as a parameter to this app, but this proved not to be possible, so I amended the app to get the URL from a file.

MSI Database overview

The web setup project creates a Windows Installer MSI file, and an accompanying Setup.exe. An MSI file has a real relational database inside that governs how the install proceeds. The database has a series of tables each with rows with various columns.

For example, the Dialog table has one row to define each dialog that can appear during the install. The Control table has rows that define each user interface element in the dialogs.

You can see what's in the MSI database using the orca tool, available in the Windows SDK. Later on, I'll use orca to build a Transform that changes the main text of the finished dialog box. The msitran tool can then be used as the setup PostBuildEvent to run this transform after every project rebuild.

The install process maintains various properties, some of which are defined in the Property table. Other properties are set at install time - these are the ones I am interested in:

  1. [TARGETVDIR]: The chosen virtual directory, eg Product
  2. [TARGETDIR]: The consequent web app path, eg C:\inetpub\wwwroot\Product
  3. [StartMenuFolder]: The Start Menu, eg C:\Documents and Settings\All Users\Start Menu

NB: There are various Win32 functions that let you work with the MSI database, such as MsiOpenDatabase().

Custom Action Installer NET assembly

You can achieve some things in a .NET custom action assembly, but your code is quite isolated from the main install. I don't think you can use/edit the current MSI database, nor can you interact with any of the setup dialogs.

To create a custom installer, create a C# class library project - give it a useful name. Then delete the initial class1.cs. Add a new item "Installer Class" and give the file and class a meaningful name. Switch to code view. You will see that your class inherits from System.Configuration.Install.

There are various overrides that you can add. You will definitely want to override Install() and possibly Commit(). You will almost certainly also want to override Rollback() and Uninstall() - to undo any changes that you have done. In fact, you must override and call Install() even if you only want to work in the other methods.

For each override that you add, add a call to the base class at the start of your implementation, eg:

public override void Install(IDictionary stateSaver)
{
  base.Install(stateSaver);
}

Compile this project and add the output to the web setup project File System Program Files "Product" sub-folder.

Note: if an exception is thrown in your Install() method, the install will rollback.

Web setup Custom Actions

In the web setup project, view the Custom Actions screen. Add a Custom Action for each of the types that you wish to support. Make sure that the InstallerClass property is True. For the Install action, set the CustomActionData as follows:


/TargetDir="[TARGETDIR]\" /TargetVDir="[TARGETVDIR]" /StartMenuFolder="[StartMenuFolder]\"

This sets three parameters that are available to your custom installer assembly. Note the double quotes, and that the backslashes that seem to mandatory in two cases.

Installer custom action

In your Installer() code, use the base class Context.Parameters StringDictionary to retrieve the values passed in the CustomActionData:

string StartMenuFolder = Context.Parameters["StartMenuFolder"];
string TargetDir = Context.Parameters["TargetDir"];
string TargetVDir = Context.Parameters["TargetVDir"];

Getting the web app URL

The URL of the installed web app will probably be http://localhost/product/ where product is the user's chosen virtual directory, as supplied in TargetVDir. However I want to try to check this. This code gets the path that should correspond to http://localhost/:

DirectoryEntry localhost1 = new DirectoryEntry(@"IIS://localhost/W3SVC/1/Root");
string localhostRoot = localhost1.Properties["Path"][0].ToString();

If this directory is C:\inetpub\wwwroot and the TargetDir is C:\inetpub\wwwroot\Product\ then I think we can be fairly certain that appending TargetVDir to http://localhost/ will give the correct URL of your installed web app. This code sets MakeLocalhostLinks to true if we can safely create URL shortcuts:

bool MakeLocalhostLinks = true;
if ((String.Compare(localhostRoot, TargetDir.Substring(0, localhostRoot.Length), true) != 0) ||
  (String.Compare(@"\" + TargetVDir + @"\", TargetDir.Substring(localhostRoot.Length), true) != 0))
{
  MessageBox.Show("Could not create Start Menu links", "Installer");
  MakeLocalhostLinks = false;
}

Add URL links to the Start Menu

I can now find our Start Menu folder that has just been created and add links there. A link is a text file with extension .url that has one line containing [InternetShortcut] and the next specifying the link after URL=

The following code does this job, creating a link to begin.aspx on the web app site, as well as creating a fixed link to the product documentation. In both cases, the path to the newly created link is saved in the stateSaver IDictionary, for use later on rollback or uninstall.

string UserStartMenuFolder = StartMenuFolder + @"Programs\product\";
if (Directory.Exists(UserStartMenuFolder))
{
  if (MakeLocalhostLinks)
  {
    string BeginLinkPath = UserStartMenuFolder + @"Begin.url";
    using (StreamWriter twBeginLink = new StreamWriter(BeginLinkPath))
    {
      twBeginLink.WriteLine("[InternetShortcut]");
      twBeginLink.WriteLine("URL=http://localhost/" + TargetVDir + "/begin.aspx");
    stateSaver.Add("BeginLinkPath", BeginLinkPath);
    }
  }
  string InfoLinkPath = UserStartMenuFolder + @"Product documentation.url";
  using (StreamWriter twInfoLink = new StreamWriter(InfoLinkPath))
  {
    twInfoLink.WriteLine("[InternetShortcut]");
    twInfoLink.WriteLine("URL=http://www.example.com/product/");
    stateSaver.Add("InfoLinkPath", InfoLinkPath);
  }
}

Commit

Commit is called when the install is complete. You cannot make changes to the stateSaver during Commit()

Commit() could be a good time to present some extra choices to the user, start an application or show a web page. Note that the installer waits for your method to return before continuing. You could spawn another process if you want to leave a dialog box open.

Rollback and Uninstall

If anything goes wrong with the install, or an uninstall occurs, you need to undo any changes you have made. In Rollback() and Uninstall() you have access to the values that were saved in stateServer. Both Rollback() and Uninstall() call my own new method RemoveCustomAdditions(). This gets the saved path strings; if they are present then the file is deleted.

public override void Rollback(IDictionary stateSaver)
{
  base.Rollback(stateSaver);
  RemoveCustomAdditions(stateSaver);
}
public override void Uninstall(IDictionary stateSaver)
{
  base.Uninstall(stateSaver);
  RemoveCustomAdditions(stateSaver);
}
private void RemoveCustomAdditions(IDictionary stateSaver)
{
  try
  {
    string BeginLinkPath = stateSaver["BeginLinkPath"] as string;
    if (!String.IsNullOrEmpty(BeginLinkPath))
      File.Delete(BeginLinkPath);

    string InfoLinkPath = stateSaver["InfoLinkPath"] as string;
    if (!String.IsNullOrEmpty(InfoLinkPath))
      File.Delete(InfoLinkPath);
  }
  catch (Exception ) { }
}
During rollback or uninstall it is best if you don't throw any exceptions, so catch and ignore these.

My web app may create data files during normal operation. The rollback/uninstall methods could remove these files. However, an upgrade installation will usually uninstall the software first (even if RemovePreviousVersions is false). To keep the data files through an upgrade, these must be left in place. My documentation tells the user to remove these data files for a complete uninstall.

There should be no need to undo any file permission changes as the directory should be about to disappear.

NTFS File Permissions

In my Install() method, I want to let my web app write to its directory and any sub-folders. In IIS5, aspnet_wp is the worker process that runs all ASP.NET web apps using username "ASPNET". In IIS6 and IIS7, the w3wp worker process runs ASP.NET web app application pool using username "Network Service". This is the code that changes the file permissions for both these user accounts for the TargetDir:

DirectorySecurity security = Directory.GetAccessControl(TargetDir);
try
{
  FileSystemAccessRule access = new FileSystemAccessRule("ASPNET",
    FileSystemRights.Modify,
    InheritanceFlags.ContainerInherit InheritanceFlags.ObjectInherit,
    PropagationFlags.None,
    AccessControlType.Allow);
  security.AddAccessRule(access);
  Directory.SetAccessControl(TargetDir, security);
}
catch (Exception ) { }

try
{
  FileSystemAccessRule access = new FileSystemAccessRule("Network Service",
    FileSystemRights.Modify,
    InheritanceFlags.ContainerInherit InheritanceFlags.ObjectInherit,
    PropagationFlags.None,
    AccessControlType.Allow);
  security.AddAccessRule(access);
  Directory.SetAccessControl(TargetDir, security);
}
catch (Exception ) { }

Changing the text in the final finished wizard page

I had originally wanted to provide a means of starting the web app on the final wizard page. So far I have not found a way to do this (either provide links/buttons on the page, or put checkboxes with options that are actions when Finish is pressed).

However I did manage to update the text to include the words "Now click on the Product links in the Start Menu".

Using the Microsoft SDK orca tool, I opened a first cut of my MSI. Create a New Transform. In the Control table, find the row for FinishedForm BodyText. Right-click on the Text column and Copy Cell. Paste it into Notepad and alter the text as you want. Copy the text and Paste Cell back into the Text cell. Select Generate Transform and save as an .MST file. Note that you cannot use "\r\n" in the cell text although line breaks can be pasted.

Use the Microsoft SDK msitran tool to apply the transform to an MSI, ie in the PostBuildEvent for your setup project. I found that calling msitran directly caused the build to fail because it has a return code of 1. I therefore had to use a batch file that called msitran and then did something innocuous to clear the return code, eg:

msitran.exe -a ..\AlterSetupFinishedText.mst ProductSetup.msi
dir

Vista Installation

If you double-click on the MSI itself in Vista, then it will fail with an obscure message "The installed was interrupted before.." This is because of lack of administrator privileges, even if you are logged in as an Administrator.

The simplest solution to this problem is to run the Setup.exe program that the web app project creates - you are prompted by Vista UAC to OK this privilege escalation.

The alternative is to Run as Administrator a command prompt, and then enter msiexec /i yourInstaller.msi

And finally, sign your assemblies and MSI

Ensure that your project assemblies are signed before rebuilding the Web Setup project. Sign the MSI and associated Setup.exe.

Suggestion for Microsoft

Can you provide a means of accessing the current MSI database in .NET Installer assemblies please.