diff --git a/doc/bash-completion/lmms b/doc/bash-completion/lmms index b582612bddc..210f184ee19 100644 --- a/doc/bash-completion/lmms +++ b/doc/bash-completion/lmms @@ -89,7 +89,7 @@ _lmms() pars_render=(--float --bitrate --format --interpolation) pars_render+=(--loop --mode --output --profile) pars_render+=(--samplerate --oversampling) - actions=(dump compress render rendertracks upgrade) + actions=(dump compress render rendertracks upgrade makebundle) actions_old=(-d --dump -r --render --rendertracks -u --upgrade) shortargs+=(-a -b -c -f -h -i -l -m -o -p -s -v -x) @@ -250,6 +250,17 @@ _lmms() filemode="files" filetypes="$savefiletypes" fi + elif [ "$action_found" == "makebundle" ] + then + if [ "$prev" == "makebundle" ] + then + filemode="existing_files" + filetypes="$savefiletypes" + elif [ "$prev2" == "makebundle" ] + then + filemode="files" + filetypes="$savefiletypes" + fi elif [[ "$action_found" =~ render(tracks)? ]] then if [[ "$prev" =~ render(tracks)? ]] diff --git a/include/DataFile.h b/include/DataFile.h index 8ddb814f1b6..9dfc36ebb67 100644 --- a/include/DataFile.h +++ b/include/DataFile.h @@ -27,6 +27,7 @@ #ifndef DATA_FILE_H #define DATA_FILE_H +#include #include #include "lmms_export.h" @@ -71,7 +72,9 @@ class LMMS_EXPORT DataFile : public QDomDocument QString nameWithExtension( const QString& fn ) const; void write( QTextStream& strm ); - bool writeFile( const QString& fn ); + bool writeFile(const QString& fn, bool withResources = false); + bool copyResources(const QString& resourcesDir); //!< Copies resources to the resourcesDir and changes the DataFile to use local paths to them + bool hasLocalPlugins(QDomElement parent = QDomElement(), bool firstCall = true) const; QDomElement& content() { @@ -122,6 +125,10 @@ class LMMS_EXPORT DataFile : public QDomDocument // List of ProjectVersions for the legacyFileVersion method static const std::vector UPGRADE_VERSIONS; + // Map with DOM elements that access resources (for making bundles) + typedef std::map> ResourcesMap; + static const ResourcesMap ELEMENTS_WITH_RESOURCES; + void upgrade(); void loadData( const QByteArray & _data, const QString & _sourceFile ); @@ -134,6 +141,7 @@ class LMMS_EXPORT DataFile : public QDomDocument } ; static typeDescStruct s_types[TypeCount]; + QString m_fileName; //!< The origin file name or "" if this DataFile didn't originate from a file QDomElement m_content; QDomElement m_head; Type m_type; diff --git a/include/PathUtil.h b/include/PathUtil.h index cc6b982a114..b1eec517e0b 100644 --- a/include/PathUtil.h +++ b/include/PathUtil.h @@ -8,12 +8,18 @@ namespace PathUtil { enum class Base { Absolute, ProjectDir, FactorySample, UserSample, UserVST, Preset, - UserLADSPA, DefaultLADSPA, UserSoundfont, DefaultSoundfont, UserGIG, DefaultGIG }; + UserLADSPA, DefaultLADSPA, UserSoundfont, DefaultSoundfont, UserGIG, DefaultGIG, + LocalDir }; //! Return the directory associated with a given base as a QString - QString LMMS_EXPORT baseLocation(const Base base); - //! Return the directory associated with a given base as a QDir - QDir LMMS_EXPORT baseQDir (const Base base); + //! Optionally, if a pointer to boolean is given the method will + //! use it to indicate whether the prefix could be resolved properly + //! or not. + QString LMMS_EXPORT baseLocation(const Base base, bool* error = nullptr); + //! Return the directory associated with a given base as a QDir. + //! Optional pointer to boolean to indicate if the prefix could + //! be resolved properly. + QDir LMMS_EXPORT baseQDir (const Base base, bool* error = nullptr); //! Return the prefix used to denote this base in path strings QString LMMS_EXPORT basePrefix(const Base base); //! Check the prefix of a path and return the base it corresponds to @@ -28,13 +34,15 @@ namespace PathUtil //! Upgrade prefix-less relative paths to the new format QString LMMS_EXPORT oldRelativeUpgrade(const QString & input); - //! Make this path absolute - QString LMMS_EXPORT toAbsolute(const QString & input); + //! Make this path absolute. If a pointer to boolean is given + //! it will indicate whether the path was converted successfully + QString LMMS_EXPORT toAbsolute(const QString & input, bool* error = nullptr); //! Make this path relative to a given base, return an absolute path if that fails QString LMMS_EXPORT relativeOrAbsolute(const QString & input, const Base base); //! Make this path relative to any base, choosing the shortest if there are - //! multiple options. Defaults to an absolute path if all bases fail. - QString LMMS_EXPORT toShortestRelative(const QString & input); + //! multiple options. allowLocal defines whether local paths should be considered. + //! Defaults to an absolute path if all bases fail. + QString LMMS_EXPORT toShortestRelative(const QString & input, bool allowLocal = false); } diff --git a/include/Song.h b/include/Song.h index 3e3405d22da..2454167c25f 100644 --- a/include/Song.h +++ b/include/Song.h @@ -72,9 +72,14 @@ class LMMS_EXPORT Song : public TrackContainer * Should we discard MIDI ControllerConnections from project files? */ BoolModel discardMIDIConnections{false}; + /** + * Should we save the project as a project bundle? (with resources) + */ + BoolModel saveAsProjectBundle{false}; void setDefaultOptions() { discardMIDIConnections.setValue(false); + saveAsProjectBundle.setValue(false); } }; @@ -282,8 +287,8 @@ class LMMS_EXPORT Song : public TrackContainer void createNewProjectFromTemplate( const QString & templ ); void loadProject( const QString & filename ); bool guiSaveProject(); - bool guiSaveProjectAs( const QString & filename ); - bool saveProjectFile( const QString & filename ); + bool guiSaveProjectAs(const QString & filename); + bool saveProjectFile(const QString & filename, bool withResources = false); const QString & projectFileName() const { diff --git a/include/VersionedSaveDialog.h b/include/VersionedSaveDialog.h index 2e30e9f095c..bb4894500f7 100644 --- a/include/VersionedSaveDialog.h +++ b/include/VersionedSaveDialog.h @@ -40,6 +40,7 @@ class SaveOptionsWidget : public QWidget { private: LedCheckBox *m_discardMIDIConnectionsCheckbox; + LedCheckBox *m_saveAsProjectBundleCheckbox; }; class VersionedSaveDialog : public FileDialog diff --git a/src/core/DataFile.cpp b/src/core/DataFile.cpp index 8bc48db1eb6..e77e1dbcf2d 100644 --- a/src/core/DataFile.cpp +++ b/src/core/DataFile.cpp @@ -27,10 +27,12 @@ #include "DataFile.h" #include +#include #include #include #include +#include #include #include "base64.h" @@ -43,12 +45,19 @@ #include "ProjectVersion.h" #include "SongEditor.h" #include "TextFloat.h" +#include "PathUtil.h" #include "lmmsversion.h" static void findIds(const QDomElement& elem, QList& idList); +// QMap with the DOM elements that access file resources +const DataFile::ResourcesMap DataFile::ELEMENTS_WITH_RESOURCES = { +{ "sampletco", {"src"} }, +{ "audiofileprocessor", {"src"} }, +}; + // Vector with all the upgrade methods const std::vector DataFile::UPGRADE_METHODS = { &DataFile::upgrade_0_2_1_20070501 , &DataFile::upgrade_0_2_1_20070508, @@ -91,6 +100,7 @@ DataFile::typeDescStruct DataFile::DataFile( Type type ) : QDomDocument( "lmms-project" ), + m_fileName(""), m_content(), m_head(), m_type( type ), @@ -117,6 +127,7 @@ DataFile::DataFile( Type type ) : DataFile::DataFile( const QString & _fileName ) : QDomDocument(), + m_fileName(_fileName), m_content(), m_head(), m_fileVersion( UPGRADE_METHODS.size() ) @@ -146,6 +157,7 @@ DataFile::DataFile( const QString & _fileName ) : DataFile::DataFile( const QByteArray & _data ) : QDomDocument(), + m_fileName(""), m_content(), m_head(), m_fileVersion( UPGRADE_METHODS.size() ) @@ -264,25 +276,90 @@ void DataFile::write( QTextStream & _strm ) -bool DataFile::writeFile( const QString& filename ) +bool DataFile::writeFile(const QString& filename, bool withResources) { - const QString fullName = nameWithExtension( filename ); + // Small lambda function for displaying errors + auto showError = [this](QString title, QString body){ + if (gui) + { + QMessageBox mb; + mb.setWindowTitle(title); + mb.setText(body); + mb.setIcon(QMessageBox::Warning); + mb.setStandardButtons(QMessageBox::Ok); + mb.exec(); + } + else + { + qWarning() << body; + } + }; + + // If we are saving without resources, filename is just the file we are + // saving to. If we are saving with resources (project bundle), filename + // will be used (discarding extensions) to create a folder where the + // bundle will be saved in + + QFileInfo fInfo(filename); + + const QString bundleDir = fInfo.path() + "/" + fInfo.fileName().section('.', 0, 0); + const QString resourcesDir = bundleDir + "/resources"; + const QString fullName = withResources + ? nameWithExtension(bundleDir + "/" + fInfo.fileName()) + : nameWithExtension(filename); const QString fullNameTemp = fullName + ".new"; const QString fullNameBak = fullName + ".bak"; - QFile outfile( fullNameTemp ); - - if( !outfile.open( QIODevice::WriteOnly | QIODevice::Truncate ) ) + // If we are saving with resources, setup the bundle folder first + if (withResources) { - if( gui ) + // First check if there's a bundle folder with the same name in + // the path already. If so, warns user that we can't overwrite a + // project bundle. + if (QDir(bundleDir).exists()) { - QMessageBox::critical( NULL, - SongEditor::tr( "Could not write file" ), - SongEditor::tr( "Could not open %1 for writing. You probably are not permitted to " - "write to this file. Please make sure you have write-access to " - "the file and try again." ).arg( fullName ) ); + showError(SongEditor::tr("Operation denied"), + SongEditor::tr("A bundle folder with that name already eists on the " + "selected path. Can't overwrite a project bundle. Please select a different " + "name.")); + + return false; + } + + // Create bundle folder + if (!QDir().mkdir(bundleDir)) + { + showError(SongEditor::tr("Error"), + SongEditor::tr("Couldn't create bundle folder.")); + return false; } + // Create resources folder + if (!QDir().mkdir(resourcesDir)) + { + showError(SongEditor::tr("Error"), + SongEditor::tr("Couldn't create resources folder.")); + return false; + } + + // Copy resources to folder and update paths + if (!copyResources(resourcesDir)) + { + showError(SongEditor::tr("Error"), + SongEditor::tr("Failed to copy resources.")); + return false; + } + } + + QFile outfile (fullNameTemp); + + if (!outfile.open(QIODevice::WriteOnly | QIODevice::Truncate)) + { + showError(SongEditor::tr("Could not write file"), + SongEditor::tr("Could not open %1 for writing. You probably are not permitted to" + "write to this file. Please make sure you have write-access to " + "the file and try again.").arg(fullName)); + return false; } @@ -328,6 +405,164 @@ bool DataFile::writeFile( const QString& filename ) +bool DataFile::copyResources(const QString& resourcesDir) +{ + // List of filenames used so we can append a counter to any + // repeating filenames + std::list namesList; + + ResourcesMap::const_iterator it = ELEMENTS_WITH_RESOURCES.begin(); + + // Copy resources and manipulate the DataFile to have local paths to them + while (it != ELEMENTS_WITH_RESOURCES.end()) + { + QDomNodeList list = elementsByTagName(it->first); + + // Go through all elements with the tagname from our map + for (int i = 0; !list.item(i).isNull(); ++i) + { + QDomElement el = list.item(i).toElement(); + + std::vector::const_iterator res = it->second.begin(); + + // Search for attributes that point to resources + while (res != it->second.end()) + { + // If the element has that attribute + if (el.hasAttribute(*res)) + { + // Get absolute path to resource + bool error; + QString resPath = PathUtil::toAbsolute(el.attribute(*res), &error); + // If we are running without the project loaded (from CLI), "local:" base + // prefixes aren't converted, so we need to convert it ourselves + if (error) + { + resPath = QFileInfo(m_fileName).path() + "/" + resPath.remove(0, + PathUtil::basePrefix(PathUtil::Base::LocalDir).length()); + } + + // Check if we need to add a counter to the filename + QString finalFileName = QFileInfo(resPath).fileName(); + QString extension = resPath.section('.', -1); + int repeatedNames = 0; + for (QString name : namesList) + { + if (finalFileName == name) + { + ++repeatedNames; + } + } + // Add the name to the list before modifying it + namesList.push_back(finalFileName); + if (repeatedNames) + { + // Remove the extension, add the counter and add the + // extension again to get the final file name + finalFileName.truncate(finalFileName.lastIndexOf('.')); + finalFileName = finalFileName + "-" + QString::number(repeatedNames) + "." + extension; + } + + // Final path is our resources dir + the new file name + QString finalPath = resourcesDir + "/" + finalFileName; + + // Copy resource file to the resources folder + if(!QFile::copy(resPath, finalPath)) + { + qWarning("ERROR: Failed to copy resource"); + return false; + } + + // Update attribute path to point to the bundle file + QString newAtt = PathUtil::basePrefix(PathUtil::Base::LocalDir) + "resources/" + finalFileName; + el.setAttribute(*res, newAtt); + } + ++res; + } + } + ++it; + } + + return true; +} + + + + +/** + * @brief This recursive method will go through all XML nodes of the DataFile + * and check whether any of them have local paths. If they are not on + * our list of elements that can have local paths we return true, + * indicating that we potentially have plugins with local paths that + * would be a security issue. The Song class can then abort loading + * this project. + * @param parent The parent node being iterated. When called + * without arguments, this will be an empty element that will be + * ignored (since the second parameter will be true). + * @param firstCall Defaults to true, and indicates to this recursive + * method whether this is the first call. If it is it will use the + * root element as the parent. + */ +bool DataFile::hasLocalPlugins(QDomElement parent /* = QDomElement()*/, bool firstCall /* = true*/) const +{ + // If this is the first iteration of the recursion we use the root element + if (firstCall) { parent = documentElement(); } + + auto children = parent.childNodes(); + for (int i = 0; i < children.size(); ++i) + { + QDomNode child = children.at(i); + QDomElement childElement = child.toElement(); + + bool skipNode = false; + // Skip the nodes allowed to have "local:" attributes, but + // still check its children + for + ( + ResourcesMap::const_iterator it = ELEMENTS_WITH_RESOURCES.begin(); + it != ELEMENTS_WITH_RESOURCES.end(); + ++it + ) + { + if (childElement.tagName() == it->first) + { + skipNode = true; + break; + } + } + + // Check if they have "local:" attribute (unless they are allowed to + // and skipNode is true) + if (!skipNode) + { + auto attributes = childElement.attributes(); + for (int i = 0; i < attributes.size(); ++i) + { + QDomNode attribute = attributes.item(i); + QDomAttr attr = attribute.toAttr(); + if (attr.value().startsWith(PathUtil::basePrefix(PathUtil::Base::LocalDir), + Qt::CaseInsensitive)) + { + return true; + } + } + } + + // Now we check the children of this node (recursively) + // and if any return true we return true. + if (hasLocalPlugins(childElement, false)) + { + return true; + } + } + + // If we got here none of the nodes had the "local:" path. + return false; +} + + + + DataFile::Type DataFile::type( const QString& typeName ) { for( int i = 0; i < TypeCount; ++i ) diff --git a/src/core/PathUtil.cpp b/src/core/PathUtil.cpp index 5881db9f40f..03f16bc89f4 100644 --- a/src/core/PathUtil.cpp +++ b/src/core/PathUtil.cpp @@ -5,14 +5,20 @@ #include #include "ConfigManager.h" +#include "Engine.h" +#include "Song.h" namespace PathUtil { Base relativeBases[] = { Base::ProjectDir, Base::FactorySample, Base::UserSample, Base::UserVST, Base::Preset, - Base::UserLADSPA, Base::DefaultLADSPA, Base::UserSoundfont, Base::DefaultSoundfont, Base::UserGIG, Base::DefaultGIG }; + Base::UserLADSPA, Base::DefaultLADSPA, Base::UserSoundfont, Base::DefaultSoundfont, Base::UserGIG, Base::DefaultGIG, + Base::LocalDir }; - QString baseLocation(const Base base) + QString baseLocation(const Base base, bool* error /* = nullptr*/) { + // error is false unless something goes wrong + if (error) { *error = false; } + QString loc = ""; switch (base) { @@ -31,15 +37,33 @@ namespace PathUtil case Base::DefaultSoundfont : loc = ConfigManager::inst()->userSf2Dir(); break; case Base::UserGIG : loc = ConfigManager::inst()->gigDir(); break; case Base::DefaultGIG : loc = ConfigManager::inst()->userGigDir(); break; + case Base::LocalDir: + { + const Song* s = Engine::getSong(); + QString projectPath; + if (s) + { + projectPath = s->projectFileName(); + loc = QFileInfo(projectPath).path(); + } + // We resolved it properly if we had an open Song and the project + // filename wasn't empty + if (error) { *error = (!s || projectPath.isEmpty()); } + break; + } default : return QString(""); } return QDir::cleanPath(loc) + "/"; } - QDir baseQDir (const Base base) + QDir baseQDir (const Base base, bool* error /* = nullptr*/) { - if (base == Base::Absolute) { return QDir::root(); } - return QDir(baseLocation(base)); + if (base == Base::Absolute) + { + if (error) { *error = false; } + return QDir::root(); + } + return QDir(baseLocation(base, error)); } QString basePrefix(const Base base) @@ -57,7 +81,8 @@ namespace PathUtil case Base::DefaultSoundfont : return QStringLiteral("defaultsoundfont:"); case Base::UserGIG : return QStringLiteral("usergig:"); case Base::DefaultGIG : return QStringLiteral("defaultgig:"); - default : return QStringLiteral(""); + case Base::LocalDir : return QStringLiteral("local:"); + default : return QStringLiteral(""); } } @@ -111,16 +136,20 @@ namespace PathUtil - QString toAbsolute(const QString & input) + QString toAbsolute(const QString & input, bool* error /* = nullptr*/) { //First, do no harm to absolute paths QFileInfo inputFileInfo = QFileInfo(input); - if (inputFileInfo.isAbsolute()) { return input; } + if (inputFileInfo.isAbsolute()) + { + if (error) { *error = false; } + return input; + } //Next, handle old relative paths with no prefix QString upgraded = input.contains(":") ? input : oldRelativeUpgrade(input); Base base = baseLookup(upgraded); - return baseLocation(base) + upgraded.remove(0, basePrefix(base).length()); + return baseLocation(base, error) + upgraded.remove(0, basePrefix(base).length()); } QString relativeOrAbsolute(const QString & input, const Base base) @@ -128,11 +157,16 @@ namespace PathUtil if (input.isEmpty()) { return input; } QString absolutePath = toAbsolute(input); if (base == Base::Absolute) { return absolutePath; } - QString relativePath = baseQDir(base).relativeFilePath(absolutePath); - return relativePath.startsWith("..") ? absolutePath : relativePath; + bool error; + QString relativePath = baseQDir(base, &error).relativeFilePath(absolutePath); + // Return the relative path if it didn't result in a path starting with .. + // and the baseQDir was resolved properly + return (relativePath.startsWith("..") || error) + ? absolutePath + : relativePath; } - QString toShortestRelative(const QString & input) + QString toShortestRelative(const QString & input, bool allowLocal /* = false*/) { QFileInfo inputFileInfo = QFileInfo(input); QString absolutePath = inputFileInfo.isAbsolute() ? input : toAbsolute(input); @@ -141,6 +175,10 @@ namespace PathUtil QString shortestPath = relativeOrAbsolute(absolutePath, shortestBase); for (auto base: relativeBases) { + // Skip local paths when searching for the shortest relative if those + // are not allowed for that resource + if (base == Base::LocalDir && !allowLocal) { continue; } + QString otherPath = relativeOrAbsolute(absolutePath, base); if (otherPath.length() < shortestPath.length()) { diff --git a/src/core/Song.cpp b/src/core/Song.cpp index be3d2745ae1..b93f755b498 100644 --- a/src/core/Song.cpp +++ b/src/core/Song.cpp @@ -983,9 +983,37 @@ void Song::loadProject( const QString & fileName ) setProjectFileName(fileName); DataFile dataFile( m_fileName ); + + bool cantLoadProject = false; // if file could not be opened, head-node is null and we create // new project if( dataFile.head().isNull() ) + { + cantLoadProject = true; + } + else + { + // We check if plugins contain local paths to prevent malicious code being + // added to project bundles and loaded with "local:" paths + if (dataFile.hasLocalPlugins()) + { + cantLoadProject = true; + + if (gui) + { + QMessageBox::critical(NULL, tr("Aborting project load"), + tr("Project file contains local paths to plugins, which could be used to " + "run malicious code.")); + } + else + { + QTextStream(stderr) << tr("Can't load project: " + "Project file contains local paths to plugins.") << endl; + } + } + } + + if (cantLoadProject) { if( m_loadOnLaunch ) { @@ -1152,8 +1180,8 @@ void Song::loadProject( const QString & fileName ) } -// only save current song as _filename and do nothing else -bool Song::saveProjectFile( const QString & filename ) +// only save current song as filename and do nothing else +bool Song::saveProjectFile(const QString & filename, bool withResources) { DataFile dataFile( DataFile::SongProject ); m_savingProject = true; @@ -1180,7 +1208,7 @@ bool Song::saveProjectFile( const QString & filename ) m_savingProject = false; - return dataFile.writeFile( filename ); + return dataFile.writeFile(filename, withResources); } @@ -1188,46 +1216,34 @@ bool Song::saveProjectFile( const QString & filename ) // Save the current song bool Song::guiSaveProject() { - DataFile dataFile( DataFile::SongProject ); - QString fileNameWithExtension = dataFile.nameWithExtension( m_fileName ); - setProjectFileName(fileNameWithExtension); - - bool const saveResult = saveProjectFile( m_fileName ); - - if( saveResult ) - { - setModified(false); - } - - return saveResult; + return guiSaveProjectAs(m_fileName); } // Save the current song with the given filename -bool Song::guiSaveProjectAs( const QString & _file_name ) +bool Song::guiSaveProjectAs(const QString & filename) { - QString o = m_oldFileName; - m_oldFileName = m_fileName; - setProjectFileName(_file_name); + DataFile dataFile(DataFile::SongProject); + QString fileNameWithExtension = dataFile.nameWithExtension(filename); + + bool withResources = m_saveOptions.saveAsProjectBundle.value(); - bool saveResult = guiSaveProject(); - // After saving as, restore default save options. + bool const saveResult = saveProjectFile(fileNameWithExtension, withResources); + + // After saving, restore default save options. m_saveOptions.setDefaultOptions(); - if(!saveResult) + // If we saved a bundle, we keep the project on the original + // file and still keep it as modified + if (saveResult && !withResources) { - // Saving failed. Restore old filenames. - setProjectFileName(m_oldFileName); - m_oldFileName = o; - - return false; + setModified(false); + setProjectFileName(fileNameWithExtension); } - m_oldFileName = m_fileName; - - return true; + return saveResult; } diff --git a/src/core/main.cpp b/src/core/main.cpp index e503184ab46..7b9dc547933 100644 --- a/src/core/main.cpp +++ b/src/core/main.cpp @@ -169,6 +169,9 @@ void printHelp() " upgrade [out] Upgrade file and save as \n" " Standard out is used if no output file\n" " is specified\n" + " makebundle [out] Make a project bundle from the project\n" + " file saving the resulting bundle\n" + " as \n" "\nGlobal options:\n" " --allowroot Bypass root user startup check (use with\n" " caution).\n" @@ -403,6 +406,28 @@ int main( int argc, char * * argv ) return EXIT_SUCCESS; } + else if (arg == "makebundle") + { + ++i; + + if (i == argc) + { + return noInputFileError(); + } + + DataFile dataFile(QString::fromLocal8Bit(argv[i])); + + if (argc > i+1) // Project bundle file name given + { + printf("Making bundle\n"); + dataFile.writeFile(QString::fromLocal8Bit(argv[i+1]), true); + return EXIT_SUCCESS; + } + else + { + return usageError("No project bundle name given"); + } + } else if( arg == "--allowroot" ) { // Ignore, processed earlier diff --git a/src/gui/dialogs/VersionedSaveDialog.cpp b/src/gui/dialogs/VersionedSaveDialog.cpp index d26f198915a..b9b229b1c0c 100644 --- a/src/gui/dialogs/VersionedSaveDialog.cpp +++ b/src/gui/dialogs/VersionedSaveDialog.cpp @@ -181,7 +181,13 @@ SaveOptionsWidget::SaveOptionsWidget(Song::SaveOptions &saveOptions) { m_discardMIDIConnectionsCheckbox = new LedCheckBox(nullptr); m_discardMIDIConnectionsCheckbox->setText(tr("Discard MIDI connections")); m_discardMIDIConnectionsCheckbox->setModel(&saveOptions.discardMIDIConnections); + + m_saveAsProjectBundleCheckbox = new LedCheckBox(nullptr); + m_saveAsProjectBundleCheckbox->setText(tr("Save As Project Bundle (with resources)")); + m_saveAsProjectBundleCheckbox->setModel(&saveOptions.saveAsProjectBundle); + layout->addWidget(m_discardMIDIConnectionsCheckbox); + layout->addWidget(m_saveAsProjectBundleCheckbox); setLayout(layout); }