feat: register cockatrice:// and cockatrice-oracle:// protocol handlers

Adds OS-level URL-scheme handlers so users can click a link in a browser,
chat client, or third-party tool to launch Cockatrice straight into a
server / game / Oracle update.

Supported URL forms:
  cockatrice://joingame?hostname=H&port=P&roomid=R&gameid=G[&spectate=1]
  cockatrice-oracle://update[?spoilers=1]

Credentials passed via URL (username/password query params) are deliberately
ignored — URLs leak through shell history, browser history, EDR capture, etc.
If the target server requires auth and no saved credentials match, the Connect
dialog opens pre-filled with the URL's host/port so the user types their
password locally.

OS integration
- Linux: MimeType=x-scheme-handler/cockatrice (and -oracle) added to the
  .desktop files; Exec=cockatrice %u passes the URL through.
- Windows: NSIS installer writes HKCR\cockatrice and HKCR\cockatrice-oracle
  registry entries; uninstaller removes them.
- macOS: per-app Info.cockatrice.plist / Info.oracle.plist declare
  CFBundleURLTypes; a QFileOpenEvent filter is installed on QApplication
  before any nested event loop so cold-start URLs aren't lost.

New abstractions
- Intent (libcockatrice_utility/libcockatrice/utility/intent.h): abstract base
  for chained async actions.  Guarantees finished() fires at most once,
  execute() is idempotent, self-deletes via deleteLater, and
  startTimeoutSafetyNet() arms a configurable per-stage deadline.  Concrete
  intents (IntentConnectToServer, IntentLogin, IntentJoinServerRoom,
  IntentJoinServerGame) compose the joingame flow via UrlParser.
- SingleInstanceManager: async per-user local-socket primary/secondary
  handshake; URL forwarded from secondary to primary with QDataStream framing
  both ways.  shared_ptr-backed resolved flag survives every lambda capture.
- UrlSchemeEventFilter (new libcockatrice_utility_gui sibling library): QObject
  event filter that translates macOS QFileOpenEvent into a urlReceived(QString)
  signal.  Lives in its own Gui-bearing lib so libcockatrice_utility stays
  Core+Network only and doesn't drag Qt::Gui into servatrice.
- UrlUtils (header-only): pure URL parsing, fully unit-tested.

Wiring
- MainWindow::handleUrl(QString) — single entry point for any URL source.
- DlgConnect::prefillNewHost(host, port) — pre-fills new-host inputs.
- ServersSettings::findSavedCredsByHostPort — case-insensitive saved-creds
  lookup.
- TabSupervisor::requestJoinRoom + roomJoinedById / roomJoinFailedById signals,
  TabServer::roomAlreadyJoined for the short-circuit "already in this room"
  path — single source of truth for duplicate-join handling.

Tests
- 36 new unit tests across four single-purpose targets in tests/:
  - url_utils_test (22 tests) — scheme matching, port/room/game validation,
    spectator flag, credentials ignored, case-insensitivity.
  - url_scheme_event_filter_test (3 tests) — QFileOpenEvent capture.
  - intent_test (7 tests) — self-delete, abort propagation, parent-destruction-
    mid-flight, finish-once gate, execute() idempotence.
  - single_instance_manager_test (4 tests) — per-user socket naming, becoming-
    primary alone, forwarding to an existing primary, single-emission of
    roleResolved.

Build tooling (incidental)
- Dockerfile.format, docker-compose.format.yml, Makefile — a docker-based
  runner for format.sh that mirrors CI's desktop-lint step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
seavor
2026-05-13 19:40:05 -05:00
parent 762e742be0
commit 371b74732e
54 changed files with 2147 additions and 57 deletions
+1
View File
@@ -15,3 +15,4 @@ compile_commands.json
.gdb_history
cockatrice/resources/config/qtlogging.ini
docs/
.claude/settings.local.json
+2 -1
View File
@@ -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)
+33
View File
@@ -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"]
+12
View File
@@ -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
+6
View File
@@ -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}")
@@ -34,5 +34,16 @@
<string>${MACOSX_BUNDLE_COPYRIGHT}</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.cockatrice.Cockatrice.url</string>
<key>CFBundleURLSchemes</key>
<array>
<string>cockatrice</string>
</array>
</dict>
</array>
</dict>
</plist>
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleExecutable</key>
<string>${MACOSX_BUNDLE_EXECUTABLE_NAME}</string>
<key>CFBundleGetInfoString</key>
<string>${MACOSX_BUNDLE_INFO_STRING}</string>
<key>CFBundleIconFile</key>
<string>${MACOSX_BUNDLE_ICON_FILE}</string>
<key>CFBundleIdentifier</key>
<string>${MACOSX_BUNDLE_GUI_IDENTIFIER}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLongVersionString</key>
<string>${MACOSX_BUNDLE_LONG_VERSION_STRING}</string>
<key>CFBundleName</key>
<string>${MACOSX_BUNDLE_BUNDLE_NAME}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${MACOSX_BUNDLE_SHORT_VERSION_STRING}</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>${MACOSX_BUNDLE_BUNDLE_VERSION}</string>
<key>CSResourcesFileMapped</key>
<true/>
<key>LSRequiresCarbon</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>${MACOSX_BUNDLE_COPYRIGHT}</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.cockatrice.Oracle.url</string>
<key>CFBundleURLSchemes</key>
<array>
<string>cockatrice-oracle</string>
</array>
</dict>
</array>
</dict>
</plist>
+12
View File
@@ -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
+10 -1
View File
@@ -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()
+2 -1
View File
@@ -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;
@@ -0,0 +1,13 @@
#ifndef CONTEXT_CONNECT_TO_SERVER_H
#define CONTEXT_CONNECT_TO_SERVER_H
#include <QString>
#include <QtGlobal>
struct ContextConnectToServer
{
QString hostname;
quint16 port;
};
#endif // CONTEXT_CONNECT_TO_SERVER_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
@@ -0,0 +1,9 @@
#ifndef CONTEXT_JOIN_ROOM_H
#define CONTEXT_JOIN_ROOM_H
struct ContextJoinRoom
{
int roomId;
};
#endif // CONTEXT_JOIN_ROOM_H
@@ -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 <QLoggingCategory>
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<unsigned int>(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();
}
@@ -0,0 +1,53 @@
#ifndef INTENT_CONNECT_TO_SERVER_H
#define INTENT_CONNECT_TO_SERVER_H
#include "contexts/context_connect_to_server.h"
#include <libcockatrice/utility/intent.h>
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
@@ -0,0 +1,23 @@
#ifndef INTENT_HELPERS_H
#define INTENT_HELPERS_H
#include "../../client/network/connection_controller/remote_connection_controller.h"
#include <QObject>
#include <libcockatrice/utility/intent.h>
/**
* @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
@@ -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 <QLoggingCategory>
#include <libcockatrice/protocol/pb/room_commands.pb.h>
#include <libcockatrice/protocol/pending_command.h>
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);
}
@@ -0,0 +1,41 @@
#ifndef INTENT_JOIN_SERVER_GAME_H
#define INTENT_JOIN_SERVER_GAME_H
#include "contexts/context_join_game.h"
#include <libcockatrice/utility/intent.h>
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
@@ -0,0 +1,44 @@
#include "intent_join_server_room.h"
#include "../widgets/tabs/tab_supervisor.h"
#include "intent_helpers.h"
#include <QLoggingCategory>
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);
}
}
@@ -0,0 +1,37 @@
#ifndef INTENT_JOIN_SERVER_ROOM_H
#define INTENT_JOIN_SERVER_ROOM_H
#include "contexts/context_join_room.h"
#include <libcockatrice/utility/intent.h>
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
@@ -0,0 +1,36 @@
#include "intent_login.h"
#include "../../client/network/connection_controller/remote_connection_controller.h"
#include <QLoggingCategory>
#include <libcockatrice/network/client/remote/remote_client.h>
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();
}
@@ -0,0 +1,32 @@
#ifndef INTENT_LOGIN_H
#define INTENT_LOGIN_H
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/utility/intent.h>
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
@@ -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 <QLoggingCategory>
#include <libcockatrice/utility/url_utils.h>
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;
}
@@ -0,0 +1,51 @@
#ifndef URL_PARSER_H
#define URL_PARSER_H
#include <QObject>
#include <libcockatrice/utility/intent.h>
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
@@ -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();
@@ -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();
@@ -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);
}
@@ -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;
}
@@ -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:
@@ -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())
@@ -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);
+15
View File
@@ -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();
}
+2
View File
@@ -155,6 +155,8 @@ public:
return tabSupervisor;
}
void handleUrl(const QString &url);
protected:
void closeEvent(QCloseEvent *event) override;
void changeEvent(QEvent *event) override;
+64
View File
@@ -35,12 +35,16 @@
#include <QCryptographicHash>
#include <QDateTime>
#include <QDebug>
#include <QEventLoop>
#include <QLibraryInfo>
#include <QLocale>
#include <QSystemTrayIcon>
#include <QTranslator>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/rng/rng_sfmt.h>
#include <libcockatrice/utility/single_instance_manager.h>
#include <libcockatrice/utility/url_utils.h>
#include <libcockatrice/utility_gui/url_scheme_event_filter.h>
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"));
}
+32
View File
@@ -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"]
@@ -33,28 +33,9 @@ QString CardSet::getCorrectedShortName() const
{
// For Windows machines.
QSet<QString> 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;
}
@@ -98,6 +98,26 @@ QString ServersSettings::getPassword()
return QString();
}
std::optional<SavedServerCreds> 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());
@@ -11,11 +11,24 @@
#include <QLoggingCategory>
#include <QObject>
#include <optional>
#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<SavedServerCreds> findSavedCredsByHostPort(const QString &host, quint16 port) const;
QString getSaveName(QString defaultname = "");
QString getSite(QString defaultName = "");
bool getSavePassword() const;
+5 -2
View File
@@ -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)
@@ -0,0 +1,125 @@
#ifndef LIBCOCKATRICE_INTENT_H
#define LIBCOCKATRICE_INTENT_H
#include <QObject>
#include <QTimer>
/**
* @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
@@ -0,0 +1,145 @@
#include "single_instance_manager.h"
#include <QDataStream>
#include <QLoggingCategory>
#include <QTimer>
#include <memory>
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<bool>(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();
}
@@ -0,0 +1,97 @@
#ifndef LIBCOCKATRICE_SINGLE_INSTANCE_MANAGER_H
#define LIBCOCKATRICE_SINGLE_INSTANCE_MANAGER_H
#include <QLocalServer>
#include <QLocalSocket>
#include <QObject>
#include <QString>
/**
* @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
@@ -0,0 +1,136 @@
#ifndef LIBCOCKATRICE_URL_UTILS_H
#define LIBCOCKATRICE_URL_UTILS_H
#include <QStringList>
#include <QUrl>
#include <QUrlQuery>
#include <optional>
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<JoinGameUrlParams> 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<quint16>(portVal);
params.roomId = roomId;
params.gameId = gameId;
params.spectator = query.queryItemValue(QStringLiteral("spectate")) == QStringLiteral("1");
return params;
}
} // namespace UrlUtils
#endif // LIBCOCKATRICE_URL_UTILS_H
+21
View File
@@ -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})
@@ -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.
@@ -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 <QEvent>
#include <QFileOpenEvent>
#include <QObject>
#include <QString>
/**
* @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<QFileOpenEvent *>(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
+2 -1
View File
@@ -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()
+2 -1
View File
@@ -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;
+39
View File
@@ -10,6 +10,8 @@
#include <QLibraryInfo>
#include <QTimer>
#include <QTranslator>
#include <libcockatrice/utility/url_utils.h>
#include <libcockatrice/utility_gui/url_scheme_event_filter.h>
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) {
+20
View File
@@ -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)
+186
View File
@@ -0,0 +1,186 @@
#include "gtest/gtest.h"
#include <QCoreApplication>
#include <QPointer>
#include <libcockatrice/utility/intent.h>
// 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<StubIntent> 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<PendingIntent> 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<PendingIntent> 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<PendingIntent> head = new PendingIntent;
QPointer<PendingIntent> 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<PendingIntent> 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();
}
+139
View File
@@ -0,0 +1,139 @@
#include "gtest/gtest.h"
#include <QCoreApplication>
#include <QEventLoop>
#include <QLocalServer>
#include <QLocalSocket>
#include <QRandomGenerator>
#include <QTimer>
#include <libcockatrice/utility/single_instance_manager.h>
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();
}
+63
View File
@@ -0,0 +1,63 @@
#include "gtest/gtest.h"
#include <QCoreApplication>
#include <QFileOpenEvent>
#include <QUrl>
#include <libcockatrice/utility_gui/url_scheme_event_filter.h>
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();
}
+187
View File
@@ -0,0 +1,187 @@
#include "gtest/gtest.h"
#include <QCoreApplication>
#include <libcockatrice/utility/url_utils.h>
// ---------------------------------------------------------------------------
// 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();
}