Merge remote-tracking branch 'upstream/develop' into updater_tests

Conflicts:
	mmc_updater/src/tests/CMakeLists.txt
This commit is contained in:
Jan Dalheimer 2013-12-16 21:17:50 +01:00
commit 47bf7fff27
31 changed files with 284 additions and 710 deletions

View File

@ -300,8 +300,6 @@ logic/net/NetJob.h
logic/net/NetJob.cpp
logic/net/HttpMetaCache.h
logic/net/HttpMetaCache.cpp
logic/net/S3ListBucket.h
logic/net/S3ListBucket.cpp
logic/net/PasteUpload.h
logic/net/PasteUpload.cpp
logic/net/URLConstants.h
@ -482,10 +480,10 @@ QT5_ADD_RESOURCES(GENERATED_QRC ${CMAKE_CURRENT_BINARY_DIR}/generated.qrc)
QT5_ADD_RESOURCES(GRAPHICS_QRC graphics.qrc)
# Add common library
ADD_LIBRARY(MultiMC_common STATIC ${MULTIMC_SOURCES} ${MULTIMC_UI} ${GENERATED_QRC} ${GRAPHICS_QRC} ${MULTIMC_RCS})
ADD_LIBRARY(MultiMC_common STATIC ${MULTIMC_SOURCES} ${MULTIMC_UI} ${GENERATED_QRC} ${GRAPHICS_QRC})
# Add executable
ADD_EXECUTABLE(MultiMC MACOSX_BUNDLE WIN32 main.cpp)
ADD_EXECUTABLE(MultiMC MACOSX_BUNDLE WIN32 main.cpp ${MULTIMC_RCS})
# Link
TARGET_LINK_LIBRARIES(MultiMC MultiMC_common)

View File

@ -58,10 +58,17 @@ ConsoleWindow::~ConsoleWindow()
void ConsoleWindow::writeColor(QString text, const char *color)
{
// append a paragraph
if (color != nullptr)
ui->text->appendHtml(QString("<font color=\"%1\">%2</font>").arg(color).arg(text));
else
ui->text->appendPlainText(text);
QString newtext;
newtext += "<span style=\"";
{
if(color)
newtext += QString("color:") + color + ";";
newtext += "font-family: monospace;";
}
newtext += "\">";
newtext += text.toHtmlEscaped();
newtext += "</span>";
ui->text->appendHtml(newtext);
}
void ConsoleWindow::write(QString data, MessageLevel::Enum mode)

View File

@ -17,11 +17,6 @@
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPlainTextEdit" name="text">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="undoRedoEnabled">
<bool>false</bool>
</property>

View File

@ -20,7 +20,6 @@
#include "MainWindow.h"
#include "ui_MainWindow.h"
#include "keyring.h"
#include <QMenu>
#include <QMessageBox>
@ -177,14 +176,15 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWi
statusBar()->addPermanentWidget(m_statusRight, 0);
// Add "manage accounts" button, right align
QWidget* spacer = new QWidget();
QWidget *spacer = new QWidget();
spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
ui->mainToolBar->addWidget(spacer);
accountMenu = new QMenu(this);
manageAccountsAction = new QAction(tr("Manage Accounts"), this);
manageAccountsAction->setCheckable(false);
connect(manageAccountsAction, SIGNAL(triggered(bool)), this, SLOT(on_actionManageAccounts_triggered()));
connect(manageAccountsAction, SIGNAL(triggered(bool)), this,
SLOT(on_actionManageAccounts_triggered()));
repopulateAccountsMenu();
@ -193,7 +193,8 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWi
accountMenuButton->setMenu(accountMenu);
accountMenuButton->setPopupMode(QToolButton::InstantPopup);
accountMenuButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
accountMenuButton->setIcon(QPixmap(":/icons/toolbar/noaccount").scaled(48, 48, Qt::KeepAspectRatio));
accountMenuButton->setIcon(
QPixmap(":/icons/toolbar/noaccount").scaled(48, 48, Qt::KeepAspectRatio));
QWidgetAction *accountMenuButtonAction = new QWidgetAction(this);
accountMenuButtonAction->setDefaultWidget(accountMenuButton);
@ -201,26 +202,28 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWi
ui->mainToolBar->addAction(accountMenuButtonAction);
// Update the menu when the active account changes.
// Shouldn't have to use lambdas here like this, but if I don't, the compiler throws a fit. Template hell sucks...
connect(MMC->accounts().get(), &MojangAccountList::activeAccountChanged, [this] { activeAccountChanged(); });
connect(MMC->accounts().get(), &MojangAccountList::listChanged, [this] { repopulateAccountsMenu(); });
// Shouldn't have to use lambdas here like this, but if I don't, the compiler throws a fit.
// Template hell sucks...
connect(MMC->accounts().get(), &MojangAccountList::activeAccountChanged, [this]
{ activeAccountChanged(); });
connect(MMC->accounts().get(), &MojangAccountList::listChanged, [this]
{ repopulateAccountsMenu(); });
std::shared_ptr<MojangAccountList> accounts = MMC->accounts();
// TODO: Nicer way to iterate?
for(int i = 0; i < accounts->count(); i++)
for (int i = 0; i < accounts->count(); i++)
{
MojangAccountPtr account = accounts->at(i);
if(account != nullptr)
if (account != nullptr)
{
auto job = new NetJob("Startup player skins: " + account->username());
for(AccountProfile profile : account->profiles())
for (AccountProfile profile : account->profiles())
{
auto meta = MMC->metacache()->resolveEntry("skins", profile.name + ".png");
auto action = CacheDownload::make(
QUrl("http://" + URLConstants::SKINS_BASE + profile.name + ".png"),
meta);
QUrl("http://" + URLConstants::SKINS_BASE + profile.name + ".png"), meta);
job->addNetAction(action);
meta->stale = true;
}
@ -245,9 +248,10 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWi
// set up the updater object.
auto updater = MMC->updateChecker();
QObject::connect(updater.get(), &UpdateChecker::updateAvailable, this, &MainWindow::updateAvailable);
QObject::connect(updater.get(), &UpdateChecker::updateAvailable, this,
&MainWindow::updateAvailable);
// if automatic update checks are allowed, start one.
if(MMC->settings()->get("AutoUpdate").toBool())
if (MMC->settings()->get("AutoUpdate").toBool())
on_actionCheckUpdate_triggered();
}
@ -321,7 +325,7 @@ void MainWindow::repopulateAccountsMenu()
QAction *action = new QAction(profile.name, this);
action->setData(account->username());
action->setCheckable(true);
if(active_username == account->username())
if (active_username == account->username())
{
action->setChecked(true);
}
@ -339,7 +343,7 @@ void MainWindow::repopulateAccountsMenu()
action->setCheckable(true);
action->setIcon(QPixmap(":/icons/toolbar/noaccount").scaled(48, 48, Qt::KeepAspectRatio));
action->setData("");
if(active_username.isEmpty())
if (active_username.isEmpty())
{
action->setChecked(true);
}
@ -356,10 +360,11 @@ void MainWindow::repopulateAccountsMenu()
*/
void MainWindow::changeActiveAccount()
{
QAction* sAction = (QAction*) sender();
QAction *sAction = (QAction *)sender();
// Profile's associated Mojang username
// Will need to change when profiles are properly implemented
if (sAction->data().type() != QVariant::Type::String) return;
if (sAction->data().type() != QVariant::Type::String)
return;
QVariant data = sAction->data();
QString id = "";
@ -390,7 +395,8 @@ void MainWindow::activeAccountChanged()
}
// Set the icon to the "no account" icon.
accountMenuButton->setIcon(QPixmap(":/icons/toolbar/noaccount").scaled(48, 48, Qt::KeepAspectRatio));
accountMenuButton->setIcon(
QPixmap(":/icons/toolbar/noaccount").scaled(48, 48, Qt::KeepAspectRatio));
}
bool MainWindow::eventFilter(QObject *obj, QEvent *ev)
@ -426,26 +432,28 @@ bool MainWindow::eventFilter(QObject *obj, QEvent *ev)
void MainWindow::updateAvailable(QString repo, QString versionName, int versionId)
{
UpdateDialog dlg;
UpdateAction action = (UpdateAction) dlg.exec();
switch(action)
UpdateAction action = (UpdateAction)dlg.exec();
switch (action)
{
case UPDATE_LATER:
QLOG_INFO() << "Update will be installed later.";
break;
case UPDATE_NOW:
downloadUpdates(repo, versionId);
break;
case UPDATE_ONEXIT:
downloadUpdates(repo, versionId, true);
break;
case UPDATE_LATER:
QLOG_INFO() << "Update will be installed later.";
break;
case UPDATE_NOW:
downloadUpdates(repo, versionId);
break;
case UPDATE_ONEXIT:
downloadUpdates(repo, versionId, true);
break;
}
}
void MainWindow::downloadUpdates(QString repo, int versionId, bool installOnExit)
{
QLOG_INFO() << "Downloading updates.";
// TODO: If the user chooses to update on exit, we should download updates in the background.
// Doing so is a bit complicated, because we'd have to make sure it finished downloading before actually exiting MultiMC.
// TODO: If the user chooses to update on exit, we should download updates in the
// background.
// Doing so is a bit complicated, because we'd have to make sure it finished downloading
// before actually exiting MultiMC.
ProgressDialog updateDlg(this);
DownloadUpdateTask updateTask(repo, versionId, &updateDlg);
// If the task succeeds, install the updates.
@ -521,36 +529,44 @@ void MainWindow::on_actionAddInstance_triggered()
{
errorMsg += "An instance with the given directory name already exists.";
CustomMessageBox::selectable(this, tr("Error"), errorMsg, QMessageBox::Warning)->show();
break;
return;
}
case InstanceFactory::CantCreateDir:
{
errorMsg += "Failed to create the instance directory.";
CustomMessageBox::selectable(this, tr("Error"), errorMsg, QMessageBox::Warning)->show();
break;
return;
}
default:
{
errorMsg += QString("Unknown instance loader error %1").arg(error);
CustomMessageBox::selectable(this, tr("Error"), errorMsg, QMessageBox::Warning)->show();
break;
return;
}
}
std::shared_ptr<MojangAccountList> accounts = MMC->accounts();
MojangAccountPtr account = accounts->activeAccount();
if(account.get() != nullptr && account->accountStatus() != NotVerified)
if (MMC->accounts()->anyAccountIsValid())
{
ProgressDialog loadDialog(this);
auto update = newInstance->doUpdate(false);
connect(update.get(), &Task::failed , [this](QString reason) {
connect(update.get(), &Task::failed, [this](QString reason)
{
QString error = QString("Instance load failed: %1").arg(reason);
CustomMessageBox::selectable(this, tr("Error"), error, QMessageBox::Warning)->show();
CustomMessageBox::selectable(this, tr("Error"), error, QMessageBox::Warning)
->show();
});
loadDialog.exec(update.get());
}
else
{
CustomMessageBox::selectable(
this, tr("Error"),
tr("MultiMC cannot download Minecraft or update instances unless you have at least "
"one account added.\nPlease add your Mojang or Minecraft account."),
QMessageBox::Warning)->show();
}
}
void MainWindow::on_actionCopyInstance_triggered()
@ -625,8 +641,14 @@ void MainWindow::on_actionChangeInstGroup_triggered()
bool ok = false;
QString name(m_selectedInstance->group());
name = QInputDialog::getText(this, tr("Group name"), tr("Enter a new group name."),
QLineEdit::Normal, name, &ok);
auto groups = MMC->instances()->getGroups();
groups.insert(0, "");
groups.sort(Qt::CaseInsensitive);
int foo = groups.indexOf(name);
name = QInputDialog::getItem(this, tr("Group name"), tr("Enter a new group name."), groups,
foo, true, &ok);
name = name.simplified();
if (ok)
m_selectedInstance->setGroupPost(name);
}
@ -810,9 +832,11 @@ void MainWindow::doLaunch()
if (accounts->count() <= 0)
{
// Tell the user they need to log in at least one account in order to play.
auto reply = CustomMessageBox::selectable(this, tr("No Accounts"),
tr("In order to play Minecraft, you must have at least one Mojang or Minecraft account logged in to MultiMC."
"Would you like to open the account manager to add an account now?"),
auto reply = CustomMessageBox::selectable(
this, tr("No Accounts"),
tr("In order to play Minecraft, you must have at least one Mojang or Minecraft "
"account logged in to MultiMC."
"Would you like to open the account manager to add an account now?"),
QMessageBox::Information, QMessageBox::Yes | QMessageBox::No)->exec();
if (reply == QMessageBox::Yes)
@ -825,7 +849,7 @@ void MainWindow::doLaunch()
{
// If no default account is set, ask the user which one to use.
AccountSelectDialog selectDialog(tr("Which account would you like to use?"),
AccountSelectDialog::GlobalDefaultCheckbox, this);
AccountSelectDialog::GlobalDefaultCheckbox, this);
selectDialog.exec();
@ -842,7 +866,7 @@ void MainWindow::doLaunch()
return;
// do the login. if the account has an access token, try to refresh it first.
if(account->accountStatus() != NotVerified)
if (account->accountStatus() != NotVerified)
{
// We'll need to validate the access token to make sure the account is still logged in.
ProgressDialog progDialog(this);
@ -851,7 +875,7 @@ void MainWindow::doLaunch()
progDialog.exec(task.get());
auto status = account->accountStatus();
if(status != NotVerified)
if (status != NotVerified)
{
updateInstance(m_selectedInstance, account);
}
@ -859,20 +883,22 @@ void MainWindow::doLaunch()
account->downgrade();
return;
}
if (loginWithPassword(account, tr("Your account is currently not logged in. Please enter your password to log in again.")))
if (loginWithPassword(account, tr("Your account is currently not logged in. Please enter "
"your password to log in again.")))
updateInstance(m_selectedInstance, account);
}
bool MainWindow::loginWithPassword(MojangAccountPtr account, const QString& errorMsg)
bool MainWindow::loginWithPassword(MojangAccountPtr account, const QString &errorMsg)
{
EditAccountDialog passDialog(errorMsg, this, EditAccountDialog::PasswordField);
if (passDialog.exec() == QDialog::Accepted)
{
// To refresh the token, we just create an authenticate task with the given account and the user's password.
// To refresh the token, we just create an authenticate task with the given account and
// the user's password.
ProgressDialog progDialog(this);
auto task = account->login(passDialog.password());
progDialog.exec(task.get());
if(task->successful())
if (task->successful())
return true;
else
{
@ -883,21 +909,20 @@ bool MainWindow::loginWithPassword(MojangAccountPtr account, const QString& erro
return false;
}
void MainWindow::updateInstance(BaseInstance* instance, MojangAccountPtr account)
void MainWindow::updateInstance(BaseInstance *instance, MojangAccountPtr account)
{
bool only_prepare = account->accountStatus() != Online;
auto updateTask = instance->doUpdate(only_prepare);
if (!updateTask)
{
launchInstance(instance, account);
return;
}
else
{
ProgressDialog tDialog(this);
connect(updateTask.get(), &Task::succeeded, [this, instance, account] { launchInstance(instance, account); });
connect(updateTask.get(), SIGNAL(failed(QString)), SLOT(onGameUpdateError(QString)));
tDialog.exec(updateTask.get());
}
ProgressDialog tDialog(this);
connect(updateTask.get(), &Task::succeeded, [this, instance, account]
{ launchInstance(instance, account); });
connect(updateTask.get(), SIGNAL(failed(QString)), SLOT(onGameUpdateError(QString)));
tDialog.exec(updateTask.get());
}
void MainWindow::launchInstance(BaseInstance *instance, MojangAccountPtr account)
@ -985,13 +1010,31 @@ void MainWindow::on_actionChangeInstMCVersion_triggered()
this, tr("Are you sure?"),
tr("This will remove any library/version customization you did previously. "
"This includes things like Forge install and similar."),
QMessageBox::Warning, QMessageBox::Ok, QMessageBox::Abort)->exec();
QMessageBox::Warning, QMessageBox::Ok | QMessageBox::Abort,
QMessageBox::Abort)->exec();
if (result != QMessageBox::Ok)
return;
}
m_selectedInstance->setIntendedVersionId(vselect.selectedVersion()->descriptor());
}
if (!MMC->accounts()->anyAccountIsValid())
{
CustomMessageBox::selectable(
this, tr("Error"),
tr("MultiMC cannot download Minecraft or update instances unless you have at least "
"one account added.\nPlease add your Mojang or Minecraft account."),
QMessageBox::Warning)->show();
return;
}
auto updateTask = m_selectedInstance->doUpdate(false /*only_prepare*/);
if (!updateTask)
{
return;
}
ProgressDialog tDialog(this);
connect(updateTask.get(), SIGNAL(failed(QString)), SLOT(onGameUpdateError(QString)));
tDialog.exec(updateTask.get());
}
void MainWindow::on_actionChangeInstLWJGLVersion_triggered()

View File

@ -36,8 +36,12 @@ AccountListDialog::AccountListDialog(QWidget *parent)
ui->setupUi(this);
m_accounts = MMC->accounts();
// TODO: Make the "Active?" column show checkboxes or radio buttons.
ui->listView->setModel(m_accounts.get());
ui->listView->header()->setSectionResizeMode(QHeaderView::ResizeToContents);
// Expand the account column
ui->listView->header()->setSectionResizeMode(1, QHeaderView::Stretch);
QItemSelectionModel* selectionModel = ui->listView->selectionModel();

View File

@ -149,7 +149,7 @@ public:
*/
virtual SettingsObject &settings() const;
/// returns a valid update task if update is needed, NULL otherwise
/// returns a valid update task
virtual std::shared_ptr<Task> doUpdate(bool only_prepare) = 0;
/// returns a valid minecraft process, ready for launch with the given account.

View File

@ -54,11 +54,9 @@ void OneSixUpdate::executeTask()
if (m_only_prepare)
{
if (m_inst->shouldUpdate())
{
emitFailed("Unable to update instance in offline mode.");
return;
}
/*
* FIXME: in offline mode, do not proceed!
*/
setStatus("Testing the Java installation.");
QString java_path = m_inst->settings().get("JavaPath").toString();
@ -243,6 +241,7 @@ void OneSixUpdate::assetIndexFinished()
auto objectDL = MD5EtagDownload::make(
QUrl("http://" + URLConstants::RESOURCE_BASE + objectName),
objectFile.filePath());
objectDL->m_total_progress = object.size;
dls.append(objectDL);
}
}

View File

@ -27,6 +27,7 @@
#include "logger/QsLog.h"
#include "logic/auth/MojangAccount.h"
#include <pathutils.h>
#define ACCOUNT_LIST_FORMAT_VERSION 2
@ -265,11 +266,6 @@ bool MojangAccountList::loadList(const QString &filePath)
return false;
}
if (!QDir::current().exists(path))
{
QDir::current().mkpath(path);
}
QFile file(path);
// Try to open the file and fail if we can't.
@ -351,9 +347,16 @@ bool MojangAccountList::saveList(const QString &filePath)
return false;
}
if (!QDir::current().exists(path))
// make sure the parent folder exists
if(!ensureFilePathExists(path))
return false;
// make sure the file wasn't overwritten with a folder before (fixes a bug)
QFileInfo finfo(path);
if(finfo.isDir())
{
QDir::current().mkpath(path);
QDir badDir(path);
badDir.removeRecursively();
}
QLOG_INFO() << "Writing account list to" << path;
@ -411,3 +414,13 @@ void MojangAccountList::setListFilePath(QString path, bool autosave)
m_listFilePath = path;
m_autosave = autosave;
}
bool MojangAccountList::anyAccountIsValid()
{
for(auto account:m_accounts)
{
if(account->accountStatus() != NotVerified)
return true;
}
return false;
}

View File

@ -126,6 +126,11 @@ public:
* If the username given is an empty string, sets the active account to nothing.
*/
virtual void setActiveAccount(const QString &username);
/*!
* Returns true if any of the account is at least Validated
*/
bool anyAccountIsValid();
signals:
/*!

View File

@ -117,6 +117,11 @@ void InstanceList::groupChanged()
saveGroupList();
}
QStringList InstanceList::getGroups()
{
return m_groups.toList();
}
void InstanceList::saveGroupList()
{
QString groupFileName = m_instDir + "/instgroups.json";
@ -126,7 +131,7 @@ void InstanceList::saveGroupList()
if (!groupFile.open(QIODevice::WriteOnly | QIODevice::Truncate))
{
// An error occurred. Ignore it.
QLOG_ERROR() << "Failed to read instance group file.";
QLOG_ERROR() << "Failed to save instance group file.";
return;
}
QTextStream out(&groupFile);
@ -137,6 +142,10 @@ void InstanceList::saveGroupList()
QString group = instance->group();
if (group.isEmpty())
continue;
// keep a list/set of groups for choosing
m_groups.insert(group);
if (!groupMap.count(group))
{
QSet<QString> set;
@ -253,6 +262,9 @@ void InstanceList::loadGroupList(QMap<QString, QString> &groupMap)
continue;
}
// keep a list/set of groups for choosing
m_groups.insert(groupName);
// Iterate through the list of instances in the group.
QJsonArray instancesArray = groupObj.value("instances").toArray();

View File

@ -17,6 +17,7 @@
#include <QObject>
#include <QAbstractListModel>
#include <QSet>
#include "categorizedsortfilterproxymodel.h"
#include <QIcon>
@ -97,6 +98,9 @@ public:
InstancePtr getInstanceById(QString id) const;
QModelIndex getInstanceIndexById(const QString &id) const;
// FIXME: instead of iterating through all instances and forming a set, keep the set around
QStringList getGroups();
signals:
void dataIsInvalid();
@ -116,6 +120,7 @@ private:
protected:
QString m_instDir;
QList<InstancePtr> m_instances;
QSet<QString> m_groups;
};
class InstanceProxyModel : public KCategorizedSortFilterProxyModel

View File

@ -42,7 +42,9 @@ void ByteArrayDownload::start()
void ByteArrayDownload::downloadProgress(qint64 bytesReceived, qint64 bytesTotal)
{
emit progress(index_within_job, bytesReceived, bytesTotal);
m_total_progress = bytesTotal;
m_progress = bytesReceived;
emit progress(m_index_within_job, bytesReceived, bytesTotal);
}
void ByteArrayDownload::downloadError(QNetworkReply::NetworkError error)
@ -62,14 +64,14 @@ void ByteArrayDownload::downloadFinished()
m_status = Job_Finished;
m_data = m_reply->readAll();
m_reply.reset();
emit succeeded(index_within_job);
emit succeeded(m_index_within_job);
return;
}
// else the download failed
else
{
m_reply.reset();
emit failed(index_within_job);
emit failed(m_index_within_job);
return;
}
}

View File

@ -35,14 +35,14 @@ void CacheDownload::start()
{
if (!m_entry->stale)
{
emit succeeded(index_within_job);
emit succeeded(m_index_within_job);
return;
}
m_output_file.setFileName(m_target_path);
// if there already is a file and md5 checking is in effect and it can be opened
if (!ensureFilePathExists(m_target_path))
{
emit failed(index_within_job);
emit failed(m_index_within_job);
return;
}
QLOG_INFO() << "Downloading " << m_url.toString();
@ -69,7 +69,9 @@ void CacheDownload::start()
void CacheDownload::downloadProgress(qint64 bytesReceived, qint64 bytesTotal)
{
emit progress(index_within_job, bytesReceived, bytesTotal);
m_total_progress = bytesTotal;
m_progress = bytesReceived;
emit progress(m_index_within_job, bytesReceived, bytesTotal);
}
void CacheDownload::downloadError(QNetworkReply::NetworkError error)
@ -116,7 +118,7 @@ void CacheDownload::downloadFinished()
MMC->metacache()->updateEntry(m_entry);
m_reply.reset();
emit succeeded(index_within_job);
emit succeeded(m_index_within_job);
return;
}
// else the download failed
@ -125,7 +127,7 @@ void CacheDownload::downloadFinished()
m_output_file.close();
m_output_file.remove();
m_reply.reset();
emit failed(index_within_job);
emit failed(m_index_within_job);
return;
}
}
@ -140,7 +142,7 @@ void CacheDownload::downloadReadyRead()
* Can't open the file... the job failed
*/
m_reply->abort();
emit failed(index_within_job);
emit failed(m_index_within_job);
return;
}
}

View File

@ -68,7 +68,7 @@ void ForgeMirrors::deferToFixedList()
"https://www.creeperhost.net/link.php?id=1",
"http://new.creeperrepo.net/forge/maven/"});
injectDownloads();
emit succeeded(index_within_job);
emit succeeded(m_index_within_job);
}
void ForgeMirrors::parseMirrorList()
@ -88,7 +88,7 @@ void ForgeMirrors::parseMirrorList()
if(!m_mirrors.size())
deferToFixedList();
injectDownloads();
emit succeeded(index_within_job);
emit succeeded(m_index_within_job);
}
void ForgeMirrors::injectDownloads()
@ -108,7 +108,9 @@ void ForgeMirrors::injectDownloads()
void ForgeMirrors::downloadProgress(qint64 bytesReceived, qint64 bytesTotal)
{
emit progress(index_within_job, bytesReceived, bytesTotal);
m_total_progress = bytesTotal;
m_progress = bytesReceived;
emit progress(m_index_within_job, bytesReceived, bytesTotal);
}
void ForgeMirrors::downloadReadyRead()

View File

@ -44,20 +44,20 @@ void ForgeXzDownload::start()
if (!m_entry->stale)
{
m_status = Job_Finished;
emit succeeded(index_within_job);
emit succeeded(m_index_within_job);
return;
}
// can we actually create the real, final file?
if (!ensureFilePathExists(m_target_path))
{
m_status = Job_Failed;
emit failed(index_within_job);
emit failed(m_index_within_job);
return;
}
if (m_mirrors.empty())
{
m_status = Job_Failed;
emit failed(index_within_job);
emit failed(m_index_within_job);
return;
}
@ -80,7 +80,9 @@ void ForgeXzDownload::start()
void ForgeXzDownload::downloadProgress(qint64 bytesReceived, qint64 bytesTotal)
{
emit progress(index_within_job, bytesReceived, bytesTotal);
m_total_progress = bytesTotal;
m_progress = bytesReceived;
emit progress(m_index_within_job, bytesReceived, bytesTotal);
}
void ForgeXzDownload::downloadError(QNetworkReply::NetworkError error)
@ -100,7 +102,7 @@ void ForgeXzDownload::failAndTryNextMirror()
m_mirror_index = next;
updateUrl();
emit failed(index_within_job);
emit failed(m_index_within_job);
}
void ForgeXzDownload::updateUrl()
@ -148,7 +150,7 @@ void ForgeXzDownload::downloadFinished()
m_status = Job_Failed;
m_pack200_xz_file.remove();
m_reply.reset();
emit failed(index_within_job);
emit failed(m_index_within_job);
return;
}
}
@ -175,7 +177,7 @@ void ForgeXzDownload::downloadReadyRead()
* Can't open the file... the job failed
*/
m_reply->abort();
emit failed(index_within_job);
emit failed(m_index_within_job);
return;
}
}
@ -347,5 +349,5 @@ void ForgeXzDownload::decompressAndInstall()
MMC->metacache()->updateEntry(m_entry);
m_reply.reset();
emit succeeded(index_within_job);
emit succeeded(m_index_within_job);
}

View File

@ -44,7 +44,7 @@ void MD5EtagDownload::start()
if (m_check_md5 && hash == m_expected_md5)
{
QLOG_INFO() << "Skipping " << m_url.toString() << ": md5 match.";
emit succeeded(index_within_job);
emit succeeded(m_index_within_job);
return;
}
else
@ -54,7 +54,7 @@ void MD5EtagDownload::start()
}
if (!ensureFilePathExists(filename))
{
emit failed(index_within_job);
emit failed(m_index_within_job);
return;
}
@ -68,7 +68,7 @@ void MD5EtagDownload::start()
// Plus, this way, we don't end up starting a download for a file we can't open.
if (!m_output_file.open(QIODevice::WriteOnly))
{
emit failed(index_within_job);
emit failed(m_index_within_job);
return;
}
@ -86,7 +86,9 @@ void MD5EtagDownload::start()
void MD5EtagDownload::downloadProgress(qint64 bytesReceived, qint64 bytesTotal)
{
emit progress(index_within_job, bytesReceived, bytesTotal);
m_total_progress = bytesTotal;
m_progress = bytesReceived;
emit progress(m_index_within_job, bytesReceived, bytesTotal);
}
void MD5EtagDownload::downloadError(QNetworkReply::NetworkError error)
@ -107,7 +109,7 @@ void MD5EtagDownload::downloadFinished()
QLOG_INFO() << "Finished " << m_url.toString() << " got " << m_reply->rawHeader("ETag").constData();
m_reply.reset();
emit succeeded(index_within_job);
emit succeeded(m_index_within_job);
return;
}
// else the download failed
@ -115,7 +117,7 @@ void MD5EtagDownload::downloadFinished()
{
m_output_file.close();
m_reply.reset();
emit failed(index_within_job);
emit failed(m_index_within_job);
return;
}
}
@ -130,7 +132,7 @@ void MD5EtagDownload::downloadReadyRead()
* Can't open the file... the job failed
*/
m_reply->abort();
emit failed(index_within_job);
emit failed(m_index_within_job);
return;
}
}

View File

@ -38,6 +38,19 @@ protected:
public:
virtual ~NetAction() {};
public:
virtual qint64 totalProgress() const
{
return m_total_progress;
}
virtual qint64 currentProgress() const
{
return m_progress;
}
virtual qint64 numberOfFailures() const
{
return m_failures;
}
public:
/// the network reply
std::shared_ptr<QNetworkReply> m_reply;
@ -46,10 +59,16 @@ public:
QUrl m_url;
/// The file's status
JobStatus m_status;
JobStatus m_status = Job_NotStarted;
/// index within the parent job
int index_within_job = 0;
int m_index_within_job = 0;
qint64 m_progress = 0;
qint64 m_total_progress = 1;
/// number of failures up to this point
int m_failures = 0;
signals:
void started(int index);

View File

@ -36,10 +36,16 @@ public:
template <typename T> bool addNetAction(T action)
{
NetActionPtr base = std::static_pointer_cast<NetAction>(action);
base->index_within_job = downloads.size();
base->m_index_within_job = downloads.size();
downloads.append(action);
parts_progress.append(part_info());
total_progress++;
part_info pi;
{
pi.current_progress = base->currentProgress();
pi.total_progress = base->totalProgress();
pi.failures = base->numberOfFailures();
}
parts_progress.append(pi);
total_progress += pi.total_progress;
// if this is already running, the action needs to be started right away!
if (isRunning())
{

View File

@ -78,7 +78,9 @@ bool PasteUpload::parseResult(QJsonDocument doc, QString *parseError)
parseError = new QString(object.value("error").toString());
return false;
}
// FIXME: not the place for GUI things.
QString pasteUrl = object.value("paste").toObject().value("link").toString();
QDesktopServices::openUrl(pasteUrl);
return true;
}

View File

@ -1,175 +0,0 @@
/* Copyright 2013 MultiMC Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "S3ListBucket.h"
#include "MultiMC.h"
#include "logger/QsLog.h"
#include <QUrlQuery>
#include <qxmlstream.h>
#include <QDomDocument>
inline QDomElement getDomElementByTagName(QDomElement parent, QString tagname)
{
QDomNodeList elementList = parent.elementsByTagName(tagname);
if (elementList.count())
return elementList.at(0).toElement();
else
return QDomElement();
}
S3ListBucket::S3ListBucket(QUrl url) : NetAction()
{
m_url = url;
m_status = Job_NotStarted;
}
void S3ListBucket::start()
{
QUrl finalUrl = m_url;
if (current_marker.size())
{
QUrlQuery query;
query.addQueryItem("marker", current_marker);
finalUrl.setQuery(query);
}
QNetworkRequest request(finalUrl);
request.setHeader(QNetworkRequest::UserAgentHeader, "MultiMC/5.0 (Uncached)");
auto worker = MMC->qnam();
QNetworkReply *rep = worker->get(request);
m_reply = std::shared_ptr<QNetworkReply>(rep);
connect(rep, SIGNAL(downloadProgress(qint64, qint64)),
SLOT(downloadProgress(qint64, qint64)));
connect(rep, SIGNAL(finished()), SLOT(downloadFinished()));
connect(rep, SIGNAL(error(QNetworkReply::NetworkError)),
SLOT(downloadError(QNetworkReply::NetworkError)));
connect(rep, SIGNAL(readyRead()), SLOT(downloadReadyRead()));
}
void S3ListBucket::downloadProgress(qint64 bytesReceived, qint64 bytesTotal)
{
emit progress(index_within_job, bytesSoFar + bytesReceived, bytesSoFar + bytesTotal);
}
void S3ListBucket::downloadError(QNetworkReply::NetworkError error)
{
// error happened during download.
QLOG_ERROR() << "Error getting URL:" << m_url.toString().toLocal8Bit()
<< "Network error: " << error;
m_status = Job_Failed;
}
void S3ListBucket::processValidReply()
{
QLOG_TRACE() << "GOT: " << m_url.toString() << " marker:" << current_marker;
auto readContents = [&](QXmlStreamReader & xml)
{
QString Key, ETag, Size;
while (!(xml.tokenType() == QXmlStreamReader::EndElement && xml.name() == "Contents"))
{
if (xml.tokenType() == QXmlStreamReader::StartElement)
{
if (xml.name() == "Key")
{
Key = xml.readElementText();
}
if (xml.name() == "ETag")
{
ETag = xml.readElementText();
}
if (xml.name() == "Size")
{
Size = xml.readElementText();
}
}
xml.readNext();
}
if (xml.error() != QXmlStreamReader::NoError)
return;
objects.append({Key, ETag, Size.toLongLong()});
};
// nothing went wrong...
QByteArray ba = m_reply->readAll();
QString xmlErrorMsg;
bool is_truncated = false;
QXmlStreamReader xml(ba);
while (!xml.atEnd() && !xml.hasError())
{
/* Read next element.*/
QXmlStreamReader::TokenType token = xml.readNext();
/* If token is just StartDocument, we'll go to next.*/
if (token == QXmlStreamReader::StartDocument)
{
continue;
}
if (token == QXmlStreamReader::StartElement)
{
/* If it's named person, we'll dig the information from there.*/
if (xml.name() == "Contents")
{
readContents(xml);
}
else if (xml.name() == "IsTruncated")
{
is_truncated = (xml.readElementText() == "true");
}
}
}
if (xml.hasError())
{
QLOG_ERROR() << "Failed to process s3.amazonaws.com/Minecraft.Resources. XML error:"
<< xml.errorString() << ba;
emit failed(index_within_job);
return;
}
if (is_truncated)
{
current_marker = objects.last().Key;
bytesSoFar += m_reply->size();
m_reply.reset();
start();
}
else
{
m_status = Job_Finished;
m_reply.reset();
emit succeeded(index_within_job);
}
return;
}
void S3ListBucket::downloadFinished()
{
// if the download succeeded
if (m_status != Job_Failed)
{
processValidReply();
}
// else the download failed
else
{
m_reply.reset();
emit failed(index_within_job);
return;
}
}
void S3ListBucket::downloadReadyRead()
{
// ~_~
}

View File

@ -1,57 +0,0 @@
/* Copyright 2013 MultiMC Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "NetAction.h"
struct S3Object
{
QString Key;
QString ETag;
qlonglong size;
};
typedef std::shared_ptr<class S3ListBucket> S3ListBucketPtr;
class S3ListBucket : public NetAction
{
Q_OBJECT
public:
S3ListBucket(QUrl url);
static S3ListBucketPtr make(QUrl url)
{
return S3ListBucketPtr(new S3ListBucket(url));
}
public:
QList<S3Object> objects;
public
slots:
virtual void start() override;
protected
slots:
virtual void downloadProgress(qint64 bytesReceived, qint64 bytesTotal) override;
virtual void downloadError(QNetworkReply::NetworkError error) override;
virtual void downloadFinished() override;
virtual void downloadReadyRead() override;
private:
void processValidReply();
private:
qint64 bytesSoFar = 0;
QString current_marker;
};

View File

@ -56,9 +56,7 @@ class UpdateScriptFile
}
};
/** Stores information about the packages and files included
* in an update, parsed from an XML file.
*/
/** Stores information about the files included in an update, parsed from an XML file. */
class UpdateScript
{
public:

View File

@ -142,7 +142,7 @@ void UpdaterOptions::parse(int argc, char** argv)
showVersion = parser.getFlag("version");
forceElevated = parser.getFlag("force-elevated");
autoClose = parser.getFlag("auto-close");
if (installDir.empty())
{
// if no --install-dir argument is present, try parsing
@ -152,3 +152,4 @@ void UpdaterOptions::parse(int argc, char** argv)
}
}

View File

@ -5,21 +5,19 @@ if (APPLE)
set(HELPER_SHARED_SOURCES ../StlSymbolsLeopard.cpp)
endif()
# Create helper binaries for unit tests
add_executable(oldapp
old_app.cpp
${HELPER_SHARED_SOURCES}
)
add_executable(newapp
new_app.cpp
${HELPER_SHARED_SOURCES}
)
# # Create helper binaries for unit tests
# add_executable(oldapp
# old_app.cpp
# ${HELPER_SHARED_SOURCES}
# )
# add_executable(newapp
# new_app.cpp
# ${HELPER_SHARED_SOURCES}
# )
# Install data files required by unit tests
set(TEST_FILES
file_list.xml
v2_file_list.xml
test-update.rb
)
foreach(TEST_FILE ${TEST_FILES})
@ -40,12 +38,6 @@ macro(ADD_UPDATER_TEST CLASS)
endif()
endmacro()
#add_updater_test(TestUpdateScript)
add_updater_test(TestParseScript)
add_updater_test(TestUpdaterOptions)
add_updater_test(TestFileUtils)
# Add updater that that performs a complete update install
# and checks the result
#find_program(RUBY_BIN ruby)
#add_test(updater_TestUpdateInstall ${RUBY_BIN} test-update.rb)

View File

@ -0,0 +1,24 @@
#include "TestParseScript.h"
#include "TestUtils.h"
#include "UpdateScript.h"
#include <iostream>
#include <algorithm>
void TestParseScript::testParse()
{
UpdateScript script;
script.parse("file_list.xml");
TEST_COMPARE(script.isValid(),true);
}
int main(int,char**)
{
TestList<TestParseScript> tests;
tests.addTest(&TestParseScript::testParse);
return TestUtils::runTest(tests);
}

View File

@ -0,0 +1,8 @@
#pragma once
class TestParseScript
{
public:
void testParse();
};

View File

@ -1,27 +0,0 @@
#include "TestUpdateScript.h"
#include "TestUtils.h"
#include "UpdateScript.h"
#include <iostream>
#include <algorithm>
void TestUpdateScript::testV2Script()
{
UpdateScript newFormat;
UpdateScript oldFormat;
newFormat.parse("file_list.xml");
oldFormat.parse("v2_file_list.xml");
TEST_COMPARE(newFormat.filesToInstall(),oldFormat.filesToInstall());
TEST_COMPARE(newFormat.filesToUninstall(),oldFormat.filesToUninstall());
}
int main(int,char**)
{
TestList<TestUpdateScript> tests;
tests.addTest(&TestUpdateScript::testV2Script);
return TestUtils::runTest(tests);
}

View File

@ -1,8 +0,0 @@
#pragma once
class TestUpdateScript
{
public:
void testV2Script();
};

View File

@ -1,20 +1,5 @@
<?xml version="1.0"?>
<update version="3">
<targetVersion>2.0</targetVersion>
<platform>Test</platform>
<dependencies>
<!-- The new updater is standalone and has no dependencies,
except for standard system libraries.
!-->
</dependencies>
<packages>
<package>
<name>app-pkg</name>
<hash>$APP_PACKAGE_HASH</hash>
<size>$APP_PACKAGE_SIZE</size>
<source>http://some/dummy/URL</source>
</package>
</packages>
<install>
<file>
<name>$APP_FILENAME</name>

View File

@ -1,218 +0,0 @@
#!/usr/bin/ruby
require 'fileutils.rb'
require 'find'
require 'rbconfig'
require 'optparse'
# Install directory - this contains a space to check
# for correct escaping of paths when passing comamnd
# line arguments under Windows
INSTALL_DIR = File.expand_path("install dir/")
PACKAGE_DIR = File.expand_path("package-dir/")
PACKAGE_SRC_DIR = File.expand_path("package-src-dir/")
IS_WINDOWS = RbConfig::CONFIG['host_os'] =~ /mswin|mingw/
if IS_WINDOWS
OLDAPP_NAME = "oldapp.exe"
NEWAPP_NAME = "newapp.exe"
APP_NAME = "app.exe"
UPDATER_NAME = "updater.exe"
ZIP_TOOL = File.expand_path("../zip-tool.exe")
else
OLDAPP_NAME = "oldapp"
NEWAPP_NAME = "newapp"
APP_NAME = "app"
UPDATER_NAME = "updater"
ZIP_TOOL = File.expand_path("../zip-tool")
end
file_list_vars = {
"APP_FILENAME" => APP_NAME,
"UPDATER_FILENAME" => UPDATER_NAME
}
def replace_vars(src_file,dest_file,vars)
content = File.read(src_file)
vars.each do |key,value|
content.gsub! "$#{key}",value
end
File.open(dest_file,'w') do |file|
file.print content
end
end
# Returns true if |src_file| and |dest_file| have the same contents, type
# and permissions or false otherwise
def compare_files(src_file, dest_file)
if File.ftype(src_file) != File.ftype(dest_file)
$stderr.puts "Type of file #{src_file} and #{dest_file} differ"
return false
end
if File.file?(src_file) && !FileUtils.identical?(src_file, dest_file)
$stderr.puts "Contents of file #{src_file} and #{dest_file} differ"
return false
end
src_stat = File.stat(src_file)
dest_stat = File.stat(dest_file)
if src_stat.mode != dest_stat.mode
$stderr.puts "Permissions of #{src_file} and #{dest_file} differ"
return false
end
return true
end
# Compares the contents of two directories and returns a map of (file path => change type)
# for files and directories which differ between the two
def compare_dirs(src_dir, dest_dir)
src_dir += '/' if !src_dir.end_with?('/')
dest_dir += '/' if !dest_dir.end_with?('/')
src_file_map = {}
Find.find(src_dir) do |src_file|
src_file = src_file[src_dir.length..-1]
src_file_map[src_file] = nil
end
change_map = {}
Find.find(dest_dir) do |dest_file|
dest_file = dest_file[dest_dir.length..-1]
if !src_file_map.include?(dest_file)
change_map[dest_file] = :deleted
elsif !compare_files("#{src_dir}/#{dest_file}", "#{dest_dir}/#{dest_file}")
change_map[dest_file] = :updated
end
src_file_map.delete(dest_file)
end
src_file_map.each do |file|
change_map[file] = :added
end
return change_map
end
def create_test_file(name, content)
File.open(name, 'w') do |file|
file.puts content
end
return name
end
force_elevation = false
run_in_debugger = false
OptionParser.new do |parser|
parser.on("-f","--force-elevated","Force the updater to elevate itself") do
force_elevation = true
end
parser.on("-d","--debug","Run the updater under GDB") do
run_in_debugger = true
end
end.parse!
# copy 'src' to 'dest', preserving the attributes
# of 'src'
def copy_file(src, dest)
FileUtils.cp src, dest, :preserve => true
end
# Remove the install and package dirs if they
# already exist
FileUtils.rm_rf(INSTALL_DIR)
FileUtils.rm_rf(PACKAGE_DIR)
FileUtils.rm_rf(PACKAGE_SRC_DIR)
# Create the install directory with the old app
Dir.mkdir(INSTALL_DIR)
copy_file OLDAPP_NAME, "#{INSTALL_DIR}/#{APP_NAME}"
# Create a dummy file to uninstall
uninstall_test_file = create_test_file("#{INSTALL_DIR}/file-to-uninstall.txt", "this file should be removed after the update")
uninstall_test_symlink = if not IS_WINDOWS
FileUtils.ln_s("#{INSTALL_DIR}/file-to-uninstall.txt", "#{INSTALL_DIR}/symlink-to-file-to-uninstall.txt")
else
create_test_file("#{INSTALL_DIR}/symlink-to-file-to-uninstall.txt", "dummy file. this is a symlink on Unix")
end
# Populate package source dir with files to install
Dir.mkdir(PACKAGE_SRC_DIR)
nested_dir_path = "#{PACKAGE_SRC_DIR}/new-dir/new-dir2"
FileUtils.mkdir_p(nested_dir_path)
FileUtils::chmod 0755, "#{PACKAGE_SRC_DIR}/new-dir"
FileUtils::chmod 0755, "#{PACKAGE_SRC_DIR}/new-dir/new-dir2"
nested_dir_test_file = "#{nested_dir_path}/new-file.txt"
File.open(nested_dir_test_file,'w') do |file|
file.puts "this is a new file in a new nested dir"
end
FileUtils::chmod 0644, nested_dir_test_file
copy_file NEWAPP_NAME, "#{PACKAGE_SRC_DIR}/#{APP_NAME}"
FileUtils::chmod 0755, "#{PACKAGE_SRC_DIR}/#{APP_NAME}"
# Create .zip packages from source files
Dir.mkdir(PACKAGE_DIR)
Dir.chdir(PACKAGE_SRC_DIR) do
if !system("#{ZIP_TOOL} #{PACKAGE_DIR}/app-pkg.zip .")
raise "Unable to create update package"
end
end
# Copy the install script and updater to the target
# directory
replace_vars("file_list.xml","#{PACKAGE_DIR}/file_list.xml",file_list_vars)
copy_file "../#{UPDATER_NAME}", "#{PACKAGE_DIR}/#{UPDATER_NAME}"
# Run the updater using the new syntax
#
# Run the application from the install directory to
# make sure that it looks in the correct directory for
# the file_list.xml file and packages
#
install_path = File.expand_path(INSTALL_DIR)
Dir.chdir(INSTALL_DIR) do
flags = "--force-elevated" if force_elevation
debug_flags = "gdb --args" if run_in_debugger
cmd = "#{debug_flags} #{PACKAGE_DIR}/#{UPDATER_NAME} #{flags} --install-dir \"#{install_path}\" --package-dir \"#{PACKAGE_DIR}\" --script file_list.xml --auto-close"
puts "Running '#{cmd}'"
system(cmd)
end
# TODO - Correctly wait until updater has finished
sleep(1)
# Check that the app was updated
app_path = "#{INSTALL_DIR}/#{APP_NAME}"
output = `"#{app_path}"`
if (output.strip != "new app starting")
throw "Updated app produced unexpected output: #{output}"
end
# Check that the packaged dir and install dir match
dir_diff = compare_dirs(PACKAGE_SRC_DIR, INSTALL_DIR)
ignored_files = ["test-dir", "test-dir/app-symlink", UPDATER_NAME]
have_unexpected_change = false
dir_diff.each do |path, change_type|
if !ignored_files.include?(path)
case change_type
when :added
$stderr.puts "File #{path} was not installed"
when :changed
$stderr.puts "File #{path} differs between install and package dir"
when :deleted
$stderr.puts "File #{path} was not uninstalled"
end
have_unexpected_change = true
end
end
if have_unexpected_change
throw "Unexpected differences between packaging and update dir"
end
puts "Test passed"

View File

@ -1,67 +0,0 @@
<?xml version="1.0"?>
<!-- The v2-compatible attribute lets the update script parser
know that it is dealing with a script structured for backwards
compatibility with the MD <= 1.0 updater.
!-->
<update version="3" v2-compatible="true">
<targetVersion>2.0</targetVersion>
<platform>Test</platform>
<dependencies>
<!-- The new updater is standalone and has no dependencies,
except for standard system libraries and itself.
!-->
</dependencies>
<packages>
<package>
<name>app-pkg</name>
<hash>$APP_PACKAGE_HASH</hash>
<size>$APP_PACKAGE_SIZE</size>
<source>http://some/dummy/URL</source>
</package>
</packages>
<!-- For compatibility with the update download in MD <= 1.0,
an <install> section lists the packages to download and
the real list of files to install is in the <install-v3>
section. !-->
<install>
<!-- A duplicate of the <packages> section should appear here,
except that each package is listed using the same structure
as files in the install-v3/files section.
!-->
</install>
<install-v3>
<file>
<name>$APP_FILENAME</name>
<hash>$UPDATED_APP_HASH</hash>
<size>$UPDATED_APP_SIZE</size>
<permissions>0755</permissions>
<package>app-pkg</package>
<is-main-binary>true</is-main-binary>
</file>
<file>
<name>$UPDATER_FILENAME</name>
<hash>$UPDATER_HASH</hash>
<size>$UPDATER_SIZE</size>
<permissions>0755</permissions>
</file>
<!-- Test symlink !-->
<file>
<name>test-dir/app-symlink</name>
<target>../app</target>
</file>
<file>
<name>new-dir/new-dir2/new-file.txt</name>
<hash>$TEST_FILENAME</hash>
<size>$TEST_SIZE</size>
<package>app-pkg</package>
<permissions>0644</permissions>
</file>
</install-v3>
<uninstall>
<!-- TODO - List some files to uninstall here !-->
<file>file-to-uninstall.txt</file>
<file>symlink-to-file-to-uninstall.txt</file>
</uninstall>
</update>