mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-07-05 04:53:54 -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:
parent
762e742be0
commit
371b74732e
54 changed files with 2147 additions and 57 deletions
|
|
@ -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();
|
||||
}
|
||||
53
cockatrice/src/interface/intents/intent_connect_to_server.h
Normal file
53
cockatrice/src/interface/intents/intent_connect_to_server.h
Normal file
|
|
@ -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
|
||||
23
cockatrice/src/interface/intents/intent_helpers.h
Normal file
23
cockatrice/src/interface/intents/intent_helpers.h
Normal file
|
|
@ -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
|
||||
63
cockatrice/src/interface/intents/intent_join_server_game.cpp
Normal file
63
cockatrice/src/interface/intents/intent_join_server_game.cpp
Normal file
|
|
@ -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);
|
||||
}
|
||||
41
cockatrice/src/interface/intents/intent_join_server_game.h
Normal file
41
cockatrice/src/interface/intents/intent_join_server_game.h
Normal file
|
|
@ -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
|
||||
44
cockatrice/src/interface/intents/intent_join_server_room.cpp
Normal file
44
cockatrice/src/interface/intents/intent_join_server_room.cpp
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
37
cockatrice/src/interface/intents/intent_join_server_room.h
Normal file
37
cockatrice/src/interface/intents/intent_join_server_room.h
Normal file
|
|
@ -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
|
||||
36
cockatrice/src/interface/intents/intent_login.cpp
Normal file
36
cockatrice/src/interface/intents/intent_login.cpp
Normal file
|
|
@ -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();
|
||||
}
|
||||
32
cockatrice/src/interface/intents/intent_login.h
Normal file
32
cockatrice/src/interface/intents/intent_login.h
Normal file
|
|
@ -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
|
||||
72
cockatrice/src/interface/intents/url_parser.cpp
Normal file
72
cockatrice/src/interface/intents/url_parser.cpp
Normal file
|
|
@ -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;
|
||||
}
|
||||
51
cockatrice/src/interface/intents/url_parser.h
Normal file
51
cockatrice/src/interface/intents/url_parser.h
Normal file
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue