Skip to content

Commit

Permalink
Add portable support (Mudlet#7375)
Browse files Browse the repository at this point in the history
/claim Mudlet#888

#### Brief overview of PR changes/additions

This PR:
- refactors Mudlet initialisation code to use a single source of truth
for config directory paths
- change QSettings storage to an `IniFormat` file in the config
directory and adds migration code from old formats
- adds support for a `<executable_dir>/portable.txt` marker which sets
the config directory to `<executable_dir>/portable`

#### Motivation for adding to Mudlet

This PR teaches Mudlet to store data portably.

#### Usage Instructions

If you're interested in trying out this PR, you can download the CI
builds from the **add-deployment-links** bot below.

There are currently two ways to enable portable mode for Mudlet (in
order of importance):
- You can create an empty `portable.txt` file in the same folder as the
Mudlet executable (or appimage etc.)
- This will tell Mudlet to use a folder named `portable` (in the same
folder as the executable) for its data
- You can create an `~/.config/mudlet/portable.txt` file with its
contents being a path on your filesystem
- This will tell Mudlet to use the path written in the file as the
folder for its data
- e.g. the contents could be `/mount/media/flashdrive/mudlet_data` or
`D:\games\portable\mudlet_data`
- The path can be relative, in which case it will be interpreted
relative to the Mudlet executable's folder
- Mudlet will create _exactly_ one folder, that is, the last part of the
given path, if it doesn't exist already. At least everything up to its
parent folder must exist already or it will lead to an error.
- e.g. if given `D:\games\portable\mudlet_data`, at least
`D:\games\portable` must be an existing folder
- This is to avoid taking unintended input. If you see this error but
this actually is what you want, just create those folders manually

Any errors will result in the issue being printed to stderr and the
program terminating. You probably won't see the error outputs if
launched from GUI so it's recommended to start Mudlet from the terminal.

Ofc when you first launch Mudlet in portable mode, it will start with a
new clean config in the respective folder, just like a new install. If
you wish to migrate your existing config data to be portable:
- You need to launch this build at least once (without any
`portable.txt`)
- This is because it needs to migrate config files from the old format
to the new, portable-friendly one
- Then you can just copy/move your default data directory
`~/.config/mudlet` to wherever you want and use one of the above
`portable.txt`s to point Mudlet to that path.
  • Loading branch information
ConcurrentCrab authored Aug 10, 2024
1 parent 10fedaf commit d8cd226
Show file tree
Hide file tree
Showing 6 changed files with 155 additions and 57 deletions.
2 changes: 1 addition & 1 deletion src/dlgProfilePreferences.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3385,7 +3385,7 @@ void dlgProfilePreferences::slot_tabChanged(int tabIndex)
return;
}

QSettings settings("mudlet", "Mudlet");
QSettings& settings = *mudlet::getQSettings();
const QString themesURL = settings.value("colorSublimeThemesURL", qsl("https://github.com/Colorsublime/Colorsublime-Themes/archive/master.zip")).toString();
// a default update period is 24h
// it would be nice to use C++14's numeric separator but Qt Creator still
Expand Down
15 changes: 2 additions & 13 deletions src/dlgTriggerEditor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -964,14 +964,7 @@ void dlgTriggerEditor::closeEvent(QCloseEvent* event)

void dlgTriggerEditor::readSettings()
{
/* In case sensitive environments, two different config directories
were used: "Mudlet" for QSettings, and "mudlet" anywhere else.
Furthermore, we skip the version from the application name to follow the convention.
For compatibility with older settings, if no config is loaded
from the config directory "mudlet", application "Mudlet", we try to load from the config
directory "Mudlet", application "Mudlet 1.0". */
const QSettings settings_new("mudlet", "Mudlet");
const QSettings settings((settings_new.contains("pos") ? "mudlet" : "Mudlet"), (settings_new.contains("pos") ? "Mudlet" : "Mudlet 1.0"));
QSettings& settings = *mudlet::getQSettings();

const QPoint pos = settings.value("script_editor_pos", QPoint(10, 10)).toPoint();
const QSize size = settings.value("script_editor_size", QSize(600, 400)).toSize();
Expand All @@ -991,11 +984,7 @@ void dlgTriggerEditor::readSettings()

void dlgTriggerEditor::writeSettings()
{
/* In case sensitive environments, two different config directories
were used: "Mudlet" for QSettings, and "mudlet" anywhere else. We change the QSettings directory
(the organization name) to "mudlet".
Furthermore, we skip the version from the application name to follow the convention.*/
QSettings settings("mudlet", "Mudlet");
QSettings& settings = *mudlet::getQSettings();
settings.setValue("script_editor_pos", pos());
settings.setValue("script_editor_size", size());
settings.setValue("autosaveIntervalMinutes", mAutosaveInterval);
Expand Down
10 changes: 6 additions & 4 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,7 @@ void removeOldNoteColorEmojiFonts()

QTranslator* loadTranslationsForCommandLine()
{
const QSettings settings_new(QLatin1String("mudlet"), QLatin1String("Mudlet"));
auto pSettings = new QSettings((settings_new.contains(QLatin1String("pos")) ? QLatin1String("mudlet") : QLatin1String("Mudlet")),
(settings_new.contains(QLatin1String("pos")) ? QLatin1String("Mudlet") : QLatin1String("Mudlet 1.0")));
QSettings* pSettings = mudlet::getQSettings();
auto interfaceLanguage = pSettings->value(QLatin1String("interfaceLanguage")).toString();
auto userLocale = interfaceLanguage.isEmpty() ? QLocale::system() : QLocale(interfaceLanguage);
if (userLocale == QLocale::c()) {
Expand Down Expand Up @@ -247,6 +245,10 @@ int main(int argc, char* argv[])
app->setApplicationVersion(QString(APP_VERSION) + appBuild);
}

mudlet::start();
// Detect config path before any files are read
mudlet::self()->setupConfig();

QPointer<QTranslator> commandLineTranslator(loadTranslationsForCommandLine());
QCommandLineParser parser;
// The third (and fourth if provided) arguments are used to populate the
Expand Down Expand Up @@ -618,7 +620,7 @@ int main(int argc, char* argv[])
}
#endif

mudlet::start();
mudlet::self()->init();

#if defined(Q_OS_WIN)
// Associate mudlet with .mpackage files
Expand Down
166 changes: 128 additions & 38 deletions src/mudlet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ bool TConsoleMonitor::eventFilter(QObject* obj, QEvent* event)

mudlet::mudlet()
: QMainWindow()
{
// Initialisation happens later in setupConfig() and init()
}

void mudlet::init()
{
smFirstLaunch = !QFile::exists(mudlet::getMudletPath(mudlet::profilesPath));

Expand All @@ -145,7 +150,6 @@ mudlet::mudlet()
scmVersion = qsl("Mudlet ") + QString(APP_VERSION) + gitSha;

mShowIconsOnMenuOriginally = !qApp->testAttribute(Qt::AA_DontShowIconsInMenus);
mpSettings = getQSettings();
readEarlySettings(*mpSettings);

if (mShowIconsOnMenuCheckedState != Qt::PartiallyChecked) {
Expand Down Expand Up @@ -623,7 +627,6 @@ mudlet::mudlet()
mpShortcutsManager->registerShortcut(qsl("Reconnect"), tr("Reconnect"), &mKeySequenceReconnect);
mpShortcutsManager->registerShortcut(qsl("Close profile"), tr("Close profile"), &mKeySequenceCloseProfile);

mpSettings = getQSettings();
readLateSettings(*mpSettings);
// The previous line will set an option used in the slot method:
connect(mpMainToolBar, &QToolBar::visibilityChanged, this, &mudlet::slot_handleToolbarVisibilityChanged);
Expand Down Expand Up @@ -691,16 +694,105 @@ mudlet::mudlet()
// });
}

QSettings* mudlet::getQSettings()
static QString findExecutableDir()
{
// Linux AppImage support
QProcessEnvironment systemEnvironment = QProcessEnvironment::systemEnvironment();
if (systemEnvironment.contains(qsl("APPIMAGE"))) {
QString appimgPath = systemEnvironment.value(qsl("APPIMAGE"), QString());
return QFileInfo(appimgPath).dir().path();
}
return QCoreApplication::applicationDirPath();
}

static QString readMarkerFile(const QString& path)
{
QString line;
QFile file(path);
file.open(QIODevice::ReadOnly);
QTextStream(&file).readLineInto(&line);
file.close();
return line;
}

static bool validateConfDir(QString& path)
{
if (path.isEmpty()) {
qWarning("WARN: portable data path not specified");
return false;
}
QFileInfo pathInfo(path);
if (pathInfo.isFile()) {
qWarning("WARN: specified portable data path is an existing file: %s", qPrintable(path));
return false;
}
QFileInfo parentInfo(pathInfo.dir().path());
if (!parentInfo.isDir()) {
qWarning("WARN: parent directory of specified portable data path doesn't exist: %s", qPrintable(parentInfo.filePath()));
return false;
}
return true;
}

static void migrateConfig(QSettings& settings)
{
if (settings.contains(qsl("pos"))) {
return;
}
// Old default configs, stored in NativeFormat
const QSettings settings_old2(qsl("mudlet"), qsl("Mudlet"));
if (settings_old2.contains(qsl("pos"))) {
for (auto& key : settings_old2.allKeys()) {
settings.setValue(key, settings_old2.value(key));
}
return;
}
const QSettings settings_old1(qsl("Mudlet"), qsl("Mudlet 1.0"));
if (settings_old1.contains(qsl("pos"))) {
for (auto& key : settings_old1.allKeys()) {
settings.setValue(key, settings_old1.value(key));
}
return;
}
}

void mudlet::setupConfig()
{
QString confDirDefault = qsl("%1/.config/mudlet").arg(QDir::homePath());
QString execDir = findExecutableDir();
QString markerExecDir = qsl("%1/portable.txt").arg(execDir);
QString markerHomeDir = qsl("%1/portable.txt").arg(confDirDefault);
if (QFileInfo(markerExecDir).isFile()) {
QString portPath = readMarkerFile(markerExecDir);
if (portPath.isEmpty()) {
portPath = qsl("./portable"); // fallback value for empty portable.txt
}
portPath = utils::pathResolveRelative(QDir::cleanPath(portPath), execDir);
if (!validateConfDir(portPath)) {
qFatal("FATAL: portable data path invalid");
}
confPath = portPath;
} else if (QFileInfo(markerHomeDir).isFile()) {
QString portPath = readMarkerFile(markerHomeDir);
portPath = utils::pathResolveRelative(QDir::cleanPath(portPath), execDir);
if (!validateConfDir(portPath)) {
qFatal("FATAL: portable data path invalid");
}
confPath = portPath;
} else {
confPath = confDirDefault;
}
qDebug() << "mudlet::setupConfig() INFO:" << "using config dir:" << confPath;

mpSettings = new QSettings(qsl("%1/Mudlet.ini").arg(confPath), QSettings::IniFormat);
migrateConfig(*mpSettings);
}

// This is a static wrapper for singleton instance method
// Should only be called after mudlet has been initialised
/*static*/ QSettings* mudlet::getQSettings()
{
/*In case sensitive environments, two different config directories
were used: "Mudlet" for QSettings, and "mudlet" anywhere else.
Furthermore, we skip the version from the application name to follow the convention.
For compatibility with older settings, if no config is loaded
from the config directory "mudlet", application "Mudlet", we try to load from the config
directory "Mudlet", application "Mudlet 1.0". */
const QSettings settings_new("mudlet", "Mudlet");
return new QSettings((settings_new.contains("pos") ? "mudlet" : "Mudlet"), (settings_new.contains("pos") ? "Mudlet" : "Mudlet 1.0"));
return self()->mpSettings;
}

void mudlet::initEdbee()
Expand Down Expand Up @@ -2012,10 +2104,7 @@ bool mudlet::isControlsVisible() const

void mudlet::writeSettings()
{
/*In case sensitive environments, two different config directories
were used: "Mudlet" for QSettings, and "mudlet" anywhere else. We change the QSettings directory to "mudlet".
Furthermore, we skip the version from the application name to follow the convention.*/
QSettings settings("mudlet", "Mudlet");
QSettings& settings = *getQSettings();
settings.setValue("pos", pos());
settings.setValue("size", size());
settings.setValue("mainiconsize", mToolbarIconSize);
Expand Down Expand Up @@ -3410,110 +3499,111 @@ bool mudlet::loadEdbeeTheme(const QString& themeName, const QString& themeFile)
return true;
}

// Convenience helper - may aide things if we want to put files in a different
// place...!
// This is a static wrapper for singleton instance method
// Should only be called after mudlet has been initialised
QString mudlet::getMudletPath(const mudletPathType mode, const QString& extra1, const QString& extra2)
{
QString confPath = self()->confPath;
switch (mode) {
case mainPath:
// The root of all mudlet data for the user - does not end in a '/'
return qsl("%1/.config/mudlet").arg(QDir::homePath());
return confPath;
case mainDataItemPath:
// Takes one extra argument as a file (or directory) relating to
// (profile independent) mudlet data - may end with a '/' if the extra
// argument does:
return qsl("%1/.config/mudlet/%2").arg(QDir::homePath(), extra1);
return qsl("%1/%2").arg(confPath, extra1);
case mainFontsPath:
// (Added for 3.5.0) a revised location to store Mudlet provided fonts
return qsl("%1/.config/mudlet/fonts").arg(QDir::homePath());
return qsl("%1/fonts").arg(confPath);
case profilesPath:
// The directory containing all the saved user's profiles - does not end
// in '/'
return qsl("%1/.config/mudlet/profiles").arg(QDir::homePath());
return qsl("%1/profiles").arg(confPath);
case profileHomePath:
// Takes one extra argument (profile name) that returns the base
// directory for that profile - does NOT end in a '/' unless the
// supplied profle name does:
return qsl("%1/.config/mudlet/profiles/%2").arg(QDir::homePath(), extra1);
return qsl("%1/profiles/%2").arg(confPath, extra1);
case profileMediaPath:
// Takes one extra argument (profile name) that returns the directory
// for the profile's cached media files - does NOT end in a '/'
return qsl("%1/.config/mudlet/profiles/%2/media").arg(QDir::homePath(), extra1);
return qsl("%1/profiles/%2/media").arg(confPath, extra1);
case profileMediaPathFileName:
// Takes two extra arguments (profile name, mediaFileName) that returns
// the pathFile name for any media file:
return qsl("%1/.config/mudlet/profiles/%2/media/%3").arg(QDir::homePath(), extra1, extra2);
return qsl("%1/profiles/%2/media/%3").arg(confPath, extra1, extra2);
case profileXmlFilesPath:
// Takes one extra argument (profile name) that returns the directory
// for the profile game save XML files - ends in a '/'
return qsl("%1/.config/mudlet/profiles/%2/current/").arg(QDir::homePath(), extra1);
return qsl("%1/profiles/%2/current/").arg(confPath, extra1);
case profileMapsPath:
// Takes one extra argument (profile name) that returns the directory
// for the profile game save maps files - does NOT end in a '/'
return qsl("%1/.config/mudlet/profiles/%2/map").arg(QDir::homePath(), extra1);
return qsl("%1/profiles/%2/map").arg(confPath, extra1);
case profileDateTimeStampedMapPathFileName:
// Takes two extra arguments (profile name, dataTime stamp) that returns
// the pathFile name for a dateTime stamped map file:
return qsl("%1/.config/mudlet/profiles/%2/map/%3map.dat").arg(QDir::homePath(), extra1, extra2);
return qsl("%1/profiles/%2/map/%3map.dat").arg(confPath, extra1, extra2);
case profileDateTimeStampedJsonMapPathFileName:
// Takes two extra arguments (profile name, dataTime stamp) that returns
// the pathFile name for a dateTime stamped JSON map file:
return qsl("%1/.config/mudlet/profiles/%2/map/%3map.json").arg(QDir::homePath(), extra1, extra2);
return qsl("%1/profiles/%2/map/%3map.json").arg(confPath, extra1, extra2);
case profileMapPathFileName:
// Takes two extra arguments (profile name, mapFileName) that returns
// the pathFile name for any map file:
return qsl("%1/.config/mudlet/profiles/%2/map/%3").arg(QDir::homePath(), extra1, extra2);
return qsl("%1/profiles/%2/map/%3").arg(confPath, extra1, extra2);
case profileXmlMapPathFileName:
// Takes one extra argument (profile name) that returns the pathFile
// name for the downloaded IRE Server provided XML map:
return qsl("%1/.config/mudlet/profiles/%2/map.xml").arg(QDir::homePath(), extra1);
return qsl("%1/profiles/%2/map.xml").arg(confPath, extra1);
case profileDataItemPath:
// Takes two extra arguments (profile name, data item) that gives a
// path file name for, typically a data item stored as a single item
// (binary) profile data) file (ideally these can be moved to a per
// profile QSettings file but that is a future pipe-dream on my part
// SlySven):
return qsl("%1/.config/mudlet/profiles/%2/%3").arg(QDir::homePath(), extra1, extra2);
return qsl("%1/profiles/%2/%3").arg(confPath, extra1, extra2);
case profilePackagePath:
// Takes two extra arguments (profile name, package name) returns the
// per profile directory used to store (unpacked) package contents
// - ends with a '/':
return qsl("%1/.config/mudlet/profiles/%2/%3/").arg(QDir::homePath(), extra1, extra2);
return qsl("%1/profiles/%2/%3/").arg(confPath, extra1, extra2);
case profilePackagePathFileName:
// Takes two extra arguments (profile name, package name) returns the
// filename of the XML file that contains the (per profile, unpacked)
// package mudlet items in that package/module:
return qsl("%1/.config/mudlet/profiles/%2/%3/%3.xml").arg(QDir::homePath(), extra1, extra2);
return qsl("%1/profiles/%2/%3/%3.xml").arg(confPath, extra1, extra2);
case profileReplayAndLogFilesPath:
// Takes one extra argument (profile name) that returns the directory
// that contains replays (*.dat files) and logs (*.html or *.txt) files
// for that profile - does NOT end in '/':
return qsl("%1/.config/mudlet/profiles/%2/log").arg(QDir::homePath(), extra1);
return qsl("%1/profiles/%2/log").arg(confPath, extra1);
case profileLogErrorsFilePath:
// Takes one extra argument (profile name) that returns the pathFileName
// to the map auditing report file that is appended to each time a
// map is loaded:
return qsl("%1/.config/mudlet/profiles/%2/log/errors.txt").arg(QDir::homePath(), extra1);
return qsl("%1/profiles/%2/log/errors.txt").arg(confPath, extra1);
case editorWidgetThemePathFile:
// Takes two extra arguments (profile name, theme name) that returns the
// pathFileName of the theme file used by the edbee editor - also
// handles the special case of the default theme "mudlet.tmTheme" that
// is carried internally in the resource file:
if (extra1.compare(qsl("Mudlet.tmTheme"), Qt::CaseSensitive)) {
// No match
return qsl("%1/.config/mudlet/edbee/Colorsublime-Themes-master/themes/%2").arg(QDir::homePath(), extra1);
return qsl("%1/edbee/Colorsublime-Themes-master/themes/%2").arg(confPath, extra1);
} else {
// Match - return path to copy held in resource file
return qsl(":/edbee_defaults/Mudlet.tmTheme");
}
case editorWidgetThemeJsonFile:
// Returns the pathFileName to the external JSON file needed to process
// an edbee editor widget theme:
return qsl("%1/.config/mudlet/edbee/Colorsublime-Themes-master/themes.json").arg(QDir::homePath());
return qsl("%1/edbee/Colorsublime-Themes-master/themes.json").arg(confPath);
case moduleBackupsPath:
// Returns the directory used to store module backups that is used in
// when saving/resyncing packages/modules - ends in a '/'
return qsl("%1/.config/mudlet/moduleBackups/").arg(QDir::homePath());
return qsl("%1/moduleBackups/").arg(confPath);
case qtTranslationsPath:
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
return QLibraryInfo::location(QLibraryInfo::TranslationsPath);
Expand Down
5 changes: 4 additions & 1 deletion src/mudlet.h
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ class mudlet : public QMainWindow, public Ui::main_window


static QString getMudletPath(mudletPathType, const QString& extra1 = QString(), const QString& extra2 = QString());
static QSettings* getQSettings();
// From https://stackoverflow.com/a/14678964/4805858 an answer to:
// "How to find and replace string?" by "Czarek Tomczak":
static bool loadEdbeeTheme(const QString& themeName, const QString& themeFile);
Expand Down Expand Up @@ -297,6 +298,7 @@ class mudlet : public QMainWindow, public Ui::main_window
// as well as encourage translators to maintain it
static const int scmTranslationGoldStar = 95;
QString scmVersion;
QString confPath;
// These have to be "inline" to satisfy the ODR (One Definition Rule):
inline static bool smDebugMode = false;
inline static bool smFirstLaunch = false;
Expand All @@ -313,6 +315,8 @@ class mudlet : public QMainWindow, public Ui::main_window
void hideEvent(QHideEvent*) override;


void init();
void setupConfig();
void activateProfile(Host*);
void takeOwnershipOfInstanceCoordinator(std::unique_ptr<MudletInstanceCoordinator>);
MudletInstanceCoordinator* getInstanceCoordinator();
Expand All @@ -338,7 +342,6 @@ class mudlet : public QMainWindow, public Ui::main_window
std::optional<QSize> getImageSize(const QString&);
const QString& getInterfaceLanguage() const { return mInterfaceLanguage; }
int64_t getPhysicalMemoryTotal();
QSettings* getQSettings();
const QLocale& getUserLocale() const { return mUserLocale; }
QSet<QString> getWordSet();
bool inDarkMode() const { return mDarkMode; }
Expand Down
Loading

0 comments on commit d8cd226

Please sign in to comment.