[DeckShare] Track link-intent chains per-run so each can restore its own session

This commit is contained in:
Lukas Brübach 2026-09-19 07:16:04 +02:00 committed by GitHub
parent 5fa8df4b83
commit 501ec70512
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 87 additions and 62 deletions

View file

@ -40,7 +40,7 @@ void IntentUrlParser::handle(const QString &urlStr)
qCDebug(UrlParserLog) << "Parsing intent URL, action:" << action;
QList<Intent *> chain;
PendingIntentChain chain;
Intent *firstIntent = nullptr;
if (action == "joingame") {
firstIntent = createJoinGameIntent(query, chain);
@ -64,7 +64,7 @@ void IntentUrlParser::handle(const QString &urlStr)
startNextChain();
}
Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, QList<Intent *> &chain)
Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, PendingIntentChain &chain)
{
auto showError = [this](const QString &message) { QMessageBox::warning(mainWindow, tr("Open game"), message); };
@ -116,14 +116,15 @@ Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, QList<Inte
// chain finishes (or fails) it deletes the whole tree.
auto joinGameIntent = new IntentJoinServerGame(mainWindow->getTabSupervisor(), client, std::move(ctx));
joinGameIntent->setParent(this);
chain.append(joinGameIntent);
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);
auto getLoginCredentialsIntent =
new IntentGetLoginCredentials(serverContext, /*promptForMissingCredentials=*/true);
getLoginCredentialsIntent->setParent(joinGameIntent);
chain.insert(0, getLoginCredentialsIntent);
chain.intents.insert(0, getLoginCredentialsIntent);
connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute);
connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed);
@ -169,7 +170,7 @@ QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context,
: tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, server);
}
Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, QList<Intent *> &chain)
Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, PendingIntentChain &chain)
{
auto showError = [this](const QString &message) {
QMessageBox::warning(mainWindow, tr("Open shared deck"), message);
@ -205,7 +206,8 @@ Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, QList<Inte
// 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.
// the link's target so a failed or cancelled chain can restore the session
// this chain moved away from.
const bool migrating =
client->getStatus() == StatusLoggedIn && !isConnectedTo(ctx->serverContext.hostname, ctx->serverContext.port);
if (migrating) {
@ -219,11 +221,9 @@ Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, QList<Inte
if (answer != QMessageBox::Yes) {
return nullptr;
}
migrationTargetHost = ctx->serverContext.hostname;
migrationTargetPort = ctx->serverContext.port;
previousServerHost = client->serverName();
previousServerPort = QString::number(client->serverPort());
pendingRestore = true;
chain.migrationTargetHost = ctx->serverContext.hostname;
chain.migrationTargetPort = ctx->serverContext.port;
chain.pendingRestore = true;
}
ContextConnectToServer *serverContext = &ctx->serverContext;
@ -233,14 +233,15 @@ Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, QList<Inte
auto openDeckIntent =
new IntentOpenSharedDeck(mainWindow->getTabSupervisor(), client, CardDatabaseManager::query(), std::move(ctx));
openDeckIntent->setParent(this);
chain.append(openDeckIntent);
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);
auto getLoginCredentialsIntent =
new IntentGetLoginCredentials(serverContext, /*promptForMissingCredentials=*/true);
getLoginCredentialsIntent->setParent(openDeckIntent);
chain.insert(0, getLoginCredentialsIntent);
chain.intents.insert(0, getLoginCredentialsIntent);
connect(getLoginCredentialsIntent, &Intent::finished, openDeckIntent, &Intent::execute);
connect(getLoginCredentialsIntent, &Intent::failed, openDeckIntent, &Intent::failed);
@ -267,38 +268,47 @@ void IntentUrlParser::startNextChain()
return;
}
chainRunning = true;
currentChainSucceeded = false;
const QList<Intent *> chain = pendingChains.takeFirst();
if (chain.isEmpty()) {
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.last();
connect(finalIntent, &Intent::finished, this, [this]() {
currentChainSucceeded = true;
chainEnded();
});
connect(finalIntent, &Intent::failed, this, &IntentUrlParser::chainEnded);
connect(finalIntent, &Intent::cancelled, this, &IntentUrlParser::chainEnded);
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); });
chain.first()->execute();
chain.intents.first()->execute();
}
void IntentUrlParser::chainEnded()
void IntentUrlParser::chainEnded(bool chainSucceeded)
{
chainRunning = false;
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 (pendingRestore && !currentChainSucceeded) {
restorePreviousServer();
if (chain.pendingRestore && !chainSucceeded) {
restorePreviousServer(chain);
}
pendingRestore = false;
startNextChain();
@ -309,9 +319,9 @@ void IntentUrlParser::chainEnded()
}
}
void IntentUrlParser::restorePreviousServer()
void IntentUrlParser::restorePreviousServer(const PendingIntentChain &chain)
{
if (previousServerHost.isEmpty()) {
if (chain.previousServerHost.isEmpty()) {
return;
}
@ -324,46 +334,47 @@ void IntentUrlParser::restorePreviousServer()
// deciding mid-connect would strand the user offline from their previous
// server.
if (status == StatusDisconnected || status == StatusLoggedIn) {
restoreToPreviousServer();
restoreToPreviousServer(chain);
return;
}
auto waitConnection = std::make_shared<QMetaObject::Connection>();
*waitConnection = connect(client, &RemoteClient::statusChanged, this, [this, client, waitConnection]() {
*waitConnection = connect(client, &RemoteClient::statusChanged, this, [this, chain, client, waitConnection]() {
const ClientStatus settled = client->getStatus();
if (settled == StatusDisconnected || settled == StatusLoggedIn) {
QObject::disconnect(*waitConnection);
restoreToPreviousServer();
restoreToPreviousServer(chain);
}
});
}
void IntentUrlParser::restoreToPreviousServer()
void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain)
{
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) {
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(migrationTargetHost, Qt::CaseInsensitive) == 0 &&
QString::number(client->serverPort()) == migrationTargetPort;
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(previousServerHost, previousServerPort);
if (index >= 0 && servers.hasLoginData(previousServerHost, previousServerPort)) {
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(previousServerHost, previousServerPort.toUInt(), username, password);
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password);
return;
}
client->disconnectFromServer();
@ -377,12 +388,12 @@ void IntentUrlParser::restoreToPreviousServer()
// 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 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(previousServerHost, previousServerPort.toUInt(), username, password);
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password);
}
}

View file

@ -8,6 +8,30 @@ class Intent;
class MainWindow;
struct ContextJoinGame;
/**
* @brief One queued intent chain with the session-migration bookkeeping for it.
*
* The restore fields are per-chain on purpose: chains are parsed while earlier
* ones are still queued, so parser-wide state would let one chain's failure
* consume the restore data another chain recorded.
*/
struct PendingIntentChain
{
QList<Intent *> intents;
// Snapshot of the session in place when this chain started running, so a
// queued chain follows whichever server the chain before it settled on.
QString previousServerHost;
QString previousServerPort;
// Recorded at parse time when the user confirmed migrating away from a live
// session to the host/port named by the link.
QString migrationTargetHost;
QString migrationTargetPort;
bool pendingRestore = false;
bool succeeded = false;
};
/**
* @brief Parses cockatrice:// links and runs them as serialized intent chains.
*
@ -30,28 +54,18 @@ signals:
void urlChainFinished(bool connected);
private:
Intent *createJoinGameIntent(const QUrlQuery &query, QList<Intent *> &chain);
Intent *createOpenDeckIntent(const QUrlQuery &query, QList<Intent *> &chain);
Intent *createJoinGameIntent(const QUrlQuery &query, PendingIntentChain &chain);
Intent *createOpenDeckIntent(const QUrlQuery &query, PendingIntentChain &chain);
QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription);
[[nodiscard]] bool isConnectedTo(const QString &hostname, const QString &port) const;
void startNextChain();
void chainEnded();
void restorePreviousServer();
void restoreToPreviousServer();
void chainEnded(bool chainSucceeded);
void restorePreviousServer(const PendingIntentChain &chain);
void restoreToPreviousServer(const PendingIntentChain &chain);
MainWindow *mainWindow;
QList<QList<Intent *>> pendingChains;
QList<PendingIntentChain> pendingChains;
bool chainRunning = false;
bool currentChainSucceeded = false;
// Set when an open-deck link migrates the session to another server. If the
// chain then fails or is cancelled while still on that server, the previous
// session is restored (reconnect if credentials are saved, else disconnect).
QString migrationTargetHost;
QString migrationTargetPort;
QString previousServerHost;
QString previousServerPort;
bool pendingRestore = false;
};
#endif // COCKATRICE_URL_PARSER_H