diff --git a/.gitignore b/.gitignore index 33c1ae31d..e2cbb478b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ compile_commands.json .gdb_history cockatrice/resources/config/qtlogging.ini docs/ +.claude/settings.local.json \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 14137ac55..a54a21b6d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -174,7 +174,7 @@ elseif(CMAKE_COMPILER_IS_GNUCXX) -Wno-error=delete-non-virtual-dtor -Wno-error=sign-compare -Wno-error=missing-declarations - -Wno-error=sfinae-incomplete # GCC 16+: Qt MOC + protobuf forward decls trigger this + -Wno-error=sfinae-incomplete # GCC 16+: Qt MOC + protobuf forward decls trigger this ) foreach(FLAG ${ADDITIONAL_DEBUG_FLAGS}) @@ -345,6 +345,7 @@ if(WITH_ORACLE OR WITH_CLIENT) add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_settings ${CMAKE_BINARY_DIR}/libcockatrice_settings) add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_models ${CMAKE_BINARY_DIR}/libcockatrice_models) add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_filters ${CMAKE_BINARY_DIR}/libcockatrice_filters) + add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_utility_gui ${CMAKE_BINARY_DIR}/libcockatrice_utility_gui) endif() if(WITH_SERVER) diff --git a/Dockerfile.format b/Dockerfile.format new file mode 100644 index 000000000..ca83c4741 --- /dev/null +++ b/Dockerfile.format @@ -0,0 +1,33 @@ +# Cockatrice formatter image — mirrors the apt installs in +# .github/workflows/desktop-lint.yml so a local `format.sh` run matches CI. +# +# Build: docker build -t cockatrice-format -f Dockerfile.format . +# Check: docker run --rm -v "$(pwd):/src" cockatrice-format +# Apply: docker run --rm -v "$(pwd):/src" cockatrice-format \ +# ./format.sh --cmake --shell --branch origin/master +# +# Or via docker compose (recommended): +# docker compose -f docker-compose.format.yml run --rm format # diff-only check +# docker compose -f docker-compose.format.yml run --rm format-apply # apply changes + +FROM ubuntu:24.04 + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + clang-format \ + cmake-format \ + git \ + shellcheck \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Allow git to operate on the bind-mounted repo despite differing owner UID. +RUN git config --system --add safe.directory /src + +WORKDIR /src + +# Default to the same invocation CI uses (.ci/lint_cpp.sh). +CMD ["./format.sh", "--diff", "--cmake", "--shell", "--print-version", "--branch", "origin/master"] diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..429afbd37 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +# Convenience shims for running the project's code-style check via Docker. +# See Dockerfile.format and docker-compose.format.yml for the underlying setup. + +.PHONY: format format-fix + +# Diff-only check (matches CI; exit 2 means changes are needed). +format: + docker compose -f docker-compose.format.yml run --rm format + +# Apply formatting changes in place. +format-fix: + docker compose -f docker-compose.format.yml run --rm format-apply diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake index 485affe52..181f3589c 100644 --- a/cmake/FindQtRuntime.cmake +++ b/cmake/FindQtRuntime.cmake @@ -115,4 +115,10 @@ string(REGEX REPLACE "([^;]+)" "${COCKATRICE_QT_VERSION_NAME}::\\1" TEST_QT_MODU # Core-only export (useful for headless libs) set(QT_CORE_MODULE "${COCKATRICE_QT_VERSION_NAME}::Core") +# Network-only export (useful for network-dependent libs that don't need GUI) +set(QT_NETWORK_MODULE "${COCKATRICE_QT_VERSION_NAME}::Network") + +# GUI export +set(QT_GUI_MODULE "${COCKATRICE_QT_VERSION_NAME}::Gui") + message(STATUS "Found Qt ${${COCKATRICE_QT_VERSION_NAME}_VERSION} at: ${${COCKATRICE_QT_VERSION_NAME}_DIR}") diff --git a/cmake/Info.plist b/cmake/Info.cockatrice.plist similarity index 81% rename from cmake/Info.plist rename to cmake/Info.cockatrice.plist index 614d82509..070563b5d 100644 --- a/cmake/Info.plist +++ b/cmake/Info.cockatrice.plist @@ -34,5 +34,16 @@ ${MACOSX_BUNDLE_COPYRIGHT} NSHighResolutionCapable + CFBundleURLTypes + + + CFBundleURLName + com.cockatrice.Cockatrice.url + CFBundleURLSchemes + + cockatrice + + + diff --git a/cmake/Info.oracle.plist b/cmake/Info.oracle.plist new file mode 100644 index 000000000..b04d71a95 --- /dev/null +++ b/cmake/Info.oracle.plist @@ -0,0 +1,49 @@ + + + + + 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 + + CFBundleURLTypes + + + CFBundleURLName + com.cockatrice.Oracle.url + CFBundleURLSchemes + + cockatrice-oracle + + + + + diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 7b52b7bcc..505176571 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -344,6 +344,16 @@ ${If} $PortableMode = 0 WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "VersionMajor" "@CPACK_PACKAGE_VERSION_MAJOR@" WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "VersionMinor" "@CPACK_PACKAGE_VERSION_MINOR@" + ; Register cockatrice:// URI scheme + WriteRegStr HKCR "cockatrice" "" "URL:Cockatrice Protocol" + WriteRegStr HKCR "cockatrice" "URL Protocol" "" + WriteRegStr HKCR "cockatrice\shell\open\command" "" '"$INSTDIR\cockatrice.exe" "%1"' + + ; Register cockatrice-oracle:// URI scheme + WriteRegStr HKCR "cockatrice-oracle" "" "URL:Cockatrice Oracle Protocol" + WriteRegStr HKCR "cockatrice-oracle" "URL Protocol" "" + WriteRegStr HKCR "cockatrice-oracle\shell\open\command" "" '"$INSTDIR\oracle.exe" "%1"' + IfFileExists "$INSTDIR\vc_redist.x86.exe" VcRedist86Exists PastVcRedist86Check VcRedist86Exists: ExecWait '"$INSTDIR\vc_redist.x86.exe" /passive /norestart' @@ -401,6 +411,8 @@ Section "un.Application" UnSecApplication RMDir "$SMPROGRAMS\Cockatrice" DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" + DeleteRegKey HKCR "cockatrice" + DeleteRegKey HKCR "cockatrice-oracle" SectionEnd ; unselected because it is /o diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 1d07371d8..a3bd4e46c 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -262,6 +262,11 @@ set(cockatrice_SOURCES src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp src/interface/window_main.cpp + src/interface/intents/intent_connect_to_server.cpp + src/interface/intents/intent_join_server_game.cpp + src/interface/intents/intent_join_server_room.cpp + src/interface/intents/intent_login.cpp + src/interface/intents/url_parser.cpp src/main.cpp src/interface/widgets/tabs/abstract_tab_deck_editor.cpp src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -429,6 +434,7 @@ if(Qt5_FOUND) libcockatrice_deck_list libcockatrice_filters libcockatrice_utility + libcockatrice_utility_gui libcockatrice_network libcockatrice_models libcockatrice_rng @@ -442,6 +448,7 @@ else() libcockatrice_deck_list libcockatrice_filters libcockatrice_utility + libcockatrice_utility_gui libcockatrice_network libcockatrice_models libcockatrice_rng @@ -459,7 +466,9 @@ if(UNIX) set(MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION}) set(MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}) - set_target_properties(cockatrice PROPERTIES MACOSX_BUNDLE_INFO_PLIST ${CMAKE_SOURCE_DIR}/cmake/Info.plist) + set_target_properties( + cockatrice PROPERTIES MACOSX_BUNDLE_INFO_PLIST ${CMAKE_SOURCE_DIR}/cmake/Info.cockatrice.plist + ) install(TARGETS cockatrice BUNDLE DESTINATION ./) else() diff --git a/cockatrice/cockatrice.desktop b/cockatrice/cockatrice.desktop index 092d84ef5..4c880e6c7 100644 --- a/cockatrice/cockatrice.desktop +++ b/cockatrice/cockatrice.desktop @@ -3,6 +3,7 @@ Version=1.0 Type=Application Name=Cockatrice -Exec=cockatrice +Exec=cockatrice %u Icon=cockatrice Categories=Game;CardGame; +MimeType=x-scheme-handler/cockatrice; 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..29319b669 --- /dev/null +++ b/cockatrice/src/interface/intents/contexts/context_connect_to_server.h @@ -0,0 +1,13 @@ +#ifndef CONTEXT_CONNECT_TO_SERVER_H +#define CONTEXT_CONNECT_TO_SERVER_H + +#include +#include + +struct ContextConnectToServer +{ + QString hostname; + quint16 port; +}; + +#endif // 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..1f0d7c8ca --- /dev/null +++ b/cockatrice/src/interface/intents/contexts/context_join_game.h @@ -0,0 +1,11 @@ +#ifndef CONTEXT_JOIN_GAME_H +#define CONTEXT_JOIN_GAME_H + +struct ContextJoinGame +{ + int gameId; + int roomId; + bool spectator{false}; +}; + +#endif // 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..309cd3ea3 --- /dev/null +++ b/cockatrice/src/interface/intents/contexts/context_join_room.h @@ -0,0 +1,9 @@ +#ifndef CONTEXT_JOIN_ROOM_H +#define CONTEXT_JOIN_ROOM_H + +struct ContextJoinRoom +{ + int roomId; +}; + +#endif // CONTEXT_JOIN_ROOM_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..31005de64 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_connect_to_server.cpp @@ -0,0 +1,49 @@ +#include "intent_connect_to_server.h" + +#include "../../client/network/connection_controller/remote_connection_controller.h" +#include "../../client/settings/cache_settings.h" +#include "../widgets/dialogs/dlg_connect.h" + +#include + +Q_LOGGING_CATEGORY(IntentConnectLog, "intent.connect") + +IntentConnectToServer::IntentConnectToServer(const ContextConnectToServer &ctx, + ConnectionController *controller, + QWidget *dialogParent, + QObject *parent) + : Intent(parent), ctx(ctx), controller(controller), dialogParent(dialogParent) +{ +} + +void IntentConnectToServer::doExecute() +{ + // 1. Try saved credentials for this hostname:port. + if (auto creds = SettingsCache::instance().servers().findSavedCredsByHostPort(ctx.hostname, ctx.port); + creds && !creds->password.isEmpty()) { + qCDebug(IntentConnectLog) << "Using saved credentials for" << ctx.hostname << ":" << ctx.port; + controller->connectToServerDirect(ctx.hostname, ctx.port, creds->playerName, creds->password); + emitFinished(true); + return; + } + + // 2. No saved match (or password not saved) — open DlgConnect pre-filled. + qCDebug(IntentConnectLog) << "No saved credentials for" << ctx.hostname << ":" << ctx.port + << "— opening Connect dialog"; + auto *dlg = new DlgConnect(dialogParent); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->setWindowModality(Qt::ApplicationModal); + dlg->prefillNewHost(ctx.hostname, QString::number(ctx.port)); + + connect(dlg, &QDialog::accepted, this, [this, dlg]() { + controller->connectToServerDirect(dlg->getHost(), static_cast(dlg->getPort()), + dlg->getPlayerName(), dlg->getPassword()); + emitFinished(true); + }); + connect(dlg, &QDialog::rejected, this, [this]() { + qCInfo(IntentConnectLog) << "User cancelled Connect dialog; aborting intent chain"; + emitFinished(false); + }); + + dlg->show(); +} 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..75682311a --- /dev/null +++ b/cockatrice/src/interface/intents/intent_connect_to_server.h @@ -0,0 +1,53 @@ +#ifndef INTENT_CONNECT_TO_SERVER_H +#define INTENT_CONNECT_TO_SERVER_H + +#include "contexts/context_connect_to_server.h" + +#include + +class ConnectionController; +class QWidget; + +/** + * @brief Resolves credentials for the URL's target server, then fires + * connectToServerDirect() and emits finished(true). + * + * Resolution order: + * 1. Look up saved credentials in ServersSettings by hostname+port. If a + * match with a saved password is found, use them. + * 2. Otherwise, open DlgConnect pre-filled with the URL's hostname/port so + * the user can enter credentials. finished(true) when the user clicks + * Connect; finished(false) when they cancel. + * + * The dialog path is user-paced — overriding @c timeoutMs() to return @c -1 + * disables the chain's timeout safety net for this intent (the dialog can + * stay open arbitrarily long). The follow-on @c IntentLogin still has its + * own 30s deadline on the actual login round-trip. + * + * The actual login success/failure is detected by the following IntentLogin + * in the chain. + */ +class IntentConnectToServer : public Intent +{ + Q_OBJECT +public: + explicit IntentConnectToServer(const ContextConnectToServer &ctx, + ConnectionController *controller, + QWidget *dialogParent, + QObject *parent = nullptr); + + [[nodiscard]] int timeoutMs() const override + { + return -1; // user-paced via DlgConnect when no saved creds match + } + +protected: + void doExecute() override; + +private: + ContextConnectToServer ctx; + ConnectionController *controller; + QWidget *dialogParent; +}; + +#endif // INTENT_CONNECT_TO_SERVER_H diff --git a/cockatrice/src/interface/intents/intent_helpers.h b/cockatrice/src/interface/intents/intent_helpers.h new file mode 100644 index 000000000..fc4590ceb --- /dev/null +++ b/cockatrice/src/interface/intents/intent_helpers.h @@ -0,0 +1,23 @@ +#ifndef INTENT_HELPERS_H +#define INTENT_HELPERS_H + +#include "../../client/network/connection_controller/remote_connection_controller.h" + +#include +#include + +/** + * @brief Wire @p intent to abort when the @p controller's connection drops. + * + * Shared boilerplate for intents that wait on a network round-trip and must + * not hang if the user (or server) tears the connection down mid-flight. + */ +inline void abortOnDisconnect(Intent *intent, ConnectionController *controller) +{ + QObject::connect(controller, &ConnectionController::statusChanged, intent, [intent](ClientStatus status) { + if (status == StatusDisconnected) + intent->abort(); + }); +} + +#endif // INTENT_HELPERS_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..6f28fe7ef --- /dev/null +++ b/cockatrice/src/interface/intents/intent_join_server_game.cpp @@ -0,0 +1,63 @@ +#include "intent_join_server_game.h" + +#include "../widgets/tabs/tab_room.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "intent_helpers.h" + +#include +#include +#include + +Q_LOGGING_CATEGORY(IntentJoinGameLog, "intent.join_game") + +IntentJoinServerGame::IntentJoinServerGame(const ContextJoinGame &ctx, + TabSupervisor *supervisor, + ConnectionController *controller, + QObject *parent) + : Intent(parent), ctx(ctx), supervisor(supervisor), controller(controller) +{ +} + +void IntentJoinServerGame::doExecute() +{ + qCDebug(IntentJoinGameLog) << "Requesting join game" << ctx.gameId << "in room" << ctx.roomId + << "spectate=" << ctx.spectator; + + // Short-circuit if the user already has a tab for this game (mirrors GameSelector). + if (supervisor->switchToGameTabIfAlreadyExists(ctx.gameId)) { + qCDebug(IntentJoinGameLog) << "Game" << ctx.gameId << "tab already open; nothing to do"; + emitFinished(true); + return; + } + + TabRoom *room = supervisor->getRoomTabs().value(ctx.roomId); + if (!room) { + qCWarning(IntentJoinGameLog) << "Room" << ctx.roomId << "not found — cannot join game"; + emitFinished(false); + return; + } + + Command_JoinGame cmd; + cmd.set_game_id(ctx.gameId); + cmd.set_spectator(ctx.spectator); + cmd.set_override_restrictions(!supervisor->getAdminLocked()); + // password and join_as_judge are intentionally not set from URL. + + PendingCommand *pend = room->prepareRoomCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this](const Response &resp, const CommandContainer &, const QVariant &) { + if (resp.response_code() == Response::RespOk) { + qCDebug(IntentJoinGameLog) << "Game" << ctx.gameId << "joined successfully"; + emitFinished(true); + } else { + qCWarning(IntentJoinGameLog) + << "Failed to join game" << ctx.gameId << "response:" << resp.response_code(); + emitFinished(false); + } + }); + + abortOnDisconnect(this, controller); + startTimeoutSafetyNet(); + + room->sendRoomCommand(pend); +} 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..6cefa5e69 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_join_server_game.h @@ -0,0 +1,41 @@ +#ifndef INTENT_JOIN_SERVER_GAME_H +#define INTENT_JOIN_SERVER_GAME_H + +#include "contexts/context_join_game.h" + +#include + +class TabSupervisor; +class ConnectionController; + +/** + * @brief Sends a Command_JoinGame for the given context and emits finished() + * based on the server response. + * + * Mirrors GameSelector::joinGame: short-circuits if the user already has a + * tab for the target game, sets override_restrictions when the user is not + * admin-locked, and passes through the spectator flag. Password and + * join_as_judge are intentionally not exposed via URL. + * + * Aborts (finished(false)) on manual disconnect or after a 30-second timeout, + * so the chain never hangs indefinitely. + */ +class IntentJoinServerGame : public Intent +{ + Q_OBJECT +public: + explicit IntentJoinServerGame(const ContextJoinGame &ctx, + TabSupervisor *supervisor, + ConnectionController *controller, + QObject *parent = nullptr); + +protected: + void doExecute() override; + +private: + ContextJoinGame ctx; + TabSupervisor *supervisor; + ConnectionController *controller; +}; + +#endif // 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..337a155f3 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_join_server_room.cpp @@ -0,0 +1,44 @@ +#include "intent_join_server_room.h" + +#include "../widgets/tabs/tab_supervisor.h" +#include "intent_helpers.h" + +#include + +Q_LOGGING_CATEGORY(IntentJoinRoomLog, "intent.join_room") + +IntentJoinServerRoom::IntentJoinServerRoom(const ContextJoinRoom &ctx, + TabSupervisor *supervisor, + ConnectionController *controller, + QObject *parent) + : Intent(parent), ctx(ctx), supervisor(supervisor), controller(controller) +{ +} + +void IntentJoinServerRoom::doExecute() +{ + qCDebug(IntentJoinRoomLog) << "Requesting join room" << ctx.roomId; + + // Wire success/failure listeners BEFORE dispatching to avoid races with a + // synchronous emission from TabSupervisor::requestJoinRoom (already-joined path). + connect(supervisor, &TabSupervisor::roomJoinedById, this, [this](int roomId) { + if (roomId == ctx.roomId) { + qCDebug(IntentJoinRoomLog) << "Room" << ctx.roomId << "joined successfully"; + emitFinished(true); + } + }); + connect(supervisor, &TabSupervisor::roomJoinFailedById, this, [this](int roomId) { + if (roomId == ctx.roomId) { + qCDebug(IntentJoinRoomLog) << "Failed to join room" << ctx.roomId; + emitFinished(false); + } + }); + + abortOnDisconnect(this, controller); + startTimeoutSafetyNet(); + + if (!supervisor->requestJoinRoom(ctx.roomId, true)) { + qCWarning(IntentJoinRoomLog) << "Server tab not open — cannot join room" << ctx.roomId; + emitFinished(false); + } +} 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..12b14d6fc --- /dev/null +++ b/cockatrice/src/interface/intents/intent_join_server_room.h @@ -0,0 +1,37 @@ +#ifndef INTENT_JOIN_SERVER_ROOM_H +#define INTENT_JOIN_SERVER_ROOM_H + +#include "contexts/context_join_room.h" + +#include + +class TabSupervisor; +class ConnectionController; + +/** + * @brief Joins the server room identified by @c ctx.roomId. + * + * Calls TabSupervisor::requestJoinRoom() and waits for the + * TabSupervisor::roomJoinedById(roomId) signal before emitting finished(). + * Aborts (finished(false)) on roomJoinFailedById, manual disconnect, or after + * a 30-second timeout, so the chain never hangs indefinitely. + */ +class IntentJoinServerRoom : public Intent +{ + Q_OBJECT +public: + explicit IntentJoinServerRoom(const ContextJoinRoom &ctx, + TabSupervisor *supervisor, + ConnectionController *controller, + QObject *parent = nullptr); + +protected: + void doExecute() override; + +private: + ContextJoinRoom ctx; + TabSupervisor *supervisor; + ConnectionController *controller; +}; + +#endif // 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..edd956509 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_login.cpp @@ -0,0 +1,36 @@ +#include "intent_login.h" + +#include "../../client/network/connection_controller/remote_connection_controller.h" + +#include +#include + +Q_LOGGING_CATEGORY(IntentLoginLog, "intent.login") + +IntentLogin::IntentLogin(ConnectionController *controller, QObject *parent) : Intent(parent), controller(controller) +{ +} + +void IntentLogin::doExecute() +{ + // Quick-fail: if the controller is already disconnected when we start, the + // upstream connect step failed synchronously and waiting for statusChanged + // would hang. Abort immediately. + if (controller->client()->getStatus() == StatusDisconnected) { + qCDebug(IntentLoginLog) << "Already disconnected at login start; aborting"; + emitFinished(false); + return; + } + + connect(controller, &ConnectionController::statusChanged, this, [this](ClientStatus status) { + if (status == StatusLoggedIn) { + qCDebug(IntentLoginLog) << "Login succeeded"; + emitFinished(true); + } else if (status == StatusDisconnected) { + qCDebug(IntentLoginLog) << "Connection lost before login completed"; + emitFinished(false); + } + }); + + startTimeoutSafetyNet(); +} diff --git a/cockatrice/src/interface/intents/intent_login.h b/cockatrice/src/interface/intents/intent_login.h new file mode 100644 index 000000000..c41bfe7a9 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_login.h @@ -0,0 +1,32 @@ +#ifndef INTENT_LOGIN_H +#define INTENT_LOGIN_H + +#include +#include + +class ConnectionController; + +/** + * @brief Waits for the server login to complete after a connection attempt. + * + * Connects to ConnectionController::statusChanged and emits finished(true) + * when StatusLoggedIn is reached, or finished(false) when StatusDisconnected + * is reached. Short-circuits to finished(false) when the controller is + * already disconnected at execute() time (the upstream connect step failed + * synchronously), and gives up after a 30-second timeout if the server + * accepts the connection but never sends a login response. + */ +class IntentLogin : public Intent +{ + Q_OBJECT +public: + explicit IntentLogin(ConnectionController *controller, QObject *parent = nullptr); + +protected: + void doExecute() override; + +private: + ConnectionController *controller; +}; + +#endif // INTENT_LOGIN_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..bfdac3c0b --- /dev/null +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -0,0 +1,72 @@ +#include "url_parser.h" + +#include "contexts/context_connect_to_server.h" +#include "contexts/context_join_game.h" +#include "contexts/context_join_room.h" +#include "intent_connect_to_server.h" +#include "intent_join_server_game.h" +#include "intent_join_server_room.h" +#include "intent_login.h" + +#include +#include + +Q_LOGGING_CATEGORY(UrlParserLog, "url_parser") + +Intent *UrlParser::parse(const QString &url, + ConnectionController *controller, + TabSupervisor *supervisor, + QWidget *dialogParent, + QObject *parent) +{ + const auto parsed = UrlUtils::parseJoinGameUrl(url); + if (!parsed) { + qCWarning(UrlParserLog) << "Could not parse cockatrice:// URL:" << url; + return nullptr; + } + + qCDebug(UrlParserLog) << "Parsed cockatrice://joingame" << "host=" << parsed->hostname << "port=" << parsed->port + << "room=" << parsed->roomId << "game=" << parsed->gameId << "spectate=" << parsed->spectator; + + // Build the intent chain. Each intent is parented to the root so that the + // whole chain is cleaned up when the root is deleted. + ContextConnectToServer connectCtx{parsed->hostname, parsed->port}; + auto *intentConnect = new IntentConnectToServer(connectCtx, controller, dialogParent, parent); + auto *intentLogin = new IntentLogin(controller, parent); + + ContextJoinRoom joinRoomCtx{parsed->roomId}; + auto *intentJoinRoom = new IntentJoinServerRoom(joinRoomCtx, supervisor, controller, parent); + + ContextJoinGame joinGameCtx{parsed->gameId, parsed->roomId, parsed->spectator}; + auto *intentJoinGame = new IntentJoinServerGame(joinGameCtx, supervisor, controller, parent); + + // Chain: connect → login → joinRoom → joinGame + QObject::connect(intentConnect, &Intent::finished, intentLogin, [intentLogin](bool ok) { + if (ok) { + intentLogin->execute(); + } else { + qCWarning(UrlParserLog) << "Connect step failed — aborting intent chain"; + intentLogin->abort(); + } + }); + + QObject::connect(intentLogin, &Intent::finished, intentJoinRoom, [intentJoinRoom](bool ok) { + if (ok) { + intentJoinRoom->execute(); + } else { + qCWarning(UrlParserLog) << "Login step failed — aborting intent chain"; + intentJoinRoom->abort(); + } + }); + + QObject::connect(intentJoinRoom, &Intent::finished, intentJoinGame, [intentJoinGame](bool ok) { + if (ok) { + intentJoinGame->execute(); + } else { + qCWarning(UrlParserLog) << "Join-room step failed — aborting intent chain"; + intentJoinGame->abort(); + } + }); + + return intentConnect; +} diff --git a/cockatrice/src/interface/intents/url_parser.h b/cockatrice/src/interface/intents/url_parser.h new file mode 100644 index 000000000..b41c83d33 --- /dev/null +++ b/cockatrice/src/interface/intents/url_parser.h @@ -0,0 +1,51 @@ +#ifndef URL_PARSER_H +#define URL_PARSER_H + +#include +#include + +class ConnectionController; +class QWidget; +class TabSupervisor; + +/** + * @brief Builds an Intent chain for a cockatrice:// URL. + * + * Supported URL forms: + * cockatrice://joingame?hostname=H&port=P&roomid=R&gameid=G + * cockatrice://joingame?hostname=H&port=P&roomid=R&gameid=G&spectate=1 + * + * Credentials are intentionally NOT accepted via URL — URLs are leak-prone + * (shell history, EDR capture, local-socket forwarding, browser history). If + * the target server requires authentication, the chain fails at the login step + * and the user can complete the connection via the normal Connect dialog. + * + * The pure URL-validation logic lives in UrlUtils::parseJoinGameUrl + * (libcockatrice/utility/url_utils.h) and is unit-tested there; this class + * only handles chain construction. + * + * Ownership: the returned Intent (and any chained intents created as children) + * is owned by the caller; parenting the result to a QObject will ensure + * automatic cleanup. + */ +class UrlParser +{ +public: + /** + * @param url Raw URL string (e.g. "cockatrice://joingame?..."). + * @param controller Connection controller used for connect / login intents. + * @param supervisor Tab supervisor used for join-room / join-game intents. + * @param dialogParent QWidget used as the parent for any UI dialog the + * chain may show (e.g. DlgConnect when no saved + * credentials match). Typically the MainWindow. + * @param parent QObject parent given to every intent in the chain. + * @return Root intent of the chain, or nullptr on parse failure. + */ + static Intent *parse(const QString &url, + ConnectionController *controller, + TabSupervisor *supervisor, + QWidget *dialogParent, + QObject *parent = nullptr); +}; + +#endif // URL_PARSER_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 0bb0eb1c9..3e565a091 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -261,13 +261,7 @@ void DlgConnect::updateDisplayInfo(const QString &saveName) QStringList _data = uci.getServerInfo(saveName); if (_data.isEmpty()) { - _data << "" - << "" - << "" - << "" - << "" - << "" - << ""; + _data << "" << "" << "" << "" << "" << "" << ""; } bool savePasswordStatus = (_data.at(5) == "1"); @@ -359,6 +353,18 @@ QString DlgConnect::getHost() const return hostEdit->text().trimmed(); } +void DlgConnect::prefillNewHost(const QString &host, const QString &port) +{ + // setChecked(true) fires toggled() → newHostSelected(), which clears the + // host/port fields. Set them AFTER toggling so the values stick. + newHostButton->setChecked(true); + hostEdit->setText(host); + portEdit->setText(port); + playernameEdit->clear(); + passwordEdit->clear(); + playernameEdit->setFocus(); +} + void DlgConnect::actForgotPassword() { ServersSettings &servers = SettingsCache::instance().servers(); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h index 41993e068..4928fe5aa 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h @@ -48,6 +48,16 @@ public: return passwordEdit->text(); } + /** + * @brief Pre-fill the new-host inputs with the given host/port, used by + * the cockatrice:// URL flow when no saved server matches. + * + * Selects the "new host" radio, then writes @p host into the host field + * and @p port into the port field. Player name and password are cleared + * so the user must enter them. + */ + void prefillNewHost(const QString &host, const QString &port); + public slots: void downloadThePublicServers(); diff --git a/cockatrice/src/interface/widgets/server/handle_public_servers.cpp b/cockatrice/src/interface/widgets/server/handle_public_servers.cpp index f37c957a4..8ebb70e83 100644 --- a/cockatrice/src/interface/widgets/server/handle_public_servers.cpp +++ b/cockatrice/src/interface/widgets/server/handle_public_servers.cpp @@ -37,13 +37,11 @@ void HandlePublicServers::actFinishParsingDownloadedData() QVariantMap jsonMap = jsonResponse.toVariant().toMap(); updateServerINISettings(jsonMap); } else { - qDebug() << "[PUBLIC SERVER HANDLER]" - << "JSON Parsing Error:" << parseError.errorString(); + qDebug() << "[PUBLIC SERVER HANDLER]" << "JSON Parsing Error:" << parseError.errorString(); emit sigPublicServersDownloadedUnsuccessfully(errorCode); } } else { - qDebug() << "[PUBLIC SERVER HANDLER]" - << "Error Downloading Public Servers" << errorCode; + qDebug() << "[PUBLIC SERVER HANDLER]" << "Error Downloading Public Servers" << errorCode; emit sigPublicServersDownloadedUnsuccessfully(errorCode); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_server.cpp b/cockatrice/src/interface/widgets/tabs/tab_server.cpp index aa52b4b1a..96a1c7186 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_server.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_server.cpp @@ -171,49 +171,52 @@ void TabServer::processServerMessageEvent(const Event_ServerMessage &event) void TabServer::joinRoom(int id, bool setCurrent) { - TabRoom *room = tabSupervisor->getRoomTabs().value(id); - if (!room) { - Command_JoinRoom cmd; - cmd.set_room_id(id); - - PendingCommand *pend = client->prepareSessionCommand(cmd); - pend->setExtraData(setCurrent); - connect(pend, &PendingCommand::finished, this, &TabServer::joinRoomFinished); - - client->sendCommand(pend); - + if (TabRoom *room = tabSupervisor->getRoomTabs().value(id)) { + if (setCurrent) + tabSupervisor->setCurrentWidget((QWidget *)room); + emit roomAlreadyJoined(id, setCurrent); return; } - if (setCurrent) - tabSupervisor->setCurrentWidget((QWidget *)room); + Command_JoinRoom cmd; + cmd.set_room_id(id); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + pend->setExtraData(setCurrent); + connect(pend, &PendingCommand::finished, this, &TabServer::joinRoomFinished); + + client->sendCommand(pend); } -void TabServer::joinRoomFinished(const Response &r, - const CommandContainer & /*commandContainer*/, - const QVariant &extraData) +void TabServer::joinRoomFinished(const Response &r, const CommandContainer &commandContainer, const QVariant &extraData) { + const int roomId = commandContainer.session_command(0).GetExtension(Command_JoinRoom::ext).room_id(); + switch (r.response_code()) { case Response::RespOk: break; 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 f2dd8f0a2..4b7e36623 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_server.h +++ b/cockatrice/src/interface/widgets/tabs/tab_server.h @@ -49,9 +49,13 @@ class TabServer : public Tab Q_OBJECT signals: void roomJoined(const ServerInfo_Room &info, bool setCurrent); + void roomJoinFailed(int roomId); + /** Emitted when joinRoom() short-circuits because the user is already in the room. */ + void roomAlreadyJoined(int roomId, bool setCurrent); +public slots: + void joinRoom(int id, bool setCurrent = true); 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); private: diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index b10d615ff..594994eb4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -566,6 +566,10 @@ void TabSupervisor::openTabServer() { tabServer = new TabServer(this, client); connect(tabServer, &TabServer::roomJoined, this, &TabSupervisor::addRoomTab); + connect(tabServer, &TabServer::roomJoined, this, + [this](const ServerInfo_Room &info, bool) { emit roomJoinedById(info.room_id()); }); + connect(tabServer, &TabServer::roomAlreadyJoined, this, [this](int roomId, bool) { emit roomJoinedById(roomId); }); + connect(tabServer, &TabServer::roomJoinFailed, this, [this](int roomId) { emit roomJoinFailedById(roomId); }); myAddTab(tabServer, aTabServer); connect(tabServer, &QObject::destroyed, this, [this] { tabServer = nullptr; @@ -834,6 +838,14 @@ void TabSupervisor::maximizeMainWindow() emit showWindowIfHidden(); } +bool TabSupervisor::requestJoinRoom(int roomId, bool setCurrent) +{ + if (!tabServer) + return false; + tabServer->joinRoom(roomId, setCurrent); + return true; +} + void TabSupervisor::talkLeft(TabMessage *tab) { if (tab == currentWidget()) diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 0c4428f83..dc7c98e2c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -166,8 +166,24 @@ signals: void localGameEnded(); void adminLockChanged(bool lock); void showWindowIfHidden(); + /** Forwarded from TabServer::roomJoined — emitted whenever a room is successfully joined. */ + void roomJoinedById(int roomId); + /** Forwarded from TabServer::roomJoinFailed — emitted whenever a room join is rejected by the server. */ + void roomJoinFailedById(int roomId); public slots: + /** + * @brief Request joining the server room with the given @p roomId. + * + * Safe to call any time after login. + * + * @return true — request dispatched (or short-circuited because the user + * is already in the room; @c roomJoinedById is emitted + * synchronously in that case so listeners aren't stuck). + * @return false — the server tab is not yet initialised; caller should + * treat this as a failure (do not wait for any signal). + */ + bool requestJoinRoom(int roomId, bool setCurrent = true); void openDeckInNewTab(const LoadedDeck &deckToOpen); TabDeckEditor *addDeckEditorTab(const LoadedDeck &deckToOpen); TabDeckEditorVisual *addVisualDeckEditorTab(const LoadedDeck &deckToOpen); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index bd7a5904e..1077fd5cd 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -33,6 +33,7 @@ #include "../interface/widgets/tabs/tab_game.h" #include "../interface/widgets/tabs/tab_supervisor.h" #include "../main.h" +#include "intents/url_parser.h" #include "logger.h" #include "version_string.h" #include "widgets/dialogs/dlg_connect.h" @@ -1096,3 +1097,17 @@ void MainWindow::actEditTokens() dlg.exec(); CardDatabaseManager::getInstance()->saveCustomTokensToFile(); } + +void MainWindow::handleUrl(const QString &url) +{ + qCInfo(WindowMainLog) << "Handling URL:" << url; + showWindowIfHidden(); + + Intent *intent = UrlParser::parse(url, connectionController, tabSupervisor, /*dialogParent=*/this, /*parent=*/this); + if (!intent) { + qCWarning(WindowMainLog) << "Unrecognised or invalid URL — ignoring:" << url; + return; + } + + intent->execute(); +} diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 528e6f211..1e2cde155 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -155,6 +155,8 @@ public: return tabSupervisor; } + void handleUrl(const QString &url); + protected: void closeEvent(QCloseEvent *event) override; void changeEvent(QEvent *event) override; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 7092a3fd7..a678af37b 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -35,12 +35,16 @@ #include #include #include +#include #include #include #include #include #include #include +#include +#include +#include QTranslator *translator, *qtTranslator; RNG_Abstract *rng; @@ -230,6 +234,9 @@ int main(int argc, char *argv[]) {{{"c", "connect"}, QCoreApplication::translate("main", "Connect on startup"), "user:pass@host:port"}, {{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")}}); + parser.addPositionalArgument("url", QCoreApplication::translate("main", "Optional cockatrice:// URL to open"), + "[url]"); + parser.process(app); if (parser.isSet("debug-output")) { @@ -253,7 +260,64 @@ int main(int argc, char *argv[]) qCInfo(MainLog) << "Starting main program"; + // Determine if a cockatrice:// URL was passed as a positional argument + QString urlArg = UrlUtils::findUrlArgument(parser.positionalArguments(), QStringLiteral("cockatrice://")); + +#ifdef Q_OS_MAC + // On macOS the OS delivers a registered URL scheme via QFileOpenEvent, + // which is queued before main() and dispatched on the FIRST event-loop + // spin. The single-instance handshake below runs a nested event loop, so + // the filter MUST be installed beforehand or the cold-start URL is lost. + // Until ui exists, buffer the URL into a local; we replay it after + // MainWindow construction. Capture the connection handle so we can + // disconnect the buffer-lambda unambiguously once ui is ready. + UrlSchemeEventFilter cockatriceFilter(QStringLiteral("cockatrice://")); + QString cocoaDeliveredUrl; + const auto cocoaBufferConn = + QObject::connect(&cockatriceFilter, &UrlSchemeEventFilter::urlReceived, + [&cocoaDeliveredUrl](const QString &url) { cocoaDeliveredUrl = url; }); + app.installEventFilter(&cockatriceFilter); +#endif + + // Single-instance: only enforce when delivering a URL to a primary. When + // no URL is involved, try to become primary if available, otherwise allow + // this instance to run alongside an existing one (multi-instance workflow). + SingleInstanceManager sim(SingleInstanceManager::perUserSocketName(QStringLiteral("CockatriceInstance"))); + bool wasForwarded = false; + { + QEventLoop startupLoop; + QObject::connect(&sim, &SingleInstanceManager::roleResolved, [&](bool forwarded) { + wasForwarded = forwarded; + startupLoop.quit(); + }); + sim.resolveStartupRole(urlArg); + startupLoop.exec(); + } + if (wasForwarded) { + qCInfo(MainLog) << "Another instance is already running; URL forwarded. Exiting."; + return 0; + } + MainWindow ui; + if (!urlArg.isEmpty()) { + // Deliver the URL once the event loop is running (after ui.show()) + QTimer::singleShot(0, &ui, [&ui, urlArg]() { ui.handleUrl(urlArg); }); + } + + // Connect future URLs forwarded from secondary instances (no-op if we are + // not the primary) + QObject::connect(&sim, &SingleInstanceManager::urlReceived, &ui, &MainWindow::handleUrl); + +#ifdef Q_OS_MAC + // Re-bind the filter from the buffer-lambda to ui->handleUrl now that ui + // exists, and replay any URL captured during the pre-ui startup window. + QObject::disconnect(cocoaBufferConn); + QObject::connect(&cockatriceFilter, &UrlSchemeEventFilter::urlReceived, &ui, &MainWindow::handleUrl); + if (!cocoaDeliveredUrl.isEmpty()) { + QTimer::singleShot(0, &ui, [&ui, url = cocoaDeliveredUrl]() { ui.handleUrl(url); }); + } +#endif + if (parser.isSet("connect")) { ui.setConnectTo(parser.value("connect")); } diff --git a/docker-compose.format.yml b/docker-compose.format.yml new file mode 100644 index 000000000..ff2c8f0a7 --- /dev/null +++ b/docker-compose.format.yml @@ -0,0 +1,32 @@ +# Convenience wrapper around Dockerfile.format for running the project's +# code-style check locally. Mirrors the CI desktop-lint job. +# +# Diff-only check (matches CI; exit 2 means changes are needed): +# docker compose -f docker-compose.format.yml run --rm format +# +# Apply changes in place (writes to the bind-mounted working tree): +# docker compose -f docker-compose.format.yml run --rm format-apply +# +# Either service auto-builds the image on first run. + +services: + # CI-equivalent diff-only check. + format: + build: + context: . + dockerfile: Dockerfile.format + image: cockatrice-format:latest + volumes: + - .:/src + working_dir: /src + + # Apply mode — same image, different default command. + format-apply: + build: + context: . + dockerfile: Dockerfile.format + image: cockatrice-format:latest + volumes: + - .:/src + working_dir: /src + command: ["./format.sh", "--cmake", "--shell", "--branch", "origin/master"] diff --git a/libcockatrice_card/libcockatrice/card/set/card_set.cpp b/libcockatrice_card/libcockatrice/card/set/card_set.cpp index 20d0aced8..6eea220bb 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.cpp +++ b/libcockatrice_card/libcockatrice/card/set/card_set.cpp @@ -33,28 +33,9 @@ QString CardSet::getCorrectedShortName() const { // For Windows machines. QSet invalidFileNames; - invalidFileNames << "CON" - << "PRN" - << "AUX" - << "NUL" - << "COM1" - << "COM2" - << "COM3" - << "COM4" - << "COM5" - << "COM6" - << "COM7" - << "COM8" - << "COM9" - << "LPT1" - << "LPT2" - << "LPT3" - << "LPT4" - << "LPT5" - << "LPT6" - << "LPT7" - << "LPT8" - << "LPT9"; + invalidFileNames << "CON" << "PRN" << "AUX" << "NUL" << "COM1" << "COM2" << "COM3" << "COM4" << "COM5" << "COM6" + << "COM7" << "COM8" << "COM9" << "LPT1" << "LPT2" << "LPT3" << "LPT4" << "LPT5" << "LPT6" << "LPT7" + << "LPT8" << "LPT9"; return invalidFileNames.contains(shortName) ? shortName + "_" : shortName; } diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp index 0140182be..d44ba3ad0 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp @@ -98,6 +98,26 @@ QString ServersSettings::getPassword() return QString(); } +std::optional ServersSettings::findSavedCredsByHostPort(const QString &host, quint16 port) const +{ + const int size = getValue("totalServers", "server", "server_details").toInt(); + const QString portStr = QString::number(port); + for (int i = 0; i <= size; ++i) { + const QString storedServer = getValue(QString("server%1").arg(i), "server", "server_details").toString(); + const QString storedPort = getValue(QString("port%1").arg(i), "server", "server_details").toString(); + if (storedServer.compare(host, Qt::CaseInsensitive) != 0 || storedPort != portStr) + continue; + + SavedServerCreds creds; + creds.playerName = getValue(QString("username%1").arg(i), "server", "server_details").toString(); + const bool savePassword = getValue(QString("savePassword%1").arg(i), "server", "server_details").toBool(); + if (savePassword) + creds.password = getValue(QString("password%1").arg(i), "server", "server_details").toString(); + return creds; + } + return std::nullopt; +} + bool ServersSettings::getSavePassword() const { int index = getPrevioushostindex(getPrevioushostName()); diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.h b/libcockatrice_settings/libcockatrice/settings/servers_settings.h index 22603a356..1b776e714 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.h @@ -11,11 +11,24 @@ #include #include +#include #define SERVERSETTINGS_DEFAULT_HOST "server.cockatrice.us" #define SERVERSETTINGS_DEFAULT_PORT "4748" inline Q_LOGGING_CATEGORY(ServersSettingsLog, "servers_settings"); +/** + * @brief Saved credentials for a server identified by hostname+port. + * + * @c password is empty when the entry's @c savePassword flag is false, in + * which case the caller should prompt the user for the password. + */ +struct SavedServerCreds +{ + QString playerName; + QString password; +}; + class ServersSettings : public SettingsManager { Q_OBJECT @@ -33,6 +46,20 @@ public: QString getFPPort(QString defaultPort = SERVERSETTINGS_DEFAULT_PORT) const; QString getFPPlayerName(QString defaultName = "") const; QString getPassword(); + + /** + * @brief Look up saved credentials by hostname+port. + * + * Used by the URL-driven join flow to authenticate against a server + * without requiring credentials in the URL itself. Returns @c nullopt + * when no saved server matches. When the matched entry has + * @c savePassword == false, the returned creds have an empty @c password + * — the caller is expected to prompt the user. + * + * Hostname matching is case-insensitive. + */ + [[nodiscard]] std::optional findSavedCredsByHostPort(const QString &host, quint16 port) const; + QString getSaveName(QString defaultname = ""); QString getSite(QString defaultName = ""); bool getSavePassword() const; diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index c0c7d8cc9..bdcc27c55 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -6,16 +6,19 @@ set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) set(UTILITY_SOURCES libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp - libcockatrice/utility/passwordhasher.cpp + libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/single_instance_manager.cpp ) set(UTILITY_HEADERS libcockatrice/utility/color.h libcockatrice/utility/expression.h + libcockatrice/utility/intent.h libcockatrice/utility/levenshtein.h libcockatrice/utility/macros.h libcockatrice/utility/passwordhasher.h + libcockatrice/utility/single_instance_manager.h libcockatrice/utility/trice_limits.h + libcockatrice/utility/url_utils.h libcockatrice/utility/zone_names.h ) @@ -23,7 +26,7 @@ add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS}) target_include_directories(libcockatrice_utility PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng ${QT_CORE_MODULE}) +target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng ${QT_CORE_MODULE} ${QT_NETWORK_MODULE}) set(ORACLE_LIBS) diff --git a/libcockatrice_utility/libcockatrice/utility/intent.h b/libcockatrice_utility/libcockatrice/utility/intent.h new file mode 100644 index 000000000..832af5aa3 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/intent.h @@ -0,0 +1,125 @@ +#ifndef LIBCOCKATRICE_INTENT_H +#define LIBCOCKATRICE_INTENT_H + +#include +#include + +/** + * @brief Abstract base for chained, URL-driven intents. + * + * An Intent encapsulates a single async action (e.g. connecting to a server, + * joining a room). Concrete subclasses implement @c doExecute() and call + * @c emitFinished(bool) when the action completes or fails unrecoverably. + * + * Typical usage: + * @code + * auto *intent = new MyIntent(ctx, parent); + * intent->execute(); // intent deletes itself via deleteLater when finished + * @endcode + * + * Guarantees: + * - @c finished() is emitted at most once (subsequent emit attempts are no-ops). + * - @c execute() is idempotent: repeated calls do nothing after the first. + * - Self-deletes via @c deleteLater after @c finished() fires (success or failure). + */ +class Intent : public QObject +{ + Q_OBJECT + +public: + /** Default deadline for intents that wait on async signals. Used by the + * default @c timeoutMs() implementation so the chain can't hang + * indefinitely. */ + static constexpr int DefaultTimeoutMs = 30000; + + explicit Intent(QObject *parent = nullptr) : QObject(parent) + { + // Self-delete after finished() fires, regardless of whether the + // emission came from doExecute() (success/failure) or abort() + // (external). + connect(this, &Intent::finished, this, &QObject::deleteLater); + } + ~Intent() override = default; + + /** + * @brief Deadline for this intent's timeout safety net, in milliseconds. + * + * Subclasses override when their work is not bounded by network/server + * timing — e.g. an intent that opens a modal dialog should return a + * non-positive value to indicate "no auto-timeout, the user paces this + * step". Consumed by @c startTimeoutSafetyNet(). + * + * @return positive deadline in ms, or <= 0 for "no timeout". + */ + [[nodiscard]] virtual int timeoutMs() const + { + return DefaultTimeoutMs; + } + + /** Start executing the intent. Idempotent — repeated calls are no-ops. */ + void execute() + { + if (m_started) + return; + m_started = true; + doExecute(); + } + + /** + * @brief Abort the intent externally, emitting finished(false). + * + * Used by chain orchestrators (e.g. UrlParser) to propagate a failure + * from an upstream intent through the rest of the chain without giving + * outside callers direct access to the protected finished() signal. + */ + void abort() + { + emitFinished(false); + } + +protected: + virtual void doExecute() = 0; + + /** + * @brief Single source of truth for emitting @c finished(). + * + * Gated by an internal flag so subsequent calls are no-ops. Concrete + * intents call this instead of @c emit finished(...) directly, which + * removes the risk of double-emission when multiple completion signals + * race (success + cleanup disconnect, timeout + late response, etc.). + */ + void emitFinished(bool success) + { + if (m_finished) + return; + m_finished = true; + emit finished(success); + } + + /** + * @brief Arm the chain-level deadline; aborts on expiry. + * + * Subclasses call this once from @c doExecute() to install the timeout + * safety net described by @c timeoutMs(). No-op when @c timeoutMs() is + * non-positive (user-paced intents opt out). + */ + void startTimeoutSafetyNet() + { + if (const int deadline = timeoutMs(); deadline > 0) { + QTimer::singleShot(deadline, this, [this]() { emitFinished(false); }); + } + } + +signals: + /** + * @brief Emitted exactly once when the intent finishes. + * @param success @c true on success, @c false on failure. + */ + void finished(bool success); + +private: + bool m_started{false}; + bool m_finished{false}; +}; + +#endif // LIBCOCKATRICE_INTENT_H diff --git a/libcockatrice_utility/libcockatrice/utility/single_instance_manager.cpp b/libcockatrice_utility/libcockatrice/utility/single_instance_manager.cpp new file mode 100644 index 000000000..de10be6f8 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/single_instance_manager.cpp @@ -0,0 +1,145 @@ +#include "single_instance_manager.h" + +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(SingleInstanceLog, "single_instance") + +SingleInstanceManager::SingleInstanceManager(const QString &socketName, QObject *parent) + : QObject(parent), socketName(socketName) +{ +} + +SingleInstanceManager::~SingleInstanceManager() = default; + +QString SingleInstanceManager::perUserSocketName(const QString &base) +{ +#ifdef Q_OS_WIN + const QByteArray user = qgetenv("USERNAME"); +#else + const QByteArray user = qgetenv("USER"); +#endif + if (user.isEmpty()) + return base; + return base + QStringLiteral("-") + QString::fromLocal8Bit(user); +} + +void SingleInstanceManager::becomePrimary() +{ + if (server) + return; // already listening — idempotent + QLocalServer::removeServer(socketName); + server = new QLocalServer(this); + if (!server->listen(socketName)) { + qCWarning(SingleInstanceLog) << "Failed to start local server:" << server->errorString(); + return; + } + connect(server, &QLocalServer::newConnection, this, &SingleInstanceManager::onNewConnection); +} + +void SingleInstanceManager::resolveStartupRole(const QString &maybeUrl) +{ + if (maybeUrl.isEmpty()) { + // No URL to forward — just try to become primary. Defer the signal + // via a queued emission so the contract ("emits roleResolved exactly + // once, asynchronously") holds uniformly across both branches. + becomePrimary(); + QMetaObject::invokeMethod(this, [this] { emit roleResolved(false); }, Qt::QueuedConnection); + return; + } + + // Probe an existing primary. Lifetime: probe and timer are owned by + // *this*; the terminal slot deleteLater()s them. A shared "resolved" + // flag prevents double emission if multiple signals race (errorOccurred + // can fire after readyRead, etc.). shared_ptr so the flag outlives + // whichever lambda fires last. + auto *probe = new QLocalSocket(this); + auto *timer = new QTimer(this); + auto resolved = std::make_shared(false); + + auto finish = [this, probe, timer, resolved](bool forwarded) { + if (*resolved) + return; + *resolved = true; + timer->stop(); + probe->deleteLater(); + timer->deleteLater(); + if (!forwarded) + becomePrimary(); + emit roleResolved(forwarded); + }; + + connect(probe, &QLocalSocket::connected, this, [probe, maybeUrl] { + QDataStream stream(probe); + stream.setVersion(QDataStream::Qt_5_0); + stream << maybeUrl; + probe->flush(); + }); + + connect(probe, &QLocalSocket::readyRead, this, [probe, finish] { + QDataStream in(probe); + in.setVersion(QDataStream::Qt_5_0); + in.startTransaction(); + QString ack; + in >> ack; + if (!in.commitTransaction()) + return; // partial ACK — wait for readyRead to fire again with the rest. + finish(true); + }); + + connect(probe, &QLocalSocket::errorOccurred, this, [finish](QLocalSocket::LocalSocketError) { + // No primary at this socket — become primary ourselves. + finish(false); + }); + + timer->setSingleShot(true); + connect(timer, &QTimer::timeout, this, [finish] { + // Primary unresponsive (e.g. stale socket from a dead old primary). + // Become primary and hope for the best. + qCWarning(SingleInstanceLog) << "Timed out forwarding URL; becoming primary"; + finish(false); + }); + timer->start(ForwardTimeoutMs); + + probe->connectToServer(socketName); +} + +void SingleInstanceManager::onNewConnection() +{ + while (server->hasPendingConnections()) { + QLocalSocket *socket = server->nextPendingConnection(); + connect(socket, &QLocalSocket::readyRead, this, [this, socket]() { processConnection(socket); }); + connect(socket, &QLocalSocket::disconnected, socket, &QLocalSocket::deleteLater); + + // The secondary may have written its URL and the bytes may have + // arrived before we got here. readyRead has already fired once for + // them with no slot connected — drain pre-buffered bytes now so the + // payload doesn't sit unread forever. + if (socket->bytesAvailable() > 0) + processConnection(socket); + } +} + +void SingleInstanceManager::processConnection(QLocalSocket *socket) +{ + QDataStream in(socket); + in.setVersion(QDataStream::Qt_5_0); + in.startTransaction(); + QString url; + in >> url; + if (!in.commitTransaction()) + return; // partial payload — readyRead will fire again + + if (!url.isEmpty()) { + qCDebug(SingleInstanceLog) << "Received URL from secondary instance:" << url; + emit urlReceived(url); + } + + // Acknowledge so the secondary can finish cleanly. + QDataStream out(socket); + out.setVersion(QDataStream::Qt_5_0); + out << QStringLiteral("ACK"); + socket->flush(); +} diff --git a/libcockatrice_utility/libcockatrice/utility/single_instance_manager.h b/libcockatrice_utility/libcockatrice/utility/single_instance_manager.h new file mode 100644 index 000000000..a1c837299 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/single_instance_manager.h @@ -0,0 +1,97 @@ +#ifndef LIBCOCKATRICE_SINGLE_INSTANCE_MANAGER_H +#define LIBCOCKATRICE_SINGLE_INSTANCE_MANAGER_H + +#include +#include +#include +#include + +/** + * @brief Local-socket-based single-instance guard with URL forwarding. + * + * Asynchronously resolves whether this process is the primary instance or a + * secondary that should forward a URL and exit. All transitions are + * driven by Qt signals (QLocalSocket::connected / readyRead / + * errorOccurred) plus a single timeout — no synchronous waitFor* calls + * anywhere, so platform-specific event-pump quirks don't race. + * + * Usage at startup (typically in main(), before QApplication::exec()): + * + * @code + * SingleInstanceManager sim(SingleInstanceManager::perUserSocketName("MyApp")); + * QEventLoop startupLoop; + * bool wasForwarded = false; + * QObject::connect(&sim, &SingleInstanceManager::roleResolved, + * [&](bool forwarded) { + * wasForwarded = forwarded; + * startupLoop.quit(); + * }); + * sim.resolveStartupRole(urlFromArgv); // may be empty + * startupLoop.exec(); + * if (wasForwarded) return 0; + * // ...continue as primary (or as a non-primary secondary if no URL)... + * QObject::connect(&sim, &SingleInstanceManager::urlReceived, + * &mainWindow, &MainWindow::handleUrl); + * @endcode + */ +class SingleInstanceManager : public QObject +{ + Q_OBJECT + +public: + /** Deadline for the probe-and-forward handshake. After this, we assume + * no primary is listening (or the old primary is dead) and become + * primary ourselves. */ + static constexpr int ForwardTimeoutMs = 2000; + + explicit SingleInstanceManager(const QString &socketName, QObject *parent = nullptr); + ~SingleInstanceManager() override; + + /** + * @brief Build a per-user socket name to prevent cross-user squatting. + * + * Appends the current user's name from $USER (Unix) or $USERNAME (Windows) + * to @p base, separated by a dash. Falls back to @p base unchanged when + * the env var is empty. + */ + static QString perUserSocketName(const QString &base); + + /** + * @brief Asynchronously resolve our startup role. + * + * - If @p maybeUrl is non-empty AND a primary instance is already + * running, forward the URL to it and emit @c roleResolved(true). + * - Otherwise (no URL, OR no primary, OR primary unresponsive within + * @c ForwardTimeoutMs), become the primary instance and emit + * @c roleResolved(false). + * + * Emits @c roleResolved exactly once. Intended to be called at most + * once at process startup, before @c QApplication::exec(). + */ + void resolveStartupRole(const QString &maybeUrl); + +signals: + /** Emitted exactly once after resolveStartupRole completes. + * @param forwarded true if a primary existed and we sent the URL to it + * (caller should exit); false if we are now primary. */ + void roleResolved(bool forwarded); + + /** Emitted on the primary instance whenever another instance sends a URL. */ + void urlReceived(const QString &url); + +private slots: + void onNewConnection(); + +private: + QString socketName; + QLocalServer *server{nullptr}; + + /** Listen on @c socketName. Idempotent — safe to call once we've been + * resolved as the primary. */ + void becomePrimary(); + + /** Read the URL from @p socket and emit @c urlReceived, then ACK. */ + void processConnection(QLocalSocket *socket); +}; + +#endif // LIBCOCKATRICE_SINGLE_INSTANCE_MANAGER_H diff --git a/libcockatrice_utility/libcockatrice/utility/url_utils.h b/libcockatrice_utility/libcockatrice/utility/url_utils.h new file mode 100644 index 000000000..7d01c83ea --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/url_utils.h @@ -0,0 +1,136 @@ +#ifndef LIBCOCKATRICE_URL_UTILS_H +#define LIBCOCKATRICE_URL_UTILS_H + +#include +#include +#include +#include + +namespace UrlUtils +{ + +/** + * @brief Scans @p args and returns the first entry that starts with + * @p schemePrefix (case-insensitive, per RFC 3986), or an empty string + * if none is found. Only the first match is returned; subsequent + * matching args are ignored. + * + * Use this to extract a custom-scheme URL from QCommandLineParser positional + * arguments or raw argv arrays. + */ +inline QString findUrlArgument(const QStringList &args, const QString &schemePrefix) +{ + for (const QString &arg : args) { + if (arg.startsWith(schemePrefix, Qt::CaseInsensitive)) + return arg; + } + return {}; +} + +/** + * @brief Parsed shape of a cockatrice-oracle:// URL. + * + * Currently only @c update is recognised; other hosts are ignored. + */ +struct OracleUrlAction +{ + bool isUpdate{false}; + bool spoilersOnly{false}; +}; + +/** + * @brief Parse a cockatrice-oracle:// URL into an OracleUrlAction. + * + * Recognised forms: + * cockatrice-oracle://update + * cockatrice-oracle://update?spoilers=1 + * + * Returns a default-constructed action (@c isUpdate == false) for any URL + * whose host is not @c update. Host matching is case-insensitive. + */ +inline OracleUrlAction parseOracleUrl(const QString &url) +{ + OracleUrlAction action; + const QUrl parsed(url); + if (parsed.host().toLower() != QStringLiteral("update")) + return action; + action.isUpdate = true; + action.spoilersOnly = QUrlQuery(parsed.query()).queryItemValue(QStringLiteral("spoilers")) == QStringLiteral("1"); + return action; +} + +/** + * @brief Parsed parameters from a cockatrice://joingame URL. + */ +struct JoinGameUrlParams +{ + QString hostname; + quint16 port; + int roomId; + int gameId; + bool spectator; +}; + +/** + * @brief Parse a cockatrice://joingame URL into its parameters. + * + * Recognised forms: + * cockatrice://joingame?hostname=H&port=P&roomid=R&gameid=G + * cockatrice://joingame?hostname=H&port=P&roomid=R&gameid=G&spectate=1 + * + * Validation: + * - scheme must be "cockatrice" (case-insensitive) + * - host must be "joingame" (case-insensitive) + * - hostname query param required + * - port required, 1..65535 + * - roomid required, >= 0 + * - gameid required, >= 0 + * - spectate=1 sets spectator true; any other value (including absence) is false + * + * Credentials in the query (username/password) are intentionally ignored. + * + * @return std::nullopt for unrecognised or malformed URLs. + */ +inline std::optional parseJoinGameUrl(const QString &url) +{ + const QUrl parsed(url); + if (!parsed.isValid()) + return std::nullopt; + if (parsed.scheme().toLower() != QStringLiteral("cockatrice")) + return std::nullopt; + if (parsed.host().toLower() != QStringLiteral("joingame")) + return std::nullopt; + + const QUrlQuery query(parsed.query()); + + const QString hostname = query.queryItemValue(QStringLiteral("hostname")); + if (hostname.isEmpty()) + return std::nullopt; + + bool portOk = false; + const uint portVal = query.queryItemValue(QStringLiteral("port")).toUInt(&portOk); + if (!portOk || portVal == 0 || portVal > 65535) + return std::nullopt; + + bool roomOk = false; + const int roomId = query.queryItemValue(QStringLiteral("roomid")).toInt(&roomOk); + if (!roomOk || roomId < 0) + return std::nullopt; + + bool gameOk = false; + const int gameId = query.queryItemValue(QStringLiteral("gameid")).toInt(&gameOk); + if (!gameOk || gameId < 0) + return std::nullopt; + + JoinGameUrlParams params; + params.hostname = hostname; + params.port = static_cast(portVal); + params.roomId = roomId; + params.gameId = gameId; + params.spectator = query.queryItemValue(QStringLiteral("spectate")) == QStringLiteral("1"); + return params; +} + +} // namespace UrlUtils + +#endif // LIBCOCKATRICE_URL_UTILS_H diff --git a/libcockatrice_utility_gui/CMakeLists.txt b/libcockatrice_utility_gui/CMakeLists.txt new file mode 100644 index 000000000..0e00153c0 --- /dev/null +++ b/libcockatrice_utility_gui/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.16) +project(UtilityGui VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}") + +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTOUIC ON) +set(CMAKE_AUTORCC ON) + +# Sibling of libcockatrice_utility but carries the Qt::Gui dependency so that +# libcockatrice_utility (consumed transitively by the headless servatrice) can +# stay Core+Network only. Host Gui-needing shared utility code here. + +set(UTILITY_GUI_HEADERS libcockatrice/utility_gui/url_scheme_event_filter.h) + +# Header-only Q_OBJECT classes need a .cpp anchor so AUTOMOC has somewhere to +# compile the generated meta-object code. An INTERFACE library skips AUTOMOC, +# so we use a STATIC lib + tiny stub. +add_library(libcockatrice_utility_gui STATIC ${UTILITY_GUI_HEADERS} libcockatrice/utility_gui/stub.cpp) + +target_include_directories(libcockatrice_utility_gui PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +target_link_libraries(libcockatrice_utility_gui PUBLIC ${QT_CORE_MODULE} ${QT_GUI_MODULE}) diff --git a/libcockatrice_utility_gui/libcockatrice/utility_gui/stub.cpp b/libcockatrice_utility_gui/libcockatrice/utility_gui/stub.cpp new file mode 100644 index 000000000..7db1a3b71 --- /dev/null +++ b/libcockatrice_utility_gui/libcockatrice/utility_gui/stub.cpp @@ -0,0 +1,2 @@ +// AUTOMOC anchor for header-only Q_OBJECT classes in libcockatrice_utility_gui. +// Intentionally empty — AUTOMOC needs at least one translation unit to live in. diff --git a/libcockatrice_utility_gui/libcockatrice/utility_gui/url_scheme_event_filter.h b/libcockatrice_utility_gui/libcockatrice/utility_gui/url_scheme_event_filter.h new file mode 100644 index 000000000..5b8bc6a39 --- /dev/null +++ b/libcockatrice_utility_gui/libcockatrice/utility_gui/url_scheme_event_filter.h @@ -0,0 +1,66 @@ +#ifndef LIBCOCKATRICE_URL_SCHEME_EVENT_FILTER_H +#define LIBCOCKATRICE_URL_SCHEME_EVENT_FILTER_H + +// Lives in libcockatrice_utility_gui (not libcockatrice_utility) because +// QFileOpenEvent is in Qt::Gui, and libcockatrice_utility is intentionally +// Core+Network-only so servatrice (headless server, transitively consumes +// libcockatrice_utility) does not pull in Qt::Gui. + +#include +#include +#include +#include + +/** + * @brief Event filter that catches QFileOpenEvent URLs matching a scheme + * prefix 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(QStringLiteral("cockatrice://")); + * QObject::connect(&filter, &UrlSchemeEventFilter::urlReceived, + * &mainWindow, &MainWindow::handleUrl); + * app.installEventFilter(&filter); + * @endcode + * + * Scheme matching is case-insensitive (per RFC 3986). Matching events are + * consumed (eventFilter returns true) so they do not propagate to other + * QFileOpenEvent handlers. If the app ever handles non-URL file-open events + * (e.g. .cor file association), make sure those handlers see the events first + * by installing this filter LAST, or by ensuring the prefix uniquely + * partitions URL events from file events. + */ +class UrlSchemeEventFilter : public QObject +{ + Q_OBJECT + +public: + explicit UrlSchemeEventFilter(const QString &schemePrefix, QObject *parent = nullptr) + : QObject(parent), m_prefix(schemePrefix) + { + } + +signals: + void urlReceived(const QString &url); + +public: + bool eventFilter(QObject *watched, QEvent *event) override + { + if (event->type() == QEvent::FileOpen) { + const QString url = static_cast(event)->url().toString(); + if (url.startsWith(m_prefix, Qt::CaseInsensitive)) { + emit urlReceived(url); + return true; + } + } + return QObject::eventFilter(watched, event); + } + +private: + QString m_prefix; +}; + +#endif // LIBCOCKATRICE_URL_SCHEME_EVENT_FILTER_H diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 3bb4de5df..3293f4dbd 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -145,6 +145,7 @@ target_link_libraries( PUBLIC libcockatrice_card PUBLIC libcockatrice_settings PUBLIC libcockatrice_network + PUBLIC libcockatrice_utility_gui PUBLIC ${ORACLE_QT_MODULES} ) @@ -167,7 +168,7 @@ if(UNIX) set(MACOSX_BUNDLE_BUNDLE_NAME ${PROJECT_NAME}) set(MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION}) set(MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}) - set_target_properties(oracle PROPERTIES MACOSX_BUNDLE_INFO_PLIST ${CMAKE_SOURCE_DIR}/cmake/Info.plist) + set_target_properties(oracle PROPERTIES MACOSX_BUNDLE_INFO_PLIST ${CMAKE_SOURCE_DIR}/cmake/Info.oracle.plist) install(TARGETS oracle BUNDLE DESTINATION ./) else() diff --git a/oracle/oracle.desktop b/oracle/oracle.desktop index c4943b859..4268ef639 100644 --- a/oracle/oracle.desktop +++ b/oracle/oracle.desktop @@ -3,6 +3,7 @@ Version=1.0 Type=Application Name=Cockatrice Oracle downloader -Exec=oracle +Exec=oracle %u Icon=oracle Categories=Game;CardGame; +MimeType=x-scheme-handler/cockatrice-oracle; diff --git a/oracle/src/main.cpp b/oracle/src/main.cpp index 5def0c887..32e28e0a8 100644 --- a/oracle/src/main.cpp +++ b/oracle/src/main.cpp @@ -10,6 +10,8 @@ #include #include #include +#include +#include QTranslator *translator, *qtTranslator; ThemeManager *themeManager; @@ -63,10 +65,26 @@ int main(int argc, char *argv[]) QCommandLineOption backgroundOption("b", QCoreApplication::translate("main", "Run in no-confirm background mode")); parser.addOption(spoilersOnlyOption); parser.addOption(backgroundOption); + parser.addPositionalArgument( + "url", QCoreApplication::translate("main", "Optional cockatrice-oracle:// URL to handle"), "[url]"); parser.process(app); isSpoilersOnly = parser.isSet(spoilersOnlyOption); isBackgrounded = parser.isSet(backgroundOption); + // Handle cockatrice-oracle:// URL passed via the OS URL scheme handler + const QString oracleUrl = + UrlUtils::findUrlArgument(parser.positionalArguments(), QStringLiteral("cockatrice-oracle://")); + if (!oracleUrl.isEmpty()) { + const auto action = UrlUtils::parseOracleUrl(oracleUrl); + if (action.isUpdate) { + isBackgrounded = true; + if (action.spoilersOnly) + isSpoilersOnly = true; + } else { + qDebug() << "Oracle: ignoring unknown cockatrice-oracle:// URL:" << oracleUrl; + } + } + #ifdef Q_OS_MAC translationPath = qApp->applicationDirPath() + "/../Resources/translations"; #elif defined(Q_OS_WIN) @@ -88,6 +106,27 @@ int main(int argc, char *argv[]) // set name of the app desktop file; used by wayland to load the window icon QGuiApplication::setDesktopFileName("oracle"); +#ifdef Q_OS_MAC + // On macOS the OS delivers a registered URL scheme via QFileOpenEvent, + // dispatched on the first event-loop spin. Oracle has no nested event + // loop before app.exec(), so installing the filter here (after wizard + // construction but before app.exec()) is sufficient — the cold-start URL + // event sits in the queue until app.exec() dispatches it, by which point + // both the filter and wizard exist. + UrlSchemeEventFilter oracleFilter(QStringLiteral("cockatrice-oracle://")); + QObject::connect(&oracleFilter, &UrlSchemeEventFilter::urlReceived, &wizard, [&wizard](const QString &url) { + const auto action = UrlUtils::parseOracleUrl(url); + if (!action.isUpdate) { + qDebug() << "Oracle: ignoring unknown cockatrice-oracle:// URL:" << url; + return; + } + if (action.spoilersOnly) + isSpoilersOnly = true; + QTimer::singleShot(0, &wizard, [&wizard]() { wizard.runInBackground(); }); + }); + app.installEventFilter(&oracleFilter); +#endif + wizard.show(); if (isBackgrounded) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fffaf1bda..69d13ad7d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,6 +6,10 @@ add_test(NAME dummy_test COMMAND dummy_test) add_test(NAME expression_test COMMAND expression_test) add_test(NAME test_age_formatting COMMAND test_age_formatting) add_test(NAME password_hash_test COMMAND password_hash_test) +add_test(NAME url_utils_test COMMAND url_utils_test) +add_test(NAME url_scheme_event_filter_test COMMAND url_scheme_event_filter_test) +add_test(NAME intent_test COMMAND intent_test) +add_test(NAME single_instance_manager_test COMMAND single_instance_manager_test) add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test) set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 5) @@ -17,6 +21,10 @@ add_executable(expression_test expression_test.cpp) add_executable(test_age_formatting test_age_formatting.cpp) add_executable(password_hash_test password_hash_test.cpp) add_executable(deck_hash_performance_test deck_hash_performance_test.cpp) +add_executable(url_utils_test url_utils_test.cpp) +add_executable(url_scheme_event_filter_test url_scheme_event_filter_test.cpp) +add_executable(intent_test intent_test.cpp) +add_executable(single_instance_manager_test single_instance_manager_test.cpp) find_package(GTest) @@ -48,6 +56,10 @@ if(NOT GTEST_FOUND) add_dependencies(test_age_formatting gtest) add_dependencies(password_hash_test gtest) add_dependencies(deck_hash_performance_test gtest) + add_dependencies(url_utils_test gtest) + add_dependencies(url_scheme_event_filter_test gtest) + add_dependencies(intent_test gtest) + add_dependencies(single_instance_manager_test gtest) endif() include_directories(${GTEST_INCLUDE_DIRS}) @@ -61,6 +73,14 @@ target_link_libraries( deck_hash_performance_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) +target_link_libraries(url_utils_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) +target_link_libraries( + url_scheme_event_filter_test libcockatrice_utility_gui Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) +target_link_libraries(intent_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) +target_link_libraries( + single_instance_manager_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) diff --git a/tests/intent_test.cpp b/tests/intent_test.cpp new file mode 100644 index 000000000..4ed90a7c5 --- /dev/null +++ b/tests/intent_test.cpp @@ -0,0 +1,186 @@ +#include "gtest/gtest.h" +#include +#include +#include + +// StubIntent and PendingIntent live at file scope (not in an anonymous +// namespace) so moc handles them straightforwardly across all supported Qt +// versions. + +class StubIntent : public Intent +{ + Q_OBJECT +public: + explicit StubIntent(QObject *parent = nullptr) : Intent(parent) + { + } + bool executed{false}; + +protected: + void doExecute() override + { + executed = true; + emitFinished(true); + } +}; + +class PendingIntent : public Intent +{ + Q_OBJECT +public: + explicit PendingIntent(QObject *parent = nullptr) : Intent(parent) + { + } + +protected: + void doExecute() override + { + // intentionally never emits finished() + } +}; + +// Emits finished(true) then finished(false) back-to-back to exercise the +// finish-once guard. +class DoubleEmitIntent : public Intent +{ + Q_OBJECT +public: + explicit DoubleEmitIntent(QObject *parent = nullptr) : Intent(parent) + { + } + +protected: + void doExecute() override + { + emitFinished(true); + emitFinished(false); // must be a no-op + } +}; + +TEST(IntentTest, SelfDeletesAfterFinished) +{ + QPointer weak = new StubIntent; + ASSERT_FALSE(weak.isNull()); + + weak->execute(); + ASSERT_TRUE(weak->executed) << "doExecute() must be called synchronously by execute()"; + + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + ASSERT_TRUE(weak.isNull()) << "Intent must delete itself after finished() fires"; +} + +TEST(IntentTest, DoesNotDeleteBeforeFinished) +{ + QPointer weak = new PendingIntent; + weak->execute(); + + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + ASSERT_FALSE(weak.isNull()) << "Intent must stay alive while in-flight"; + + // Clean up manually for test hygiene. + delete weak.data(); +} + +TEST(IntentTest, AbortDeletesIntent) +{ + // abort() emits finished(false) without execute() being called. The + // self-delete connection is wired in the constructor, so the intent + // should clean itself up regardless. + QPointer weak = new PendingIntent; + ASSERT_FALSE(weak.isNull()); + + weak->abort(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + ASSERT_TRUE(weak.isNull()) << "Aborted intent must self-delete"; +} + +TEST(IntentTest, AbortChainPropagates) +{ + // Build a tiny two-stage chain: head fails, mid should abort and be + // deleted along with head. Mirrors the failure-propagation pattern in + // UrlParser without depending on cockatrice GUI types. + QPointer head = new PendingIntent; + QPointer mid = new PendingIntent; + + QObject::connect(head.data(), &Intent::finished, mid.data(), [m = mid.data()](bool ok) { + if (ok) + m->execute(); + else + m->abort(); + }); + + head->abort(); + + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + ASSERT_TRUE(head.isNull()) << "Head intent must self-delete after abort"; + ASSERT_TRUE(mid.isNull()) << "Mid intent must self-delete after chained abort"; +} + +TEST(IntentTest, DeletedByParentBeforeFinished) +{ + // Simulates the "user closes Cockatrice mid-flow" path: an intent that + // never reaches finished() must die cleanly when its QObject parent + // (typically MainWindow) is destroyed, with no signal emission, no + // crash, and no leaked timer. + auto *parent = new QObject; + QPointer weak = new PendingIntent(parent); + weak->execute(); // never emits finished + ASSERT_FALSE(weak.isNull()); + + delete parent; // simulates MainWindow destruction + ASSERT_TRUE(weak.isNull()) << "Intent must die with its parent, even mid-flight"; +} + +TEST(IntentTest, FinishedEmitsAtMostOnce) +{ + // Regression: before the m_finished gate, a concrete intent that emitted + // finished() from multiple paths (success signal, disconnect, timeout) + // could deliver finished() twice to chain listeners. + auto *intent = new DoubleEmitIntent; + int finishedCount = 0; + bool firstValue = false; + QObject::connect(intent, &Intent::finished, [&](bool ok) { + if (finishedCount == 0) + firstValue = ok; + ++finishedCount; + }); + + intent->execute(); + + ASSERT_EQ(finishedCount, 1) << "finished() must be emitted exactly once even on duplicate emitFinished calls"; + ASSERT_TRUE(firstValue) << "First emission wins (true)"; + + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); +} + +TEST(IntentTest, ExecuteIsIdempotent) +{ + // Regression: calling execute() twice must not re-enter doExecute(). + class CountingIntent : public Intent + { + public: + int calls{0}; + + protected: + void doExecute() override + { + ++calls; + } + }; + + auto *intent = new CountingIntent; + intent->execute(); + intent->execute(); + intent->execute(); + ASSERT_EQ(intent->calls, 1) << "execute() must be a no-op after the first call"; + delete intent; +} + +#include "intent_test.moc" + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/single_instance_manager_test.cpp b/tests/single_instance_manager_test.cpp new file mode 100644 index 000000000..27cf279b2 --- /dev/null +++ b/tests/single_instance_manager_test.cpp @@ -0,0 +1,139 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +QString uniqueSocketName() +{ + return QStringLiteral("CockatriceTest-") + QString::number(QCoreApplication::applicationPid()) + + QStringLiteral("-") + QString::number(QRandomGenerator::global()->generate()); +} + +// Drive resolveStartupRole to completion and return the manager. +// The caller owns the returned manager (parented to @p parent if given). +SingleInstanceManager *makeResolvedPrimary(const QString &socketName, QObject *parent = nullptr) +{ + auto *mgr = new SingleInstanceManager(socketName, parent); + QEventLoop loop; + QObject::connect(mgr, &SingleInstanceManager::roleResolved, &loop, &QEventLoop::quit); + mgr->resolveStartupRole(QString()); + QTimer::singleShot(5000, &loop, &QEventLoop::quit); + loop.exec(); + return mgr; +} + +} // namespace + +TEST(SingleInstanceManagerTest, PerUserSocketNameContainsBase) +{ + const QString name = SingleInstanceManager::perUserSocketName(QStringLiteral("CockatriceInstance")); + ASSERT_TRUE(name.startsWith(QStringLiteral("CockatriceInstance"))) + << "perUserSocketName must preserve the base prefix; got " << qPrintable(name); +} + +TEST(SingleInstanceManagerTest, ResolvesAsPrimaryWhenNoneExists) +{ + const QString socketName = uniqueSocketName(); + QLocalServer::removeServer(socketName); + + SingleInstanceManager mgr(socketName); + bool resolvedForwarded = true; + QEventLoop loop; + QObject::connect(&mgr, &SingleInstanceManager::roleResolved, [&](bool forwarded) { + resolvedForwarded = forwarded; + loop.quit(); + }); + mgr.resolveStartupRole(QString()); + + QTimer::singleShot(5000, &loop, &QEventLoop::quit); + loop.exec(); + + ASSERT_FALSE(resolvedForwarded) << "With no existing primary, we must become primary ourselves"; +} + +TEST(SingleInstanceManagerTest, ForwardsUrlToExistingPrimary) +{ + const QString socketName = uniqueSocketName(); + QLocalServer::removeServer(socketName); + + QObject parent; + auto *primary = makeResolvedPrimary(socketName, &parent); + + int receivedCount = 0; + QString receivedUrl; + QObject::connect(primary, &SingleInstanceManager::urlReceived, [&](const QString &url) { + ++receivedCount; + receivedUrl = url; + }); + + SingleInstanceManager secondary(socketName); + bool secondaryForwarded = false; + QEventLoop loop; + // Wait on roleResolved (the terminal event of the handshake) rather than + // urlReceived: the secondary's roleResolved fires only after the ACK has + // round-tripped back from the primary, which itself happens after the + // primary emits urlReceived. By the time we quit here, all three of + // secondaryForwarded / receivedCount / receivedUrl are set. + QObject::connect(&secondary, &SingleInstanceManager::roleResolved, [&](bool forwarded) { + secondaryForwarded = forwarded; + loop.quit(); + }); + + const QString url = QStringLiteral("cockatrice://joingame?hostname=example.com&port=4748&roomid=1&gameid=42"); + secondary.resolveStartupRole(url); + + QTimer::singleShot(5000, &loop, &QEventLoop::quit); + loop.exec(); + + ASSERT_TRUE(secondaryForwarded) << "Secondary should resolve as forwarded when primary exists"; + ASSERT_EQ(receivedCount, 1) << "urlReceived should fire exactly once on the primary"; + ASSERT_EQ(receivedUrl, url); +} + +TEST(SingleInstanceManagerTest, RoleResolvedEmitsAtMostOnce) +{ + // Regression: the probe-side shared flag must keep roleResolved single- + // emission even when multiple of QLocalSocket's signals fire (e.g. + // errorOccurred after a successful readyRead, or a timeout firing in the + // same tick as the terminal signal). Pre-fix the flag's storage was + // delete-then-read on subsequent fires, UB whose only visible symptom on + // a forgiving allocator was duplicate emission — so we observe that. + const QString socketName = uniqueSocketName(); + QLocalServer::removeServer(socketName); + + QObject parent; + auto *primary = makeResolvedPrimary(socketName, &parent); + Q_UNUSED(primary); + + SingleInstanceManager secondary(socketName); + int resolvedCount = 0; + QObject::connect(&secondary, &SingleInstanceManager::roleResolved, [&](bool) { ++resolvedCount; }); + + QEventLoop loop; + QObject::connect(&secondary, &SingleInstanceManager::roleResolved, &loop, &QEventLoop::quit); + secondary.resolveStartupRole( + QStringLiteral("cockatrice://joingame?hostname=example.com&port=4748&roomid=1&gameid=42")); + + QTimer::singleShot(5000, &loop, &QEventLoop::quit); + loop.exec(); + + // Give any straggling signals (errorOccurred on socket teardown, + // timeout that may have armed) a chance to fire before we count. + QCoreApplication::processEvents(); + + ASSERT_EQ(resolvedCount, 1) << "roleResolved must fire exactly once across the entire handshake"; +} + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/url_scheme_event_filter_test.cpp b/tests/url_scheme_event_filter_test.cpp new file mode 100644 index 000000000..7a6127675 --- /dev/null +++ b/tests/url_scheme_event_filter_test.cpp @@ -0,0 +1,63 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include + +TEST(UrlSchemeEventFilterTest, EmitsAndConsumesMatchingUrl) +{ + UrlSchemeEventFilter filter(QStringLiteral("cockatrice://")); + + int callCount = 0; + QString received; + QObject::connect(&filter, &UrlSchemeEventFilter::urlReceived, [&](const QString &url) { + ++callCount; + received = url; + }); + + const QString url = QStringLiteral("cockatrice://joingame?hostname=example.com&port=4748"); + QUrl qurl{url}; + QFileOpenEvent event{qurl}; + const bool consumed = filter.eventFilter(nullptr, &event); + + ASSERT_TRUE(consumed) << "Matching URL should be consumed by the filter"; + ASSERT_EQ(callCount, 1) << "urlReceived should have been emitted once"; + ASSERT_EQ(received, url) << "Emitted URL should match the event URL"; +} + +TEST(UrlSchemeEventFilterTest, PassesThroughNonMatchingUrl) +{ + UrlSchemeEventFilter filter(QStringLiteral("cockatrice://")); + + int callCount = 0; + QObject::connect(&filter, &UrlSchemeEventFilter::urlReceived, [&](const QString &) { ++callCount; }); + + QUrl qurl{QStringLiteral("https://example.com")}; + QFileOpenEvent event{qurl}; + const bool consumed = filter.eventFilter(nullptr, &event); + + ASSERT_FALSE(consumed) << "Non-matching URL should not be consumed"; + ASSERT_EQ(callCount, 0) << "urlReceived should not have been emitted"; +} + +TEST(UrlSchemeEventFilterTest, MatchesCaseInsensitively) +{ + UrlSchemeEventFilter filter(QStringLiteral("cockatrice://")); + + int callCount = 0; + QObject::connect(&filter, &UrlSchemeEventFilter::urlReceived, [&](const QString &) { ++callCount; }); + + QUrl qurl{QStringLiteral("Cockatrice://joingame?hostname=example.com&port=4748")}; + QFileOpenEvent event{qurl}; + const bool consumed = filter.eventFilter(nullptr, &event); + + ASSERT_TRUE(consumed); + ASSERT_EQ(callCount, 1); +} + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/url_utils_test.cpp b/tests/url_utils_test.cpp new file mode 100644 index 000000000..71a83a06a --- /dev/null +++ b/tests/url_utils_test.cpp @@ -0,0 +1,187 @@ +#include "gtest/gtest.h" +#include +#include + +// --------------------------------------------------------------------------- +// UrlUtils::findUrlArgument +// --------------------------------------------------------------------------- + +TEST(UrlUtilsTest, FindsMatchingArgument) +{ + const QStringList args{QStringLiteral("--debug"), + QStringLiteral("cockatrice://joingame?hostname=example.com&port=4748")}; + ASSERT_EQ(UrlUtils::findUrlArgument(args, QStringLiteral("cockatrice://")), + QStringLiteral("cockatrice://joingame?hostname=example.com&port=4748")); +} + +TEST(UrlUtilsTest, ReturnsEmptyStringWhenNoMatch) +{ + const QStringList args{QStringLiteral("--debug"), QStringLiteral("foo"), QStringLiteral("bar")}; + ASSERT_TRUE(UrlUtils::findUrlArgument(args, QStringLiteral("cockatrice://")).isEmpty()); +} + +TEST(UrlUtilsTest, FindsArgumentCaseInsensitively) +{ + const QStringList args{QStringLiteral("Cockatrice://joingame?hostname=example.com&port=4748")}; + ASSERT_EQ(UrlUtils::findUrlArgument(args, QStringLiteral("cockatrice://")), + QStringLiteral("Cockatrice://joingame?hostname=example.com&port=4748")); +} + +TEST(UrlUtilsTest, ReturnsFirstMatchOnly) +{ + const QStringList args{QStringLiteral("cockatrice://joingame?first=1"), + QStringLiteral("cockatrice://joingame?second=2")}; + ASSERT_EQ(UrlUtils::findUrlArgument(args, QStringLiteral("cockatrice://")), + QStringLiteral("cockatrice://joingame?first=1")); +} + +// --------------------------------------------------------------------------- +// UrlUtils::parseOracleUrl +// --------------------------------------------------------------------------- + +TEST(ParseOracleUrlTest, RecognisesUpdate) +{ + const auto action = UrlUtils::parseOracleUrl(QStringLiteral("cockatrice-oracle://update")); + ASSERT_TRUE(action.isUpdate); + ASSERT_FALSE(action.spoilersOnly); +} + +TEST(ParseOracleUrlTest, RecognisesUpdateWithSpoilers) +{ + const auto action = UrlUtils::parseOracleUrl(QStringLiteral("cockatrice-oracle://update?spoilers=1")); + ASSERT_TRUE(action.isUpdate); + ASSERT_TRUE(action.spoilersOnly); +} + +TEST(ParseOracleUrlTest, IgnoresUnknownHost) +{ + const auto action = UrlUtils::parseOracleUrl(QStringLiteral("cockatrice-oracle://unrelated")); + ASSERT_FALSE(action.isUpdate); +} + +TEST(ParseOracleUrlTest, MatchesHostCaseInsensitively) +{ + const auto action = UrlUtils::parseOracleUrl(QStringLiteral("cockatrice-oracle://UPDATE")); + ASSERT_TRUE(action.isUpdate); +} + +// --------------------------------------------------------------------------- +// UrlUtils::parseJoinGameUrl +// --------------------------------------------------------------------------- + +namespace +{ +const QString kValidUrl = QStringLiteral("cockatrice://joingame?hostname=example.com&port=4748&roomid=1&gameid=42"); +} + +TEST(ParseJoinGameUrlTest, ParsesHappyPath) +{ + const auto parsed = UrlUtils::parseJoinGameUrl(kValidUrl); + ASSERT_TRUE(parsed.has_value()); + ASSERT_EQ(parsed->hostname, QStringLiteral("example.com")); + ASSERT_EQ(parsed->port, 4748); + ASSERT_EQ(parsed->roomId, 1); + ASSERT_EQ(parsed->gameId, 42); + ASSERT_FALSE(parsed->spectator); +} + +TEST(ParseJoinGameUrlTest, AcceptsSpectateFlag) +{ + const auto parsed = UrlUtils::parseJoinGameUrl(kValidUrl + QStringLiteral("&spectate=1")); + ASSERT_TRUE(parsed.has_value()); + ASSERT_TRUE(parsed->spectator); +} + +TEST(ParseJoinGameUrlTest, SpectateZeroIsNotSpectator) +{ + const auto parsed = UrlUtils::parseJoinGameUrl(kValidUrl + QStringLiteral("&spectate=0")); + ASSERT_TRUE(parsed.has_value()); + ASSERT_FALSE(parsed->spectator); +} + +TEST(ParseJoinGameUrlTest, MatchesSchemeCaseInsensitively) +{ + const auto parsed = UrlUtils::parseJoinGameUrl( + QStringLiteral("Cockatrice://joingame?hostname=example.com&port=4748&roomid=1&gameid=42")); + ASSERT_TRUE(parsed.has_value()); +} + +TEST(ParseJoinGameUrlTest, RejectsInvalidScheme) +{ + const auto parsed = + UrlUtils::parseJoinGameUrl(QStringLiteral("http://joingame?hostname=example.com&port=4748&roomid=1&gameid=42")); + ASSERT_FALSE(parsed.has_value()); +} + +TEST(ParseJoinGameUrlTest, RejectsUnsupportedHost) +{ + const auto parsed = UrlUtils::parseJoinGameUrl( + QStringLiteral("cockatrice://something?hostname=example.com&port=4748&roomid=1&gameid=42")); + ASSERT_FALSE(parsed.has_value()); +} + +TEST(ParseJoinGameUrlTest, RejectsMissingHostname) +{ + const auto parsed = + UrlUtils::parseJoinGameUrl(QStringLiteral("cockatrice://joingame?port=4748&roomid=1&gameid=42")); + ASSERT_FALSE(parsed.has_value()); +} + +TEST(ParseJoinGameUrlTest, RejectsZeroPort) +{ + const auto parsed = UrlUtils::parseJoinGameUrl( + QStringLiteral("cockatrice://joingame?hostname=example.com&port=0&roomid=1&gameid=42")); + ASSERT_FALSE(parsed.has_value()); +} + +TEST(ParseJoinGameUrlTest, RejectsOutOfRangePort) +{ + const auto parsed = UrlUtils::parseJoinGameUrl( + QStringLiteral("cockatrice://joingame?hostname=example.com&port=99999&roomid=1&gameid=42")); + ASSERT_FALSE(parsed.has_value()); +} + +TEST(ParseJoinGameUrlTest, RejectsNonNumericPort) +{ + const auto parsed = UrlUtils::parseJoinGameUrl( + QStringLiteral("cockatrice://joingame?hostname=example.com&port=abc&roomid=1&gameid=42")); + ASSERT_FALSE(parsed.has_value()); +} + +TEST(ParseJoinGameUrlTest, RejectsNegativeRoomId) +{ + const auto parsed = UrlUtils::parseJoinGameUrl( + QStringLiteral("cockatrice://joingame?hostname=example.com&port=4748&roomid=-1&gameid=42")); + ASSERT_FALSE(parsed.has_value()); +} + +TEST(ParseJoinGameUrlTest, RejectsNegativeGameId) +{ + const auto parsed = UrlUtils::parseJoinGameUrl( + QStringLiteral("cockatrice://joingame?hostname=example.com&port=4748&roomid=1&gameid=-1")); + ASSERT_FALSE(parsed.has_value()); +} + +TEST(ParseJoinGameUrlTest, IgnoresCredentialQueryParams) +{ + // Regression test for the security blocker: even if username/password are + // present in the URL (e.g. legacy bookmark), they must not surface in the + // parsed output. Parsing should succeed and yield the same params as the + // equivalent URL without those fields. + const auto withCreds = UrlUtils::parseJoinGameUrl(kValidUrl + QStringLiteral("&username=alice&password=hunter2")); + const auto withoutCreds = UrlUtils::parseJoinGameUrl(kValidUrl); + ASSERT_TRUE(withCreds.has_value()); + ASSERT_TRUE(withoutCreds.has_value()); + ASSERT_EQ(withCreds->hostname, withoutCreds->hostname); + ASSERT_EQ(withCreds->port, withoutCreds->port); + ASSERT_EQ(withCreds->roomId, withoutCreds->roomId); + ASSERT_EQ(withCreds->gameId, withoutCreds->gameId); + ASSERT_EQ(withCreds->spectator, withoutCreds->spectator); +} + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}