demomanager.cpp
1 //------------------------------------------------------------------------------
2 // demomanager.h
3 //------------------------------------------------------------------------------
4 //
5 // This library is free software; you can redistribute it and/or
6 // modify it under the terms of the GNU Lesser General Public
7 // License as published by the Free Software Foundation; either
8 // version 2.1 of the License, or (at your option) any later version.
9 //
10 // This library is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13 // Lesser General Public License for more details.
14 //
15 // You should have received a copy of the GNU Lesser General Public
16 // License along with this library; if not, write to the Free Software
17 // Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
18 // 02110-1301 USA
19 //
20 //------------------------------------------------------------------------------
21 // Copyright (C) 2011 Braden "Blzut3" Obrzut <admin@maniacsvault.net>
22 //------------------------------------------------------------------------------
23 #include "demomanager.h"
24 
25 #include "configuration/doomseekerconfig.h"
26 #include "fileutils.h"
27 #include "gamedemo.h"
28 #include "gui/commongui.h"
29 #include "gui/createserver/wadspicker.h"
30 #include "gui/createserverdialog.h"
31 #include "gui/demometadatadialog.h"
32 #include "pathfinder/pathfinder.h"
33 #include "pathfinder/wadpathfinder.h"
34 #include "plugins/enginedefaults.h"
35 #include "plugins/engineplugin.h"
36 #include "plugins/pluginloader.h"
37 #include "serverapi/gamecreateparams.h"
38 #include "serverapi/gameexeretriever.h"
39 #include "serverapi/gamehost.h"
40 #include "serverapi/message.h"
41 #include "serverapi/server.h"
42 #include "templatedpathresolver.h"
43 #include "ui_demomanager.h"
44 
45 #include <QDir>
46 #include <QFileDialog>
47 #include <QMenu>
48 #include <QMessageBox>
49 #include <QModelIndex>
50 #include <QPointer>
51 #include <QSplitter>
52 #include <QStandardItemModel>
53 #include <QStandardPaths>
54 #include <QString>
55 
56 #include <cstdint>
57 
58 namespace
59 {
60 class DemoModel : public QStandardItemModel
61 {
62  Q_OBJECT;
63 
64 public:
65  enum Column : int8_t
66  {
67  ColData = 0,
68  ColGame = 0,
69  ColCreatedTime,
70  ColAuthor,
71  ColIwad,
72  ColWads,
73  };
74 
75  enum Role : int
76  {
77  ManagedNameRole = Qt::UserRole + 1,
78  FilePathRole = Qt::UserRole + 2,
79  };
80 
81  DemoModel(QObject *parent) : QStandardItemModel(parent)
82  {
83  setHorizontalHeaderLabels({"", tr("Created Time"), tr("Author"), tr("IWAD"), tr("WADs")});
84  }
85 
86  void addGameDemo(const GameDemo &demo)
87  {
88  auto gameinfo = this->gameInfo(demo.game);
89  auto *itemGame = new QStandardItem;
90  itemGame->setIcon(gameinfo.icon);
91  itemGame->setToolTip(gameinfo.name);
92 
93  auto *itemCreatedTime = new QStandardItem;
94  itemCreatedTime->setData(demo.time, Qt::EditRole);
95  itemCreatedTime->setToolTip(demo.time.toString());
96 
97  auto *itemAuthor = new QStandardItem(demo.author);
98  itemAuthor->setToolTip(demo.author);
99 
100  auto *itemIwad = new QStandardItem(demo.iwad);
101  itemIwad->setToolTip(demo.iwad);
102 
103  QStringList wads;
104  for (const PWad &wad : demo.wads)
105  {
106  wads << (wad.isOptional()
107  ? ("[" + wad.name() + "]")
108  : wad.name());
109  }
110  auto *itemWads = new QStandardItem(wads.join("; "));
111  itemWads->setToolTip(wads.join("\n"));
112 
113  QStandardItem *itemData = itemGame;
114  itemData->setData(demo.managedName(), ManagedNameRole);
115  itemData->setData(demo.demopath, FilePathRole);
116 
117  appendRow(QList<QStandardItem *> {itemGame, itemCreatedTime, itemAuthor, itemIwad, itemWads});
118  }
119 
120  QString demoNameFromIndex(const QModelIndex &index) const
121  {
122  return index.isValid() ? demoNameFromRow(index.row()) : QString();
123  }
124 
125  QString demoNameFromRow(int row) const
126  {
127  if (row >= this->rowCount())
128  return QString();
129  auto *item = this->item(row, ColData);
130  return item->data(FilePathRole).toString();
131  }
132 
133  void removeAll()
134  {
135  this->removeRows(0, this->rowCount());
136  }
137 
138 private:
139  struct GameInfo
140  {
141  QIcon icon;
142  QString name;
143 
144  static GameInfo unknown(QString name)
145  {
146  return GameInfo {gUnknownEngineIcon(), name};
147  }
148  };
149 
151  static const int GAMES_LIMIT = 10000;
152 
153  mutable QMap<QString, GameInfo> games;
154 
155  GameInfo gameInfo(const QString &name) const
156  {
157  if (this->games.contains(name))
158  return this->games[name];
159  if (this->games.size() > GAMES_LIMIT)
160  {
161  // Something fishy is going on if we went beyond this limit.
162  // Protect against DoS.
163  return GameInfo::unknown(name);
164  }
165  GameInfo gameinfo = gameInfoFromPlugin(name);
166  this->games[name] = gameinfo;
167  return gameinfo;
168  }
169 
170  static GameInfo gameInfoFromPlugin(const QString &name)
171  {
172  EnginePlugin *plugin = gPlugins->info(name);
173  return plugin != nullptr
174  ? GameInfo {plugin->icon(), plugin->data()->name}
175  : GameInfo::unknown(name);
176  }
177 };
178 }
179 
180 DClass<DemoManagerDlg> : public Ui::DemoManagerDlg
181 {
182 public:
183  DemoStore demoStore;
184  DemoModel *demoModel;
185  QPointer<QSplitter> splitter;
186 
187  QModelIndex currentIndex() const
188  {
189  return this->demoTable->selectionModel()->currentIndex();
190  }
191 
192  QString askForExportPath(QWidget *parent, QString proposedName) const
193  {
194  QDir proposedDir = FileUtils::dirOrDir(
195  gConfig.doomseeker.previousDemoExportDir,
196  QStandardPaths::standardLocations(QStandardPaths::DocumentsLocation).first());
197 
198  QString exportPath;
199  QFileDialog saveDialog(parent);
200  saveDialog.setAcceptMode(QFileDialog::AcceptSave);
201  saveDialog.selectFile(proposedName);
202  saveDialog.setDirectory(proposedDir);
203  if (saveDialog.exec() == QDialog::Accepted)
204  {
205  exportPath = saveDialog.selectedFiles().first();
206  gConfig.doomseeker.previousDemoExportDir = QFileInfo(exportPath).path();
207  }
208  return exportPath;
209  }
210 };
211 
212 DPointered(DemoManagerDlg)
213 
215 {
216  d->setupUi(this);
217  CommonGUI::setupDialog(*this);
218  d->demoStore = DemoStore();
219 
220  d->splitter = new QSplitter(Qt::Horizontal, this);
221  d->splitter->addWidget(d->demoTable);
222  d->splitter->addWidget(d->previewArea);
223  d->splitter->setStretchFactor(0, 1);
224  d->horizontalLayout->addWidget(d->splitter);
225  d->splitter->restoreState(gConfig.doomseeker.demoManagerSplitterState);
226 
227  auto *menu = new QMenu(this);
228  menu->addAction(DemoManagerDlg::tr("Export plain demo file"),
229  this, SLOT(exportSelectedPlain()));
230  menu->addAction(DemoManagerDlg::tr("Export with Doomseeker metadata ..."),
231  this, SLOT(exportSelectedDoomseeker()));
232  d->btnExport->setMenu(menu);
233 
234  d->demoModel = new DemoModel(this);
235  d->demoTable->setModel(d->demoModel);
236  d->demoTable->setColumnWidth(DemoModel::ColGame, 24);
237 
238  auto *header = d->demoTable->horizontalHeader();
239  header->setSectionResizeMode(DemoModel::ColCreatedTime, QHeaderView::ResizeToContents);
240  header->setSectionResizeMode(DemoModel::ColGame, QHeaderView::ResizeToContents);
241 
242  adjustDemoList();
243  d->demoTable->sortByColumn(DemoModel::ColCreatedTime, Qt::DescendingOrder);
244 
245  updateUiSelectionState();
246  connect(d->demoTable->selectionModel(), &QItemSelectionModel::currentChanged,
247  this, &DemoManagerDlg::itemSelected);
248 }
249 
250 DemoManagerDlg::~DemoManagerDlg()
251 {
252  gConfig.doomseeker.demoManagerSplitterState = d->splitter->saveState();
253 }
254 
255 void DemoManagerDlg::adjustDemoList()
256 {
257  d->demoModel->removeAll();
258  QStringList demos = d->demoStore.listManagedDemos();
259  d->demoTable->setSortingEnabled(false);
260  for (const QString &demoName : demos)
261  {
262  GameDemo demo = d->demoStore.loadManagedGameDemo(demoName);
263  d->demoModel->addGameDemo(demo);
264  }
265  d->demoTable->setSortingEnabled(true);
266  d->demoTable->resizeRowsToContents();
267 }
268 
269 bool DemoManagerDlg::doRemoveDemo(const QString &file)
270 {
271  if (!d->demoStore.removeManagedDemo(file))
272  QMessageBox::critical(this, tr("Unable to remove"), tr("Could not remove the selected demo."));
273  else
274  {
275  updateUiSelectionState();
276  return true;
277  }
278  return false;
279 }
280 
281 void DemoManagerDlg::deleteSelected()
282 {
283  if (QMessageBox::question(this, tr("Remove demo?"),
284  tr("Are you sure you want to remove the selected demo?"),
285  QMessageBox::Yes | QMessageBox::Cancel) == QMessageBox::Yes)
286  {
287  QModelIndex index = d->currentIndex();
288  if (index.isValid())
289  {
290  const QString &demoName = d->demoModel->demoNameFromIndex(index);
291  if (doRemoveDemo(demoName))
292  {
293  d->demoModel->removeRow(index.row());
294  }
295  }
296  }
297 }
298 
299 void DemoManagerDlg::exportSelectedDoomseeker()
300 {
301  QString demoName = d->demoModel->demoNameFromIndex(d->demoTable->currentIndex());
302  if (demoName.isEmpty())
303  return;
304  GameDemo selectedDemo = d->demoStore.loadManagedGameDemo(demoName);
305 
306  // Omit Doomseeker's path portablization here, because the feature
307  // is explicitly called "export".
308  QString exportPath = d->askForExportPath(this, selectedDemo.exportedName());
309  if (exportPath.isEmpty())
310  return;
311 
312  QFile demoFile(selectedDemo.demopath);
313  QFile metaFile(DemoStore::metafile(selectedDemo.demopath));
314 
315  // Check if the files exist at target location and ask to overwrite.
316  QFile destDemo(exportPath);
317  QFile destMeta(DemoStore::metafile(exportPath));
318 
319  bool demoExists = destDemo.exists();
320  bool metaExists = destMeta.exists();
321  if (demoExists || metaExists)
322  {
323  QStringList existingPaths;
324  if (demoExists)
325  existingPaths << destDemo.fileName();
326  if (metaExists)
327  existingPaths << destMeta.fileName();
328  auto answer = QMessageBox::question(this, tr("Demo export"),
329  tr("The exported files already exist at the specified "
330  "directory. Overwrite?\n\n%1").arg(existingPaths.join("\n")));
331  if (answer == QMessageBox::No)
332  return;
333 
334  if (demoExists)
335  destDemo.remove();
336  if (metaExists)
337  destMeta.remove();
338  }
339 
340  // Copy the demo to the new location.
341  bool fail = false;
342  if (!demoFile.copy(destDemo.fileName()))
343  {
344  fail = true;
345  }
346 
347  // Copy the metadata file to the new location.
348  if (!fail && metaFile.exists())
349  {
350  if (!metaFile.copy(destMeta.fileName()))
351  {
352  fail = true;
353  // Delete the already copied demo file to clean-up after an unsuccessful export.
354  destDemo.remove();
355  }
356  }
357 
358  if (fail)
359  {
360  QMessageBox::critical(this, tr("Unable to save"), tr("Could not write to the specified location."));
361  }
362  return;
363 }
364 
365 void DemoManagerDlg::exportSelectedPlain()
366 {
367  QString demoName = d->demoModel->demoNameFromIndex(d->demoTable->currentIndex());
368  if (demoName.isEmpty())
369  return;
370  GameDemo selectedDemo = d->demoStore.loadManagedGameDemo(demoName);
371 
372  // Omit Doomseeker's path portablization here, because the feature
373  // is explicitly called "export".
374  QString exportPath = d->askForExportPath(this, selectedDemo.exportedName());
375  if (exportPath.isEmpty())
376  return;
377 
378  // Copy the demo to the new location.
379  if (QFile::exists(exportPath))
380  QFile::remove(exportPath);
381  if (!QFile::copy(selectedDemo.demopath, exportPath))
382  {
383  QMessageBox::critical(this, tr("Unable to save"),
384  tr("Could not write to the specified location."));
385  }
386 }
387 
388 void DemoManagerDlg::importDemo()
389 {
390  // Get valid extensions
391  QStringList demoExtensions = DemoStore::demoFileFilters();
392  demoExtensions << "*.ini";
393 
394  QDir proposedDir = FileUtils::dirOrDir(
395  gConfig.doomseeker.previousDemoExportDir,
396  QStandardPaths::standardLocations(QStandardPaths::DocumentsLocation).first());
397 
398  QString selectedFile = QFileDialog::getOpenFileName(this, tr("Import demo"),
399  proposedDir.path(),
400  tr("Demo files (%1)").arg(demoExtensions.join(" ")));
401  if (selectedFile.isEmpty())
402  return;
403 
404  gConfig.doomseeker.previousDemoExportDir = QFileInfo(selectedFile).path();
405 
406  GameDemo demo = DemoStore::loadGameDemo(selectedFile);
407  do
408  {
409  DemoMetaDataDialog importDialog(this, demo);
410  importDialog.setWindowTitle(tr("Import game demo"));
411  if (importDialog.exec() == QDialog::Accepted)
412  {
413  GameDemo importedDemo = importDialog.gameDemo();
414  importedDemo.demopath = demo.demopath;
415 
416  GameDemo existingDemo = d->demoStore.loadManagedGameDemo(importedDemo.managedName());
417  if (existingDemo.hasDemoFile())
418  {
419  QFileInfo existingDemoFile(existingDemo.demopath);
420  QFileInfo importedDemoFile(importedDemo.demopath);
421 
422  QByteArray existingMd5 = FileUtils::md5(existingDemo.demopath);
423  QByteArray importedMd5 = FileUtils::md5(importedDemo.demopath);
424 
425  QString question;
426  if (existingDemoFile.size() == importedDemoFile.size()
427  && existingMd5 == importedMd5)
428  {
429  question = tr("It looks like this demo is already imported. Overwrite anyway?");
430  }
431  else
432  {
433  question = tr("Another demo with this metadata already exists. Overwrite?");
434  }
435  QString comparison = tr(
436  "Existing: %1\n"
437  "New: %2\n")
438  .arg(labelDemoFile(existingDemoFile, existingMd5))
439  .arg(labelDemoFile(importedDemoFile, importedMd5));
440 
441  auto answer = QMessageBox::question(this, tr("Demo import"),
442  tr("%1\n\n%2").arg(question, comparison));
443  if (answer == QMessageBox::No)
444  {
445  demo = importedDemo;
446  continue;
447  }
448  }
449  if (!d->demoStore.importDemo(importedDemo))
450  {
451  QMessageBox::critical(this, tr("Demo import"),
452  tr("Could not import the selected demo."));
453  }
454  else
455 
456  {
457  adjustDemoList();
458  }
459  }
460  } while(false);
461 }
462 
463 QString DemoManagerDlg::labelDemoFile(QFileInfo &file, const QByteArray &md5)
464 {
465  return tr("%1 %L2 B (MD5: %3)")
466  .arg(file.fileName())
467  .arg(file.size())
468  .arg(QString::fromLatin1(md5.toHex().toLower()));
469 }
470 
471 void DemoManagerDlg::itemDoubleClicked(const QModelIndex &index)
472 {
473  QString demoName = d->demoModel->demoNameFromIndex(index);
474  if (!demoName.isEmpty())
475  playDemo(demoName);
476 }
477 
478 void DemoManagerDlg::playSelected()
479 {
480  QString demoName = d->demoModel->demoNameFromIndex(d->demoTable->currentIndex());
481  if (!demoName.isEmpty())
482  playDemo(demoName);
483 }
484 
485 void DemoManagerDlg::playDemo(const QString &demoName)
486 {
487  GameDemo demo = d->demoStore.loadManagedGameDemo(demoName);
488 
489  // Look for the plugin used to record.
490  EnginePlugin *plugin = gPlugins->info(demo.game);
491  if (plugin == nullptr)
492  {
493  QMessageBox::critical(this, tr("No plugin"),
494  tr("The \"%1\" plugin does not appear to be loaded.").arg(demo.game));
495  return;
496  }
497 
498  // Get executable path for pathfinder.
499  Message binMessage;
500  const QString binPath = gDoomseekerTemplatedPathResolver().resolve(
501  GameExeRetriever(*plugin->gameExe()).pathToOfflineExe(binMessage));
502 
503  // Locate all the files needed to play the demo
504  PathFinder pf;
505  pf.addPrioritySearchDir(binPath);
506  WadPathFinder wadFinder = WadPathFinder(pf);
507 
508  QStringList missingWads;
509  QList<PickedGameFile> wadPaths;
510 
511  QList<PWad> wads = demo.wads;
512  wads.prepend(PWad(demo.iwad));
513 
514  for (const PWad &wad : wads)
515  {
516  WadFindResult findResult = wadFinder.find(wad.name());
517  if (findResult.isValid())
518  wadPaths << PickedGameFile(findResult.path(), wad.isOptional());
519  else if (!wad.isOptional())
520  missingWads << wad.name();
521  }
522 
523  // Report missing files and abort.
524  //
525  // TODO with the CreateServerDialog being in use now (Doomseeker 1.5),
526  // we could allow the user to locate the missing files on their own.
527  if (!missingWads.isEmpty())
528  {
529  QMessageBox::critical(this, tr("Files not found"),
530  tr("The following files could not be located: ") + missingWads.join(", "));
531  return;
532  }
533 
534  const QString iwad = wadPaths[0].path;
535  const QList<PickedGameFile> pwads = wadPaths.mid(1);
536 
537  CreateServerDialog csd(GameCreateParams::Demo, this);
538  csd.setAttribute(Qt::WA_DeleteOnClose, false);
539  csd.makeDemoPlaybackSetupDialog(plugin, demo, iwad, pwads);
540  csd.exec();
541 }
542 
543 void DemoManagerDlg::itemSelected(const QModelIndex &index)
544 {
545  updateUiSelectionState();
546 }
547 
548 void DemoManagerDlg::updateUiSelectionState()
549 {
550  updatePreview();
551 
552  bool selected = d->demoTable->currentIndex().isValid();
553  d->btnPlay->setEnabled(selected);
554  d->btnDelete->setEnabled(selected);
555  d->btnExport->setEnabled(selected);
556 }
557 
558 void DemoManagerDlg::updatePreview()
559 {
560  QString demoName = d->demoModel->demoNameFromIndex(d->demoTable->currentIndex());
561  if (demoName.isEmpty())
562  {
563  d->preview->setText("");
564  return;
565  }
566  GameDemo selectedDemo = d->demoStore.loadManagedGameDemo(demoName);
567 
568  static const QString PAR = "<p style=\"margin: 0px 0px 0px 10px\">";
569  static const QString ENDPAR = "</p>";
570 
571  static const auto header = [](const QString &text) { return "<b>" + text + ":</b>"; };
572 
573  // Try to get the "display" name of the plugin.
574  EnginePlugin *plugin = gPlugins->info(selectedDemo.game);
575  QString gameName = plugin != nullptr ? plugin->data()->name : selectedDemo.game;
576 
577  QString text = header(tr("Game")) + PAR + gameName;
578  if (!selectedDemo.gameVersion.isEmpty())
579  text += QString(" (%1)").arg(selectedDemo.gameVersion);
580  text += ENDPAR;
581  if (!selectedDemo.author.isEmpty())
582  {
583  text += header(tr("Author")) + PAR + selectedDemo.author + ENDPAR;
584  }
585  text += header(tr("WADs / mods")) + PAR;
586  text += selectedDemo.iwad + "<br />";
587  for (const PWad &wad : selectedDemo.wads)
588  {
589  if (wad.isOptional())
590  text += "[" + wad.name() + "]";
591  else
592  text += wad.name();
593  text += "<br />";
594  }
595  text += ENDPAR;
596  d->preview->setText(text);
597 }
598 
599 #include "demomanager.moc"