[DeckShare] Open shared decks via links with a gated preview flow (#7244)

* [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

* [DeckShare] End the open-shared-deck files with a trailing newline

* [DeckShare] Forward a dependency's cancellation as the owner's own

* [DeckShare] Let intent chains opt into the link sign-in dialog

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

* [Settings] Match a server on the exact host and port when adding it

* [DeckShare] Confirm the share link's target server before opening a deck

* [DeckShare] Reformat the link sign-in intent constructor

* [DeckShare] Time the share-list round trip and backstop silently-destroyed intent chains

* [Client] Drain a single-instance payload before its handlers read the socket again

* [Client] Treat a busy single-instance primary as alive instead of stealing its socket

* [DeckShare] Keep arrow-key navigation between flow items inside a scroll area

* [Client] Skip the startup connection when a macOS URL launch owns the connection

* [Client] Redact share secrets from activation URL logs

* [Client] Make the link-connection gates port-aware and keyboard-safe

Second-pass review notes for the shared-deck link flow (Cockatrice#7244):

- FlowWidget arrow-key navigation is opt-in via addNavigableWidget, so
  combo/spin controls on the analytics flows keep their own arrow keys
- isConnectedTo and the open-deck/join-game preconditions compare the
  configured server port alongside the host, so a same-host/different-port
  link cannot resolve its share token or game id on the wrong instance
- the link sign-in dialog reuses an existing server entry's saved name
  instead of renaming it to the raw hostname
- skipStartupAutoConnect is cleared once the launch chain connects, so a
  later mid-session declined link cannot fire the startup fallback
- the plain-launch path of SingleInstanceManager no longer blocks on the
  primary's ACK
- link- and server-supplied text is html-escaped in the confirm prompts and
  shared-deck preview so markup cannot spoof the shown messages

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-09-20 20:22:17 +02:00 committed by GitHub
parent ba2900dcb9
commit 8ca749c07d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1666 additions and 86 deletions

View file

@ -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;
PendingIntentChain 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, PendingIntentChain &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,33 @@ 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.intents.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, /*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)
@ -134,3 +169,270 @@ 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, 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()));
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<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);
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);
}
}