ACloudViewer  3.9.4
A Modern Library for 3D Data Processing
PackageManager.cpp
Go to the documentation of this file.
1 // ----------------------------------------------------------------------------
2 // - CloudViewer: www.cloudViewer.org -
3 // ----------------------------------------------------------------------------
4 // Copyright (c) 2018-2024 www.cloudViewer.org
5 // SPDX-License-Identifier: MIT
6 // ----------------------------------------------------------------------------
7 
8 #include "PackageManager.h"
9 #include "PythonInterpreter.h"
10 #include "Resources.h"
11 
12 #include <QDialog>
13 #include <QDir>
14 #include <QIcon>
15 #include <QInputDialog>
16 #include <QMessageBox>
17 #include <QPlainTextEdit>
18 #include <QProcess>
19 #include <QTableWidgetItem>
20 #include <QtGlobal>
21 
22 // Qt5/Qt6 Compatibility
23 #include <QtCompat.h>
24 
25 #include <CVLog.h>
26 
27 #include <ui_InstallDialog.h>
28 
29 #include "Utilities.h"
30 
31 #if defined(Q_OS_WIN32)
32 #include <Windows.h>
33 #endif
34 
35 #if defined(Q_OS_WIN32)
36 static BOOL GetFolderRights(LPCTSTR folderName, DWORD genericAccessRights, DWORD *grantedRights)
37 {
38  DWORD length = 0;
39 
40  constexpr SECURITY_INFORMATION requestedInformation =
41  OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION;
42 
43  /* Get the needed length */
44  GetFileSecurity(folderName, requestedInformation, nullptr, NULL, &length);
45 
46  if (ERROR_INSUFFICIENT_BUFFER != GetLastError())
47  {
48  return FALSE;
49  }
50 
51  PSECURITY_DESCRIPTOR security = LocalAlloc(LPTR, length);
52  if (GetFileSecurity(folderName, requestedInformation, security, length, &length) == FALSE)
53  {
54  LocalFree(security);
55  return FALSE;
56  }
57 
58  DWORD desiredAccess = TOKEN_IMPERSONATE | TOKEN_QUERY | TOKEN_DUPLICATE | STANDARD_RIGHTS_READ;
59  HANDLE hToken = nullptr;
60  if (OpenProcessToken(GetCurrentProcess(), desiredAccess, &hToken) == FALSE)
61  {
62  LocalFree(security);
63  return FALSE;
64  }
65 
66  HANDLE hImpersonatedToken = nullptr;
67  if (DuplicateToken(hToken, SecurityImpersonation, &hImpersonatedToken) == FALSE)
68  {
69  CloseHandle(hToken);
70  LocalFree(security);
71  return FALSE;
72  }
73 
74  GENERIC_MAPPING mapping = {0xFFFFFFFF};
75  PRIVILEGE_SET privileges = {0};
76  DWORD grantedAccess = 0, privilegesLength = sizeof(privileges);
77  BOOL result = FALSE;
78 
79  mapping.GenericRead = FILE_GENERIC_READ;
80  mapping.GenericWrite = FILE_GENERIC_WRITE;
81  mapping.GenericExecute = FILE_GENERIC_EXECUTE;
82  mapping.GenericAll = FILE_ALL_ACCESS;
83 
84  MapGenericMask(&genericAccessRights, &mapping);
85  if (AccessCheck(security,
86  hImpersonatedToken,
87  genericAccessRights,
88  &mapping,
89  &privileges,
90  &privilegesLength,
91  &grantedAccess,
92  &result) == FALSE)
93  {
94  CloseHandle(hImpersonatedToken);
95  CloseHandle(hToken);
96  LocalFree(security);
97  return FALSE;
98  }
99 
100  *grantedRights = grantedAccess;
101 
102  CloseHandle(hImpersonatedToken);
103  CloseHandle(hToken);
104  LocalFree(security);
105 
106  return TRUE;
107 }
108 
109 static bool HasReadWriteAccessToFolder(const QString &folderPath)
110 {
111  constexpr DWORD access_mask = MAXIMUM_ALLOWED;
112  DWORD grant = 0;
113 #if defined(UNICODE)
114  const std::wstring str = folderPath.toStdWString();
115  BOOL ret = GetFolderRights(str.c_str(), access_mask, &grant);
116 #else
117  const std::string str = folderPath.toStdString();
118  BOOL ret = GetFolderRights(str.c_str(), access_mask, &grant);
119 #endif
120 
121  if (ret == FALSE)
122  {
123  plgWarning() << "Failed to get access rights for path '" << folderPath << '\n';
124  return false;
125  }
126 
127  bool hasRead = false;
128  if (((grant & GENERIC_READ) == GENERIC_READ) ||
129  ((grant & FILE_GENERIC_READ) == FILE_GENERIC_READ))
130  {
131  hasRead = true;
132  }
133 
134  bool hasWrite = false;
135  if (((grant & GENERIC_WRITE) == GENERIC_WRITE) ||
136  ((grant & FILE_GENERIC_WRITE) == FILE_GENERIC_WRITE))
137  {
138  hasWrite = true;
139  }
140 
141  bool hasExecute = false;
142  if (((grant & GENERIC_EXECUTE) == GENERIC_EXECUTE) ||
143  ((grant & FILE_GENERIC_EXECUTE) == FILE_GENERIC_EXECUTE))
144  {
145  hasExecute = true;
146  }
147  return hasRead && hasWrite && hasExecute;
148 }
149 #endif // defined(Q_OS_WIN32)
150 
151 class InstallDialog : public QDialog
152 {
153  Q_OBJECT
154  public:
155  explicit InstallDialog(QWidget *parent = nullptr) : QDialog(parent), m_ui(new Ui_InstallDialog)
156  {
157  m_ui->setupUi(this);
158  }
159  bool force() const
160  {
161  return m_ui->forceCheckBox;
162  }
163  bool upgrade() const
164  {
165  return m_ui->updateCheckBox;
166  }
167  QString packageName() const
168  {
169  return m_ui->packageNameEdit->text();
170  }
171 
172  private:
173  Ui_InstallDialog *m_ui;
174 };
175 
176 class CommandOutputDialog : public QDialog
177 {
178  Q_OBJECT
179 
180  public:
181  explicit CommandOutputDialog(QWidget *parent = nullptr)
182  : QDialog(parent), m_display(new QPlainTextEdit(this))
183  {
184  setWindowTitle("pip output");
185  m_display->setReadOnly(true);
186  auto *widgetLayout = new QVBoxLayout;
187  widgetLayout->addWidget(m_display);
188  setLayout(widgetLayout);
189  resize(600, 300);
190  }
191 
192  void appendPlainText(const QString &text) const
193  {
194  m_display->appendPlainText(text);
195  }
196 
197  void clear() const
198  {
199  m_display->clear();
200  }
201 
202  private:
203  QPlainTextEdit *m_display;
204 };
205 
206 PackageManager::PackageManager(const PythonConfig &config, QWidget *parent)
207  : QWidget(parent),
208  m_ui(new Ui_PackageManager),
209  m_pythonProcess(new QProcess),
210  m_outputDialog(new CommandOutputDialog(this)),
211  m_shouldUseUserOption(false)
212 {
213  m_ui->setupUi(this);
214  connect(m_pythonProcess, &QProcess::started, [this]() { setBusy(true); });
215  connect(m_pythonProcess,
216  static_cast<void (QProcess::*)(int, QProcess::ExitStatus)>(&QProcess::finished),
217  [this](int, QProcess::ExitStatus) { setBusy(false); });
218 
219  setWindowIcon(QIcon(PACKAGE_MANAGER_ICON_PATH));
220 
221  m_ui->installedPackagesView->setColumnCount(2);
222  m_ui->installedPackagesView->setEditTriggers(QAbstractItemView::NoEditTriggers);
223  m_ui->installedPackagesView->setHorizontalHeaderLabels({"Package Name", "Version"});
224  m_ui->installedPackagesView->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
225  connect(m_ui->installedPackagesView,
226  &QTableWidget::itemSelectionChanged,
227  this,
228  &PackageManager::handleSelectionChanged);
229 
230  m_ui->uninstallBtn->setEnabled(false);
231  connect(m_ui->installBtn, &QPushButton::clicked, this, &PackageManager::handleInstallPackage);
232  connect(
233  m_ui->uninstallBtn, &QPushButton::clicked, this, &PackageManager::handleUninstallPackage);
234 
235  connect(m_ui->searchBar, &QLineEdit::returnPressed, this, &PackageManager::handleSearch);
236  config.preparePythonProcess(*m_pythonProcess);
237  refreshInstalledPackagesList();
238 
239  m_ui->installBtn->setEnabled(true);
240  m_ui->messageFrame->hide();
241 
242  switch (config.type())
243  {
244  // The main intent of checking access rights of venv is for the Windows bundled
245  // env, but checking for all venv won't hurt.
246  // on Windowsn the bundled env is very likely to be installed in
247  // "C:\Programs\ACloudViewer\plugins\Python" and that requires admin rights to add/modify.
248  // It is better to notify user and prevent them from trying something that will faill.
252  {
253 #if defined Q_OS_WIN32
254  // On Windows we use a custom function because isWritable from Qt was not correct
255  // for our use case (its mentionned in their doc)
256  const bool hasEnoughRights = HasReadWriteAccessToFolder(config.pythonHome());
257 #else
258  const QFileInfo dirInfo(config.pythonHome());
259  const bool hasEnoughRights = dirInfo.isWritable();
260 #endif
261  if (!hasEnoughRights)
262  {
263  m_ui->installBtn->setEnabled(false);
264  m_ui->messageIconLabel->setPixmap(QApplication::style()
265  ->standardIcon(QStyle::SP_MessageBoxCritical)
266  .pixmap({64, 64}));
267  m_ui->messageTextLabel->setText("Admin rights are required to be able to install "
268  "packages in the current environment");
269  m_ui->messageFrame->show();
270  }
271  }
272  break;
274  m_shouldUseUserOption = true;
275  break;
276  }
277 }
278 
279 void PackageManager::refreshInstalledPackagesList()
280 {
281  m_pythonProcess->setProcessChannelMode(QProcess::SeparateChannels);
282 
283  const QStringList arguments = {"-m", "pip", "list"};
284  m_pythonProcess->setArguments(arguments);
285 
286  QEventLoop loop;
287  QObject::connect(
288  m_pythonProcess,
289  static_cast<void (QProcess::*)(int, QProcess::ExitStatus)>(&QProcess::finished),
290  &loop,
291  &QEventLoop::quit);
292  QObject::connect(m_pythonProcess, &QProcess::errorOccurred, &loop, &QEventLoop::quit);
293  m_pythonProcess->start(QIODevice::ReadOnly);
294  if (m_pythonProcess->state() != QProcess::ProcessState::Starting &&
295  m_pythonProcess->state() != QProcess::ProcessState::Running)
296  {
297  CVLog::Warning("Failed to start python process");
298  return;
299  }
300  loop.exec();
301 
302  if (m_pythonProcess->exitStatus() != QProcess::ExitStatus::NormalExit)
303  {
304  const QString errorMsg =
305  QString("Failed to list installed packages: '%1'").arg(m_pythonProcess->errorString());
306  QMessageBox::critical(this, "Package Manager Error", errorMsg);
307  return;
308  }
309 
310  const QString output =
311  qtCompatCodecForName("utf-8")->toUnicode(m_pythonProcess->readAllStandardOutput());
312 
313  const auto lines = qtCompatSplitRefChar(output, '\n');
314 
315  const QtCompatRegExp regex(R"((\S*)(?:\s*)(\S*)(?:\s*)(\S?))");
316 
317  // First line is a header, second is separator
318  // and last one seems to always be empty
319  if (lines.size() <= 3)
320  {
321  return;
322  }
323 
324  m_ui->installedPackagesView->setRowCount(lines.size() - 3);
325 
326  for (int i{2}; i < lines.size() - 1; ++i)
327  {
328  const auto &currentLine = lines[i];
329 
330  // Do it this way to avoid an extra allocation
331 #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
332  QRegularExpressionMatch match = regex.match(currentLine.toString());
333  if (match.hasMatch())
334  {
335  for (int j = 1; j < 3; ++j)
336  {
337  const QString lol = match.captured(j);
338 #else
339  int pos = regex.indexIn(currentLine.toString());
340  if (pos != -1)
341  {
342  for (int j = 1; j < 3; ++j)
343  {
344  const QString lol = regex.cap(j);
345 #endif
346  auto *thing = new QTableWidgetItem(lol);
347  if (j != 1)
348  {
349  thing->setFlags(thing->flags() & ~Qt::ItemFlag::ItemIsSelectable);
350  }
351  m_ui->installedPackagesView->setItem(i - 2, j - 1, thing);
352  }
353  }
354  }
355 
356  const QString errorOutput =
357  qtCompatCodecForName("utf-8")->toUnicode(m_pythonProcess->readAllStandardError());
358 
359  if (!errorOutput.isEmpty())
360  {
361  CVLog::Warning(errorOutput);
362  }
363 
364  m_pythonProcess->setProcessChannelMode(QProcess::MergedChannels);
365 }
366 
367 void PackageManager::handleInstallPackage()
368 {
369  InstallDialog installDialog(this);
370  if (installDialog.exec() != QDialog::Accepted)
371  {
372  return;
373  };
374 
375  const QString packageName = installDialog.packageName();
376 
377  if (packageName.isEmpty())
378  {
379  return;
380  }
381 
382  QStringList arguments = {"-m", "pip", "install", packageName};
383  if (installDialog.force())
384  {
385  arguments.push_back("--force");
386  }
387  if (installDialog.upgrade())
388  {
389  arguments.push_back("--upgrade");
390  }
391  if (m_shouldUseUserOption)
392  {
393  arguments.push_back("--user");
394  }
395  executeCommand(arguments);
396 
397  if (m_pythonProcess->exitCode() != 0)
398  {
399  CVLog::Error("Failed to run install commands", m_pythonProcess->error());
400  CVLog::Warning(m_pythonProcess->errorString());
401  }
402  refreshInstalledPackagesList();
403 }
404 
405 void PackageManager::handleUninstallPackage()
406 {
407  const QList<QTableWidgetItem *> selectedItems = m_ui->installedPackagesView->selectedItems();
408 
409  if (selectedItems.isEmpty())
410  {
411  return;
412  }
413 
414  for (const QTableWidgetItem *item : selectedItems)
415  {
416  const QString packageName = item->text();
417  QMessageBox::StandardButton choice = QMessageBox::question(
418  this, "Confirm", QString("Do you really want to uninstall: '%1' ?").arg(packageName));
419 
420  if (choice != QMessageBox::StandardButton::Yes)
421  {
422  continue;
423  }
424  const QStringList arguments = {"-m", "pip", "uninstall", "--yes", packageName};
425  executeCommand(arguments);
426 
427  if (m_pythonProcess->exitCode() != 0)
428  {
429  CVLog::Error("Failed to run uninstall commands", m_pythonProcess->error());
430  CVLog::Warning(m_pythonProcess->errorString());
431  }
432  }
433  refreshInstalledPackagesList();
434 }
435 
436 void PackageManager::handleSearch()
437 {
438  const QString searchString = m_ui->searchBar->text();
439  QTableWidget *table = m_ui->installedPackagesView;
440 
441  if (searchString.isEmpty())
442  {
443  for (int i = 0; i < table->rowCount(); ++i)
444  {
445  table->setRowHidden(i, false);
446  }
447  }
448  else
449  {
450  for (int i = 0; i < table->rowCount(); ++i)
451  {
452  bool match = false;
453  for (int j = 0; j < table->columnCount(); ++j)
454  {
455  QTableWidgetItem *item = table->item(i, j);
456  if (item->text().contains(searchString))
457  {
458  match = true;
459  break;
460  }
461  }
462  table->setRowHidden(i, !match);
463  }
464  }
465 }
466 
467 void PackageManager::executeCommand(const QStringList &arguments)
468 {
469  m_outputDialog->show();
470  m_outputDialog->clear();
471  m_pythonProcess->setArguments(arguments);
472  m_pythonProcess->start(QIODevice::ReadOnly);
473 
474  while (m_pythonProcess->state() != QProcess::ProcessState::NotRunning)
475  {
476  if (m_pythonProcess->waitForReadyRead())
477  {
478  const QString output =
479  qtCompatCodecForName("utf-8")->toUnicode(m_pythonProcess->readAll());
480  m_outputDialog->appendPlainText(output);
481  QApplication::processEvents();
482  }
483  }
484  m_outputDialog->exec();
485 }
486 
487 void PackageManager::handleSelectionChanged()
488 {
489  m_ui->uninstallBtn->setEnabled(!m_ui->installedPackagesView->selectedItems().isEmpty());
490 }
491 
492 void PackageManager::setBusy(bool isBusy)
493 {
494  m_ui->installBtn->setEnabled(!isBusy);
495  m_ui->uninstallBtn->setEnabled(!isBusy);
496 }
497 
499 {
500  delete m_ui;
501 }
502 
503 #include "PackageManager.moc"
QtCompatTextCodec * qtCompatCodecForName(const char *name)
Definition: QtCompat.h:657
QtCompatStringRefList qtCompatSplitRefChar(const QString &str, QChar sep)
Definition: QtCompat.h:430
QRegularExpression QtCompatRegExp
Definition: QtCompat.h:170
#define PACKAGE_MANAGER_ICON_PATH
Definition: Resources.h:19
#define NULL
PluginLogger< CVLog::LOG_WARNING > plgWarning
Definition: Utilities.h:102
core::Tensor result
Definition: VtkUtils.cpp:76
static bool Warning(const char *format,...)
Prints out a formatted warning message in console.
Definition: CVLog.cpp:133
static bool Error(const char *format,...)
Display an error dialog with formatted message.
Definition: CVLog.cpp:143
void appendPlainText(const QString &text) const
CommandOutputDialog(QWidget *parent=nullptr)
bool force() const
InstallDialog(QWidget *parent=nullptr)
QString packageName() const
bool upgrade() const
PackageManager(const PythonConfig &config, QWidget *parent=nullptr)
~PackageManager() noexcept override
const QString & pythonHome() const
Definition: PythonConfig.h:109
Type type() const
Definition: PythonConfig.h:84
void preparePythonProcess(QProcess &pythonProcess) const
QString toUnicode(const char *chars, int len=-1)
Definition: QtCompat.h:610
__host__ __device__ float length(float2 v)
Definition: cutil_math.h:1162
#define TRUE
Definition: lsd.c:119
#define FALSE
Definition: lsd.c:115
bool errorOccurred