diff --git a/cmake/Info.plist b/cmake/Info.plist
index 614d82509..7f01befcb 100644
--- a/cmake/Info.plist
+++ b/cmake/Info.plist
@@ -1,38 +1,118 @@
-
+
+
+
+
+
+
CFBundleDevelopmentRegion
English
+
CFBundleExecutable
${MACOSX_BUNDLE_EXECUTABLE_NAME}
+
CFBundleGetInfoString
${MACOSX_BUNDLE_INFO_STRING}
+
CFBundleIconFile
${MACOSX_BUNDLE_ICON_FILE}
+
CFBundleIdentifier
${MACOSX_BUNDLE_GUI_IDENTIFIER}
+
CFBundleInfoDictionaryVersion
6.0
+
CFBundleLongVersionString
${MACOSX_BUNDLE_LONG_VERSION_STRING}
+
CFBundleName
${MACOSX_BUNDLE_BUNDLE_NAME}
+
CFBundlePackageType
APPL
+
CFBundleShortVersionString
${MACOSX_BUNDLE_SHORT_VERSION_STRING}
+
CFBundleSignature
????
+
CFBundleVersion
${MACOSX_BUNDLE_BUNDLE_VERSION}
- CSResourcesFileMapped
-
- LSRequiresCarbon
-
+
NSHumanReadableCopyright
${MACOSX_BUNDLE_COPYRIGHT}
+
NSHighResolutionCapable
+
+
+
+
+
+ UTExportedTypeDeclarations
+
+
+ UTTypeIdentifier
+ org.cockatrice.deck
+
+ UTTypeDescription
+ Cockatrice Deck
+
+ UTTypeConformsTo
+
+ public.data
+
+
+ UTTypeTagSpecification
+
+ public.filename-extension
+
+ cod
+
+
+
+
+
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeName
+ Cockatrice Deck
+
+ CFBundleTypeRole
+ Editor
+
+ LSHandlerRank
+ Default
+
+ LSItemContentTypes
+
+ org.cockatrice.deck
+
+
+
+
+
+
+
+
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ Cockatrice URL Scheme
+
+ CFBundleURLSchemes
+
+ cockatrice
+
+
+
+
diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in
index 5af116470..84b2c38af 100644
--- a/cmake/NSIS.template.in
+++ b/cmake/NSIS.template.in
@@ -294,6 +294,20 @@ Section "Application" SecApplication
SetShellVarContext all
SetOutPath "$INSTDIR"
+${If} $PortableMode = 0
+
+ ; --- Register .cod file type ---
+ WriteRegStr HKCR ".cod" "" "Cockatrice"
+ WriteRegStr HKCR "Cockatrice" "" "Cockatrice Deck File"
+ WriteRegStr HKCR "Cockatrice\shell\open\command" "" '"$INSTDIR\cockatrice.exe" "%1"'
+
+ ; --- Register custom URI protocol ---
+ WriteRegStr HKCR "cockatrice" "" "URL: Cockatrice Protocol"
+ WriteRegStr HKCR "cockatrice" "URL Protocol" ""
+ WriteRegStr HKCR "cockatrice\shell\open\command" "" '"$INSTDIR\cockatrice.exe" "%1"'
+
+${EndIf}
+
${If} $PortableMode = 1
${AndIf} ${FileExists} "$INSTDIR\portable.dat"
; upgrade portable mode
@@ -402,6 +416,18 @@ Section "un.Application" UnSecApplication
RMDir "$SMPROGRAMS\Cockatrice"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice"
+
+ ; Only remove the file/protocol associations if we registered them (i.e. the
+ ; 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
; unselected because it is /o
diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt
index cc58c5b43..574c9bc34 100644
--- a/cockatrice/CMakeLists.txt
+++ b/cockatrice/CMakeLists.txt
@@ -135,6 +135,12 @@ set(cockatrice_SOURCES
src/interface/card_picture_loader/card_picture_loader_worker.cpp
src/interface/card_picture_loader/card_picture_loader_worker_work.cpp
src/interface/card_picture_loader/card_picture_to_load.cpp
+ src/interface/intents/intent.cpp
+ src/interface/intents/intent.h
+ src/interface/intents/intent_open_local_deck.cpp
+ src/interface/intents/intent_open_local_deck.h
+ src/interface/intents/intent_wait_for_database_load.cpp
+ src/interface/intents/intent_wait_for_database_load.h
src/interface/layouts/flow_layout.cpp
src/interface/layouts/overlap_layout.cpp
src/interface/widgets/utility/line_edit_completer.cpp
@@ -293,6 +299,7 @@ set(cockatrice_SOURCES
src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp
src/interface/window_main.cpp
src/main.cpp
+ src/single_instance_manager.cpp
src/interface/widgets/tabs/abstract_tab_deck_editor.cpp
src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp
src/interface/widgets/tabs/api/archidekt/api_response/archidekt_deck_listing_api_response.cpp
@@ -360,6 +367,20 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h
src/interface/widgets/utility/compact_push_button.cpp
src/interface/widgets/utility/compact_push_button.h
+ src/single_instance_manager.h
+ src/client/url_scheme_event_filter.h
+ src/interface/intents/intent_connect_to_server.cpp
+ src/interface/intents/intent_connect_to_server.h
+ src/interface/intents/intent_disconnect_from_server.cpp
+ src/interface/intents/intent_disconnect_from_server.h
+ src/interface/intents/intent_join_server_game.cpp
+ src/interface/intents/intent_join_server_game.h
+ src/interface/intents/intent_join_server_room.cpp
+ src/interface/intents/intent_join_server_room.h
+ src/interface/intents/intent_login.cpp
+ src/interface/intents/intent_login.h
+ src/interface/intents/url_parser.cpp
+ src/interface/intents/url_parser.h
src/interface/widgets/server/user/user_info_popup.cpp
src/interface/widgets/server/user/user_info_popup.h
)
@@ -419,6 +440,11 @@ set(DESKTOPDIR
CACHE STRING "desktop file destination"
)
+set(MIMEDIR
+ share/mime/packages
+ CACHE STRING "mime file destination"
+)
+
set(COCKATRICE_MAC_QM_INSTALL_DIR "cockatrice.app/Contents/Resources/translations")
set(COCKATRICE_UNIX_QM_INSTALL_DIR "share/cockatrice/translations")
set(COCKATRICE_WIN32_QM_INSTALL_DIR "translations")
@@ -503,6 +529,23 @@ if(UNIX)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/resources/cockatrice.png DESTINATION ${ICONDIR}/hicolor/48x48/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-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()
elseif(WIN32)
install(TARGETS cockatrice RUNTIME DESTINATION ./)
diff --git a/cockatrice/cockatrice-cod.xml b/cockatrice/cockatrice-cod.xml
new file mode 100644
index 000000000..1a0199433
--- /dev/null
+++ b/cockatrice/cockatrice-cod.xml
@@ -0,0 +1,7 @@
+
+
+
+ Cockatrice Deck File
+
+
+
diff --git a/cockatrice/cockatrice.desktop b/cockatrice/cockatrice.desktop
index 092d84ef5..4b15fa9c1 100644
--- a/cockatrice/cockatrice.desktop
+++ b/cockatrice/cockatrice.desktop
@@ -3,6 +3,8 @@
Version=1.0
Type=Application
Name=Cockatrice
-Exec=cockatrice
+Exec=cockatrice %U
Icon=cockatrice
Categories=Game;CardGame;
+MimeType=application/x-cockatrice;
+X-Scheme-Handler/cockatrice=true
diff --git a/cockatrice/src/client/url_scheme_event_filter.h b/cockatrice/src/client/url_scheme_event_filter.h
new file mode 100644
index 000000000..9e96502ca
--- /dev/null
+++ b/cockatrice/src/client/url_scheme_event_filter.h
@@ -0,0 +1,69 @@
+#ifndef COCKATRICE_URL_SCHEME_EVENT_FILTER_H
+#define COCKATRICE_URL_SCHEME_EVENT_FILTER_H
+
+#include
+#include
+#include
+#include
+#include
+
+/**
+ * @brief Event filter that catches QFileOpenEvent URLs matching a scheme and
+ * re-emits them as urlReceived().
+ *
+ * On macOS, when the application is registered as a URL scheme handler, the
+ * OS delivers incoming URLs via QFileOpenEvent on the QApplication object.
+ * Install this filter on QApplication to intercept them:
+ *
+ * @code
+ * UrlSchemeEventFilter filter(QStringList{QStringLiteral("cockatrice")});
+ * QObject::connect(&filter, &UrlSchemeEventFilter::urlReceived,
+ * &mainWindow, &MainWindow::handleUrl);
+ * app.installEventFilter(&filter);
+ * @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
+{
+ Q_OBJECT
+
+public:
+ explicit UrlSchemeEventFilter(const QStringList &schemes, QObject *parent = nullptr)
+ : QObject(parent), prefixes(schemes)
+ {
+ }
+
+signals:
+ void urlReceived(const QString &url);
+
+public:
+ bool eventFilter(QObject *watched, QEvent *event) override
+ {
+ if (event->type() == QEvent::FileOpen) {
+ auto *fileEvent = static_cast(event);
+
+ const QUrl url = fileEvent->url();
+
+ for (const auto &prefix : prefixes) {
+ if (url.scheme() == prefix) {
+ emit urlReceived(url.toString());
+ return true;
+ }
+ }
+
+ if (url.isLocalFile()) {
+ emit urlReceived(url.toLocalFile());
+ return true;
+ }
+ }
+
+ return QObject::eventFilter(watched, event);
+ }
+
+private:
+ QStringList prefixes;
+};
+
+#endif // COCKATRICE_URL_SCHEME_EVENT_FILTER_H
diff --git a/cockatrice/src/interface/intents/contexts/context_connect_to_server.h b/cockatrice/src/interface/intents/contexts/context_connect_to_server.h
new file mode 100644
index 000000000..c7c40b261
--- /dev/null
+++ b/cockatrice/src/interface/intents/contexts/context_connect_to_server.h
@@ -0,0 +1,14 @@
+#ifndef COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H
+#define COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H
+
+#include
+
+struct ContextConnectToServer
+{
+ QString hostname;
+ QString port;
+ QString username;
+ QString password;
+};
+
+#endif // COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H
diff --git a/cockatrice/src/interface/intents/contexts/context_join_game.h b/cockatrice/src/interface/intents/contexts/context_join_game.h
new file mode 100644
index 000000000..102e2a520
--- /dev/null
+++ b/cockatrice/src/interface/intents/contexts/context_join_game.h
@@ -0,0 +1,11 @@
+#ifndef COCKATRICE_CONTEXT_JOIN_GAME_H
+#define COCKATRICE_CONTEXT_JOIN_GAME_H
+#include "context_join_room.h"
+
+struct ContextJoinGame
+{
+ ContextJoinRoom roomContext;
+ int gameId;
+};
+
+#endif // COCKATRICE_CONTEXT_JOIN_GAME_H
diff --git a/cockatrice/src/interface/intents/contexts/context_join_room.h b/cockatrice/src/interface/intents/contexts/context_join_room.h
new file mode 100644
index 000000000..23ae05e81
--- /dev/null
+++ b/cockatrice/src/interface/intents/contexts/context_join_room.h
@@ -0,0 +1,14 @@
+#ifndef COCKATRICE_CONTEXT_JOIN_ROOM_H
+#define COCKATRICE_CONTEXT_JOIN_ROOM_H
+
+#include "context_connect_to_server.h"
+
+#include
+
+struct ContextJoinRoom
+{
+ ContextConnectToServer serverContext;
+ int roomId;
+};
+
+#endif // COCKATRICE_CONTEXT_JOIN_ROOM_H
diff --git a/cockatrice/src/interface/intents/intent.cpp b/cockatrice/src/interface/intents/intent.cpp
new file mode 100644
index 000000000..c02a89f35
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent.cpp
@@ -0,0 +1,48 @@
+#include "intent.h"
+
+Intent::Intent(QObject *parent) : QObject(parent)
+{
+ // An intent is done as soon as it reports success or failure. Deleting it
+ // also tears down its dependency chain and disconnects any signal wiring.
+ connect(this, &Intent::finished, this, &QObject::deleteLater);
+ connect(this, &Intent::failed, this, &QObject::deleteLater);
+}
+
+Intent::~Intent() = default;
+
+void Intent::execute()
+{
+ if (checkPrecondition()) {
+ onPreconditionSatisfied();
+ } else {
+ onPreconditionNotSatisfied();
+ }
+}
+
+void Intent::runDependency(Intent *dependency)
+{
+ dependency->setParent(this);
+ connect(dependency, &Intent::finished, this, [this]() {
+ // Re-check after dependency finishes
+ this->execute();
+ });
+ connect(dependency, &Intent::failed, this, &Intent::failed);
+
+ dependency->execute();
+}
+
+void Intent::emitFinished()
+{
+ if (!completed) {
+ completed = true;
+ emit finished();
+ }
+}
+
+void Intent::emitFailed(const QString &reason)
+{
+ if (!completed) {
+ completed = true;
+ emit failed(reason);
+ }
+}
diff --git a/cockatrice/src/interface/intents/intent.h b/cockatrice/src/interface/intents/intent.h
new file mode 100644
index 000000000..125900ecd
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent.h
@@ -0,0 +1,37 @@
+#ifndef COCKATRICE_INTENT_H
+#define COCKATRICE_INTENT_H
+
+#include
+
+class Intent : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit Intent(QObject *parent = nullptr);
+ ~Intent() override;
+
+ void execute();
+
+signals:
+ void finished();
+ void failed(QString reason);
+
+protected:
+ // --- Subclasses must implement these ---
+ virtual bool checkPrecondition() const = 0;
+ virtual void onPreconditionSatisfied() = 0;
+ virtual void onPreconditionNotSatisfied() = 0;
+
+ // Helper to chain another intent
+ 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
diff --git a/cockatrice/src/interface/intents/intent_connect_to_server.cpp b/cockatrice/src/interface/intents/intent_connect_to_server.cpp
new file mode 100644
index 000000000..1cccc5a23
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_connect_to_server.cpp
@@ -0,0 +1,46 @@
+#include "intent_connect_to_server.h"
+
+#include "intent_disconnect_from_server.h"
+
+#include
+
+IntentConnectToServer::IntentConnectToServer(RemoteClient *_remoteClient, ContextConnectToServer *_context)
+ : Intent(), remoteClient(_remoteClient), context(_context)
+{
+}
+
+bool IntentConnectToServer::checkPrecondition() const
+{
+ return remoteClient->getStatus() == ClientStatus::StatusDisconnected;
+}
+
+void IntentConnectToServer::onPreconditionSatisfied()
+{
+ remoteClient->connectToServer(context->hostname, context->port.toUInt(), context->username, context->password);
+ connect(remoteClient, &RemoteClient::statusChanged, this, &IntentConnectToServer::onStatusChanged);
+ connect(remoteClient, &RemoteClient::socketError, this, &IntentConnectToServer::onSocketError);
+ connect(
+ remoteClient, &RemoteClient::loginError, this,
+ [this](Response::ResponseCode, const QString &reason, quint32, const QList &) { emitFailed(reason); });
+
+ QTimer::singleShot(15000, this, [this]() {
+ emitFailed(tr("Timed out while connecting to %1:%2").arg(context->hostname, context->port));
+ });
+}
+
+void IntentConnectToServer::onPreconditionNotSatisfied()
+{
+ runDependency(new IntentDisconnectFromServer(remoteClient));
+}
+
+void IntentConnectToServer::onStatusChanged(ClientStatus status)
+{
+ if (status == ClientStatus::StatusLoggedIn) {
+ emitFinished();
+ }
+}
+
+void IntentConnectToServer::onSocketError(const QString &errorString)
+{
+ emitFailed(tr("Failed to connect to %1:%2: %3").arg(context->hostname, context->port, errorString));
+}
diff --git a/cockatrice/src/interface/intents/intent_connect_to_server.h b/cockatrice/src/interface/intents/intent_connect_to_server.h
new file mode 100644
index 000000000..eab4d1a21
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_connect_to_server.h
@@ -0,0 +1,29 @@
+#ifndef COCKATRICE_INTENT_CONNECT_TO_SERVER_H
+#define COCKATRICE_INTENT_CONNECT_TO_SERVER_H
+
+#include "contexts/context_connect_to_server.h"
+#include "intent.h"
+#include "remote_client.h"
+
+class IntentConnectToServer : public Intent
+{
+ Q_OBJECT
+
+public:
+ IntentConnectToServer(RemoteClient *_remoteClient, ContextConnectToServer *_context);
+
+protected:
+ bool checkPrecondition() const override;
+ void onPreconditionSatisfied() override;
+ void onPreconditionNotSatisfied() override;
+
+private:
+ RemoteClient *remoteClient;
+ ContextConnectToServer *context;
+
+private slots:
+ void onStatusChanged(ClientStatus status);
+ void onSocketError(const QString &errorString);
+};
+
+#endif // COCKATRICE_INTENT_CONNECT_TO_SERVER_H
diff --git a/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp b/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp
new file mode 100644
index 000000000..cb39d7bab
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp
@@ -0,0 +1,29 @@
+#include "intent_disconnect_from_server.h"
+
+IntentDisconnectFromServer::IntentDisconnectFromServer(RemoteClient *_remoteClient)
+ : Intent(), remoteClient(_remoteClient)
+{
+}
+
+bool IntentDisconnectFromServer::checkPrecondition() const
+{
+ return remoteClient->getStatus() == ClientStatus::StatusDisconnected;
+}
+
+void IntentDisconnectFromServer::onPreconditionSatisfied()
+{
+ emitFinished();
+}
+
+void IntentDisconnectFromServer::onPreconditionNotSatisfied()
+{
+ connect(remoteClient, &RemoteClient::statusChanged, this, &IntentDisconnectFromServer::onStatusChanged);
+ remoteClient->disconnectFromServer();
+}
+
+void IntentDisconnectFromServer::onStatusChanged(ClientStatus status)
+{
+ if (status == ClientStatus::StatusDisconnected) {
+ emitFinished();
+ }
+}
diff --git a/cockatrice/src/interface/intents/intent_disconnect_from_server.h b/cockatrice/src/interface/intents/intent_disconnect_from_server.h
new file mode 100644
index 000000000..6e1dfd0c1
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_disconnect_from_server.h
@@ -0,0 +1,26 @@
+#ifndef COCKATRICE_INTENT_DISCONNECT_FROM_SERVER_H
+#define COCKATRICE_INTENT_DISCONNECT_FROM_SERVER_H
+
+#include "intent.h"
+#include "remote_client.h"
+
+class IntentDisconnectFromServer : public Intent
+{
+ Q_OBJECT
+
+public:
+ IntentDisconnectFromServer(RemoteClient *_remoteClient);
+
+protected:
+ bool checkPrecondition() const override;
+ void onPreconditionSatisfied() override;
+ void onPreconditionNotSatisfied() override;
+
+private:
+ RemoteClient *remoteClient;
+
+private slots:
+ void onStatusChanged(ClientStatus status);
+};
+
+#endif // COCKATRICE_INTENT_DISCONNECT_FROM_SERVER_H
diff --git a/cockatrice/src/interface/intents/intent_join_server_game.cpp b/cockatrice/src/interface/intents/intent_join_server_game.cpp
new file mode 100644
index 000000000..fb9c4d5ce
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_join_server_game.cpp
@@ -0,0 +1,76 @@
+#include "intent_join_server_game.h"
+
+#include "../widgets/server/game_selector.h"
+#include "../widgets/tabs/tab_room.h"
+#include "../widgets/tabs/tab_supervisor.h"
+#include "intent_join_server_room.h"
+
+#include
+
+IntentJoinServerGame::IntentJoinServerGame(TabSupervisor *_tabSupervisor,
+ RemoteClient *_remoteClient,
+ std::unique_ptr _context)
+ : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), context(_context.release())
+{
+}
+
+bool IntentJoinServerGame::checkPrecondition() const
+{
+ if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) {
+ return false;
+ }
+ // peerPort() reflects the actual TCP peer, which may differ from the
+ // configured server port (e.g. when connecting through a proxy), so only
+ // the hostname is compared here.
+ if (remoteClient->peerName() != context->roomContext.serverContext.hostname) {
+ return false;
+ }
+ if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) {
+ return false;
+ }
+
+ if (!tabSupervisor->getRoomTabs().contains(context->roomContext.roomId)) {
+ return false;
+ }
+
+ return true;
+}
+
+void IntentJoinServerGame::onPreconditionSatisfied()
+{
+ TabRoom *room = tabSupervisor->getRoomTabs().value(context->roomContext.roomId);
+ if (!tryJoinGame(room)) {
+ waitForGame(room);
+ }
+}
+
+void IntentJoinServerGame::onPreconditionNotSatisfied()
+{
+ runDependency(new IntentJoinServerRoom(tabSupervisor, remoteClient, &context->roomContext));
+}
+
+bool IntentJoinServerGame::tryJoinGame(TabRoom *room)
+{
+ if (!room) {
+ return false;
+ }
+
+ if (room->getGameSelector()->joinGameById(context->gameId)) {
+ emitFinished();
+ return true;
+ }
+
+ return false;
+}
+
+void IntentJoinServerGame::waitForGame(TabRoom *room)
+{
+ connect(room, &TabRoom::gameListUpdated, this, [this]() {
+ TabRoom *updatedRoom = tabSupervisor->getRoomTabs().value(context->roomContext.roomId);
+ if (updatedRoom) {
+ tryJoinGame(updatedRoom);
+ }
+ });
+
+ QTimer::singleShot(15000, this, [this]() { emitFailed(tr("Game %1 not found in the room").arg(context->gameId)); });
+}
diff --git a/cockatrice/src/interface/intents/intent_join_server_game.h b/cockatrice/src/interface/intents/intent_join_server_game.h
new file mode 100644
index 000000000..5e196df38
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_join_server_game.h
@@ -0,0 +1,37 @@
+#ifndef COCKATRICE_INTENT_JOIN_SERVER_GAME_H
+#define COCKATRICE_INTENT_JOIN_SERVER_GAME_H
+
+#include "contexts/context_join_game.h"
+#include "intent.h"
+#include "remote_client.h"
+
+#include
+#include
+
+class TabRoom;
+class TabSupervisor;
+
+class IntentJoinServerGame : public Intent
+{
+ Q_OBJECT
+
+public:
+ IntentJoinServerGame(TabSupervisor *_tabSupervisor,
+ RemoteClient *_remoteClient,
+ std::unique_ptr _context);
+
+protected:
+ bool checkPrecondition() const override;
+ void onPreconditionSatisfied() override;
+ void onPreconditionNotSatisfied() override;
+
+private:
+ bool tryJoinGame(TabRoom *room);
+ void waitForGame(TabRoom *room);
+
+ TabSupervisor *tabSupervisor;
+ RemoteClient *remoteClient;
+ QScopedPointer context;
+};
+
+#endif // COCKATRICE_INTENT_JOIN_SERVER_GAME_H
diff --git a/cockatrice/src/interface/intents/intent_join_server_room.cpp b/cockatrice/src/interface/intents/intent_join_server_room.cpp
new file mode 100644
index 000000000..d25bc8d17
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_join_server_room.cpp
@@ -0,0 +1,74 @@
+#include "intent_join_server_room.h"
+
+#include "../widgets/tabs/tab_room.h"
+#include "../widgets/tabs/tab_server.h"
+#include "../widgets/tabs/tab_supervisor.h"
+#include "intent_connect_to_server.h"
+
+#include
+#include
+
+IntentJoinServerRoom::IntentJoinServerRoom(TabSupervisor *_tabSupervisor,
+ RemoteClient *_remoteClient,
+ ContextJoinRoom *_context)
+ : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), context(_context)
+{
+}
+
+bool IntentJoinServerRoom::checkPrecondition() const
+{
+ if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) {
+ return false;
+ }
+ // peerPort() reflects the actual TCP peer, which may differ from the
+ // configured server port (e.g. when connecting through a proxy), so only
+ // the hostname is compared here.
+ if (remoteClient->peerName() != context->serverContext.hostname) {
+ return false;
+ }
+ if (QString::number(remoteClient->peerPort()) != context->serverContext.port) {
+ return false;
+ }
+
+ return true;
+}
+
+void IntentJoinServerRoom::onPreconditionSatisfied()
+{
+ if (tabSupervisor->getRoomTabs().contains(context->roomId)) {
+ tabSupervisor->setCurrentWidget(tabSupervisor->getRoomTabs().value(context->roomId));
+ emitFinished();
+ return;
+ }
+
+ TabServer *tabServer = tabSupervisor->getTabServer();
+ if (!tabServer) {
+ tabSupervisor->openTabServer();
+ tabServer = tabSupervisor->getTabServer();
+ }
+ if (!tabServer) {
+ emitFailed(tr("No server tab available"));
+ return;
+ }
+
+ const int roomId = context->roomId;
+ 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()
+{
+ runDependency(new IntentConnectToServer(remoteClient, &context->serverContext));
+}
diff --git a/cockatrice/src/interface/intents/intent_join_server_room.h b/cockatrice/src/interface/intents/intent_join_server_room.h
new file mode 100644
index 000000000..4a5599896
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_join_server_room.h
@@ -0,0 +1,28 @@
+#ifndef COCKATRICE_INTENT_JOIN_SERVER_ROOM_H
+#define COCKATRICE_INTENT_JOIN_SERVER_ROOM_H
+
+#include "contexts/context_join_room.h"
+#include "intent.h"
+#include "remote_client.h"
+
+class TabSupervisor;
+
+class IntentJoinServerRoom : public Intent
+{
+ Q_OBJECT
+
+public:
+ IntentJoinServerRoom(TabSupervisor *_tabSupervisor, RemoteClient *_remoteClient, ContextJoinRoom *_context);
+
+protected:
+ bool checkPrecondition() const override;
+ void onPreconditionSatisfied() override;
+ void onPreconditionNotSatisfied() override;
+
+private:
+ TabSupervisor *tabSupervisor;
+ RemoteClient *remoteClient;
+ ContextJoinRoom *context;
+};
+
+#endif // COCKATRICE_INTENT_JOIN_SERVER_ROOM_H
diff --git a/cockatrice/src/interface/intents/intent_login.cpp b/cockatrice/src/interface/intents/intent_login.cpp
new file mode 100644
index 000000000..ff871fd03
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_login.cpp
@@ -0,0 +1,33 @@
+#include "intent_login.h"
+
+#include "../../client/settings/cache_settings.h"
+#include "libcockatrice/settings/servers_settings.h"
+
+IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context) : Intent(), context(_context)
+{
+}
+
+bool IntentGetLoginCredentials::checkPrecondition() const
+{
+ ServersSettings &servers = SettingsCache::instance().servers();
+ return servers.hasLoginData(context->hostname, context->port);
+}
+
+void IntentGetLoginCredentials::onPreconditionSatisfied()
+{
+ ServersSettings &servers = SettingsCache::instance().servers();
+ const int index = servers.findServerIndex(context->hostname, context->port);
+
+ if (index >= 0) {
+ 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();
+ emitFinished();
+ } else {
+ emitFailed(tr("No saved credentials for this server"));
+ }
+}
+
+void IntentGetLoginCredentials::onPreconditionNotSatisfied()
+{
+ emitFailed(tr("No saved credentials for this server"));
+}
diff --git a/cockatrice/src/interface/intents/intent_login.h b/cockatrice/src/interface/intents/intent_login.h
new file mode 100644
index 000000000..c7fec92b7
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_login.h
@@ -0,0 +1,23 @@
+#ifndef COCKATRICE_INTENT_LOGIN_H
+#define COCKATRICE_INTENT_LOGIN_H
+
+#include "contexts/context_connect_to_server.h"
+#include "intent.h"
+
+class IntentGetLoginCredentials : public Intent
+{
+ Q_OBJECT
+
+public:
+ IntentGetLoginCredentials(ContextConnectToServer *_context);
+
+protected:
+ bool checkPrecondition() const override;
+ void onPreconditionSatisfied() override;
+ void onPreconditionNotSatisfied() override;
+
+private:
+ ContextConnectToServer *context;
+};
+
+#endif // COCKATRICE_INTENT_LOGIN_H
diff --git a/cockatrice/src/interface/intents/intent_open_local_deck.cpp b/cockatrice/src/interface/intents/intent_open_local_deck.cpp
new file mode 100644
index 000000000..2457bec72
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_open_local_deck.cpp
@@ -0,0 +1,34 @@
+#include "intent_open_local_deck.h"
+
+#include "../deck_loader/deck_file_format.h"
+#include "../deck_loader/deck_loader.h"
+#include "../widgets/tabs/tab_supervisor.h"
+#include "intent_wait_for_database_load.h"
+
+#include
+
+IntentOpenLocalDeck::IntentOpenLocalDeck(TabSupervisor *_tabSupervisor, const QString &_file)
+ : Intent(), tabSupervisor(_tabSupervisor), file(_file)
+{
+}
+
+bool IntentOpenLocalDeck::checkPrecondition() const
+{
+ return CardDatabaseManager::getInstance()->getLoadStatus() == LoadStatus::Ok;
+}
+
+void IntentOpenLocalDeck::onPreconditionSatisfied()
+{
+ std::optional deckOpt = DeckLoader::loadFromFile(file, DeckFileFormat::getFormatFromName(file), true);
+ if (deckOpt) {
+ tabSupervisor->openDeckInNewTab(deckOpt.value());
+ emitFinished();
+ } else {
+ emitFailed(tr("Unable to load deck file %1").arg(file));
+ }
+}
+
+void IntentOpenLocalDeck::onPreconditionNotSatisfied()
+{
+ runDependency(new IntentWaitForDatabaseLoad);
+}
diff --git a/cockatrice/src/interface/intents/intent_open_local_deck.h b/cockatrice/src/interface/intents/intent_open_local_deck.h
new file mode 100644
index 000000000..97f875e39
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_open_local_deck.h
@@ -0,0 +1,27 @@
+#ifndef COCKATRICE_INTENT_OPEN_LOCAL_DECK_H
+#define COCKATRICE_INTENT_OPEN_LOCAL_DECK_H
+
+#include "intent.h"
+
+#include
+
+class TabSupervisor;
+
+class IntentOpenLocalDeck : public Intent
+{
+ Q_OBJECT
+
+public:
+ IntentOpenLocalDeck(TabSupervisor *_tabSupervisor, const QString &_file);
+
+protected:
+ bool checkPrecondition() const override;
+ void onPreconditionSatisfied() override;
+ void onPreconditionNotSatisfied() override;
+
+private:
+ TabSupervisor *tabSupervisor;
+ QString file;
+};
+
+#endif // COCKATRICE_INTENT_OPEN_LOCAL_DECK_H
diff --git a/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp b/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp
new file mode 100644
index 000000000..c36378818
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp
@@ -0,0 +1,19 @@
+#include "intent_wait_for_database_load.h"
+
+#include
+
+bool IntentWaitForDatabaseLoad::checkPrecondition() const
+{
+ return CardDatabaseManager::getInstance()->getLoadStatus() == LoadStatus::Ok;
+}
+
+void IntentWaitForDatabaseLoad::onPreconditionSatisfied()
+{
+ emitFinished();
+}
+
+void IntentWaitForDatabaseLoad::onPreconditionNotSatisfied()
+{
+ connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this,
+ [this]() { emitFinished(); });
+}
diff --git a/cockatrice/src/interface/intents/intent_wait_for_database_load.h b/cockatrice/src/interface/intents/intent_wait_for_database_load.h
new file mode 100644
index 000000000..72f4a1ffc
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_wait_for_database_load.h
@@ -0,0 +1,16 @@
+#ifndef COCKATRICE_INTENT_WAIT_FOR_DATABASE_LOAD_H
+#define COCKATRICE_INTENT_WAIT_FOR_DATABASE_LOAD_H
+
+#include "intent.h"
+
+class IntentWaitForDatabaseLoad : public Intent
+{
+ Q_OBJECT
+
+protected:
+ bool checkPrecondition() const override;
+ void onPreconditionSatisfied() override;
+ void onPreconditionNotSatisfied() override;
+};
+
+#endif // COCKATRICE_INTENT_WAIT_FOR_DATABASE_LOAD_H
diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp
new file mode 100644
index 000000000..8b5309603
--- /dev/null
+++ b/cockatrice/src/interface/intents/url_parser.cpp
@@ -0,0 +1,89 @@
+#include "url_parser.h"
+
+#include "../window_main.h"
+#include "contexts/context_join_game.h"
+#include "intent_join_server_game.h"
+#include "intent_login.h"
+
+#include
+#include
+#include
+#include
+#include
+
+IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow)
+{
+}
+
+void IntentUrlParser::handle(const QString &urlStr)
+{
+ QUrl url(urlStr);
+
+ if (url.scheme() != "cockatrice") {
+ return;
+ }
+
+ const QString action = url.host();
+ QUrlQuery query(url);
+
+ if (action == "joingame") {
+ handleJoinGame(query);
+ } else if (action == "opendeck") {
+ // handleOpenDeck(query);
+ } else {
+ qWarning() << "Unknown intent:" << action;
+ }
+}
+
+void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
+{
+ auto showError = [this](const QString &message) { QMessageBox::warning(mainWindow, tr("Open game"), message); };
+
+ auto ctx = std::make_unique();
+
+ ctx->roomContext.serverContext.hostname = query.queryItemValue("hostname");
+ 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;
+ 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);
+
+ if (!ok) {
+ showError(tr("Invalid or missing room id in the game link"));
+ return;
+ }
+
+ ok = false;
+ ctx->gameId = query.queryItemValue("gameid").toInt(&ok);
+
+ if (!ok) {
+ showError(tr("Invalid or missing game id in the game link"));
+ return;
+ }
+
+ // The join game intent owns the context and the credential lookup; once the
+ // chain finishes (or fails) it deletes the whole tree.
+ ContextConnectToServer *serverContext = &ctx->roomContext.serverContext;
+ auto joinGameIntent =
+ new IntentJoinServerGame(mainWindow->getTabSupervisor(), mainWindow->getRemoteClient(), std::move(ctx));
+ joinGameIntent->setParent(this);
+
+ auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext);
+ getLoginCredentialsIntent->setParent(joinGameIntent);
+
+ connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute);
+ connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed);
+ connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
+
+ getLoginCredentialsIntent->execute();
+}
diff --git a/cockatrice/src/interface/intents/url_parser.h b/cockatrice/src/interface/intents/url_parser.h
new file mode 100644
index 000000000..bac0e3d25
--- /dev/null
+++ b/cockatrice/src/interface/intents/url_parser.h
@@ -0,0 +1,20 @@
+#ifndef COCKATRICE_URL_PARSER_H
+#define COCKATRICE_URL_PARSER_H
+#include
+#include
+
+class MainWindow;
+class IntentUrlParser : public QObject
+{
+ Q_OBJECT
+
+public:
+ IntentUrlParser(QObject *parent, MainWindow *mainWindow);
+ void handle(const QString &urlStr);
+ void handleJoinGame(const QUrlQuery &query);
+
+private:
+ MainWindow *mainWindow;
+};
+
+#endif // COCKATRICE_URL_PARSER_H
diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp
index e9fa3c3cf..11b36ca92 100644
--- a/cockatrice/src/interface/widgets/server/game_selector.cpp
+++ b/cockatrice/src/interface/widgets/server/game_selector.cpp
@@ -10,12 +10,16 @@
#include "games_model.h"
#include "user/user_list_manager.h"
+#include
#include
+#include
#include
#include
#include
#include
#include
+#include
+#include
#include
#include
#include
@@ -315,6 +319,21 @@ void GameSelector::customContextMenu(const QPoint &point)
dlg.exec();
});
+ QAction copyLink(tr("Copy Game Link"));
+ connect(©Link, &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;
menu.addAction(&joinGame);
@@ -332,6 +351,11 @@ void GameSelector::customContextMenu(const QPoint &point)
menu.addAction(&spectateGame);
menu.addAction(&getGameInfo);
+
+ if (!client->serverName().isEmpty()) {
+ menu.addAction(©Link);
+ }
+
menu.exec(gameListView->mapToGlobal(point));
}
@@ -379,6 +403,24 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge)
disableButtons();
}
+bool GameSelector::joinGameById(int gameId)
+{
+ auto *model = gameListView->model();
+
+ for (int row = 0; row < model->rowCount(); ++row) {
+ QModelIndex idx = model->index(row, 0);
+ const ServerInfo_Game &game = gameListModel->getGame(idx.data(Qt::UserRole).toInt());
+ if (game.game_id() == gameId) {
+ gameListView->setCurrentIndex(idx);
+ joinGame();
+ return true;
+ }
+ }
+
+ qWarning() << "Game" << gameId << "not found";
+ return false;
+}
+
void GameSelector::disableButtons()
{
if (createButton) {
diff --git a/cockatrice/src/interface/widgets/server/game_selector.h b/cockatrice/src/interface/widgets/server/game_selector.h
index fa91e5f96..da34d5322 100644
--- a/cockatrice/src/interface/widgets/server/game_selector.h
+++ b/cockatrice/src/interface/widgets/server/game_selector.h
@@ -202,6 +202,7 @@ public:
* @param info The ServerInfo_Game object containing information about the game to update.
*/
void processGameInfo(const ServerInfo_Game &info);
+ bool joinGameById(int gameId);
};
#endif
diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp
index 5cf400099..899f38ec2 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp
@@ -280,6 +280,7 @@ void TabRoom::processListGamesEvent(const Event_ListGames &event)
for (int i = 0; i < gameListSize; ++i) {
gameSelector->processGameInfo(event.game_list(i));
}
+ emit gameListUpdated();
}
void TabRoom::processJoinRoomEvent(const Event_JoinRoom &event)
diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.h b/cockatrice/src/interface/widgets/tabs/tab_room.h
index d669b6107..2881c25f4 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_room.h
+++ b/cockatrice/src/interface/widgets/tabs/tab_room.h
@@ -78,6 +78,7 @@ signals:
void openMessageDialog(const QString &userName, bool focus);
void maximizeClient();
void notIdle();
+ void gameListUpdated();
private slots:
void sendMessage();
void sayFinished(const Response &response);
@@ -127,6 +128,10 @@ public:
{
return ownUser;
}
+ [[nodiscard]] GameSelector *getGameSelector() const
+ {
+ return gameSelector;
+ }
PendingCommand *prepareRoomCommand(const ::google::protobuf::Message &cmd);
void sendRoomCommand(PendingCommand *pend);
diff --git a/cockatrice/src/interface/widgets/tabs/tab_server.cpp b/cockatrice/src/interface/widgets/tabs/tab_server.cpp
index 2fce5c1fa..13a77e957 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_server.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_server.cpp
@@ -191,7 +191,10 @@ void TabServer::joinRoom(int id, bool setCurrent)
PendingCommand *pend = client->prepareSessionCommand(cmd);
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);
@@ -205,7 +208,8 @@ void TabServer::joinRoom(int id, bool setCurrent)
void TabServer::joinRoomFinished(const Response &r,
const CommandContainer & /*commandContainer*/,
- const QVariant &extraData)
+ const QVariant &extraData,
+ int roomId)
{
switch (r.response_code()) {
case Response::RespOk:
@@ -213,21 +217,25 @@ void TabServer::joinRoomFinished(const Response &r,
case Response::RespNameNotFound:
QMessageBox::critical(this, tr("Error"),
tr("Failed to join the server room: it doesn't exist on the server."));
+ emit roomJoinFailed(roomId);
return;
case Response::RespContextError:
QMessageBox::critical(
this, tr("Error"),
tr("The server thinks you are in the server room but your client is unable to display it. "
"Try restarting your client."));
+ emit roomJoinFailed(roomId);
return;
case Response::RespUserLevelTooLow:
QMessageBox::critical(this, tr("Error"),
tr("You do not have the required permission to join this server room."));
+ emit roomJoinFailed(roomId);
return;
default:
QMessageBox::critical(
this, tr("Error"),
tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code()));
+ emit roomJoinFailed(roomId);
return;
}
diff --git a/cockatrice/src/interface/widgets/tabs/tab_server.h b/cockatrice/src/interface/widgets/tabs/tab_server.h
index 137823592..c10b7945b 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_server.h
+++ b/cockatrice/src/interface/widgets/tabs/tab_server.h
@@ -49,10 +49,13 @@ class TabServer : public Tab
Q_OBJECT
signals:
void roomJoined(const ServerInfo_Room &info, bool setCurrent);
+ void roomJoinFailed(int roomId);
private slots:
void processServerMessageEvent(const Event_ServerMessage &event);
- void joinRoom(int id, bool setCurrent);
- 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:
AbstractClient *client;
@@ -62,6 +65,7 @@ private:
public:
TabServer(TabSupervisor *_tabSupervisor, AbstractClient *_client);
+ void joinRoom(int id, bool setCurrent);
void retranslateUi() override;
[[nodiscard]] QString getTabText() const override
{
diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp
index c9478ee0b..3f30ba8be 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp
@@ -587,6 +587,10 @@ void TabSupervisor::actTabServer(bool checked)
void TabSupervisor::openTabServer()
{
+ if (tabServer) {
+ return;
+ }
+
tabServer = new TabServer(this, client);
connect(tabServer, &TabServer::roomJoined, this, &TabSupervisor::addRoomTab);
myAddTab(tabServer, aTabServer);
diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h
index 3eac144b7..e6c009fda 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h
+++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h
@@ -152,6 +152,10 @@ public:
{
return userListManager;
}
+ [[nodiscard]] TabServer *getTabServer() const
+ {
+ return tabServer;
+ }
[[nodiscard]] const QMap &getRoomTabs() const
{
return roomTabs;
@@ -183,6 +187,7 @@ public slots:
void maximizeMainWindow();
void actTabVisualDeckStorage(bool checked);
void actTabReplays(bool checked);
+ void openTabServer();
private slots:
void refreshShortcuts();
@@ -195,7 +200,6 @@ private slots:
void openTabVisualDeckStorage();
void openTabHome();
- void openTabServer();
void openTabAccount();
void openTabDeckStorage();
void openTabReplays();
diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h
index 5f631ddc3..610f11965 100644
--- a/cockatrice/src/interface/window_main.h
+++ b/cockatrice/src/interface/window_main.h
@@ -150,6 +150,11 @@ public:
}
~MainWindow() override;
+ RemoteClient *getRemoteClient() const
+ {
+ return connectionController->client();
+ }
+
TabSupervisor *getTabSupervisor() const
{
return tabSupervisor;
diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp
index dbfd2b6b7..0524112e4 100644
--- a/cockatrice/src/main.cpp
+++ b/cockatrice/src/main.cpp
@@ -23,12 +23,17 @@
#include "client/network/update/card_spoiler/spoiler_background_updater.h"
#include "client/settings/cache_settings.h"
#include "client/sound_engine.h"
+#include "client/url_scheme_event_filter.h"
#include "database/interface/settings_card_preference_provider.h"
+#include "interface/intents/intent_open_local_deck.h"
+#include "interface/intents/url_parser.h"
#include "interface/logger.h"
#include "interface/pixel_map_generator.h"
#include "interface/theme_manager.h"
#include "interface/widgets/dialogs/dlg_settings.h"
+#include "interface/widgets/tabs/tab_supervisor.h"
#include "interface/window_main.h"
+#include "single_instance_manager.h"
#include "version_string.h"
#include
@@ -37,6 +42,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -177,6 +183,7 @@ int main(int argc, char *argv[])
SetUnhandledExceptionFilter(CockatriceUnhandledExceptionFilter);
#endif
+ // Logging setup
#ifdef Q_OS_APPLE
// /cockatrice/cockatrice.app/Contents/MacOS/cockatrice
const QByteArray configPath = "../../../qtlogging.ini";
@@ -194,15 +201,29 @@ int main(int argc, char *argv[])
// Set the QT_LOGGING_CONF environment variable
qputenv("QT_LOGGING_CONF", configPath);
}
+
qSetMessagePattern(
"\033[0m[%{time yyyy-MM-dd h:mm:ss.zzz} "
"%{if-debug}\033[36mD%{endif}%{if-info}\033[32mI%{endif}%{if-warning}\033[33mW%{endif}%{if-critical}\033[31mC%{"
"endif}%{if-fatal}\033[1;31mF%{endif}\033[0m] [%{function}] - %{message} [%{file}:%{line}]");
QApplication app(argc, argv);
+#ifdef Q_OS_MAC
+ UrlSchemeEventFilter cockatriceFilter(QStringList{QStringLiteral("cockatrice")});
+
+ QStringList pendingMacUrls;
+
+ const auto cocoaBufferConn =
+ QObject::connect(&cockatriceFilter, &UrlSchemeEventFilter::urlReceived,
+ [&pendingMacUrls](const QString &url) { pendingMacUrls.append(url); });
+
+ app.installEventFilter(&cockatriceFilter);
+#endif
+
QObject::connect(&app, &QApplication::lastWindowClosed, &app, &QApplication::quit);
qInstallMessageHandler(CockatriceLogger);
+
#ifdef Q_OS_WIN
app.addLibraryPath(app.applicationDirPath() + "/plugins");
#endif
@@ -218,6 +239,7 @@ int main(int argc, char *argv[])
qApp->setAttribute(Qt::AA_DontShowIconsInMenus, true);
#endif
+ // Translations
#ifdef Q_OS_MAC
translationPath = qApp->applicationDirPath() + "/../Resources/translations";
#elif defined(Q_OS_WIN)
@@ -226,6 +248,7 @@ int main(int argc, char *argv[])
translationPath = qApp->applicationDirPath() + "/../share/cockatrice/translations";
#endif
+ // Command-line parser
QCommandLineParser parser;
parser.setApplicationDescription("Cockatrice");
parser.addHelpOption();
@@ -241,6 +264,35 @@ int main(int argc, char *argv[])
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;
themeManager = new ThemeManager;
soundEngine = new SoundEngine;
@@ -272,6 +324,26 @@ int main(int argc, char *argv[])
CardDatabaseManager::getInstance()->loadCardDatabases();
MainWindow ui;
+
+ auto handleActivation = [&ui](const QString &file) {
+ if (file.startsWith("cockatrice://")) {
+ auto urlParser = new IntentUrlParser(&ui, &ui);
+ urlParser->handle(file);
+ } else if (QFileInfo(file).exists()) {
+ 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();
+ }
+ };
+
+#ifdef Q_OS_MAC
+ QObject::disconnect(cocoaBufferConn);
+
+ QObject::connect(&cockatriceFilter, &UrlSchemeEventFilter::urlReceived,
+ [&handleActivation](const QString &url) { handleActivation(url); });
+#endif
if (parser.isSet("connect")) {
ui.setConnectTo(parser.value("connect"));
}
@@ -297,7 +369,26 @@ int main(int argc, char *argv[])
#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0))
app.setAttribute(Qt::AA_UseHighDpiPixmaps);
#endif
- app.exec();
+
+#ifdef Q_OS_MAC
+ for (const QString &url : pendingMacUrls) {
+ handleActivation(url);
+ }
+ pendingMacUrls.clear();
+#endif
+
+ for (const QString &file : startupFiles) {
+ handleActivation(file);
+ }
+
+ // Connect to future file/URL events from other instances
+ QObject::connect(&instance, &SingleInstanceManager::filesReceived, [&handleActivation](const QStringList &files) {
+ for (const QString &file : files) {
+ handleActivation(file);
+ }
+ });
+
+ int ret = app.exec();
qCInfo(MainLog) << "Event loop finished, terminating...";
delete rng;
@@ -305,5 +396,5 @@ int main(int argc, char *argv[])
CountryPixmapGenerator::clear();
UserLevelPixmapGenerator::clear();
- return 0;
+ return ret;
}
diff --git a/cockatrice/src/single_instance_manager.cpp b/cockatrice/src/single_instance_manager.cpp
new file mode 100644
index 000000000..aca23160c
--- /dev/null
+++ b/cockatrice/src/single_instance_manager.cpp
@@ -0,0 +1,126 @@
+#include "single_instance_manager.h"
+
+#include
+
+SingleInstanceManager::SingleInstanceManager(QObject *parent) : QObject(parent)
+{
+}
+
+bool SingleInstanceManager::tryRun(const QStringList &filesToSend)
+{
+ // 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.
+ if (forwardToPrimary(filesToSend)) {
+ return false;
+ }
+
+ // 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.
+ if (forwardToPrimary(filesToSend)) {
+ return false;
+ }
+
+ // The socket is stale (left over by a crashed instance): 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;
+}
+
+bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend)
+{
+ QLocalSocket socket;
+ socket.connectToServer(serverName);
+ if (!socket.waitForConnected(200)) {
+ return false;
+ }
+
+ // 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);
+
+ return true;
+}
+
+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::create();
+ auto expectedSize = QSharedPointer::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(sizeof(quint32))) {
+ return;
+ }
+
+ stream >> *expectedSize;
+ }
+
+ // Step 2: wait for full payload
+ if (buffer->size() < static_cast(sizeof(quint32) + *expectedSize)) {
+ return;
+ }
+
+ // Step 3: extract payload
+ QByteArray payload = buffer->mid(sizeof(quint32), *expectedSize);
+
+ QDataStream payloadStream(&payload, QIODevice::ReadOnly);
+ QStringList files;
+ payloadStream >> files;
+
+ emit filesReceived(files);
+
+ // Reset buffer (single message use-case)
+ buffer->clear();
+ *expectedSize = 0;
+
+ socket->disconnectFromServer();
+ return;
+ }
+ });
+
+ connect(socket, &QLocalSocket::disconnected, socket, &QLocalSocket::deleteLater);
+}
diff --git a/cockatrice/src/single_instance_manager.h b/cockatrice/src/single_instance_manager.h
new file mode 100644
index 000000000..55bff0e80
--- /dev/null
+++ b/cockatrice/src/single_instance_manager.h
@@ -0,0 +1,32 @@
+#ifndef COCKATRICE_SINGLE_INSTANCE_MANAGER_H
+#define COCKATRICE_SINGLE_INSTANCE_MANAGER_H
+
+#include
+#include
+#include
+#include
+
+class SingleInstanceManager : public QObject
+{
+ Q_OBJECT
+public:
+ explicit SingleInstanceManager(QObject *parent = nullptr);
+
+ // Returns true if this process became the primary instance, false if
+ // another instance is already running (and received our files).
+ bool tryRun(const QStringList &initialFiles);
+
+signals:
+ void filesReceived(const QStringList &files);
+
+private slots:
+ void handleNewConnection();
+
+private:
+ bool forwardToPrimary(const QStringList &filesToSend);
+
+ QString serverName;
+ QLocalServer *server = nullptr;
+};
+
+#endif // COCKATRICE_SINGLE_INSTANCE_MANAGER_H
diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h
index 2eb7e3356..982aa6bf3 100644
--- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h
+++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h
@@ -122,6 +122,20 @@ public:
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 *prepareRoomCommand(const ::google::protobuf::Message &cmd, int roomId);
static PendingCommand *prepareModeratorCommand(const ::google::protobuf::Message &cmd);
diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h
index 289fdc5d0..862dac06e 100644
--- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h
+++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h
@@ -131,6 +131,22 @@ public:
return socket->peerName();
}
}
+ quint16 peerPort() const
+ {
+ if (usingWebSocket) {
+ return websocket->peerPort();
+ } else {
+ return socket->peerPort();
+ }
+ }
+ QString serverName() const override
+ {
+ return lastHostname;
+ }
+ quint16 serverPort() const override
+ {
+ return static_cast(lastPort);
+ }
void
connectToServer(const QString &hostname, unsigned int port, const QString &_userName, const QString &_password);
void registerToServer(const QString &hostname,
diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp
index d9b98e036..5c271328b 100644
--- a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp
+++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp
@@ -293,3 +293,48 @@ bool ServersSettings::updateExistingServer(QString saveName,
}
return false;
}
+
+int ServersSettings::findServerIndex(const QString &host, const QString &port) const
+{
+ int size = getValue("totalServers", "server", "server_details").toInt();
+
+ for (int i = 0; i <= size; ++i) {
+ QString storedHost = getValue(QString("server%1").arg(i), "server", "server_details").toString();
+ QString storedPort = getValue(QString("port%1").arg(i), "server", "server_details").toString();
+
+ if (storedHost == host && storedPort == port) {
+ return i;
+ }
+ }
+
+ return -1;
+}
+
+bool ServersSettings::hasUsername(const QString &host, const QString &port) const
+{
+ int index = findServerIndex(host, port);
+ if (index < 0) {
+ return false;
+ }
+
+ QString user = getValue(QString("username%1").arg(index), "server", "server_details").toString();
+ return !user.isEmpty();
+}
+
+bool ServersSettings::hasCredentials(const QString &host, const QString &port) const
+{
+ int index = findServerIndex(host, port);
+ if (index < 0) {
+ return false;
+ }
+
+ bool save = getValue(QString("savePassword%1").arg(index), "server", "server_details").toBool();
+ QString password = getValue(QString("password%1").arg(index), "server", "server_details").toString();
+
+ return save && !password.isEmpty();
+}
+
+bool ServersSettings::hasLoginData(const QString &host, const QString &port) const
+{
+ return hasUsername(host, port) && hasCredentials(host, port);
+}
diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.h b/libcockatrice_settings/libcockatrice/settings/servers_settings.h
index 40fa996fb..f9803a158 100644
--- a/libcockatrice_settings/libcockatrice/settings/servers_settings.h
+++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.h
@@ -61,6 +61,10 @@ public:
QString password,
bool savePassword,
QString site = QString());
+ int findServerIndex(const QString &host, const QString &port) const;
+ bool hasUsername(const QString &host, const QString &port) const;
+ bool hasCredentials(const QString &host, const QString &port) const;
+ bool hasLoginData(const QString &host, const QString &port) const;
bool updateExistingServerWithoutLoss(QString saveName,
QString serv = QString(),