diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake
index d80416c1e..cabd1ee3b 100644
--- a/cmake/qet_compilation_vars.cmake
+++ b/cmake/qet_compilation_vars.cmake
@@ -291,6 +291,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/qetmainwindow.h
${QET_DIR}/sources/qetmessagebox.cpp
${QET_DIR}/sources/qetmessagebox.h
+ ${QET_DIR}/sources/projectlock.cpp
+ ${QET_DIR}/sources/projectlock.h
${QET_DIR}/sources/qetproject.cpp
${QET_DIR}/sources/qetproject.h
${QET_DIR}/sources/qetregexpvalidator.cpp
diff --git a/sources/cli_export.cpp b/sources/cli_export.cpp
index 89e5ceb9f..8e8418fe6 100644
--- a/sources/cli_export.cpp
+++ b/sources/cli_export.cpp
@@ -16,6 +16,7 @@
along with QElectroTech. If not, see .
*/
#include "cli_export.h"
+#include "projectlock.h"
#include "bomexport.h"
#include "bordertitleblock.h"
@@ -1053,6 +1054,7 @@ int run(const QStringList &args)
// --no-slaves and --no-junctions leave rows out of --export-bom.
const bool includeSlaves = filtered.removeAll("--no-slaves") == 0;
const bool includeJunctions = filtered.removeAll("--no-junctions") == 0;
+ const bool ignoreLock = filtered.removeAll("--ignore-lock") > 0;
QString flag;
QStringList rest;
@@ -1127,6 +1129,19 @@ int run(const QStringList &args)
return exportNets(project, output);
if (format == "links")
return exportLinks(project, output);
+ //The verbs that write a project file leave alone one that someone
+ //has open (their lock beside it), unless told to: the read-only
+ //verbs above never look at locks
+ if ((format == "resave" || format == "settb") && !ignoreLock) {
+ const ProjectLock::Holder holder = ProjectLock::liveHolder(output);
+ if (holder.valid) {
+ err << "'" << output << "' is open by " << holder.user << " on "
+ << holder.host << " (lock file " << ProjectLock::lockPathFor(output)
+ << "). Not writing it; close it there, write to another file, "
+ "or pass --ignore-lock.\n";
+ return 1;
+ }
+ }
if (format == "resave")
return resaveProject(project, output);
if (format == "settb")
diff --git a/sources/cli_export.h b/sources/cli_export.h
index 357746403..c0ccd1e05 100644
--- a/sources/cli_export.h
+++ b/sources/cli_export.h
@@ -95,6 +95,12 @@ namespace CLIExport {
per-page element / conductor counts and unconnected terminals.
check-elements: validate .elmt file(s) against the element schema.
resave: load and rewrite the project XML (round-trip integrity).
+ --ignore-lock: resave and set-titleblock leave a file alone when a lock
+ file beside it (.~.lock, made by a QElectroTech that has
+ the project open) says someone else has it; this writes it
+ anyway. The verbs that only read a project never look at locks,
+ and none of them takes one. A lock made on this machine by a
+ process that is gone is stale and does not count.
set-titleblock: stamp title-block fields onto every folio, then save.
Keys: title, author, date (or date=today), plant, location,
revision, version, filename; any other key becomes a custom
diff --git a/sources/main.cpp b/sources/main.cpp
index 09aab1f21..7e0f34e29 100644
--- a/sources/main.cpp
+++ b/sources/main.cpp
@@ -240,6 +240,9 @@ int main(int argc, char **argv)
// runs on a background thread referencing the project and races the
// process exit (intermittent segfault in QET::writeToFile).
QETProject::setBackupEnabled(false);
+ // Headless runs read locks, to refuse writing a project someone
+ // has open, but never take one
+ ProjectLock::setHeadless(true);
// Answer message boxes instead of showing them: opening a project
// saved by an older QElectroTech raises a warning from
// QETProject::readProjectXml(), and with nobody able to dismiss it
@@ -255,6 +258,7 @@ int main(int argc, char **argv)
installHeadlessMessageHandler();
QApplication script_app(argc, argv);
QETProject::setBackupEnabled(false);
+ ProjectLock::setHeadless(true);
QET::QetMessageBox::setNonInteractive(true);
return QetScripting::run(headlessArguments(script_app.arguments()));
}
diff --git a/sources/projectlock.cpp b/sources/projectlock.cpp
new file mode 100644
index 000000000..f3ab47a07
--- /dev/null
+++ b/sources/projectlock.cpp
@@ -0,0 +1,385 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech 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.
+
+ QElectroTech 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 QElectroTech. If not, see .
+*/
+
+#include "projectlock.h"
+
+#include "qetversion.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef Q_OS_WIN
+# include
+#else
+# include
+# include
+#endif
+
+namespace {
+ bool g_headless = false;
+ int g_heartbeat_ms = 60000;
+ //A lock file that cannot be read yet may be one just created and
+ //not written: it counts as held for this long
+ constexpr int unreadable_grace_s = 10;
+
+ bool processAlive(qint64 pid)
+ {
+ if (pid <= 0) return false;
+#ifdef Q_OS_WIN
+ HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast(pid));
+ if (!h) return GetLastError() == ERROR_ACCESS_DENIED;
+ DWORD code = 0;
+ const bool alive = GetExitCodeProcess(h, &code) && code == STILL_ACTIVE;
+ CloseHandle(h);
+ return alive;
+#else
+ return ::kill(static_cast(pid), 0) == 0 || errno == EPERM;
+#endif
+ }
+
+ QString isoUtc(const QDateTime &t)
+ {
+ return t.toUTC().toString(Qt::ISODate);
+ }
+}
+
+/**
+ @brief ProjectLock::ProjectLock
+ @param parent
+*/
+ProjectLock::ProjectLock(QObject *parent) :
+ QObject(parent),
+ m_session(QUuid::createUuid().toString(QUuid::WithoutBraces))
+{
+ m_timer.setInterval(g_heartbeat_ms);
+ connect(&m_timer, &QTimer::timeout, this, &ProjectLock::refresh);
+}
+
+/**
+ @brief ProjectLock::~ProjectLock
+ Deletes the lock file if it is still ours.
+*/
+ProjectLock::~ProjectLock()
+{
+ release();
+}
+
+/**
+ @brief ProjectLock::lockPathFor
+ @return the lock file of the project @p project_path:
+ ".~plant.qetz" gives ".~plant.qetz.lock" in the same folder
+*/
+QString ProjectLock::lockPathFor(const QString &project_path)
+{
+ const QFileInfo info(project_path);
+ return info.absoluteDir().filePath(QStringLiteral(".~") + info.fileName() + QStringLiteral(".lock"));
+}
+
+/**
+ @brief ProjectLock::enabled
+ @return false when the user turned locking off (Settings, General,
+ Projects); on by default
+*/
+bool ProjectLock::enabled()
+{
+ QSettings settings;
+ return settings.value(QStringLiteral("projectlock/enabled"), true).toBool();
+}
+
+void ProjectLock::setEnabled(bool enabled)
+{
+ QSettings settings;
+ settings.setValue(QStringLiteral("projectlock/enabled"), enabled);
+}
+
+/**
+ @brief ProjectLock::mayTake
+ @return true when this process should take locks: locking is on and this
+ is not a headless run (those only read locks, to refuse a write)
+*/
+bool ProjectLock::mayTake()
+{
+ return !g_headless && enabled();
+}
+
+void ProjectLock::setHeadless(bool headless)
+{
+ g_headless = headless;
+}
+
+/**
+ @brief ProjectLock::setHeartbeatInterval
+ For tests. Applies to locks made afterwards.
+*/
+void ProjectLock::setHeartbeatInterval(int msec)
+{
+ g_heartbeat_ms = msec;
+}
+
+QString ProjectLock::thisHost()
+{
+ return QSysInfo::machineHostName();
+}
+
+QString ProjectLock::thisUser()
+{
+ for (const char *name : {"USER", "USERNAME", "LOGNAME"}) {
+ const QString user = qEnvironmentVariable(name);
+ if (!user.isEmpty()) return user;
+ }
+ return QStringLiteral("unknown");
+}
+
+/**
+ @brief ProjectLock::readHolder
+ @return what the lock file of @p project_path says; not valid when there
+ is none or it is not a lock
+*/
+ProjectLock::Holder ProjectLock::readHolder(const QString &project_path)
+{
+ Holder holder;
+ QFile file(lockPathFor(project_path));
+ if (!file.open(QIODevice::ReadOnly)) return holder;
+ const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
+ if (!doc.isObject()) return holder;
+ const QJsonObject o = doc.object();
+ if (o.value(QStringLiteral("format")).toInt() < 1 || !o.contains(QStringLiteral("session"))) return holder;
+ holder.session = o.value(QStringLiteral("session")).toString();
+ holder.user = o.value(QStringLiteral("user")).toString();
+ holder.host = o.value(QStringLiteral("host")).toString();
+ holder.pid = static_cast(o.value(QStringLiteral("pid")).toDouble());
+ holder.app = o.value(QStringLiteral("app")).toString();
+ holder.since = QDateTime::fromString(o.value(QStringLiteral("since")).toString(), Qt::ISODate);
+ holder.heartbeat = QDateTime::fromString(o.value(QStringLiteral("heartbeat")).toString(), Qt::ISODate);
+ holder.valid = !holder.session.isEmpty();
+ return holder;
+}
+
+/**
+ @brief ProjectLock::isStale
+ A lock is known to be dead only when it was made on this machine by a
+ process that no longer exists. Of a lock from another machine nothing is
+ known, whatever its age.
+*/
+bool ProjectLock::isStale(const Holder &holder)
+{
+ return holder.valid
+ && holder.host.compare(thisHost(), Qt::CaseInsensitive) == 0
+ && !processAlive(holder.pid);
+}
+
+/**
+ @brief ProjectLock::liveHolder
+ @param own_session the session of the caller, whose lock is not a conflict
+ @return who holds @p project_path now: not valid when nobody does, the
+ lock is stale or it is @p own_session's. A lock file that cannot be read
+ and is younger than a few seconds is a lock being written: held, by an
+ unknown holder.
+*/
+ProjectLock::Holder ProjectLock::liveHolder(const QString &project_path, const QString &own_session)
+{
+ const QString lock_path = lockPathFor(project_path);
+ if (!QFileInfo::exists(lock_path)) return Holder();
+ Holder holder = readHolder(project_path);
+ if (!holder.valid) {
+ const QDateTime modified = QFileInfo(lock_path).lastModified();
+ if (modified.secsTo(QDateTime::currentDateTime()) < unreadable_grace_s) {
+ holder.valid = true;
+ holder.session = QStringLiteral("?");
+ holder.user = holder.host = QStringLiteral("?");
+ holder.since = holder.heartbeat = modified;
+ return holder;
+ }
+ return Holder();
+ }
+ if (!own_session.isEmpty() && holder.session == own_session) return Holder();
+ if (isStale(holder)) return Holder();
+ return holder;
+}
+
+QByteArray ProjectLock::content(const QDateTime &since, const QDateTime &heartbeat) const
+{
+ QJsonObject scope;
+ scope.insert(QStringLiteral("kind"), QStringLiteral("project"));
+ QJsonObject o;
+ o.insert(QStringLiteral("format"), 1);
+ o.insert(QStringLiteral("session"), m_session);
+ o.insert(QStringLiteral("user"), thisUser());
+ o.insert(QStringLiteral("host"), thisHost());
+ o.insert(QStringLiteral("pid"), static_cast(QCoreApplication::applicationPid()));
+ o.insert(QStringLiteral("app"), QStringLiteral("QElectroTech %1").arg(QetVersion::currentVersion().toString()));
+ o.insert(QStringLiteral("since"), isoUtc(since));
+ o.insert(QStringLiteral("heartbeat"), isoUtc(heartbeat));
+ o.insert(QStringLiteral("scope"), QJsonArray{scope});
+ return QJsonDocument(o).toJson(QJsonDocument::Indented);
+}
+
+/**
+ @brief ProjectLock::createExclusive
+ Make the lock file, failing if it exists. NewOnly is O_EXCL / CREATE_NEW:
+ the check and the creation are one step on the file system, so two
+ sessions cannot both succeed, also over SMB.
+*/
+bool ProjectLock::createExclusive(const QString &lock_path)
+{
+ QFile file(lock_path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::NewOnly)) return false;
+ const QByteArray data = content(m_since, QDateTime::currentDateTimeUtc());
+ const bool ok = file.write(data) == data.size();
+ file.close();
+ if (!ok) QFile::remove(lock_path);
+ return ok;
+}
+
+void ProjectLock::hide(const QString &lock_path)
+{
+#ifdef Q_OS_WIN
+ const std::wstring path = QDir::toNativeSeparators(lock_path).toStdWString();
+ SetFileAttributesW(path.c_str(), FILE_ATTRIBUTE_HIDDEN);
+#else
+ Q_UNUSED(lock_path)
+#endif
+}
+
+/**
+ @brief ProjectLock::acquire
+ Take the lock of @p project_path.
+ @param take_over take it although another live session has it: its
+ lock file is replaced, and that session learns of it at its next heartbeat
+ @return Acquired; Held, with blockingHolder() saying by whom; or
+ Unavailable when the lock file cannot be made (read-only folder):
+ the caller opens the project as without locking
+*/
+ProjectLock::Result ProjectLock::acquire(const QString &project_path, bool take_over)
+{
+ release();
+ m_blocking = Holder();
+ const QString lock_path = lockPathFor(project_path);
+ m_since = QDateTime::currentDateTimeUtc();
+
+ for (int attempt = 0; attempt < 3; ++attempt)
+ {
+ if (createExclusive(lock_path)) {
+ // Read back: if two sessions removed a stale lock and made
+ // theirs at once, only the survivor's session is in the file
+ if (readHolder(project_path).session != m_session) continue;
+ m_project_path = project_path;
+ m_held = true;
+ hide(lock_path);
+ m_timer.setInterval(g_heartbeat_ms);
+ m_timer.start();
+ return Result::Acquired;
+ }
+ if (!QFileInfo::exists(lock_path)) {
+ //Not there and cannot be made
+ if (!QFileInfo(QFileInfo(lock_path).absolutePath()).isWritable())
+ return Result::Unavailable;
+ continue;
+ }
+ const Holder holder = liveHolder(project_path);
+ if (holder.valid && !take_over) {
+ m_blocking = holder;
+ return Result::Held;
+ }
+ //Stale, unreadable for good, or taken over: replace it
+ if (!QFile::remove(lock_path) && QFileInfo::exists(lock_path))
+ return Result::Unavailable;
+ }
+ return Result::Unavailable;
+}
+
+/**
+ @brief ProjectLock::moveTo
+ The project is now @p project_path (Save As, .qet saved as .qetz): take
+ the new lock, then let the old one go. Nothing changes when the new one
+ cannot be had.
+ @return true when the lock is now on @p project_path
+*/
+bool ProjectLock::moveTo(const QString &project_path)
+{
+ if (m_held && QFileInfo(project_path).absoluteFilePath() == QFileInfo(m_project_path).absoluteFilePath()) return true;
+ const QString old_path = m_project_path;
+ const bool was_held = m_held;
+ m_held = false; // keep release() from touching the old file
+ m_timer.stop();
+ const Result result = acquire(project_path);
+ if (result == Result::Acquired) {
+ if (was_held) {
+ const QString old_lock = lockPathFor(old_path);
+ if (readHolder(old_path).session == m_session) QFile::remove(old_lock);
+ }
+ return true;
+ }
+ if (was_held) { // go back to the old one
+ m_project_path = old_path;
+ m_held = true;
+ m_timer.start();
+ }
+ return false;
+}
+
+/**
+ @brief ProjectLock::release
+ Delete the lock file, if it is ours: a lock another session took over
+ is left to it.
+*/
+void ProjectLock::release()
+{
+ m_timer.stop();
+ if (!m_held) return;
+ m_held = false;
+ if (readHolder(m_project_path).session == m_session)
+ QFile::remove(lockPathFor(m_project_path));
+}
+
+/**
+ @brief ProjectLock::refresh
+ Write a fresh heartbeat. Called by the timer, every minute.
+ If the file is gone (a share dropped it) it is made again; if another
+ session's lock is there, ours was taken over: lockTaken() is emitted and
+ the lock is let go.
+ @return true when the lock is still ours
+*/
+bool ProjectLock::refresh()
+{
+ if (!m_held) return false;
+ const QString lock_path = lockPathFor(m_project_path);
+ if (QFileInfo::exists(lock_path)) {
+ const Holder holder = readHolder(m_project_path);
+ if (holder.valid && holder.session != m_session) {
+ m_held = false;
+ m_timer.stop();
+ emit lockTaken(holder);
+ return false;
+ }
+ QFile file(lock_path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) return true;
+ file.write(content(m_since, QDateTime::currentDateTimeUtc()));
+ return true;
+ }
+ if (createExclusive(lock_path)) hide(lock_path);
+ return true;
+}
diff --git a/sources/projectlock.h b/sources/projectlock.h
new file mode 100644
index 000000000..d3b464615
--- /dev/null
+++ b/sources/projectlock.h
@@ -0,0 +1,116 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech 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.
+
+ QElectroTech 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 QElectroTech. If not, see .
+*/
+
+#ifndef PROJECTLOCK_H
+#define PROJECTLOCK_H
+
+#include
+#include
+#include
+#include
+
+/**
+ @brief The ProjectLock class
+ A sidecar file beside a project that says who has it open, so that a second
+ person (or a second QElectroTech) opening the same file on a shared drive
+ is told, instead of both editing and the last save wiping the other's work.
+
+ The file is ".~.lock" in the project's folder, a small versioned
+ JSON object. It is not the project file itself: a project is saved by
+ writing a temporary file and renaming it over the old one, which would
+ replace a lock kept in the project at every save.
+
+ Taking the lock is an exclusive create (QFile NewOnly: O_EXCL, or
+ CREATE_NEW on Windows), the one operation shared drives do reliably. This is
+ not a QLockFile: its stale time defaults to 30 s, and for a lock made on
+ another machine it decides by age alone, so another PC would take a lock
+ held for over 30 s. Here a lock is stale only when it was made on this
+ machine by a process that is gone; any other lock is shown to the user, with
+ the age of its heartbeat, to judge.
+*/
+class ProjectLock : public QObject
+{
+ Q_OBJECT
+
+ public:
+ /// What a lock file says
+ struct Holder
+ {
+ bool valid = false; ///< false: the file could not be read as a lock
+ QString session;
+ QString user;
+ QString host;
+ qint64 pid = 0;
+ QString app;
+ QDateTime since;
+ QDateTime heartbeat;
+ };
+
+ enum class Result {
+ Acquired, ///< the lock is ours
+ Held, ///< a live lock of another session is there
+ Unavailable ///< the lock file cannot be made (read-only folder, ...)
+ };
+
+ explicit ProjectLock(QObject *parent = nullptr);
+ ~ProjectLock() override;
+
+ Result acquire(const QString &project_path, bool take_over = false);
+ bool moveTo(const QString &project_path);
+ void release();
+ bool refresh();
+
+ bool isHeld() const {return m_held;}
+ QString projectPath() const {return m_project_path;}
+ QString session() const {return m_session;}
+ Holder blockingHolder() const {return m_blocking;}
+
+ static QString lockPathFor(const QString &project_path);
+ static Holder readHolder(const QString &project_path);
+ static bool isStale(const Holder &holder);
+ static Holder liveHolder(const QString &project_path, const QString &own_session = QString());
+
+ static bool enabled();
+ static void setEnabled(bool);
+ static bool mayTake();
+ static void setHeadless(bool);
+ static void setHeartbeatInterval(int msec);
+
+ static QString thisHost();
+ static QString thisUser();
+
+ signals:
+ /// Another session took the lock over (its "Open anyway")
+ void lockTaken(const ProjectLock::Holder &by);
+
+ private:
+ QByteArray content(const QDateTime &since, const QDateTime &heartbeat) const;
+ bool createExclusive(const QString &lock_path);
+ static void hide(const QString &lock_path);
+
+ QString m_project_path;
+ QString m_session;
+ QDateTime m_since;
+ bool m_held = false;
+ Holder m_blocking;
+ QTimer m_timer;
+};
+
+Q_DECLARE_METATYPE(ProjectLock::Holder)
+
+#endif
diff --git a/sources/projectview.cpp b/sources/projectview.cpp
index b6e85aef2..4764879f1 100644
--- a/sources/projectview.cpp
+++ b/sources/projectview.cpp
@@ -385,6 +385,9 @@ QString ProjectView::askUserForFilePath(bool assign) {
}
filepath += zipped ? ".qetz" : ".qet";
+ //Not onto a file someone else has open
+ if (!refuseIfLockedByOther(filepath)) return(QString());
+
if (assign) {
// assign the provided filepath to the currently edited project
m_project -> setFilePath(filepath);
@@ -393,6 +396,26 @@ QString ProjectView::askUserForFilePath(bool assign) {
return(filepath);
}
+/**
+ @brief ProjectView::refuseIfLockedByOther
+ @return false, after telling the user, when another session holds the
+ lock of the file @p filepath this project is about to be saved to
+*/
+bool ProjectView::refuseIfLockedByOther(const QString &filepath)
+{
+ if (!ProjectLock::mayTake()) return(true);
+ const ProjectLock::Holder holder = ProjectLock::liveHolder(
+ filepath, m_project && m_project->lock() ? m_project->lock()->session() : QString());
+ if (!holder.valid) return(true);
+ QMessageBox::warning(
+ this,
+ tr("Project in use", "message box title"),
+ tr("%1 is open by %2 on %3. Choose another file name, or save again "
+ "once it has been closed there.")
+ .arg(QFileInfo(filepath).fileName(), holder.user, holder.host));
+ return(false);
+}
+
/**
@return the QETResult object to be returned when it appears this project
view is not associated to any project.
@@ -819,6 +842,7 @@ QETResult ProjectView::doSave()
//and ask before replacing a .qetz already there
if (m_project->savesBesideOpenedFile()) {
const QString beside = m_project->besideContainerPath();
+ if (!refuseIfLockedByOther(beside)) return(QETResult());
const QString message = tr("This project was opened from %1. It is saved as %2, "
"beside it, in the zipped project format; %1 stays as "
"it is.\n\nTo write a file for a version of "
diff --git a/sources/projectview.h b/sources/projectview.h
index ec0c0feeb..57b82f279 100644
--- a/sources/projectview.h
+++ b/sources/projectview.h
@@ -163,6 +163,7 @@ class ProjectView : public QWidget
bool tryClosingElementEditors();
int tryClosingDiagrams();
QString askUserForFilePath(bool = true);
+ bool refuseIfLockedByOther(const QString &filepath);
QETResult noProjectResult() const;
private slots:
diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp
index a646f942a..f0b7fc371 100644
--- a/sources/qetdiagrameditor.cpp
+++ b/sources/qetdiagrameditor.cpp
@@ -1893,6 +1893,51 @@ bool QETDiagramEditor::closeProject(QETProject *project)
return(true);
}
+/**
+ @brief QETDiagramEditor::askAboutLock
+ Tell the user who has the project @p filepath open and let them choose.
+ Read-only is the default: opening anyway can lose the other person's
+ work, and a lock left by a crash on another machine cannot be told from
+ a live one, which is why the age of the last sign of life is shown.
+*/
+QETDiagramEditor::LockChoice QETDiagramEditor::askAboutLock(
+ const QString &filepath,
+ const ProjectLock::Holder &holder)
+{
+ const QString name = QFileInfo(filepath).fileName();
+ QString text;
+ if (holder.session == QLatin1String("?")) {
+ text = tr("%1 is being opened by another session of QElectroTech.").arg(name);
+ } else {
+ const QString since = QLocale::system().toString(holder.since.toLocalTime(), QLocale::ShortFormat);
+ text = tr("%1 is open by %2 on %3 since %4").arg(name, holder.user, holder.host, since);
+ const qint64 minutes = holder.heartbeat.isValid()
+ ? holder.heartbeat.secsTo(QDateTime::currentDateTimeUtc()) / 60
+ : -1;
+ if (minutes >= 0) {
+ text += minutes < 1
+ ? tr(" (active just now).")
+ : tr(" (active %n minute(s) ago).", nullptr, static_cast(minutes));
+ } else {
+ text += QLatin1Char('.');
+ }
+ }
+
+ QMessageBox box(QMessageBox::Warning, tr("Project in use", "message box title"), text, QMessageBox::NoButton, this);
+ box.setInformativeText(tr("Open it read-only to look at it without changing it. "
+ "Open it anyway only if you know that session is not "
+ "using it any more: both of you could then save over each other."));
+ QPushButton *read_only = box.addButton(tr("Open read-only"), QMessageBox::AcceptRole);
+ QPushButton *anyway = box.addButton(tr("Open anyway"), QMessageBox::DestructiveRole);
+ box.addButton(QMessageBox::Cancel);
+ box.setDefaultButton(read_only);
+ box.setEscapeButton(box.button(QMessageBox::Cancel));
+ box.exec();
+ if (box.clickedButton() == read_only) return LockChoice::ReadOnly;
+ if (box.clickedButton() == anyway) return LockChoice::OpenAnyway;
+ return LockChoice::Cancel;
+}
+
/**
@brief QETDiagramEditor::offerToRecover
@p filepath could not be read (@p reason): say why, and offer its newest
@@ -2080,6 +2125,40 @@ bool QETDiagramEditor::openAndAddProject(
}
}
+ //Is someone else working on this file? Taken before the project is
+ //read, so a cancel costs nothing. Never blocks opening: a folder
+ //the lock cannot be made in, or the setting off, opens as before.
+ std::unique_ptr lock;
+ bool opened_read_only_by_lock = false;
+ if (ProjectLock::mayTake() && filepath_info.isWritable())
+ {
+ lock = std::make_unique();
+ if (lock->acquire(filepath) == ProjectLock::Result::Held)
+ {
+ const ProjectLock::Holder holder = lock->blockingHolder();
+ bool take_over = false;
+ if (interactive) {
+ switch (askAboutLock(filepath, holder)) {
+ case LockChoice::Cancel:
+ return(false);
+ case LockChoice::OpenAnyway:
+ take_over = true;
+ break;
+ case LockChoice::ReadOnly:
+ break;
+ }
+ }
+ //Not interactive (an assistant opening it): read-only, which
+ //is the safe answer to a question nobody is there to ask
+ if (take_over && lock->acquire(filepath, true) == ProjectLock::Result::Acquired) {
+ // ours now
+ } else if (!take_over) {
+ lock.reset();
+ opened_read_only_by_lock = true;
+ }
+ }
+ }
+
//Create the project
DialogWaiting::instance(this);
@@ -2122,6 +2201,17 @@ bool QETDiagramEditor::openAndAddProject(
return(false);
}
+ if (opened_read_only_by_lock) project->setOpenedReadOnlyByLock(true);
+ if (lock && lock->isHeld()) {
+ connect(lock.get(), &ProjectLock::lockTaken, this, [this, project](const ProjectLock::Holder &by) {
+ statusBar()->showMessage(
+ tr("%1 on %2 has taken over %3. Save your changes with Save As to keep them.")
+ .arg(by.user, by.host, QFileInfo(project->filePath()).fileName()),
+ 30000);
+ });
+ project->setLock(std::move(lock));
+ }
+
QETApp::projectsRecentFiles() -> fileWasOpened(filepath);
addProject(project);
DialogWaiting::dropInstance();
diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h
index 41a8a365f..891d02e7a 100644
--- a/sources/qetdiagrameditor.h
+++ b/sources/qetdiagrameditor.h
@@ -20,6 +20,7 @@
#include "SearchAndReplace/ui/searchandreplacewidget.h"
#include "diagramcontext.h"
+#include "projectlock.h"
#include "qetmainwindow.h"
#include
@@ -75,6 +76,8 @@ class QETDiagramEditor : public QETMainWindow
QList openedProjects () const;
void addProjectView (ProjectView *);
bool openAndAddProject (const QString &, bool = true);
+ enum class LockChoice {ReadOnly, OpenAnyway, Cancel};
+ LockChoice askAboutLock (const QString &, const ProjectLock::Holder &);
QList editedFiles () const;
ProjectView *viewForFile (const QString &) const;
ProjectView *currentProjectView() const;
diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp
index 70061cbed..b7463fe81 100644
--- a/sources/qetproject.cpp
+++ b/sources/qetproject.cpp
@@ -2805,6 +2805,10 @@ QETResult QETProject::write(bool overwrite_changed)
if (m_file_path.isEmpty())
return(QString("unable to save project to file: no filepath was specified"));
+ //Another session holds this file: it was opened read-only for that
+ if (!m_locked_out_path.isEmpty() && m_locked_out_path == m_file_path)
+ return(tr("%1 was opened read-only because someone else has it open. Use Save As to keep your changes in another file.").arg(m_file_path));
+
//A project read from a .qet is saved as a .qetz beside it; the
//.qet stays as it was (stage D1). If the .qetz cannot be written
//(a read-only folder), the project stays the .qet it was.
@@ -2877,6 +2881,17 @@ QETResult QETProject::write(bool overwrite_changed)
if (isReadOnly())
setReadOnly(false);
+ //The file may be a new one (Save As, a .qet saved as .qetz): the lock
+ //follows it. A project that had none (new, or opened read-only for
+ //another's lock) takes one now. Never stops the save: it is done.
+ if (ProjectLock::mayTake()) {
+ if (!m_lock) m_lock = std::make_unique();
+ if (!m_lock->isHeld() || m_lock->projectPath() != m_file_path) {
+ if (m_lock->isHeld()) m_lock->moveTo(m_file_path);
+ else m_lock->acquire(m_file_path);
+ }
+ }
+
//title block variables should be updated after file save dialog is confirmed, before file is saved.
m_project_properties.addValue("saveddate", QLocale::system().toString(QDate::currentDate(), QLocale::ShortFormat));
m_project_properties.addValue("saveddate-us", QDate::currentDate().toString("yyyy-MM-dd"));
@@ -2901,6 +2916,27 @@ bool QETProject::isReadOnly() const
return(m_read_only && read_only_file_path_ == m_file_path);
}
+/**
+ @brief QETProject::setLock
+ Hand the lock sidecar taken for this project's file to the project: it
+ is released when the project is closed.
+*/
+void QETProject::setLock(std::unique_ptr lock)
+{
+ m_lock = std::move(lock);
+}
+
+/**
+ @brief QETProject::setOpenedReadOnlyByLock
+ Open this project read-only because another session has it: write()
+ does not write to this file (Save As to another file is allowed).
+*/
+void QETProject::setOpenedReadOnlyByLock(bool by_lock)
+{
+ m_locked_out_path = by_lock ? m_file_path : QString();
+ if (by_lock) setReadOnly(true);
+}
+
/**
@brief QETProject::setReadOnly
Set this project to read only if read_only = true
diff --git a/sources/qetproject.h b/sources/qetproject.h
index 42aef41fb..8b0dbe26f 100644
--- a/sources/qetproject.h
+++ b/sources/qetproject.h
@@ -33,6 +33,7 @@
#include "diagram.h"
#include "autoNum/assignvariables.h"
#include "container/qetcontainerdb.h"
+#include "projectlock.h"
#ifdef BUILD_WITHOUT_KF
# include "ui/nokde/kautosavefile.h"
#else
@@ -342,6 +343,10 @@ class QETProject : public QObject
/// Opened from a .qetz of a later format (opened read-only)
bool isFromLaterVersion() const {return m_from_later_version;}
void setReadOnly(bool);
+ /// The lock sidecar this session holds on the project file, if any
+ ProjectLock *lock() const {return m_lock.get();}
+ void setLock(std::unique_ptr lock);
+ void setOpenedReadOnlyByLock(bool by_lock);
bool isEmpty() const;
ElementsLocation importElement(ElementsLocation &location);
QString integrateTitleBlockTemplate(const TitleBlockTemplateLocation &, MoveTitleBlockTemplatesHandler *handler);
@@ -499,6 +504,11 @@ class QETProject : public QObject
/// Whether the project is read only
bool m_read_only = false;
bool m_from_later_version = false;
+ /// The lock sidecar of the project file (see ProjectLock)
+ std::unique_ptr m_lock;
+ /// File this project was opened read-only for, because another
+ /// session holds its lock: write() does not write to it
+ QString m_locked_out_path;
/// Filepath for which this project is considered read only
QString read_only_file_path_;
/// Default dimensions and properties for new diagrams created within the project
diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp
index 3ffe17358..e40151823 100644
--- a/sources/scripting/qetscriptapi.cpp
+++ b/sources/scripting/qetscriptapi.cpp
@@ -42,6 +42,7 @@
#include "../dataBase/projectdatabase.h"
#include "../qetapp.h"
#include "../qetproject.h"
+#include "../projectlock.h"
#include "../qetresult.h"
#include "../qetgraphicsitem/conductor.h"
#include "../qetgraphicsitem/conductortextitem.h"
@@ -269,6 +270,9 @@ bool QetScriptApi::runFlag(const QString &flag, const QStringList &args)
}
QStringList full_args;
full_args << flag << path << args;
+ //The project's own file, which this session has locked, is no conflict
+ if (m_project->lock() && m_project->lock()->isHeld() && args.value(0) == path)
+ full_args << QStringLiteral("--ignore-lock");
if (m_view)
{
@@ -420,6 +424,19 @@ bool QetScriptApi::save(const QString &output)
{
if (!m_project) return false;
+ //Not onto a file someone else has open (their lock beside it); the
+ //lock this project holds itself is no conflict
+ {
+ const QString target = output.isEmpty() ? m_project->filePath() : output;
+ const ProjectLock::Holder holder = target.isEmpty() ? ProjectLock::Holder()
+ : ProjectLock::liveHolder(target, m_project->lock() ? m_project->lock()->session() : QString());
+ if (holder.valid) {
+ log(QStringLiteral("qet.save: %1 is open by %2 on %3 (lock file %4); not written -- pass another output path")
+ .arg(target, holder.user, holder.host, ProjectLock::lockPathFor(target)));
+ return false;
+ }
+ }
+
if (output.isEmpty())
{
if (m_project->filePath().isEmpty()) {
diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp
index 68ef91306..05688b006 100644
--- a/sources/ui/configpage/generalconfigurationpage.cpp
+++ b/sources/ui/configpage/generalconfigurationpage.cpp
@@ -191,6 +191,7 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
ui->m_use_folio_label->setChecked(settings.value("genericpanel/folio", true).toBool());
ui->m_border_0->setChecked(settings.value("border-columns_0", false).toBool());
ui->m_autosave_sb->setValue(settings.value("diagrameditor/autosave-interval", 0).toInt());
+ ui->m_project_lock->setChecked(settings.value("projectlock/enabled", true).toBool());
ui->m_previous_versions_sb->setValue(PreviousVersions::count());
QString fontInfos = settings.value("diagramitemfont", "Liberation Sans").toString() + " " +
@@ -419,6 +420,7 @@ void GeneralConfigurationPage::applyConf()
}
}
settings.setValue("diagrameditor/autosave-interval", ui->m_autosave_sb->value());
+ settings.setValue("projectlock/enabled", ui->m_project_lock->isChecked());
PreviousVersions::setCount(ui->m_previous_versions_sb->value());
settings.setValue("diagrameditor/grid_display_startup", ui->grid_startup_cb->isChecked());
diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui
index 88858fe8e..5a91496c9 100644
--- a/sources/ui/configpage/generalconfigurationpage.ui
+++ b/sources/ui/configpage/generalconfigurationpage.ui
@@ -337,6 +337,16 @@
+ -
+
+
+ A small hidden file beside the project says who has it open. Someone else opening it is told, and can open it read-only. Turn this off if your shared drive does not cope with it.
+
+
+ Tell others who has a project open (lock file beside the project)
+
+
+
-
@@ -387,7 +397,7 @@
- -
+
-
Qt::Vertical
@@ -1554,6 +1564,7 @@
m_use_tab_mode_rb
m_save_label_paste
m_autonumber_pasted
+ m_project_lock
m_use_folio_label
m_border_0
m_autosave_sb
diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt
index fdf34309e..df7bc5015 100644
--- a/tests/qttest/CMakeLists.txt
+++ b/tests/qttest/CMakeLists.txt
@@ -596,6 +596,17 @@ else()
target_link_libraries(tst_crashhandler PRIVATE Qt::Test Qt::Xml)
endif()
+# projectlock.cpp is the sidecar file that says who has a project open; it
+# needs only Qt Core and the version number, so it is linked on its own.
+add_executable(
+ tst_projectlock
+ tst_projectlock.cpp
+ ${QET_DIR}/sources/projectlock.cpp
+ ${QET_DIR}/sources/qetversion.cpp)
+add_test(NAME tst_projectlock COMMAND tst_projectlock)
+target_include_directories(tst_projectlock PRIVATE ${QET_DIR}/sources)
+target_link_libraries(tst_projectlock PRIVATE Qt::Test Qt::Xml)
+
# The crash-dump bookkeeping from #905: which dumps get listed, offered and
# deleted, and what redact() masks. qetlogger.cpp needs exactly one symbol
# from the application (QETApp::dataDir()), which the test supplies itself,
diff --git a/tests/qttest/tst_projectlock.cpp b/tests/qttest/tst_projectlock.cpp
new file mode 100644
index 000000000..8befad6e2
--- /dev/null
+++ b/tests/qttest/tst_projectlock.cpp
@@ -0,0 +1,279 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech 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.
+
+ QElectroTech 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 QElectroTech. If not, see .
+*/
+
+#include "projectlock.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#ifndef Q_OS_WIN
+# include
+#endif
+
+/**
+ ProjectLock -- the sidecar file saying who has a project open.
+*/
+class tst_projectlock : public QObject
+{
+ Q_OBJECT
+
+ private:
+ QTemporaryDir m_dir;
+ QString m_project;
+
+ static QJsonObject readJson(const QString &project)
+ {
+ QFile f(ProjectLock::lockPathFor(project));
+ if (!f.open(QIODevice::ReadOnly)) return QJsonObject();
+ return QJsonDocument::fromJson(f.readAll()).object();
+ }
+
+ //A lock file as another session would have made it
+ static void writeLock(const QString &project, const QString &host, qint64 pid,
+ const QDateTime &heartbeat, const QString &user = QStringLiteral("alice"))
+ {
+ QJsonObject o;
+ o["format"] = 1;
+ o["session"] = QStringLiteral("other-session");
+ o["user"] = user;
+ o["host"] = host;
+ o["pid"] = static_cast(pid);
+ o["app"] = QStringLiteral("QElectroTech test");
+ o["since"] = heartbeat.addSecs(-600).toUTC().toString(Qt::ISODate);
+ o["heartbeat"] = heartbeat.toUTC().toString(Qt::ISODate);
+ QFile f(ProjectLock::lockPathFor(project));
+ QVERIFY(f.open(QIODevice::WriteOnly));
+ f.write(QJsonDocument(o).toJson());
+ }
+
+ static qint64 deadPid()
+ {
+ QProcess p;
+ p.start(QStringLiteral("true"));
+ p.waitForFinished();
+ return p.processId() ? p.processId() : 999999;
+ }
+
+ private slots:
+ void initTestCase()
+ {
+ QCoreApplication::setOrganizationName(QStringLiteral("QElectroTech-tst_projectlock"));
+ QCoreApplication::setApplicationName(QStringLiteral("tst_projectlock"));
+ QSettings().clear();
+ QVERIFY(m_dir.isValid());
+ }
+
+ void init()
+ {
+ m_project = m_dir.filePath(QStringLiteral("plant.qetz"));
+ QFile::remove(ProjectLock::lockPathFor(m_project));
+ ProjectLock::setHeadless(false);
+ ProjectLock::setHeartbeatInterval(60000);
+ }
+
+ void sidecarNameAndContent()
+ {
+ QCOMPARE(QFileInfo(ProjectLock::lockPathFor(m_project)).fileName(),
+ QStringLiteral(".~plant.qetz.lock"));
+ ProjectLock lock;
+ QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
+ QVERIFY(lock.isHeld());
+ const QJsonObject o = readJson(m_project);
+ QCOMPARE(o["format"].toInt(), 1);
+ QCOMPARE(o["session"].toString(), lock.session());
+ QCOMPARE(o["user"].toString(), ProjectLock::thisUser());
+ QCOMPARE(o["host"].toString(), ProjectLock::thisHost());
+ QCOMPARE(static_cast(o["pid"].toDouble()), QCoreApplication::applicationPid());
+ QVERIFY(o["app"].toString().startsWith(QStringLiteral("QElectroTech")));
+ QVERIFY(QDateTime::fromString(o["since"].toString(), Qt::ISODate).isValid());
+ QVERIFY(QDateTime::fromString(o["heartbeat"].toString(), Qt::ISODate).isValid());
+ QCOMPARE(o["scope"].toArray().at(0).toObject()["kind"].toString(), QStringLiteral("project"));
+ }
+
+ void removedOnRelease()
+ {
+ {
+ ProjectLock lock;
+ QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
+ QVERIFY(QFileInfo::exists(ProjectLock::lockPathFor(m_project)));
+ }
+ QVERIFY(!QFileInfo::exists(ProjectLock::lockPathFor(m_project)));
+ }
+
+ void secondSessionSeesItHeld()
+ {
+ ProjectLock first;
+ QCOMPARE(first.acquire(m_project), ProjectLock::Result::Acquired);
+ ProjectLock second;
+ QCOMPARE(second.acquire(m_project), ProjectLock::Result::Held);
+ QVERIFY(!second.isHeld());
+ QCOMPARE(second.blockingHolder().session, first.session());
+ QCOMPARE(second.blockingHolder().user, ProjectLock::thisUser());
+ //and it did not disturb the first one's file
+ QCOMPARE(readJson(m_project)["session"].toString(), first.session());
+ //a released session does not remove another's lock
+ second.release();
+ QVERIFY(QFileInfo::exists(ProjectLock::lockPathFor(m_project)));
+ }
+
+ void takeOverReplacesAndTheOldOneIsTold()
+ {
+ ProjectLock first;
+ QCOMPARE(first.acquire(m_project), ProjectLock::Result::Acquired);
+ ProjectLock second;
+ QCOMPARE(second.acquire(m_project, true), ProjectLock::Result::Acquired);
+ QCOMPARE(readJson(m_project)["session"].toString(), second.session());
+ QSignalSpy taken(&first, &ProjectLock::lockTaken);
+ QVERIFY(!first.refresh());
+ QCOMPARE(taken.count(), 1);
+ //the old one must not delete the new one's file
+ first.release();
+ QCOMPARE(readJson(m_project)["session"].toString(), second.session());
+ }
+
+ void staleLockOfDeadProcessOnThisHostIsTaken()
+ {
+ writeLock(m_project, ProjectLock::thisHost(), deadPid(), QDateTime::currentDateTimeUtc());
+ QVERIFY(ProjectLock::isStale(ProjectLock::readHolder(m_project)));
+ QVERIFY(!ProjectLock::liveHolder(m_project).valid);
+ ProjectLock lock;
+ QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
+ QCOMPARE(readJson(m_project)["session"].toString(), lock.session());
+ }
+
+ void liveProcessOnThisHostIsHeldHoweverOld()
+ {
+ writeLock(m_project, ProjectLock::thisHost(), QCoreApplication::applicationPid(),
+ QDateTime::currentDateTimeUtc().addDays(-3));
+ ProjectLock lock;
+ QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Held);
+ }
+
+ void otherHostIsHeldHoweverOldAndShowsHeartbeat()
+ {
+ const QDateTime old_beat = QDateTime::currentDateTimeUtc().addSecs(-3 * 3600);
+ writeLock(m_project, QStringLiteral("WORKSHOP-PC"), 4120, old_beat);
+ ProjectLock lock;
+ QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Held);
+ QCOMPARE(lock.blockingHolder().host, QStringLiteral("WORKSHOP-PC"));
+ QCOMPARE(lock.blockingHolder().user, QStringLiteral("alice"));
+ QVERIFY(qAbs(lock.blockingHolder().heartbeat.secsTo(old_beat)) <= 1);
+ //the 30 s age that makes QLockFile decide "stale" does not apply
+ QVERIFY(ProjectLock::liveHolder(m_project).valid);
+ }
+
+ void heartbeatIsRefreshed()
+ {
+ ProjectLock lock;
+ QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
+ const QDateTime first = ProjectLock::readHolder(m_project).heartbeat;
+ const QString since = readJson(m_project)["since"].toString();
+ QTest::qWait(1100);
+ QVERIFY(lock.refresh());
+ QVERIFY(ProjectLock::readHolder(m_project).heartbeat > first);
+ //since stays what it was
+ QCOMPARE(readJson(m_project)["since"].toString(), since);
+ }
+
+ void heartbeatTimerRuns()
+ {
+ ProjectLock::setHeartbeatInterval(200);
+ ProjectLock lock;
+ QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
+ const QDateTime first = ProjectLock::readHolder(m_project).heartbeat;
+ QTRY_VERIFY_WITH_TIMEOUT(ProjectLock::readHolder(m_project).heartbeat > first, 4000);
+ }
+
+ void refreshMakesAgainAFileTheShareDropped()
+ {
+ ProjectLock lock;
+ QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
+ QFile::remove(ProjectLock::lockPathFor(m_project));
+ QVERIFY(lock.refresh());
+ QCOMPARE(readJson(m_project)["session"].toString(), lock.session());
+ }
+
+ void readOnlyFolderDoesNotBlock()
+ {
+#ifndef Q_OS_WIN
+ if (geteuid() == 0)
+ QSKIP("root can write anywhere");
+#endif
+ QTemporaryDir ro;
+ QVERIFY(ro.isValid());
+ const QString project = ro.filePath(QStringLiteral("plant.qet"));
+ QVERIFY(QFile::setPermissions(ro.path(), QFileDevice::ReadOwner | QFileDevice::ExeOwner));
+ ProjectLock lock;
+ QCOMPARE(lock.acquire(project), ProjectLock::Result::Unavailable);
+ QVERIFY(!lock.isHeld());
+ QFile::setPermissions(ro.path(), QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
+ }
+
+ void moveToFollowsSaveAs()
+ {
+ const QString other = m_dir.filePath(QStringLiteral("copy.qetz"));
+ QFile::remove(ProjectLock::lockPathFor(other));
+ ProjectLock lock;
+ QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
+ QVERIFY(lock.moveTo(other));
+ QVERIFY(!QFileInfo::exists(ProjectLock::lockPathFor(m_project)));
+ QCOMPARE(readJson(other)["session"].toString(), lock.session());
+ QCOMPARE(lock.projectPath(), other);
+ }
+
+ void moveToRefusedOntoAFileSomeoneElseHas()
+ {
+ const QString other = m_dir.filePath(QStringLiteral("theirs.qetz"));
+ writeLock(other, QStringLiteral("WORKSHOP-PC"), 4120, QDateTime::currentDateTimeUtc());
+ QVERIFY(ProjectLock::liveHolder(other, QStringLiteral("my-session")).valid);
+ ProjectLock lock;
+ QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
+ QVERIFY(!lock.moveTo(other));
+ //still holding the old file
+ QVERIFY(lock.isHeld());
+ QCOMPARE(readJson(m_project)["session"].toString(), lock.session());
+ QCOMPARE(readJson(other)["user"].toString(), QStringLiteral("alice"));
+ QFile::remove(ProjectLock::lockPathFor(other));
+ }
+
+ void ownLockIsNoConflict()
+ {
+ ProjectLock lock;
+ QCOMPARE(lock.acquire(m_project), ProjectLock::Result::Acquired);
+ QVERIFY(!ProjectLock::liveHolder(m_project, lock.session()).valid);
+ QVERIFY(ProjectLock::liveHolder(m_project).valid); // a headless caller has no session
+ }
+
+ void settingTurnsItOff()
+ {
+ QVERIFY(ProjectLock::enabled());
+ QVERIFY(ProjectLock::mayTake());
+ ProjectLock::setEnabled(false);
+ QVERIFY(!ProjectLock::mayTake());
+ ProjectLock::setEnabled(true);
+ ProjectLock::setHeadless(true);
+ QVERIFY(!ProjectLock::mayTake());
+ }
+};
+
+QTEST_GUILESS_MAIN(tst_projectlock)
+#include "tst_projectlock.moc"