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();
+}