[Themes] Route theme writes to the user themes directory

setColorScheme()/setStyleName() and the palette editor wrote directly to
the resolved theme directory, which for built-in themes is the read-only
system (install) location. Changes therefore landed in the install dir and
were lost on upgrade.

Add ThemeManager::writableThemeDir(), which always resolves to the user
themes directory, and route all theme writes through it. The palette editor
reuses the same helper, dropping its private writability probe.
This commit is contained in:
Lukas Brübach 2026-09-11 20:34:48 +02:00 committed by GitHub
parent 7549f9c2cf
commit a127ff7e1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 39 additions and 36 deletions

View file

@ -1,6 +1,5 @@
#include "palette_editor_dialog.h" #include "palette_editor_dialog.h"
#include "../../client/settings/cache_settings.h"
#include "../theme_manager.h" #include "../theme_manager.h"
#include "palette_generator.h" #include "palette_generator.h"
#include "palette_grid_widget.h" #include "palette_grid_widget.h"
@ -11,31 +10,11 @@
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo>
#include <QFrame> #include <QFrame>
#include <QGuiApplication>
#include <QLabel> #include <QLabel>
#include <QLoggingCategory>
#include <QMessageBox> #include <QMessageBox>
#include <QPushButton> #include <QPushButton>
#include <QStyleHints>
#include <QTimer> #include <QTimer>
#include <libcockatrice/settings/paths_settings.h>
// Probe whether a directory is truly writable by trying to create and remove a
// temporary file. QFileInfo::isWritable() on a directory is unreliable (notably
// on Windows where UAC VirtualStore can make a system dir appear writable).
static bool isDirReallyWritable(const QString &dirPath)
{
const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test");
QFile f(probe);
if (!f.open(QIODevice::WriteOnly)) {
return false;
}
f.close();
f.remove();
return true;
}
PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent) PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent)
: QDialog(parent), themeDirPath(_themeDirPath), themeName(_themeName) : QDialog(parent), themeDirPath(_themeDirPath), themeName(_themeName)
@ -46,14 +25,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt
// Resolve a writable directory for saving. Built-in (Default / Fusion) and // Resolve a writable directory for saving. Built-in (Default / Fusion) and
// other read-only theme directories must be customised in the user-writable // other read-only theme directories must be customised in the user-writable
// themes directory; otherwise the write would fail or be lost on upgrade. // themes directory; otherwise the write would fail or be lost on upgrade.
if (!themeDirPath.isEmpty() && isDirReallyWritable(themeDirPath)) { saveDir = ThemeManager::writableThemeDir(themeName);
saveDir = themeDirPath;
} else {
saveDir = QDir(SettingsCache::instance().paths().getThemesPath()).absoluteFilePath(themeName);
if (!QDir().mkpath(saveDir)) {
qWarning() << "Failed to create palette save directory:" << saveDir;
}
}
// Load both scheme configs upfront so switching is instant // Load both scheme configs upfront so switching is instant
loadSchemes(); loadSchemes();
@ -214,7 +186,7 @@ void PaletteEditorDialog::retranslateUi()
resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette")); resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette"));
saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower())); saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower()));
if (saveDir.isEmpty() || !isDirReallyWritable(saveDir)) { if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) {
saveBtn->setEnabled(false); saveBtn->setEnabled(false);
saveBtn->setToolTip(tr("Cannot save: this theme has no writable directory")); saveBtn->setToolTip(tr("Cannot save: this theme has no writable directory"));
} }

View file

@ -6,6 +6,7 @@
#include <QApplication> #include <QApplication>
#include <QColor> #include <QColor>
#include <QDebug> #include <QDebug>
#include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QLibraryInfo> #include <QLibraryInfo>
#include <QMap> #include <QMap>
@ -184,11 +185,32 @@ QString ThemeManager::assetPath(QStringView prefix) const
return resolvedPlain.isEmpty() ? prefix.toString() : resolvedPlain; return resolvedPlain.isEmpty() ? prefix.toString() : resolvedPlain;
} }
bool ThemeManager::isBuiltInTheme() // Probe whether a directory is truly writable by trying to create and remove a
// temporary file. QFileInfo::isWritable() on a directory is unreliable (notably
// on Windows where UAC VirtualStore can make a system dir appear writable).
bool ThemeManager::isDirReallyWritable(const QString &dirPath)
{ {
const auto themeName = SettingsCache::instance().getThemeName(); const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test");
QFile f(probe);
if (!f.open(QIODevice::WriteOnly)) {
return false;
}
f.close();
f.remove();
return true;
}
return themeName == NONE_THEME_NAME || themeName == FUSION_THEME_NAME; QString ThemeManager::writableThemeDir(const QString &themeName)
{
// All theme writes go to the user themes directory regardless of whether
// the resolved (system) theme directory happens to be writable. Even when a
// write would succeed in-place, routing it to the user directory keeps the
// install intact and guarantees changes survive upgrades.
const QString dirPath = QDir(SettingsCache::instance().paths().getThemesPath()).absoluteFilePath(themeName);
if (!QDir().mkpath(dirPath)) {
qWarning() << "Failed to create theme save directory:" << dirPath;
}
return dirPath;
} }
// System (read-only) themes location, relative to the application binary. // System (read-only) themes location, relative to the application binary.
@ -331,7 +353,7 @@ bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &col
void ThemeManager::setColorScheme(const QString &scheme) void ThemeManager::setColorScheme(const QString &scheme)
{ {
const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName());
ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath);
cfg.colorScheme = scheme; cfg.colorScheme = scheme;
@ -342,7 +364,7 @@ void ThemeManager::setColorScheme(const QString &scheme)
void ThemeManager::setStyleName(const QString &styleName) void ThemeManager::setStyleName(const QString &styleName)
{ {
const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName());
ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath);
cfg.styleName = styleName; cfg.styleName = styleName;

View file

@ -66,7 +66,16 @@ protected:
const QString &activeScheme); const QString &activeScheme);
public: public:
bool isBuiltInTheme(); // Resolves the directory to write theme changes to for the given theme
// name. The resolved theme dir (user or system) is used when writable;
// read-only system themes fall back to the user themes directory, creating
// it if needed, so customisations never get lost on upgrade.
static QString writableThemeDir(const QString &themeName);
// Probe whether a directory is truly writable by trying to create and remove
// a temporary file. QFileInfo::isWritable() on a directory is unreliable
// (notably on Windows where UAC VirtualStore can make a system dir appear
// writable).
static bool isDirReallyWritable(const QString &dirPath);
// Explicit color scheme of the theme: theme.cfg's ColorScheme setting // Explicit color scheme of the theme: theme.cfg's ColorScheme setting
// (Dark/Light), falling back to the OS color scheme when it is "System". // (Dark/Light), falling back to the OS color scheme when it is "System".
bool isDarkMode(const QString &themeDirPath) const; bool isDarkMode(const QString &themeDirPath) const;