1 /** @file updater.cpp Automatic updater that works with dengine.net.
2  * @ingroup updater
3  *
4  * When one of the updater dialogs is shown, the main window is automatically
5  * switched to windowed mode. This is because the dialogs would be hidden
6  * behind the main window or incorrectly located when the main window is in
7  * fullscreen mode. It is also possible that the screen resolution is too low
8  * to fit the shown dialogs. In the long term, the native dialogs should be
9  * replaced with the engine's own (scriptable) UI widgets (once they are
10  * available).
11  *
12  * @authors Copyright © 2012-2017 Jaakko Keränen <jaakko.keranen@iki.fi>
13  * @authors Copyright © 2013 Daniel Swanson <danij@dengine.net>
14  *
15  * @par License
16  * GPL: http://www.gnu.org/licenses/gpl.html
17  *
18  * <small>This program is free software; you can redistribute it and/or modify
19  * it under the terms of the GNU General Public License as published by the
20  * Free Software Foundation; either version 2 of the License, or (at your
21  * option) any later version. This program is distributed in the hope that it
22  * will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
23  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
24  * Public License for more details. You should have received a copy of the GNU
25  * General Public License along with this program; if not, write to the Free
26  * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
27  * 02110-1301 USA</small>
28  */
29 
30 #include <QDateTime>
31 #include <QStringList>
32 #include <QDesktopServices>
33 #include <QNetworkAccessManager>
34 #include <QTextStream>
35 #include <QDir>
36 
37 #include "de_platform.h"
38 
39 #ifdef WIN32
40 #  undef open
41 #endif
42 
43 #include <stdlib.h>
44 #include "sys_system.h"
45 #include "dd_version.h"
46 #include "dd_def.h"
47 #include "dd_types.h"
48 #include "dd_main.h"
49 #include "clientapp.h"
50 #include "ui/nativeui.h"
51 #include "ui/clientwindowsystem.h"
52 #include "ui/clientwindow.h"
53 #include "ui/widgets/taskbarwidget.h"
54 #include "updater.h"
55 #include "updater/processcheckdialog.h"
56 #include "updater/updateavailabledialog.h"
57 #include "updater/updatedownloaddialog.h"
58 #include "updater/updatersettings.h"
59 #include "updater/updatersettingsdialog.h"
60 
61 #include <de/App>
62 #include <de/CommandLine>
63 #include <de/Date>
64 #include <de/LogBuffer>
65 #include <de/NotificationAreaWidget>
66 #include <de/SignalAction>
67 #include <de/Time>
68 #include <de/data/json.h>
69 #include <doomsday/console/exec.h>
70 
71 using namespace de;
72 
73 #ifdef MACOSX
74 #  define INSTALL_SCRIPT_NAME "deng-upgrade.scpt"
75 #endif
76 
77 #define PLATFORM_ID     DENG_PLATFORM_ID
78 
79 static CommandLine* installerCommand;
80 
81 /**
82  * Callback for atexit(). Create the installerCommand before calling this.
83  */
runInstallerCommand(void)84 static void runInstallerCommand(void)
85 {
86     DENG_ASSERT(installerCommand != 0);
87 
88     installerCommand->execute();
89     delete installerCommand;
90     installerCommand = 0;
91 }
92 
93 /**
94  * Notification widget about the status of the Updater.
95  */
96 class UpdaterStatusWidget : public ProgressWidget
97 {
98 public:
UpdaterStatusWidget()99     UpdaterStatusWidget()
100     {
101         useMiniStyle();
102         setColor("text");
103         setShadowColor(""); // no shadow, please
104         setSizePolicy(ui::Expand, ui::Expand);
105 
106         _icon = new LabelWidget;
107         _icon->setImage(ClientApp::windowSystem().style().images().image("updater"));
108         _icon->setOverrideImageSize(overrideImageSize());
109         _icon->rule().setRect(rule());
110         add(_icon);
111         hideIcon();
112 
113         // The notification has a hidden button that can be clicked.
114         _clickable = new PopupButtonWidget;
115         _clickable->setOpacity(0); // not drawn
116         _clickable->rule().setRect(rule());
117         _clickable->setOpener([] (PopupWidget *) {
118             ClientApp::updater().showCurrentDownload();
119         });
120         add(_clickable);
121     }
122 
showIcon(DotPath const & path)123     void showIcon(DotPath const &path)
124     {
125         _icon->setImageColor(ClientApp::windowSystem().style().colors().colorf(path));
126     }
127 
hideIcon()128     void hideIcon()
129     {
130         _icon->setImageColor(Vector4f());
131     }
132 
popupButton()133     PopupButtonWidget &popupButton()
134     {
135         return *_clickable;
136     }
137 
138 private:
139     LabelWidget *_icon;
140     PopupButtonWidget *_clickable;
141 };
142 
DENG2_PIMPL(Updater)143 DENG2_PIMPL(Updater)
144 , DENG2_OBSERVES(App, StartupComplete)
145 {
146     QNetworkAccessManager *network = nullptr;
147     UpdateDownloadDialog *download = nullptr; // not owned (in the widget tree, if exists)
148     UniqueWidgetPtr<UpdaterStatusWidget> status;
149     UpdateAvailableDialog *availableDlg = nullptr; ///< If currently open (not owned).
150     bool alwaysShowNotification;
151     bool savingSuggested = false;
152 
153     Version latestVersion;
154     QString latestPackageUri;
155     QString latestPackageUri2; // fallback location
156     QString latestLogUri;
157 
158     Impl(Public *i) : Base(i)
159     {
160         network = new QNetworkAccessManager(thisPublic);
161 
162         // Delete a package installed earlier?
163         UpdaterSettings st;
164         if (st.deleteAfterUpdate())
165         {
166             de::String p = st.pathToDeleteAtStartup();
167             if (!p.isEmpty())
168             {
169                 QFile file(p);
170                 if (file.exists())
171                 {
172                     LOG_NOTE("Deleting previously installed package: %s") << p;
173                     file.remove();
174                 }
175             }
176         }
177         st.setPathToDeleteAtStartup("");
178     }
179 
180     void setupUI()
181     {
182         status.reset(new UpdaterStatusWidget);
183     }
184 
185     QString composeCheckUri()
186     {
187         UpdaterSettings st;
188         String uri = String("%1builds?latest_for=%2&type=%3")
189                 .arg(App::apiUrl())
190                 .arg(DENG_PLATFORM_ID)
191                 .arg(st.channel() == UpdaterSettings::Stable? "stable" :
192                      st.channel() == UpdaterSettings::Unstable? "unstable" : "candidate");
193         LOG_XVERBOSE("URI: ", uri);
194         return uri;
195     }
196 
197     bool shouldCheckForUpdate() const
198     {
199         UpdaterSettings st;
200         if (st.onlyCheckManually()) return false;
201 
202         float dayInterval = 30;
203         switch (st.frequency())
204         {
205         case UpdaterSettings::AtStartup:
206             dayInterval = 0;
207             break;
208 
209         case UpdaterSettings::Daily:
210             dayInterval = 1;
211             break;
212 
213         case UpdaterSettings::Biweekly:
214             dayInterval = 5;
215             break;
216 
217         case UpdaterSettings::Weekly:
218             dayInterval = 7;
219             break;
220 
221         default:
222             break;
223         }
224 
225         de::Time now;
226 
227         // Check always when the day interval has passed. Note that this
228         // doesn't check the actual time interval since the last check, but the
229         // difference in "calendar" days.
230         if (st.lastCheckTime().asDate().daysTo(de::Date()) >= dayInterval)
231             return true;
232 
233         if (st.frequency() == UpdaterSettings::Biweekly)
234         {
235             // Check on Tuesday and Saturday, as the builds are usually on
236             // Monday and Friday.
237             int weekday = now.asDateTime().date().dayOfWeek();
238             if (weekday == 2 || weekday == 6) return true;
239         }
240 
241         // No need to check right now.
242         return false;
243     }
244 
245     void appStartupCompleted()
246     {
247         LOG_AS("Updater")
248         LOG_DEBUG("App startup was completed");
249 
250         if (shouldCheckForUpdate())
251         {
252             queryLatestVersion(false);
253         }
254     }
255 
256     void showNotification(bool show)
257     {
258         ClientWindow::main().notifications().showOrHide(*status, show);
259     }
260 
261     void showCheckingNotification()
262     {
263         status->setRange(Rangei(0, 1));
264         status->setProgress(0, 0);
265         status->showIcon("text");
266         showNotification(true);
267     }
268 
269     void showUpdateAvailableNotification()
270     {
271         showCheckingNotification();
272         status->showIcon("accent");
273     }
274 
275     void showDownloadNotification()
276     {
277         status->setMode(ProgressWidget::Indefinite);
278         status->hideIcon();
279         showNotification(true);
280     }
281 
282     void queryLatestVersion(bool notifyAlways)
283     {
284         showCheckingNotification();
285 
286         UpdaterSettings().setLastCheckTime(de::Time());
287         alwaysShowNotification = notifyAlways;
288         network->get(QNetworkRequest(composeCheckUri()));
289     }
290 
291     void handleReply(QNetworkReply *reply)
292     {
293         reply->deleteLater(); // make sure it gets deleted
294 
295         DENG2_ASSERT_IN_MAIN_THREAD();
296         showNotification(false);
297 
298         if (reply->error() != QNetworkReply::NoError)
299         {
300             LOG_WARNING("Network request failed: %s") << reply->url().toString();
301             return;
302         }
303 
304         QVariant result = de::parseJSON(QString::fromUtf8(reply->readAll()));
305         if (!result.isValid()) return;
306 
307         QVariantMap const map = result.toMap();
308         if (!map.contains("direct_download_uri")) return;
309 
310         latestPackageUri = map["direct_download_uri"].toString();
311         latestLogUri     = map["release_changeloguri"].toString();
312 
313         // Check if a fallback location is specified for the download.
314         if (map.contains("direct_download_fallback_uri"))
315         {
316             latestPackageUri2 = map["direct_download_fallback_uri"].toString();
317         }
318         else
319         {
320             latestPackageUri2 = "";
321         }
322 
323         latestVersion = Version(map["version"].toString(), map["build_uniqueid"].toInt());
324 
325         Version const currentVersion = Version::currentBuild();
326 
327         LOG_MSG(_E(b) "Received version information:\n" _E(.)
328                 " - installed version: " _E(>) "%s ") << currentVersion.asHumanReadableText();
329         LOG_MSG(" - latest version: " _E(>) "%s") << latestVersion.asHumanReadableText();
330         LOG_MSG(" - package: " _E(>) _E(i) "%s") << latestPackageUri;
331         LOG_MSG(" - change log: " _E(>) _E(i) "%s") << latestLogUri;
332 
333         if (availableDlg)
334         {
335             // This was a recheck.
336             availableDlg->showResult(latestVersion, latestLogUri);
337             return;
338         }
339 
340         bool const gotUpdate = latestVersion > currentVersion;
341 
342         // Is this newer than what we're running?
343         if (gotUpdate)
344         {
345             LOG_NOTE("Found an update: " _E(b)) << latestVersion.asHumanReadableText();
346 
347             if (!alwaysShowNotification)
348             {
349                 if (UpdaterSettings().autoDownload())
350                 {
351                     startDownload();
352                     return;
353                 }
354 
355                 // Show the notification so the user knows an update is
356                 // available.
357                 showUpdateAvailableNotification();
358             }
359         }
360         else
361         {
362             LOG_NOTE("You are running the latest available " _E(b) "%s" _E(.) " release")
363                     << (UpdaterSettings().channel() == UpdaterSettings::Stable? "stable" : "unstable");
364         }
365 
366         if (alwaysShowNotification)
367         {
368             showAvailableDialogAndPause();
369         }
370     }
371 
372     void showAvailableDialogAndPause()
373     {
374         if (availableDlg) return; // Just one at a time.
375 
376         // Modal dialogs will interrupt gameplay.
377         ClientWindow::main().taskBar().openAndPauseGame();
378 
379         availableDlg = new UpdateAvailableDialog(latestVersion, latestLogUri);
380         execAvailableDialog();
381     }
382 
383     void execAvailableDialog()
384     {
385         DENG2_ASSERT(availableDlg != 0);
386 
387         availableDlg->setDeleteAfterDismissed(true);
388         QObject::connect(availableDlg, SIGNAL(checkAgain()), thisPublic, SLOT(recheck()));
389 
390         if (availableDlg->exec(ClientWindow::main().root()))
391         {
392             startDownload();
393             download->open();
394         }
395         availableDlg = 0;
396     }
397 
398     void startDownload()
399     {
400         DENG2_ASSERT(!download);
401 
402         // The notification provides access to the download dialog.
403         showDownloadNotification();
404 
405         LOG_MSG("Download and install update");
406 
407         download = new UpdateDownloadDialog(latestPackageUri, latestPackageUri2);
408         status->popupButton().setPopup(*download, ui::Down);
409         QObject::connect(download, SIGNAL(closed()), thisPublic, SLOT(downloadDialogClosed()));
410         QObject::connect(download, SIGNAL(downloadProgress(int)),thisPublic, SLOT(downloadProgressed(int)));
411         QObject::connect(download, SIGNAL(downloadFailed(QString)), thisPublic, SLOT(downloadFailed(QString)));
412         QObject::connect(download, SIGNAL(accepted(int)), thisPublic, SLOT(downloadCompleted(int)));
413 
414         ClientWindow::main().root().addOnTop(download);
415     }
416 
417     /**
418      * Starts the installation process using the provided distribution package.
419      * The engine is first shut down gracefully (game has already been autosaved).
420      *
421      * @param distribPackagePath  File path of the distribution package.
422      */
423     void startInstall(de::String distribPackagePath)
424     {
425 #ifdef MACOSX
426         de::String volName = "Doomsday Engine " + latestVersion.compactNumber();
427 
428 #ifdef DENG2_QT_5_0_OR_NEWER
429         QString scriptPath = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
430 #else
431         QString scriptPath = QDesktopServices::storageLocation(QDesktopServices::CacheLocation);
432 #endif
433         QDir::current().mkpath(scriptPath); // may not exist
434         scriptPath = QDir(scriptPath).filePath(INSTALL_SCRIPT_NAME);
435         QFile file(scriptPath);
436         if (file.open(QFile::WriteOnly | QFile::Truncate))
437         {
438             QTextStream out(&file);
439             out << "tell application \"System Events\" to set visible of process \"Finder\" to true\n"
440                    "tell application \"Finder\"\n"
441                    "  open POSIX file \"" << distribPackagePath << "\"\n"
442                    "  -- Wait for it to get mounted\n"
443                    "  repeat until name of every disk contains \"" << volName << "\"\n"
444                    "    delay 1\n"
445                    "  end repeat\n"
446                    /*"  -- Start the installer\n"
447                    "  open file \"" << volName << ":Doomsday.pkg\"\n"
448                    "  -- Activate the Installer\n"
449                    "  repeat until name of every process contains \"Installer\"\n"
450                    "    delay 2\n"
451                    "  end repeat\n"*/
452                    "end tell\n"
453                    /*"delay 1\n"
454                    "tell application \"Installer\" to activate\n"
455                    "tell application \"Finder\"\n"
456                    "  -- Wait for it to finish\n"
457                    "  repeat until name of every process does not contain \"Installer\"\n"
458                    "    delay 1\n"
459                    "  end repeat\n"
460                    "  -- Unmount\n"
461                    "  eject disk \"" << volName << "\"\n"
462                    "end tell\n"*/;
463             file.close();
464         }
465         else
466         {
467             qWarning() << "Could not write" << scriptPath;
468         }
469 
470         // Register a shutdown action to execute the script and quit.
471         installerCommand = new de::CommandLine;
472         installerCommand->append("osascript");
473         installerCommand->append(scriptPath);
474         atexit(runInstallerCommand);
475 
476 #elif defined(WIN32)
477         /**
478          * @todo It would be slightly neater to check all these processes at
479          * the same time.
480          */
481         Updater_AskToStopProcess("doomsday-shell.exe", "Please quit all Doomsday Shell instances "
482                                  "before starting the update. Windows cannot update "
483                                  "files that are currently in use.");
484 
485         Updater_AskToStopProcess("doomsday-server.exe", "Please stop all Doomsday servers "
486                                  "before starting the update. Windows cannot update "
487                                  "files that are currently in use.");
488 
489         // The distribution package is in .msi format.
490         installerCommand = new de::CommandLine;
491         installerCommand->append("msiexec");
492         installerCommand->append("/i");
493         installerCommand->append(distribPackagePath);
494         atexit(runInstallerCommand);
495 
496 #else
497         // Open the package with the default handler.
498         installerCommand = new de::CommandLine;
499         installerCommand->append("xdg-open");
500         installerCommand->append(distribPackagePath);
501         atexit(runInstallerCommand);
502 #endif
503 
504         // If requested, delete the downloaded package afterwards. Currently
505         // this occurs the next time when the engine is launched; on some
506         // platforms it could be incorporated into the reinstall procedure.
507         // (This will work better when there is no more separate frontend, as
508         // the engine is restarted after the install.)
509         UpdaterSettings st;
510         if (st.deleteAfterUpdate())
511         {
512             st.setPathToDeleteAtStartup(distribPackagePath);
513         }
514 
515         Sys_Quit();
516     }
517 };
518 
Updater()519 Updater::Updater() : d(new Impl(this))
520 {
521     connect(d->network, SIGNAL(finished(QNetworkReply *)), this, SLOT(gotReply(QNetworkReply *)));
522 
523     // Do a silent auto-update check when starting.
524     App::app().audienceForStartupComplete() += d;
525 }
526 
setupUI()527 void Updater::setupUI()
528 {
529     d->setupUI();
530 }
531 
progress()532 ProgressWidget &Updater::progress()
533 {
534     return *d->status;
535 }
536 
gotReply(QNetworkReply * reply)537 void Updater::gotReply(QNetworkReply *reply)
538 {
539     d->handleReply(reply);
540 }
541 
downloadProgressed(int percentage)542 void Updater::downloadProgressed(int percentage)
543 {
544     d->status->setRange(Rangei(0, 100));
545     d->status->setProgress(percentage);
546 }
547 
downloadCompleted(int)548 void Updater::downloadCompleted(int)
549 {
550     // Autosave the game.
551     // Well, we can't do that yet so just remind the user about saving.
552     if (App_GameLoaded() && !d->savingSuggested && gx.GetInteger(DD_GAME_RECOMMENDS_SAVING))
553     {
554         d->savingSuggested = true;
555 
556         MessageDialog *msg = new MessageDialog;
557         msg->setDeleteAfterDismissed(true);
558         msg->title().setText(tr("Save Game?"));
559         msg->message().setText(tr(_E(b) "Installing the update will discard unsaved progress in the game.\n\n"
560                                   _E(.) "Doomsday will be shut down before the installation can start. "
561                                   "The game is not saved automatically, so you will have to "
562                                   "save the game before installing the update."));
563         msg->buttons()
564                 << new DialogButtonItem(DialogWidget::Accept | DialogWidget::Default, tr("I'll Save First"))
565                 << new DialogButtonItem(DialogWidget::Reject, tr("Discard Progress & Install"));
566 
567         if (msg->exec(ClientWindow::main().root()))
568         {
569             Con_Execute(CMDS_DDAY, "savegame", false, false);
570             return;
571         }
572     }
573 
574     /// @todo Check the signature of the downloaded file.
575 
576     // Everything is ready to begin the installation!
577     d->startInstall(d->download->downloadedFilePath());
578 
579     // The download dialog can be dismissed now.
580     d->download->guiDeleteLater();
581     d->download = 0;
582     d->savingSuggested = false;
583 }
584 
downloadFailed(QString message)585 void Updater::downloadFailed(QString message)
586 {
587     LOG_NOTE("Update cancelled: ") << message;
588 }
589 
recheck()590 void Updater::recheck()
591 {
592     d->queryLatestVersion(d->alwaysShowNotification);
593 }
594 
showSettings()595 void Updater::showSettings()
596 {
597     //d->showSettingsNonModal();
598 
599     ClientWindow::main().taskBar().showUpdaterSettings();
600 }
601 
showCurrentDownload()602 void Updater::showCurrentDownload()
603 {
604     if (d->download)
605     {
606         d->download->open();
607     }
608     else
609     {
610         d->showNotification(false);
611         d->showAvailableDialogAndPause();
612     }
613 }
614 
checkNow(CheckMode mode)615 void Updater::checkNow(CheckMode mode)
616 {
617     // Not if there is an ongoing download.
618     if (d->download)
619     {
620         d->download->open();
621         return;
622     }
623 
624     d->queryLatestVersion(mode == AlwaysShowResult);
625 }
626 
checkNowShowingProgress()627 void Updater::checkNowShowingProgress()
628 {
629     // Not if there is an ongoing download.
630     if (d->download) return;
631 
632     ClientWindow::main().glActivate();
633 
634     d->availableDlg = new UpdateAvailableDialog;
635     d->queryLatestVersion(true);
636     d->execAvailableDialog();
637 }
638 
printLastUpdated(void)639 void Updater::printLastUpdated(void)
640 {
641     String ago = UpdaterSettings().lastCheckAgo();
642     if (ago.isEmpty())
643     {
644         LOG_MSG("Never checked for updates");
645     }
646     else
647     {
648         LOG_MSG("Latest update check was made %s") << ago;
649     }
650 }
651 
downloadDialogClosed()652 void Updater::downloadDialogClosed()
653 {
654     if (!d->download || d->download->isFailed())
655     {
656         if (d->download)
657         {
658             d->download->setDeleteAfterDismissed(true);
659             d->download = 0;
660         }
661         d->showNotification(false);
662     }
663 }
664