diff --git a/src/common/utility.h b/src/common/utility.h index f80851a6999b9..cf6b7f252d9ac 100644 --- a/src/common/utility.h +++ b/src/common/utility.h @@ -170,6 +170,10 @@ namespace Utility { constexpr bool isBSD(); // use with care, does not match OS X OCSYNC_EXPORT QString platformName(); + + // AppImage helpers (Linux only, return empty/false elsewhere) + OCSYNC_EXPORT QString appImagePath(); + OCSYNC_EXPORT bool isRunningInAppImage(); // crash helper for --debug OCSYNC_EXPORT void crash(); diff --git a/src/common/utility_mac.mm b/src/common/utility_mac.mm index e66f69a65d588..34cb2f4fd4dfe 100644 --- a/src/common/utility_mac.mm +++ b/src/common/utility_mac.mm @@ -18,6 +18,16 @@ namespace OCC { +QString Utility::appImagePath() +{ + return {}; +} + +bool Utility::isRunningInAppImage() +{ + return false; +} + QVector Utility::queryProcessInfosKeepingFileOpen(const QString &filePath) { Q_UNUSED(filePath) diff --git a/src/common/utility_unix.cpp b/src/common/utility_unix.cpp index fc7d56058b695..aea1cb00550a4 100644 --- a/src/common/utility_unix.cpp +++ b/src/common/utility_unix.cpp @@ -18,6 +18,17 @@ namespace OCC { +QString Utility::appImagePath() +{ + return qEnvironmentVariable("APPIMAGE"); +} + +bool Utility::isRunningInAppImage() +{ + const auto currentAppImagePath = appImagePath(); + return !currentAppImagePath.isEmpty() && QFile::exists(currentAppImagePath); +} + QVector Utility::queryProcessInfosKeepingFileOpen(const QString &filePath) { Q_UNUSED(filePath) @@ -99,9 +110,9 @@ void Utility::setLaunchOnStartup(const QString &appName, const QString &guiName, } // When running inside an AppImage, we need to set the path to the // AppImage instead of the path to the executable - const QString appImagePath = qEnvironmentVariable("APPIMAGE"); - const bool runningInsideAppImage = !appImagePath.isNull() && QFile::exists(appImagePath); - const QString executablePath = runningInsideAppImage ? appImagePath : QCoreApplication::applicationFilePath(); + const auto currentAppImagePath = appImagePath(); + const auto runningInsideAppImage = isRunningInAppImage(); + const auto executablePath = runningInsideAppImage ? currentAppImagePath : QCoreApplication::applicationFilePath(); QTextStream ts(&iniFile); ts << QLatin1String("[Desktop Entry]\n") @@ -134,10 +145,7 @@ QString Utility::getCurrentUserName() void Utility::registerUriHandlerForLocalEditing() { - const auto appImagePath = qEnvironmentVariable("APPIMAGE"); - const auto runningInsideAppImage = !appImagePath.isNull() && QFile::exists(appImagePath); - - if (!runningInsideAppImage) { + if (!isRunningInAppImage()) { // only register x-scheme-handler if running inside appImage return; } diff --git a/src/common/utility_win.cpp b/src/common/utility_win.cpp index 364e075b96ea2..3968d1cb6fd33 100644 --- a/src/common/utility_win.cpp +++ b/src/common/utility_win.cpp @@ -34,6 +34,16 @@ static const char runPathC[] = R"(HKEY_CURRENT_USER\Software\Microsoft\Windows\C namespace OCC { +QString Utility::appImagePath() +{ + return {}; +} + +bool Utility::isRunningInAppImage() +{ + return false; +} + QVector Utility::queryProcessInfosKeepingFileOpen(const QString &filePath) { QVector results; diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 0b9097cb152a7..65c6f00815afe 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -285,6 +285,13 @@ IF(BUILD_UPDATER) updater/updater.h updater/updater.cpp ) + # Linux AppImage updater + if(UNIX AND NOT APPLE) + list(APPEND updater_SRCS + updater/appimageupdater.h + updater/appimageupdater.cpp + ) + endif() endif() IF( APPLE ) diff --git a/src/gui/updater/appimageupdater.cpp b/src/gui/updater/appimageupdater.cpp new file mode 100644 index 0000000000000..9ed1340cec21e --- /dev/null +++ b/src/gui/updater/appimageupdater.cpp @@ -0,0 +1,440 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "updater/appimageupdater.h" + +#include "theme.h" +#include "configfile.h" +#include "common/utility.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OCC { +namespace { +void raiseAppImageDialog(QWidget *widget); + +QWidget *appImageDialogParent() +{ + if (auto *active = QApplication::activeWindow()) { + return active; + } + + if (auto *focus = QApplication::focusWidget()) { + return focus->window(); + } + + return nullptr; +} + +bool attachAndOpenAppImageDialog(QDialog *dialog, const bool removeContextHelpButton, const bool requireParent) +{ + if (!dialog || dialog->isVisible()) { + return dialog != nullptr; + } + + auto *parent = appImageDialogParent(); + if (requireParent && !parent) { + return false; + } + + if (parent) { + dialog->setParent(parent, dialog->windowFlags()); + dialog->setWindowModality(Qt::WindowModal); + } else { + dialog->setWindowModality(Qt::ApplicationModal); + } + + auto flags = dialog->windowFlags() | Qt::WindowStaysOnTopHint; + if (removeContextHelpButton) { + flags &= ~Qt::WindowContextHelpButtonHint; + } + dialog->setWindowFlags(flags); + dialog->open(); + raiseAppImageDialog(dialog); + return true; +} + +void presentAppImageDialog(QDialog *dialog, const bool removeContextHelpButton) +{ + if (!dialog) { + return; + } + + if (attachAndOpenAppImageDialog(dialog, removeContextHelpButton, true)) { + return; + } + + auto *timer = new QTimer(dialog); + timer->setInterval(150); + timer->setSingleShot(false); + QObject::connect(timer, &QTimer::timeout, dialog, [dialog, removeContextHelpButton, timer]() { + if (!dialog) { + timer->stop(); + timer->deleteLater(); + return; + } + + if (attachAndOpenAppImageDialog(dialog, removeContextHelpButton, true)) { + timer->stop(); + timer->deleteLater(); + } + }); + timer->start(); + + QTimer::singleShot(2000, dialog, [dialog, removeContextHelpButton, timer]() { + if (!dialog || dialog->isVisible()) { + return; + } + + attachAndOpenAppImageDialog(dialog, removeContextHelpButton, false); + if (timer) { + timer->stop(); + timer->deleteLater(); + } + }); + + QObject::connect(qApp, &QApplication::focusChanged, dialog, [dialog]() { + if (!dialog || !dialog->isVisible()) { + return; + } + + raiseAppImageDialog(dialog); + }); +} + +void raiseAppImageDialog(QWidget *widget) +{ + if (!widget) { + return; + } + + widget->showNormal(); + widget->raise(); + widget->activateWindow(); +} +} // namespace + +using namespace Qt::StringLiterals; + +Q_LOGGING_CATEGORY(lcAppImageUpdater, "nextcloud.gui.updater.appimage", QtInfoMsg) + +AppImageUpdater::AppImageUpdater(const QUrl &url) + : OCUpdater(url) +{ + qCInfo(lcAppImageUpdater) << "AppImageUpdater constructed with URL:" << url.toString(); + qCInfo(lcAppImageUpdater) << "Current AppImage path:" << currentAppImagePath(); + qCInfo(lcAppImageUpdater) << "Can write to location:" << canWriteToAppImageLocation(); +} + +bool AppImageUpdater::isRunningAppImage() +{ + return Utility::isRunningInAppImage(); +} + +QString AppImageUpdater::currentAppImagePath() +{ + return Utility::appImagePath(); +} + +bool AppImageUpdater::canWriteToAppImageLocation() const +{ + const auto appImagePath = currentAppImagePath(); + if (appImagePath.isEmpty()) { + return false; + } + + const auto appImageInfo = QFileInfo{appImagePath}; + // We only need to check if the directory is writable because we replace the file + // by moving the new one over it (which requires directory write permissions). + // The file itself might report not writable if it is currently running (ETXTBSY). + + // Check if the directory is writable (needed for backup during replacement) + const auto dirInfo = QFileInfo{appImageInfo.dir().path()}; + if (!dirInfo.isWritable()) { + qCInfo(lcAppImageUpdater) << "AppImage directory is not writable:" << dirInfo.path(); + return false; + } + + return true; +} + +void AppImageUpdater::wipeUpdateData() +{ + const auto cfg = ConfigFile{}; + auto settings = QSettings{cfg.configFile(), QSettings::IniFormat}; + const auto updateFileName = settings.value(updateAvailableKey).toString(); + if (!updateFileName.isEmpty()) { + QFile::remove(updateFileName); + } + settings.remove(updateAvailableKey); + settings.remove(updateTargetVersionKey); + settings.remove(updateTargetVersionStringKey); + settings.remove(autoUpdateAttemptedKey); +} + +void AppImageUpdater::slotWriteFile() +{ + auto *reply = qobject_cast(sender()); + if (_file->isOpen()) { + const auto data = reply->readAll(); + _file->write(data); + } +} + +void AppImageUpdater::slotDownloadFinished() +{ + auto *reply = qobject_cast(sender()); + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + qCWarning(lcAppImageUpdater) << "Download failed:" << reply->errorString(); + setDownloadState(DownloadFailed); + return; + } + + const auto url = reply->url(); + _file->close(); + + const auto cfg = ConfigFile{}; + auto settings = QSettings{cfg.configFile(), QSettings::IniFormat}; + + // Remove previously downloaded but not used update file + auto oldTargetFile = QFile{settings.value(updateAvailableKey).toString()}; + if (oldTargetFile.exists()) { + oldTargetFile.remove(); + } + + // Copy downloaded file to target location + if (!QFile::copy(_file->fileName(), _targetFile)) { + qCWarning(lcAppImageUpdater) << "Failed to copy update file to" << _targetFile; + setDownloadState(DownloadFailed); + return; + } + + // Make the downloaded AppImage executable + auto targetFile = QFile{_targetFile}; + targetFile.setPermissions(targetFile.permissions() | QFile::ExeUser | QFile::ExeGroup | QFile::ExeOwner); + + setDownloadState(DownloadComplete); + qCInfo(lcAppImageUpdater) << "Downloaded" << url.toString() << "to" << _targetFile; + settings.setValue(updateTargetVersionKey, updateInfo().version()); + settings.setValue(updateTargetVersionStringKey, updateInfo().versionString()); + settings.setValue(updateAvailableKey, _targetFile); +} + +void AppImageUpdater::versionInfoArrived(const UpdateInfo &info) +{ + const auto cfg = ConfigFile{}; + const auto infoVersion = Helper::stringVersionToInt(info.version()); + const auto currVersion = Helper::currentVersionToInt(); + qCInfo(lcAppImageUpdater) << "Version info arrived:" + << "Your version:" << currVersion + << "Available version:" << infoVersion << info.version() + << "Available version string:" << info.versionString() + << "Web url:" << info.web() + << "Download url:" << info.downloadUrl(); + + if (info.version().isEmpty()) { + qCInfo(lcAppImageUpdater) << "No version information available at the moment"; + setDownloadState(UpToDate); + return; + } + + const auto currentVer = Helper::currentVersionToInt(); + const auto remoteVer = Helper::stringVersionToInt(info.version()); + + if (currentVer >= remoteVer) { + qCInfo(lcAppImageUpdater) << "Client is on latest version!"; + setDownloadState(UpToDate); + return; + } + + // Check if we can write to the AppImage location + if (!canWriteToAppImageLocation()) { + qCInfo(lcAppImageUpdater) << "Cannot write to AppImage location, falling back to notification only"; + setDownloadState(UpdateOnlyAvailableThroughSystem); + return; + } + + const auto url = info.downloadUrl(); + if (url.isEmpty()) { + qCInfo(lcAppImageUpdater) << "No download URL provided"; + setDownloadState(UpdateOnlyAvailableThroughSystem); + return; + } + + // Download to config directory + const auto fileName = QFileInfo{QUrl{url}.path()}.fileName(); + const auto targetFileName = fileName.isEmpty() ? "nextcloud-update.AppImage"_L1 : fileName; + _targetFile = QDir(cfg.configPath()).filePath(targetFileName); + + // Check if already downloaded + if (QFile(_targetFile).exists()) { + qCInfo(lcAppImageUpdater) << "Update already downloaded at" << _targetFile; + setDownloadState(DownloadComplete); + return; + } + + // Start download + auto request = QNetworkRequest(QUrl{url}); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + auto *reply = qnam()->get(request); + connect(reply, &QIODevice::readyRead, this, &AppImageUpdater::slotWriteFile); + connect(reply, &QNetworkReply::finished, this, &AppImageUpdater::slotDownloadFinished); + setDownloadState(Downloading); + + _file.reset(new QTemporaryFile); + _file->setAutoRemove(true); + _file->open(); +} + +void AppImageUpdater::showUpdateErrorDialog(const QString &targetVersion) +{ + auto *msgBox = new QDialog; + msgBox->setAttribute(Qt::WA_DeleteOnClose); + + const auto infoIcon = msgBox->style()->standardIcon(QStyle::SP_MessageBoxInformation); + const auto iconSize = msgBox->style()->pixelMetric(QStyle::PM_MessageBoxIconSize); + + msgBox->setWindowIcon(infoIcon); + + auto *layout = new QVBoxLayout(msgBox); + auto *hlayout = new QHBoxLayout; + layout->addLayout(hlayout); + + msgBox->setWindowTitle(tr("Update Failed")); + + auto *ico = new QLabel; + ico->setFixedSize(iconSize, iconSize); + ico->setPixmap(infoIcon.pixmap(iconSize)); + auto *lbl = new QLabel; + const auto txt = tr("

A new version of the %1 Client is available but the updating process failed.

" + "

%2 has been downloaded. The installed version is %3.

") + .arg(Utility::escape(Theme::instance()->appNameGUI()), + Utility::escape(targetVersion), Utility::escape(clientVersion())); + + lbl->setText(txt); + lbl->setTextFormat(Qt::RichText); + lbl->setWordWrap(true); + + hlayout->addWidget(ico); + hlayout->addWidget(lbl); + + auto *bb = new QDialogButtonBox; + auto *askagain = bb->addButton(tr("Ask again later"), QDialogButtonBox::ResetRole); + auto *retry = bb->addButton(tr("Restart and update"), QDialogButtonBox::AcceptRole); + auto *getupdate = bb->addButton(tr("Update manually"), QDialogButtonBox::AcceptRole); + + connect(askagain, &QAbstractButton::clicked, msgBox, &QDialog::reject); + connect(retry, &QAbstractButton::clicked, msgBox, &QDialog::accept); + connect(getupdate, &QAbstractButton::clicked, msgBox, &QDialog::accept); + + connect(retry, &QAbstractButton::clicked, this, [this]() { + slotStartInstaller(); + }); + connect(getupdate, &QAbstractButton::clicked, this, &AppImageUpdater::slotOpenUpdateUrl); + + layout->addWidget(bb); + + presentAppImageDialog(msgBox, true); +} + +bool AppImageUpdater::handleStartup() +{ + const auto cfg = ConfigFile{}; + auto settings = QSettings{cfg.configFile(), QSettings::IniFormat}; + + // No need to try to install a previously fetched update when the user doesn't want automated updates + if (cfg.skipUpdateCheck() || !cfg.autoUpdateCheck()) { + qCInfo(lcAppImageUpdater) << "Skipping installation of update due to config settings"; + return false; + } + + const auto updateFileName = settings.value(updateAvailableKey).toString(); + // Has the previous run downloaded an update? + if (!updateFileName.isEmpty() && QFile(updateFileName).exists()) { + qCInfo(lcAppImageUpdater) << "An updater file is available:" << updateFileName; + // Did it try to execute the update? + if (settings.value(autoUpdateAttemptedKey, false).toBool()) { + if (updateSucceeded()) { + // Success: clean up + qCInfo(lcAppImageUpdater) << "The requested update attempt has succeeded" + << Helper::currentVersionToInt(); + wipeUpdateData(); + return false; + } else { + // Auto update failed. Ask user what to do + qCInfo(lcAppImageUpdater) << "The requested update attempt has failed" + << settings.value(updateTargetVersionKey).toString(); + showUpdateErrorDialog(settings.value(updateTargetVersionStringKey).toString()); + return false; + } + } else { + qCInfo(lcAppImageUpdater) << "Triggering an update"; + return performUpdate(); + } + } + return false; +} + +void AppImageUpdater::slotStartInstaller() +{ + const auto cfg = ConfigFile{}; + auto settings = QSettings{cfg.configFile(), QSettings::IniFormat}; + const auto updateFile = settings.value(updateAvailableKey).toString(); + const auto currentAppImage = currentAppImagePath(); + + if (updateFile.isEmpty() || currentAppImage.isEmpty()) { + qCWarning(lcAppImageUpdater) << "Missing update file or current AppImage path"; + return; + } + + settings.setValue(autoUpdateAttemptedKey, true); + settings.sync(); + qCInfo(lcAppImageUpdater) << "Starting AppImage update from" << updateFile << "to" << currentAppImage; + + const auto backupPath = QString{currentAppImage + ".backup"_L1}; + QFile::remove(backupPath); + + if (!QFile::rename(currentAppImage, backupPath)) { + qCWarning(lcAppImageUpdater) << "Failed to rename running AppImage to" << backupPath; + showUpdateErrorDialog(updateInfo().versionString()); + return; + } + + if (!QFile::copy(updateFile, currentAppImage)) { + qCWarning(lcAppImageUpdater) << "Failed to copy update file to" << currentAppImage; + QFile::rename(backupPath, currentAppImage); + showUpdateErrorDialog(updateInfo().versionString()); + return; + } + + auto targetFile = QFile{currentAppImage}; + targetFile.setPermissions(targetFile.permissions() | QFile::ExeUser | QFile::ExeGroup | QFile::ExeOwner); + + if (!QFile::remove(backupPath)) { + qCWarning(lcAppImageUpdater) << "Failed to remove backup AppImage" << backupPath; + } + + emit requestRestart(); + qApp->quit(); +} + +} // namespace OCC diff --git a/src/gui/updater/appimageupdater.h b/src/gui/updater/appimageupdater.h new file mode 100644 index 0000000000000..76e21d091b82f --- /dev/null +++ b/src/gui/updater/appimageupdater.h @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef APPIMAGEUPDATER_H +#define APPIMAGEUPDATER_H + +#include "updater/ocupdater.h" + +#include + +namespace OCC { + +/** + * @brief Linux AppImage Updater + * @ingroup gui + * + * Downloads the new AppImage and replaces the current one on restart. + * Only active when running inside an AppImage (detected via APPIMAGE env var). + */ +class AppImageUpdater : public OCUpdater +{ + Q_OBJECT +public: + explicit AppImageUpdater(const QUrl &url); + bool handleStartup() override; + + /** + * @brief Check if the application is running as an AppImage + * @return true if APPIMAGE environment variable is set and points to existing file + */ + static bool isRunningAppImage(); + + /** + * @brief Get the path to the currently running AppImage + * @return Path from APPIMAGE environment variable, or empty string if not an AppImage + */ + static QString currentAppImagePath(); + +public slots: + void slotStartInstaller() override; + +private slots: + void slotDownloadFinished(); + void slotWriteFile(); + +private: + void wipeUpdateData(); + void showUpdateErrorDialog(const QString &targetVersion); + void versionInfoArrived(const UpdateInfo &info) override; + bool canWriteToAppImageLocation() const; + + QScopedPointer _file; + QString _targetFile; +}; + +} // namespace OCC + +#endif // APPIMAGEUPDATER_H diff --git a/src/gui/updater/ocupdater.cpp b/src/gui/updater/ocupdater.cpp index 8c2d5a31877b5..58963ee41abcc 100644 --- a/src/gui/updater/ocupdater.cpp +++ b/src/gui/updater/ocupdater.cpp @@ -20,14 +20,110 @@ #include namespace OCC { - namespace { -const auto updateAvailableC = QStringLiteral("Updater/updateAvailable"); -const auto updateTargetVersionC = QStringLiteral("Updater/updateTargetVersion"); -const auto updateTargetVersionStringC = QStringLiteral("Updater/updateTargetVersionString"); -const auto autoUpdateAttemptedC = QStringLiteral("Updater/autoUpdateAttempted"); +void raiseUpdaterDialog(QWidget *widget); + +QWidget *updaterDialogParent() +{ + if (auto *active = QApplication::activeWindow()) { + return active; + } + + if (auto *focus = QApplication::focusWidget()) { + return focus->window(); + } + + return nullptr; +} + +bool attachAndOpenUpdaterDialog(QDialog *dialog, const bool removeContextHelpButton, const bool requireParent) +{ + if (!dialog || dialog->isVisible()) { + return dialog != nullptr; + } + + auto *parent = updaterDialogParent(); + if (requireParent && !parent) { + return false; + } + + if (parent) { + dialog->setParent(parent, dialog->windowFlags()); + dialog->setWindowModality(Qt::WindowModal); + } else { + dialog->setWindowModality(Qt::ApplicationModal); + } + + auto flags = dialog->windowFlags() | Qt::WindowStaysOnTopHint; + if (removeContextHelpButton) { + flags &= ~Qt::WindowContextHelpButtonHint; + } + dialog->setWindowFlags(flags); + dialog->open(); + raiseUpdaterDialog(dialog); + return true; +} + +void presentUpdaterDialog(QDialog *dialog, const bool removeContextHelpButton) +{ + if (!dialog) { + return; + } + + if (attachAndOpenUpdaterDialog(dialog, removeContextHelpButton, true)) { + return; + } + + auto *timer = new QTimer(dialog); + timer->setInterval(150); + timer->setSingleShot(false); + QObject::connect(timer, &QTimer::timeout, dialog, [dialog, removeContextHelpButton, timer]() { + if (!dialog) { + timer->stop(); + timer->deleteLater(); + return; + } + + if (attachAndOpenUpdaterDialog(dialog, removeContextHelpButton, true)) { + timer->stop(); + timer->deleteLater(); + } + }); + timer->start(); + + QTimer::singleShot(2000, dialog, [dialog, removeContextHelpButton, timer]() { + if (!dialog || dialog->isVisible()) { + return; + } + + attachAndOpenUpdaterDialog(dialog, removeContextHelpButton, false); + if (timer) { + timer->stop(); + timer->deleteLater(); + } + }); + + QObject::connect(qApp, &QApplication::focusChanged, dialog, [dialog]() { + if (!dialog || !dialog->isVisible()) { + return; + } + + raiseUpdaterDialog(dialog); + }); } +void raiseUpdaterDialog(QWidget *widget) +{ + if (!widget) { + return; + } + + widget->showNormal(); + widget->raise(); + widget->activateWindow(); +} +} // namespace + UpdaterScheduler::UpdaterScheduler(QObject *parent) : QObject(parent) { @@ -92,7 +188,7 @@ bool OCUpdater::performUpdate() { ConfigFile cfg; QSettings settings(cfg.configFile(), QSettings::IniFormat); - QString updateFile = settings.value(updateAvailableC).toString(); + QString updateFile = settings.value(updateAvailableKey).toString(); if (!updateFile.isEmpty() && QFile(updateFile).exists() && !updateSucceeded() /* Someone might have run the updater manually between restarts */) { const auto messageBoxStartInstaller = new QMessageBox(QMessageBox::Information, @@ -111,7 +207,7 @@ bool OCUpdater::performUpdate() slotStartInstaller(); } }); - messageBoxStartInstaller->open(); + presentUpdaterDialog(messageBoxStartInstaller, false); } return false; } @@ -200,37 +296,7 @@ void OCUpdater::setDownloadState(DownloadState state) void OCUpdater::slotStartInstaller() { - ConfigFile cfg; - QSettings settings(cfg.configFile(), QSettings::IniFormat); - QString updateFile = settings.value(updateAvailableC).toString(); - settings.setValue(autoUpdateAttemptedC, true); - settings.sync(); - qCInfo(lcUpdater) << "Running updater" << updateFile; - - if(updateFile.endsWith(".exe")) { - QProcess::startDetached(updateFile, QStringList() << "/S" - << "/launch"); - } else if(updateFile.endsWith(".msi")) { - // When MSIs are installed without gui they cannot launch applications - // as they lack the user context. That is why we need to run the client - // manually here. We wrap the msiexec and client invocation in a powershell - // script because owncloud.exe will be shut down for installation. - // | Out-Null forces powershell to wait for msiexec to finish. - auto preparePathForPowershell = [](QString path) { - path.replace("'", "''"); - - return QDir::toNativeSeparators(path); - }; - - QString msiLogFile = cfg.configPath() + "msi.log"; - QString command = QStringLiteral("&{msiexec /i '%1' /L*V '%2'| Out-Null ; &'%3'}") - .arg(preparePathForPowershell(updateFile)) - .arg(preparePathForPowershell(msiLogFile)) - .arg(preparePathForPowershell(QCoreApplication::applicationFilePath())); - - QProcess::startDetached("powershell.exe", QStringList{"-Command", command}); - } - qApp->quit(); + qCWarning(lcUpdater) << "slotStartInstaller called on non-NSIS updater"; } void OCUpdater::checkForUpdate() @@ -253,7 +319,7 @@ bool OCUpdater::updateSucceeded() const ConfigFile cfg; QSettings settings(cfg.configFile(), QSettings::IniFormat); - qint64 targetVersionInt = Helper::stringVersionToInt(settings.value(updateTargetVersionC).toString()); + qint64 targetVersionInt = Helper::stringVersionToInt(settings.value(updateTargetVersionKey).toString()); qint64 currentVersion = Helper::currentVersionToInt(); return currentVersion >= targetVersionInt; } @@ -306,13 +372,13 @@ void NSISUpdater::wipeUpdateData() { ConfigFile cfg; QSettings settings(cfg.configFile(), QSettings::IniFormat); - QString updateFileName = settings.value(updateAvailableC).toString(); + QString updateFileName = settings.value(updateAvailableKey).toString(); if (!updateFileName.isEmpty()) QFile::remove(updateFileName); - settings.remove(updateAvailableC); - settings.remove(updateTargetVersionC); - settings.remove(updateTargetVersionStringC); - settings.remove(autoUpdateAttemptedC); + settings.remove(updateAvailableKey); + settings.remove(updateTargetVersionKey); + settings.remove(updateTargetVersionStringKey); + settings.remove(autoUpdateAttemptedKey); } void NSISUpdater::slotDownloadFinished() @@ -331,7 +397,7 @@ void NSISUpdater::slotDownloadFinished() QSettings settings(cfg.configFile(), QSettings::IniFormat); // remove previously downloaded but not used installer - QFile oldTargetFile(settings.value(updateAvailableC).toString()); + QFile oldTargetFile(settings.value(updateAvailableKey).toString()); if (oldTargetFile.exists()) { oldTargetFile.remove(); } @@ -339,9 +405,9 @@ void NSISUpdater::slotDownloadFinished() QFile::copy(_file->fileName(), _targetFile); setDownloadState(DownloadComplete); qCInfo(lcUpdater) << "Downloaded" << url.toString() << "to" << _targetFile; - settings.setValue(updateTargetVersionC, updateInfo().version()); - settings.setValue(updateTargetVersionStringC, updateInfo().versionString()); - settings.setValue(updateAvailableC, _targetFile); + settings.setValue(updateTargetVersionKey, updateInfo().version()); + settings.setValue(updateTargetVersionStringKey, updateInfo().versionString()); + settings.setValue(updateAvailableKey, _targetFile); } void NSISUpdater::versionInfoArrived(const UpdateInfo &info) @@ -396,7 +462,6 @@ void NSISUpdater::showNoUrlDialog(const UpdateInfo &info) // if the version tag is set, there is a newer version. auto *msgBox = new QDialog; msgBox->setAttribute(Qt::WA_DeleteOnClose); - msgBox->setWindowFlags(msgBox->windowFlags() & ~Qt::WindowContextHelpButtonHint); QIcon infoIcon = msgBox->style()->standardIcon(QStyle::SP_MessageBoxInformation); int iconSize = msgBox->style()->pixelMetric(QStyle::PM_MessageBoxIconSize); @@ -436,14 +501,13 @@ void NSISUpdater::showNoUrlDialog(const UpdateInfo &info) layout->addWidget(bb); - msgBox->open(); + presentUpdaterDialog(msgBox, true); } void NSISUpdater::showUpdateErrorDialog(const QString &targetVersion) { auto msgBox = new QDialog; msgBox->setAttribute(Qt::WA_DeleteOnClose); - msgBox->setWindowFlags(msgBox->windowFlags() & ~Qt::WindowContextHelpButtonHint); QIcon infoIcon = msgBox->style()->standardIcon(QStyle::SP_MessageBoxInformation); int iconSize = msgBox->style()->pixelMetric(QStyle::PM_MessageBoxIconSize); @@ -491,7 +555,7 @@ void NSISUpdater::showUpdateErrorDialog(const QString &targetVersion) layout->addWidget(bb); - msgBox->open(); + presentUpdaterDialog(msgBox, true); } bool NSISUpdater::handleStartup() @@ -505,12 +569,12 @@ bool NSISUpdater::handleStartup() return false; } - QString updateFileName = settings.value(updateAvailableC).toString(); + QString updateFileName = settings.value(updateAvailableKey).toString(); // has the previous run downloaded an update? if (!updateFileName.isEmpty() && QFile(updateFileName).exists()) { qCInfo(lcUpdater) << "An updater file is available"; // did it try to execute the update? - if (settings.value(autoUpdateAttemptedC, false).toBool()) { + if (settings.value(autoUpdateAttemptedKey, false).toBool()) { if (updateSucceeded()) { // success: clean up qCInfo(lcUpdater) << "The requested update attempt has succeeded" @@ -520,8 +584,8 @@ bool NSISUpdater::handleStartup() } else { // auto update failed. Ask user what to do qCInfo(lcUpdater) << "The requested update attempt has failed" - << settings.value(updateTargetVersionC).toString(); - showUpdateErrorDialog(settings.value(updateTargetVersionStringC).toString()); + << settings.value(updateTargetVersionKey).toString(); + showUpdateErrorDialog(settings.value(updateTargetVersionStringKey).toString()); return false; } } else { @@ -532,6 +596,41 @@ bool NSISUpdater::handleStartup() return false; } +void NSISUpdater::slotStartInstaller() +{ + ConfigFile cfg; + QSettings settings(cfg.configFile(), QSettings::IniFormat); + QString updateFile = settings.value(updateAvailableKey).toString(); + settings.setValue(autoUpdateAttemptedKey, true); + settings.sync(); + qCInfo(lcUpdater) << "Running updater" << updateFile; + + if (updateFile.endsWith(".exe")) { + QProcess::startDetached(updateFile, QStringList() << "/S" + << "/launch"); + } else if (updateFile.endsWith(".msi")) { + // When MSIs are installed without gui they cannot launch applications + // as they lack the user context. That is why we need to run the client + // manually here. We wrap the msiexec and client invocation in a powershell + // script because owncloud.exe will be shut down for installation. + // | Out-Null forces powershell to wait for msiexec to finish. + auto preparePathForPowershell = [](QString path) { + path.replace("'", "''"); + + return QDir::toNativeSeparators(path); + }; + + QString msiLogFile = cfg.configPath() + "msi.log"; + QString command = QStringLiteral("&{msiexec /i '%1' /L*V '%2'| Out-Null ; &'%3'}") + .arg(preparePathForPowershell(updateFile)) + .arg(preparePathForPowershell(msiLogFile)) + .arg(preparePathForPowershell(QCoreApplication::applicationFilePath())); + + QProcess::startDetached("powershell.exe", QStringList{"-Command", command}); + } + qApp->quit(); +} + //////////////////////////////////////////////////////////////////////// PassiveUpdateNotifier::PassiveUpdateNotifier(const QUrl &url) diff --git a/src/gui/updater/ocupdater.h b/src/gui/updater/ocupdater.h index 25c477b6864dd..3540ba7edfcd2 100644 --- a/src/gui/updater/ocupdater.h +++ b/src/gui/updater/ocupdater.h @@ -8,6 +8,7 @@ #define OCUPDATER_H #include +#include #include #include #include @@ -20,6 +21,13 @@ class QNetworkReply; namespace OCC { +using namespace Qt::StringLiterals; + +constexpr auto updateAvailableKey = "Updater/updateAvailable"_L1; +constexpr auto updateTargetVersionKey = "Updater/updateTargetVersion"_L1; +constexpr auto updateTargetVersionStringKey = "Updater/updateTargetVersionString"_L1; +constexpr auto autoUpdateAttemptedKey = "Updater/autoUpdateAttempted"_L1; + /** * @brief Schedule update checks every couple of hours if the client runs. * @ingroup gui @@ -112,8 +120,7 @@ class OCUpdater : public Updater void requestRestart(); public slots: - // FIXME Maybe this should be in the NSISUpdater which should have been called WindowsUpdater - void slotStartInstaller(); + virtual void slotStartInstaller(); protected slots: void backgroundCheckForUpdate() override; @@ -147,6 +154,7 @@ class NSISUpdater : public OCUpdater public: explicit NSISUpdater(const QUrl &url); bool handleStartup() override; + void slotStartInstaller() override; private slots: void slotDownloadFinished(); void slotWriteFile(); diff --git a/src/gui/updater/updater.cpp b/src/gui/updater/updater.cpp index d57336e1e8002..4935cc08acfa4 100644 --- a/src/gui/updater/updater.cpp +++ b/src/gui/updater/updater.cpp @@ -11,6 +11,9 @@ #include "updater/updater.h" #include "updater/sparkleupdater.h" #include "updater/ocupdater.h" +#ifdef Q_OS_LINUX +#include "updater/appimageupdater.h" +#endif #include "theme.h" #include "common/utility.h" @@ -132,6 +135,12 @@ Updater *Updater::create() #elif defined(Q_OS_WIN32) // Also for MSI return new NSISUpdater(url); +#elif defined(Q_OS_LINUX) + // Use AppImageUpdater when running as AppImage, otherwise fall back to passive notifier + if (AppImageUpdater::isRunningAppImage()) { + return new AppImageUpdater(url); + } + return new PassiveUpdateNotifier(url); #else // the best we can do is notify about updates return new PassiveUpdateNotifier(url);