10

This question is about version 2.0 of the Qt Installer Framework.

At this point, it is common knowledge for people using the Qt Installer Framework that, without customization, you simply can't overwrite an existing installation through your installer. This was apparently done to resolve some issues that occurred when this was done with the Qt framework.

However, for smaller, relatively simple projects, overwriting is perfectly fine and much more convenient than having to manually run the maintenance tool beforehand.

I am looking for a solution involving a custom UI + component script that adds a button to the target directory page that allows the user to either

  1. Remove the specified directory if it exists, or
  2. Run the maintenance tool in that directory.

It would be preferable to be able to run the maintenance tool in the target directory, having it automatically remove a given package, but I realize that that is asking for a little too much.

I have read answers to other questions on this site about solving the same problem, but none of the solutions work correctly. I would also like to mention that I have a component script up and running, but no custom UIs.

rationalcoder
  • 1,327
  • 1
  • 13
  • 27

5 Answers5

18

I finally found a workable solution.

You need three things to pull this off:

  1. A component script,
  2. A custom UI for the target directory page, and
  3. 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.

rationalcoder
  • 1,327
  • 1
  • 13
  • 27
  • 1
    It works but the original installation must have this auto_uninstall script to work. Having this file in from the previous release of a project is necessary. – N Jacobs Sep 03 '18 at 12:15
  • This is really great and useful, thank you. One change I made that I think makes sense is to move the running of the uninstaller into QInstaller.PerformInstallation, since we're done asking the user questions and they not going to bail at that point. – Fred Ross-Perry Sep 24 '18 at 23:30
  • @FredRoss-Perry I would like to know more about this. Does this remove the need for a second administrator privilege check? What exactly were your changes? – rationalcoder Sep 28 '18 at 23:00
  • @rationalcoder - I did not do admin checks (I probably should). What I changed was to add this line: Component.prototype.installerLoaded = function() { ... gui.pageById(QInstaller.PerformInstallation).entered.connect(this, this.componentPerformInstallationPageEntered); } and then add this function: Component.prototype.componentPerformInstallationPageEntered = function () { // before we install, we'll run the uninstaller silently (not shown) } – Fred Ross-Perry Sep 29 '18 at 04:05
  • Thx, it works as expected. I tried to move the uninstall when entering in PerformInstallation page like @FredRoss-Perry but the following installation is partial after, some files are missing (like my application binary :-(). I tried to add a timer without success, it looks like a synchronization issue, but the installer.execute should be synchronous. Have you any idea? – Xavier Bigand Aug 27 '20 at 12:08
2

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.

Skeird
  • 51
  • 5
0

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")));

mbm
  • 905
  • 10
  • 12
  • This put me on the right path, as the running the maintenance tool works like this. However, the "RemoveTargerDir" doesn't do anything. See my answer for the solution I came up with. – rationalcoder Oct 06 '17 at 21:37
0

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.

Macke
  • 22,774
  • 6
  • 76
  • 108
nwp
  • 8,897
  • 2
  • 32
  • 67
  • I'll try this when I get a chance. – rationalcoder Sep 14 '19 at 22:44
  • Check your installed programs through the add/remove programs UI in Windows. Do you get duplicates of the installed program when you do it this way? – rationalcoder Sep 16 '19 at 01:56
  • @rationalcoder I do indeed, which sucks. I'll see if I can find a hack for that too. – nwp Sep 16 '19 at 07:16
  • @rationalcoder Finally found more hacks that remove the add/remove programs entry as well. The good part is that it's 2 lines to copy/paste. The bad part is that it will probably break when you look at it funny. – nwp Sep 19 '19 at 11:57
0

Ok, this answer is based on the latest installer framework (3.2.2), I'm not sure if it works for older versions.

To overwrite target dir, you just need set RemoveTargetDir to false in your configuration file:

<RemoveTargetDir>false</RemoveTargetDir>

It worked.

Offical doc explains this element as below:

Set to false if the target directory should not be deleted when uninstalling.

It's a bit confusing if you dont know how it is used:

bool TargetDirectoryPage::validatePage()
{
    m_textChangeTimer.stop();

    if (!isComplete())
        return false;

    if (!isVisible())
        return true;

    ///
    /// NOTICE HERE:
    /// If you set RemoveTargetDir to false, function return true here.
    const QString remove = packageManagerCore()->value(QLatin1String("RemoveTargetDir"));
    if (!QVariant(remove).toBool())
        return true;

    const QString targetDir = this->targetDir();
    const QDir dir(targetDir);
    // the directory exists and is empty...
    if (dir.exists() && dir.entryList(QDir::AllEntries | QDir::NoDotAndDotDot).isEmpty())
        return true;

    const QFileInfo fi(targetDir);
    if (fi.isDir()) {
        QString fileName = packageManagerCore()->settings().maintenanceToolName();
#if defined(Q_OS_MACOS)
        if (QInstaller::isInBundle(QCoreApplication::applicationDirPath()))
            fileName += QLatin1String(".app/Contents/MacOS/") + fileName;
#elif defined(Q_OS_WIN)
        fileName += QLatin1String(".exe");
#endif

        QFileInfo fi2(targetDir + QDir::separator() + fileName);
        ///
        /// AND NOTICE HERE:
        /// Do exists check here.
        if (fi2.exists()) {
            return failWithError(QLatin1String("TargetDirectoryInUse"), tr("The directory you selected already "
                "exists and contains an installation. Choose a different target for installation."));
        }

        return askQuestion(QLatin1String("OverwriteTargetDirectory"),
            tr("You have selected an existing, non-empty directory for installation.\nNote that it will be "
            "completely wiped on uninstallation of this application.\nIt is not advisable to install into "
            "this directory as installation might fail.\nDo you want to continue?"));
    } else if (fi.isFile() || fi.isSymLink()) {
        return failWithError(QLatin1String("WrongTargetDirectory"), tr("You have selected an existing file "
            "or symlink, please choose a different target for installation."));
    }
    return true;
}

Notice comments "NOTICE HERE:".

KnowNothing
  • 121
  • 1
  • 10
  • 1
    As far as I tested, this approach won't remove the previous installation. So, both old and new installations would be among Windows programs. Not a preferred behavior. – user3405291 Jan 24 '21 at 13:28
  • @user3405291yes you are right, and finally i had to refactor my installer to be online to avoid this. – KnowNothing Apr 09 '21 at 11:26