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 <folder>/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 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-01 08:00:55 +13:00
parent 2a6155e0c9
commit 65a1080cd3
6 changed files with 214 additions and 14 deletions
+8 -1
View File
@@ -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
+32 -13
View File
@@ -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")
+36
View File
@@ -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.
+14
View File
@@ -35,6 +35,7 @@
#include <QDomImplementation>
#include <QFont>
#include <QSettings>
#include <QStyleFactory>
#include <QtConcurrentRun>
@@ -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,
// <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.
+12
View File
@@ -377,6 +377,18 @@ if(QET_HAS_SCRIPTING)
target_compile_definitions(tst_scriptconductoruuid PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"")
# 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=\"$<TARGET_FILE:qelectrotech>\""
"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.
+112
View File
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDir>
#include <QFile>
#include <QProcess>
#include <QProcessEnvironment>
#include <QRegularExpression>
#include <QTemporaryDir>
// 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"