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