mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-06-12 11:01:29 -07:00
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:
@@ -15,3 +15,4 @@ compile_commands.json
|
||||
.gdb_history
|
||||
cockatrice/resources/config/qtlogging.ini
|
||||
docs/
|
||||
.claude/settings.local.json
|
||||
+2
-1
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -155,6 +155,8 @@ public:
|
||||
return tabSupervisor;
|
||||
}
|
||||
|
||||
void handleUrl(const QString &url);
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
void changeEvent(QEvent *event) override;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user