mudlet/src/utils.h
Vadim Peretokin 61c2afd406
Fix an empty XDG config directory hiding every profile (#9712)
#### Brief overview of PR changes/additions

- An empty `$XDG_CONFIG_HOME/mudlet` silently beat a populated
`~/.config/mudlet`, so a stray `mkdir` hid every profile and Mudlet ran
its first-launch onboarding as though the user were new. It also stuck:
the first such launch wrote `Mudlet.ini` into that directory, which then
kept it winning.
- The two candidate roots are now ranked (`profiles/` > `Mudlet.ini` >
exists > absent) and the stronger claim wins, with
`$XDG_CONFIG_HOME/mudlet` taking ties so a fresh install and a
deliberate opt-in both still land there. A directory that cannot be
listed counts as populated rather than empty, so a permission bit cannot
re-enter the bug.
- Creating `profiles/` is now the opt-in a test harness uses; the
`mudlet` directory alone is not, because other tooling creates that by
accident. Where both roots hold profiles, `setupConfig()` names the one
it is ignoring instead of leaving those profiles apparently gone.

#### Motivation for adding to Mudlet

Data-loss-shaped regression from #9552 "improve: honor XDG_CONFIG_HOME
for Mudlet's config directory" (`e6c268cb0`). The profiles are orphaned
rather than destroyed, but a returning user sees "5.0 wiped my
profiles". `src/mudlet-lua/tests/README.md` itself instructed `mkdir -p
"$CONFIG_DIR/mudlet"`, so following Mudlet's own test docs triggered it.

#### Other info (issues closed, discussion etc)

Test case: create `~/.config/mudlet/profiles/{AlphaGame,BetaGame}`,
`mkdir -p $XDG_CONFIG_HOME/mudlet`, launch. Before: no profiles and the
onboarding dialog. After: both profiles listed.

`ConfigDirOverrideTest` covers the resolution table including the sticky
`Mudlet.ini` state, both-populated, symlinked and unreadable
directories; each new guard was mutation-checked. The busted suite
passes 2422/0 against an isolated `$XDG_CONFIG_HOME/mudlet/profiles`
root.

Not fixed here, and pre-existing rather than 5.0 regressions:
`CredentialManager` stores passwords and the OAuth reconnect token under
`AppConfigLocation` while the config root is `confPath`, so exporting
`XDG_CONFIG_HOME` strands them, and the plaintext-password migration
reads one path, writes the other and deletes the original. Both
reproduce identically on the 4.22.0 binary and need their own migration
path.

Assisted-by: Claude:claude-opus-5
2026-08-10 22:17:09 +02:00

380 lines
16 KiB
C++

#ifndef MUDLET_UTILS_H
#define MUDLET_UTILS_H
/***************************************************************************
* Copyright (C) 2021 by Vadim Peretokin - vperetokin@hey.com *
* Copyright (C) 2021, 2023, 2025-2026 by Stephen Lyons *
* - slysven@virginmedia.com *
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program; if not, write to the *
* Free Software Foundation, Inc., *
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
***************************************************************************/
#include <QApplication>
#include <QEnterEvent>
#include <QDir>
#include <QRegularExpression>
#include <QString>
#include <QScreen>
#include <QWidget>
#include <cstdint>
#include <cstring>
#define qsl(s) QStringLiteral(s)
// user-defined literals to represent kilobytes and megabytes
// C++ standard requires unsigned long long parameter for integer literal operators
constexpr auto operator""_KB(unsigned long long const x) -> int64_t // NOLINT(runtime/int)
{
return 1024LL * x;
}
constexpr auto operator""_MB(unsigned long long const x) -> int64_t // NOLINT(runtime/int)
{
return 1024LL * 1024LL * x;
}
using TEnterEvent = QEnterEvent;
using NameGroupMatches = QVector<QPair<QString, QString>>;
// Common enum for specifying insertion mode for tree items
// Used across all editor item types (triggers, aliases, timers, scripts, actions, keys)
enum class TreeItemInsertMode {
Append, // Add to end of parent's child list
AtPosition // Insert at specific position
};
class utils
{
public:
// Safe string copy: copies up to destSize-1 bytes and always null-terminates.
// Returns the number of bytes copied (excluding null terminator).
static size_t copyString(char* dest, size_t destSize, const char* src, size_t srcLen)
{
if (destSize == 0) {
return 0;
}
const size_t copyLen = (srcLen < destSize) ? srcLen : destSize - 1;
std::memcpy(dest, src, copyLen);
dest[copyLen] = '\0';
return copyLen;
}
// As copyString(), but for UTF-8 data that has to stay valid UTF-8: the copy
// stops at the last character that fits whole rather than at the last byte,
// so no trailing half-character is left behind. Use it wherever a truncated
// copy is handed on to something that decodes it - Discord discards an
// entire presence frame whose JSON payload carries an incomplete sequence.
// Returns the number of bytes copied (excluding the null terminator).
static size_t copyUtf8String(char* dest, size_t destSize, const char* src, size_t srcLen)
{
if (destSize == 0) {
return 0;
}
size_t copyLen = (srcLen < destSize) ? srcLen : destSize - 1;
// Every byte after the first of a multi-byte character has the form
// 10xxxxxx, so a cut in front of one is a cut inside a character: walk
// back to where that character starts. A cut that took everything (or
// that landed on a character start) needs no adjustment.
while (copyLen > 0 && copyLen < srcLen && (static_cast<unsigned char>(src[copyLen]) & 0xC0u) == 0x80u) {
--copyLen;
}
std::memcpy(dest, src, copyLen);
dest[copyLen] = '\0';
return copyLen;
}
// This construct will be very useful for formatting tooltips and by
// defining a static function/method here we can save using the same
// qsl all over the place:
static QString richText(const QString& text) { return qsl("<p>%1</p>").arg(text); }
// Call this in the destructor of a window class that connects any of its
// own widgets to its own slots - keep it first, so that nothing else the
// destructor does can deliver a child's signal either.
//
// A visible window is taken off the screen while the base-class
// destructors unwind: ~QDialog hides it explicitly, and any other window
// class gets closed by ~QWidget. That moves the keyboard focus away from
// whichever child widget holds it, and an editing widget reacts to the
// focus-out by emitting - QLineEdit (once its text has been touched, which
// includes any setText()), QAbstractSpinBox and QKeySequenceEdit all emit
// editingFinished() there. Qt then tries to deliver that to a slot of a
// window whose derived part has already been destroyed, which aborts with
// "Called object is not of the correct type (class destructor may have
// already run)" (#9574). In a release build the assert is compiled out and
// the slot runs against destroyed members instead.
//
// A window that is being destroyed cannot do anything useful with a
// signal from its own widgets, so every one of them is severed rather
// than just the widget types that emit during teardown today. Note that
// this only reaches connections whose receiver is the window: a
// connect(child, &Signal, [this]{...}) written without a context object
// survives it and brings the crash back, so always pass the context:
static void disconnectChildSignals(QWidget* window)
{
for (QObject* child : window->findChildren<QObject*>()) {
QObject::disconnect(child, nullptr, window, nullptr);
}
}
// Qt 6.9 deprecated QDateTime::setOffsetFromUtc(int) and made it hard to
// replicate the exact strings that we had before:
static QString dateStamp() {
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
auto localNow = QDateTime::currentDateTime();
const int offset = localNow.offsetFromUtc();
if (offset) {
unsigned hoursOff = abs(offset/3600);
unsigned minutesOff = (abs(offset) - hoursOff * 3600) / 60;
return localNow.toString(Qt::ISODate).append(qsl("%1%2:%3")
.arg(offset >= 0 ? QLatin1Char('+') : QLatin1Char('-'))
.arg(hoursOff, 2, 10, QLatin1Char('0'))
.arg(minutesOff, 2, 10, QLatin1Char('0')));
}
return localNow.toString(Qt::ISODate).append(qsl("+00:00"));
#else
auto localNow = QDateTime::currentDateTime();
const int offset = localNow.offsetFromUtc();
localNow.setOffsetFromUtc(offset);
return localNow.toString(Qt::ISODate);
#endif
}
// Return a new QString with path made absolute, resolved against base and cleaned if it was relative
// Returns path unchanged if it was already absolute or an empty string
static QString pathResolveRelative(const QString& path, const QString& base)
{
if (path.isEmpty()) {
return path;
}
if (QDir::isAbsolutePath(path)) {
return path;
}
return QDir::cleanPath(base + "/" + path);
}
struct ConfigDirResolution
{
QString path;
// XDG_CONFIG_HOME is set, but an existing legacy dir was used anyway, so
// the caller can hint at the migration
bool migrationPending = false;
// legacyDefault, when it holds profiles that the chosen dir now hides. The
// caller has to name it, or those profiles read as gone.
QString shadowedProfilesPath;
};
// How strongly a directory claims to be Mudlet's config root; the stronger
// claim wins in xdgConfigDir(), so the order is the contract.
enum class ConfigDirClaim {
absent = 0,
// Exists, but holds nothing Mudlet put there - including the stale
// Mudlet.conf pre-4.19 Mudlet left in $XDG_CONFIG_HOME/mudlet while its
// profiles stayed in ~/.config/mudlet
unclaimed = 1,
settings = 2,
profiles = 3,
};
// A directory that cannot be listed must never read as "nothing here": that
// inference is what hides profiles, so assume the strongest content instead.
static bool configDirHoldsProfiles(const QString& dir)
{
if (!QDir(dir).exists()) {
return false;
}
if (!QFileInfo(dir).isReadable()) {
return true;
}
const QDir profiles(qsl("%1/profiles").arg(dir));
if (!profiles.exists()) {
return false;
}
// Counted as mudlet.cpp's anyProfilesExist() does, so the two cannot disagree
return !QFileInfo(profiles.path()).isReadable() || !profiles.entryList(QDir::Dirs | QDir::NoDotAndDotDot).isEmpty();
}
static ConfigDirClaim configDirClaim(const QString& dir)
{
if (!QDir(dir).exists()) {
return ConfigDirClaim::absent;
}
if (configDirHoldsProfiles(dir)) {
return ConfigDirClaim::profiles;
}
if (QFileInfo::exists(qsl("%1/Mudlet.ini").arg(dir))) {
return ConfigDirClaim::settings;
}
return ConfigDirClaim::unclaimed;
}
// $XDG_CONFIG_HOME/mudlet claims more than it holds, because creating
// profiles/ there is the deliberate opt-in into an isolated config root. The
// legacy dir gets no such credit: an empty profiles/ left behind by deleting
// the last profile would otherwise outrank a config root in active use.
static ConfigDirClaim xdgConfigDirClaim(const QString& dir)
{
if (QDir(qsl("%1/profiles").arg(dir)).exists()) {
return ConfigDirClaim::profiles;
}
return configDirClaim(dir);
}
// cleanPath() is not enough: a symlinked ~/.config gives one directory two
// spellings, and dotfile managers produce exactly that
static QString configDirIdentity(const QString& dir)
{
const QString canonical = QFileInfo(dir).canonicalFilePath();
return canonical.isEmpty() ? QDir::cleanPath(dir) : canonical;
}
// Resolve Mudlet's config root honoring XDG_CONFIG_HOME; the caller handles
// portable.txt first, which still wins. $XDG_CONFIG_HOME/mudlet takes a tie so
// that a fresh install lands there.
static ConfigDirResolution xdgConfigDir(const QString& legacyDefault)
{
const QString xdgConfigHome = qEnvironmentVariable("XDG_CONFIG_HOME");
// The XDG base-dir spec requires an absolute path; a relative (or empty)
// value must be ignored, which also avoids a surprising CWD-relative root.
if (xdgConfigHome.isEmpty() || !QDir::isAbsolutePath(xdgConfigHome)) {
return {legacyDefault, false, QString()};
}
const QString xdgTarget = QDir::cleanPath(qsl("%1/mudlet").arg(xdgConfigHome));
if (xdgConfigDirClaim(xdgTarget) < configDirClaim(legacyDefault)) {
return {legacyDefault, true, QString()};
}
// XDG_CONFIG_HOME=$HOME/.config makes both candidates one directory
const bool shadowing = configDirIdentity(legacyDefault) != configDirIdentity(xdgTarget) && configDirHoldsProfiles(legacyDefault);
return {xdgTarget, false, shadowing ? legacyDefault : QString()};
}
inline static const auto scmfileSystemUnsafeChars = QRegularExpression(qsl(R"REGEX([/\\:*?"<>|])REGEX"));
// Sanitize a string for safe use as filename/path component
// Replaces filesystem-unsafe characters with underscores and limits length
static QString sanitizeForPath(const QString& input)
{
QString sanitized = input;
// Replace filesystem-unsafe characters with underscores
sanitized.replace(scmfileSystemUnsafeChars, qsl("_"));
// Limit length to prevent filesystem issues
if (sanitized.length() > 50) {
sanitized = sanitized.left(50);
}
return sanitized;
}
// Position a dialog on the same screen as its parent window
// This improves multi-monitor UX by keeping dialogs with their parent windows
static void positionDialogOnParentScreen(QWidget* dialog, QWidget* parent)
{
if (!dialog || !parent) {
return;
}
// Get the screen containing the parent window
// Use mapToGlobal to get the actual screen position of the parent widget
QPoint parentPos = parent->mapToGlobal(parent->rect().center());
const QScreen* parentScreen = QApplication::screenAt(parentPos);
if (!parentScreen) {
// Fallback to parent's screen property if screenAt fails
parentScreen = parent->screen();
}
if (parentScreen) {
// Get the current screen of the dialog to see if it needs repositioning
// Use the dialog's current geometry center for more accurate screen detection
QPoint dialogCenter = dialog->mapToGlobal(dialog->rect().center());
const QScreen* dialogScreen = QApplication::screenAt(dialogCenter);
// If the dialog is not visible or not yet positioned, or if it's on the wrong screen,
// then reposition it. This handles cases where the dialog retains old positions.
if (!dialog->isVisible() || !dialogScreen || dialogScreen != parentScreen) {
centerDialogOnScreen(dialog, parentScreen);
}
}
}
// Position a dialog on the same screen as the active profile's console
// This version considers the actual console widget position for better accuracy
static void positionDialogOnActiveProfileScreen(QWidget* dialog, QWidget* parentWindow, QWidget* activeConsole)
{
if (!dialog) {
return;
}
// Prefer the active console position if available, otherwise fall back to parent window
QWidget* referenceWidget = activeConsole ? activeConsole : parentWindow;
if (referenceWidget) {
positionDialogOnParentScreen(dialog, referenceWidget);
}
}
// Force reposition a dialog on the specified screen, regardless of current position
// This is useful for singleton dialogs that may retain old positions
static void forceRepositionDialogOnParentScreen(QWidget* dialog, QWidget* parent)
{
if (!dialog || !parent) {
return;
}
// Get the screen containing the parent window
QPoint parentPos = parent->mapToGlobal(parent->rect().center());
const QScreen* parentScreen = QApplication::screenAt(parentPos);
if (!parentScreen) {
parentScreen = parent->screen();
}
if (parentScreen) {
// Always reposition, regardless of current dialog position
centerDialogOnScreen(dialog, parentScreen);
}
}
// Position a dialog in the center of the specified screen
static void centerDialogOnScreen(QWidget* dialog, const QScreen* screen)
{
if (!dialog || !screen) {
return;
}
const QRect screenGeometry = screen->availableGeometry();
// Ensure dialog has a size first
if (dialog->size().isEmpty()) {
dialog->adjustSize();
}
// Calculate center position
const QSize dialogSize = dialog->size();
const QPoint centerPoint = screenGeometry.center();
const QPoint newPos(
centerPoint.x() - dialogSize.width() / 2,
centerPoint.y() - dialogSize.height() / 2);
// Ensure dialog stays within screen bounds
QPoint constrainedPos = newPos;
constrainedPos.setX(qMax(screenGeometry.left(),
qMin(newPos.x(), screenGeometry.right() - dialogSize.width())));
constrainedPos.setY(qMax(screenGeometry.top(),
qMin(newPos.y(), screenGeometry.bottom() - dialogSize.height())));
dialog->move(constrainedPos);
}
};
#endif // UPDATER_H