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"