Workaround for Qt Installer Framework not overwriting existing installation
I finally found a workable solution.
You need three things to pull this off:
- A component script,
- A custom UI for the target directory page, and
- A controller script that clicks through the uninstaller automatically.
I will now list verbatim what is working for me (with my project specific stuff). My component is called Atlas4500 Tuner
config.xml:
<?xml version="1.0" encoding="UTF-8"?>
<Installer>
<Name>Atlas4500 Tuner</Name>
<Version>1.0.0</Version>
<Title>Atlas4500 Tuner Installer</Title>
<Publisher>EF Johnson Technologies</Publisher>
<StartMenuDir>EF Johnson</StartMenuDir>
<TargetDir>C:\Program Files (x86)\EF Johnson\Atlas4500 Tuner</TargetDir>
</Installer>
packages/Atlas4500 Tuner/meta/package.xml:
<?xml version="1.0" encoding="UTF-8"?>
<Package>
<DisplayName>Atlas4500Tuner</DisplayName>
<Description>Install the Atlas4500 Tuner</Description>
<Version>1.0.0</Version>
<ReleaseDate></ReleaseDate>
<Default>true</Default>
<Required>true</Required>
<Script>installscript.qs</Script>
<UserInterfaces>
<UserInterface>targetwidget.ui</UserInterface>
</UserInterfaces>
</Package>
custom component script packages/Atlas4500 Tuner/meta/installscript.qs:
var targetDirectoryPage = null;
function Component()
{
installer.gainAdminRights();
component.loaded.connect(this, this.installerLoaded);
}
Component.prototype.createOperations = function()
{
// Add the desktop and start menu shortcuts.
component.createOperations();
component.addOperation("CreateShortcut",
"@TargetDir@/Atlas4500Tuner.exe",
"@DesktopDir@/Atlas4500 Tuner.lnk",
"workingDirectory=@TargetDir@");
component.addOperation("CreateShortcut",
"@TargetDir@/Atlas4500Tuner.exe",
"@StartMenuDir@/Atlas4500 Tuner.lnk",
"workingDirectory=@TargetDir@");
}
Component.prototype.installerLoaded = function()
{
installer.setDefaultPageVisible(QInstaller.TargetDirectory, false);
installer.addWizardPage(component, "TargetWidget", QInstaller.TargetDirectory);
targetDirectoryPage = gui.pageWidgetByObjectName("DynamicTargetWidget");
targetDirectoryPage.windowTitle = "Choose Installation Directory";
targetDirectoryPage.description.setText("Please select where the Atlas4500 Tuner will be installed:");
targetDirectoryPage.targetDirectory.textChanged.connect(this, this.targetDirectoryChanged);
targetDirectoryPage.targetDirectory.setText(installer.value("TargetDir"));
targetDirectoryPage.targetChooser.released.connect(this, this.targetChooserClicked);
gui.pageById(QInstaller.ComponentSelection).entered.connect(this, this.componentSelectionPageEntered);
}
Component.prototype.targetChooserClicked = function()
{
var dir = QFileDialog.getExistingDirectory("", targetDirectoryPage.targetDirectory.text);
targetDirectoryPage.targetDirectory.setText(dir);
}
Component.prototype.targetDirectoryChanged = function()
{
var dir = targetDirectoryPage.targetDirectory.text;
if (installer.fileExists(dir) && installer.fileExists(dir + "/maintenancetool.exe")) {
targetDirectoryPage.warning.setText("<p style=\"color: red\">Existing installation detected and will be overwritten.</p>");
}
else if (installer.fileExists(dir)) {
targetDirectoryPage.warning.setText("<p style=\"color: red\">Installing in existing directory. It will be wiped on uninstallation.</p>");
}
else {
targetDirectoryPage.warning.setText("");
}
installer.setValue("TargetDir", dir);
}
Component.prototype.componentSelectionPageEntered = function()
{
var dir = installer.value("TargetDir");
if (installer.fileExists(dir) && installer.fileExists(dir + "/maintenancetool.exe")) {
installer.execute(dir + "/maintenancetool.exe", "--script=" + dir + "/scripts/auto_uninstall.qs");
}
}
Custom target directory widget packages/Atlas4500 Tuner/meta/targetwidget.ui:
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>TargetWidget</class>
<widget class="QWidget" name="TargetWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>491</width>
<height>190</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>491</width>
<height>190</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="description">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLineEdit" name="targetDirectory">
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="targetChooser">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="topMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="warning">
<property name="enabled">
<bool>true</bool>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>122</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
packages/Atlas4500 Tuner/data/scripts/auto_uninstall.qs:
// Controller script to pass to the uninstaller to get it to run automatically.
// It's passed to the maintenance tool during installation if there is already an
// installation present with: <target dir>/maintenancetool.exe --script=<target dir>/scripts/auto_uninstall.qs.
// This is required so that the user doesn't have to see/deal with the uninstaller in the middle of
// an installation.
function Controller()
{
gui.clickButton(buttons.NextButton);
gui.clickButton(buttons.NextButton);
installer.uninstallationFinished.connect(this, this.uninstallationFinished);
}
Controller.prototype.uninstallationFinished = function()
{
gui.clickButton(buttons.NextButton);
}
Controller.prototype.FinishedPageCallback = function()
{
gui.clickButton(buttons.FinishButton);
}
The idea here is to detect if the current directory has an installation in it or not, and, if it does, run the maintenance tool in that directory with a controller script that just clicks through it.
Note that I put the controller script in a scripts directory that is part of the actual component data. You could probably do something cleaner if you have multiple components, but this is what I am using.
You should be able to copy these files for yourself and just tweak the strings to make it work.
I made a hack around. Put it at the end of your installscript.qs.
component.addOperation("AppendFile", "@TargetDir@/cleanup.bat",
"ping 127.0.0.1 -n 4\r\ndel /F /Q maintenancetool.exe \
&& for /F %%L in ('reg query HKEY_USERS /v /f \"@TargetDir@\\maintenancetool.exe\" /d /t REG_SZ /s /e') do \
reg query %%L /v DisplayName \
&& reg delete %%L /f\r\ndel /F /Q cleanup.bat \
&& exit\r\n")
component.addOperation("Execute", "workingdirectory=@TargetDir@",
"cmd", "/C", "start", "/B",
"Cleaning up", "cmd /C ping 127.0.0.1 -n 2 > nul && cleanup.bat > nul")
This will delete the maintenancetool.exe after waiting 3 seconds which causes the installer to merely warn that the target folder is not empty instead of refusing to install. Also it deletes the registry entry for uninstalling the program so it doesn't accumulate in add/remove programs. Obviously after the maintenancetool is deleted you cannot use it anymore for things like uninstalling or updating, but I only support that via running the installer again. The maintenancetool is only written after installation is finished and the cmd start cmd
hackery is to make it so the installer doesn't notice that there is a step still running. If you have multiple optional components you may need to increase the delay or make it more robust to check if something is still running.
In theory it should not be necessary to write a batch file and then execute it. You should be able to execute the command directly. In practice I have not found a way to correctly escape quotes to make the right cmd instance evaluate the correct parts.
I found a way to use rationalcoder's solution without an embedded controller script !
All you have to do is launch the maintenancetool with the purge
command and send yes
to its standard input.
So replace this line installer.execute(dir + "/maintenancetool.exe", "--script=" + dir + "/scripts/auto_uninstall.qs");
from the component script by this line installer.execute(dir + "/maintenancetool.exe", ["purge"], "yes");
This way, the installation is replaced and the add/remove programs UI in Windows does not contain any duplicates.
For Windows users, make sure that the original installation directory isn't opened by a terminal. If it is, the directory will not get removed and the installation will fail. The directory will remain in a bugged state where you can't remove or access it until you restart your session.
There are few things you need to do:
To get past of TargetDirectoryPage which you can achieve by adding this code
installer.setValue("RemoveTargetDir", false)
Custom UI (or message box) that allow you to to run this code. This UI should be inserted after the TargetDirectoryPage.
// you need to append .exe on the maintenance for windows installation installer.execute(installer.findPath(installer.value("MaintenanceToolName"), installer.value("TargetDir")));