Skip to content

Commit 79e5d29

Browse files
Nerixyzpajlada
andauthored
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>
1 parent 2b4ede1 commit 79e5d29

File tree

9 files changed

+526
-5
lines changed

9 files changed

+526
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
- Minor: Added broadcaster-only `/poll`, `/cancelpoll`, and `/endpoll` commands. (#6583, #6605)
2323
- Minor: Added broadcaster-only `/prediction`, `/cancelprediction`, `/lockprediction`, and `/completeprediction` commands. (#6583, #6612, #6632, #6749)
2424
- Minor: Added support for BetterTTV Pro subscriber badges. (#6625, #6724)
25+
- Minor: Added backup restore dialog if settings fail to load. (#6662)
2526
- Minor: Added `debug.traceback` for plugins. (#6652)
2627
- Minor: Added title and duration options for `/clip` command. (#6669)
2728
- 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)

mocks/include/mocks/BaseApplication.hpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class BaseApplication : public EmptyApplication
2020
{
2121
public:
2222
BaseApplication()
23-
: settings(this->args, this->settingsDir.path())
23+
: settings(this->args, this->settingsDir.path(), /*isTest=*/true)
2424
, updates(this->paths_, this->settings)
2525
, theme(this->paths_)
2626
, fonts(this->settings)
@@ -29,7 +29,7 @@ class BaseApplication : public EmptyApplication
2929

3030
explicit BaseApplication(const QString &settingsData)
3131
: EmptyApplication(settingsData)
32-
, settings(this->args, this->settingsDir.path())
32+
, settings(this->args, this->settingsDir.path(), /*isTest=*/true)
3333
, updates(this->paths_, this->settings)
3434
, theme(this->paths_)
3535
, fonts(this->settings)

src/CMakeLists.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,8 @@ set(SOURCE_FILES
541541
util/AbandonObject.hpp
542542
util/AttachToConsole.cpp
543543
util/AttachToConsole.hpp
544+
util/Backup.cpp
545+
util/Backup.hpp
544546
util/BadgeRegistry.cpp
545547
util/BadgeRegistry.hpp
546548
util/CancellationToken.hpp
@@ -707,6 +709,8 @@ set(SOURCE_FILES
707709
widgets/dialogs/QualityPopup.hpp
708710
widgets/dialogs/ReplyThreadPopup.cpp
709711
widgets/dialogs/ReplyThreadPopup.hpp
712+
widgets/dialogs/RestoreBackupsDialog.cpp
713+
widgets/dialogs/RestoreBackupsDialog.hpp
710714
widgets/dialogs/SelectChannelDialog.cpp
711715
widgets/dialogs/SelectChannelDialog.hpp
712716
widgets/dialogs/SelectChannelFiltersDialog.cpp

src/singletons/Settings.cpp

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
#include "controllers/nicknames/Nickname.hpp"
1616
#include "debug/Benchmark.hpp"
1717
#include "pajlada/settings/signalargs.hpp"
18+
#include "util/Backup.hpp"
1819
#include "util/WindowsHelper.hpp"
1920

2021
#include <pajlada/signals/scoped-connection.hpp>
2122

2223
namespace {
2324

2425
using namespace chatterino;
26+
using namespace Qt::Literals;
2527

2628
template <typename T>
2729
void initializeSignalVector(pajlada::Signals::SignalHolder &signalHolder,
@@ -148,7 +150,8 @@ bool Settings::toggleMutedChannel(const QString &channelName)
148150

149151
Settings *Settings::instance_ = nullptr;
150152

151-
Settings::Settings(const Args &args, const QString &settingsDirectory)
153+
Settings::Settings(const Args &args, const QString &settingsDirectory,
154+
bool isTest)
152155
: prevInstance_(Settings::instance_)
153156
, disableSaving(args.dontSaveSettings)
154157
{
@@ -157,7 +160,43 @@ Settings::Settings(const Args &args, const QString &settingsDirectory)
157160
// get global instance of the settings library
158161
auto settingsInstance = pajlada::Settings::SettingManager::getInstance();
159162

160-
settingsInstance->load(qPrintable(settingsPath));
163+
if (isTest)
164+
{
165+
settingsInstance->load(qPrintable(settingsPath));
166+
}
167+
else
168+
{
169+
backup::loadWithBackups(
170+
backup::FileData{
171+
.fileName = u"settings.json"_s,
172+
.directory = settingsDirectory,
173+
.fileKind = u"Settings"_s,
174+
.fileDescription =
175+
u"This file contains the main application settings such as accounts and hotkeys."_s,
176+
},
177+
[&]() -> ExpectedStr<void> {
178+
using LoadError = pajlada::Settings::SettingManager::LoadError;
179+
auto err = settingsInstance->load(qPrintable(settingsPath));
180+
switch (err)
181+
{
182+
case LoadError::NoError:
183+
return {}; // ok
184+
case LoadError::CannotOpenFile:
185+
return makeUnexpected(u"Failed to open '" %
186+
settingsPath % '\'');
187+
case LoadError::FileHandleError:
188+
return makeUnexpected("File handle error");
189+
case LoadError::FileReadError:
190+
return makeUnexpected("Failed to read file");
191+
case LoadError::FileSeekError:
192+
return makeUnexpected("Failed to seek in file");
193+
case LoadError::JSONParseError:
194+
return makeUnexpected("File contained malformed JSON");
195+
}
196+
assert(false);
197+
return makeUnexpected("Unknown error");
198+
});
199+
}
161200

162201
settingsInstance->setBackupEnabled(true);
163202
settingsInstance->setBackupSlots(9);

src/singletons/Settings.hpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ class Settings
127127
bool disableSaving;
128128

129129
public:
130-
Settings(const Args &args, const QString &settingsDirectory);
130+
Settings(const Args &args, const QString &settingsDirectory,
131+
bool isTest = false);
131132
~Settings();
132133

133134
static Settings &instance();

src/util/Backup.cpp

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
#include "util/Backup.hpp"
6+
7+
#include "common/QLogging.hpp"
8+
#include "util/Expected.hpp"
9+
#include "util/FilesystemHelpers.hpp"
10+
#include "widgets/dialogs/RestoreBackupsDialog.hpp"
11+
12+
#include <pajlada/settings/settingmanager.hpp>
13+
#include <QDir>
14+
#include <QRegularExpression>
15+
16+
#include <algorithm>
17+
18+
namespace {
19+
20+
QRegularExpression regexForFile(const QString &file)
21+
{
22+
return QRegularExpression(
23+
QStringView(u"^%1\\.bkp-\\d+$").arg(QRegularExpression::escape(file)));
24+
}
25+
26+
bool anyBackupsOf(const QString &directory, const QString &filename)
27+
{
28+
QDir fileDir(directory);
29+
if (!fileDir.exists())
30+
{
31+
return false;
32+
}
33+
34+
auto regex = regexForFile(filename);
35+
return std::ranges::any_of(fileDir.entryList(QDir::Files),
36+
[&](const auto &entry) {
37+
return regex.match(entry).hasMatch();
38+
});
39+
}
40+
41+
} // namespace
42+
43+
namespace chatterino::backup {
44+
45+
std::vector<BackupFile> findBackupsFor(const QString &directory,
46+
const QString &filename)
47+
{
48+
QDir fileDir(directory);
49+
if (!fileDir.exists())
50+
{
51+
return {};
52+
}
53+
auto dst = qStringToStdPath(fileDir.filePath(filename));
54+
55+
auto regex = regexForFile(filename);
56+
std::vector<BackupFile> backups;
57+
const auto entries = fileDir.entryInfoList(QDir::Files, QDir::Time);
58+
auto testSM = pajlada::Settings::SettingManager();
59+
testSM.saveMethod =
60+
pajlada::Settings::SettingManager::SaveMethod::SaveManually;
61+
62+
for (const auto &entry : entries)
63+
{
64+
if (!regex.match(entry.fileName()).hasMatch())
65+
{
66+
continue;
67+
}
68+
auto canonicalPath = entry.filesystemCanonicalFilePath();
69+
70+
BackupState state = BackupState::UnableToRead;
71+
using LoadError = pajlada::Settings::SettingManager::LoadError;
72+
73+
auto res = testSM.loadFrom(canonicalPath);
74+
switch (res)
75+
{
76+
case LoadError::NoError:
77+
state = BackupState::Ok;
78+
break;
79+
80+
case LoadError::CannotOpenFile:
81+
case LoadError::FileHandleError:
82+
case LoadError::FileReadError:
83+
case LoadError::FileSeekError:
84+
state = BackupState::UnableToRead;
85+
break;
86+
87+
case LoadError::JSONParseError:
88+
state = BackupState::BadContents;
89+
break;
90+
91+
case LoadError::SavingFromTemporaryFileFailed:
92+
// should never happen, temporary file loading/saving is not enabled
93+
assert(false);
94+
break;
95+
}
96+
97+
backups.emplace_back(BackupFile{
98+
.path = canonicalPath,
99+
.dstPath = dst,
100+
.lastModified = entry.lastModified(),
101+
.fileSize = entry.size(),
102+
.state = state,
103+
});
104+
}
105+
106+
return backups;
107+
}
108+
109+
void loadWithBackups(const FileData &fileData,
110+
const std::function<ExpectedStr<void>()> &load)
111+
{
112+
while (true)
113+
{
114+
auto loadResult = load();
115+
if (loadResult)
116+
{
117+
return;
118+
}
119+
qCDebug(chatterinoSettings)
120+
<< fileData.fileKind << "failed to load:" << loadResult.error();
121+
122+
if (!anyBackupsOf(fileData.directory, fileData.fileName))
123+
{
124+
qCDebug(chatterinoSettings)
125+
<< "No backups for" << fileData.fileKind;
126+
return;
127+
}
128+
129+
auto *diag = new RestoreBackupsDialog(fileData, loadResult.error());
130+
auto ret = diag->exec(); // we need to use exec here to block
131+
if (ret != QDialog::Accepted)
132+
{
133+
return; // rejected -> don't retry
134+
}
135+
136+
qCDebug(chatterinoSettings) << "Retrying to load" << fileData.fileKind;
137+
}
138+
}
139+
140+
} // namespace chatterino::backup

src/util/Backup.hpp

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
#pragma once
6+
7+
#include "util/Expected.hpp"
8+
9+
#include <QDateTime>
10+
#include <QString>
11+
12+
#include <filesystem>
13+
#include <vector>
14+
15+
class QJsonValue;
16+
17+
namespace chatterino {
18+
class Paths;
19+
} // namespace chatterino
20+
21+
namespace chatterino::backup {
22+
23+
enum class BackupState : uint8_t {
24+
/// The backup contains valid JSON
25+
Ok,
26+
/// The backup could not be read (e.g. invalid file permissions)
27+
UnableToRead,
28+
/// The backup contains invalid JSON
29+
BadContents,
30+
};
31+
32+
/// A backup file (e.g. `settings.json.bkp-7`) and its state.
33+
struct BackupFile {
34+
std::filesystem::path path;
35+
std::filesystem::path dstPath;
36+
QDateTime lastModified;
37+
qint64 fileSize = 0;
38+
BackupState state = BackupState::Ok;
39+
};
40+
41+
/// Specifies where to load the file from and descriptions about the file and its contents.
42+
struct FileData {
43+
/// "settings.json", "window-layout.json"
44+
QString fileName;
45+
QString directory;
46+
/// "Settings", "Window layout" etc.
47+
QString fileKind;
48+
/// "This file stores..."
49+
QString fileDescription;
50+
};
51+
52+
/// Find a list of backups for the given `filename` in the given `directory`.
53+
std::vector<BackupFile> findBackupsFor(const QString &directory,
54+
const QString &filename);
55+
56+
/// Attempt to load the file described in `fileData` using the `load` param.
57+
///
58+
/// If the load fails and any backups are available, spawn a restore backups dialog.
59+
void loadWithBackups(const FileData &fileData,
60+
const std::function<ExpectedStr<void>()> &load);
61+
62+
} // namespace chatterino::backup
63+
64+
Q_DECLARE_METATYPE(chatterino::backup::BackupFile);

0 commit comments

Comments
 (0)