Browse Source

Activate the existing VibeChat window on repeated launches

stream-multiview
evgeny 6 days ago
parent
commit
d7f30d7bb1
  1. 9
      tools/chatgui/CMakeLists.txt
  2. 6
      tools/chatgui/installer/README.txt
  3. 33
      tools/chatgui/src/main.cpp
  4. 16
      tools/chatgui/src/mainwindow.cpp
  5. 186
      tools/chatgui/src/single_instance.cpp
  6. 44
      tools/chatgui/src/single_instance.h
  7. 207
      tools/chatgui/tests/test_single_instance.cpp

9
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")

6
tools/chatgui/installer/README.txt

@ -63,6 +63,12 @@ EXE собирается как GUI-приложение: запуск чере
Лог приложения показывает режим окна и наличие консоли, лог установки —
выбранный режим автозапуска и путь ярлыка.
Повторный запуск для того же профиля показывает окно уже работающего приложения,
в том числе скрытое в трей или свёрнутое через --minimized. Если открыт мастер
первой настройки или модальный диалог, активируется он. Второе ядро не запускается.
При недоступности работающего экземпляра запрос завершается ошибкой через 5 секунд.
Диагностика активации в логе: GUI instance / GUI activation.
При обновлении установщик предлагает закрыть приложение, использующее EXE/DLL.
Обновление и удаление сохраняют конфиг, ключи, историю и медиа пользователя.
Деинсталлятор удаляет своё правило firewall и установленные файлы программы.

33
tools/chatgui/src/main.cpp

@ -8,7 +8,6 @@
#include <QStyleFactory>
#include <QDir>
#include <QFile>
#include <QLockFile>
#include <QMessageBox>
#include <QTranslator>
#include <QComboBox>
@ -20,6 +19,7 @@
#include <QVBoxLayout>
#include "gui_language.h"
#include "call_camera_capture.h"
#include "single_instance.h"
#include "config_parser.h"
#include <cstdio>
#include <cstring>
@ -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<int>(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();

16
tools/chatgui/src/mainwindow.cpp

@ -1,5 +1,6 @@
#include <QCoreApplication>
#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<MessageNotification>();
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);
}
}

186
tools/chatgui/src/single_instance.cpp

@ -0,0 +1,186 @@
#include "single_instance.h"
#include "debug_config.h"
#include <QApplication>
#include <QCryptographicHash>
#include <QDir>
#include <QEventLoop>
#include <QFileInfo>
#include <QLocalSocket>
#include <QTimer>
#include <QWidget>
#ifdef _WIN32
#include <windows.h>
#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<HWND>(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<unsigned long>(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();
});
}
}

44
tools/chatgui/src/single_instance.h

@ -0,0 +1,44 @@
/* Один GUI на каталог профиля: QLockFile защищает данные, локальный канал передаёт
* запрос показа владельцу. start вызывается один раз до чтения конфига/запуска ядра.
* Объект живёт до уничтожения окон; все вызовы — GUI-поток. */
#pragma once
#include <QObject>
#include <QString>
#include <QLockFile>
#include <QLocalServer>
#include <QPointer>
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<QWidget> m_window; // Заимствованное окно; обнуляется при уничтожении мастера/GUI.
bool m_started = false;
bool m_pending = false; // Запрос принят до создания окна; повторные запросы объединяются.
};

207
tools/chatgui/tests/test_single_instance.cpp

@ -0,0 +1,207 @@
/* Настоящие процессы и локальные сокеты: один владелец профиля, ACK, startup-race,
* аварийный выход и показ окна без запуска сетевого ядра/аудиоустройств. */
#include "../src/single_instance.h"
#include "debug_config.h"
#include <QtTest>
#include <QApplication>
#include <QDialog>
#include <QDir>
#include <QElapsedTimer>
#include <QFileInfo>
#include <QLocalSocket>
#include <QProcess>
#include <QTemporaryDir>
#include <QTimer>
#include <QWidget>
#include <memory>
#include <vector>
// Дочерний процесс создаёт окно только после определения роли.
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<std::unique_ptr<Peer>> peers;
for (int i = 0; i < 4; i++) peers.push_back(std::make_unique<Peer>(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<QByteArray>("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"
Loading…
Cancel
Save