Better single-instance handoff, url intent harded

copy game link context-menu
Polish for installers

Took 35 minutes

Took 8 seconds
This commit is contained in:
Lukas Brübach 2026-08-08 19:01:51 +02:00
parent a9077aa65b
commit 0a1a80eadd
27 changed files with 257 additions and 84 deletions

View file

@ -416,9 +416,18 @@ Section "un.Application" UnSecApplication
RMDir "$SMPROGRAMS\Cockatrice" RMDir "$SMPROGRAMS\Cockatrice"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice"
DeleteRegKey HKCR ".cod"
DeleteRegKey HKCR "Cockatrice" ; Only remove the file/protocol associations if we registered them (i.e. the
DeleteRegKey HKCR "cockatrice" ; install was not portable) and .cod is still owned by Cockatrice, so we don't
; clobber a .cod association installed by another application.
${If} Not ${FileExists} "$INSTDIR\portable.dat"
ReadRegStr $0 HKCR ".cod" ""
${If} $0 == "Cockatrice"
DeleteRegKey HKCR ".cod"
DeleteRegKey HKCR "Cockatrice"
DeleteRegKey HKCR "cockatrice"
${EndIf}
${EndIf}
SectionEnd SectionEnd
; unselected because it is /o ; unselected because it is /o

View file

@ -530,6 +530,22 @@ if(UNIX)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/resources/cockatrice.svg DESTINATION ${ICONDIR}/hicolor/scalable/apps) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/resources/cockatrice.svg DESTINATION ${ICONDIR}/hicolor/scalable/apps)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cockatrice.desktop DESTINATION ${DESKTOPDIR}) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cockatrice.desktop DESTINATION ${DESKTOPDIR})
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cockatrice-cod.xml DESTINATION ${MIMEDIR}) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cockatrice-cod.xml DESTINATION ${MIMEDIR})
# Refresh the freedesktop databases so the file associations and scheme
# handler register without requiring the user to run them manually. The
# tools may be missing on minimal systems; that is fine, packaging systems
# usually refresh these databases through their own triggers.
find_program(UPDATE_MIME_DATABASE update-mime-database)
if(UPDATE_MIME_DATABASE)
install(CODE "execute_process(COMMAND \"${UPDATE_MIME_DATABASE}\" \"${CMAKE_INSTALL_PREFIX}/share/mime\")")
endif()
find_program(UPDATE_DESKTOP_DATABASE update-desktop-database)
if(UPDATE_DESKTOP_DATABASE)
install(
CODE "execute_process(COMMAND \"${UPDATE_DESKTOP_DATABASE}\" \"${CMAKE_INSTALL_PREFIX}/share/applications\")"
)
endif()
endif() endif()
elseif(WIN32) elseif(WIN32)
install(TARGETS cockatrice RUNTIME DESTINATION ./) install(TARGETS cockatrice RUNTIME DESTINATION ./)

View file

@ -5,29 +5,33 @@
#include <QFileOpenEvent> #include <QFileOpenEvent>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QUrl>
/** /**
* @brief Event filter that catches QFileOpenEvent URLs matching a scheme * @brief Event filter that catches QFileOpenEvent URLs matching a scheme and
* prefix and re-emits them as urlReceived(). * re-emits them as urlReceived().
* *
* On macOS, when the application is registered as a URL scheme handler, the * On macOS, when the application is registered as a URL scheme handler, the
* OS delivers incoming URLs via QFileOpenEvent on the QApplication object. * OS delivers incoming URLs via QFileOpenEvent on the QApplication object.
* Install this filter on QApplication to intercept them: * Install this filter on QApplication to intercept them:
* *
* @code * @code
* UrlSchemeEventFilter filter(QStringLiteral("cockatrice://")); * UrlSchemeEventFilter filter(QStringList{QStringLiteral("cockatrice")});
* QObject::connect(&filter, &UrlSchemeEventFilter::urlReceived, * QObject::connect(&filter, &UrlSchemeEventFilter::urlReceived,
* &mainWindow, &MainWindow::handleUrl); * &mainWindow, &MainWindow::handleUrl);
* app.installEventFilter(&filter); * app.installEventFilter(&filter);
* @endcode * @endcode
*
* Note: the strings are compared against QUrl::scheme(), so they must be
* written without the "://" suffix (e.g. "cockatrice", not "cockatrice://").
*/ */
class UrlSchemeEventFilter : public QObject class UrlSchemeEventFilter : public QObject
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit UrlSchemeEventFilter(const QStringList &schemePrefix, QObject *parent = nullptr) explicit UrlSchemeEventFilter(const QStringList &schemes, QObject *parent = nullptr)
: QObject(parent), m_prefixes(schemePrefix) : QObject(parent), prefixes(schemes)
{ {
} }
@ -42,7 +46,7 @@ public:
const QUrl url = fileEvent->url(); const QUrl url = fileEvent->url();
for (auto prefix : m_prefixes) { for (const auto &prefix : prefixes) {
if (url.scheme() == prefix) { if (url.scheme() == prefix) {
emit urlReceived(url.toString()); emit urlReceived(url.toString());
return true; return true;
@ -59,7 +63,7 @@ public:
} }
private: private:
QStringList m_prefixes; QStringList prefixes;
}; };
#endif // COCKATRICE_URL_SCHEME_EVENT_FILTER_H #endif // COCKATRICE_URL_SCHEME_EVENT_FILTER_H

View file

@ -30,3 +30,19 @@ void Intent::runDependency(Intent *dependency)
dependency->execute(); dependency->execute();
} }
void Intent::emitFinished()
{
if (!completed) {
completed = true;
emit finished();
}
}
void Intent::emitFailed(const QString &reason)
{
if (!completed) {
completed = true;
emit failed(reason);
}
}

View file

@ -25,6 +25,13 @@ protected:
// Helper to chain another intent // Helper to chain another intent
void runDependency(Intent *dependency); void runDependency(Intent *dependency);
// Emit the outcome exactly once; ignore late signals after the intent is done.
void emitFinished();
void emitFailed(const QString &reason);
private:
bool completed = false;
}; };
#endif // COCKATRICE_INTENT_H #endif // COCKATRICE_INTENT_H

View file

@ -2,6 +2,8 @@
#include "intent_disconnect_from_server.h" #include "intent_disconnect_from_server.h"
#include <QTimer>
IntentConnectToServer::IntentConnectToServer(RemoteClient *_remoteClient, ContextConnectToServer *_context) IntentConnectToServer::IntentConnectToServer(RemoteClient *_remoteClient, ContextConnectToServer *_context)
: Intent(), remoteClient(_remoteClient), context(_context) : Intent(), remoteClient(_remoteClient), context(_context)
{ {
@ -16,10 +18,14 @@ void IntentConnectToServer::onPreconditionSatisfied()
{ {
remoteClient->connectToServer(context->hostname, context->port.toUInt(), context->username, context->password); remoteClient->connectToServer(context->hostname, context->port.toUInt(), context->username, context->password);
connect(remoteClient, &RemoteClient::statusChanged, this, &IntentConnectToServer::onStatusChanged); connect(remoteClient, &RemoteClient::statusChanged, this, &IntentConnectToServer::onStatusChanged);
connect(remoteClient, &RemoteClient::loginError, this, connect(remoteClient, &RemoteClient::socketError, this, &IntentConnectToServer::onSocketError);
[this](Response::ResponseCode, const QString &reason, quint32, const QList<QString> &) { connect(
emit failed(reason); remoteClient, &RemoteClient::loginError, this,
}); [this](Response::ResponseCode, const QString &reason, quint32, const QList<QString> &) { emitFailed(reason); });
QTimer::singleShot(15000, this, [this]() {
emitFailed(tr("Timed out while connecting to %1:%2").arg(context->hostname, context->port));
});
} }
void IntentConnectToServer::onPreconditionNotSatisfied() void IntentConnectToServer::onPreconditionNotSatisfied()
@ -30,6 +36,11 @@ void IntentConnectToServer::onPreconditionNotSatisfied()
void IntentConnectToServer::onStatusChanged(ClientStatus status) void IntentConnectToServer::onStatusChanged(ClientStatus status)
{ {
if (status == ClientStatus::StatusLoggedIn) { if (status == ClientStatus::StatusLoggedIn) {
emit finished(); emitFinished();
} }
} }
void IntentConnectToServer::onSocketError(const QString &errorString)
{
emitFailed(tr("Failed to connect to %1:%2: %3").arg(context->hostname, context->port, errorString));
}

View file

@ -23,6 +23,7 @@ private:
private slots: private slots:
void onStatusChanged(ClientStatus status); void onStatusChanged(ClientStatus status);
void onSocketError(const QString &errorString);
}; };
#endif // COCKATRICE_INTENT_CONNECT_TO_SERVER_H #endif // COCKATRICE_INTENT_CONNECT_TO_SERVER_H

View file

@ -12,7 +12,7 @@ bool IntentDisconnectFromServer::checkPrecondition() const
void IntentDisconnectFromServer::onPreconditionSatisfied() void IntentDisconnectFromServer::onPreconditionSatisfied()
{ {
emit finished(); emitFinished();
} }
void IntentDisconnectFromServer::onPreconditionNotSatisfied() void IntentDisconnectFromServer::onPreconditionNotSatisfied()
@ -24,6 +24,6 @@ void IntentDisconnectFromServer::onPreconditionNotSatisfied()
void IntentDisconnectFromServer::onStatusChanged(ClientStatus status) void IntentDisconnectFromServer::onStatusChanged(ClientStatus status)
{ {
if (status == ClientStatus::StatusDisconnected) { if (status == ClientStatus::StatusDisconnected) {
emit finished(); emitFinished();
} }
} }

View file

@ -9,8 +9,8 @@
IntentJoinServerGame::IntentJoinServerGame(TabSupervisor *_tabSupervisor, IntentJoinServerGame::IntentJoinServerGame(TabSupervisor *_tabSupervisor,
RemoteClient *_remoteClient, RemoteClient *_remoteClient,
ContextJoinGame *_context) std::unique_ptr<ContextJoinGame> _context)
: Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), context(_context) : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), context(_context.release())
{ {
} }
@ -25,6 +25,9 @@ bool IntentJoinServerGame::checkPrecondition() const
if (remoteClient->peerName() != context->roomContext.serverContext.hostname) { if (remoteClient->peerName() != context->roomContext.serverContext.hostname) {
return false; return false;
} }
if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) {
return false;
}
if (!tabSupervisor->getRoomTabs().contains(context->roomContext.roomId)) { if (!tabSupervisor->getRoomTabs().contains(context->roomContext.roomId)) {
return false; return false;
@ -53,8 +56,7 @@ bool IntentJoinServerGame::tryJoinGame(TabRoom *room)
} }
if (room->getGameSelector()->joinGameById(context->gameId)) { if (room->getGameSelector()->joinGameById(context->gameId)) {
joined = true; emitFinished();
emit finished();
return true; return true;
} }
@ -70,9 +72,5 @@ void IntentJoinServerGame::waitForGame(TabRoom *room)
} }
}); });
QTimer::singleShot(15000, this, [this]() { QTimer::singleShot(15000, this, [this]() { emitFailed(tr("Game %1 not found in the room").arg(context->gameId)); });
if (!joined) {
emit failed(tr("Game %1 not found in the room").arg(context->gameId));
}
});
} }

View file

@ -6,6 +6,7 @@
#include "remote_client.h" #include "remote_client.h"
#include <QScopedPointer> #include <QScopedPointer>
#include <memory>
class TabRoom; class TabRoom;
class TabSupervisor; class TabSupervisor;
@ -15,7 +16,9 @@ class IntentJoinServerGame : public Intent
Q_OBJECT Q_OBJECT
public: public:
IntentJoinServerGame(TabSupervisor *_tabSupervisor, RemoteClient *_remoteClient, ContextJoinGame *_context); IntentJoinServerGame(TabSupervisor *_tabSupervisor,
RemoteClient *_remoteClient,
std::unique_ptr<ContextJoinGame> _context);
protected: protected:
bool checkPrecondition() const override; bool checkPrecondition() const override;
@ -29,7 +32,6 @@ private:
TabSupervisor *tabSupervisor; TabSupervisor *tabSupervisor;
RemoteClient *remoteClient; RemoteClient *remoteClient;
QScopedPointer<ContextJoinGame> context; QScopedPointer<ContextJoinGame> context;
bool joined = false;
}; };
#endif // COCKATRICE_INTENT_JOIN_SERVER_GAME_H #endif // COCKATRICE_INTENT_JOIN_SERVER_GAME_H

View file

@ -5,6 +5,9 @@
#include "../widgets/tabs/tab_supervisor.h" #include "../widgets/tabs/tab_supervisor.h"
#include "intent_connect_to_server.h" #include "intent_connect_to_server.h"
#include <QTimer>
#include <libcockatrice/protocol/pb/serverinfo_room.pb.h>
IntentJoinServerRoom::IntentJoinServerRoom(TabSupervisor *_tabSupervisor, IntentJoinServerRoom::IntentJoinServerRoom(TabSupervisor *_tabSupervisor,
RemoteClient *_remoteClient, RemoteClient *_remoteClient,
ContextJoinRoom *_context) ContextJoinRoom *_context)
@ -23,6 +26,9 @@ bool IntentJoinServerRoom::checkPrecondition() const
if (remoteClient->peerName() != context->serverContext.hostname) { if (remoteClient->peerName() != context->serverContext.hostname) {
return false; return false;
} }
if (QString::number(remoteClient->peerPort()) != context->serverContext.port) {
return false;
}
return true; return true;
} }
@ -31,7 +37,7 @@ void IntentJoinServerRoom::onPreconditionSatisfied()
{ {
if (tabSupervisor->getRoomTabs().contains(context->roomId)) { if (tabSupervisor->getRoomTabs().contains(context->roomId)) {
tabSupervisor->setCurrentWidget(tabSupervisor->getRoomTabs().value(context->roomId)); tabSupervisor->setCurrentWidget(tabSupervisor->getRoomTabs().value(context->roomId));
emit finished(); emitFinished();
return; return;
} }
@ -41,12 +47,25 @@ void IntentJoinServerRoom::onPreconditionSatisfied()
tabServer = tabSupervisor->getTabServer(); tabServer = tabSupervisor->getTabServer();
} }
if (!tabServer) { if (!tabServer) {
emit failed(tr("No server tab available")); emitFailed(tr("No server tab available"));
return; return;
} }
tabServer->joinRoom(context->roomId, true); const int roomId = context->roomId;
connect(tabServer, &TabServer::roomJoined, this, &IntentJoinServerRoom::finished); tabServer->joinRoom(roomId, true);
connect(tabServer, &TabServer::roomJoined, this, [this, roomId](const ServerInfo_Room &info, bool) {
if (info.room_id() == roomId) {
emitFinished();
}
});
connect(tabServer, &TabServer::roomJoinFailed, this, [this, roomId](int failedRoomId) {
if (failedRoomId == roomId) {
emitFailed(tr("Failed to join the server room %1").arg(roomId));
}
});
QTimer::singleShot(15000, this,
[this, roomId]() { emitFailed(tr("Timed out while joining the server room %1").arg(roomId)); });
} }
void IntentJoinServerRoom::onPreconditionNotSatisfied() void IntentJoinServerRoom::onPreconditionNotSatisfied()

View file

@ -21,13 +21,13 @@ void IntentGetLoginCredentials::onPreconditionSatisfied()
if (index >= 0) { if (index >= 0) {
context->username = servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); context->username = servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
context->password = servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); context->password = servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
emit finished(); emitFinished();
} else { } else {
emit failed(tr("No saved credentials for this server")); emitFailed(tr("No saved credentials for this server"));
} }
} }
void IntentGetLoginCredentials::onPreconditionNotSatisfied() void IntentGetLoginCredentials::onPreconditionNotSatisfied()
{ {
emit failed(tr("No saved credentials for this server")); emitFailed(tr("No saved credentials for this server"));
} }

View file

@ -22,8 +22,10 @@ void IntentOpenLocalDeck::onPreconditionSatisfied()
std::optional<LoadedDeck> deckOpt = DeckLoader::loadFromFile(file, DeckFileFormat::getFormatFromName(file), true); std::optional<LoadedDeck> deckOpt = DeckLoader::loadFromFile(file, DeckFileFormat::getFormatFromName(file), true);
if (deckOpt) { if (deckOpt) {
tabSupervisor->openDeckInNewTab(deckOpt.value()); tabSupervisor->openDeckInNewTab(deckOpt.value());
emitFinished();
} else {
emitFailed(tr("Unable to load deck file %1").arg(file));
} }
emit finished();
} }
void IntentOpenLocalDeck::onPreconditionNotSatisfied() void IntentOpenLocalDeck::onPreconditionNotSatisfied()

View file

@ -9,11 +9,11 @@ bool IntentWaitForDatabaseLoad::checkPrecondition() const
void IntentWaitForDatabaseLoad::onPreconditionSatisfied() void IntentWaitForDatabaseLoad::onPreconditionSatisfied()
{ {
emit finished(); emitFinished();
} }
void IntentWaitForDatabaseLoad::onPreconditionNotSatisfied() void IntentWaitForDatabaseLoad::onPreconditionNotSatisfied()
{ {
connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this,
[this]() { emit finished(); }); [this]() { emitFinished(); });
} }

View file

@ -6,8 +6,10 @@
#include "intent_login.h" #include "intent_login.h"
#include <QDebug> #include <QDebug>
#include <QMessageBox>
#include <QUrl> #include <QUrl>
#include <QUrlQuery> #include <QUrlQuery>
#include <memory>
IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow) IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow)
{ {
@ -35,17 +37,29 @@ void IntentUrlParser::handle(const QString &urlStr)
void IntentUrlParser::handleJoinGame(const QUrlQuery &query) void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
{ {
auto ctx = new ContextJoinGame(); auto showError = [this](const QString &message) { QMessageBox::warning(mainWindow, tr("Open game"), message); };
auto ctx = std::make_unique<ContextJoinGame>();
ctx->roomContext.serverContext.hostname = query.queryItemValue("hostname"); ctx->roomContext.serverContext.hostname = query.queryItemValue("hostname");
ctx->roomContext.serverContext.port = query.queryItemValue("port"); ctx->roomContext.serverContext.port = query.queryItemValue("port");
if (ctx->roomContext.serverContext.hostname.isEmpty()) {
showError(tr("Missing or empty hostname in the game link"));
return;
}
bool ok = false; bool ok = false;
ctx->roomContext.serverContext.port.toUShort(&ok);
if (!ok) {
showError(tr("Invalid or missing port in the game link"));
return;
}
ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok); ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok);
if (!ok) { if (!ok) {
qWarning() << "Invalid or missing roomId"; showError(tr("Invalid or missing room id in the game link"));
delete ctx;
return; return;
} }
@ -53,21 +67,23 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
ctx->gameId = query.queryItemValue("gameid").toInt(&ok); ctx->gameId = query.queryItemValue("gameid").toInt(&ok);
if (!ok) { if (!ok) {
qWarning() << "Invalid or missing gameId"; showError(tr("Invalid or missing game id in the game link"));
delete ctx;
return; return;
} }
// The join game intent owns the context and the credential lookup; once the // The join game intent owns the context and the credential lookup; once the
// chain finishes (or fails) it deletes the whole tree. // chain finishes (or fails) it deletes the whole tree.
auto joinGameIntent = new IntentJoinServerGame(mainWindow->getTabSupervisor(), mainWindow->getRemoteClient(), ctx); ContextConnectToServer *serverContext = &ctx->roomContext.serverContext;
auto joinGameIntent =
new IntentJoinServerGame(mainWindow->getTabSupervisor(), mainWindow->getRemoteClient(), std::move(ctx));
joinGameIntent->setParent(this); joinGameIntent->setParent(this);
auto getLoginCredentialsIntent = new IntentGetLoginCredentials(&ctx->roomContext.serverContext); auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext);
getLoginCredentialsIntent->setParent(joinGameIntent); getLoginCredentialsIntent->setParent(joinGameIntent);
connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute); connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute);
connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed); connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed);
connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
getLoginCredentialsIntent->execute(); getLoginCredentialsIntent->execute();
} }

View file

@ -13,8 +13,6 @@ public:
void handle(const QString &urlStr); void handle(const QString &urlStr);
void handleJoinGame(const QUrlQuery &query); void handleJoinGame(const QUrlQuery &query);
void parse(QString url);
private: private:
MainWindow *mainWindow; MainWindow *mainWindow;
}; };

View file

@ -10,12 +10,16 @@
#include "games_model.h" #include "games_model.h"
#include "user/user_list_manager.h" #include "user/user_list_manager.h"
#include <QClipboard>
#include <QDebug> #include <QDebug>
#include <QGuiApplication>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
#include <QMessageBox> #include <QMessageBox>
#include <QPushButton> #include <QPushButton>
#include <QTreeView> #include <QTreeView>
#include <QUrl>
#include <QUrlQuery>
#include <libcockatrice/network/client/abstract/abstract_client.h> #include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response.pb.h> #include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/room_commands.pb.h> #include <libcockatrice/protocol/pb/room_commands.pb.h>
@ -315,6 +319,21 @@ void GameSelector::customContextMenu(const QPoint &point)
dlg.exec(); dlg.exec();
}); });
QAction copyLink(tr("Copy Game Link"));
connect(&copyLink, &QAction::triggered, this, [=, this]() {
const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt());
QUrl url;
url.setScheme("cockatrice");
url.setHost("joingame");
QUrlQuery query;
query.addQueryItem("hostname", client->serverName());
query.addQueryItem("port", QString::number(client->serverPort()));
query.addQueryItem("roomid", QString::number(gameInfo.room_id()));
query.addQueryItem("gameid", QString::number(gameInfo.game_id()));
url.setQuery(query);
QGuiApplication::clipboard()->setText(url.toString(QUrl::FullyEncoded));
});
QMenu menu; QMenu menu;
menu.addAction(&joinGame); menu.addAction(&joinGame);
@ -332,6 +351,11 @@ void GameSelector::customContextMenu(const QPoint &point)
menu.addAction(&spectateGame); menu.addAction(&spectateGame);
menu.addAction(&getGameInfo); menu.addAction(&getGameInfo);
if (!client->serverName().isEmpty()) {
menu.addAction(&copyLink);
}
menu.exec(gameListView->mapToGlobal(point)); menu.exec(gameListView->mapToGlobal(point));
} }

View file

@ -191,7 +191,10 @@ void TabServer::joinRoom(int id, bool setCurrent)
PendingCommand *pend = client->prepareSessionCommand(cmd); PendingCommand *pend = client->prepareSessionCommand(cmd);
pend->setExtraData(setCurrent); pend->setExtraData(setCurrent);
connect(pend, &PendingCommand::finished, this, &TabServer::joinRoomFinished); connect(pend, &PendingCommand::finished, this,
[this, id](const Response &r, const CommandContainer &c, const QVariant &v) {
joinRoomFinished(r, c, v, id);
});
client->sendCommand(pend); client->sendCommand(pend);
@ -205,7 +208,8 @@ void TabServer::joinRoom(int id, bool setCurrent)
void TabServer::joinRoomFinished(const Response &r, void TabServer::joinRoomFinished(const Response &r,
const CommandContainer & /*commandContainer*/, const CommandContainer & /*commandContainer*/,
const QVariant &extraData) const QVariant &extraData,
int roomId)
{ {
switch (r.response_code()) { switch (r.response_code()) {
case Response::RespOk: case Response::RespOk:
@ -213,21 +217,25 @@ void TabServer::joinRoomFinished(const Response &r,
case Response::RespNameNotFound: case Response::RespNameNotFound:
QMessageBox::critical(this, tr("Error"), QMessageBox::critical(this, tr("Error"),
tr("Failed to join the server room: it doesn't exist on the server.")); tr("Failed to join the server room: it doesn't exist on the server."));
emit roomJoinFailed(roomId);
return; return;
case Response::RespContextError: case Response::RespContextError:
QMessageBox::critical( QMessageBox::critical(
this, tr("Error"), this, tr("Error"),
tr("The server thinks you are in the server room but your client is unable to display it. " tr("The server thinks you are in the server room but your client is unable to display it. "
"Try restarting your client.")); "Try restarting your client."));
emit roomJoinFailed(roomId);
return; return;
case Response::RespUserLevelTooLow: case Response::RespUserLevelTooLow:
QMessageBox::critical(this, tr("Error"), QMessageBox::critical(this, tr("Error"),
tr("You do not have the required permission to join this server room.")); tr("You do not have the required permission to join this server room."));
emit roomJoinFailed(roomId);
return; return;
default: default:
QMessageBox::critical( QMessageBox::critical(
this, tr("Error"), this, tr("Error"),
tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code())); tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code()));
emit roomJoinFailed(roomId);
return; return;
} }

View file

@ -49,9 +49,13 @@ class TabServer : public Tab
Q_OBJECT Q_OBJECT
signals: signals:
void roomJoined(const ServerInfo_Room &info, bool setCurrent); void roomJoined(const ServerInfo_Room &info, bool setCurrent);
void roomJoinFailed(int roomId);
private slots: private slots:
void processServerMessageEvent(const Event_ServerMessage &event); void processServerMessageEvent(const Event_ServerMessage &event);
void joinRoomFinished(const Response &resp, const CommandContainer &commandContainer, const QVariant &extraData); void joinRoomFinished(const Response &resp,
const CommandContainer &commandContainer,
const QVariant &extraData,
int roomId);
private: private:
AbstractClient *client; AbstractClient *client;

View file

@ -42,6 +42,7 @@
#include <QDebug> #include <QDebug>
#include <QLibraryInfo> #include <QLibraryInfo>
#include <QLocale> #include <QLocale>
#include <QMessageBox>
#include <QSystemTrayIcon> #include <QSystemTrayIcon>
#include <QTranslator> #include <QTranslator>
#include <libcockatrice/card/database/card_database_manager.h> #include <libcockatrice/card/database/card_database_manager.h>
@ -263,6 +264,35 @@ int main(int argc, char *argv[])
Logger::getInstance().logToFile(true); Logger::getInstance().logToFile(true);
} }
// --- Handle files or URLs passed at startup ---
// Only positional arguments are treated as files/URLs, so options like
// --connect are never handed off to another instance.
const QStringList startupFiles = parser.positionalArguments();
const bool hasActivationFiles = !startupFiles.isEmpty();
SingleInstanceManager instance;
if (hasActivationFiles) {
// Activation launch: hand off to the primary instance if one is
// running, otherwise become the primary ourselves. Do this before
// constructing the main window so a hand-off exits cheaply.
if (!instance.tryRun(startupFiles)) {
// Sent successfully → exit
return 0;
}
// No primary instance → become server
qInfo() << "No existing instance found, becoming primary instance";
} else {
// Plain launch: if another instance is running, run independently
// instead of handing off and exiting.
if (!instance.tryRun(QStringList())) {
// Another instance is already running → just run independently
qInfo() << "Another instance exists, running independently";
} else {
qInfo() << "No existing instance found, starting server";
}
}
rng = new RNG_SFMT; rng = new RNG_SFMT;
themeManager = new ThemeManager; themeManager = new ThemeManager;
soundEngine = new SoundEngine; soundEngine = new SoundEngine;
@ -301,6 +331,9 @@ int main(int argc, char *argv[])
urlParser->handle(file); urlParser->handle(file);
} else if (QFileInfo(file).exists()) { } else if (QFileInfo(file).exists()) {
auto openDeckIntent = new IntentOpenLocalDeck(ui.getTabSupervisor(), file); auto openDeckIntent = new IntentOpenLocalDeck(ui.getTabSupervisor(), file);
QObject::connect(openDeckIntent, &Intent::failed, &ui, [&ui](const QString &reason) {
QMessageBox::warning(&ui, QObject::tr("Open deck"), reason);
});
openDeckIntent->execute(); openDeckIntent->execute();
} }
}; };
@ -326,35 +359,6 @@ int main(int argc, char *argv[])
// then reload the DB. otherwise just reload the DB // then reload the DB. otherwise just reload the DB
SpoilerBackgroundUpdater spoilerBackgroundUpdater; SpoilerBackgroundUpdater spoilerBackgroundUpdater;
// --- Handle files or URLs passed at startup ---
QStringList startupFiles;
for (int i = 1; i < argc; ++i) {
startupFiles.append(QString::fromLocal8Bit(argv[i]));
}
bool hasActivationFiles = !startupFiles.isEmpty();
SingleInstanceManager instance;
if (hasActivationFiles) {
// Activation launch: try to forward
if (!instance.tryRun(startupFiles)) {
// Sent successfully → exit
return 0;
}
// No primary instance → become server
qInfo() << "No existing instance found, becoming primary instance";
} else {
// Plain launch: if another instance is running, run independently
// instead of handing off and exiting.
if (!instance.tryRun(QStringList())) {
// Another instance is already running → just run independently
qInfo() << "Another instance exists, running independently";
} else {
qInfo() << "No existing instance found, starting server";
}
}
ui.show(); ui.show();
qCInfo(MainLog) << "ui.show() finished"; qCInfo(MainLog) << "ui.show() finished";

View file

@ -1,12 +1,24 @@
#include "single_instance_manager.h" #include "single_instance_manager.h"
#include <QDir>
SingleInstanceManager::SingleInstanceManager(QObject *parent) : QObject(parent) SingleInstanceManager::SingleInstanceManager(QObject *parent) : QObject(parent)
{ {
} }
bool SingleInstanceManager::tryRun(const QStringList &filesToSend) bool SingleInstanceManager::tryRun(const QStringList &filesToSend)
{ {
serverName = "CockatriceSingleInstance"; // Scope the socket name to the current user. On Linux the default abstract
// namespace is system-wide, so a plain name would let one user's instance
// hijack another user's session.
QString userName = qEnvironmentVariable("USER");
if (userName.isEmpty()) {
userName = qEnvironmentVariable("USERNAME");
}
if (userName.isEmpty()) {
userName = QDir::home().dirName();
}
serverName = QStringLiteral("CockatriceSingleInstance-%1").arg(userName);
// Hand off to an already-running primary instance if one exists. // Hand off to an already-running primary instance if one exists.
if (forwardToPrimary(filesToSend)) { if (forwardToPrimary(filesToSend)) {

View file

@ -122,6 +122,20 @@ public:
return userName; return userName;
} }
/**
* @brief Returns the server address configured for the current connection.
*
* May be empty for clients that have no server counterpart (e.g. local test clients).
*/
virtual QString serverName() const
{
return {};
}
virtual quint16 serverPort() const
{
return 0;
}
static PendingCommand *prepareSessionCommand(const ::google::protobuf::Message &cmd); static PendingCommand *prepareSessionCommand(const ::google::protobuf::Message &cmd);
static PendingCommand *prepareRoomCommand(const ::google::protobuf::Message &cmd, int roomId); static PendingCommand *prepareRoomCommand(const ::google::protobuf::Message &cmd, int roomId);
static PendingCommand *prepareModeratorCommand(const ::google::protobuf::Message &cmd); static PendingCommand *prepareModeratorCommand(const ::google::protobuf::Message &cmd);

View file

@ -139,6 +139,14 @@ public:
return socket->peerPort(); return socket->peerPort();
} }
} }
QString serverName() const override
{
return lastHostname;
}
quint16 serverPort() const override
{
return static_cast<quint16>(lastPort);
}
void void
connectToServer(const QString &hostname, unsigned int port, const QString &_userName, const QString &_password); connectToServer(const QString &hostname, unsigned int port, const QString &_userName, const QString &_password);
void registerToServer(const QString &hostname, void registerToServer(const QString &hostname,