aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-23 20:06:13 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-23 20:06:13 -0500
commit7df72eb247b44cddb59ea46a241c3f877814fb70 (patch)
tree38086598dd66f6d03504ccffcf3fe245fa88c206
parent64241ff84673ce7400741c83c5b4445746cc5fd9 (diff)
Updates tab: Install & restart button
Adds an "Install update & restart" button that: 1. Downloads the release asset (with progress bar). 2. Extracts tarball to ~/.local/share/fluorine/update-staging/extract/. 3. Writes a small install.sh helper that waits for the current Fluorine PID to exit, then execs the new launcher. 4. Launches the helper detached and quits the current process. The new launcher runs its existing sync-into-bin/ logic and starts from there. Button stays disabled until a check has reported an update with a valid .tar.gz asset attached. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
-rw-r--r--src/src/settingsdialogupdates.cpp263
-rw-r--r--src/src/settingsdialogupdates.h7
2 files changed, 246 insertions, 24 deletions
diff --git a/src/src/settingsdialogupdates.cpp b/src/src/settingsdialogupdates.cpp
index c5441d1..b903975 100644
--- a/src/src/settingsdialogupdates.cpp
+++ b/src/src/settingsdialogupdates.cpp
@@ -5,18 +5,35 @@
#include "ui_settingsdialog.h"
#include <fluorine_build_info.h>
+#include <log.h>
+#include <QApplication>
#include <QCheckBox>
#include <QComboBox>
+#include <QCoreApplication>
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
#include <QFormLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QNetworkRequest>
+#include <QProcess>
+#include <QProgressBar>
#include <QPushButton>
+#include <QStandardPaths>
#include <QTabWidget>
+#include <QTimer>
+#include <QUrl>
#include <QVBoxLayout>
#include <QWidget>
+#include <sys/types.h>
+#include <unistd.h>
+
#include <algorithm>
UpdatesSettingsTab::UpdatesSettingsTab(Settings& s, SettingsDialog& d)
@@ -25,7 +42,7 @@ UpdatesSettingsTab::UpdatesSettingsTab(Settings& s, SettingsDialog& d)
// Build the tab contents programmatically so we don't have to hand-edit
// settingsdialog.ui — the Updates section used to live on the General
// tab; moving it to its own tab keeps General uncluttered and gives us
- // room for a "Check for updates now" button + live status line.
+ // room for "Check now" + "Install & restart" buttons with progress.
QWidget* page = new QWidget(ui->tabWidget);
auto* layout = new QVBoxLayout(page);
@@ -33,23 +50,19 @@ UpdatesSettingsTab::UpdatesSettingsTab(Settings& s, SettingsDialog& d)
auto* infoGroup = new QGroupBox(tr("Current build"), page);
auto* infoLayout = new QFormLayout(infoGroup);
- const QString currentVersion =
- QStringLiteral(FLUORINE_DISPLAY_VERSION);
- const QString channel =
- QStringLiteral(FLUORINE_BUILD_CHANNEL);
- const QString commit = QStringLiteral(FLUORINE_BUILD_COMMIT);
- const QString timestamp = QStringLiteral(FLUORINE_BUILD_TIMESTAMP);
+ const QString currentVersion = QStringLiteral(FLUORINE_DISPLAY_VERSION);
+ const QString channel = QStringLiteral(FLUORINE_BUILD_CHANNEL);
+ const QString commit = QStringLiteral(FLUORINE_BUILD_COMMIT);
+ const QString timestamp = QStringLiteral(FLUORINE_BUILD_TIMESTAMP);
m_currentVersionLabel = new QLabel(currentVersion, infoGroup);
infoLayout->addRow(tr("Version:"), m_currentVersionLabel);
QString buildLine = QStringLiteral("channel=%1").arg(channel);
- if (!commit.isEmpty()) {
+ if (!commit.isEmpty())
buildLine += QStringLiteral(" commit=%1").arg(commit);
- }
- if (!timestamp.isEmpty()) {
+ if (!timestamp.isEmpty())
buildLine += QStringLiteral(" timestamp=%1").arg(timestamp);
- }
m_buildInfoLabel = new QLabel(buildLine, infoGroup);
m_buildInfoLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
infoLayout->addRow(tr("Build:"), m_buildInfoLabel);
@@ -64,8 +77,8 @@ UpdatesSettingsTab::UpdatesSettingsTab(Settings& s, SettingsDialog& d)
prefsGroup);
m_checkForUpdates->setToolTip(
tr("Query GitHub for a newer Fluorine Manager build when the app "
- "starts. Nothing is installed automatically — you'll see a log "
- "entry and can download the new build from the release page."));
+ "starts. Nothing is installed automatically — you'll see a notice "
+ "here and can install from the button below."));
prefsLayout->addWidget(m_checkForUpdates);
auto* channelRow = new QHBoxLayout();
@@ -75,11 +88,6 @@ UpdatesSettingsTab::UpdatesSettingsTab(Settings& s, SettingsDialog& d)
QStringLiteral("stable"));
m_channelBox->addItem(tr("Beta (rolling build from main)"),
QStringLiteral("beta"));
- m_channelBox->setToolTip(
- tr("Stable: only tagged v* releases. Beta: the rolling `beta` "
- "release that's replaced on every successful CI build. Beta "
- "builds embed a commit hash and timestamp so the updater can "
- "tell whether you're already on the latest one."));
channelRow->addWidget(m_channelBox, 1);
prefsLayout->addLayout(channelRow);
@@ -92,10 +100,19 @@ UpdatesSettingsTab::UpdatesSettingsTab(Settings& s, SettingsDialog& d)
auto* buttonRow = new QHBoxLayout();
m_checkNowButton =
new QPushButton(tr("Check for updates now"), actionGroup);
+ m_installButton =
+ new QPushButton(tr("Install update && restart"), actionGroup);
+ m_installButton->setEnabled(false);
buttonRow->addWidget(m_checkNowButton);
+ buttonRow->addWidget(m_installButton);
buttonRow->addStretch(1);
actionLayout->addLayout(buttonRow);
+ m_progressBar = new QProgressBar(actionGroup);
+ m_progressBar->setVisible(false);
+ m_progressBar->setRange(0, 100);
+ actionLayout->addWidget(m_progressBar);
+
m_statusLabel = new QLabel(actionGroup);
m_statusLabel->setTextFormat(Qt::RichText);
m_statusLabel->setTextInteractionFlags(Qt::TextBrowserInteraction);
@@ -106,7 +123,7 @@ UpdatesSettingsTab::UpdatesSettingsTab(Settings& s, SettingsDialog& d)
layout->addWidget(actionGroup);
layout->addStretch(1);
- // Insert as the second tab (right after General) so it's easy to find.
+ // Insert after General.
const int insertIndex =
std::max(1, std::min(ui->tabWidget->count(), 1));
ui->tabWidget->insertTab(insertIndex, page, tr("Updates"));
@@ -122,11 +139,14 @@ UpdatesSettingsTab::UpdatesSettingsTab(Settings& s, SettingsDialog& d)
QObject::connect(m_checkNowButton, &QPushButton::clicked, &d,
[this]() { onCheckNow(); });
+ QObject::connect(m_installButton, &QPushButton::clicked, &d,
+ [this]() { onInstall(); });
+
QObject::connect(m_updater, &FluorineUpdater::updateAvailable, &d,
[this](const FluorineUpdater::ReleaseInfo& info) {
- const QString url = info.htmlUrl.isEmpty()
- ? QString()
- : info.htmlUrl;
+ m_pendingUpdate = info;
+ m_updatePending = true;
+ const QString url = info.htmlUrl.isEmpty() ? QString() : info.htmlUrl;
QString line = tr("<b>Update available:</b> %1")
.arg(info.name.isEmpty() ? info.tagName
: info.name);
@@ -137,9 +157,18 @@ UpdatesSettingsTab::UpdatesSettingsTab(Settings& s, SettingsDialog& d)
}
m_statusLabel->setText(line);
m_checkNowButton->setEnabled(true);
+ m_installButton->setEnabled(!info.downloadUrl.isEmpty());
+ if (info.downloadUrl.isEmpty()) {
+ m_installButton->setToolTip(
+ tr("This release has no .tar.gz asset attached."));
+ } else {
+ m_installButton->setToolTip(QString());
+ }
});
QObject::connect(m_updater, &FluorineUpdater::upToDate, &d,
[this](const FluorineUpdater::ReleaseInfo&) {
+ m_updatePending = false;
+ m_installButton->setEnabled(false);
m_statusLabel->setText(
tr("You're on the latest build for this channel."));
m_checkNowButton->setEnabled(true);
@@ -157,8 +186,6 @@ void UpdatesSettingsTab::update()
settings().setCheckForUpdates(m_checkForUpdates->isChecked());
const QString channel = m_channelBox->currentData().toString();
settings().setFluorineUpdateChannel(channel);
- // Keep the legacy usePrereleases flag aligned so the Nexus-side pre-
- // release toggle mirrors the Fluorine channel selection.
settings().setUsePrereleases(channel == QStringLiteral("beta"));
}
@@ -166,9 +193,197 @@ void UpdatesSettingsTab::onCheckNow()
{
m_statusLabel->setText(tr("Checking…"));
m_checkNowButton->setEnabled(false);
+ m_installButton->setEnabled(false);
const QString channel = m_channelBox->currentData().toString();
const FluorineUpdater::Channel c = FluorineUpdater::channelFromString(
channel, FluorineUpdater::buildChannel());
m_updater->checkForUpdates(c);
}
+
+void UpdatesSettingsTab::onInstall()
+{
+ if (!m_updatePending || m_pendingUpdate.downloadUrl.isEmpty()) {
+ return;
+ }
+
+ const QString downloadUrl = m_pendingUpdate.downloadUrl;
+
+ // Staging dir for the downloaded tarball. Separate from the live bin/
+ // so we can fall back cleanly if anything fails mid-extract.
+ const QString dataRoot =
+ QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) +
+ QStringLiteral("/fluorine");
+ const QString stagingDir = dataRoot + QStringLiteral("/update-staging");
+ QDir().mkpath(stagingDir);
+ const QString tarPath = stagingDir + QStringLiteral("/download.tar.gz");
+ const QString extractDir = stagingDir + QStringLiteral("/extract");
+
+ QFile::remove(tarPath);
+ QDir(extractDir).removeRecursively();
+ QDir().mkpath(extractDir);
+
+ m_installButton->setEnabled(false);
+ m_checkNowButton->setEnabled(false);
+ m_progressBar->setVisible(true);
+ m_progressBar->setRange(0, 100);
+ m_progressBar->setValue(0);
+ m_statusLabel->setText(tr("Downloading update…"));
+
+ auto* nam = new QNetworkAccessManager(m_installButton);
+ QNetworkRequest req{QUrl(downloadUrl)};
+ req.setRawHeader("User-Agent", "Fluorine-Manager/updater");
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
+ QNetworkRequest::NoLessSafeRedirectPolicy);
+ QNetworkReply* reply = nam->get(req);
+
+ auto* outFile = new QFile(tarPath, reply);
+ if (!outFile->open(QIODevice::WriteOnly)) {
+ m_statusLabel->setText(
+ tr("<i>Install failed:</i> cannot open '%1' for writing")
+ .arg(tarPath));
+ m_progressBar->setVisible(false);
+ m_installButton->setEnabled(true);
+ m_checkNowButton->setEnabled(true);
+ reply->deleteLater();
+ return;
+ }
+
+ QObject::connect(reply, &QNetworkReply::readyRead, reply,
+ [reply, outFile]() {
+ outFile->write(reply->readAll());
+ });
+ QObject::connect(
+ reply, &QNetworkReply::downloadProgress, m_progressBar,
+ [this](qint64 received, qint64 total) {
+ if (total > 0) {
+ m_progressBar->setRange(0, 100);
+ m_progressBar->setValue(
+ static_cast<int>((received * 100) / total));
+ } else {
+ m_progressBar->setRange(0, 0); // indeterminate
+ }
+ });
+ QObject::connect(
+ reply, &QNetworkReply::finished, m_installButton,
+ [this, reply, outFile, tarPath, extractDir, stagingDir]() {
+ outFile->close();
+ const QNetworkReply::NetworkError err = reply->error();
+ const QString errString = reply->errorString();
+ reply->deleteLater();
+
+ if (err != QNetworkReply::NoError) {
+ m_statusLabel->setText(
+ tr("<i>Download failed:</i> %1").arg(errString));
+ m_progressBar->setVisible(false);
+ m_installButton->setEnabled(true);
+ m_checkNowButton->setEnabled(true);
+ return;
+ }
+
+ m_statusLabel->setText(tr("Extracting update…"));
+ m_progressBar->setRange(0, 0); // indeterminate while tar runs
+
+ auto* tar = new QProcess(m_installButton);
+ tar->setWorkingDirectory(extractDir);
+ tar->setProgram(QStringLiteral("tar"));
+ tar->setArguments({QStringLiteral("xzf"), tarPath});
+ QObject::connect(
+ tar,
+ QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
+ m_installButton,
+ [this, tar, tarPath, extractDir, stagingDir](
+ int code, QProcess::ExitStatus st) {
+ tar->deleteLater();
+ if (st != QProcess::NormalExit || code != 0) {
+ m_statusLabel->setText(
+ tr("<i>Extraction failed:</i> tar exited with %1")
+ .arg(code));
+ m_progressBar->setVisible(false);
+ m_installButton->setEnabled(true);
+ m_checkNowButton->setEnabled(true);
+ return;
+ }
+
+ // The tarball is packed as `fluorine-manager/…`. Locate the
+ // top-level dir (picks up a rename too, just in case).
+ QDir extract(extractDir);
+ const QStringList tops = extract.entryList(
+ QDir::Dirs | QDir::NoDotAndDotDot);
+ if (tops.isEmpty()) {
+ m_statusLabel->setText(
+ tr("<i>Install failed:</i> extracted archive is empty"));
+ m_progressBar->setVisible(false);
+ m_installButton->setEnabled(true);
+ m_checkNowButton->setEnabled(true);
+ return;
+ }
+ const QString newBundle =
+ extract.absoluteFilePath(tops.first());
+ const QString newLauncher =
+ newBundle + QStringLiteral("/fluorine-manager");
+ if (!QFileInfo::exists(newLauncher)) {
+ m_statusLabel->setText(
+ tr("<i>Install failed:</i> launcher not found in "
+ "extracted archive"));
+ m_progressBar->setVisible(false);
+ m_installButton->setEnabled(true);
+ m_checkNowButton->setEnabled(true);
+ return;
+ }
+
+ // Write a tiny helper script that waits for the current
+ // Fluorine process to exit (the fluorine-manager launcher
+ // refuses to re-sync itself while we're still holding bin/
+ // open) then execs the new launcher, which performs its
+ // own sync into ~/.local/share/fluorine/bin/.
+ const QString helperPath =
+ stagingDir + QStringLiteral("/install.sh");
+ const qint64 currentPid = ::getpid();
+ QFile helper(helperPath);
+ if (!helper.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
+ m_statusLabel->setText(
+ tr("<i>Install failed:</i> cannot write install helper"));
+ m_progressBar->setVisible(false);
+ m_installButton->setEnabled(true);
+ m_checkNowButton->setEnabled(true);
+ return;
+ }
+ const QString script =
+ QStringLiteral(
+ "#!/usr/bin/env bash\n"
+ "set -u\n"
+ "OLD_PID=%1\n"
+ "NEW_LAUNCHER=%2\n"
+ "for _ in $(seq 1 200); do\n"
+ " if ! kill -0 \"$OLD_PID\" 2>/dev/null; then break; fi\n"
+ " sleep 0.1\n"
+ "done\n"
+ "exec \"$NEW_LAUNCHER\"\n")
+ .arg(QString::number(currentPid), newLauncher);
+ helper.write(script.toUtf8());
+ helper.close();
+ QFile::setPermissions(
+ helperPath,
+ QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner |
+ QFile::ReadGroup | QFile::ExeGroup |
+ QFile::ReadOther | QFile::ExeOther);
+
+ m_statusLabel->setText(
+ tr("Update staged. Restarting Fluorine Manager…"));
+
+ // Detach the helper so it survives our exit.
+ QProcess::startDetached(QStringLiteral("/usr/bin/env"),
+ {QStringLiteral("bash"), helperPath});
+
+ MOBase::log::info(
+ "update installer: spawned helper to restart into {}",
+ newLauncher);
+
+ // Give the signal a beat to propagate, then quit cleanly.
+ QTimer::singleShot(250, qApp,
+ []() { QCoreApplication::quit(); });
+ });
+ tar->start();
+ });
+}
diff --git a/src/src/settingsdialogupdates.h b/src/src/settingsdialogupdates.h
index 9712beb..3a656db 100644
--- a/src/src/settingsdialogupdates.h
+++ b/src/src/settingsdialogupdates.h
@@ -1,6 +1,7 @@
#ifndef SETTINGSDIALOGUPDATES_H
#define SETTINGSDIALOGUPDATES_H
+#include "fluorineupdater.h"
#include "settingsdialog.h"
#include <QCoreApplication>
@@ -8,6 +9,7 @@
class QCheckBox;
class QComboBox;
class QLabel;
+class QProgressBar;
class QPushButton;
class FluorineUpdater;
@@ -25,14 +27,19 @@ public:
private:
void onCheckNow();
+ void onInstall();
QCheckBox* m_checkForUpdates = nullptr;
QComboBox* m_channelBox = nullptr;
QLabel* m_currentVersionLabel = nullptr;
QLabel* m_buildInfoLabel = nullptr;
QPushButton* m_checkNowButton = nullptr;
+ QPushButton* m_installButton = nullptr;
QLabel* m_statusLabel = nullptr;
+ QProgressBar* m_progressBar = nullptr;
FluorineUpdater* m_updater = nullptr;
+ FluorineUpdater::ReleaseInfo m_pendingUpdate{};
+ bool m_updatePending = false;
};
#endif // SETTINGSDIALOGUPDATES_H