From 65a1080cd30f3985a49b0ba1e5e1ccc7d879c771 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Thu, 1 Oct 2026 08:00:55 +1300 Subject: [PATCH] Keep headless runs' settings in a folder of their own (QET_SETTINGS_DIR) The MCP server gives each QElectroTech run a private HOME and writes the element collection path for it (elements_dir) into ~/.config/QElectroTech/QElectroTech.conf there. Only Linux reads that file: Qt keeps settings in the registry on Windows and in the system preferences on macOS. So on those systems elements_dir did nothing, and every run read the user's own settings (#1178). When QET_SETTINGS_DIR names a folder, QElectroTech now keeps its settings in /QElectroTech/QElectroTech.ini on every system. It is set in main() before the first setting is read. Without the variable nothing changes. The server sets it for each run and writes the collection path to the .ini as well as the .conf, so an older QElectroTech keeps working on Linux. The path is written with forward slashes: Qt reads a backslash in these files as an escape. The README notes that elements_dir needs this on Windows and macOS, and that long arguments go on stdin with "-" (Windows refuses a command line over 32,767 characters). tst_settingsdir: a script places a symbol that only the folder's settings can resolve, while the usual settings file points at an empty collection. It fails on master and passes here. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 9 ++- misc/qet-mcp/qet_mcp.py | 45 +++++++++---- misc/qet-mcp/test_qet_mcp.py | 36 ++++++++++ sources/main.cpp | 14 ++++ tests/qttest/CMakeLists.txt | 12 ++++ tests/qttest/tst_settingsdir.cpp | 112 +++++++++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 14 deletions(-) create mode 100644 tests/qttest/tst_settingsdir.cpp diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 6beb31ff1..37a3a2cb8 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -161,6 +161,10 @@ python3 qet_mcp.py --call qet_elements '{"path": "drawing.qet"}' echo '{"path": "drawing.qet"}' | python3 qet_mcp.py --call qet_check - ``` +Pass long arguments, such as a large `qet_edit` operation list, on stdin +with `-`: Windows refuses a command line over 32,767 characters +(`WinError 206`). + It prints the tool's JSON result and exits 0, or 1 if the tool reported an error, or 2 if the call itself was malformed. The workspace rule applies as it does in a server. The sandbox has no QElectroTech in it, so only the @@ -472,7 +476,10 @@ Python, plus the hang guard on `addConductor` and the database refresh in collection path, which on a machine that never ran `make install` does not exist. The only symptom is `addElement` reporting that a file plainly present "does not resolve to an element". An absolute `.elmt` path works - without it. + without it. On Windows and macOS, `elements_dir` needs a QElectroTech that + reads `QET_SETTINGS_DIR` (#1178): an older one keeps its settings in the + registry or the system preferences, never sees the path written for the + run, and uses the collection it was installed with. - **`set_conductor` changes the whole potential, not one segment.** That is what the application does — a wire number describes a potential — so name a terminal carrying exactly one conductor and the change reaches every diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index f3f92539c..20485964d 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -64,7 +64,7 @@ import subprocess import sys import tempfile import xml.etree.ElementTree as ET -from pathlib import Path +from pathlib import Path, PurePath SERVER_NAME = "qet-mcp" SERVER_VERSION = "0.1.0" @@ -906,22 +906,40 @@ def _launch_executable(src: Path, sandbox: Path, windows: bool) -> Path: def _launch_env(env: dict, home: Path, windows: bool) -> dict: """The environment _run_qet() starts QElectroTech in. - A private HOME and XDG directories, and, except on Windows, Qt's - offscreen platform so no display is needed. The Windows packages ship - only the qwindows platform plugin: asked for "offscreen", Qt finds no - plugin and stops at a message box nobody can close, so every call hung - until its timeout. The export flags and --run open no window there, so - the default platform is what they need. + A private HOME and XDG directories, and QET_SETTINGS_DIR pointing into + them. HOME and XDG move QElectroTech's settings only on Linux: Qt keeps + them in the registry on Windows and in the user's preferences on + macOS, so without QET_SETTINGS_DIR every run there read the user's own + settings and never saw a collection path written for it + (qelectrotech-source-mirror#1178). A QElectroTech that knows the + variable keeps its settings in an INI file in that folder instead. + + Except on Windows, also Qt's offscreen platform so no display is + needed. The Windows packages ship only the qwindows platform plugin: + asked for "offscreen", Qt finds no plugin and stops at a message box + nobody can close, so every call hung until its timeout. The export + flags and --run open no window there, so the default platform is what + they need. """ env = dict(env, HOME=str(home), XDG_CONFIG_HOME=str(home / ".config"), - XDG_DATA_HOME=str(home / ".local" / "share")) + XDG_DATA_HOME=str(home / ".local" / "share"), + QET_SETTINGS_DIR=str(home / ".config")) if not windows: env["QT_QPA_PLATFORM"] = "offscreen" return env +def _collection_setting(collection: PurePath) -> str: + """The settings file that points QElectroTech at @p collection. + + Forward slashes: Qt reads a backslash in these files as an escape, so a + Windows path written as-is arrives mangled.""" + return ("[elements-collections]\n" + f"common-collection-path={collection.as_posix()}\n") + + def _run_qet(binary: str, args: list[str], timeout: int = 180, elements_dir: str | None = None, script: str | None = None, tail: int = 4000) -> dict: @@ -941,8 +959,10 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180, reporting "does not resolve to an element" for a file that is plainly there. elements_dir writes the one setting that fixes it. The file name is not free-choice: QSettings derives it from the organisation and - application names main.cpp sets before this branch runs, so it must be - QElectroTech/QElectroTech.conf and nothing else. + application names main.cpp sets before this branch runs. It is written + twice: QElectroTech/QElectroTech.ini is what a QElectroTech that knows + QET_SETTINGS_DIR reads, on every system (see _launch_env()), and + QElectroTech/QElectroTech.conf is what an older one reads on Linux. script, when given, is written into the sandbox and passed to --run. It lives inside the temporary directory so it cannot collide with a @@ -973,9 +993,8 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180, raise ValueError(f"no such elements directory: {coll}") cfg = home / ".config" / "QElectroTech" cfg.mkdir(parents=True, exist_ok=True) - (cfg / "QElectroTech.conf").write_text( - "[elements-collections]\n" - f"common-collection-path={coll}\n", encoding="utf-8") + for name in ("QElectroTech.ini", "QElectroTech.conf"): + (cfg / name).write_text(_collection_setting(coll), encoding="utf-8") if script is not None: script_path = sandbox / "qet-mcp-edit.js" script_path.write_text(script, encoding="utf-8") diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 083becf46..422c4a085 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -2632,6 +2632,13 @@ class LaunchExecutable(unittest.TestCase): self.assertEqual(env["QT_QPA_PLATFORM"], "offscreen") self.assertEqual(env["HOME"], str(self.sandbox)) + def test_settings_stay_in_the_sandbox_everywhere(self): + """HOME and XDG move the settings only on Linux; QET_SETTINGS_DIR + moves them on Windows and macOS too (#1178).""" + for windows in (True, False): + env = m._launch_env({}, self.sandbox, windows) + self.assertEqual(env["QET_SETTINGS_DIR"], str(self.sandbox / ".config")) + def test_elsewhere_runs_a_private_copy(self): exe = m._launch_executable(self.src, self.sandbox, False) self.assertEqual(exe.parent, self.sandbox) @@ -2639,6 +2646,35 @@ class LaunchExecutable(unittest.TestCase): self.assertEqual(exe.read_text(), self.src.read_text()) +class ElementsDirSetting(unittest.TestCase): + """elements_dir reaches QElectroTech as a settings file in the sandbox + (#1178): the .ini that a QElectroTech knowing QET_SETTINGS_DIR reads on + every system, and the .conf an older one reads on Linux.""" + + def test_both_files_carry_the_collection(self): + seen = {} + + def run(argv, **kwargs): + cfg = Path(kwargs["env"]["QET_SETTINGS_DIR"]) / "QElectroTech" + for f in cfg.iterdir(): + seen[f.name] = f.read_text(encoding="utf-8") + return subprocess.CompletedProcess(argv, 0, "", "") + + with tempfile.TemporaryDirectory() as coll, \ + mock.patch.object(m.subprocess, "run", run): + m._run_qet("/bin/true", ["x.qet"], elements_dir=coll) + want = m._collection_setting(Path(coll)) + self.assertEqual(seen, {"QElectroTech.ini": want, "QElectroTech.conf": want}) + self.assertIn(f"common-collection-path={Path(coll).as_posix()}", want) + + def test_a_windows_path_has_forward_slashes(self): + """Qt reads a backslash in the file as an escape.""" + from pathlib import PureWindowsPath + text = m._collection_setting(PureWindowsPath(r"C:\x\custom")) + self.assertIn("common-collection-path=C:/x/custom\n", text) + self.assertNotIn("\\", text) + + class ScriptingDisabledHint(unittest.TestCase): """QElectroTech may refuse to run scripts at all, and says so in French. diff --git a/sources/main.cpp b/sources/main.cpp index 92cbaf1be..5c8f78ab8 100644 --- a/sources/main.cpp +++ b/sources/main.cpp @@ -35,6 +35,7 @@ #include #include +#include #include #include @@ -103,6 +104,19 @@ int main(int argc, char **argv) QCoreApplication::setOrganizationDomain("qelectrotech.org"); QCoreApplication::setApplicationName("QElectroTech"); + // QET_SETTINGS_DIR keeps the settings in an INI file in that folder, + // /QElectroTech/QElectroTech.ini, instead of the registry on + // Windows, the system preferences on macOS or ~/.config on Linux. A tool + // running QElectroTech headlessly (misc/qet-mcp) can then give each run + // its own settings, and point it at an element collection, on every + // system (issue #1178). Set before anything reads a setting. + const QString settings_dir = qEnvironmentVariable("QET_SETTINGS_DIR"); + if (!settings_dir.isEmpty()) { + QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, settings_dir); + QSettings::setPath(QSettings::IniFormat, QSettings::SystemScope, settings_dir); + } + // Refuse invalid data when building QDom documents instead of // serializing malformed XML (CVE-2026-15037). This is the default // from Qt 6.12 on; opt in explicitly for older Qt 5/6. diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 1371aa6d6..93c0f1630 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -377,6 +377,18 @@ if(QET_HAS_SCRIPTING) target_compile_definitions(tst_scriptconductoruuid PRIVATE "QET_TEST_BINARY_PATH=\"$\"") + # QET_SETTINGS_DIR moves the settings into an INI file there (#1178): a + # script places a symbol only the folder's settings file can resolve. + add_executable( + tst_settingsdir + tst_settingsdir.cpp) + add_test(NAME tst_settingsdir COMMAND tst_settingsdir) + add_dependencies(tst_settingsdir qelectrotech) + target_link_libraries(tst_settingsdir PRIVATE Qt::Test) + target_compile_definitions(tst_settingsdir PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_ELEMENTS_DIR=\"${QET_DIR}/elements\"") + # The project database filled from the file as it is read holds exactly # what it holds filled from the built folios (QET_DATABASE_FROM_FOLIOS=1), # on every example saved once; an older file falls back, saying why. diff --git a/tests/qttest/tst_settingsdir.cpp b/tests/qttest/tst_settingsdir.cpp new file mode 100644 index 000000000..b3231cc76 --- /dev/null +++ b/tests/qttest/tst_settingsdir.cpp @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include + +// QET_SETTINGS_DIR makes QElectroTech keep its settings in an INI file in +// that folder (issue #1178). On Windows and macOS that is the only way to +// give a headless run its own settings: HOME and XDG_CONFIG_HOME move them +// on Linux alone. Checked here on Linux by making the usual settings file +// point at an empty collection and the one in QET_SETTINGS_DIR at a +// collection holding the symbol a script places: the symbol resolves only +// if the folder's file is the one read. +class tst_settingsdir : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + static void writeCollectionSetting(const QString &file, const QString &collection) + { + QDir().mkpath(QFileInfo(file).path()); + QFile f(file); + QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text)); + f.write("[elements-collections]\ncommon-collection-path="); + f.write(collection.toUtf8()); + f.write("\n"); + } + + // Runs a script placing common://custom/my_siren.elmt and returns what it + // logged: "resolved", "not resolved", or empty if the run itself failed. + QString placeTheSymbol(bool with_settings_dir) + { + const QString root = m_dir.filePath(with_settings_dir ? QStringLiteral("with") + : QStringLiteral("without")); + const QString home = root + QStringLiteral("/home"); + const QString settings = root + QStringLiteral("/settings"); + const QString collection = root + QStringLiteral("/collection"); + const QString empty_collection = root + QStringLiteral("/empty"); + QDir().mkpath(collection + QStringLiteral("/custom")); + QDir().mkpath(empty_collection); + QDir().mkpath(root + QStringLiteral("/tmp")); + if (!QFile::copy(QStringLiteral(QET_ELEMENTS_DIR "/10_electric/10_allpole/380_signaling_operating/12_acoustic_signaling/sirene.elmt"), + collection + QStringLiteral("/custom/my_siren.elmt"))) + return QString(); + + // The usual place (Linux): an empty collection. The folder: the right one. + writeCollectionSetting(home + QStringLiteral("/.config/QElectroTech/QElectroTech.conf"), + empty_collection); + writeCollectionSetting(settings + QStringLiteral("/QElectroTech/QElectroTech.ini"), + collection); + + const QString project = root + QStringLiteral("/p.qet"); + QFile::copy(QFINDTESTDATA("fixtures/qet_bug_repro_resaved.qet"), project); + const QString script = root + QStringLiteral("/add.js"); + QFile js(script); + if (!js.open(QIODevice::WriteOnly)) + return QString(); + js.write("var u = qet.addElement(0, 'common://custom/my_siren.elmt', 100, 100);\n" + "qet.log('RESULT ' + (u ? 'resolved' : 'not resolved'));\n"); + js.close(); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/.config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/.local/share")); + env.insert(QStringLiteral("TMPDIR"), root + QStringLiteral("/tmp")); + if (with_settings_dir) + env.insert(QStringLiteral("QET_SETTINGS_DIR"), settings); + else + env.remove(QStringLiteral("QET_SETTINGS_DIR")); + + QProcess proc; + proc.setProcessEnvironment(env); + proc.setProcessChannelMode(QProcess::MergedChannels); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), {QStringLiteral("--run"), script, project}); + if (!proc.waitForFinished(60000)) + return QString(); + const QString out = QString::fromUtf8(proc.readAll()); + static const QRegularExpression result(QStringLiteral("RESULT ([a-z ]+)")); + return result.match(out).captured(1).trimmed(); + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + } + + void theFolderIsRead() + { + QCOMPARE(placeTheSymbol(true), QStringLiteral("resolved")); + } + + // Without the variable nothing changes: the usual file is read, and it + // points at the empty collection. + void withoutItTheUsualPlaceIsRead() + { + QCOMPARE(placeTheSymbol(false), QStringLiteral("not resolved")); + } +}; + +QTEST_APPLESS_MAIN(tst_settingsdir) + +#include "tst_settingsdir.moc"