diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index fcedf5817..867729970 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 5c4d5188f..2603f6a78 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" @@ -1019,6 +1020,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; @@ -1083,6 +1085,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 09f66826f..f59263b61 100644 --- a/sources/cli_export.h +++ b/sources/cli_export.h @@ -94,6 +94,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 158cc2ae5..1a655ef51 100644 --- a/sources/projectview.cpp +++ b/sources/projectview.cpp @@ -383,6 +383,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); @@ -391,6 +394,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. @@ -773,6 +796,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 eec92dcc7..13e097e01 100644 --- a/sources/projectview.h +++ b/sources/projectview.h @@ -162,6 +162,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 e582ac26f..e7ddc1b65 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -1888,6 +1888,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; +} + /** Ouvre un projet depuis un fichier et l'ajoute a cet editeur @param filepath Chemin du projet a ouvrir @@ -1966,6 +2011,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); @@ -1998,6 +2077,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 c8ff27793..6dee63837 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 45ef03635..da6b7d1a7 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -2729,6 +2729,10 @@ QETResult QETProject::write() 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. @@ -2783,6 +2787,17 @@ QETResult QETProject::write() 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")); @@ -2807,6 +2822,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 e2c7f0f9d..d4df8bddc 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 @@ -337,6 +338,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); @@ -494,6 +499,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 ccbc24cdd..a8fa963dc 100644 --- a/sources/ui/configpage/generalconfigurationpage.cpp +++ b/sources/ui/configpage/generalconfigurationpage.cpp @@ -190,6 +190,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()); QString fontInfos = settings.value("diagramitemfont", "Liberation Sans").toString() + " " + settings.value("diagramitemsize", "9").toString() + " (" + @@ -417,6 +418,7 @@ void GeneralConfigurationPage::applyConf() } } settings.setValue("diagrameditor/autosave-interval", ui->m_autosave_sb->value()); + settings.setValue("projectlock/enabled", ui->m_project_lock->isChecked()); settings.setValue("diagrameditor/grid_display_startup", ui->grid_startup_cb->isChecked()); settings.setValue("diagrameditor/guides_display_startup", ui->guides_startup_cb->isChecked()); diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui index b0f0d0d6e..42ad7f262 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) + + + @@ -1531,6 +1541,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 32f68efc8..9b2ecbbc2 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"