mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 01:23:24 -08:00
The server list used hand-numbered saveName%1/server%1 keys and an O(n) shift loop to delete entries. Store the profiles with beginReadArray and beginWriteArray instead, and migrate the legacy keys once in the constructor so existing profiles and saved passwords survive. SettingsManager::batchWrite no longer disables atomic sync, so a batch is still written through QSettings' atomic save. Add tests for add, update, remove, password updates and the legacy migration. Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
444 lines
19 KiB
C++
444 lines
19 KiB
C++
#include "url_parser.h"
|
|
|
|
#include "../../client/settings/cache_settings.h"
|
|
#include "../widgets/tabs/tab_room.h"
|
|
#include "../widgets/tabs/tab_supervisor.h"
|
|
#include "../window_main.h"
|
|
#include "contexts/context_connect_to_server.h"
|
|
#include "contexts/context_join_game.h"
|
|
#include "contexts/context_join_room.h"
|
|
#include "contexts/context_open_deck.h"
|
|
#include "intent.h"
|
|
#include "intent_join_server_game.h"
|
|
#include "intent_login.h"
|
|
#include "intent_open_shared_deck.h"
|
|
#include "remote_client.h"
|
|
|
|
#include <QDebug>
|
|
#include <QLoggingCategory>
|
|
#include <QMap>
|
|
#include <QMessageBox>
|
|
#include <QMessageLogger>
|
|
#include <QSharedPointer>
|
|
#include <QStringLiteral>
|
|
#include <QUrl>
|
|
#include <QUrlQuery>
|
|
#include <QVariant>
|
|
#include <QtGlobal>
|
|
#include <libcockatrice/card/database/card_database_manager.h>
|
|
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
|
#include <libcockatrice/settings/servers_settings.h>
|
|
#include <libcockatrice/utility/qt_utils.h>
|
|
#include <memory>
|
|
#include <qlogging.h>
|
|
#include <qnamespace.h>
|
|
#include <utility>
|
|
|
|
inline Q_LOGGING_CATEGORY(UrlParserLog, "url_parser");
|
|
|
|
IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow)
|
|
{
|
|
}
|
|
|
|
void IntentUrlParser::handle(const QString &urlStr)
|
|
{
|
|
QUrl url(urlStr);
|
|
|
|
if (url.scheme() != "cockatrice") {
|
|
return;
|
|
}
|
|
|
|
const QString action = url.host();
|
|
QUrlQuery query(url);
|
|
|
|
qCDebug(UrlParserLog) << "Parsing intent URL, action:" << action;
|
|
|
|
PendingIntentChain chain;
|
|
Intent *firstIntent = nullptr;
|
|
if (action == "joingame") {
|
|
firstIntent = createJoinGameIntent(query, chain);
|
|
} else if (action == "opendeck") {
|
|
firstIntent = createOpenDeckIntent(query, chain);
|
|
} else {
|
|
qWarning() << "Unknown intent:" << action;
|
|
}
|
|
|
|
if (firstIntent == nullptr) {
|
|
// The link was invalid or the user declined the confirm: nothing runs.
|
|
// Report the idle state when no other chain is queued so that a startup
|
|
// launch (which skipped its own connection for this URL) falls back to it.
|
|
if (!chainRunning && pendingChains.isEmpty()) {
|
|
emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn);
|
|
}
|
|
return;
|
|
}
|
|
|
|
pendingChains.append(chain);
|
|
startNextChain();
|
|
}
|
|
|
|
Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, PendingIntentChain &chain)
|
|
{
|
|
auto showError = [this](const QString &message) { QMessageBox::warning(mainWindow, tr("Open game"), message); };
|
|
|
|
auto ctx = std::make_unique<ContextJoinGame>();
|
|
|
|
ctx->roomContext.serverContext.hostname = query.queryItemValue("hostname");
|
|
ctx->roomContext.serverContext.port = query.queryItemValue("port");
|
|
|
|
if (ctx->roomContext.serverContext.hostname.isEmpty()) {
|
|
showError(tr("Missing or empty hostname in the game link"));
|
|
return nullptr;
|
|
}
|
|
|
|
bool ok = false;
|
|
ctx->roomContext.serverContext.port.toUShort(&ok);
|
|
if (!ok) {
|
|
showError(tr("Invalid or missing port in the game link"));
|
|
return nullptr;
|
|
}
|
|
|
|
ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok);
|
|
|
|
if (!ok) {
|
|
showError(tr("Invalid or missing room id in the game link"));
|
|
return nullptr;
|
|
}
|
|
|
|
ok = false;
|
|
ctx->gameId = query.queryItemValue("gameid").toInt(&ok);
|
|
|
|
if (!ok) {
|
|
showError(tr("Invalid or missing game id in the game link"));
|
|
return nullptr;
|
|
}
|
|
|
|
const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded);
|
|
const QString message = generateJoinGameMessage(*ctx, gameDescription);
|
|
|
|
if (!QtUtils::confirm(mainWindow, tr("Join game"), message, QMessageBox::Yes)) {
|
|
return nullptr;
|
|
}
|
|
|
|
RemoteClient *client = mainWindow->getRemoteClient();
|
|
ContextConnectToServer *serverContext = &ctx->roomContext.serverContext;
|
|
|
|
// The join game intent owns the context and the credential lookup; once the
|
|
// chain finishes (or fails) it deletes the whole tree.
|
|
auto joinGameIntent = new IntentJoinServerGame(mainWindow->getTabSupervisor(), client, std::move(ctx));
|
|
joinGameIntent->setParent(this);
|
|
chain.intents.append(joinGameIntent);
|
|
connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
|
|
|
|
Intent *firstIntent = joinGameIntent;
|
|
if (!isConnectedTo(serverContext->hostname, serverContext->port)) {
|
|
auto getLoginCredentialsIntent =
|
|
new IntentGetLoginCredentials(serverContext, /*promptForMissingCredentials=*/true);
|
|
getLoginCredentialsIntent->setParent(joinGameIntent);
|
|
chain.intents.insert(0, getLoginCredentialsIntent);
|
|
|
|
connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute);
|
|
connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed);
|
|
connect(getLoginCredentialsIntent, &Intent::cancelled, joinGameIntent, &Intent::cancelled);
|
|
firstIntent = getLoginCredentialsIntent;
|
|
}
|
|
|
|
return firstIntent;
|
|
}
|
|
|
|
QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription)
|
|
{
|
|
const QString hostname = context.roomContext.serverContext.hostname;
|
|
const QString port = context.roomContext.serverContext.port;
|
|
const int roomId = context.roomContext.roomId;
|
|
const int gameId = context.gameId;
|
|
const QString server = QStringLiteral("%1:%2").arg(hostname, port);
|
|
|
|
// Prefer the room name over the raw numeric id: it means something to the
|
|
// user. The name is only known when we are already connected to the same
|
|
// server and sitting in that room — otherwise fall back to a plain prompt.
|
|
AbstractClient *client = mainWindow->getTabSupervisor()->getClient();
|
|
const bool sameServer = client != nullptr && client->getStatus() == StatusLoggedIn &&
|
|
hostname.compare(client->serverName(), Qt::CaseInsensitive) == 0 &&
|
|
QString::number(client->serverPort()) == port;
|
|
TabRoom *roomTab = sameServer ? mainWindow->getTabSupervisor()->getRoomTabs().value(roomId) : nullptr;
|
|
|
|
const QString gameIdStr = QString::number(gameId);
|
|
// Links built by newer clients embed the game description ("game" item);
|
|
// restate it in the confirm so it matches what the chat anchor showed.
|
|
// Unknown query items are ignored, so old links without it keep working.
|
|
// The multi-arg .arg() overloads replace in a single pass, so a description
|
|
// containing "%…" cannot corrupt later placeholders.
|
|
// FullyDecoded undoes every %XX escape and must match the chat anchor's
|
|
// decode mode, so a description containing "%" reads identically in both.
|
|
if (gameDescription.isEmpty()) {
|
|
return roomTab ? tr("Join game #%1 in \"%2\" on %3?").arg(gameIdStr, roomTab->getRoomName(), server)
|
|
: tr("Join game #%1 on %2?").arg(gameIdStr, server);
|
|
}
|
|
|
|
return roomTab ? tr("Join game \"%1\" (#%2) in \"%3\" on %4?")
|
|
.arg(gameDescription, gameIdStr, roomTab->getRoomName(), server)
|
|
: tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, server);
|
|
}
|
|
|
|
Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, PendingIntentChain &chain)
|
|
{
|
|
auto showError = [this](const QString &message) {
|
|
QMessageBox::warning(mainWindow, tr("Open shared deck"), message);
|
|
};
|
|
|
|
auto ctx = std::make_unique<ContextOpenDeck>();
|
|
|
|
ctx->serverContext.hostname = query.queryItemValue("hostname");
|
|
ctx->serverContext.port = query.queryItemValue("port");
|
|
ctx->shareToken = query.queryItemValue("share");
|
|
|
|
qCDebug(UrlParserLog) << "Open-deck intent: host" << ctx->serverContext.hostname << "port"
|
|
<< ctx->serverContext.port << "token length" << ctx->shareToken.length();
|
|
|
|
if (ctx->serverContext.hostname.isEmpty()) {
|
|
showError(tr("Missing or empty hostname in the share link"));
|
|
return nullptr;
|
|
}
|
|
|
|
bool ok = false;
|
|
const quint16 port = ctx->serverContext.port.toUShort(&ok);
|
|
if (!ok || port == 0) {
|
|
showError(tr("Invalid or missing port in the share link"));
|
|
return nullptr;
|
|
}
|
|
|
|
if (ctx->shareToken.isEmpty()) {
|
|
showError(tr("Missing or empty share value in the share link"));
|
|
return nullptr;
|
|
}
|
|
|
|
RemoteClient *client = mainWindow->getRemoteClient();
|
|
|
|
// The open deck download needs a connection to the link's server. Ask before
|
|
// taking the session anywhere it isn't already, naming the host we would
|
|
// connect to. Remember the link's target when it moves us away from a live
|
|
// session so a failed or cancelled chain can restore the session it left.
|
|
// The hostname is link-supplied and percent-decoded, so escape it: QMessageBox
|
|
// renders AutoText, and markup in a hostname would otherwise flip the whole
|
|
// prompt to rich text and let a link pad the message the user is shown.
|
|
const bool alreadyConnected = isConnectedTo(ctx->serverContext.hostname, ctx->serverContext.port);
|
|
if (!alreadyConnected) {
|
|
const QString target =
|
|
QStringLiteral("%1:%2").arg(ctx->serverContext.hostname.toHtmlEscaped(), ctx->serverContext.port);
|
|
|
|
if (client->getStatus() == StatusLoggedIn) {
|
|
const QString current =
|
|
QStringLiteral("%1:%2").arg(client->serverName(), QString::number(client->serverPort()));
|
|
if (!QtUtils::confirm(
|
|
mainWindow, tr("Open shared deck"),
|
|
tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current),
|
|
QMessageBox::Yes)) {
|
|
return nullptr;
|
|
}
|
|
chain.migrationTargetHost = ctx->serverContext.hostname;
|
|
chain.migrationTargetPort = ctx->serverContext.port;
|
|
chain.pendingRestore = true;
|
|
} else {
|
|
// Fresh connection is harmless to wander away from, but a server the
|
|
// client has never been configured for deserves a harder warning (no
|
|
// by default) so a stray link cannot silently steer the client there.
|
|
const bool knownHost = SettingsCache::instance().servers().findHostIndex(ctx->serverContext.hostname) >= 0;
|
|
const QMessageBox::StandardButton answer =
|
|
knownHost
|
|
? QMessageBox::question(mainWindow, tr("Open shared deck"),
|
|
tr("Opening this share link connects you to %1.\n\nContinue?").arg(target))
|
|
: QMessageBox::warning(mainWindow, tr("Open shared deck"),
|
|
tr("Opening this share link connects you to %1, a server you have "
|
|
"never connected to before.\n\nContinue?")
|
|
.arg(target),
|
|
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
|
|
if (answer != QMessageBox::Yes) {
|
|
return nullptr;
|
|
}
|
|
}
|
|
}
|
|
|
|
ContextConnectToServer *serverContext = &ctx->serverContext;
|
|
|
|
// The open deck intent owns the context and the credential lookup; once
|
|
// the chain finishes (or fails) it deletes the whole tree.
|
|
auto openDeckIntent =
|
|
new IntentOpenSharedDeck(mainWindow->getTabSupervisor(), client, CardDatabaseManager::query(), std::move(ctx));
|
|
openDeckIntent->setParent(this);
|
|
chain.intents.append(openDeckIntent);
|
|
connect(openDeckIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
|
|
|
|
Intent *firstIntent = openDeckIntent;
|
|
if (!isConnectedTo(serverContext->hostname, serverContext->port)) {
|
|
auto getLoginCredentialsIntent =
|
|
new IntentGetLoginCredentials(serverContext, /*promptForMissingCredentials=*/true);
|
|
getLoginCredentialsIntent->setParent(openDeckIntent);
|
|
chain.intents.insert(0, getLoginCredentialsIntent);
|
|
|
|
connect(getLoginCredentialsIntent, &Intent::finished, openDeckIntent, &Intent::execute);
|
|
connect(getLoginCredentialsIntent, &Intent::failed, openDeckIntent, &Intent::failed);
|
|
connect(getLoginCredentialsIntent, &Intent::cancelled, openDeckIntent, &Intent::cancelled);
|
|
firstIntent = getLoginCredentialsIntent;
|
|
}
|
|
|
|
return firstIntent;
|
|
}
|
|
|
|
bool IntentUrlParser::isConnectedTo(const QString &hostname, const QString &port) const
|
|
{
|
|
// serverName() reflects the server the client was configured to connect to,
|
|
// which may differ from the actual TCP peer (e.g. when connecting through a
|
|
// proxy), so compare the configured host and port — exactly what a link
|
|
// names. A link to the same host on another port is a different server and
|
|
// must not silently reuse an existing session there.
|
|
RemoteClient *client = mainWindow->getRemoteClient();
|
|
return client->getStatus() == StatusLoggedIn && client->serverName().compare(hostname, Qt::CaseInsensitive) == 0 &&
|
|
QString::number(client->serverPort()) == port;
|
|
}
|
|
|
|
void IntentUrlParser::startNextChain()
|
|
{
|
|
if (chainRunning || pendingChains.isEmpty()) {
|
|
return;
|
|
}
|
|
chainRunning = true;
|
|
|
|
PendingIntentChain &chain = pendingChains.first();
|
|
if (chain.intents.isEmpty()) {
|
|
pendingChains.removeFirst();
|
|
chainRunning = false;
|
|
startNextChain();
|
|
return;
|
|
}
|
|
|
|
// Snapshot the session this chain moves away from now that it actually
|
|
// runs. Chains are parsed while earlier ones are still queued, so a capture
|
|
// at parse time would follow whichever server the chain before it settled
|
|
// on, not the one the user is really on when this link is handled.
|
|
if (chain.pendingRestore) {
|
|
RemoteClient *client = mainWindow->getRemoteClient();
|
|
chain.previousServerHost = client->serverName();
|
|
chain.previousServerPort = QString::number(client->serverPort());
|
|
}
|
|
|
|
// Only the last intent completes the chain; its terminal signal ends the
|
|
// whole run. Cancellation of an intermediate intent (e.g. declined login
|
|
// prompt) is forwarded onto the last intent in the chain builders above.
|
|
Intent *finalIntent = chain.intents.last();
|
|
connect(finalIntent, &Intent::finished, this, [this]() { chainEnded(true); });
|
|
connect(finalIntent, &Intent::failed, this, [this]() { chainEnded(false); });
|
|
connect(finalIntent, &Intent::cancelled, this, [this]() { chainEnded(false); });
|
|
// Backstop: if the final intent is destroyed without emitting a terminal
|
|
// signal (e.g. a network error dropped it while running), end the chain so
|
|
// later links are not queued and dropped for the rest of the session.
|
|
chainBackstopConnection = connect(finalIntent, &QObject::destroyed, this, &IntentUrlParser::onChainIntentDestroyed);
|
|
|
|
chain.intents.first()->execute();
|
|
}
|
|
|
|
void IntentUrlParser::chainEnded(bool chainSucceeded)
|
|
{
|
|
chainRunning = false;
|
|
QObject::disconnect(chainBackstopConnection);
|
|
|
|
const PendingIntentChain chain = pendingChains.takeFirst();
|
|
|
|
// Only a failed or cancelled chain restores the session the link migrated
|
|
// away from; a successful one leaves the user where they are.
|
|
if (chain.pendingRestore && !chainSucceeded) {
|
|
restorePreviousServer(chain);
|
|
}
|
|
|
|
startNextChain();
|
|
|
|
// Only report the terminal state once the queue has fully drained, so a
|
|
// queued follow-up link keeps the startup fallback out of the picture.
|
|
if (!chainRunning && pendingChains.isEmpty()) {
|
|
emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn);
|
|
}
|
|
}
|
|
|
|
void IntentUrlParser::onChainIntentDestroyed()
|
|
{
|
|
if (!chainRunning) {
|
|
return;
|
|
}
|
|
qCWarning(UrlParserLog) << "Share-link intent destroyed without a terminal signal; ending its chain";
|
|
chainEnded(false);
|
|
}
|
|
|
|
void IntentUrlParser::restorePreviousServer(const PendingIntentChain &chain)
|
|
{
|
|
if (chain.previousServerHost.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
RemoteClient *client = mainWindow->getRemoteClient();
|
|
const ClientStatus status = client->getStatus();
|
|
|
|
// A failed/cancelled chain can fire while the client is still settling the
|
|
// in-flight connection attempt (wrong password, connect timeout). Only
|
|
// decide once the client has settled into logged-in or disconnected;
|
|
// deciding mid-connect would strand the user offline from their previous
|
|
// server.
|
|
if (status == StatusDisconnected || status == StatusLoggedIn) {
|
|
restoreToPreviousServer(chain);
|
|
return;
|
|
}
|
|
auto waitConnection = std::make_shared<QMetaObject::Connection>();
|
|
*waitConnection = connect(client, &RemoteClient::statusChanged, this, [this, chain, client, waitConnection]() {
|
|
const ClientStatus settled = client->getStatus();
|
|
if (settled == StatusDisconnected || settled == StatusLoggedIn) {
|
|
QObject::disconnect(*waitConnection);
|
|
restoreToPreviousServer(chain);
|
|
}
|
|
});
|
|
}
|
|
|
|
void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain)
|
|
{
|
|
RemoteClient *client = mainWindow->getRemoteClient();
|
|
|
|
// Back on the previous server already → nothing to undo.
|
|
if (client->serverName().compare(chain.previousServerHost, Qt::CaseInsensitive) == 0 &&
|
|
QString::number(client->serverPort()) == chain.previousServerPort) {
|
|
return;
|
|
}
|
|
|
|
// When logged in somewhere, only intervene if that somewhere is the server
|
|
// the link moved us to; if the user went elsewhere on their own, leave them.
|
|
if (client->getStatus() == StatusLoggedIn) {
|
|
const bool onMigrationTarget =
|
|
client->serverName().compare(chain.migrationTargetHost, Qt::CaseInsensitive) == 0 &&
|
|
QString::number(client->serverPort()) == chain.migrationTargetPort;
|
|
if (!onMigrationTarget) {
|
|
return;
|
|
}
|
|
|
|
ServersSettings &servers = SettingsCache::instance().servers();
|
|
const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort);
|
|
const auto server = servers.getServer(index);
|
|
if (server && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
|
|
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), server->username,
|
|
server->password);
|
|
return;
|
|
}
|
|
client->disconnectFromServer();
|
|
return;
|
|
}
|
|
|
|
if (client->getStatus() != StatusDisconnected) {
|
|
return;
|
|
}
|
|
|
|
// The link's connection attempt failed: reconnect to the previous server
|
|
// when credentials are saved, otherwise stay offline.
|
|
ServersSettings &servers = SettingsCache::instance().servers();
|
|
const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort);
|
|
const auto server = servers.getServer(index);
|
|
if (server && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
|
|
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), server->username,
|
|
server->password);
|
|
}
|
|
}
|