From 59d90db3c73a03de2ac59ea83567c9540667b91f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:02:19 +0200 Subject: [PATCH] Define Cockatrice as an editor/handler for .cod files and cockatrice:// protocol on all platforms (#6775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Application] Add single instance guard and mime types. Took 2 hours 39 minutes Took 18 minutes Took 5 minutes Took 12 seconds Took 11 seconds * Rework Took 30 minutes Took 50 seconds * Only enforce single instance if launched with arguments. Took 5 minutes * Prototype intents Took 53 minutes Took 6 seconds * Connect/disconnect and join game/room intents. Took 3 hours 14 minutes Took 2 seconds Took 15 seconds * Fix include. Took 1 minute Took 23 seconds Took 2 seconds * Mac handling. Took 10 minutes Took 12 seconds Took 3 minutes * Lint. Took 3 minutes * Rebase. Took 3 minutes Took 17 seconds * Implement UrlSchemeEventFilter Took 10 minutes Took 7 seconds * Qt Moc Took 3 minutes * Modern PList. Took 21 minutes Took 1 minute * Debug output. Took 6 minutes Took 19 minutes * Watch file:// prefix. Took 15 minutes Took 7 seconds * Better handler. Took 6 minutes * Don't store reference in member Took 5 minutes * Move impl to cpp, fix lifetime issues. Took 11 minutes Took 2 minutes * Better single-instance handoff, url intent harded copy game link context-menu Polish for installers Took 35 minutes Took 8 seconds --------- Co-authored-by: Lukas Brübach --- cmake/Info.plist | 90 ++++++++++++- cmake/NSIS.template.in | 26 ++++ cockatrice/CMakeLists.txt | 43 ++++++ cockatrice/cockatrice-cod.xml | 7 + cockatrice/cockatrice.desktop | 4 +- .../src/client/url_scheme_event_filter.h | 69 ++++++++++ .../contexts/context_connect_to_server.h | 14 ++ .../intents/contexts/context_join_game.h | 11 ++ .../intents/contexts/context_join_room.h | 14 ++ cockatrice/src/interface/intents/intent.cpp | 48 +++++++ cockatrice/src/interface/intents/intent.h | 37 +++++ .../intents/intent_connect_to_server.cpp | 46 +++++++ .../intents/intent_connect_to_server.h | 29 ++++ .../intents/intent_disconnect_from_server.cpp | 29 ++++ .../intents/intent_disconnect_from_server.h | 26 ++++ .../intents/intent_join_server_game.cpp | 76 +++++++++++ .../intents/intent_join_server_game.h | 37 +++++ .../intents/intent_join_server_room.cpp | 74 ++++++++++ .../intents/intent_join_server_room.h | 28 ++++ .../src/interface/intents/intent_login.cpp | 33 +++++ .../src/interface/intents/intent_login.h | 23 ++++ .../intents/intent_open_local_deck.cpp | 34 +++++ .../intents/intent_open_local_deck.h | 27 ++++ .../intents/intent_wait_for_database_load.cpp | 19 +++ .../intents/intent_wait_for_database_load.h | 16 +++ .../src/interface/intents/url_parser.cpp | 89 +++++++++++++ cockatrice/src/interface/intents/url_parser.h | 20 +++ .../widgets/server/game_selector.cpp | 42 ++++++ .../interface/widgets/server/game_selector.h | 1 + .../src/interface/widgets/tabs/tab_room.cpp | 1 + .../src/interface/widgets/tabs/tab_room.h | 5 + .../src/interface/widgets/tabs/tab_server.cpp | 12 +- .../src/interface/widgets/tabs/tab_server.h | 8 +- .../interface/widgets/tabs/tab_supervisor.cpp | 4 + .../interface/widgets/tabs/tab_supervisor.h | 6 +- cockatrice/src/interface/window_main.h | 5 + cockatrice/src/main.cpp | 95 ++++++++++++- cockatrice/src/single_instance_manager.cpp | 126 ++++++++++++++++++ cockatrice/src/single_instance_manager.h | 32 +++++ .../network/client/abstract/abstract_client.h | 14 ++ .../network/client/remote/remote_client.h | 16 +++ .../settings/servers_settings.cpp | 45 +++++++ .../libcockatrice/settings/servers_settings.h | 4 + 43 files changed, 1372 insertions(+), 13 deletions(-) create mode 100644 cockatrice/cockatrice-cod.xml create mode 100644 cockatrice/src/client/url_scheme_event_filter.h create mode 100644 cockatrice/src/interface/intents/contexts/context_connect_to_server.h create mode 100644 cockatrice/src/interface/intents/contexts/context_join_game.h create mode 100644 cockatrice/src/interface/intents/contexts/context_join_room.h create mode 100644 cockatrice/src/interface/intents/intent.cpp create mode 100644 cockatrice/src/interface/intents/intent.h create mode 100644 cockatrice/src/interface/intents/intent_connect_to_server.cpp create mode 100644 cockatrice/src/interface/intents/intent_connect_to_server.h create mode 100644 cockatrice/src/interface/intents/intent_disconnect_from_server.cpp create mode 100644 cockatrice/src/interface/intents/intent_disconnect_from_server.h create mode 100644 cockatrice/src/interface/intents/intent_join_server_game.cpp create mode 100644 cockatrice/src/interface/intents/intent_join_server_game.h create mode 100644 cockatrice/src/interface/intents/intent_join_server_room.cpp create mode 100644 cockatrice/src/interface/intents/intent_join_server_room.h create mode 100644 cockatrice/src/interface/intents/intent_login.cpp create mode 100644 cockatrice/src/interface/intents/intent_login.h create mode 100644 cockatrice/src/interface/intents/intent_open_local_deck.cpp create mode 100644 cockatrice/src/interface/intents/intent_open_local_deck.h create mode 100644 cockatrice/src/interface/intents/intent_wait_for_database_load.cpp create mode 100644 cockatrice/src/interface/intents/intent_wait_for_database_load.h create mode 100644 cockatrice/src/interface/intents/url_parser.cpp create mode 100644 cockatrice/src/interface/intents/url_parser.h create mode 100644 cockatrice/src/single_instance_manager.cpp create mode 100644 cockatrice/src/single_instance_manager.h 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(),