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.
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.
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.
