Cockatrice/cockatrice/src/single_instance_manager.cpp
BruebachL a44757bd11
[Client] Improve card database update visibility (#7458)
* [Client] Improve card database update visibility

Addresses Cockatrice/Cockatrice#7431.

Cockatrice previously offered no in-client feedback while the card
database updater ran: the only progress UI lived in the first-run
wizard, stderr/stdout were not parsed in interactive runs, and starting
a second update with Oracle already open or with a hidden background run
active just showed a confusing 'already running' guard - even though
Oracle is launched as a separate process with no lifecycle tracking.

- Add a status-bar widget (CardDatabaseUpdateStatusBar) that appears
  while an update runs and shows the Downloading/Parsing/Importing
  stages with percent progress; it is also used in the quit-confirm
  prompt so users see an unfinished update before cancelling it.
- Parse the updater's PROGRESS lines (new CardUpdateProgressParser,
  unit-tested) in both foreground and background runs and log stage
  changes to the new card_database.update category, throttled to at
  most one line per second; forward Oracle stderr to the logs.
- Show a tray balloon when a background update starts and on finish or
  failure instead of staying silent.
- Request a running Oracle instance to raise itself (QLocalServer
  CockatriceOracleRaise-<user>) when the user starts another update, and
  demote the 'already running' guard to an informative message only when
  no running instance can be found.
- Emit download/scan/import progress unconditionally from Oracle so
  interactive and background runs behave the same.
- Document the new log category in the troubleshooting guide.

* [Client] Address card database update review feedback

* [Client] Add setting to control card database update tray notifications

* [Client] Address remaining card database update review nits

- Rename card_update_progress_parser.{h,cpp} to card_update_progress.{h,cpp}
- Store the CardUpdateProgress struct in the status bar widget

* Lint.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-09 11:07:48 +02:00

199 lines
7.9 KiB
C++

#include "single_instance_manager.h"
#include <QByteArray>
#include <QDataStream>
#include <QIODevice>
#include <QList>
#include <QLocalServer>
#include <QLocalSocket>
#include <QPointer>
#include <QSharedPointer>
#include <QtGlobal>
#include <libcockatrice/utility/local_server_name.h>
namespace
{
// Sent by the primary instance after it has read a forwarded payload. Without
// an acknowledgment, a second instance cannot tell a live primary apart from a
// stale socket left behind by a process that is still shutting down.
const QByteArray ACK_MESSAGE = QByteArrayLiteral("COCKATRICE_ACK");
} // namespace
SingleInstanceManager::SingleInstanceManager(QObject *parent) : QObject(parent)
{
}
bool SingleInstanceManager::tryRun(const QStringList &filesToSend)
{
// Scope the socket name to the current user so one user's instance cannot
// hijack another user's session.
serverName = scopedLocalServerName(QStringLiteral("CockatriceSingleInstance"));
// Hand off to an already-running primary instance if one exists. Never steal
// the socket of a busy primary: it is alive and will act on the payload.
switch (forwardToPrimary(filesToSend)) {
case ForwardResult::Delivered:
return false;
case ForwardResult::PrimaryBusy:
return false;
case ForwardResult::NoPrimary:
break;
}
// No primary instance is currently reachable, so become the primary.
server = new QLocalServer(this);
connect(server, &QLocalServer::newConnection, this, &SingleInstanceManager::handleNewConnection);
if (server->listen(serverName)) {
return true;
}
// Another instance may have started while we were probing; hand off to it
// instead of stealing its socket.
switch (forwardToPrimary(filesToSend)) {
case ForwardResult::Delivered:
return false;
case ForwardResult::PrimaryBusy:
return false;
case ForwardResult::NoPrimary:
break;
}
// The socket is stale (left over by a crashed instance), so no primary is
// holding it: remove it and retry. If that still fails, another instance
// just took the name.
QLocalServer::removeServer(serverName);
if (server->listen(serverName)) {
return true;
}
forwardToPrimary(filesToSend);
return false;
}
SingleInstanceManager::ForwardResult SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend)
{
QLocalSocket socket;
socket.connectToServer(serverName);
if (!socket.waitForConnected(200)) {
return ForwardResult::NoPrimary;
}
// Serialize payload with length prefix
QByteArray payload;
QDataStream out(&payload, QIODevice::WriteOnly);
out << filesToSend;
QByteArray message;
QDataStream msgStream(&message, QIODevice::WriteOnly);
msgStream << quint32(payload.size());
message.append(payload);
socket.write(message);
socket.flush();
socket.waitForBytesWritten(1000);
// A plain launch has nothing for the primary to act on, so there is nothing
// to acknowledge. Waiting here would block the new instance for seconds if
// the primary is busy in a modal dialog, so only the activation path (which
// needs the ACK to avoid stealing a live primary's socket) waits below.
if (filesToSend.isEmpty()) {
return ForwardResult::Delivered;
}
// Only report a successful hand-off once the primary has acknowledged that
// it actually read the payload. A socket that connects but is still working
// on an earlier payload is alive but busy, not dead: give it more room
// before giving up, so a slow handler does not make a live primary look
// dead (which would lead to stealing its socket).
if (!socket.waitForReadyRead(1000) && !socket.waitForReadyRead(4000)) {
return ForwardResult::PrimaryBusy;
}
return socket.readAll() == ACK_MESSAGE ? ForwardResult::Delivered : ForwardResult::PrimaryBusy;
}
void SingleInstanceManager::handleNewConnection()
{
QLocalSocket *socket = server->nextPendingConnection();
// Per-connection state. QSharedPointer keeps the buffers alive for as long
// as the connection handler is attached to the socket.
auto buffer = QSharedPointer<QByteArray>::create();
auto expectedSize = QSharedPointer<quint32>::create(0);
connect(socket, &QLocalSocket::readyRead, this, [this, socket, buffer, expectedSize]() {
buffer->append(socket->readAll());
QDataStream stream(buffer.data(), QIODevice::ReadOnly);
while (true) {
// Step 1: read size
if (*expectedSize == 0) {
if (buffer->size() < static_cast<int>(sizeof(quint32))) {
return;
}
stream >> *expectedSize;
}
// Step 2: wait for full payload
if (buffer->size() < static_cast<int>(sizeof(quint32) + *expectedSize)) {
return;
}
// Step 3: extract payload
QByteArray payload = buffer->mid(sizeof(quint32), *expectedSize);
QDataStream payloadStream(&payload, QIODevice::ReadOnly);
QStringList files;
payloadStream >> files;
// Acknowledge receipt as soon as the payload is parsed, before the
// primary starts handling it. The handlers run synchronously and can
// take longer than the sender's readiness timeout (e.g. a modal
// confirmation box), which would otherwise make a live primary look
// dead and cause duplicate handling.
socket->write(ACK_MESSAGE);
socket->flush();
// Drop the payload from the buffer before handing it off, so a
// re-entrant event loop cannot re-parse and re-emit the same files.
buffer->clear();
*expectedSize = 0;
// Take the guard while the socket is still alive: QPointer has to be
// built from a live pointer, and by the time the handlers below return
// the socket may already be gone.
QPointer<QLocalSocket> guardedSocket(socket);
// Hand the payload over synchronously, exactly as before, so the
// "Join game?" confirmation still appears. A handler can spin a nested
// event loop (that modal box is one), and the sending instance exits as
// soon as it has the ACK above, so while that loop runs the peer closes
// and this socket is disconnected.
emit filesReceived(files);
// The handlers run synchronously, so returning from the emit means the
// readyRead frame has unwound and it is safe to touch the socket again.
// This teardown must therefore NOT be queued: a queued call would be
// picked up by the nested loop above while the frame is still on the
// stack, which is precisely the use-after-free this guards against.
// The guard covers the one remaining way the socket can die under us --
// the primary being torn down (e.g. quit) while the dialog was open --
// since QLocalServer owns and destroys its pending connections.
if (guardedSocket) {
guardedSocket->disconnectFromServer();
guardedSocket->deleteLater();
}
return;
}
});
// Deliberately no `disconnected -> deleteLater()` here. The handlers above run
// synchronously and may spin a nested event loop, during which the sender exits
// and this socket is disconnected. A deleteLater() triggered from there is not
// deferred to the outer loop: Qt frees the object as soon as that nested loop
// unwinds, which is still inside this readyRead emission, so Qt's own signal
// dispatch is left holding a freed socket. The socket is instead closed and
// deleted once the handlers have returned, on the code path above.
}