infrastructure: decouple profile management (Host) from UI dialogs (#9514)
#### Brief overview of PR changes/additions
- Removes all raw Qt Widgets usage from `Host.{h,cpp}` so the
`mudlet_core` Qt Widgets audit (`cmake/audit-core-widgets.sh`, added in
#9508) drops both files to zero: the offending-file count goes from 158
to 156 and both move to the "Clean files" list. (The committed
report/baseline are intentionally not regenerated here, to avoid
conflicts with sibling wave-2 PRs.)
- Follows the seam pattern established by #9507: the core (`Host`) emits
Qt signals carrying already-translated `tr()` strings, and the frontend
(`TMainConsole`/`mudlet`) owns the actual widgets.
- The dockable map widget (`mpDockableMapWidget`, a `QDockWidget`) moved
from `Host` to the profile's own `TMainConsole`.
`TMainConsole::createMapperDock()` constructs it and the console's
destructor disposes of it. `Host` still drives it through
`mpConsole->mpDockableMapWidget` (an already out-of-scope pointer per
the split plan) but no longer names any Qt Widgets type. The external
accessors in `mudlet.cpp`/`TDetachedWindow.cpp` gained an `mpConsole &&`
null-guard.
- The mapping-script reminder and package-unpacking progress dialogs are
now shown by the frontend in response to
`signal_showMapperScriptReminder` / `signal_showUnpackingProgress` /
`signal_hideUnpackingProgress`, wired up in
`mudlet::addConsoleForNewHost`.
- `TDockWidget` now sets its own dock features (moved out of
`Host::openWindow`); `Host::setBorders` uses
`QCoreApplication::sendEvent`; and the user-window scrollbar is hidden
via `TConsole::setScrollBarVisible()` instead of reaching into the raw
`QScrollBar`.
- Adds `HostWidgetDecouplingTest` (ephemeral port-0 stub + a real
profile, modelled on `TelnetTlsPromptTest`): verifies the map dock is
created and owned by the console, that `setMapperTitle` routes through
it, that the reminder dialog is raised, and that the unpacking dialog is
replaced then disposed (asserting the replaced dialog is destroyed, not
leaked). Two further tests cover the seams end to end: a real package
install has to reach the dialog through the `addConsoleForNewHost`
wiring, and closing a profile has to take the console-owned map dock
with it.
#### Motivation for adding to Mudlet
Continues the re-scoped libmudlet plan (a Qt Widgets-free `mudlet_core`
for headless use, testability and WASM). `Host` is the second concrete
extraction after `cTelnet` (#9507) and copies its template so later
extractions can follow the same shape.
#### Other info (issues closed, discussion etc)
Part of #8681 / #9011. Behavior-preserving: dialogs keep the same
modality/defaults and all strings stay in `Host`'s translation context,
so existing translations are unaffected. One intentional behaviour
change: failing to load the cosmetic unpacking/reminder `.ui` now warns
and no-ops instead of aborting the package install (a fire-and-forget
signal cannot fail the install back to `Host`), which is strictly
better. The dock's `deleteLater()` cleanup moved from `Host`'s
destructor to `TMainConsole`'s.
Reviewed with the code-reviewer and silent-failure-hunter agents; both
flagged a leak in the unpacking-dialog replace path (parentless dialog
`close()`d instead of `deleteLater()`d) and the missing destructor
cleanup, both fixed and now covered by the test. Expect the `mudlet.cpp`
`addConsoleForNewHost` wiring to conflict with sibling wave-2 PRs; that
is fine.
Assisted-by: Claude:claude-opus-4-8
**Test case:** Open a profile and open the mapper via the Map toolbar
button - it appears docked as before, and its title can be changed with
`setMapperTitle(...)`. Install a `.zip`/`.mpackage` from the package
manager (a normal package, not a module-from-UI and not a script/quiet
install) and confirm the "Unpacking..." progress dialog shows and then
closes. On a profile with no mapper script, open the mapper and confirm
the "you have no mapper script" reminder dialog appears and its link
opens the mapping scripts page.
#### Demo (before & after)
Parity check that the moved dialog/dock flows behave identically before
(development) and after this PR: the package-install "Unpacking..."
dialog and the dockable map widget.
https://github.com/user-attachments/assets/41579b67-7de1-463b-b642-bd7660b52431
2026-08-02 13:04:21 +02:00
|
|
|
/***************************************************************************
|
|
|
|
|
* Copyright (C) 2026 by Mudlet Developers *
|
|
|
|
|
* *
|
|
|
|
|
* 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 <QtTest/QtTest>
|
|
|
|
|
|
|
|
|
|
#include <chrono>
|
|
|
|
|
|
|
|
|
|
#include "Host.h"
|
|
|
|
|
#include "MudletInstanceCoordinator.h"
|
|
|
|
|
#include "TMainConsole.h"
|
|
|
|
|
#include "TelnetServerStub.h"
|
|
|
|
|
#include "ctelnet.h"
|
|
|
|
|
#include "dlgConnectionProfiles.h"
|
|
|
|
|
#include "mudlet.h"
|
|
|
|
|
#include "utils.h"
|
|
|
|
|
|
|
|
|
|
#include <QDialog>
|
|
|
|
|
#include <QDockWidget>
|
|
|
|
|
#include <QLabel>
|
|
|
|
|
#include <QTemporaryDir>
|
|
|
|
|
|
fix: package lifecycle - queued save outliving the profile, unremovable archives, module priority (#9684)
#### Brief overview of PR changes/additions
- The profile save that installing or uninstalling a package owes is now
held in a member `QTimer` that the profile close and `~Host()` stop,
instead of a `QTimer::singleShot()` queued on the `Host`: that call was
still delivered after `HostManager::deleteHost()` had destroyed the
profile, and `Host::saveProfile()` then read freed members.
- `installPackage()` refuses an archive it could read no package out of
and takes the folder it unpacked back off disk - but only a folder
inside the profile, since the name can be whatever an untrusted
`config.lua` says. It answered `true` for such an archive before,
leaving something registered nowhere that could never be uninstalled.
- `getModulePriority()` asks `mInstalledModules` whether the module
exists, the same list `setModulePriority()` uses, and reports the
default priority of 0 for one nobody has prioritised yet.
#### Motivation for adding to Mudlet
Uninstall a package, close Mudlet, and the queued save runs against the
destroyed profile - a heap use-after-free on the way out, which is what
a "Mudlet crashed when I closed it" report looks like. Reproduced under
AddressSanitizer, clean afterwards. The other two are smaller but
user-visible: picking the wrong zip in the package manager reported
success and left a folder behind that nothing could remove, and a script
could not tell "module not installed" from "installed, never
prioritised".
#### Other info (issues closed, discussion etc)
Closes #9653, closes #9654, closes #9655.
Test case: `installPackage("something.mpackage")`,
`uninstallPackage("something")`, then close Mudlet straight away - it
exits cleanly; `installPackage()` on a zip with no package XML in it now
answers `nil` plus a message and leaves nothing behind;
`getModulePriority()` on a freshly installed module answers `0`.
The package lifecycle specs carried the last two as `pending()`; both
are flipped to real specs, and a new `PackageUninstallSaveTeardownTest`
covers the save deferral, its coalescing, the profile close, and that a
refused archive can only take its own folder with it.
Assisted-by: Claude:claude-opus-5
2026-08-05 19:55:59 +02:00
|
|
|
#include <zip.h>
|
|
|
|
|
|
infrastructure: decouple profile management (Host) from UI dialogs (#9514)
#### Brief overview of PR changes/additions
- Removes all raw Qt Widgets usage from `Host.{h,cpp}` so the
`mudlet_core` Qt Widgets audit (`cmake/audit-core-widgets.sh`, added in
#9508) drops both files to zero: the offending-file count goes from 158
to 156 and both move to the "Clean files" list. (The committed
report/baseline are intentionally not regenerated here, to avoid
conflicts with sibling wave-2 PRs.)
- Follows the seam pattern established by #9507: the core (`Host`) emits
Qt signals carrying already-translated `tr()` strings, and the frontend
(`TMainConsole`/`mudlet`) owns the actual widgets.
- The dockable map widget (`mpDockableMapWidget`, a `QDockWidget`) moved
from `Host` to the profile's own `TMainConsole`.
`TMainConsole::createMapperDock()` constructs it and the console's
destructor disposes of it. `Host` still drives it through
`mpConsole->mpDockableMapWidget` (an already out-of-scope pointer per
the split plan) but no longer names any Qt Widgets type. The external
accessors in `mudlet.cpp`/`TDetachedWindow.cpp` gained an `mpConsole &&`
null-guard.
- The mapping-script reminder and package-unpacking progress dialogs are
now shown by the frontend in response to
`signal_showMapperScriptReminder` / `signal_showUnpackingProgress` /
`signal_hideUnpackingProgress`, wired up in
`mudlet::addConsoleForNewHost`.
- `TDockWidget` now sets its own dock features (moved out of
`Host::openWindow`); `Host::setBorders` uses
`QCoreApplication::sendEvent`; and the user-window scrollbar is hidden
via `TConsole::setScrollBarVisible()` instead of reaching into the raw
`QScrollBar`.
- Adds `HostWidgetDecouplingTest` (ephemeral port-0 stub + a real
profile, modelled on `TelnetTlsPromptTest`): verifies the map dock is
created and owned by the console, that `setMapperTitle` routes through
it, that the reminder dialog is raised, and that the unpacking dialog is
replaced then disposed (asserting the replaced dialog is destroyed, not
leaked). Two further tests cover the seams end to end: a real package
install has to reach the dialog through the `addConsoleForNewHost`
wiring, and closing a profile has to take the console-owned map dock
with it.
#### Motivation for adding to Mudlet
Continues the re-scoped libmudlet plan (a Qt Widgets-free `mudlet_core`
for headless use, testability and WASM). `Host` is the second concrete
extraction after `cTelnet` (#9507) and copies its template so later
extractions can follow the same shape.
#### Other info (issues closed, discussion etc)
Part of #8681 / #9011. Behavior-preserving: dialogs keep the same
modality/defaults and all strings stay in `Host`'s translation context,
so existing translations are unaffected. One intentional behaviour
change: failing to load the cosmetic unpacking/reminder `.ui` now warns
and no-ops instead of aborting the package install (a fire-and-forget
signal cannot fail the install back to `Host`), which is strictly
better. The dock's `deleteLater()` cleanup moved from `Host`'s
destructor to `TMainConsole`'s.
Reviewed with the code-reviewer and silent-failure-hunter agents; both
flagged a leak in the unpacking-dialog replace path (parentless dialog
`close()`d instead of `deleteLater()`d) and the missing destructor
cleanup, both fixed and now covered by the test. Expect the `mudlet.cpp`
`addConsoleForNewHost` wiring to conflict with sibling wave-2 PRs; that
is fine.
Assisted-by: Claude:claude-opus-4-8
**Test case:** Open a profile and open the mapper via the Map toolbar
button - it appears docked as before, and its title can be changed with
`setMapperTitle(...)`. Install a `.zip`/`.mpackage` from the package
manager (a normal package, not a module-from-UI and not a script/quiet
install) and confirm the "Unpacking..." progress dialog shows and then
closes. On a profile with no mapper script, open the mapper and confirm
the "you have no mapper script" reminder dialog appears and its link
opens the mapping scripts page.
#### Demo (before & after)
Parity check that the moved dialog/dock flows behave identically before
(development) and after this PR: the package-install "Unpacking..."
dialog and the dockable map widget.
https://github.com/user-attachments/assets/41579b67-7de1-463b-b642-bd7660b52431
2026-08-02 13:04:21 +02:00
|
|
|
extern void qInitResources_mudlet();
|
|
|
|
|
extern void qInitResources_qm();
|
|
|
|
|
extern void qInitResources_additional_splash_screens();
|
|
|
|
|
extern void qInitResources_mudlet_fonts_common();
|
|
|
|
|
extern void qInitResources_mudlet_fonts_posix();
|
|
|
|
|
void initializeQRCResourcesForHostWidgetDecoupling();
|
|
|
|
|
|
|
|
|
|
using namespace std::chrono_literals;
|
|
|
|
|
|
|
|
|
|
// Exercises the widget-free seams introduced when Host was de-widgeted: the
|
|
|
|
|
// dockable map widget is now created and owned by the profile's main console
|
|
|
|
|
// (TMainConsole), and the mapping-script reminder and package-unpacking dialogs
|
|
|
|
|
// are shown by the frontend in response to Host signals carrying already
|
|
|
|
|
// translated strings. These tests verify that ownership moved and that the
|
|
|
|
|
// signals drive the frontend widgets as expected.
|
|
|
|
|
class HostWidgetDecouplingTest : public QObject
|
|
|
|
|
{
|
|
|
|
|
Q_OBJECT
|
|
|
|
|
|
|
|
|
|
private:
|
|
|
|
|
TelnetServerStub* mpServer = nullptr;
|
|
|
|
|
const QString mHostname = "Test-Host-Widget-Decoupling";
|
|
|
|
|
const QString mLocalhost = "localhost";
|
|
|
|
|
QString mPort;
|
|
|
|
|
|
|
|
|
|
private slots:
|
|
|
|
|
void initTestCase() { initializeQRCResourcesForHostWidgetDecoupling(); }
|
|
|
|
|
|
|
|
|
|
void init()
|
|
|
|
|
{
|
|
|
|
|
mpServer = new TelnetServerStub(qApp);
|
|
|
|
|
// Bind an ephemeral OS-assigned port so parallel test runs (e.g. across
|
|
|
|
|
// git worktrees) do not collide on a shared fixed port.
|
|
|
|
|
mpServer->start(mLocalhost, 0);
|
|
|
|
|
mPort = QString::number(mpServer->serverPort());
|
|
|
|
|
mudlet::start();
|
|
|
|
|
mudlet::self()->setupConfig();
|
|
|
|
|
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
|
|
|
|
|
mudlet::self()->init();
|
|
|
|
|
mudlet::self()->setStorePasswordsSecurely(false);
|
|
|
|
|
deleteProfileDirectory(mHostname);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The dockable map widget used to be a QDockWidget member of Host; it now
|
|
|
|
|
// lives on (and is owned by) the profile's TMainConsole. Creating the mapper
|
|
|
|
|
// must populate that console-owned pointer.
|
|
|
|
|
void test_dockableMapperOwnedByConsole()
|
|
|
|
|
{
|
|
|
|
|
startProfile(mHostname, mLocalhost, mPort);
|
|
|
|
|
auto host = mudlet::self()->getActiveHost();
|
|
|
|
|
QVERIFY2(host, "No active host available for the test.");
|
|
|
|
|
QVERIFY2(host->mpConsole, "The active host has no main console.");
|
|
|
|
|
|
|
|
|
|
QVERIFY2(!host->mpConsole->mpDockableMapWidget, "A fresh profile must not have a dockable map widget yet.");
|
|
|
|
|
|
|
|
|
|
host->showHideOrCreateMapper(true);
|
|
|
|
|
|
|
|
|
|
QVERIFY2(host->mpConsole->mpDockableMapWidget, "Creating the mapper must give the console a dockable map widget it owns.");
|
|
|
|
|
QCOMPARE(host->mpConsole->mpDockableMapWidget->objectName(), qsl("dockMap_%1").arg(host->getName()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// setMapperTitle is still a Host-facing (Lua) call, but it now drives the
|
|
|
|
|
// console-owned dock: it must fail when there is no dock and set the window
|
|
|
|
|
// title on the console's dock once one exists.
|
|
|
|
|
void test_setMapperTitleDrivesConsoleDock()
|
|
|
|
|
{
|
|
|
|
|
startProfile(mHostname, mLocalhost, mPort);
|
|
|
|
|
auto host = mudlet::self()->getActiveHost();
|
|
|
|
|
QVERIFY2(host, "No active host available for the test.");
|
|
|
|
|
QVERIFY2(host->mpConsole, "The active host has no main console.");
|
|
|
|
|
|
|
|
|
|
auto [okWithoutDock, messageWithoutDock] = host->setMapperTitle(qsl("anything"));
|
|
|
|
|
QVERIFY2(!okWithoutDock, "setMapperTitle must fail when there is no dockable map widget.");
|
|
|
|
|
|
|
|
|
|
host->showHideOrCreateMapper(true);
|
|
|
|
|
QVERIFY2(host->mpConsole->mpDockableMapWidget, "The mapper dock was not created.");
|
|
|
|
|
|
|
|
|
|
auto [okWithDock, messageWithDock] = host->setMapperTitle(qsl("Custom map title"));
|
|
|
|
|
QVERIFY2(okWithDock, qPrintable(messageWithDock));
|
|
|
|
|
QCOMPARE(host->mpConsole->mpDockableMapWidget->windowTitle(), qsl("Custom map title"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The mapping-script reminder used to be a QDialog built inside Host; it is
|
|
|
|
|
// now shown by the frontend in response to signal_showMapperScriptReminder().
|
|
|
|
|
// Verify the frontend handler actually raises a dialog parented on the main
|
|
|
|
|
// window. (Whether Host emits the signal depends on the profile's script
|
|
|
|
|
// state, which is Host-side logic unchanged by this refactor, so we drive
|
|
|
|
|
// the handler directly here.)
|
|
|
|
|
void test_mappingScriptReminderShownByConsole()
|
|
|
|
|
{
|
|
|
|
|
startProfile(mHostname, mLocalhost, mPort);
|
|
|
|
|
auto host = mudlet::self()->getActiveHost();
|
|
|
|
|
QVERIFY2(host, "No active host available for the test.");
|
|
|
|
|
QVERIFY2(host->mpConsole, "The active host has no main console.");
|
|
|
|
|
|
|
|
|
|
const int dialogsBefore = mudlet::self()->findChildren<QDialog*>().count();
|
|
|
|
|
host->mpConsole->showMapperScriptReminder();
|
|
|
|
|
const int dialogsAfter = mudlet::self()->findChildren<QDialog*>().count();
|
|
|
|
|
QVERIFY2(dialogsAfter > dialogsBefore, "showMapperScriptReminder must raise a reminder dialog owned by the main window.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The package-unpacking progress dialog is now owned by the console and
|
|
|
|
|
// shown from Host's signal payload. A second show must replace (not stack
|
|
|
|
|
// on top of) the first, and closing must dispose of it.
|
|
|
|
|
void test_unpackingProgressDialogReplacedAndClosed()
|
|
|
|
|
{
|
|
|
|
|
startProfile(mHostname, mLocalhost, mPort);
|
|
|
|
|
auto host = mudlet::self()->getActiveHost();
|
|
|
|
|
QVERIFY2(host, "No active host available for the test.");
|
|
|
|
|
QVERIFY2(host->mpConsole, "The active host has no main console.");
|
|
|
|
|
|
|
|
|
|
auto console = host->mpConsole;
|
|
|
|
|
QVERIFY2(!console->mpUnpackingDialog, "There must be no unpacking dialog before one is requested.");
|
|
|
|
|
|
|
|
|
|
console->showUnpackingProgress(qsl("Unpacking package:\n\"first\"\nplease wait..."), qsl("Unpacking"));
|
|
|
|
|
QVERIFY2(console->mpUnpackingDialog, "showUnpackingProgress must create a dialog.");
|
|
|
|
|
QCOMPARE(console->mpUnpackingDialog->windowTitle(), qsl("Unpacking"));
|
|
|
|
|
if (auto* pLabel = console->mpUnpackingDialog->findChild<QLabel*>(qsl("label"))) {
|
|
|
|
|
QVERIFY2(pLabel->text().contains(qsl("first")), "The dialog label did not carry the message payload.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Track the first dialog: a replacement must dispose of it, not leak it
|
|
|
|
|
// (the dialog is parentless, so nothing else would ever delete it).
|
|
|
|
|
QPointer<QDialog> firstDialog = console->mpUnpackingDialog;
|
|
|
|
|
console->showUnpackingProgress(qsl("Unpacking package:\n\"second\"\nplease wait..."), qsl("Unpacking"));
|
|
|
|
|
QVERIFY2(console->mpUnpackingDialog, "A replacement unpacking dialog must exist.");
|
|
|
|
|
QVERIFY2(console->mpUnpackingDialog != firstDialog, "The replacement must be a distinct dialog.");
|
|
|
|
|
if (auto* pLabel = console->mpUnpackingDialog->findChild<QLabel*>(qsl("label"))) {
|
|
|
|
|
QVERIFY2(pLabel->text().contains(qsl("second")), "The replacement dialog did not carry the new message payload.");
|
|
|
|
|
}
|
|
|
|
|
QTest::qWait(50ms); // let the replaced dialog's queued deleteLater() run
|
|
|
|
|
QVERIFY2(!firstDialog, "Replacing the unpacking dialog must dispose of the previous one, not leak it.");
|
|
|
|
|
|
|
|
|
|
console->closeUnpackingProgress();
|
|
|
|
|
QTest::qWait(50ms);
|
|
|
|
|
QVERIFY2(!console->mpUnpackingDialog, "closeUnpackingProgress must dispose of the dialog.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Regression guard: showUnpackingProgress() spins the event loop via
|
|
|
|
|
// processEvents(). A deferred install completion can deliver a re-entrant
|
|
|
|
|
// close (or a second show) during that spin, disposing of the dialog and
|
|
|
|
|
// clearing mpUnpackingDialog. The frame must not then dereference the member.
|
|
|
|
|
// Before the fix it did (mpUnpackingDialog->raise() on a nulled member) and
|
|
|
|
|
// crashed; now it drives a local pointer, so reaching this test's end without
|
|
|
|
|
// a crash is the assertion.
|
|
|
|
|
void test_reentrantUnpackingProgressDoesNotCrash()
|
|
|
|
|
{
|
|
|
|
|
startProfile(mHostname, mLocalhost, mPort);
|
|
|
|
|
auto host = mudlet::self()->getActiveHost();
|
|
|
|
|
QVERIFY2(host, "No active host available for the test.");
|
|
|
|
|
QVERIFY2(host->mpConsole, "The active host has no main console.");
|
|
|
|
|
auto console = host->mpConsole;
|
|
|
|
|
|
|
|
|
|
// Queue a re-entrant close to fire while showUnpackingProgress() is inside
|
|
|
|
|
// its first processEvents(), mimicking a deferred install completion.
|
|
|
|
|
QMetaObject::invokeMethod(
|
|
|
|
|
qApp,
|
|
|
|
|
[console]() {
|
|
|
|
|
console->closeUnpackingProgress();
|
|
|
|
|
},
|
|
|
|
|
Qt::QueuedConnection);
|
|
|
|
|
|
|
|
|
|
console->showUnpackingProgress(qsl("Unpacking package:\n\"reentrant\"\nplease wait..."), qsl("Unpacking"));
|
|
|
|
|
|
|
|
|
|
QTest::qWait(50ms);
|
|
|
|
|
QVERIFY2(!console->mpUnpackingDialog, "The re-entrant close should have left no unpacking dialog behind.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The tests above drive the console's handlers directly, so they would all
|
|
|
|
|
// still pass if the Host -> console connections made in
|
|
|
|
|
// mudlet::addConsoleForNewHost() were lost (that function is a merge-conflict
|
|
|
|
|
// hot spot). This one installs a real package instead, so the show and hide
|
|
|
|
|
// signals have to travel the production wiring to reach the dialog.
|
|
|
|
|
void test_unpackingDialogDrivenByInstall()
|
|
|
|
|
{
|
|
|
|
|
startProfile(mHostname, mLocalhost, mPort);
|
|
|
|
|
auto host = mudlet::self()->getActiveHost();
|
|
|
|
|
QVERIFY2(host, "No active host available for the test.");
|
|
|
|
|
QVERIFY2(host->mpConsole, "The active host has no main console.");
|
|
|
|
|
auto console = host->mpConsole;
|
|
|
|
|
|
|
|
|
|
QTemporaryDir packageDir;
|
|
|
|
|
QVERIFY2(packageDir.isValid(), "Could not create a temporary directory for the test package.");
|
fix: package lifecycle - queued save outliving the profile, unremovable archives, module priority (#9684)
#### Brief overview of PR changes/additions
- The profile save that installing or uninstalling a package owes is now
held in a member `QTimer` that the profile close and `~Host()` stop,
instead of a `QTimer::singleShot()` queued on the `Host`: that call was
still delivered after `HostManager::deleteHost()` had destroyed the
profile, and `Host::saveProfile()` then read freed members.
- `installPackage()` refuses an archive it could read no package out of
and takes the folder it unpacked back off disk - but only a folder
inside the profile, since the name can be whatever an untrusted
`config.lua` says. It answered `true` for such an archive before,
leaving something registered nowhere that could never be uninstalled.
- `getModulePriority()` asks `mInstalledModules` whether the module
exists, the same list `setModulePriority()` uses, and reports the
default priority of 0 for one nobody has prioritised yet.
#### Motivation for adding to Mudlet
Uninstall a package, close Mudlet, and the queued save runs against the
destroyed profile - a heap use-after-free on the way out, which is what
a "Mudlet crashed when I closed it" report looks like. Reproduced under
AddressSanitizer, clean afterwards. The other two are smaller but
user-visible: picking the wrong zip in the package manager reported
success and left a folder behind that nothing could remove, and a script
could not tell "module not installed" from "installed, never
prioritised".
#### Other info (issues closed, discussion etc)
Closes #9653, closes #9654, closes #9655.
Test case: `installPackage("something.mpackage")`,
`uninstallPackage("something")`, then close Mudlet straight away - it
exits cleanly; `installPackage()` on a zip with no package XML in it now
answers `nil` plus a message and leaves nothing behind;
`getModulePriority()` on a freshly installed module answers `0`.
The package lifecycle specs carried the last two as `pending()`; both
are flipped to real specs, and a new `PackageUninstallSaveTeardownTest`
covers the save deferral, its coalescing, the profile close, and that a
refused archive can only take its own folder with it.
Assisted-by: Claude:claude-opus-5
2026-08-05 19:55:59 +02:00
|
|
|
const QString packageName = qsl("HostWidgetDecouplingPackage");
|
|
|
|
|
const QString packagePath = packageDir.filePath(qsl("%1.zip").arg(packageName));
|
|
|
|
|
QVERIFY2(writePackageArchive(packagePath, packageName), "Could not write the test package archive.");
|
infrastructure: decouple profile management (Host) from UI dialogs (#9514)
#### Brief overview of PR changes/additions
- Removes all raw Qt Widgets usage from `Host.{h,cpp}` so the
`mudlet_core` Qt Widgets audit (`cmake/audit-core-widgets.sh`, added in
#9508) drops both files to zero: the offending-file count goes from 158
to 156 and both move to the "Clean files" list. (The committed
report/baseline are intentionally not regenerated here, to avoid
conflicts with sibling wave-2 PRs.)
- Follows the seam pattern established by #9507: the core (`Host`) emits
Qt signals carrying already-translated `tr()` strings, and the frontend
(`TMainConsole`/`mudlet`) owns the actual widgets.
- The dockable map widget (`mpDockableMapWidget`, a `QDockWidget`) moved
from `Host` to the profile's own `TMainConsole`.
`TMainConsole::createMapperDock()` constructs it and the console's
destructor disposes of it. `Host` still drives it through
`mpConsole->mpDockableMapWidget` (an already out-of-scope pointer per
the split plan) but no longer names any Qt Widgets type. The external
accessors in `mudlet.cpp`/`TDetachedWindow.cpp` gained an `mpConsole &&`
null-guard.
- The mapping-script reminder and package-unpacking progress dialogs are
now shown by the frontend in response to
`signal_showMapperScriptReminder` / `signal_showUnpackingProgress` /
`signal_hideUnpackingProgress`, wired up in
`mudlet::addConsoleForNewHost`.
- `TDockWidget` now sets its own dock features (moved out of
`Host::openWindow`); `Host::setBorders` uses
`QCoreApplication::sendEvent`; and the user-window scrollbar is hidden
via `TConsole::setScrollBarVisible()` instead of reaching into the raw
`QScrollBar`.
- Adds `HostWidgetDecouplingTest` (ephemeral port-0 stub + a real
profile, modelled on `TelnetTlsPromptTest`): verifies the map dock is
created and owned by the console, that `setMapperTitle` routes through
it, that the reminder dialog is raised, and that the unpacking dialog is
replaced then disposed (asserting the replaced dialog is destroyed, not
leaked). Two further tests cover the seams end to end: a real package
install has to reach the dialog through the `addConsoleForNewHost`
wiring, and closing a profile has to take the console-owned map dock
with it.
#### Motivation for adding to Mudlet
Continues the re-scoped libmudlet plan (a Qt Widgets-free `mudlet_core`
for headless use, testability and WASM). `Host` is the second concrete
extraction after `cTelnet` (#9507) and copies its template so later
extractions can follow the same shape.
#### Other info (issues closed, discussion etc)
Part of #8681 / #9011. Behavior-preserving: dialogs keep the same
modality/defaults and all strings stay in `Host`'s translation context,
so existing translations are unaffected. One intentional behaviour
change: failing to load the cosmetic unpacking/reminder `.ui` now warns
and no-ops instead of aborting the package install (a fire-and-forget
signal cannot fail the install back to `Host`), which is strictly
better. The dock's `deleteLater()` cleanup moved from `Host`'s
destructor to `TMainConsole`'s.
Reviewed with the code-reviewer and silent-failure-hunter agents; both
flagged a leak in the unpacking-dialog replace path (parentless dialog
`close()`d instead of `deleteLater()`d) and the missing destructor
cleanup, both fixed and now covered by the test. Expect the `mudlet.cpp`
`addConsoleForNewHost` wiring to conflict with sibling wave-2 PRs; that
is fine.
Assisted-by: Claude:claude-opus-4-8
**Test case:** Open a profile and open the mapper via the Map toolbar
button - it appears docked as before, and its title can be changed with
`setMapperTitle(...)`. Install a `.zip`/`.mpackage` from the package
manager (a normal package, not a module-from-UI and not a script/quiet
install) and confirm the "Unpacking..." progress dialog shows and then
closes. On a profile with no mapper script, open the mapper and confirm
the "you have no mapper script" reminder dialog appears and its link
opens the mapping scripts page.
#### Demo (before & after)
Parity check that the moved dialog/dock flows behave identically before
(development) and after this PR: the package-install "Unpacking..."
dialog and the dockable map widget.
https://github.com/user-attachments/assets/41579b67-7de1-463b-b642-bd7660b52431
2026-08-02 13:04:21 +02:00
|
|
|
|
|
|
|
|
// installPackage() postpones the whole install (and so emits nothing) if a
|
|
|
|
|
// profile save is still in flight from loading the profile.
|
|
|
|
|
QTRY_VERIFY(!host->currentlySavingProfile());
|
|
|
|
|
|
|
|
|
|
QSignalSpy showSpy(host, &Host::signal_showUnpackingProgress);
|
|
|
|
|
QSignalSpy hideSpy(host, &Host::signal_hideUnpackingProgress);
|
|
|
|
|
|
|
|
|
|
// Connected after the console's own handler, so it observes the dialog
|
|
|
|
|
// that handler has just put up - if the wiring is intact.
|
|
|
|
|
QObject captureContext;
|
|
|
|
|
bool dialogUpWhileUnpacking = false;
|
|
|
|
|
QPointer<QDialog> dialogWhileUnpacking;
|
|
|
|
|
connect(host, &Host::signal_showUnpackingProgress, &captureContext, [&](const QString&, const QString&) {
|
|
|
|
|
dialogWhileUnpacking = console->mpUnpackingDialog;
|
|
|
|
|
dialogUpWhileUnpacking = !dialogWhileUnpacking.isNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
auto [ok, message] = host->installPackage(packagePath, enums::PackageModuleType::Package, false);
|
|
|
|
|
QVERIFY2(ok, qPrintable(message));
|
|
|
|
|
|
|
|
|
|
QCOMPARE(showSpy.count(), 1);
|
|
|
|
|
QCOMPARE(hideSpy.count(), 1);
|
|
|
|
|
QVERIFY2(dialogUpWhileUnpacking, "Installing a package must put the unpacking dialog up via the Host signal.");
|
|
|
|
|
QVERIFY2(!console->mpUnpackingDialog, "Finishing the install must take the unpacking dialog down again.");
|
|
|
|
|
QTest::qWait(50ms); // let the dialog's queued deleteLater() run
|
|
|
|
|
QVERIFY2(dialogWhileUnpacking.isNull(), "The unpacking dialog was taken down but never disposed of.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The map dock moved from Host to TMainConsole, so disposing of it is now the
|
|
|
|
|
// console destructor's job. addDockWidget() reparents the dock onto the main
|
|
|
|
|
// window, which outlives the profile, so nothing else would clean it up.
|
|
|
|
|
void test_mapDockDestroyedOnProfileClose()
|
|
|
|
|
{
|
|
|
|
|
startProfile(mHostname, mLocalhost, mPort);
|
|
|
|
|
auto host = mudlet::self()->getActiveHost();
|
|
|
|
|
QVERIFY2(host, "No active host available for the test.");
|
|
|
|
|
QVERIFY2(host->mpConsole, "The active host has no main console.");
|
|
|
|
|
|
|
|
|
|
host->showHideOrCreateMapper(true);
|
|
|
|
|
QPointer<QDockWidget> dock = host->mpConsole->mpDockableMapWidget;
|
|
|
|
|
QVERIFY2(dock, "The mapper dock was not created.");
|
|
|
|
|
|
|
|
|
|
// Forcing the close stops TMainConsole::closeEvent() asking whether the
|
|
|
|
|
// profile should be saved, which would block on a modal dialog here.
|
|
|
|
|
// requestClose() is the half of the profile-close path that disposes of
|
|
|
|
|
// the console; the mudlet::closeHost() that normally follows it only
|
|
|
|
|
// removes the tab and the Host, and would reopen the connection dialog
|
|
|
|
|
// as the last profile went away.
|
|
|
|
|
host->forceClose();
|
|
|
|
|
QVERIFY2(host->requestClose(), "Closing the profile was refused.");
|
|
|
|
|
|
|
|
|
|
// Two chained deferred deletes to get through: the console (it carries
|
|
|
|
|
// WA_DeleteOnClose) and then, from its destructor, the dock.
|
|
|
|
|
QTest::qWait(500ms);
|
|
|
|
|
QVERIFY2(dock.isNull(), "Closing the profile must destroy the map dock the console owns.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void cleanup()
|
|
|
|
|
{
|
|
|
|
|
delete mpServer;
|
|
|
|
|
mpServer = nullptr;
|
|
|
|
|
deleteProfileDirectory(mHostname);
|
|
|
|
|
delete mudlet::self();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Utility function to manually start a profile like a user would do via the
|
|
|
|
|
// GUI
|
|
|
|
|
void startProfile(const QString& hostname, const QString& address, const QString& port)
|
|
|
|
|
{
|
|
|
|
|
QTimer::singleShot(0, qApp, [hostname, address, port]() {
|
|
|
|
|
mudlet::self()->startAutoLogin({});
|
|
|
|
|
QTest::qWait(100ms);
|
|
|
|
|
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
|
|
|
|
|
QTest::qWait(100ms);
|
|
|
|
|
QTest::keyClicks(QApplication::focusWidget(), hostname);
|
|
|
|
|
QTest::qWait(100ms);
|
|
|
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
|
|
|
|
|
QTest::qWait(100ms);
|
|
|
|
|
QTest::keyClicks(QApplication::focusWidget(), address);
|
|
|
|
|
QTest::qWait(100ms);
|
|
|
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
|
|
|
|
|
QTest::qWait(100ms);
|
|
|
|
|
QTest::keyClicks(QApplication::focusWidget(), port);
|
|
|
|
|
QTest::qWait(100ms);
|
|
|
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded);
|
|
|
|
|
if (!spy.wait(5s)) {
|
|
|
|
|
QFAIL("Profile took too long to load.");
|
|
|
|
|
}
|
|
|
|
|
auto host = mudlet::self()->getActiveHost();
|
|
|
|
|
if (!host) {
|
|
|
|
|
QFAIL("No active host available for the test.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QSignalSpy spy2(&(host->mTelnet), &cTelnet::signal_connected);
|
|
|
|
|
if (!spy2.wait(2s)) {
|
|
|
|
|
QFAIL("Could not connect with the host.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix: package lifecycle - queued save outliving the profile, unremovable archives, module priority (#9684)
#### Brief overview of PR changes/additions
- The profile save that installing or uninstalling a package owes is now
held in a member `QTimer` that the profile close and `~Host()` stop,
instead of a `QTimer::singleShot()` queued on the `Host`: that call was
still delivered after `HostManager::deleteHost()` had destroyed the
profile, and `Host::saveProfile()` then read freed members.
- `installPackage()` refuses an archive it could read no package out of
and takes the folder it unpacked back off disk - but only a folder
inside the profile, since the name can be whatever an untrusted
`config.lua` says. It answered `true` for such an archive before,
leaving something registered nowhere that could never be uninstalled.
- `getModulePriority()` asks `mInstalledModules` whether the module
exists, the same list `setModulePriority()` uses, and reports the
default priority of 0 for one nobody has prioritised yet.
#### Motivation for adding to Mudlet
Uninstall a package, close Mudlet, and the queued save runs against the
destroyed profile - a heap use-after-free on the way out, which is what
a "Mudlet crashed when I closed it" report looks like. Reproduced under
AddressSanitizer, clean afterwards. The other two are smaller but
user-visible: picking the wrong zip in the package manager reported
success and left a folder behind that nothing could remove, and a script
could not tell "module not installed" from "installed, never
prioritised".
#### Other info (issues closed, discussion etc)
Closes #9653, closes #9654, closes #9655.
Test case: `installPackage("something.mpackage")`,
`uninstallPackage("something")`, then close Mudlet straight away - it
exits cleanly; `installPackage()` on a zip with no package XML in it now
answers `nil` plus a message and leaves nothing behind;
`getModulePriority()` on a freshly installed module answers `0`.
The package lifecycle specs carried the last two as `pending()`; both
are flipped to real specs, and a new `PackageUninstallSaveTeardownTest`
covers the save deferral, its coalescing, the profile close, and that a
refused archive can only take its own folder with it.
Assisted-by: Claude:claude-opus-5
2026-08-05 19:55:59 +02:00
|
|
|
// Utility function producing the smallest package archive that installs: a
|
|
|
|
|
// zip holding one Mudlet package XML with nothing in it. An archive with no
|
|
|
|
|
// package XML at all is refused (it would install nowhere and could never be
|
|
|
|
|
// uninstalled), so the dialog wiring this test is about needs a real one.
|
|
|
|
|
bool writePackageArchive(const QString& path, const QString& packageName)
|
infrastructure: decouple profile management (Host) from UI dialogs (#9514)
#### Brief overview of PR changes/additions
- Removes all raw Qt Widgets usage from `Host.{h,cpp}` so the
`mudlet_core` Qt Widgets audit (`cmake/audit-core-widgets.sh`, added in
#9508) drops both files to zero: the offending-file count goes from 158
to 156 and both move to the "Clean files" list. (The committed
report/baseline are intentionally not regenerated here, to avoid
conflicts with sibling wave-2 PRs.)
- Follows the seam pattern established by #9507: the core (`Host`) emits
Qt signals carrying already-translated `tr()` strings, and the frontend
(`TMainConsole`/`mudlet`) owns the actual widgets.
- The dockable map widget (`mpDockableMapWidget`, a `QDockWidget`) moved
from `Host` to the profile's own `TMainConsole`.
`TMainConsole::createMapperDock()` constructs it and the console's
destructor disposes of it. `Host` still drives it through
`mpConsole->mpDockableMapWidget` (an already out-of-scope pointer per
the split plan) but no longer names any Qt Widgets type. The external
accessors in `mudlet.cpp`/`TDetachedWindow.cpp` gained an `mpConsole &&`
null-guard.
- The mapping-script reminder and package-unpacking progress dialogs are
now shown by the frontend in response to
`signal_showMapperScriptReminder` / `signal_showUnpackingProgress` /
`signal_hideUnpackingProgress`, wired up in
`mudlet::addConsoleForNewHost`.
- `TDockWidget` now sets its own dock features (moved out of
`Host::openWindow`); `Host::setBorders` uses
`QCoreApplication::sendEvent`; and the user-window scrollbar is hidden
via `TConsole::setScrollBarVisible()` instead of reaching into the raw
`QScrollBar`.
- Adds `HostWidgetDecouplingTest` (ephemeral port-0 stub + a real
profile, modelled on `TelnetTlsPromptTest`): verifies the map dock is
created and owned by the console, that `setMapperTitle` routes through
it, that the reminder dialog is raised, and that the unpacking dialog is
replaced then disposed (asserting the replaced dialog is destroyed, not
leaked). Two further tests cover the seams end to end: a real package
install has to reach the dialog through the `addConsoleForNewHost`
wiring, and closing a profile has to take the console-owned map dock
with it.
#### Motivation for adding to Mudlet
Continues the re-scoped libmudlet plan (a Qt Widgets-free `mudlet_core`
for headless use, testability and WASM). `Host` is the second concrete
extraction after `cTelnet` (#9507) and copies its template so later
extractions can follow the same shape.
#### Other info (issues closed, discussion etc)
Part of #8681 / #9011. Behavior-preserving: dialogs keep the same
modality/defaults and all strings stay in `Host`'s translation context,
so existing translations are unaffected. One intentional behaviour
change: failing to load the cosmetic unpacking/reminder `.ui` now warns
and no-ops instead of aborting the package install (a fire-and-forget
signal cannot fail the install back to `Host`), which is strictly
better. The dock's `deleteLater()` cleanup moved from `Host`'s
destructor to `TMainConsole`'s.
Reviewed with the code-reviewer and silent-failure-hunter agents; both
flagged a leak in the unpacking-dialog replace path (parentless dialog
`close()`d instead of `deleteLater()`d) and the missing destructor
cleanup, both fixed and now covered by the test. Expect the `mudlet.cpp`
`addConsoleForNewHost` wiring to conflict with sibling wave-2 PRs; that
is fine.
Assisted-by: Claude:claude-opus-4-8
**Test case:** Open a profile and open the mapper via the Map toolbar
button - it appears docked as before, and its title can be changed with
`setMapperTitle(...)`. Install a `.zip`/`.mpackage` from the package
manager (a normal package, not a module-from-UI and not a script/quiet
install) and confirm the "Unpacking..." progress dialog shows and then
closes. On a profile with no mapper script, open the mapper and confirm
the "you have no mapper script" reminder dialog appears and its link
opens the mapping scripts page.
#### Demo (before & after)
Parity check that the moved dialog/dock flows behave identically before
(development) and after this PR: the package-install "Unpacking..."
dialog and the dockable map widget.
https://github.com/user-attachments/assets/41579b67-7de1-463b-b642-bd7660b52431
2026-08-02 13:04:21 +02:00
|
|
|
{
|
fix: package lifecycle - queued save outliving the profile, unremovable archives, module priority (#9684)
#### Brief overview of PR changes/additions
- The profile save that installing or uninstalling a package owes is now
held in a member `QTimer` that the profile close and `~Host()` stop,
instead of a `QTimer::singleShot()` queued on the `Host`: that call was
still delivered after `HostManager::deleteHost()` had destroyed the
profile, and `Host::saveProfile()` then read freed members.
- `installPackage()` refuses an archive it could read no package out of
and takes the folder it unpacked back off disk - but only a folder
inside the profile, since the name can be whatever an untrusted
`config.lua` says. It answered `true` for such an archive before,
leaving something registered nowhere that could never be uninstalled.
- `getModulePriority()` asks `mInstalledModules` whether the module
exists, the same list `setModulePriority()` uses, and reports the
default priority of 0 for one nobody has prioritised yet.
#### Motivation for adding to Mudlet
Uninstall a package, close Mudlet, and the queued save runs against the
destroyed profile - a heap use-after-free on the way out, which is what
a "Mudlet crashed when I closed it" report looks like. Reproduced under
AddressSanitizer, clean afterwards. The other two are smaller but
user-visible: picking the wrong zip in the package manager reported
success and left a folder behind that nothing could remove, and a script
could not tell "module not installed" from "installed, never
prioritised".
#### Other info (issues closed, discussion etc)
Closes #9653, closes #9654, closes #9655.
Test case: `installPackage("something.mpackage")`,
`uninstallPackage("something")`, then close Mudlet straight away - it
exits cleanly; `installPackage()` on a zip with no package XML in it now
answers `nil` plus a message and leaves nothing behind;
`getModulePriority()` on a freshly installed module answers `0`.
The package lifecycle specs carried the last two as `pending()`; both
are flipped to real specs, and a new `PackageUninstallSaveTeardownTest`
covers the save deferral, its coalescing, the profile close, and that a
refused archive can only take its own folder with it.
Assisted-by: Claude:claude-opus-5
2026-08-05 19:55:59 +02:00
|
|
|
static const char packageXml[] = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
|
|
|
|
"<!DOCTYPE MudletPackage>\n"
|
|
|
|
|
"<MudletPackage version=\"1.001\">\n"
|
|
|
|
|
"<TriggerPackage /><TimerPackage /><AliasPackage /><ActionPackage />\n"
|
|
|
|
|
"<ScriptPackage /><KeyPackage /><VariablePackage><HiddenVariables /></VariablePackage>\n"
|
|
|
|
|
"</MudletPackage>\n";
|
|
|
|
|
|
|
|
|
|
int errorCode = 0;
|
|
|
|
|
zip* archive = zip_open(path.toUtf8().constData(), ZIP_CREATE | ZIP_TRUNCATE, &errorCode);
|
|
|
|
|
if (!archive) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
// sizeof - 1 to leave the terminating null out of the archived file
|
|
|
|
|
zip_source* source = zip_source_buffer(archive, packageXml, sizeof(packageXml) - 1, 0);
|
|
|
|
|
if (!source || zip_file_add(archive, qsl("%1.xml").arg(packageName).toUtf8().constData(), source, ZIP_FL_ENC_UTF_8) < 0) {
|
|
|
|
|
zip_source_free(source);
|
|
|
|
|
zip_discard(archive);
|
infrastructure: decouple profile management (Host) from UI dialogs (#9514)
#### Brief overview of PR changes/additions
- Removes all raw Qt Widgets usage from `Host.{h,cpp}` so the
`mudlet_core` Qt Widgets audit (`cmake/audit-core-widgets.sh`, added in
#9508) drops both files to zero: the offending-file count goes from 158
to 156 and both move to the "Clean files" list. (The committed
report/baseline are intentionally not regenerated here, to avoid
conflicts with sibling wave-2 PRs.)
- Follows the seam pattern established by #9507: the core (`Host`) emits
Qt signals carrying already-translated `tr()` strings, and the frontend
(`TMainConsole`/`mudlet`) owns the actual widgets.
- The dockable map widget (`mpDockableMapWidget`, a `QDockWidget`) moved
from `Host` to the profile's own `TMainConsole`.
`TMainConsole::createMapperDock()` constructs it and the console's
destructor disposes of it. `Host` still drives it through
`mpConsole->mpDockableMapWidget` (an already out-of-scope pointer per
the split plan) but no longer names any Qt Widgets type. The external
accessors in `mudlet.cpp`/`TDetachedWindow.cpp` gained an `mpConsole &&`
null-guard.
- The mapping-script reminder and package-unpacking progress dialogs are
now shown by the frontend in response to
`signal_showMapperScriptReminder` / `signal_showUnpackingProgress` /
`signal_hideUnpackingProgress`, wired up in
`mudlet::addConsoleForNewHost`.
- `TDockWidget` now sets its own dock features (moved out of
`Host::openWindow`); `Host::setBorders` uses
`QCoreApplication::sendEvent`; and the user-window scrollbar is hidden
via `TConsole::setScrollBarVisible()` instead of reaching into the raw
`QScrollBar`.
- Adds `HostWidgetDecouplingTest` (ephemeral port-0 stub + a real
profile, modelled on `TelnetTlsPromptTest`): verifies the map dock is
created and owned by the console, that `setMapperTitle` routes through
it, that the reminder dialog is raised, and that the unpacking dialog is
replaced then disposed (asserting the replaced dialog is destroyed, not
leaked). Two further tests cover the seams end to end: a real package
install has to reach the dialog through the `addConsoleForNewHost`
wiring, and closing a profile has to take the console-owned map dock
with it.
#### Motivation for adding to Mudlet
Continues the re-scoped libmudlet plan (a Qt Widgets-free `mudlet_core`
for headless use, testability and WASM). `Host` is the second concrete
extraction after `cTelnet` (#9507) and copies its template so later
extractions can follow the same shape.
#### Other info (issues closed, discussion etc)
Part of #8681 / #9011. Behavior-preserving: dialogs keep the same
modality/defaults and all strings stay in `Host`'s translation context,
so existing translations are unaffected. One intentional behaviour
change: failing to load the cosmetic unpacking/reminder `.ui` now warns
and no-ops instead of aborting the package install (a fire-and-forget
signal cannot fail the install back to `Host`), which is strictly
better. The dock's `deleteLater()` cleanup moved from `Host`'s
destructor to `TMainConsole`'s.
Reviewed with the code-reviewer and silent-failure-hunter agents; both
flagged a leak in the unpacking-dialog replace path (parentless dialog
`close()`d instead of `deleteLater()`d) and the missing destructor
cleanup, both fixed and now covered by the test. Expect the `mudlet.cpp`
`addConsoleForNewHost` wiring to conflict with sibling wave-2 PRs; that
is fine.
Assisted-by: Claude:claude-opus-4-8
**Test case:** Open a profile and open the mapper via the Map toolbar
button - it appears docked as before, and its title can be changed with
`setMapperTitle(...)`. Install a `.zip`/`.mpackage` from the package
manager (a normal package, not a module-from-UI and not a script/quiet
install) and confirm the "Unpacking..." progress dialog shows and then
closes. On a profile with no mapper script, open the mapper and confirm
the "you have no mapper script" reminder dialog appears and its link
opens the mapping scripts page.
#### Demo (before & after)
Parity check that the moved dialog/dock flows behave identically before
(development) and after this PR: the package-install "Unpacking..."
dialog and the dockable map widget.
https://github.com/user-attachments/assets/41579b67-7de1-463b-b642-bd7660b52431
2026-08-02 13:04:21 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
fix: package lifecycle - queued save outliving the profile, unremovable archives, module priority (#9684)
#### Brief overview of PR changes/additions
- The profile save that installing or uninstalling a package owes is now
held in a member `QTimer` that the profile close and `~Host()` stop,
instead of a `QTimer::singleShot()` queued on the `Host`: that call was
still delivered after `HostManager::deleteHost()` had destroyed the
profile, and `Host::saveProfile()` then read freed members.
- `installPackage()` refuses an archive it could read no package out of
and takes the folder it unpacked back off disk - but only a folder
inside the profile, since the name can be whatever an untrusted
`config.lua` says. It answered `true` for such an archive before,
leaving something registered nowhere that could never be uninstalled.
- `getModulePriority()` asks `mInstalledModules` whether the module
exists, the same list `setModulePriority()` uses, and reports the
default priority of 0 for one nobody has prioritised yet.
#### Motivation for adding to Mudlet
Uninstall a package, close Mudlet, and the queued save runs against the
destroyed profile - a heap use-after-free on the way out, which is what
a "Mudlet crashed when I closed it" report looks like. Reproduced under
AddressSanitizer, clean afterwards. The other two are smaller but
user-visible: picking the wrong zip in the package manager reported
success and left a folder behind that nothing could remove, and a script
could not tell "module not installed" from "installed, never
prioritised".
#### Other info (issues closed, discussion etc)
Closes #9653, closes #9654, closes #9655.
Test case: `installPackage("something.mpackage")`,
`uninstallPackage("something")`, then close Mudlet straight away - it
exits cleanly; `installPackage()` on a zip with no package XML in it now
answers `nil` plus a message and leaves nothing behind;
`getModulePriority()` on a freshly installed module answers `0`.
The package lifecycle specs carried the last two as `pending()`; both
are flipped to real specs, and a new `PackageUninstallSaveTeardownTest`
covers the save deferral, its coalescing, the profile close, and that a
refused archive can only take its own folder with it.
Assisted-by: Claude:claude-opus-5
2026-08-05 19:55:59 +02:00
|
|
|
return zip_close(archive) == 0;
|
infrastructure: decouple profile management (Host) from UI dialogs (#9514)
#### Brief overview of PR changes/additions
- Removes all raw Qt Widgets usage from `Host.{h,cpp}` so the
`mudlet_core` Qt Widgets audit (`cmake/audit-core-widgets.sh`, added in
#9508) drops both files to zero: the offending-file count goes from 158
to 156 and both move to the "Clean files" list. (The committed
report/baseline are intentionally not regenerated here, to avoid
conflicts with sibling wave-2 PRs.)
- Follows the seam pattern established by #9507: the core (`Host`) emits
Qt signals carrying already-translated `tr()` strings, and the frontend
(`TMainConsole`/`mudlet`) owns the actual widgets.
- The dockable map widget (`mpDockableMapWidget`, a `QDockWidget`) moved
from `Host` to the profile's own `TMainConsole`.
`TMainConsole::createMapperDock()` constructs it and the console's
destructor disposes of it. `Host` still drives it through
`mpConsole->mpDockableMapWidget` (an already out-of-scope pointer per
the split plan) but no longer names any Qt Widgets type. The external
accessors in `mudlet.cpp`/`TDetachedWindow.cpp` gained an `mpConsole &&`
null-guard.
- The mapping-script reminder and package-unpacking progress dialogs are
now shown by the frontend in response to
`signal_showMapperScriptReminder` / `signal_showUnpackingProgress` /
`signal_hideUnpackingProgress`, wired up in
`mudlet::addConsoleForNewHost`.
- `TDockWidget` now sets its own dock features (moved out of
`Host::openWindow`); `Host::setBorders` uses
`QCoreApplication::sendEvent`; and the user-window scrollbar is hidden
via `TConsole::setScrollBarVisible()` instead of reaching into the raw
`QScrollBar`.
- Adds `HostWidgetDecouplingTest` (ephemeral port-0 stub + a real
profile, modelled on `TelnetTlsPromptTest`): verifies the map dock is
created and owned by the console, that `setMapperTitle` routes through
it, that the reminder dialog is raised, and that the unpacking dialog is
replaced then disposed (asserting the replaced dialog is destroyed, not
leaked). Two further tests cover the seams end to end: a real package
install has to reach the dialog through the `addConsoleForNewHost`
wiring, and closing a profile has to take the console-owned map dock
with it.
#### Motivation for adding to Mudlet
Continues the re-scoped libmudlet plan (a Qt Widgets-free `mudlet_core`
for headless use, testability and WASM). `Host` is the second concrete
extraction after `cTelnet` (#9507) and copies its template so later
extractions can follow the same shape.
#### Other info (issues closed, discussion etc)
Part of #8681 / #9011. Behavior-preserving: dialogs keep the same
modality/defaults and all strings stay in `Host`'s translation context,
so existing translations are unaffected. One intentional behaviour
change: failing to load the cosmetic unpacking/reminder `.ui` now warns
and no-ops instead of aborting the package install (a fire-and-forget
signal cannot fail the install back to `Host`), which is strictly
better. The dock's `deleteLater()` cleanup moved from `Host`'s
destructor to `TMainConsole`'s.
Reviewed with the code-reviewer and silent-failure-hunter agents; both
flagged a leak in the unpacking-dialog replace path (parentless dialog
`close()`d instead of `deleteLater()`d) and the missing destructor
cleanup, both fixed and now covered by the test. Expect the `mudlet.cpp`
`addConsoleForNewHost` wiring to conflict with sibling wave-2 PRs; that
is fine.
Assisted-by: Claude:claude-opus-4-8
**Test case:** Open a profile and open the mapper via the Map toolbar
button - it appears docked as before, and its title can be changed with
`setMapperTitle(...)`. Install a `.zip`/`.mpackage` from the package
manager (a normal package, not a module-from-UI and not a script/quiet
install) and confirm the "Unpacking..." progress dialog shows and then
closes. On a profile with no mapper script, open the mapper and confirm
the "you have no mapper script" reminder dialog appears and its link
opens the mapping scripts page.
#### Demo (before & after)
Parity check that the moved dialog/dock flows behave identically before
(development) and after this PR: the package-install "Unpacking..."
dialog and the dockable map widget.
https://github.com/user-attachments/assets/41579b67-7de1-463b-b642-bd7660b52431
2026-08-02 13:04:21 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Utility function
|
|
|
|
|
void deleteProfileDirectory(const QString& profileName)
|
|
|
|
|
{
|
|
|
|
|
const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName);
|
|
|
|
|
QDir dir(path);
|
|
|
|
|
|
|
|
|
|
if (!dir.exists()) {
|
|
|
|
|
qInfo() << "Profile directory does not exist:" << path;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
dir.removeRecursively();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void initializeQRCResourcesForHostWidgetDecoupling()
|
|
|
|
|
{
|
|
|
|
|
#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN
|
|
|
|
|
qInitResources_additional_splash_screens();
|
|
|
|
|
#endif
|
|
|
|
|
#ifdef INCLUDE_FONTS
|
|
|
|
|
qInitResources_mudlet_fonts_common();
|
|
|
|
|
#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
|
|
|
|
|
qInitResources_mudlet_fonts_posix();
|
|
|
|
|
#endif
|
|
|
|
|
#endif
|
|
|
|
|
qInitResources_mudlet();
|
|
|
|
|
qInitResources_qm();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#include "HostWidgetDecouplingTest.moc"
|
|
|
|
|
QTEST_MAIN(HostWidgetDecouplingTest)
|