#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_join_game.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 #include #include #include #include #include #include #include #include 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(); 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); const QMessageBox::StandardButton answer = QMessageBox::question( mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); if (answer != 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(); 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())); const QMessageBox::StandardButton answer = QMessageBox::question( mainWindow, tr("Open shared deck"), tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current), QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); if (answer != 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(); *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); if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { const QString username = servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); const QString password = servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, 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); if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { const QString username = servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); const QString password = servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password); } }