/* * 86Box A hypervisor and IBM PC system emulator that specializes in * running old operating systems and software designed for IBM * PC systems and compatibles from 1981 through fairly recent * system designs based on the PCI bus. * * This file is part of the 86Box distribution. * * 86Box VM manager main module * * * * Authors: cold-brewed * * Copyright 2024 cold-brewed */ #include #include #include #include #include #include #include #include #include "qt_vmmanager_main.hpp" #include "ui_qt_vmmanager_main.h" #include "qt_vmmanager_model.hpp" #include "qt_vmmanager_addmachine.hpp" VMManagerMain::VMManagerMain(QWidget *parent) : QWidget(parent), ui(new Ui::VMManagerMain), selected_sysconfig(new VMManagerSystem) { ui->setupUi(this); this->setWindowTitle("86Box VM Manager"); // Set up the main listView ui->listView->setItemDelegate(new VMManagerListViewDelegate); vm_model = new VMManagerModel; proxy_model = new StringListProxyModel(this); proxy_model->setSourceModel(vm_model); ui->listView->setModel(proxy_model); proxy_model->setSortCaseSensitivity(Qt::CaseInsensitive); ui->listView->model()->sort(0, Qt::AscendingOrder); // Connect the model signal connect(vm_model, &VMManagerModel::systemDataChanged, this, &VMManagerMain::modelDataChange); // Set up the context menu for the list view ui->listView->setContextMenuPolicy(Qt::CustomContextMenu); connect(ui->listView, &QListView::customContextMenuRequested, [this](const QPoint &pos) { const auto indexAt = ui->listView->indexAt(pos); if (indexAt.isValid()) { QMenu contextMenu(tr("Context Menu"), ui->listView); QAction nameChangeAction(tr("Change display name")); contextMenu.addAction(&nameChangeAction); // Use a lambda to call a function so indexAt can be passed connect(&nameChangeAction, &QAction::triggered, ui->listView, [this, indexAt] { updateDisplayName(indexAt); }); QAction openSystemFolderAction(tr("Open folder")); contextMenu.addAction(&openSystemFolderAction); connect(&openSystemFolderAction, &QAction::triggered, [this, indexAt] { if (const auto configDir = indexAt.data(VMManagerModel::Roles::ConfigDir).toString(); !configDir.isEmpty()) { QDesktopServices::openUrl(QUrl(QString("file:///") + configDir)); } }); QAction convertToP3(tr("Convert system to PIII")); contextMenu.addAction(&convertToP3); convertToP3.setEnabled(false); QAction setSystemIcon(tr("Set icon")); contextMenu.addAction(&setSystemIcon); connect(&setSystemIcon, &QAction::triggered, [this, indexAt] { IconSelectionDialog dialog(":/systemicons/"); if(dialog.exec() == QDialog::Accepted) { const QString iconName = dialog.getSelectedIconName(); // A Blank iconName will cause setIcon to reset to the default selected_sysconfig->setIcon(iconName); } }); contextMenu.addSeparator(); QAction showRawConfigFile(tr("Show config file")); contextMenu.addAction(&showRawConfigFile); connect(&showRawConfigFile, &QAction::triggered, [this, indexAt] { if (const auto configFile = indexAt.data(VMManagerModel::Roles::ConfigFile).toString(); !configFile.isEmpty()) { showTextFileContents(indexAt.data(Qt::DisplayRole).toString(), configFile); } }); contextMenu.exec(ui->listView->viewport()->mapToGlobal(pos)); } }); // Initial default details view vm_details = new VMManagerDetails(); ui->detailsArea->layout()->addWidget(vm_details); const QItemSelectionModel *selection_model = ui->listView->selectionModel(); connect(selection_model, &QItemSelectionModel::currentChanged, this, &VMManagerMain::currentSelectionChanged); // If there are items in the model, make sure to select the first item by default. // When settings are loaded, the last selected item will be selected (if available) if (proxy_model->rowCount(QModelIndex()) > 0) { const QModelIndex first_index = proxy_model->index(0, 0); ui->listView->setCurrentIndex(first_index); } // Load and apply settings loadSettings(); // Set up search bar connect(ui->searchBar, &QLineEdit::textChanged, this, &VMManagerMain::searchSystems); // Create the completer auto *completer = new QCompleter(this); completer->setCaseSensitivity(Qt::CaseInsensitive); completer->setFilterMode(Qt::MatchContains); // Get the completer list const auto allStrings = getSearchCompletionList(); // Set up the completer auto *completerModel = new QStringListModel(allStrings, completer); completer->setModel(completerModel); ui->searchBar->setCompleter(completer); // Set initial status bar after the event loop starts QTimer::singleShot(0, this, [this] { emit updateStatusRight(totalCountString()); }); // Start update check after a slight delay QTimer::singleShot(1000, this, [this] { if(updateCheck) { backgroundUpdateCheckStart(); } }); } VMManagerMain::~VMManagerMain() { delete ui; delete vm_model; } void VMManagerMain::currentSelectionChanged(const QModelIndex ¤t, const QModelIndex &previous) { if(!current.isValid()) { return; } const auto mapped_index = proxy_model->mapToSource(current); selected_sysconfig = vm_model->getConfigObjectForIndex(mapped_index); vm_details->updateData(selected_sysconfig); // Emit that the selection changed, include with the process state emit selectionChanged(current, selected_sysconfig->process->state()); } void VMManagerMain::settingsButtonPressed() { if(!currentSelectionIsValid()) { return; } selected_sysconfig->launchSettings(); // If the process is already running, the system will be instructed to open its settings window. // Otherwise the process will be launched and will need to be tracked here. if (!selected_sysconfig->isProcessRunning()) { connect(selected_sysconfig->process, QOverload::of(&QProcess::finished), [=](const int exitCode, const QProcess::ExitStatus exitStatus){ if (exitCode != 0 || exitStatus != QProcess::NormalExit) { qInfo().nospace().noquote() << "Abnormal program termination while launching settings: exit code " << exitCode << ", exit status " << exitStatus; return; } selected_sysconfig->reloadConfig(); vm_details->updateData(selected_sysconfig); }); } } void VMManagerMain::startButtonPressed() const { if(!currentSelectionIsValid()) { return; } selected_sysconfig->startButtonPressed(); } void VMManagerMain::restartButtonPressed() const { if(!currentSelectionIsValid()) { return; } selected_sysconfig->restartButtonPressed(); } void VMManagerMain::pauseButtonPressed() const { if(!currentSelectionIsValid()) { return; } selected_sysconfig->pauseButtonPressed(); } void VMManagerMain::shutdownRequestButtonPressed() const { if (!currentSelectionIsValid()) { return; } selected_sysconfig->shutdownRequestButtonPressed(); } void VMManagerMain::shutdownForceButtonPressed() const { if (!currentSelectionIsValid()) { return; } selected_sysconfig->shutdownForceButtonPressed(); } // This function doesn't appear to be needed any longer void VMManagerMain::refresh() { bool running = selected_sysconfig->process->state() == QProcess::ProcessState::Running; const auto current_index = ui->listView->currentIndex(); emit selectionChanged(current_index, selected_sysconfig->process->state()); // if(!selected_sysconfig->config_file.path().isEmpty()) { if(!selected_sysconfig->isValid()) { // what was happening here? } } void VMManagerMain::updateDisplayName(const QModelIndex &index) { QDialog dialog; dialog.setMinimumWidth(400); dialog.setWindowTitle(tr("Set display name")); const auto layout = new QVBoxLayout(&dialog); const auto label = new QLabel(tr("Enter the new display name (blank to reset)")); label->setAlignment(Qt::AlignHCenter); label->setContentsMargins(QMargins(0, 0, 0, 5)); layout->addWidget(label); const auto lineEdit = new QLineEdit(index.data().toString(), &dialog); layout->addWidget(lineEdit); lineEdit->selectAll(); const auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); layout->addWidget(buttonBox); connect(buttonBox, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); connect(buttonBox, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); if (const bool accepted = dialog.exec() == QDialog::Accepted; accepted) { const auto mapped_index = proxy_model->mapToSource(index); vm_model->updateDisplayName(mapped_index, lineEdit->text()); selected_sysconfig = vm_model->getConfigObjectForIndex(mapped_index); vm_details->updateData(selected_sysconfig); ui->listView->scrollTo(ui->listView->currentIndex(), QAbstractItemView::PositionAtCenter); } } void VMManagerMain::loadSettings() { const auto config = new VMManagerConfig(VMManagerConfig::ConfigType::General); const auto lastSelection = config->getStringValue("last_selection"); updateCheck = config->getStringValue("update_check").toInt(); regexSearch = config->getStringValue("regex_search").toInt(); const auto matches = ui->listView->model()->match(vm_model->index(0, 0), VMManagerModel::Roles::ConfigName, QVariant::fromValue(lastSelection)); if (!matches.empty()) { ui->listView->setCurrentIndex(matches.first()); ui->listView->scrollTo(ui->listView->currentIndex(), QAbstractItemView::PositionAtCenter); } } bool VMManagerMain::currentSelectionIsValid() const { return ui->listView->currentIndex().isValid() && selected_sysconfig->isValid(); } // Used from MainWindow during app exit to obtain and persist the current selection QString VMManagerMain::getCurrentSelection() const { return ui->listView->currentIndex().data(VMManagerModel::Roles::ConfigName).toString(); } void VMManagerMain::searchSystems(const QString &text) const { // Escape the search text string unless regular expression searching is enabled. // When escaped, the search string functions as a plain text match. const auto searchText = regexSearch ? text : QRegularExpression::escape(text); const QRegularExpression regex(searchText, QRegularExpression::CaseInsensitiveOption); if (!regex.isValid()) { qDebug() << "Skipping, invalid regex"; return; } proxy_model->setFilterRegularExpression(regex); // Searching (filtering) can cause the list view to change. If there is still a valid selection, // make sure to scroll to it if (ui->listView->currentIndex().isValid()) { ui->listView->scrollTo(ui->listView->currentIndex(), QAbstractItemView::PositionAtCenter); } } void VMManagerMain::newMachineWizard() { const auto wizard = new VMManagerAddMachine(this); if (wizard->exec() == QDialog::Accepted) { const auto newName = wizard->field("systemName").toString(); const auto systemDir = wizard->field("systemLocation").toString(); const auto existingConfiguration = wizard->field("existingConfiguration").toString(); addNewSystem(newName, systemDir, existingConfiguration); } } void VMManagerMain::addNewSystem(const QString &name, const QString &dir, const QString &configFile) { const auto newSytemDirectory = QDir(QDir::cleanPath(dir + "/" + name)); // qt replaces `/` with native separators const auto newSystemConfigFile = QFileInfo(newSytemDirectory.path() + "/" + "86box.cfg"); if (newSystemConfigFile.exists() || newSytemDirectory.exists()) { QMessageBox::critical(this, tr("Directory in use"), tr("The selected directory is already in use. Please select a different directory.")); return; } // Create the directory const QDir qmkdir; if (const bool mkdirResult = qmkdir.mkdir(newSytemDirectory.path()); !mkdirResult) { QMessageBox::critical(this, tr("Create directory failed"), tr("Unable to create the directory for the new system")); return; } // If specified, write the contents of the configuration file before starting if (!configFile.isEmpty()) { const auto configPath = newSystemConfigFile.absoluteFilePath(); const auto file = new QFile(configPath); if (!file->open(QIODevice::WriteOnly)) { qWarning() << "Unable to open file " << configPath; QMessageBox::critical(this, tr("Configuration write failed"), tr("Unable to open the configuration file at %1 for writing").arg(configPath)); return; } file->write(configFile.toUtf8()); file->flush(); file->close(); } const auto new_system = new VMManagerSystem(newSystemConfigFile.absoluteFilePath()); new_system->launchSettings(); // Handle this in a closure so we can capture the temporary new_system object connect(new_system->process, QOverload::of(&QProcess::finished), [=](const int exitCode, const QProcess::ExitStatus exitStatus) { if (exitCode != 0 || exitStatus != QProcess::NormalExit) { qInfo().nospace().noquote() << "Abnormal program termination while creating new system: exit code " << exitCode << ", exit status " << exitStatus; qInfo() << "Not adding system due to errors"; QMessageBox::critical(this, tr("Error adding system"), tr("Abnormal program termination while creating new system: exit code %1, exit status %2.\n\nThe system will not be added.").arg(QString::number(exitCode), exitStatus)); delete new_system; return; } // Create a new QFileInfo because the info from the old one may be cached if (const auto fi = QFileInfo(new_system->config_file.absoluteFilePath()); !fi.exists()) { // No config file which means the cancel button was pressed in the settings dialog // Attempt to clean up the directory that was created const QDir qrmdir; if (const bool result = qrmdir.rmdir(newSytemDirectory.path()); !result) { qWarning() << "Error cleaning up the old directory for canceled operation. Continuing anyway."; } delete new_system; return; } const auto current_index = ui->listView->currentIndex(); vm_model->reload(); const auto created_object = vm_model->getIndexForConfigFile(new_system->config_file); if (created_object.row() < 0) { // For some reason the index of the new object couldn't be determined. Fall back to the old index. ui->listView->setCurrentIndex(current_index); delete new_system; return; } // Get the index of the newly-created system and select it const QModelIndex mapped_index = proxy_model->mapFromSource(created_object); ui->listView->setCurrentIndex(mapped_index); delete new_system; }); } QStringList VMManagerMain::getSearchCompletionList() const { QSet uniqueStrings; for (int row = 0; row < vm_model->rowCount(QModelIndex()); ++row) { QModelIndex index = vm_model->index(row, 0); auto fullList = vm_model->data(index, VMManagerModel::Roles::SearchList).toStringList(); QSet uniqueSet(fullList.begin(), fullList.end()); uniqueStrings.unite(uniqueSet); } // Convert the set back to a QStringList QStringList allStrings = uniqueStrings.values(); return allStrings; } QString VMManagerMain::totalCountString() const { const auto count = vm_model->rowCount(QModelIndex()); return QString("%1 %2").arg(QString::number(count), tr("total")); } void VMManagerMain::modelDataChange() { // Model data has changed. This includes process status. // Update the counts / totals accordingly auto modelStats = vm_model->getProcessStats(); QStringList stats; for (auto it = modelStats.constBegin(); it != modelStats.constEnd(); ++it) { const auto &key = it.key(); stats.append(QString("%1 %2").arg(QString::number(modelStats[key]), key)); } auto states = stats.join(", "); if (!modelStats.isEmpty()) { states.append(", "); } emit updateStatusRight(states + totalCountString()); } void VMManagerMain::onPreferencesUpdated() { // Only reload values that we care about const auto config = new VMManagerConfig(VMManagerConfig::ConfigType::General); const auto oldRegexSearch = regexSearch; regexSearch = config->getStringValue("regex_search").toInt(); if (oldRegexSearch != regexSearch) { ui->searchBar->clear(); } } void VMManagerMain::backgroundUpdateCheckStart() const { auto updateChannel = UpdateCheck::UpdateChannel::CI; #ifdef RELEASE_BUILD updateChannel = UpdateCheck::UpdateChannel::Stable; #endif const auto updateCheck = new UpdateCheck(updateChannel); connect(updateCheck, &UpdateCheck::updateCheckComplete, this, &VMManagerMain::backgroundUpdateCheckComplete); connect(updateCheck, &UpdateCheck::updateCheckError, this, &VMManagerMain::backgroundUpdateCheckError); updateCheck->checkForUpdates(); } void VMManagerMain::backgroundUpdateCheckComplete(const UpdateCheck::UpdateResult &result) { qDebug() << "Check complete: update available?" << result.updateAvailable; auto type = result.channel == UpdateCheck::UpdateChannel::CI ? tr("Build") : tr("Version"); const auto updateMessage = QString("%1: %2 %3").arg( tr("An update to 86Box is available"), type, result.latestVersion); emit updateStatusLeft(updateMessage); } void VMManagerMain::backgroundUpdateCheckError(const QString &errorMsg) { qDebug() << "Update check failed with the following error:" << errorMsg; // TODO: Update the status bar } void VMManagerMain::showTextFileContents(const QString &title, const QString &path) { // Make sure we can open the file const auto fi = QFileInfo(path); if(!fi.exists()) { qWarning("Requested file does not exist: %s", path.toUtf8().constData()); return; } // Read the file QFile displayFile(path); if (!displayFile.open(QIODevice::ReadOnly | QIODevice::Text)) { qWarning("Couldn't open the file: error %d", displayFile.error()); return; } const QString configFileContents = displayFile.readAll(); displayFile.close(); const auto textDisplayDialog = new QDialog(this); textDisplayDialog->setFixedSize(QSize(540, 360)); textDisplayDialog->setWindowTitle(QString("%1 - %2").arg(title, fi.fileName())); const auto textEdit = new QPlainTextEdit(); textEdit->setReadOnly(true); textEdit->setPlainText(configFileContents); const auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok); connect(buttonBox, &QDialogButtonBox::accepted, textDisplayDialog, &QDialog::accept); const auto layout = new QVBoxLayout(); textDisplayDialog->setLayout(layout); textDisplayDialog->layout()->addWidget(textEdit); textDisplayDialog->layout()->addWidget(buttonBox); textDisplayDialog->exec(); }