Remote Synthesis
Search my blog:
Viewing By Entry / Main
Jul 14, 2009

Building a Self-updating ColdFusion Builder Extension in 3 Steps

One of the things I love about Mango Blog is the way it checks for newer versions of itself and updates itself. I was thinking about this along because ColdFusion Builder extensions would seem like a perfect place to implement this kind of functionality. So, I got myself completely sidetracked trying to work out at least an initial draft implementation of how you could do this, using my Application Skeleton Generator extension as the guinea pig. The important thing to note here is that, since ColdFusion Builder extensions are simply CFML, we don't need to do anything fancy - though there were some pitfalls that I will discuss here. Lastly, I will cover how I hope to modularize this so that you can add it to your own extensions.

Step 1: Compare Versions

The first issue you need to handle is making you extension application aware of its current version and then compare it to the posted version. This already exists within the ide_config.xml required for all ColdFusion Builder extensions, so I simply read it from there. I didn't want to check for new versions every time you used the extension, so I am setting a date for the last update and plan on checking weekly (at this point that is hardcoded, but I hope to make that configurable in the future). Here's the updated onApplicationStart() method from my Application.cfc:

<cffunction name="onApplicationStart" returnType="boolean" output="false">
   <cfset var config = "" />
   <cffile action="read" file="#expandPath('../ide_config.xml')#" variable="config" />
   <cfset config = xmlParse(config) />
   <cfset application.version = config.application.version.xmlText />
   <!--- we don't want to reset this every time --->
   <cfif not StructKeyExists(application,"updateChecked")>
      <cfset application.updateChecked = "" />
   </cfif>
   
   <cfreturn true>
</cffunction>

You also need to add the following to your onRequest() method in Application.cfc:

<cfif not structKeyExists(application,"version")>
   <cfset onApplicationStart() />
</cfif>

So, now that the application knows its own version, it needs to know what version has been posted. In this implementation, I just posted the ide_config.xml to a location on my site where I have also placed a zip update. Therefore we simply need to parse the XML as we did above and compare the version numbers. I do this on the initial CFM page loaded by my extension. I wrap all of the external HTTP calls with a cftry and set the timeout pretty low since you don't need to be online to use the extension. Once I have checked if an update is available I either offer the user the option to download it or, if none is available, I display the extensions initial window as I would normally. Below is the code that does this:

<cfparam name="ideeventinfo">
<cfset data = xmlParse(ideeventinfo)>

<cfset updateAvailable = false />
<cfset onlineVersion = 0 />
<cfif not isDate(application.updateChecked) or dateDiff("d",application.updateChecked,now()) gte 7>
   <cftry>
      <cfhttp url="http://www.remotesynthesis.com/projects/Application Skeleton Generator/ide_config.xml" timeout="10" />
      <cfset onlineConfig = xmlParse(cfhttp.fileContent) />
      <cfset onlineVersion = onlineConfig.application.version.xmlText />
      <cfif onlineVersion gt application.version>
         <cfset updateAvailable = true />
      <cfelse>
         <cfset application.updateChecked = now() />
      </cfif>
      <cfcatch type="any">
         <!--- ignore --->
      </cfcatch>
   </cftry>
</cfif>

<cfif updateAvailable>
   <cfheader name="Content-Type" value="text/xml">
   <cfoutput>
   <response status="success" type="default">
      <ide handlerfile="downloadUpdate.cfm">
         <dialog width="320" height="200">
            <input name="Update Available (v. #onlineVersion#). Download?" Lable="downloadUpdate" type="list">
               <option value="Yes" />
               <option value="No" />
            </input>               
         </dialog>
      </ide>
   </response>
   </cfoutput>
<cfelse>
   <cfinclude template="selectSkeletonInclude.cfm" />
</cfif>

Step 2: Download and Install the Update
As you can see from the code above, if an update is available the window allows the user to choose to download it and passes the response to the downloadUpdate.cfm template. Looking at the downloadUpdate template, it will first check the user's response. If the user says "No," I will include the file for the standard initial window (i.e. the application proceeds as normal). If the user says "Yes," I perform a nummber of steps. First, I create a temp directory and download the zip file into that directory. Next I unzip the downloaded file into the extensions root directory.

Here's where you can hit a snag. If your zip includes the downloadUpdate.cfm file that we are currently executing, it will cause an error. However, since you will want to update this file, I simply rename it in the update to "_downloadUpdate.cfm" in the zip. I handle updating this file in the cleanup process we'll discuss later.

Another snag was that, when I used cfzip, everything was dumped into the root folder. I haven't encountered that in other cases (and, in fact, cfzip is used elsewhere in this extension). I am not sure what caused it, but I decided to steal the unzip code that Mango uses for its update process which worked the way I expected.

The last part of this code simply posts a window letting the user know that the update was installed and to rerun the extension to finish. Also, we remove the version key in application scope so that the new downloaded version is loaded upon the next request. Here's the code for downloadUpdate.cfm:

<cfparam name="ideeventinfo">
<cfset data = xmlParse(ideeventinfo)>

<cfscript>
/**
* Unzips a file to the specified directory.
*
* @param zipFilePath     Path to the zip file (Required)
* @param outputPath     Path where the unzipped file(s) should go (Required)
* @return void
* @author Samuel Neff (sam@serndesign.com)
* @version 1, September 1, 2003
*/
function unzipFile(zipFilePath, outputPath) {
   var zipFile = ""; // ZipFile
   var entries = ""; // Enumeration of ZipEntry
   var entry = ""; // ZipEntry
   var fil = ""; //File
   var inStream = "";
   var filOutStream = "";
   var bufOutStream = "";
   var nm = "";
   var pth = "";
   var lenPth = "";
   var buffer = "";
   var l = 0;

   zipFile = createObject("java", "java.util.zip.ZipFile");
   zipFile.init(zipFilePath);
   
   entries = zipFile.entries();
   
   while(entries.hasMoreElements()) {
      entry = entries.nextElement();
      if(NOT entry.isDirectory()) {
         nm = entry.getName();
         
         lenPth = len(nm) - len(getFileFromPath(nm));
         
         if (lenPth) {
         pth = outputPath & left(nm, lenPth);
      } else {
         pth = outputPath;
      }
      if (NOT directoryExists(pth)) {
         fil = createObject("java", "java.io.File");
         fil.init(pth);
         fil.mkdirs();
      }
      filOutStream = createObject(
         "java",
         "java.io.FileOutputStream");
      
      filOutStream.init(outputPath & nm);
      
      bufOutStream = createObject(
         "java",
         "java.io.BufferedOutputStream");
      
      bufOutStream.init(filOutStream);
      
      inStream = zipFile.getInputStream(entry);
      buffer = repeatString(" ",1024).getBytes();
      
      l = inStream.read(buffer);
      while(l GTE 0) {
         bufOutStream.write(buffer, 0, l);
         l = inStream.read(buffer);
      }
      inStream.close();
      bufOutStream.close();
      }
   }
   zipFile.close();
}
</cfscript>

<cfset doDownload = data.event.user.input.xmlAttributes.value />
<cfif doDownload eq "Yes">
   <cfif not directoryExists(expandPath('temp'))>
      <cfdirectory action="create" directory="#expandPath('temp')#" />
   </cfif>
   <cfhttp url="http://www.remotesynthesis.com/projects/Application Skeleton Generator/Application Skeleton Generator.zip" method="get" path="#expandPath('temp')#">
   
   <!---for some reason cfzip unzipped everthing into the root--->
   <cfset structDelete(application,"version") />
   <cfset application.updateChecked = now() />
   <cfset unzipFile(expandPath('temp')&'/Application Skeleton Generator.zip',expandPath('../')) />
   
   <cfheader name="Content-Type" value="text/xml">
   <response status="success" showresponse="true">
   <ide>
   <dialog width="550" height="350" />
   <body>
   <![CDATA[<p style="font-size:11px;">
Update downloaded and installed. Close this window and run the plugin again.
   </p>]]>
   </body>
   </ide>
   </response>
<cfelse>
   <cfinclude template="selectSkeletonInclude.cfm" />
</cfif>

Step 3: Cleanup
After performing the update, I found it necessary to perform a cleanup process before the next request is run. The reason for this was primarily the issue discussed above whereby I couldn't replace the executing file. However, while I was at it, I also removed the temp directory I created and some "garbage" that sometimes appears in the zip. Here's the cleanup process code which appears in the onRequest() method in Application.cfc:

<!--- do some cleanup on the update if one occurred --->
<cfif directoryExists(expandPath("temp"))>
   <cfdirectory action="delete" directory="#expandPath("temp")#" recurse="true" />
</cfif>
<cfif directoryExists(expandPath("../__MACOSX"))>
   <cfdirectory action="delete" directory="#expandPath("../__MACOSX")#" recurse="true" />
</cfif>
<!--- if I replace this file during the initial update the reponse errors --->
<cfif fileExists(expandPath("_downloadUpdate.cfm"))>
   <cffile action="delete" file="#expandPath("downloadUpdate.cfm")#" />
   <cffile action="rename" source="#expandPath("_downloadUpdate.cfm")#" destination="#expandPath("downloadUpdate.cfm")#" />
</cfif>

Looking Forward
I have a number of ideas on how to make this process more flexible and modularized so that it is easy to reuse. As it exists above, it works but is too tied into the extension code to be reused. In discussing this with Ray Camden, he suggested using his RIAForge API to check for the version and download the zip. This sounded like a great default option since many of us, including me, have uploaded our extensions there. I would need to work out how to overwrite the executing file in a better manner if I am going to do that since the download version would also be the install version (though it would be caught on the first run anyway, so it may be a non-issue). I also want to make the frequency of update checks configurable. In addition, I would like to offer the user an option not to be reminded of updates or "remind me later." Currently, if you choose the "No" option, you will be asked again when you rerun the extension.

Even though this is an early implementation, I wanted to share it to see if people had ideas and thoughts on how I can improve it and make it usable in their projects. In case you are wondering, the code for the extension on RIAForge has not been updated with this yet as I think it needs some further refining and configuring. However, if you are curious, the URL for the zip is in the code.

Comments
Dan Vega
Impressive stuff Brian! This will make it so much easier on the user.


Mike Schierberl
Nice work! I really want to get this into varScoper ASAP. What about removing the dependence on application completely and moving all the persistent info into a file (or into ide_config). Then you don't have to worry about application restarts breaking "remind me in a week".

Additionally, it might be nice to give a user the option to "ignore this release" and they wouldn't be reminded again until the next version.

Also, what are your thoughts about encapsulating this into a component? That would make it really easy to drop into another app with a single file. Something that would have hasUpdate(), runUpdate(), snooze(), etc.


Adam Tuttle
Brian, this looks like great work. I think I see a problem -- If you're storing the last time an update was checked for as an application variable, it will be reset every time the application is reset. The default setting for duration of application variables is 2 days. I can't envision many scenarios where I would be using the generator over and over enough to make it actually not check for updates in the 3-6 days range since the last check.

You might be better served storing that date in the XML file to get permanent persistence, and then load it on application startup, at the same time as the current version number.


Brian Rinaldi
@Adam - very good point. I will alter the process to use a file to persist.


Write your comment



(it will not be displayed)