feat: Show restore dialog when settings fail to load (#6662)

Co-authored-by: pajlada <rasmus.karlsson@pajlada.com>
Tested-by: pajlada <rasmus.karlsson@pajlada.com>
Tested-by: Mm2PL <mm2pl+gh@kotmisia.pl>
Reported-by: w3bprinz
Reported-by: cqmpact
Reviewed-by: pajlada <rasmus.karlsson@pajlada.com>
Reviewed-by: Mm2PL <mm2pl+gh@kotmisia.pl>
This commit is contained in:
Nerixyz
2026-02-23 16:44:48 +01:00
committed by GitHub
co-authored by pajlada
parent 2b4ede1b58
commit 79e5d295d4
9 changed files with 526 additions and 5 deletions
+1
View File
@@ -22,6 +22,7 @@
- Minor: Added broadcaster-only `/poll`, `/cancelpoll`, and `/endpoll` commands. (#6583, #6605)
- Minor: Added broadcaster-only `/prediction`, `/cancelprediction`, `/lockprediction`, and `/completeprediction` commands. (#6583, #6612, #6632, #6749)
- Minor: Added support for BetterTTV Pro subscriber badges. (#6625, #6724)
- Minor: Added backup restore dialog if settings fail to load. (#6662)
- Minor: Added `debug.traceback` for plugins. (#6652)
- Minor: Added title and duration options for `/clip` command. (#6669)
- Minor: Added the ability to filter on messages by the author's external badges (example: `author.external_badges contains "chatterino:Top Donator"` or `author.external_badges contains "frankerfacez:bot"`). (#6709)
+2 -2
View File
@@ -20,7 +20,7 @@ class BaseApplication : public EmptyApplication
{
public:
BaseApplication()
: settings(this->args, this->settingsDir.path())
: settings(this->args, this->settingsDir.path(), /*isTest=*/true)
, updates(this->paths_, this->settings)
, theme(this->paths_)
, fonts(this->settings)
@@ -29,7 +29,7 @@ public:
explicit BaseApplication(const QString &settingsData)
: EmptyApplication(settingsData)
, settings(this->args, this->settingsDir.path())
, settings(this->args, this->settingsDir.path(), /*isTest=*/true)
, updates(this->paths_, this->settings)
, theme(this->paths_)
, fonts(this->settings)
+4
View File
@@ -541,6 +541,8 @@ set(SOURCE_FILES
util/AbandonObject.hpp
util/AttachToConsole.cpp
util/AttachToConsole.hpp
util/Backup.cpp
util/Backup.hpp
util/BadgeRegistry.cpp
util/BadgeRegistry.hpp
util/CancellationToken.hpp
@@ -707,6 +709,8 @@ set(SOURCE_FILES
widgets/dialogs/QualityPopup.hpp
widgets/dialogs/ReplyThreadPopup.cpp
widgets/dialogs/ReplyThreadPopup.hpp
widgets/dialogs/RestoreBackupsDialog.cpp
widgets/dialogs/RestoreBackupsDialog.hpp
widgets/dialogs/SelectChannelDialog.cpp
widgets/dialogs/SelectChannelDialog.hpp
widgets/dialogs/SelectChannelFiltersDialog.cpp
+41 -2
View File
@@ -15,6 +15,7 @@
#include "controllers/nicknames/Nickname.hpp"
#include "debug/Benchmark.hpp"
#include "pajlada/settings/signalargs.hpp"
#include "util/Backup.hpp"
#include "util/WindowsHelper.hpp"
#include <pajlada/signals/scoped-connection.hpp>
@@ -22,6 +23,7 @@
namespace {
using namespace chatterino;
using namespace Qt::Literals;
template <typename T>
void initializeSignalVector(pajlada::Signals::SignalHolder &signalHolder,
@@ -148,7 +150,8 @@ bool Settings::toggleMutedChannel(const QString &channelName)
Settings *Settings::instance_ = nullptr;
Settings::Settings(const Args &args, const QString &settingsDirectory)
Settings::Settings(const Args &args, const QString &settingsDirectory,
bool isTest)
: prevInstance_(Settings::instance_)
, disableSaving(args.dontSaveSettings)
{
@@ -157,7 +160,43 @@ Settings::Settings(const Args &args, const QString &settingsDirectory)
// get global instance of the settings library
auto settingsInstance = pajlada::Settings::SettingManager::getInstance();
settingsInstance->load(qPrintable(settingsPath));
if (isTest)
{
settingsInstance->load(qPrintable(settingsPath));
}
else
{
backup::loadWithBackups(
backup::FileData{
.fileName = u"settings.json"_s,
.directory = settingsDirectory,
.fileKind = u"Settings"_s,
.fileDescription =
u"This file contains the main application settings such as accounts and hotkeys."_s,
},
[&]() -> ExpectedStr<void> {
using LoadError = pajlada::Settings::SettingManager::LoadError;
auto err = settingsInstance->load(qPrintable(settingsPath));
switch (err)
{
case LoadError::NoError:
return {}; // ok
case LoadError::CannotOpenFile:
return makeUnexpected(u"Failed to open '" %
settingsPath % '\'');
case LoadError::FileHandleError:
return makeUnexpected("File handle error");
case LoadError::FileReadError:
return makeUnexpected("Failed to read file");
case LoadError::FileSeekError:
return makeUnexpected("Failed to seek in file");
case LoadError::JSONParseError:
return makeUnexpected("File contained malformed JSON");
}
assert(false);
return makeUnexpected("Unknown error");
});
}
settingsInstance->setBackupEnabled(true);
settingsInstance->setBackupSlots(9);
+2 -1
View File
@@ -127,7 +127,8 @@ class Settings
bool disableSaving;
public:
Settings(const Args &args, const QString &settingsDirectory);
Settings(const Args &args, const QString &settingsDirectory,
bool isTest = false);
~Settings();
static Settings &instance();
+140
View File
@@ -0,0 +1,140 @@
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT
#include "util/Backup.hpp"
#include "common/QLogging.hpp"
#include "util/Expected.hpp"
#include "util/FilesystemHelpers.hpp"
#include "widgets/dialogs/RestoreBackupsDialog.hpp"
#include <pajlada/settings/settingmanager.hpp>
#include <QDir>
#include <QRegularExpression>
#include <algorithm>
namespace {
QRegularExpression regexForFile(const QString &file)
{
return QRegularExpression(
QStringView(u"^%1\\.bkp-\\d+$").arg(QRegularExpression::escape(file)));
}
bool anyBackupsOf(const QString &directory, const QString &filename)
{
QDir fileDir(directory);
if (!fileDir.exists())
{
return false;
}
auto regex = regexForFile(filename);
return std::ranges::any_of(fileDir.entryList(QDir::Files),
[&](const auto &entry) {
return regex.match(entry).hasMatch();
});
}
} // namespace
namespace chatterino::backup {
std::vector<BackupFile> findBackupsFor(const QString &directory,
const QString &filename)
{
QDir fileDir(directory);
if (!fileDir.exists())
{
return {};
}
auto dst = qStringToStdPath(fileDir.filePath(filename));
auto regex = regexForFile(filename);
std::vector<BackupFile> backups;
const auto entries = fileDir.entryInfoList(QDir::Files, QDir::Time);
auto testSM = pajlada::Settings::SettingManager();
testSM.saveMethod =
pajlada::Settings::SettingManager::SaveMethod::SaveManually;
for (const auto &entry : entries)
{
if (!regex.match(entry.fileName()).hasMatch())
{
continue;
}
auto canonicalPath = entry.filesystemCanonicalFilePath();
BackupState state = BackupState::UnableToRead;
using LoadError = pajlada::Settings::SettingManager::LoadError;
auto res = testSM.loadFrom(canonicalPath);
switch (res)
{
case LoadError::NoError:
state = BackupState::Ok;
break;
case LoadError::CannotOpenFile:
case LoadError::FileHandleError:
case LoadError::FileReadError:
case LoadError::FileSeekError:
state = BackupState::UnableToRead;
break;
case LoadError::JSONParseError:
state = BackupState::BadContents;
break;
case LoadError::SavingFromTemporaryFileFailed:
// should never happen, temporary file loading/saving is not enabled
assert(false);
break;
}
backups.emplace_back(BackupFile{
.path = canonicalPath,
.dstPath = dst,
.lastModified = entry.lastModified(),
.fileSize = entry.size(),
.state = state,
});
}
return backups;
}
void loadWithBackups(const FileData &fileData,
const std::function<ExpectedStr<void>()> &load)
{
while (true)
{
auto loadResult = load();
if (loadResult)
{
return;
}
qCDebug(chatterinoSettings)
<< fileData.fileKind << "failed to load:" << loadResult.error();
if (!anyBackupsOf(fileData.directory, fileData.fileName))
{
qCDebug(chatterinoSettings)
<< "No backups for" << fileData.fileKind;
return;
}
auto *diag = new RestoreBackupsDialog(fileData, loadResult.error());
auto ret = diag->exec(); // we need to use exec here to block
if (ret != QDialog::Accepted)
{
return; // rejected -> don't retry
}
qCDebug(chatterinoSettings) << "Retrying to load" << fileData.fileKind;
}
}
} // namespace chatterino::backup
+64
View File
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT
#pragma once
#include "util/Expected.hpp"
#include <QDateTime>
#include <QString>
#include <filesystem>
#include <vector>
class QJsonValue;
namespace chatterino {
class Paths;
} // namespace chatterino
namespace chatterino::backup {
enum class BackupState : uint8_t {
/// The backup contains valid JSON
Ok,
/// The backup could not be read (e.g. invalid file permissions)
UnableToRead,
/// The backup contains invalid JSON
BadContents,
};
/// A backup file (e.g. `settings.json.bkp-7`) and its state.
struct BackupFile {
std::filesystem::path path;
std::filesystem::path dstPath;
QDateTime lastModified;
qint64 fileSize = 0;
BackupState state = BackupState::Ok;
};
/// Specifies where to load the file from and descriptions about the file and its contents.
struct FileData {
/// "settings.json", "window-layout.json"
QString fileName;
QString directory;
/// "Settings", "Window layout" etc.
QString fileKind;
/// "This file stores..."
QString fileDescription;
};
/// Find a list of backups for the given `filename` in the given `directory`.
std::vector<BackupFile> findBackupsFor(const QString &directory,
const QString &filename);
/// Attempt to load the file described in `fileData` using the `load` param.
///
/// If the load fails and any backups are available, spawn a restore backups dialog.
void loadWithBackups(const FileData &fileData,
const std::function<ExpectedStr<void>()> &load);
} // namespace chatterino::backup
Q_DECLARE_METATYPE(chatterino::backup::BackupFile);
@@ -0,0 +1,227 @@
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT
#include "widgets/dialogs/RestoreBackupsDialog.hpp"
#include "common/QLogging.hpp"
#include "util/Backup.hpp"
#include "util/FilesystemHelpers.hpp"
#include "util/FormatTime.hpp"
#include <QApplication>
#include <QComboBox>
#include <QDesktopServices>
#include <QDialogButtonBox>
#include <QKeyEvent>
#include <QLabel>
#include <QLocale>
#include <QMessageBox>
#include <QPushButton>
#include <QString>
#include <QStringBuilder>
#include <QVBoxLayout>
#include <chrono>
#include <filesystem>
using namespace Qt::Literals;
namespace {
void closeApp()
{
// Using a force exit over QApplication::exit, because we're currently in
// the initialization. QApplication::exit only tells the eventloops to exit,
// but it returns to the caller. If we return, we'd continue with the
// initialization, which could cause settings to be loaded/overwritten.
_Exit(1);
}
} // namespace
namespace chatterino {
RestoreBackupsDialog::RestoreBackupsDialog(backup::FileData fileData,
const QString &prevError,
QWidget *parent)
: QDialog(parent,
QFlags{
// same as QMessageBox
Qt::Dialog,
Qt::MSWindowsFixedSizeDialogHint,
// Disable default style
Qt::CustomizeWindowHint,
// Show window title
Qt::WindowTitleHint,
// Show minimize button
Qt::WindowMinimizeButtonHint,
})
, fileData(std::move(fileData))
, backupCombo(new QComboBox)
, showButton(u"Show"_s)
, corruptedBackupsWarning(
u"Some backups are damaged or otherwise unreadable."_s)
{
this->setAttribute(Qt::WA_DeleteOnClose);
this->setWindowTitle(u"Chatterino - Restore Backup of " %
this->fileData.fileKind % '?');
auto *layout = new QVBoxLayout(this);
auto *description =
new QLabel(u"Chatterino " % this->fileData.fileKind.toLower() %
u" failed to load: " % prevError % u"<p>" %
this->fileData.fileDescription %
u"<p>There are backups of this file.<br>Do you want to "
u"restore the selected backup?");
layout->addWidget(description);
auto *hbox = new QHBoxLayout;
hbox->addWidget(&this->backupCombo, 1);
hbox->addWidget(&this->showButton);
layout->addLayout(hbox);
this->corruptedBackupsWarning.hide();
layout->addWidget(&this->corruptedBackupsWarning);
layout->addSpacing(10);
auto *buttons = new QDialogButtonBox;
layout->addWidget(buttons);
auto *restoreButton = buttons->addButton(QDialogButtonBox::Yes);
restoreButton->setText(u"Restore Backup"_s);
auto *ignoreBtn = buttons->addButton(QDialogButtonBox::No);
// Qt has StandardButton::Ignore, but that button has the AcceptRole
ignoreBtn->setText(u"Ignore"_s);
auto *abortBtn = buttons->addButton(QDialogButtonBox::Abort);
QObject::connect(restoreButton, &QAbstractButton::clicked, this, [this] {
auto selected = this->backupCombo.currentData();
auto *data = get_if<backup::BackupFile>(&selected);
if (!data)
{
return;
}
bool retry = true;
while (retry)
{
retry = false;
std::error_code ec;
qCDebug(chatterinoSettings)
<< "Copying" << stdPathToQString(data->path) << "to"
<< stdPathToQString(data->dstPath);
if (!std::filesystem::copy_file(
data->path, data->dstPath,
std::filesystem::copy_options::overwrite_existing, ec))
{
retry = QMessageBox::critical(
this, "Failed to restore file",
u"Failed to copy '%1' to '%2': %3"_s.arg(
stdPathToQString(data->path),
stdPathToQString(data->dstPath),
QString::fromStdString(ec.message())),
QMessageBox::Retry | QMessageBox::Ok) ==
QMessageBox::Retry;
}
}
this->hasChosenAnything = true;
this->accept();
});
QObject::connect(ignoreBtn, &QAbstractButton::clicked, this, [this] {
auto res = QMessageBox::question(
this, u"Chatterino - Discard Backup?"_s,
u"Are you sure you want to discard the backup? Doing so will "_s
"overwrite and discard any previous settings.");
if (res == QMessageBox::Yes)
{
this->hasChosenAnything = true;
this->reject();
}
});
QObject::connect(abortBtn, &QAbstractButton::clicked, this, &closeApp);
QObject::connect(&this->showButton, &QPushButton::clicked, this, [this] {
auto selected = this->backupCombo.currentData();
auto *data = get_if<backup::BackupFile>(&selected);
if (!data)
{
return;
}
auto url = QUrl::fromLocalFile(stdPathToQString(data->path));
QDesktopServices::openUrl(url);
});
this->refreshBackups();
#ifdef Q_OS_LINUX
// Needed for Sway to make the dialog floating. See
// https://github.com/swaywm/sway/issues/3095
this->layout()->activate();
this->setFixedSize(this->layout()->totalMinimumSize());
#endif
}
void RestoreBackupsDialog::closeEvent(QCloseEvent * /*event*/)
{
if (!this->hasChosenAnything)
{
closeApp();
}
}
void RestoreBackupsDialog::keyPressEvent(QKeyEvent *event)
{
event->ignore();
// Don't call QDialog here, as it would handle QKeySequence::Cancel, Enter,
// and Return.
}
void RestoreBackupsDialog::refreshBackups()
{
this->backupCombo.clear();
auto availableBackups = backup::findBackupsFor(this->fileData.directory,
this->fileData.fileName);
auto dtf = QLocale::system().dateTimeFormat(QLocale::ShortFormat);
auto now = QDateTime::currentDateTime();
bool anyCorrupt = false;
for (const auto &backup : availableBackups)
{
if (backup.state != backup::BackupState::Ok)
{
anyCorrupt = true;
continue;
}
QString itemStr = stdPathToQString(backup.path.filename());
itemStr += u" (";
itemStr += backup.lastModified.toString(dtf);
auto timeDiff = std::chrono::duration_cast<std::chrono::seconds>(
now - backup.lastModified);
if (timeDiff.count() > 0)
{
itemStr += u" - ";
itemStr += formatTime(timeDiff);
itemStr += u" ago";
}
itemStr += ')';
this->backupCombo.addItem(itemStr, QVariant::fromValue(backup));
}
this->corruptedBackupsWarning.setVisible(anyCorrupt);
bool anyBackups = this->backupCombo.count() > 0;
auto *okButton = this->dialogButtons.button(QDialogButtonBox::Ok);
if (okButton)
{
okButton->setEnabled(anyBackups);
}
this->showButton.setEnabled(anyBackups);
}
} // namespace chatterino
@@ -0,0 +1,45 @@
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT
#pragma once
#include "util/Backup.hpp"
#include "util/Expected.hpp"
#include <QComboBox>
#include <QDialog>
#include <QDialogButtonBox>
#include <QLabel>
#include <QPushButton>
namespace chatterino {
class Paths;
class RestoreBackupsDialog : public QDialog
{
public:
RestoreBackupsDialog(backup::FileData fileData, const QString &prevError,
QWidget *parent = nullptr);
protected:
void closeEvent(QCloseEvent * /*event*/) override;
void keyPressEvent(QKeyEvent *event) override;
private:
void refreshBackups();
backup::FileData *selectedFileData() const;
backup::FileData fileData;
QComboBox backupCombo;
QPushButton showButton;
QDialogButtonBox dialogButtons;
QLabel corruptedBackupsWarning;
bool hasChosenAnything = false;
};
} // namespace chatterino