Saturday 21 January 2017

Self-updating Application with SHA1 check and FireDAC

- or basic self-improvement with room for improvement.


There are many ways to keep the users windows applications updated, like pushing them out via enterprise distribution setups, click-once, Squirrel (not the scripting language or the animal). But what if I just want the basics - and as simple as possible?

Disclaimer: This is not a post about security - even if SHA1 is mentioned - this is solely intended as an example on how an update process could be done in a controlled environment - like in-house distribution.

So there are also several "updater" components that could help - I am aware of ones in the JediVCL, TMS Softwares TWebUpdate and CleverComponents TclWebUpdate - I have been using some of the last mentioned company's components for years - utilizing a launcher application that kept everything updated - apart from a basic core part of itself.

The idea here is just to create a simple application, that is able to check whether a new version is available, "download" it and replace the running program. I will them comment on where improvements could or should be done - and please feel free to use the comments for further suggestions :)

We start off creating a new VCL or FMX Application, throw in a couple of TLabels, a TButton, a database connection and a query component - here I use TFDConnection and TFDQuery - but you preference a different set of DACs - feel free.

When using FireDAC you will also need to include the TFDGUIxWaitCursor and the TFDPhysXXXDriverLink for the database you plan to have your latest an greatest version of application streamed from.

Already a word on improving here - you might want to setup a middle tier like a RESTfull server here, that does the talking with the database - and you might also not want to keep the actual binary in a database - at least you definitely want to compress it.

Well back to the conceptual fun - we start of getting the version and build info from our application by including a little helper procedure on the onFormCreate event:

procedure TForm1.FormCreate(Sender: TObject);

  procedure GetBuildInfo(var V1, V2, V3, V4: Word);
  var
    VerInfoSize: DWORD;
    VerInfo: pointer;
    VerValueSize: DWORD;
    VerValue: PVSFixedFileInfo;
    Dummy: DWORD;
  begin
    VerInfoSize := GetFileVersionInfoSize(PWideChar(ParamStr(0)), Dummy);
    GetMem(VerInfo, VerInfoSize);
    GetFileVersionInfo(PWideChar(Application.ExeName), 0, VerInfoSize, VerInfo);
    VerQueryValue(VerInfo, '\', pointer(VerValue), VerValueSize);
    with VerValue^ do
      begin
        V1 := dwFileVersionMS shr 16;
        V2 := dwFileVersionMS and $FFFF;
        V3 := dwFileVersionLS shr 16;
        V4 := dwFileVersionLS and $FFFF;
      end;
    FreeMem(VerInfo, VerInfoSize);
  end;

begin
  DeleteFile(Application.ExeName+'_');
  GetBuildInfo(curMajor, curMinor, curRelease, curBuildno);
  Label1.Caption := 'Running version: ' + IntToStr(curMajor) + '.' + IntToStr(curMinor) + '.' + IntToStr(curRelease) + '.' + IntToStr(curBuildno);
end;

Noticed the DeleteFile statement - well we will come back to that - when we talk about what you shouldn't do just because it works.

We then need to prepare our database with a table with some basic info like the filename, SHA1 hash and the build info for the fully tested, approved and deployed version of our program.

I just did a table called Application with the following (pseudo) structure:

CREATE TABLE Application (
  FileName varchar(128) NOT NULL,
  SHA1 varchar(40) NULL,
  FileDate datetime2(7) NULL,
  FileStream varbinary(max) NULL,
  Major int NULL,
  Minor int NULL,
  Release int NULL,
  Build int NULL
)

Datatypes may vary on your choice of database - here I am using one of my none-favorite. As mentioned above having the program binary in there might not be a good idea for you database/setup - very much depends on program size.

Setup the database connection component and ensure that you can connect.

Now on the TButton.OnClick event, we want to retrieve the light info on the deployed program, before we start "downloading" the binary data - which we might not need anyway - before we have determined if we should/would update.

procedure TForm1.btnUpdateClick(Sender: TObject);
var
  Update: Boolean;
begin
  FDQuery1.Open('select Major, Minor, Release, Build, SHA1 from Application where FileName = '+QuotedStr(ExtractFileName(Application.ExeName)));
  newMajor := FDQuery1.FieldByName('Major').AsInteger;
  newMinor := FDQuery1.FieldByName('Minor').AsInteger;
  newRelease := FDQuery1.FieldByName('Release').AsInteger;
  newBuildno := FDQuery1.FieldByName('Build').AsInteger;
  HashExpected := FDQuery1.FieldByName('SHA1').AsString;
  Label2.Caption := 'Version found: ' + IntToStr(newMajor) + '.' + IntToStr(newMinor) + '.' + IntToStr(newRelease) + '.' + IntToStr(newBuildno);
  FDQuery1.Close;
  Update := False;
  if newMajor>curMajor then
    Update := True;  // Must update - reinstall
  if not Update and (newMinor>curMinor) then
    Update := True;  // Must update
  if not Update and ((newRelease>curRelease) or (newBuildno>curBuildno)) then  // Optional update?
    Update := (MessageDlg('Minor update available - Update now?', mtInformation, mbYesNo, 0, mbYes) = mrYes);
  if Update then
  begin
    if (MessageDlg('Updating to newest version.', mtInformation, mbOKCancel, 0, mbOK) = mrOk) then
      DownloadRestart
    else
      Close; // Should update.
  end;
end;

So we validate on the version info differences, having just decided that other that major and minor version number changes are optional updates.

If we then end up wanting to update the DownloadRestart procedure is called.

procedure TForm1.DownloadRestart;
var
  ms: TMemoryStream;
  bs: TStream;
  hash: THashSHA1;
  GoodToGo: boolean;
begin
  GoodToGo := False;
  // Download and check SHA1
  FDQuery1.Open('select FileStream from Application where FileName='+QuotedStr(ExtractFileName(Application.ExeName)));
  hash := THashSHA1.Create;
  ms := TMemoryStream.Create;
  try
    bs := FDQuery1.CreateBlobStream(FDQuery1.FieldByName('FileStream'), bmRead);
    ms.LoadFromStream(bs);
  finally
    bs.Free;
  end;
  FDQuery1.Close;
  try
    hash.Update(ms.Memory, ms.Size);
    HashRetrived := hash.HashAsString;
    if HashExpected=HashRetrived then
    begin
      if RenameFile(Application.ExeName, Application.ExeName+'_') then
      begin
        try
          ms.SaveToFile(ExtractFileName(Application.ExeName));
        finally
          GoodToGo := True;
        end;
      end;
    end
    else
    begin
      ShowMessage('Error in download.');
      Exit;
    end;
  finally
    ms.Free;
  end;
  if GoodToGo then
  begin
    ShowMessage('Program updated - restarting..');
    ShellExecute(Handle, 'open', PWideChar(Application.ExeName), nil, nil, SW_SHOWNORMAL) ;
    Application.Terminate;
  end
  else
  begin
    if FileExists(Application.ExeName+'_') then // Put things back in order.
      RenameFile(Application.ExeName+'_', Application.ExeName);
  end;
end;

So we start off with getting the memory stream and generate the SHA1 hash key, so that we can compare it with the expected we earlier read from the database - if they match we can be confident that the "download" didn't fail.

Since Delphi XE8 there is the System.Hash unit which I use here, but if you for some strange reason use an older version of Delphi - you can use Indy or some other library - and also settle on CRC or MD5.

The reason for using SHA1, and not CRC or MD5 - was that CRC is not as bulletproof and MD5 takes a bit longer to generate - and I still wanted a fairly short hash.

So if the two hash keys matches, the binary data we have in memory is good, so we just need to get the running application out of the way.

There are nice ways of doing this, by maybe creating a command file that does the killing of the running process, renaming, coping, deleting and launching - and then execute that.

But we will stay conceptual with what apparently also works - just rename the running program, save the memory stream to the name of the running program. You can now try and start the updated version of the program, while terminating the running one.

There might be scenarios where the above might not work - long finalization or if the application implements a singleton pattern - would be some of my guesses.

Now you also know the reason for the DeleteFile statement in the onFormCreate event - so even if this is not the nicest code I have ever written - I did do a bit of housekeeping :)

Also be aware that the above example will not work if your program resides where you need elevated rights - so Program Files might not be the place to have this kind of fun.

I have not included the part about adding the binary data and the SHA1 hash key into the table record.

But basically you can just use you favorite database administration tool to load the binary into the blob-type field, and generate the SHA1 hash key using a function like this:

function SHA1(const fileName: string): string;
var
  hash: THashSHA1;
  fs : TFileStream;
  ms : TMemoryStream;
begin
  hash := THashSHA1.Create;
  ms := TMemoryStream.Create;
  fs := TFileStream.Create(fileName, fmOpenRead OR fmShareDenyWrite);
  ms.LoadFromStream(fs);
  try
    hash.Update(ms.Memory, ms.Size);
    result := hash.HashAsString;
  finally
    fs.Free;
    ms.Free;
  end;
end;

BTW: The THashXXX are record structures so no freeing.

There is a lot of room for improvements - the middle-tier is one thing, but also progress bars, resume-able downloads and the list goes on - many of the things that are included in offerings from TMS Software and CleverComponent.

Enjoy.

No comments:

Post a Comment