How to handle prerequisites and custom install steps for a ClickOnce Deployed Application

UPDATE:There was one little hitch with the code I presented earlier in this posting… it didn’t work in partial trust because the BinaryFormatter and serialization require security permissions that are not granted in partial trust. At the end of the posting is the new code that does work fine in partial trust, but only works for built-in basic types in .NET (int, string, bool, etc.) because a custom XmlSerializableHashtable was required to get things working. The full code can be downloaded here. It is also available on the IDesign web site, download tab at http://www.idesign.net/. The code below has been updated to show the full source of the helper class and the XmlSerializableHashtable, which was adapted from the one in the Configuration Management Block. One of the other improvements that were made at my colleague Juval Lowy’s prompting was to make the Read and Write methods generic so that they could read and write in a type safe way instead of using object parameters and return types, which was an excellent suggestion.

—————–



These are some common questions I get about ClickOnce:



“My app depends on XXX, how can I ensure it is on the user’s machine before trying to launch my ClickOnce app?”



“I need to do XXX the first time the app is run, how can I do this from ClickOnce?”



*Prerequisite Detection *

The answer to the first question is twofold. First, there is the issue of detection. There is no general way to do prerequisite detection across the board. It is going to depend a lot on what those prerequisites are. In some cases, you may be able to use WMI, in others, File I/O to look for some known signature in an expected folder. In almost all of these approaches, you are going to need highly elevated permissions on the box, both from the perspective of the user that executes the application, and second from a Code Access Security (CAS) perspective.



Probably a better way is to have some startup code in your application that only runs once for a given version that attempts to use the APIs (directly or indirectly) of the prerequisites that you depend on. If that attempt fails, catch the exception and notify the user that they are lacking the respective prerequisite. If it is MDAC or SMO or something like that, try to use the calls that your app uses that calls through those layers (to do something trivial and non-destructive obviously). I’ll talk about strategy for managing one-time steps like this in your ClickOnce apps towards the end of this post.



Prerequisite Installation

The second part of the answer to the question on dependencies is to use the bootstrapper. This is a new feature in .NET 2.0 that is not technically part of ClickOnce, but ClickOnce capitalizes on it for getting prerequisites deployed. Using the bootstrapper, you can wrap up all the MSI installs that need to be run for prerequisites for your app into one setup.exe that an admin needs to run on the client box just once. It will run each of the prereq setup packages as needed, and then you should be good to go to run your ClickOnce app.



If your deployment environment is a controlled intranet environment, then managing this through group policy/SMS/basic client machine configuration control should be sufficient. You have an admin for your domain somewhere that has configuration control over the user’s desktops, get them to roll out the prereqs necessary to support your application. If your target audience is open internet users, then you will have to provide them a link to the bootstrapper setup.exe that they run first (and they will have to be an admin on the box to do so), and then they can run your ClickOnce app.



One-Time Setup Steps

The answer to the second question – how to do one-time actions for your ClickOnce app before it runs – is actually fairly simple to solve, even though ClickOnce provides no direct mechanisms for doing so. By design, ClickOnce does not allow you to configure or run any custom install steps as part of the ClickOnce deployment. The idea is that there should be no way the act of deploying a ClickOnce app to a client machine will corrupt other applications or other user data on that machine. If they opened the Pandora box of allowing custom install actions, there would be no way to guarantee that. However, once your app is on the machine and executing, it can do whatever it was designed to do, provided it has sufficient CAS permissions and that the user is authorized to access whatever resources they are trying to access.



For example, say that the first time you run your application, you need to prompt the user for the name of the database server and database name (obviously a power user requirement – something more simple would be just prompting them for their name and initials like Office apps do the first time they are run). Once you prompt the user for that information, you need to store it somewhere. You also have to have logic in your app to know to only prompt the user for that information the first time the app is run, so you need a flag somewhere to detect when that is the case.



Well, one of the simplest ways to do this is to use the data you are collecting for these situations as the flag themselves. If it is some other kind of one time action you need to run that doesn’t generate stored data, then you will need a separate flag that is easy to read and check at app startup. So all you need to do is have some internal startup code in your app that checks for the first time run flag, or checks for the presence of the needed data, and does appropriate prompting if not.



Once you have collected the data, you need to persist it for the next run, whether it is a simple first-time-run flag, or whether it is the database server and database name example mentioned above. You could just write it out to some file in your application directory, but that is going to require File I/O permission. You app will not have that permission by default in a ClickOnce deployed app on the internet or local intranet, unless you deploy it requesting elevated permissions. You should avoid asking for more permissions than you really need, and if this is the only File I/O your app does, then a better choice is to use Isolated Storage.




Reading and writing to Isolated Storage is permitted in both the Internet and Local Intranet CAS zones for AppDomain isolation, so you don’t need any elevated permissions to be able to read and write your config settings there. There is a new level of isolation in .NET 2.0, scoped to the application level (as opposed to the assembly and AppDomain levels present in earlier versions of .NET). Application scoped isolated storage settings will be accessible regardless of the version of your application when ClickOnce deployed, so you won’t lose these settings when the application is updated. However, if you want your app to check for pre-reqs each time the app is updated, maybe writing that flag to an appdomain scoped isolated storage is exactly what you want, because then the setting will be refreshed for each update.



Writing to isolated storage is fairly straightforward. You create an instance of an IsolatedStorageFile scoped to the appropriate isolation level through the exposed factory methods. You then create an IsolatedStorageFileStream, and from there it is just straight I/O against a stream, the same as you would do for a normal file in .NET.



I wrote a little helper class to make this as easy as possible to read and write settings from isolated storage, both in ClickOnce and non-ClickOnce applications. The IsoStoreSettingsHelper is shown below. To use this as is, you will need to set a reference to the System.Deployment.dll assembly in the framework, and will need the using statements shown below. The Read and Write methods take the name of file you want to use or create for storing settings within isolated storage. The Read method attempts to read a named setting from the specified file name in isolated storage. If the file does not exist, does not contain a hashtable, or the setting key specified cannot be found, then null will be returned. Otherwise the value from the hashtable will be returned. When write is called, it will add the specified key/value pair to the settings file, and will create it if it does not already exist.



The code dynamically selects either isolation by domain or isolation by application based on whether the application has been ClickOnce deployed. The reason for this is that application isolation depends on an identity for an application that is only present in a ClickOnce deployed app. So you cannot use application isolation unless you are running through ClickOnce. It checks for this with the IsNetworkDeployed property on the ApplicationDeployment class.



UPDATED CODE:

IsoStoreSettingsHelper class:




using System;

using System.IO;

using System.IO.IsolatedStorage;

using System.Deployment.Application;// Need to add reference to System.Deployment.dll framework assembly



namespace IDesign.Utilities

{

publicstaticclassIsoStoreSettingsHelper

{

///

/// Reads a setting from isolated storage.

/// Assumes isolation by domain for non-ClickOnce deployed apps.

/// Assumes isolation by application for ClickOnce deployed apps.

///

///The name of the settings file to use.

///The key name of the setting to look up.

///The value stored in the file if found, the default value for the type otherwise.

publicstatic T Read(string fileName,K key)

{

try

{

IsolatedStorageFile isoFile = OpenIsoStoreFile(fileName);

string[] files = isoFile.GetFileNames(fileName);

if(files.Length 0 || files[0].Length 0)

{

// File does not exist, return default

returndefault(T);

}

// File exists, deserialize the settings

using(Stream stream = newIsolatedStorageFileStream(fileName, FileMode.Open, isoFile))

{

XmlSerializableHashtable settings = XmlSerializableHashtable.Load(stream);

if(settings null)

{

returndefault(T);

}

return (T)settings[key];

}

}

catch

{}

returndefault(T);

}



///

/// Write a setting to an isolated storage setting file.

/// Assumes isolation by domain for non-ClickOnce deployed apps.

/// Assumes isolation by application for ClickOnce deployed apps.

///

///The name of the settings file to use.

///The key name of the setting to look up.

///The value of the object to store.

publicstaticvoid Write(string fileName, K key, T value)

{

try

{

IsolatedStorageFile isoFile = OpenIsoStoreFile(fileName);

XmlSerializableHashtable settings = null;

// First try to read in existing settings is there are any

using(Stream stream = newIsolatedStorageFileStream(fileName, FileMode.OpenOrCreate, isoFile))

{

try

{

settings = XmlSerializableHashtable.Load(stream);

}

catch

{

}

if(settings null)

{

settings = newXmlSerializableHashtable(); // Create empty one for new settings

}

}

//Add the new setting to the collection

settings[key] = value;

//Now write the collection back out

using(Stream writeStream = newIsolatedStorageFileStream(fileName,FileMode.Create,isoFile))

{

settings.Save(writeStream);

}

}

catch

{ }

}



staticIsolatedStorageFile OpenIsoStoreFile(string fileName)

{

IsolatedStorageFile isoFile = null;

if(ApplicationDeployment.IsNetworkDeployed)

{

isoFile = IsolatedStorageFile.GetUserStoreForApplication();

}

else

{

isoFile = IsolatedStorageFile.GetUserStoreForDomain();

}

return isoFile;

}

}

}



XmlSerializableHashtable class:

using System;

using System.Collections;

using System.Xml;

using System.Xml.Serialization;

using System.IO;



namespace IDesign.Utilities

{

// Support storage of .NET primitives

[XmlInclude( typeof(string) )]

[XmlInclude( typeof(bool) )]

[XmlInclude( typeof(short) )]

[XmlInclude( typeof(int) )]

[XmlInclude( typeof(long) )]

[XmlInclude( typeof(float) )]

[XmlInclude( typeof(double) )]

[XmlInclude( typeof(DateTime) )]

[XmlInclude( typeof(char) )]

[XmlInclude( typeof(decimal) )]

[XmlInclude( typeof(UInt16) )]

[XmlInclude( typeof(UInt32) )]

[XmlInclude( typeof(UInt64) )]

[XmlInclude( typeof(Int64) )]

publicclassXmlSerializableHashtable

{



#region Nested Class–Entry



///

/// Represents an entry for the hashtable

///

publicclassEntry

{

privateobject m_EntryKey;

privateobject m_EntryValue;



///

/// Default constructor, needed by serialization support

///

public Entry(){}



///

/// Construct the Entity specifying the key and the entry

///

///

///

public Entry( object entryKey , object entryValue )

{

m_EntryKey = entryKey;

m_EntryValue = entryValue;

}



///

/// Return the key

///

[XmlElement(“key”)]

publicobject EntryKey

{

get{ return m_EntryKey; }

set{ m_EntryKey = value; }

}



///

/// Return the entry value

///

[XmlElement(“value”)]

publicobject EntryValue

{

get{ return m_EntryValue; }

set{ m_EntryValue = value; }

}

}



#endregion



#region Declarations



privateHashtable m_HashTable;



#endregion



#region Constructors



///

/// Default constructor

///

public XmlSerializableHashtable()

{

m_HashTable = newHashtable();

}





///

/// Creates a serializable hashtable using a hashtable

///

///

public XmlSerializableHashtable( Hashtable existingHashTable )

{

m_HashTable = existingHashTable;

}





#endregion





#region Public Methods & Properties



///

/// Indexer to access the underlying Hashtable

///

///The key for which you want to retrieve the value

///

[XmlIgnore]

publicobjectthis[object key]

{

get

{

lock (m_HashTable.SyncRoot)

{

return m_HashTable[key];

}

}

set

{

lock (m_HashTable.SyncRoot)

{

m_HashTable[key] = value;

}

}

}



///

/// Save the hashtable to an output stream

///

///The stream to write to

publicvoid Save(Stream outputStream)

{

XmlSerializer serializer = newXmlSerializer(typeof(XmlSerializableHashtable));

serializer.Serialize(outputStream,this);

}



///

/// Load a hashtable from an input stream

///

///The stream from which to load

///A new instance of an XmlSerializableHashtable

publicstaticXmlSerializableHashtable Load(Stream inputStream)

{

XmlSerializer serializer = newXmlSerializer(typeof(XmlSerializableHashtable));

return serializer.Deserialize(inputStream) asXmlSerializableHashtable;

}



///

/// Returns the contained hashtable

///

[XmlIgnore]

publicHashtable InnerHashtable

{

get{ return m_HashTable; }

}



///

/// Used to serilalize the contents of the hashtable

///

publicEntry[] Entries

{

get

{

lock (m_HashTable.SyncRoot)

{

Entry[] entries = newEntry[m_HashTable.Count];

int index = 0;

foreach (DictionaryEntry entry in m_HashTable)

{

entries[index] = newEntry(entry.Key,entry.Value);

index++;

}

return entries;

}

}



set

{

lock( m_HashTable.SyncRoot )

{

m_HashTable.Clear();

foreach( Entry item invalue )

{

m_HashTable.Add(item.EntryKey,item.EntryValue);

}

}

}

}



#endregion

}

}