Activate the existing VibeChat window on repeated launches
This commit is contained in:
1 parent
50017b7f74
commit
d7f30d7bb1
7 files changed
+473
-28
No files matched your search
@@ -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")
|
||||
|
||||
@@ -63,6 +63,12 @@ EXE собирается как GUI-приложение: запуск чере
|
||||
Лог приложения показывает режим окна и наличие консоли, лог установки —
|
||||
выбранный режим автозапуска и путь ярлыка.
|
||||
|
||||
Повторный запуск для того же профиля показывает окно уже работающего приложения,
|
||||
в том числе скрытое в трей или свёрнутое через --minimized. Если открыт мастер
|
||||
первой настройки или модальный диалог, активируется он. Второе ядро не запускается.
|
||||
При недоступности работающего экземпляра запрос завершается ошибкой через 5 секунд.
|
||||
Диагностика активации в логе: GUI instance / GUI activation.
|
||||
|
||||
При обновлении установщик предлагает закрыть приложение, использующее EXE/DLL.
|
||||
Обновление и удаление сохраняют конфиг, ключи, историю и медиа пользователя.
|
||||
Деинсталлятор удаляет своё правило firewall и установленные файлы программы.
|
||||
|
||||
+17
-16
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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; // Запрос принят до создания окна; повторные запросы объединяются.
|
||||
};
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user