mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-21 09:05:10 -07:00
[DeckShare] Open shared decks via links with a gated preview flow
- Serialized url-chain dispatcher in IntentUrlParser; queue-drained urlChainFinished(bool) drives the startup auto-connect fallback - Open-shared-deck intent with sequential download state machine, 15s per-item timeout, partial-success offer, livable Cancel via ApplicationModal dlg_login_prompt interactive fallback - Preview dialog: download progress label, share vocab sweep, palette-highlight selection frame, Space/Enter keyboard toggle, NoFocus checkbox, double-click tile opens immediately - Confirm-before-server-migration with one-shot restore to the previous server on failed/cancelled chains (statusChanged settle deferral), hostname-only identity comparisons - Skip credential link when already connected; arrow-key navigation in FlowWidget; card glows use palette highlight - Address code-review M1-M4 and UI/UX QA blockers 1-2
This commit is contained in:
parent
90e11e0d63
commit
ad2fb40a2a
25 changed files with 1350 additions and 55 deletions
|
|
@ -1,19 +1,28 @@
|
|||
#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 <QDebug>
|
||||
#include <QLoggingCategory>
|
||||
#include <QMessageBox>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||
#include <libcockatrice/settings/servers_settings.h>
|
||||
#include <memory>
|
||||
|
||||
inline Q_LOGGING_CATEGORY(UrlParserLog, "url_parser");
|
||||
|
||||
IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow)
|
||||
{
|
||||
}
|
||||
|
|
@ -29,16 +38,33 @@ void IntentUrlParser::handle(const QString &urlStr)
|
|||
const QString action = url.host();
|
||||
QUrlQuery query(url);
|
||||
|
||||
qCDebug(UrlParserLog) << "Parsing intent URL, action:" << action;
|
||||
|
||||
QList<Intent *> chain;
|
||||
Intent *firstIntent = nullptr;
|
||||
if (action == "joingame") {
|
||||
handleJoinGame(query);
|
||||
firstIntent = createJoinGameIntent(query, chain);
|
||||
} else if (action == "opendeck") {
|
||||
// handleOpenDeck(query);
|
||||
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();
|
||||
}
|
||||
|
||||
void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
|
||||
Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, QList<Intent *> &chain)
|
||||
{
|
||||
auto showError = [this](const QString &message) { QMessageBox::warning(mainWindow, tr("Open game"), message); };
|
||||
|
||||
|
|
@ -49,21 +75,21 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
|
|||
|
||||
if (ctx->roomContext.serverContext.hostname.isEmpty()) {
|
||||
showError(tr("Missing or empty hostname in the game link"));
|
||||
return;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
ctx->roomContext.serverContext.port.toUShort(&ok);
|
||||
if (!ok) {
|
||||
showError(tr("Invalid or missing port in the game link"));
|
||||
return;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok);
|
||||
|
||||
if (!ok) {
|
||||
showError(tr("Invalid or missing room id in the game link"));
|
||||
return;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ok = false;
|
||||
|
|
@ -71,7 +97,7 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
|
|||
|
||||
if (!ok) {
|
||||
showError(tr("Invalid or missing game id in the game link"));
|
||||
return;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded);
|
||||
|
|
@ -80,24 +106,32 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
|
|||
const QMessageBox::StandardButton answer = QMessageBox::question(
|
||||
mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
|
||||
if (answer != QMessageBox::Yes) {
|
||||
return;
|
||||
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.
|
||||
ContextConnectToServer *serverContext = &ctx->roomContext.serverContext;
|
||||
auto joinGameIntent =
|
||||
new IntentJoinServerGame(mainWindow->getTabSupervisor(), mainWindow->getRemoteClient(), std::move(ctx));
|
||||
auto joinGameIntent = new IntentJoinServerGame(mainWindow->getTabSupervisor(), client, std::move(ctx));
|
||||
joinGameIntent->setParent(this);
|
||||
|
||||
auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext);
|
||||
getLoginCredentialsIntent->setParent(joinGameIntent);
|
||||
|
||||
connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute);
|
||||
connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed);
|
||||
chain.append(joinGameIntent);
|
||||
connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
|
||||
|
||||
getLoginCredentialsIntent->execute();
|
||||
Intent *firstIntent = joinGameIntent;
|
||||
if (!isConnectedTo(serverContext->hostname, serverContext->port)) {
|
||||
auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext);
|
||||
getLoginCredentialsIntent->setParent(joinGameIntent);
|
||||
chain.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)
|
||||
|
|
@ -134,3 +168,221 @@ QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context,
|
|||
.arg(gameDescription, gameIdStr, roomTab->getRoomName(), server)
|
||||
: tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, server);
|
||||
}
|
||||
|
||||
Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, QList<Intent *> &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();
|
||||
|
||||
// When the link would move us away from a live session, ask first — the
|
||||
// open deck download needs the connection the user already has. Remember
|
||||
// the current session so a failed or cancelled chain can restore it.
|
||||
const bool migrating =
|
||||
client->getStatus() == StatusLoggedIn && !isConnectedTo(ctx->serverContext.hostname, ctx->serverContext.port);
|
||||
if (migrating) {
|
||||
const QString target = QStringLiteral("%1:%2").arg(ctx->serverContext.hostname, ctx->serverContext.port);
|
||||
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;
|
||||
}
|
||||
migrationTargetHost = ctx->serverContext.hostname;
|
||||
migrationTargetPort = ctx->serverContext.port;
|
||||
previousServerHost = client->serverName();
|
||||
previousServerPort = QString::number(client->serverPort());
|
||||
pendingRestore = true;
|
||||
}
|
||||
|
||||
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.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);
|
||||
getLoginCredentialsIntent->setParent(openDeckIntent);
|
||||
chain.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
|
||||
{
|
||||
Q_UNUSED(port);
|
||||
// Deliberately hostname-only (no port): the intents' preconditions apply the
|
||||
// same rule, so a link to the same host on another port still connects
|
||||
// rather than silently reusing an existing session on a different server.
|
||||
RemoteClient *client = mainWindow->getRemoteClient();
|
||||
return client->getStatus() == StatusLoggedIn && client->serverName().compare(hostname, Qt::CaseInsensitive) == 0;
|
||||
}
|
||||
|
||||
void IntentUrlParser::startNextChain()
|
||||
{
|
||||
if (chainRunning || pendingChains.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
chainRunning = true;
|
||||
currentChainSucceeded = false;
|
||||
|
||||
const QList<Intent *> chain = pendingChains.takeFirst();
|
||||
if (chain.isEmpty()) {
|
||||
chainRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.last();
|
||||
connect(finalIntent, &Intent::finished, this, [this]() {
|
||||
currentChainSucceeded = true;
|
||||
chainEnded();
|
||||
});
|
||||
connect(finalIntent, &Intent::failed, this, &IntentUrlParser::chainEnded);
|
||||
connect(finalIntent, &Intent::cancelled, this, &IntentUrlParser::chainEnded);
|
||||
|
||||
chain.first()->execute();
|
||||
}
|
||||
|
||||
void IntentUrlParser::chainEnded()
|
||||
{
|
||||
chainRunning = false;
|
||||
|
||||
// Only a failed or cancelled chain restores the session the link migrated
|
||||
// away from; a successful one leaves the user where they are.
|
||||
if (pendingRestore && !currentChainSucceeded) {
|
||||
restorePreviousServer();
|
||||
}
|
||||
pendingRestore = false;
|
||||
|
||||
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::restorePreviousServer()
|
||||
{
|
||||
if (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();
|
||||
return;
|
||||
}
|
||||
auto waitConnection = std::make_shared<QMetaObject::Connection>();
|
||||
*waitConnection = connect(client, &RemoteClient::statusChanged, this, [this, client, waitConnection]() {
|
||||
const ClientStatus settled = client->getStatus();
|
||||
if (settled == StatusDisconnected || settled == StatusLoggedIn) {
|
||||
QObject::disconnect(*waitConnection);
|
||||
restoreToPreviousServer();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void IntentUrlParser::restoreToPreviousServer()
|
||||
{
|
||||
RemoteClient *client = mainWindow->getRemoteClient();
|
||||
|
||||
// Back on the previous server already → nothing to undo.
|
||||
if (client->serverName().compare(previousServerHost, Qt::CaseInsensitive) == 0 &&
|
||||
QString::number(client->serverPort()) == 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(migrationTargetHost, Qt::CaseInsensitive) == 0 &&
|
||||
QString::number(client->serverPort()) == migrationTargetPort;
|
||||
if (!onMigrationTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
ServersSettings &servers = SettingsCache::instance().servers();
|
||||
const int index = servers.findServerIndex(previousServerHost, previousServerPort);
|
||||
if (index >= 0 && servers.hasLoginData(previousServerHost, 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(previousServerHost, 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(previousServerHost, previousServerPort);
|
||||
if (index >= 0 && servers.hasLoginData(previousServerHost, 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(previousServerHost, previousServerPort.toUInt(), username, password);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue