From d7f30d7bb1ca7e5f3e35c46dfa3329772f92c37b Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 19 Oct 2026 10:29:12 +0200 Subject: [PATCH] Activate the existing VibeChat window on repeated launches --- tools/chatgui/CMakeLists.txt | 9 + tools/chatgui/installer/README.txt | 6 + tools/chatgui/src/main.cpp | 33 +-- tools/chatgui/src/mainwindow.cpp | 16 +- tools/chatgui/src/single_instance.cpp | 186 +++++++++++++++++ tools/chatgui/src/single_instance.h | 44 ++++ tools/chatgui/tests/test_single_instance.cpp | 207 +++++++++++++++++++ 7 files changed, 473 insertions(+), 28 deletions(-) create mode 100644 tools/chatgui/src/single_instance.cpp create mode 100644 tools/chatgui/src/single_instance.h create mode 100644 tools/chatgui/tests/test_single_instance.cpp diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 1ab22edc..5ab20164 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -265,6 +265,7 @@ set_target_properties(utun_voice PROPERTIES POSITION_INDEPENDENT_CODE ON) add_executable(vibechat WIN32 src/main.cpp + src/single_instance.cpp src/mainwindow.cpp src/channellist.cpp src/creategroupdialog.cpp @@ -445,6 +446,14 @@ endif() enable_testing() +add_executable(test_single_instance tests/test_single_instance.cpp src/single_instance.cpp) +target_link_libraries(test_single_instance PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun pthread ${CMAKE_DL_LIBS}) +if(WIN32) + target_link_libraries(test_single_instance PRIVATE user32) +endif() +add_test(NAME test_single_instance COMMAND test_single_instance) +set_tests_properties(test_single_instance PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 30) + add_executable(test_message_notification tests/test_message_notification.cpp) target_link_libraries(test_message_notification PRIVATE chatgui_notifications ${QT_TEST_LIB} pthread ${CMAKE_DL_LIBS}) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") diff --git a/tools/chatgui/installer/README.txt b/tools/chatgui/installer/README.txt index bf585c61..66c69039 100644 --- a/tools/chatgui/installer/README.txt +++ b/tools/chatgui/installer/README.txt @@ -63,6 +63,12 @@ EXE собирается как GUI-приложение: запуск чере Лог приложения показывает режим окна и наличие консоли, лог установки — выбранный режим автозапуска и путь ярлыка. +Повторный запуск для того же профиля показывает окно уже работающего приложения, +в том числе скрытое в трей или свёрнутое через --minimized. Если открыт мастер +первой настройки или модальный диалог, активируется он. Второе ядро не запускается. +При недоступности работающего экземпляра запрос завершается ошибкой через 5 секунд. +Диагностика активации в логе: GUI instance / GUI activation. + При обновлении установщик предлагает закрыть приложение, использующее EXE/DLL. Обновление и удаление сохраняют конфиг, ключи, историю и медиа пользователя. Деинсталлятор удаляет своё правило firewall и установленные файлы программы. diff --git a/tools/chatgui/src/main.cpp b/tools/chatgui/src/main.cpp index 36cab95d..74bf51e2 100644 --- a/tools/chatgui/src/main.cpp +++ b/tools/chatgui/src/main.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include #include #include @@ -20,6 +19,7 @@ #include #include "gui_language.h" #include "call_camera_capture.h" +#include "single_instance.h" #include "config_parser.h" #include #include @@ -108,6 +108,8 @@ static struct ShutdownSentinel { int main(int argc, char *argv[]) { if (argc >= 2 && std::strcmp(argv[1], "--call-camera-worker") == 0) return call_camera_worker_main(argc, argv); debug_config_init(); + /* До чтения конфига видны запуск и IPC; затем уровень заменяет настройка профиля. */ + debug_set_level(DEBUG_LEVEL_INFO); #ifdef _WIN32 const bool consoleAttached = attach_parent_console(); #endif @@ -149,16 +151,14 @@ int main(int argc, char *argv[]) { } #endif - /* Блокировка до любых изменений конфига/БД; предупреждение после загрузки языка. */ - QLockFile lock(cfgDir + "/vibechat.lock"); - const bool locked = lock.tryLock(); -#ifdef _WIN32 - if (!locked && lock.error() != QLockFile::LockFailedError) { - DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Cannot lock data directory %s: error=%d; selection reason: %s", - qPrintable(cfgDir), static_cast(lock.error()), qPrintable(storageReason)); + /* Второй экземпляр передаёт запрос владельцу до чтения конфига и запуска ядра. */ + SingleInstance instance(cfgDir); + const auto role = instance.start(); + if (role == SingleInstance::Activated) return 0; + if (role == SingleInstance::Error) { + QMessageBox::critical(nullptr, "vibechat", instance.errorString()); return 1; } -#endif app.setStyle(QStyleFactory::create("Fusion")); QPalette p; @@ -181,7 +181,7 @@ int main(int argc, char *argv[]) { QString cfgPath = cfgDir + "/vibechat.cfg"; /* миграция: старый конфиг chatgui.cfg → vibechat.cfg */ - if (locked && !QFile::exists(cfgPath) && QFile::exists(cfgDir + "/chatgui.cfg")) + if (!QFile::exists(cfgPath) && QFile::exists(cfgDir + "/chatgui.cfg")) QFile::rename(cfgDir + "/chatgui.cfg", cfgPath); NodeConfig cfg(cfgPath); @@ -208,10 +208,6 @@ int main(int argc, char *argv[]) { }; QString language = resolveGuiLanguage(cfg.language()); if (!loadLanguage(language)) return 1; - if (!locked) { - QMessageBox::warning(nullptr, "vibechat", QCoreApplication::translate("ChatGui", "vibechat is already running.")); - return 1; - } bool needSave = false; const bool firstRun = !cfg.exists(); @@ -275,7 +271,10 @@ int main(int argc, char *argv[]) { nickname->setFocus(); nickname->selectAll(); DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "First-run setup: requesting nickname and language, config=%s", qPrintable(cfgPath)); - if (wizard.exec() != QDialog::Accepted) { + instance.setWindow(&wizard); + const int setupResult = wizard.exec(); + instance.setWindow(nullptr); + if (setupResult != QDialog::Accepted) { if (!languageReady) return 1; DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "First-run setup: cancelled, config not created"); return 0; @@ -341,8 +340,10 @@ int main(int argc, char *argv[]) { DbManager db; MainWindow w(nullptr, &db, cfgPath, dbPath, debugFile, cfg.debugLevel(), cfg.debugCategories()); + instance.setWindow(&w); if (minimized) w.showMinimized(); else w.show(); - DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "GUI startup: minimized_requested=%d minimized=%d", minimized, w.isMinimized()); + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "GUI startup: minimized_requested=%d minimized=%d activation_server=%s", + minimized, w.isMinimized(), qPrintable(instance.serverName())); #ifdef _WIN32 g_ctrl_node = w.utunNode(); diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index 8a4ddae2..8f9a2f72 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -1,5 +1,6 @@ #include #include "mainwindow.h" +#include "single_instance.h" #include "streamplayer_window.h" #include "stream_publish_engine.h" #include "stream_publish_window.h" @@ -943,10 +944,7 @@ void MainWindow::setupTray() { m_messageNotification = std::make_unique(); connect(m_messageNotification.get(), &MessageNotification::activated, this, [this](const QString& target) { - if (isMinimized()) setWindowState(windowState() & ~Qt::WindowMinimized); - show(); - raise(); - activateWindow(); + showAndActivateWindow(this); reloadChannels(); m_channelList->selectChannel(target); if (m_currentChannelId != target) GUI_WARN("notification: target no longer available target=%s", qPrintable(target)); @@ -961,10 +959,7 @@ void MainWindow::setupTray() { QAction *showAction = m_trayMenu->addAction(QCoreApplication::translate("ChatGui", "Show")); connect(showAction, &QAction::triggered, this, [this]() { - if (isMinimized()) setWindowState(windowState() & ~Qt::WindowMinimized); - show(); - raise(); - activateWindow(); + showAndActivateWindow(this); }); QAction *quitAction = m_trayMenu->addAction(QCoreApplication::translate("ChatGui", "Quit")); @@ -1028,10 +1023,7 @@ void MainWindow::onShutdownCheck() { void MainWindow::onTrayActivated(QSystemTrayIcon::ActivationReason reason) { if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) { - if (isMinimized()) setWindowState(windowState() & ~Qt::WindowMinimized); - show(); - raise(); - activateWindow(); + showAndActivateWindow(this); } } diff --git a/tools/chatgui/src/single_instance.cpp b/tools/chatgui/src/single_instance.cpp new file mode 100644 index 00000000..d972a9c5 --- /dev/null +++ b/tools/chatgui/src/single_instance.cpp @@ -0,0 +1,186 @@ +#include "single_instance.h" +#include "debug_config.h" +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#endif + +static const QByteArray activationRequest("ACTIVATE\n"); +static const QByteArray activationReply("OK\n"); + +// Общий путь для повторного запуска, трея и уведомлений; showNormal потерял бы maximized. +void showAndActivateWindow(QWidget* window) { + if (!window) { DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "GUI activation: window is unavailable"); return; } + QWidget* modal = QApplication::activeModalWidget(); + if (window->isMinimized()) window->setWindowState(window->windowState() & ~Qt::WindowMinimized); + window->show(); + QWidget* target = modal ? modal : window; + if (target != window) { + if (target->isMinimized()) target->setWindowState(target->windowState() & ~Qt::WindowMinimized); + target->show(); + } + target->raise(); target->activateWindow(); +#ifdef _WIN32 + if (QApplication::platformName() == QStringLiteral("windows") && !SetForegroundWindow(reinterpret_cast(target->winId()))) + DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "GUI activation: Windows refused foreground pid=%lld", QCoreApplication::applicationPid()); +#endif + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "GUI activation: visible=%d minimized=%d maximized=%d modal=%d active=%d", + window->isVisible(), window->isMinimized(), window->isMaximized(), target->isModal(), target->isActiveWindow()); +} + +// Канонический путь объединяет обращения через symlink; домашний каталог разделяет пользователей. +SingleInstance::SingleInstance(const QString& profileDirectory, QObject* parent) + : QObject(parent), m_lock(QDir(profileDirectory).filePath(QStringLiteral("vibechat.lock"))) { + QString path = QFileInfo(profileDirectory).canonicalFilePath(); + if (path.isEmpty()) { m_error = QStringLiteral("Profile directory does not exist: %1").arg(profileDirectory); return; } + QString identity = QDir::homePath() + '\n' + path; +#ifdef _WIN32 + identity = identity.toCaseFolded(); +#endif + m_name = QStringLiteral("vibechat-") + QString::fromLatin1( + QCryptographicHash::hash(identity.toUtf8(), QCryptographicHash::Sha256).toHex().left(32)); + m_server.setSocketOptions(QLocalServer::UserAccessOption); + connect(&m_server, &QLocalServer::newConnection, this, &SingleInstance::acceptConnections); +} + +SingleInstance::~SingleInstance() { + if (m_lock.isLocked()) { + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "GUI instance: stopping pid=%lld server=%s", + QCoreApplication::applicationPid(), qPrintable(m_name)); + m_server.close(); + } +} + +SingleInstance::Result SingleInstance::fail(const QString& error) { + m_error = error; + DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "GUI instance: %s", qPrintable(error)); + return Error; +} + +// Только владелец блокировки может убирать оставшийся после аварии адрес локального канала. +SingleInstance::Result SingleInstance::start() { + if (m_started) return fail(QStringLiteral("Instance startup requested twice")); + m_started = true; + if (!m_error.isEmpty()) return fail(m_error); + if (!m_lock.tryLock()) { + if (m_lock.error() != QLockFile::LockFailedError) + return fail(QStringLiteral("Cannot lock profile %1: error=%2").arg(m_lock.fileName()).arg(int(m_lock.error()))); + return activatePrimary(); + } + if (!m_server.listen(m_name)) { + if (m_server.serverError() != QAbstractSocket::AddressInUseError || + !QLocalServer::removeServer(m_name) || !m_server.listen(m_name)) + return fail(QStringLiteral("Cannot listen on %1: %2").arg(m_name, m_server.errorString())); + DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "GUI instance: removed stale local endpoint server=%s", qPrintable(m_name)); + } + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "GUI instance: primary pid=%lld profile=%s server=%s", + QCoreApplication::applicationPid(), qPrintable(m_lock.fileName()), qPrintable(m_name)); + return Primary; +} + +// До появления канала владелец может ещё инициализироваться; после подключения ждём именно ACK. +SingleInstance::Result SingleInstance::activatePrimary() { + QLocalSocket socket; + QEventLoop wait; + QTimer deadline, retry; + deadline.setSingleShot(true); retry.setSingleShot(true); + bool connected = false, accepted = false; + QByteArray reply; + QString error; + connect(&deadline, &QTimer::timeout, &wait, [&] { + error = QStringLiteral("Running vibechat did not acknowledge activation within 5 seconds"); wait.quit(); + }); + connect(&retry, &QTimer::timeout, &socket, [&] { socket.abort(); socket.connectToServer(m_name); }); + connect(&socket, &QLocalSocket::connected, &wait, [&] { + connected = true; retry.stop(); + qint64 pid = 0; QString host, application; + bool ownerKnown = m_lock.getLockInfo(&pid, &host, &application); +#ifdef _WIN32 + if (!ownerKnown || pid <= 0 || quint64(pid) > MAXDWORD) + DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "GUI instance: cannot identify foreground recipient"); + else if (!AllowSetForegroundWindow(DWORD(pid))) + DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "GUI instance: foreground permission failed pid=%lld error=%lu", + pid, static_cast(GetLastError())); +#endif + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "GUI instance: requesting activation owner_pid=%lld known=%d server=%s", + pid, ownerKnown, qPrintable(m_name)); + if (socket.write(activationRequest) != activationRequest.size()) { + error = QStringLiteral("Cannot send activation: %1").arg(socket.errorString()); wait.quit(); + } + }); + connect(&socket, &QLocalSocket::readyRead, &wait, [&] { + reply += socket.readAll(); + if (!activationReply.startsWith(reply)) { + error = QStringLiteral("Invalid activation acknowledgement"); wait.quit(); + } else if (reply == activationReply) { accepted = true; wait.quit(); } + }); + connect(&socket, &QLocalSocket::disconnected, &wait, [&] { + if (!accepted) { error = QStringLiteral("Running vibechat disconnected before activation acknowledgement"); wait.quit(); } + }); + connect(&socket, &QLocalSocket::errorOccurred, &wait, [&](QLocalSocket::LocalSocketError code) { + if (accepted) return; + if (!connected && (code == QLocalSocket::ServerNotFoundError || code == QLocalSocket::ConnectionRefusedError)) { + retry.start(50); return; + } + error = QStringLiteral("Cannot activate running vibechat: %1").arg(socket.errorString()); wait.quit(); + }); + socket.setReadBufferSize(activationReply.size() + 1); + deadline.start(5000); retry.start(0); wait.exec(); + if (!accepted) return fail(error); + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "GUI instance: activation acknowledged; secondary pid=%lld exiting", + QCoreApplication::applicationPid()); + return Activated; +} + +// Окно назначается до первого show/exec; отложенный показ должен примениться после начального --minimized. +void SingleInstance::setWindow(QWidget* window) { + m_window = window; + if (!window || !m_pending) return; + QTimer::singleShot(0, this, [this] { + if (m_pending && m_window) { m_pending = false; showAndActivateWindow(m_window); } + }); +} + +// Фиксированная маленькая команда: частичное чтение допустимо, неверный/зависший клиент закрывается. +void SingleInstance::acceptConnections() { + while (m_server.hasPendingConnections()) { + auto* socket = m_server.nextPendingConnection(); + if (!socket) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "GUI instance: missing pending connection"); continue; } + socket->setReadBufferSize(activationRequest.size() + 1); + auto* deadline = new QTimer(socket); deadline->setSingleShot(true); deadline->start(3000); + connect(deadline, &QTimer::timeout, socket, [socket] { + DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "GUI instance: activation client timed out"); socket->abort(); + }); + connect(socket, &QLocalSocket::errorOccurred, socket, [socket](QLocalSocket::LocalSocketError code) { + if (code != QLocalSocket::PeerClosedError) + DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "GUI instance: activation connection failed: %s", qPrintable(socket->errorString())); + }); + connect(socket, &QLocalSocket::disconnected, socket, [socket, deadline] { + if (deadline->isActive()) DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "GUI instance: client disconnected before sending activation"); + socket->deleteLater(); + }); + connect(socket, &QLocalSocket::readyRead, this, [this, socket, deadline, request = QByteArray{}, done = false]() mutable { + if (done) return; + request += socket->readAll(); + if (!activationRequest.startsWith(request)) { + DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "GUI instance: invalid activation command bytes=%lld", qint64(request.size())); + deadline->stop(); socket->abort(); return; + } + if (request != activationRequest) return; + done = true; deadline->stop(); m_pending = true; + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "GUI instance: activation received pid=%lld window_ready=%d", + QCoreApplication::applicationPid(), !m_window.isNull()); + if (m_window) { m_pending = false; showAndActivateWindow(m_window); } + if (socket->write(activationReply) != activationReply.size()) + DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "GUI instance: cannot acknowledge activation: %s", qPrintable(socket->errorString())); + socket->disconnectFromServer(); + }); + } +} diff --git a/tools/chatgui/src/single_instance.h b/tools/chatgui/src/single_instance.h new file mode 100644 index 00000000..11a857f7 --- /dev/null +++ b/tools/chatgui/src/single_instance.h @@ -0,0 +1,44 @@ +/* Один GUI на каталог профиля: QLockFile защищает данные, локальный канал передаёт + * запрос показа владельцу. start вызывается один раз до чтения конфига/запуска ядра. + * Объект живёт до уничтожения окон; все вызовы — GUI-поток. */ +#pragma once + +#include +#include +#include +#include +#include + +class QWidget; + +/* Показать окно, сохранив maximized/fullscreen, и активировать его модальный диалог, если он есть. */ +void showAndActivateWindow(QWidget* window); + +class SingleInstance : public QObject { +public: + enum Result { Error = -1, Primary, Activated }; + + /* profileDirectory — существующий каталог конфига; parent владеет объектом по правилам QObject. */ + explicit SingleInstance(const QString& profileDirectory, QObject* parent = nullptr); + ~SingleInstance() override; + + /* Получить блокировку и открыть канал либо дождаться ACK владельца (до 5 секунд). + * Primary: продолжить запуск; Activated: завершить второй процесс с 0; Error: errorString(). */ + Result start(); + /* Текущее окно: мастер настройки или MainWindow. nullptr откладывает показ до следующего setWindow. */ + void setWindow(QWidget* window); + QString errorString() const { return m_error; } + QString serverName() const { return m_name; } + +private: + Result fail(const QString& error); + Result activatePrimary(); + void acceptConnections(); + + QLockFile m_lock; // Защита профиля на весь срок жизни первого экземпляра. + QLocalServer m_server; // Только текущий пользователь; имя связано с каноническим путём профиля. + QString m_name, m_error; + QPointer m_window; // Заимствованное окно; обнуляется при уничтожении мастера/GUI. + bool m_started = false; + bool m_pending = false; // Запрос принят до создания окна; повторные запросы объединяются. +}; diff --git a/tools/chatgui/tests/test_single_instance.cpp b/tools/chatgui/tests/test_single_instance.cpp new file mode 100644 index 00000000..91ddc4c0 --- /dev/null +++ b/tools/chatgui/tests/test_single_instance.cpp @@ -0,0 +1,207 @@ +/* Настоящие процессы и локальные сокеты: один владелец профиля, ACK, startup-race, + * аварийный выход и показ окна без запуска сетевого ядра/аудиоустройств. */ +#include "../src/single_instance.h" +#include "debug_config.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Дочерний процесс создаёт окно только после определения роли. +static int peerMain(QApplication& app, const QString& profile) { + SingleInstance instance(profile); + const auto role = instance.start(); + if (role == SingleInstance::Error) return 2; + if (role == SingleInstance::Activated) { qInfo("PEER_FORWARDED"); return 0; } + QWidget window; + instance.setWindow(&window); + qInfo("PEER_PRIMARY"); + QTimer watch; + QObject::connect(&watch, &QTimer::timeout, &window, [&] { + if (window.isVisible()) { qInfo("PEER_VISIBLE"); watch.stop(); } + }); + watch.start(10); + QTimer::singleShot(15000, &app, &QCoreApplication::quit); + return app.exec(); +} + +// Процесс всегда завершается до удаления временного каталога, включая ранний выход QVERIFY. +struct Peer { + QProcess process; + QByteArray output; + explicit Peer(const QString& profile) { + process.setProcessChannelMode(QProcess::MergedChannels); + process.start(QCoreApplication::applicationFilePath(), {"--peer", profile}); + } + ~Peer() { + if (process.state() != QProcess::NotRunning) { process.kill(); process.waitForFinished(3000); } + } + bool saw(const QByteArray& marker) { output += process.readAll(); return output.contains(marker); } +}; + +class InstanceTest : public QObject { + Q_OBJECT +private slots: + void forwardsToExistingProcess() { + QTemporaryDir profile; QVERIFY(profile.isValid()); + QString path = profile.filePath(QString::fromUtf8("profile space-кириллица")); QVERIFY(QDir().mkpath(path)); + Peer first(path); QTRY_VERIFY(first.saw("PEER_PRIMARY")); + Peer second(path); QTRY_COMPARE(second.process.state(), QProcess::NotRunning); + QCOMPARE(second.process.exitCode(), 0); QVERIFY(second.saw("PEER_FORWARDED")); + QVERIFY(!second.output.contains("PEER_PRIMARY")); QTRY_VERIFY(first.saw("PEER_VISIBLE")); + QVERIFY(!QFileInfo::exists(QDir(path).filePath("vibechat.cfg"))); + } + + void simultaneousStartup() { + QTemporaryDir profile; QVERIFY(profile.isValid()); + std::vector> peers; + for (int i = 0; i < 4; i++) peers.push_back(std::make_unique(profile.path())); + for (const auto& peer : peers) QTRY_VERIFY(peer->saw("PEER_PRIMARY") || peer->saw("PEER_FORWARDED")); + unsigned primary = 0, forwarded = 0; + for (const auto& peer : peers) { + if (peer->output.contains("PEER_PRIMARY")) { primary++; QTRY_VERIFY(peer->saw("PEER_VISIBLE")); } + else { + QTRY_COMPARE(peer->process.state(), QProcess::NotRunning); + QCOMPARE(peer->process.exitCode(), 0); forwarded++; + } + } + QCOMPARE(primary, 1U); QCOMPARE(forwarded, 3U); + } + + void differentProfiles() { + QTemporaryDir a, b; QVERIFY(a.isValid() && b.isValid()); + Peer first(a.path()), second(b.path()); + QTRY_VERIFY(first.saw("PEER_PRIMARY")); QTRY_VERIFY(second.saw("PEER_PRIMARY")); + QVERIFY(!first.output.contains("PEER_FORWARDED") && !second.output.contains("PEER_FORWARDED")); + } + + void recoversAfterCrash() { + QTemporaryDir profile; QVERIFY(profile.isValid()); + Peer first(profile.path()); QTRY_VERIFY(first.saw("PEER_PRIMARY")); + first.process.kill(); QVERIFY(first.process.waitForFinished(3000)); + Peer replacement(profile.path()); QTRY_VERIFY(replacement.saw("PEER_PRIMARY")); + Peer secondary(profile.path()); QTRY_COMPARE(secondary.process.state(), QProcess::NotRunning); + QCOMPARE(secondary.process.exitCode(), 0); QTRY_VERIFY(replacement.saw("PEER_VISIBLE")); + } + + void queuesActivationUntilWindowExists() { + QTemporaryDir profile; QVERIFY(profile.isValid()); + SingleInstance instance(profile.path()); QCOMPARE(instance.start(), SingleInstance::Primary); + Peer secondary(profile.path()); QTRY_COMPARE(secondary.process.state(), QProcess::NotRunning); + QCOMPARE(secondary.process.exitCode(), 0); + QWidget window; QVERIFY(!window.isVisible()); + instance.setWindow(&window); window.showMinimized(); + QTRY_VERIFY(window.isVisible() && !window.isMinimized()); + } + + void waitsForServerStartup() { + QTemporaryDir profile; QVERIFY(profile.isValid()); + QLockFile lock(profile.filePath("vibechat.lock")); QVERIFY(lock.tryLock()); + SingleInstance identity(profile.path()); QLocalServer server; + QTimer::singleShot(250, &server, [&] { QVERIFY(server.listen(identity.serverName())); }); + connect(&server, &QLocalServer::newConnection, &server, [&] { + auto* socket = server.nextPendingConnection(); QVERIFY(socket); + connect(socket, &QLocalSocket::readyRead, socket, [socket, request = QByteArray{}]() mutable { + request += socket->readAll(); + if (!request.endsWith('\n')) return; + QCOMPARE(request, QByteArray("ACTIVATE\n")); + QCOMPARE(socket->write("OK\n"), 3LL); socket->disconnectFromServer(); + }); + }); + Peer secondary(profile.path()); QTRY_COMPARE(secondary.process.state(), QProcess::NotRunning); + QCOMPARE(secondary.process.exitCode(), 0); QVERIFY(secondary.saw("PEER_FORWARDED")); QVERIFY(lock.isLocked()); + } + + void liveOwnerWithoutServerTimesOut() { + QTemporaryDir profile; QVERIFY(profile.isValid()); + QLockFile lock(profile.filePath("vibechat.lock")); QVERIFY(lock.tryLock()); + QElapsedTimer time; time.start(); + Peer secondary(profile.path()); QTRY_COMPARE_WITH_TIMEOUT(secondary.process.state(), QProcess::NotRunning, 7000); + QCOMPARE(secondary.process.exitCode(), 2); QVERIFY(secondary.saw("did not acknowledge")); + QVERIFY(time.elapsed() < 7000 && lock.isLocked()); QVERIFY(!secondary.output.contains("PEER_PRIMARY")); + } + + void rejectsMissingAndInvalidAcknowledgement_data() { + QTest::addColumn("reply"); + QTest::newRow("invalid") << QByteArray("NO\n"); + QTest::newRow("disconnected") << QByteArray(); + QTest::newRow("truncated") << QByteArray("O"); + } + + void rejectsMissingAndInvalidAcknowledgement() { + QFETCH(QByteArray, reply); + QTemporaryDir profile; QVERIFY(profile.isValid()); + QLockFile lock(profile.filePath("vibechat.lock")); QVERIFY(lock.tryLock()); + SingleInstance identity(profile.path()); QLocalServer server; QVERIFY(server.listen(identity.serverName())); + connect(&server, &QLocalServer::newConnection, &server, [&] { + auto* socket = server.nextPendingConnection(); QVERIFY(socket); + connect(socket, &QLocalSocket::readyRead, socket, [socket, reply] { + socket->readAll(); + if (!reply.isEmpty()) QCOMPARE(socket->write(reply), qint64(reply.size())); + socket->disconnectFromServer(); + }); + }); + Peer secondary(profile.path()); QTRY_COMPARE(secondary.process.state(), QProcess::NotRunning); + QCOMPARE(secondary.process.exitCode(), 2); QVERIFY(!secondary.saw("PEER_PRIMARY")); QVERIFY(lock.isLocked()); + } + + void validatesCommandsAndPartialReads() { + QTemporaryDir profile; QVERIFY(profile.isValid()); + SingleInstance instance(profile.path()); QCOMPARE(instance.start(), SingleInstance::Primary); + QWidget window; instance.setWindow(&window); + QLocalSocket bad; bad.connectToServer(instance.serverName()); QVERIFY(bad.waitForConnected(1000)); + QCOMPARE(bad.write("BAD\n"), 4LL); QVERIFY(bad.waitForBytesWritten(1000)); + QTRY_COMPARE(bad.state(), QLocalSocket::UnconnectedState); QVERIFY(!window.isVisible()); + QLocalSocket good; good.connectToServer(instance.serverName()); QVERIFY(good.waitForConnected(1000)); + QCOMPARE(good.write("ACT"), 3LL); QVERIFY(good.waitForBytesWritten(1000)); + QTest::qWait(30); QVERIFY(!window.isVisible()); QCOMPARE(good.bytesAvailable(), 0LL); + QCOMPARE(good.write("IVATE\n"), 6LL); QVERIFY(good.waitForBytesWritten(1000)); + QTRY_VERIFY(window.isVisible()); QTRY_COMPARE(good.bytesAvailable(), 3LL); QCOMPARE(good.readAll(), QByteArray("OK\n")); + } + + void missingDirectoryFails() { + QTemporaryDir profile; QVERIFY(profile.isValid()); + SingleInstance instance(profile.filePath("missing")); QCOMPARE(instance.start(), SingleInstance::Error); + QVERIFY(instance.errorString().contains("does not exist")); + QVERIFY(!QFileInfo::exists(profile.filePath("missing"))); + } + + void restoresWindowState() { + QWidget window; + showAndActivateWindow(&window); QVERIFY(window.isVisible()); QVERIFY(!window.isMinimized()); + window.setWindowState(Qt::WindowMaximized | Qt::WindowMinimized); + showAndActivateWindow(&window); QVERIFY(window.isVisible()); QVERIFY(window.isMaximized()); QVERIFY(!window.isMinimized()); + window.hide(); showAndActivateWindow(&window); QVERIFY(window.isVisible() && window.isMaximized()); + } + + void activatesModalDialog() { + QTemporaryDir profile; QVERIFY(profile.isValid()); + SingleInstance instance(profile.path()); QCOMPARE(instance.start(), SingleInstance::Primary); + QWidget window; instance.setWindow(&window); window.show(); + QDialog modal(&window); modal.setModal(true); modal.show(); + QTRY_COMPARE(QApplication::activeModalWidget(), &modal); + Peer secondary(profile.path()); QTRY_COMPARE(secondary.process.state(), QProcess::NotRunning); + QCOMPARE(secondary.process.exitCode(), 0); QVERIFY(modal.isVisible()); + QCOMPARE(QApplication::activeModalWidget(), &modal); + } +}; + +int main(int argc, char** argv) { + QApplication app(argc, argv); app.setQuitOnLastWindowClosed(false); + debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); + if (app.arguments().size() == 3 && app.arguments()[1] == QStringLiteral("--peer")) + return peerMain(app, app.arguments()[2]); + InstanceTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "test_single_instance.moc"