diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 98c294e34..59f14cef7 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -45,12 +45,14 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp src/interface/widgets/dialogs/dlg_load_remote_deck.cpp src/interface/widgets/dialogs/dlg_local_game_options.cpp + src/interface/widgets/dialogs/dlg_login_prompt.cpp src/interface/widgets/dialogs/dlg_manage_sets.cpp src/interface/widgets/dialogs/dlg_my_reports.cpp src/interface/widgets/dialogs/dlg_register.cpp src/interface/widgets/dialogs/dlg_report_user.cpp src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp src/interface/widgets/dialogs/dlg_share_deck.cpp + src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp src/interface/widgets/dialogs/dlg_settings.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp @@ -59,6 +61,7 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/override_printing_warning.cpp src/interface/widgets/dialogs/tip_of_the_day.cpp src/interface/widgets/deck_share/deck_share_utils.cpp + src/interface/widgets/deck_share/shared_deck_preview_widget.cpp src/interface/widgets/deck_share/share_bar_widget.cpp src/filters/deck_filter_string.cpp src/filters/filter_builder.cpp @@ -448,6 +451,8 @@ set(cockatrice_SOURCES src/interface/intents/intent_login.h src/interface/intents/intent_open_server_room_by_name.cpp src/interface/intents/intent_open_server_room_by_name.h + src/interface/intents/intent_open_shared_deck.cpp + src/interface/intents/intent_open_shared_deck.h src/interface/intents/url_parser.cpp src/interface/intents/url_parser.h src/interface/widgets/server/user/user_info_popup.cpp diff --git a/cockatrice/src/interface/intents/contexts/context_open_deck.h b/cockatrice/src/interface/intents/contexts/context_open_deck.h new file mode 100644 index 000000000..03dca088e --- /dev/null +++ b/cockatrice/src/interface/intents/contexts/context_open_deck.h @@ -0,0 +1,14 @@ +#ifndef COCKATRICE_CONTEXT_OPEN_DECK_H +#define COCKATRICE_CONTEXT_OPEN_DECK_H + +#include "context_connect_to_server.h" + +#include + +struct ContextOpenDeck +{ + ContextConnectToServer serverContext; + QString shareToken; +}; + +#endif // COCKATRICE_CONTEXT_OPEN_DECK_H diff --git a/cockatrice/src/interface/intents/intent.cpp b/cockatrice/src/interface/intents/intent.cpp index c02a89f35..db0d13b2c 100644 --- a/cockatrice/src/interface/intents/intent.cpp +++ b/cockatrice/src/interface/intents/intent.cpp @@ -2,10 +2,11 @@ Intent::Intent(QObject *parent) : QObject(parent) { - // An intent is done as soon as it reports success or failure. Deleting it - // also tears down its dependency chain and disconnects any signal wiring. + // An intent is done as soon as it reports success, failure, or cancellation. + // Deleting it also tears down its dependency chain and disconnects any signal wiring. connect(this, &Intent::finished, this, &QObject::deleteLater); connect(this, &Intent::failed, this, &QObject::deleteLater); + connect(this, &Intent::cancelled, this, &QObject::deleteLater); } Intent::~Intent() = default; @@ -27,6 +28,7 @@ void Intent::runDependency(Intent *dependency) this->execute(); }); connect(dependency, &Intent::failed, this, &Intent::failed); + connect(dependency, &Intent::cancelled, this, &Intent::cancelled); dependency->execute(); } @@ -46,3 +48,11 @@ void Intent::emitFailed(const QString &reason) emit failed(reason); } } + +void Intent::emitCancelled() +{ + if (!completed) { + completed = true; + emit cancelled(); + } +} diff --git a/cockatrice/src/interface/intents/intent.h b/cockatrice/src/interface/intents/intent.h index 125900ecd..5d9fdd3d6 100644 --- a/cockatrice/src/interface/intents/intent.h +++ b/cockatrice/src/interface/intents/intent.h @@ -16,6 +16,7 @@ public: signals: void finished(); void failed(QString reason); + void cancelled(); protected: // --- Subclasses must implement these --- @@ -29,6 +30,7 @@ protected: // Emit the outcome exactly once; ignore late signals after the intent is done. void emitFinished(); void emitFailed(const QString &reason); + void emitCancelled(); private: bool completed = false; diff --git a/cockatrice/src/interface/intents/intent_join_server_game.cpp b/cockatrice/src/interface/intents/intent_join_server_game.cpp index 205c4dc70..b22b200ed 100644 --- a/cockatrice/src/interface/intents/intent_join_server_game.cpp +++ b/cockatrice/src/interface/intents/intent_join_server_game.cpp @@ -19,13 +19,15 @@ bool IntentJoinServerGame::checkPrecondition() const if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { return false; } - // peerPort() reflects the actual TCP peer, which may differ from the - // configured server port (e.g. when connecting through a proxy), so only - // the hostname is compared here. - if (remoteClient->peerName() != context->roomContext.serverContext.hostname) { + // serverName()/serverPort() reflect 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 those configured values. A link + // naming the same host on another port is a different server and must not + // reuse the session there. + if (remoteClient->serverName().compare(context->roomContext.serverContext.hostname, Qt::CaseInsensitive) != 0) { return false; } - if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) { + if (QString::number(remoteClient->serverPort()) != context->roomContext.serverContext.port) { return false; } diff --git a/cockatrice/src/interface/intents/intent_login.cpp b/cockatrice/src/interface/intents/intent_login.cpp index ff871fd03..7beb63e1d 100644 --- a/cockatrice/src/interface/intents/intent_login.cpp +++ b/cockatrice/src/interface/intents/intent_login.cpp @@ -1,9 +1,14 @@ #include "intent_login.h" #include "../../client/settings/cache_settings.h" +#include "../widgets/dialogs/dlg_login_prompt.h" #include "libcockatrice/settings/servers_settings.h" -IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context) : Intent(), context(_context) +#include + +IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context, + bool _promptForMissingCredentials) + : Intent(), context(_context), promptForMissingCredentials(_promptForMissingCredentials) { } @@ -29,5 +34,46 @@ void IntentGetLoginCredentials::onPreconditionSatisfied() void IntentGetLoginCredentials::onPreconditionNotSatisfied() { - emitFailed(tr("No saved credentials for this server")); + // MainWindow::applyStartupDestination runs this intent on every launch for + // users whose startup tab is Server / Server Room; keep that path quiet, as + // it was before the link-driven sign-in dialog existed. + if (!promptForMissingCredentials) { + emitFailed(tr("No saved credentials for this server")); + return; + } + + // No credentials saved for the target server: ask the user for them. They + // opt into saving them so later links to the same server connect directly. + const QString serverText = context->hostname + ":" + context->port; + DlgLoginPrompt dialog(serverText); + // ApplicationModal: the dialog has no parent (the intent is not a widget), + // so WindowModal would not actually block any other window. + dialog.setWindowModality(Qt::ApplicationModal); + + if (dialog.exec() != QDialog::Accepted) { + emitCancelled(); + return; + } + + context->username = dialog.username(); + context->password = dialog.password(); + + if (dialog.savePassword() && !context->username.isEmpty()) { + ServersSettings &servers = SettingsCache::instance().servers(); + // The host may already be saved under a friendly name (e.g. a public-server + // list entry) with no credentials; reuse that name instead of overwriting + // it with the raw hostname when addNewServer updates the entry in place. + QString saveName = context->hostname; + const int existingIndex = servers.findServerIndex(context->hostname, context->port); + if (existingIndex >= 0) { + saveName = + servers.getValue(QString("saveName%1").arg(existingIndex), "server", "server_details").toString(); + if (saveName.isEmpty()) { + saveName = context->hostname; + } + } + servers.addNewServer(saveName, context->hostname, context->port, context->username, context->password, true); + } + + emitFinished(); } diff --git a/cockatrice/src/interface/intents/intent_login.h b/cockatrice/src/interface/intents/intent_login.h index c7fec92b7..8ffd91a0a 100644 --- a/cockatrice/src/interface/intents/intent_login.h +++ b/cockatrice/src/interface/intents/intent_login.h @@ -9,7 +9,10 @@ class IntentGetLoginCredentials : public Intent Q_OBJECT public: - IntentGetLoginCredentials(ContextConnectToServer *_context); + // When promptForMissingCredentials is false (the default) a server without + // saved credentials fails silently; only intent chains from cockatrice:// + // links opt into the interactive sign-in dialog. + explicit IntentGetLoginCredentials(ContextConnectToServer *_context, bool _promptForMissingCredentials = false); protected: bool checkPrecondition() const override; @@ -18,6 +21,7 @@ protected: private: ContextConnectToServer *context; + bool promptForMissingCredentials; }; #endif // COCKATRICE_INTENT_LOGIN_H diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.cpp b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp new file mode 100644 index 000000000..016de63e6 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp @@ -0,0 +1,208 @@ +#include "intent_open_shared_deck.h" + +#include "../deck_loader/deck_loader.h" +#include "../widgets/dialogs/dlg_shared_decks_preview.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "intent_connect_to_server.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +IntentOpenSharedDeck::IntentOpenSharedDeck(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + const CardDatabaseQuerier *_querier, + std::unique_ptr _context) + : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), querier(_querier), + context(_context.release()) +{ + downloadTimer = new QTimer(this); + downloadTimer->setSingleShot(true); + downloadTimer->setInterval(15000); + connect(downloadTimer, &QTimer::timeout, this, &IntentOpenSharedDeck::onDownloadTimeout); +} + +bool IntentOpenSharedDeck::checkPrecondition() const +{ + if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { + return false; + } + // serverName()/serverPort() reflect 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 those configured values. The + // share token must be resolved against the host the link named — a link to + // the same host on another port is a different server. + if (remoteClient->serverName().compare(context->serverContext.hostname, Qt::CaseInsensitive) != 0) { + return false; + } + return QString::number(remoteClient->serverPort()) == context->serverContext.port; +} + +void IntentOpenSharedDeck::onPreconditionSatisfied() +{ + // Resolve the share token to its items first; a share can contain more than + // one deck, and each item is downloaded by id. Time the round trip like the + // downloads, so a silent server cannot hang the chain forever. + listPhase = true; + downloadTimer->start(); + + Command_DeckShareList cmd; + cmd.set_token(context->shareToken.toStdString()); + + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::listShareFinished); + remoteClient->sendCommand(pend); +} + +void IntentOpenSharedDeck::onPreconditionNotSatisfied() +{ + runDependency(new IntentConnectToServer(remoteClient, &context->serverContext)); +} + +void IntentOpenSharedDeck::listShareFinished(const Response &response, const CommandContainer & /* commandContainer */) +{ + downloadTimer->stop(); + listPhase = false; + + if (response.response_code() != Response::RespOk) { + emitFailed(tr("The shared deck could not be found or has expired")); + return; + } + + const Response_DeckShareList &resp = response.GetExtension(Response_DeckShareList::ext); + if (resp.items_size() == 0) { + emitFailed(tr("The shared deck is empty")); + return; + } + + QList items; + items.reserve(resp.items_size()); + for (const ServerInfo_DeckShareItem &item : resp.items()) { + items.append(item); + itemNames.insert(item.id(), QString::fromStdString(item.name())); + } + + const QString serverText = context->serverContext.hostname + ":" + context->serverContext.port; + + // Ask the user which decks to open before downloading anything. + previewDialog = new DlgSharedDecksPreview(tabSupervisor, querier, QString::fromStdString(resp.name()), + resp.expires_at(), serverText, items); + connect(previewDialog, &DlgSharedDecksPreview::openRequested, this, &IntentOpenSharedDeck::startDownloads); + connect(previewDialog, &DlgSharedDecksPreview::cancelled, this, &IntentOpenSharedDeck::emitCancelled); + connect(previewDialog, &DlgSharedDecksPreview::cancelled, previewDialog, &QWidget::deleteLater); + previewDialog->show(); + previewDialog->raise(); + previewDialog->activateWindow(); +} + +void IntentOpenSharedDeck::startDownloads(const QList &itemIds) +{ + pendingItemIds = itemIds; + totalItems = itemIds.size(); + completedItems = 0; + loadedDecks.clear(); + downloadNextItem(); +} + +void IntentOpenSharedDeck::downloadNextItem() +{ + if (pendingItemIds.isEmpty()) { + finishAll(); + return; + } + + currentItemId = pendingItemIds.takeFirst(); + downloadTimer->start(); + + Command_DeckShareDownload cmd; + cmd.set_token(context->shareToken.toStdString()); + cmd.set_item_id(currentItemId); + + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::downloadShareFinished); + remoteClient->sendCommand(pend); +} + +void IntentOpenSharedDeck::downloadShareFinished(const Response &response, + const CommandContainer & /* commandContainer */) +{ + downloadTimer->stop(); + + QString failureReason; + if (response.response_code() != Response::RespOk) { + failureReason = tr("Failed to download the shared deck"); + } else { + const Response_DeckShareDownload &resp = response.GetExtension(Response_DeckShareDownload::ext); + const QString deckString = QString::fromStdString(resp.deck()); + if (deckString.isEmpty()) { + failureReason = tr("The shared deck is empty"); + } else { + std::optional deckOpt = + DeckLoader::loadFromRemote(deckString, LoadedDeck::LoadInfo::NON_REMOTE_ID); + if (!deckOpt) { + failureReason = tr("The shared deck could not be loaded"); + } else { + loadedDecks.append(deckOpt.value()); + ++completedItems; + previewDialog->setDownloadProgress(completedItems, totalItems, + itemNames.value(currentItemId, tr("Unknown deck"))); + downloadNextItem(); + return; + } + } + } + + onItemFailure(failureReason); +} + +void IntentOpenSharedDeck::onItemFailure(const QString &reason) +{ + downloadTimer->stop(); + + if (loadedDecks.isEmpty()) { + previewDialog->deleteLater(); + emitFailed(reason); + return; + } + + const int downloadedCount = loadedDecks.size(); + const QMessageBox::StandardButton answer = QMessageBox::question( + previewDialog, tr("Open shared decks"), + tr("Could not download the deck \"%1\".\n\n%n deck(s) were already downloaded. Open them?", "", downloadedCount) + .arg(itemNames.value(currentItemId, tr("Unknown deck"))), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + + if (answer == QMessageBox::Yes) { + finishAll(); + } else { + previewDialog->deleteLater(); + emitCancelled(); + } +} + +void IntentOpenSharedDeck::onDownloadTimeout() +{ + // The list phase has no preview dialog yet to report progress into; fail the + // whole intent instead of letting the shared deck hang in limbo. + if (listPhase) { + emitFailed(tr("Timed out while loading the shared deck")); + return; + } + onItemFailure(tr("Timed out while downloading the shared deck")); +} + +void IntentOpenSharedDeck::finishAll() +{ + previewDialog->deleteLater(); + for (const LoadedDeck &deck : loadedDecks) { + tabSupervisor->openDeckInNewTab(deck); + } + emitFinished(); +} diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.h b/cockatrice/src/interface/intents/intent_open_shared_deck.h new file mode 100644 index 000000000..87812bdea --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.h @@ -0,0 +1,60 @@ +#ifndef COCKATRICE_INTENT_OPEN_SHARED_DECK_H +#define COCKATRICE_INTENT_OPEN_SHARED_DECK_H + +#include "contexts/context_open_deck.h" +#include "intent.h" +#include "remote_client.h" + +#include +#include +#include +#include + +class TabSupervisor; +struct LoadedDeck; +class CardDatabaseQuerier; +class DlgSharedDecksPreview; +class QTimer; + +class IntentOpenSharedDeck : public Intent +{ + Q_OBJECT + +public: + IntentOpenSharedDeck(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + const CardDatabaseQuerier *_querier, + std::unique_ptr _context); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private slots: + void listShareFinished(const Response &response, const CommandContainer &commandContainer); + void downloadShareFinished(const Response &response, const CommandContainer &commandContainer); + void onDownloadTimeout(); + +private: + void startDownloads(const QList &itemIds); + void downloadNextItem(); + void onItemFailure(const QString &reason); + void finishAll(); + + TabSupervisor *tabSupervisor; + RemoteClient *remoteClient; + const CardDatabaseQuerier *querier; + QScopedPointer context; + DlgSharedDecksPreview *previewDialog = nullptr; + QTimer *downloadTimer; + QMap itemNames; + QList pendingItemIds; + QList loadedDecks; + bool listPhase = true; + int currentItemId = 0; + int totalItems = 0; + int completedItems = 0; +}; + +#endif // COCKATRICE_INTENT_OPEN_SHARED_DECK_H diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp index 509390611..707863354 100644 --- a/cockatrice/src/interface/intents/url_parser.cpp +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -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 +#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) { } @@ -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(); + + 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); + } +} diff --git a/cockatrice/src/interface/intents/url_parser.h b/cockatrice/src/interface/intents/url_parser.h index 6d705e013..ea29fed53 100644 --- a/cockatrice/src/interface/intents/url_parser.h +++ b/cockatrice/src/interface/intents/url_parser.h @@ -1,10 +1,46 @@ #ifndef COCKATRICE_URL_PARSER_H #define COCKATRICE_URL_PARSER_H + +#include #include #include +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 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; +}; + +/** + * @brief Parses cockatrice:// links and runs them as serialized intent chains. + * + * Links are parsed by action (joingame/opendeck) and translated into an intent + * chain. Chains are queued and run one at a time: a document can hand multiple + * links to the window while an earlier chain still connects, and running two + * connect chains concurrently tears the connection down. urlChainFinished is + * emitted once the queue has fully drained. + */ class IntentUrlParser : public QObject { Q_OBJECT @@ -12,12 +48,28 @@ class IntentUrlParser : public QObject public: IntentUrlParser(QObject *parent, MainWindow *mainWindow); void handle(const QString &urlStr); - void handleJoinGame(const QUrlQuery &query); + +signals: + /** @brief Emitted when the last queued chain ended; carries whether the client is logged in. */ + void urlChainFinished(bool connected); private: + 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(bool chainSucceeded); + void onChainIntentDestroyed(); + void restorePreviousServer(const PendingIntentChain &chain); + void restoreToPreviousServer(const PendingIntentChain &chain); MainWindow *mainWindow; + QList pendingChains; + bool chainRunning = false; + // Disconnects the destroyed-signal backstop once a chain ends, so an old + // intent's deferred deletion cannot end the chain that runs after it. + QMetaObject::Connection chainBackstopConnection; }; #endif // COCKATRICE_URL_PARSER_H diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp index c5cb59b3b..000a88b2f 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp @@ -133,12 +133,14 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event) path.addRoundedRect(glowRect, radius, radius); // Soft outer glow - QColor glowColor(0, 150, 255, 80); // subtle blu + QColor glowColor = palette().color(QPalette::Highlight); + glowColor.setAlpha(80); painter.setPen(QPen(glowColor, 6)); painter.drawPath(path); // Thin inner border for crispness - QColor borderColor(0, 150, 255, 200); + QColor borderColor = palette().color(QPalette::Highlight); + borderColor.setAlpha(200); painter.setPen(QPen(borderColor, 2)); painter.drawRoundedRect(pixmapRect, radius, radius); diff --git a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp index 707173560..147143e7f 100644 --- a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp @@ -27,14 +27,16 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent, const QColor &textColor, const QColor &outlineColor, const int fontSize, - const Qt::Alignment alignment) + const Qt::Alignment alignment, + const bool _emitClickImmediately) : CardInfoPictureWithTextOverlayWidget(parent, hoverToZoomEnabled, raiseOnEnter, textColor, outlineColor, fontSize, - alignment) + alignment), + emitClickImmediately(_emitClickImmediately) { singleClickTimer = new QTimer(this); singleClickTimer->setSingleShot(true); @@ -50,8 +52,13 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent, void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { - lastMouseEvent = event; - singleClickTimer->start(QApplication::doubleClickInterval()); + if (emitClickImmediately) { + emit imageClicked(event, this); + emit imageSingleClicked(); + } else { + lastMouseEvent = event; + singleClickTimer->start(QApplication::doubleClickInterval()); + } } else { emit imageClicked(event, this); event->accept(); @@ -61,7 +68,14 @@ void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event) void DeckPreviewCardPictureWidget::mouseDoubleClickEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { - singleClickTimer->stop(); // Prevent single-click logic - emit imageDoubleClicked(lastMouseEvent, this); + if (emitClickImmediately) { + // Do not report a second single click for the second press of the + // double-click; the consumer maps the double-click to select+open. + lastMouseEvent = event; + emit imageDoubleClicked(event, this); + } else { + singleClickTimer->stop(); // Prevent single-click logic + emit imageDoubleClicked(lastMouseEvent, this); + } } } diff --git a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h index 303b6bf67..7571bc256 100644 --- a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h +++ b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h @@ -20,13 +20,28 @@ class DeckPreviewCardPictureWidget final : public CardInfoPictureWithTextOverlay Q_OBJECT public: + /** + * @brief Constructs a DeckPreviewCardPictureWidget. + * @param parent The parent widget. + * @param hoverToZoomEnabled If this widget will spawn a larger widget when hovered over. + * @param raiseOnEnter If the widget raises its border when the mouse enters. + * @param textColor The color of the overlay text. + * @param outlineColor The color of the outline around the text. + * @param fontSize The font size of the overlay text. + * @param alignment The alignment of the text within the overlay. + * @param emitClickImmediately If true, a left click is reported immediately on click + * instead of after the double-click interval. Use this for selection surfaces + * where reacting to a double-click (select-and-open) would needlessly delay the + * single-click feedback. The double-click signal is still emitted. + */ explicit DeckPreviewCardPictureWidget(QWidget *parent, bool hoverToZoomEnabled = false, bool raiseOnEnter = false, const QColor &textColor = Qt::white, const QColor &outlineColor = Qt::black, int fontSize = 12, - Qt::Alignment alignment = Qt::AlignCenter); + Qt::Alignment alignment = Qt::AlignCenter, + bool _emitClickImmediately = false); signals: void imageClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); @@ -36,6 +51,7 @@ signals: private: QTimer *singleClickTimer; QMouseEvent *lastMouseEvent = nullptr; // Store the last mouse event + bool emitClickImmediately; protected: void mousePressEvent(QMouseEvent *event) override; diff --git a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp new file mode 100644 index 000000000..21ec80e08 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp @@ -0,0 +1,140 @@ +#include "shared_deck_preview_widget.h" + +#include "../cards/additional_info/color_identity_widget.h" +#include "../cards/deck_preview_card_picture_widget.h" + +#include +#include +#include +#include +#include +#include +#include + +SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &deckName, + const QString &bannerCardName, + const QString &colorIdentity, + const QString &gameFormat, + const QString &deckToolTip) + : QWidget(parent) +{ + bannerCardDisplayWidget = + new DeckPreviewCardPictureWidget(this, false, false, Qt::white, Qt::black, 12, Qt::AlignCenter, true); + bannerCardDisplayWidget->setScaleFactor(100); + const ExactCard bannerCard = bannerCardName.isEmpty() ? ExactCard() : querier->getCard(CardRef{bannerCardName, {}}); + bannerCardDisplayWidget->setCard(bannerCard); + bannerCardDisplayWidget->setOverlayText(deckName); + setToolTip(deckToolTip.isEmpty() ? deckName : deckToolTip); + setFocusPolicy(Qt::StrongFocus); + setBaseAccessibleName(deckName); + + colorIdentityWidget = new ColorIdentityWidget(this, colorIdentity); + colorIdentityWidget->setVisible(!colorIdentity.isEmpty()); + + // gameFormat is server-supplied and the QLabel renders AutoText, so escape it. + gameFormatLabel = new QLabel(gameFormat.toHtmlEscaped(), this); + gameFormatLabel->setAlignment(Qt::AlignCenter); + gameFormatLabel->setVisible(!gameFormat.isEmpty()); + + selectionCheckBox = new QCheckBox(this); + selectionCheckBox->setToolTip(tr("Select this deck")); + // The tile itself is focusable (Space/Enter toggles); keep the checkbox + // from creating a second tab stop per tile. + selectionCheckBox->setFocusPolicy(Qt::NoFocus); + + // Selection frame reused from the deck-preview selection covenant: a + // palette(highlight) border around the banner card, shown while selected. + selectionFrame = new QFrame(bannerCardDisplayWidget); + selectionFrame->setAttribute(Qt::WA_TransparentForMouseEvents); + selectionFrame->setStyleSheet(QStringLiteral( + "QFrame { border: 2px solid palette(highlight); border-radius: 4px; background: transparent; }")); + selectionFrame->setVisible(false); + + auto *selectionRow = new QHBoxLayout; + selectionRow->addWidget(selectionCheckBox); + selectionRow->addStretch(1); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addLayout(selectionRow); + layout->addWidget(bannerCardDisplayWidget, 0, Qt::AlignHCenter); + layout->addWidget(colorIdentityWidget, 0, Qt::AlignHCenter); + layout->addWidget(gameFormatLabel, 0, Qt::AlignHCenter); + setLayout(layout); + + connect(selectionCheckBox, &QCheckBox::toggled, this, [this](bool checked) { + updateSelectionVisual(checked); + emit selectionToggled(checked); + }); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this, + &SharedDeckPreviewWidget::toggleSelection); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, + &SharedDeckPreviewWidget::activate); +} + +bool SharedDeckPreviewWidget::isSelected() const +{ + return selectionCheckBox->isChecked(); +} + +void SharedDeckPreviewWidget::setSelected(bool selected) +{ + if (isSelected() == selected) { + return; + } + selectionCheckBox->setChecked(selected); +} + +void SharedDeckPreviewWidget::updateSelectionVisual(bool selected) +{ + selectionFrame->setVisible(selected); + selectionFrame->raise(); + if (selected) { + setAccessibleName(baseAccessibleName + tr(" (selected)")); + } else { + setAccessibleName(baseAccessibleName); + } +} + +void SharedDeckPreviewWidget::setBaseAccessibleName(const QString &name) +{ + baseAccessibleName = name; + setAccessibleName(name); +} + +void SharedDeckPreviewWidget::toggleSelection() +{ + setSelected(!isSelected()); +} + +void SharedDeckPreviewWidget::activate() +{ + setSelected(true); + emit activated(); +} + +void SharedDeckPreviewWidget::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + updateSelectionFrameGeometry(); +} + +void SharedDeckPreviewWidget::updateSelectionFrameGeometry() +{ + if (selectionFrame == nullptr || bannerCardDisplayWidget == nullptr) { + return; + } + selectionFrame->setGeometry(bannerCardDisplayWidget->rect().adjusted(1, 1, -1, -1)); +} + +void SharedDeckPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) { + toggleSelection(); + event->accept(); + return; + } + QWidget::keyPressEvent(event); +} diff --git a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h new file mode 100644 index 000000000..bd4c4ee5f --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h @@ -0,0 +1,77 @@ +/** + * @file shared_deck_preview_widget.h + * @ingroup DeckShareWidgets + */ +//! \todo Document this file. + +#ifndef SHARED_DECK_PREVIEW_WIDGET_H +#define SHARED_DECK_PREVIEW_WIDGET_H + +#include + +class ColorIdentityWidget; +class DeckPreviewCardPictureWidget; +class QCheckBox; +class QFrame; +class QKeyEvent; +class QLabel; +class QResizeEvent; +class CardDatabaseQuerier; + +/** + * @brief A selectable preview tile for a deck that has no local file. + * + * Renders a banner card picture (looked up by name in the card database), the + * deck name, color identity and game format. Used to preview decks shared via a + * cockatrice:// link (metadata from Command_DeckShareList) and the deck + * currently open in the deck editor. + * + * Selection follows the deck-preview covenant: the tile reports its click + * immediately (no double-click interval delay), a palette(highlight) frame + * marks the selected tile, and Space/Enter toggles selection from the keyboard. + * A double click selects the tile and emits activated() so the caller can open + * just that deck. + */ +class SharedDeckPreviewWidget : public QWidget +{ + Q_OBJECT + +public: + explicit SharedDeckPreviewWidget(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &deckName, + const QString &bannerCardName, + const QString &colorIdentity, + const QString &gameFormat = QString(), + const QString &deckToolTip = QString()); + + [[nodiscard]] bool isSelected() const; + void setSelected(bool selected); + + void setBaseAccessibleName(const QString &name); + +signals: + void selectionToggled(bool selected); + void activated(); + +protected: + void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + +private slots: + void toggleSelection(); + void activate(); + +private: + void updateSelectionVisual(bool selected); + void updateSelectionFrameGeometry(); + + DeckPreviewCardPictureWidget *bannerCardDisplayWidget; + ColorIdentityWidget *colorIdentityWidget; + QLabel *gameFormatLabel; + QCheckBox *selectionCheckBox; + QFrame *selectionFrame; + QString baseAccessibleName; +}; + +#endif // SHARED_DECK_PREVIEW_WIDGET_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp new file mode 100644 index 000000000..86647ef26 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp @@ -0,0 +1,50 @@ +#include "dlg_login_prompt.h" + +#include +#include +#include +#include +#include +#include + +DlgLoginPrompt::DlgLoginPrompt(const QString &serverText, QWidget *parent) : QDialog(parent) +{ + setWindowTitle(tr("Sign in")); + + auto *mainLayout = new QVBoxLayout(this); + mainLayout->addWidget( + new QLabel(tr("This link requires you to be signed in.\nSign in to %1:").arg(serverText), this)); + + auto *formLayout = new QFormLayout; + usernameEdit = new QLineEdit(this); + passwordEdit = new QLineEdit(this); + passwordEdit->setEchoMode(QLineEdit::Password); + formLayout->addRow(tr("Username:"), usernameEdit); + formLayout->addRow(tr("Password:"), passwordEdit); + mainLayout->addLayout(formLayout); + + savePasswordCheckBox = new QCheckBox(tr("Save password for this server"), this); + mainLayout->addWidget(savePasswordCheckBox); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + mainLayout->addWidget(buttonBox); + + usernameEdit->setFocus(); +} + +QString DlgLoginPrompt::username() const +{ + return usernameEdit->text().trimmed(); +} + +QString DlgLoginPrompt::password() const +{ + return passwordEdit->text(); +} + +bool DlgLoginPrompt::savePassword() const +{ + return savePasswordCheckBox->isChecked(); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h new file mode 100644 index 000000000..e47924058 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h @@ -0,0 +1,40 @@ +/** + * @file dlg_login_prompt.h + * @ingroup ConnectionDialogs + */ +//! \todo Document this file. + +#ifndef DLG_LOGIN_PROMPT_H +#define DLG_LOGIN_PROMPT_H + +#include + +class QCheckBox; +class QLineEdit; + +/** + * @brief Small sign-in dialog used when a cockatrice:// link needs credentials + * that are not saved for the target server. + * + * The entered name and password are handed to the intent chain; when the user + * opts to save them, they are stored in the server settings so that later links + * to the same server connect seamlessly. + */ +class DlgLoginPrompt : public QDialog +{ + Q_OBJECT + +public: + explicit DlgLoginPrompt(const QString &serverText, QWidget *parent = nullptr); + + [[nodiscard]] QString username() const; + [[nodiscard]] QString password() const; + [[nodiscard]] bool savePassword() const; + +private: + QLineEdit *usernameEdit; + QLineEdit *passwordEdit; + QCheckBox *savePasswordCheckBox; +}; + +#endif // DLG_LOGIN_PROMPT_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp new file mode 100644 index 000000000..cd7ecdcdd --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp @@ -0,0 +1,181 @@ +#include "dlg_shared_decks_preview.h" + +#include "../deck_share/shared_deck_preview_widget.h" +#include "../general/layout_containers/flow_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &shareName, + qint64 expiresAt, + const QString &serverText, + const QList &items) + : QDialog(parent) +{ + setWindowTitle(tr("Open shared decks")); + resize(700, 500); + + auto *mainLayout = new QVBoxLayout(this); + + // shareName and serverText come from the share server, so escape them: the + // QLabels render AutoText and markup would otherwise be shown as rich text. + auto *titleLabel = + new QLabel(tr("Share: %1").arg((shareName.isEmpty() ? tr("Untitled") : shareName).toHtmlEscaped()), this); + QFont titleFont = titleLabel->font(); + titleFont.setBold(true); + titleFont.setPointSize(titleFont.pointSize() + 2); + titleLabel->setFont(titleFont); + mainLayout->addWidget(titleLabel); + + if (!serverText.isEmpty()) { + mainLayout->addWidget(new QLabel(tr("From %1").arg(serverText.toHtmlEscaped()), this)); + } + + if (expiresAt > 0) { + const QString expiryText = QDateTime::fromSecsSinceEpoch(expiresAt).toLocalTime().toString(Qt::TextDate); + mainLayout->addWidget(new QLabel(tr("This share link expires on %1").arg(expiryText), this)); + } + + downloadStatusLabel = new QLabel(this); + downloadStatusLabel->setVisible(false); + mainLayout->addWidget(downloadStatusLabel); + + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + mainLayout->addWidget(flowWidget, 1); + + for (const ServerInfo_DeckShareItem &item : items) { + QStringList tags; + for (const auto &tag : item.tags()) { + tags.append(QString::fromStdString(tag)); + } + + auto *tile = new SharedDeckPreviewWidget( + this, querier, QString::fromStdString(item.name()), QString::fromStdString(item.banner_card()), + QString::fromStdString(item.color_identity()), QString::fromStdString(item.game_format()), tags.join(", ")); + flowWidget->addNavigableWidget(tile); + tiles.append(tile); + itemIds.append(item.id()); + } + + if (tiles.size() == 1) { + tiles.first()->setSelected(true); + } + + auto *buttonBox = new QDialogButtonBox(this); + openSelectedButton = buttonBox->addButton(tr("Open selected"), QDialogButtonBox::AcceptRole); + openAllButton = buttonBox->addButton(tr("Open all"), QDialogButtonBox::ActionRole); + buttonBox->addButton(tr("Cancel"), QDialogButtonBox::RejectRole); + mainLayout->addWidget(buttonBox); + + connect(buttonBox, &QDialogButtonBox::rejected, this, [this]() { + onCancel(); + close(); + }); + + // Esc calls QDialog::reject() directly (which hides the dialog without a + // close event), so route it through the same guarded cancel as the button. + connect(this, &QDialog::rejected, this, [this]() { + onCancel(); + close(); + }); + + connect(openSelectedButton, &QPushButton::clicked, this, &DlgSharedDecksPreview::openSelected); + connect(buttonBox, &QDialogButtonBox::clicked, this, [this, buttonBox](QAbstractButton *button) { + if (buttonBox->buttonRole(button) == QDialogButtonBox::ActionRole) { + openAll(); + } + }); + + for (SharedDeckPreviewWidget *tile : tiles) { + connect(tile, &SharedDeckPreviewWidget::selectionToggled, this, + &DlgSharedDecksPreview::updateOpenSelectedEnabled); + } + for (int i = 0; i < tiles.size(); ++i) { + const int itemId = itemIds.at(i); + // Double-clicking a tile selects it and opens just that deck. + connect(tiles.at(i), &SharedDeckPreviewWidget::activated, this, [this, itemId]() { + resultEmitted = true; + setDownloading(true); + emit openRequested(QList{itemId}); + }); + } + updateOpenSelectedEnabled(); +} + +QList DlgSharedDecksPreview::selectedItemIds() const +{ + QList selectedIds; + for (int i = 0; i < tiles.size(); ++i) { + if (tiles.at(i)->isSelected()) { + selectedIds.append(itemIds.at(i)); + } + } + return selectedIds; +} + +void DlgSharedDecksPreview::openSelected() +{ + const QList selectedIds = selectedItemIds(); + if (selectedIds.isEmpty()) { + return; + } + resultEmitted = true; + setDownloading(true); + emit openRequested(selectedIds); +} + +void DlgSharedDecksPreview::openAll() +{ + resultEmitted = true; + setDownloading(true); + emit openRequested(itemIds); +} + +void DlgSharedDecksPreview::setDownloading(bool downloading) +{ + if (downloadInProgress == downloading) { + return; + } + downloadInProgress = downloading; + downloadStatusLabel->setVisible(downloading); + for (SharedDeckPreviewWidget *tile : tiles) { + tile->setEnabled(!downloading); + } + openSelectedButton->setEnabled(!downloading); + openAllButton->setEnabled(!downloading); +} + +void DlgSharedDecksPreview::setDownloadProgress(int done, int total, const QString ¤tDeckName) +{ + if (!downloadInProgress) { + return; + } + downloadStatusLabel->setText(tr("Downloading deck %1 of %2: %3").arg(done).arg(total).arg(currentDeckName)); +} + +void DlgSharedDecksPreview::updateOpenSelectedEnabled() +{ + openSelectedButton->setEnabled(!selectedItemIds().isEmpty()); +} + +void DlgSharedDecksPreview::onCancel() +{ + if (!resultEmitted || downloadInProgress) { + resultEmitted = true; + emit cancelled(); + } +} + +void DlgSharedDecksPreview::closeEvent(QCloseEvent *event) +{ + onCancel(); + QDialog::closeEvent(event); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h new file mode 100644 index 000000000..31820e3a0 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h @@ -0,0 +1,68 @@ +#ifndef COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H +#define COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H + +#include +#include + +class FlowWidget; +class QCloseEvent; +class QLabel; +class QPushButton; +class ServerInfo_DeckShareItem; +class SharedDeckPreviewWidget; +class CardDatabaseQuerier; + +/** + * @brief Non-modal preview of the decks contained in a shared-deck link. + * + * Lets the user pick which of the shared decks to open before anything is + * downloaded. Emits openRequested with the ids of the chosen decks, or + * cancelled when the user closes the dialog without choosing. Once the user + * picks, the dialog switches into a "downloading" state: the tiles and open + * buttons are disabled, a progress label shows the current download and Cancel + * stays functional so the download can be aborted. + */ +class DlgSharedDecksPreview : public QDialog +{ + Q_OBJECT + +public: + explicit DlgSharedDecksPreview(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &shareName, + qint64 expiresAt, + const QString &serverText, + const QList &items); + + void setDownloadProgress(int done, int total, const QString ¤tDeckName); + +public slots: + void setDownloading(bool downloading); + +signals: + void openRequested(const QList &itemIds); + void cancelled(); + +protected: + void closeEvent(QCloseEvent *event) override; + +private slots: + void openSelected(); + void openAll(); + void updateOpenSelectedEnabled(); + void onCancel(); + +private: + QList selectedItemIds() const; + + FlowWidget *flowWidget; + QList tiles; + QList itemIds; + QPushButton *openSelectedButton; + QPushButton *openAllButton; + QLabel *downloadStatusLabel; + bool resultEmitted = false; + bool downloadInProgress = false; +}; + +#endif // COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp index 025f457bd..01c9ac34e 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp @@ -7,6 +7,7 @@ #include "flow_widget.h" #include +#include #include #include #include @@ -80,13 +81,35 @@ FlowWidget::FlowWidget(QWidget *parent, /** * @brief Adds a widget to the flow layout within the FlowWidget. * + * Plain widgets are not filtered for arrow keys: intercepting them would steal + * Up/Down/Left/Right from controls that use them (combo boxes, spin boxes + * etc.). Widgets that want keyboard navigation between flow items must be + * added via addNavigableWidget instead. + * * @param widget_to_add The widget to add to the flow layout. */ -void FlowWidget::addWidget(QWidget *widget_to_add) const +void FlowWidget::addWidget(QWidget *widget_to_add) { flowLayout->addWidget(widget_to_add); } +/** + * @brief Adds a widget and routes its arrow keys to FlowWidget focus navigation. + * + * The widget is filtered for arrow-key events so keyboard navigation between + * the flow items keeps working even when the flow sits inside a QScrollArea, + * which swallows arrow keys before they can reach FlowWidget::keyPressEvent. + * Only widgets added through this method are affected; anything that needs its + * own arrow keys should use plain addWidget. + * + * @param widget_to_add The widget to add to the flow layout. + */ +void FlowWidget::addNavigableWidget(QWidget *widget_to_add) +{ + widget_to_add->installEventFilter(this); + flowLayout->addWidget(widget_to_add); +} + void FlowWidget::insertWidgetAtIndex(QWidget *toInsert, int index) { flowLayout->insertWidgetAtIndex(toInsert, index); @@ -177,6 +200,66 @@ QLayoutItem *FlowWidget::itemAt(int index) const return flowLayout->itemAt(index); } +void FlowWidget::keyPressEvent(QKeyEvent *event) +{ + if (moveFocus(event)) { + event->accept(); + return; + } + QWidget::keyPressEvent(event); +} + +bool FlowWidget::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::KeyPress && moveFocus(static_cast(event))) { + return true; + } + return QWidget::eventFilter(watched, event); +} + +bool FlowWidget::moveFocus(QKeyEvent *event) +{ + // Keyboard navigation between the flow items: arrow keys move focus just + // like clicking the sibling tiles would. Only items that can take keyboard + // focus (e.g. the deck-preview tiles in shared-deck links) are visited. + const bool moveForward = event->key() == Qt::Key_Right || event->key() == Qt::Key_Down; + const bool moveBackward = event->key() == Qt::Key_Left || event->key() == Qt::Key_Up; + if (!moveForward && !moveBackward) { + return false; + } + + QList focusableItems; + for (int i = 0; i < flowLayout->count(); ++i) { + QWidget *item = flowLayout->itemAt(i)->widget(); + if (item != nullptr && (item->focusPolicy() & Qt::TabFocus)) { + focusableItems.append(item); + } + } + + if (focusableItems.isEmpty()) { + return false; + } + + int currentIndex = -1; + for (int i = 0; i < focusableItems.size(); ++i) { + if (focusableItems.at(i)->hasFocus()) { + currentIndex = i; + break; + } + } + + const int delta = moveForward ? 1 : -1; + int nextIndex; + if (currentIndex < 0) { + nextIndex = moveForward ? 0 : focusableItems.size() - 1; + } else { + nextIndex = (currentIndex + delta + focusableItems.size()) % focusableItems.size(); + } + focusableItems.value(nextIndex)->setFocus(); + event->accept(); + return true; +} + int FlowWidget::count() const { return flowLayout->count(); diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h index a232336d8..4d52db3f1 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h @@ -11,6 +11,7 @@ #include "../../../layouts/flow_layout.h" #include +#include #include #include #include @@ -28,7 +29,8 @@ public: Qt::ScrollBarPolicy horizontalPolicy, Qt::ScrollBarPolicy verticalPolicy); - void addWidget(QWidget *widget_to_add) const; + void addWidget(QWidget *widget_to_add); + void addNavigableWidget(QWidget *widget_to_add); void insertWidgetAtIndex(QWidget *toInsert, int index); void removeWidget(QWidget *widgetToRemove) const; void clearLayout(); @@ -43,9 +45,15 @@ public slots: void setSpacing(int hSpacing, int vSpacing); protected: + bool eventFilter(QObject *watched, QEvent *event) override; void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; private: + /// @brief Moves keyboard focus to an adjacent flow item for an arrow-key event. + /// @return True when the event was an arrow key and was handled. + bool moveFocus(QKeyEvent *event); + Qt::Orientation flowDirection; QHBoxLayout *mainLayout; FlowLayout *flowLayout; diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 722df90ef..595d38d5b 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -512,6 +512,7 @@ MainWindow::MainWindow(QWidget *parent) connectionController = new ConnectionController(this, this); urlParser = new IntentUrlParser(this, this); + connect(urlParser, &IntentUrlParser::urlChainFinished, this, &MainWindow::onUrlChainFinished); createActions(); createMenus(); @@ -707,6 +708,12 @@ void MainWindow::applyStartupDestination() return; } + // A cockatrice:// link owns the startup connection while its chain runs; + // connecting here would race (and tear down) the link's own connection. + if (skipStartupAutoConnect) { + return; + } + const int destination = SettingsCache::instance().tabs().getStartupTabIndex(); if (destination != StartupTab::StartupTabServer && destination != StartupTab::StartupTabServerRoom) { return; @@ -728,6 +735,7 @@ void MainWindow::applyStartupDestination() connect(credentials, &Intent::finished, connector, &Intent::execute); connect(credentials, &Intent::failed, this, &MainWindow::startupDestinationFailed); + connect(credentials, &Intent::cancelled, this, [this]() { startupDestinationFailed(tr("Sign-in cancelled")); }); connect(connector, &Intent::finished, this, [this, destination, serverContext]() { onStartupDestinationConnected(destination, *serverContext); }); connect(connector, &Intent::failed, this, &MainWindow::startupDestinationFailed); @@ -879,18 +887,7 @@ void MainWindow::changeEvent(QEvent *event) } else if (event->type() == QEvent::ActivationChange) { if (isActiveWindow() && !bHasActivated) { bHasActivated = true; - if (!connectTo.isEmpty()) { - qCInfo(WindowMainStartupAutoconnectLog) << "Command line connect to " << connectTo; - connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(), - connectTo.password()); - } else if (SettingsCache::instance().servers().getAutoConnect() && - !SettingsCache::instance().debug().getLocalGameOnStartup() && - !startupDestinationConnectsToServer()) { - qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; - DlgConnect dlg(this); - connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), - dlg.getPlayerName(), dlg.getPassword()); - } + attemptStartupAutoConnect(); } } @@ -916,6 +913,59 @@ void MainWindow::handleCockatriceLink(const QString &url) urlParser->handle(url); } +void MainWindow::attemptStartupAutoConnect() +{ + if (startupAutoConnectAttempted || skipStartupAutoConnect) { + return; + } + startupAutoConnectAttempted = true; + + if (!connectTo.isEmpty()) { + qCInfo(WindowMainStartupAutoconnectLog) << "Command line connect to " << connectTo; + connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(), + connectTo.password()); + } else if (SettingsCache::instance().servers().getAutoConnect() && + !SettingsCache::instance().debug().getLocalGameOnStartup() && !startupDestinationConnectsToServer()) { + qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; + DlgConnect dlg(this); + connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), + dlg.getPlayerName(), dlg.getPassword()); + } +} + +void MainWindow::onUrlChainFinished(bool connected) +{ + // A cockatrice:// link owns the startup connection while it runs. When its + // chain ended without connecting (declined, invalid, offline), fall back to + // the startup connection so the activation launch still behaves like a + // normal launch. + if (connected) { + // The launch link connected, so the startup fallback has served its + // purpose: drop the skip so a later mid-session link that ends declined + // or offline cannot silently fire auto-connect or applyStartupDestination + // again. + skipStartupAutoConnect = false; + return; + } + + if (!skipStartupAutoConnect || getRemoteClient()->getStatus() != StatusDisconnected) { + return; + } + + if (startupDestinationConnectsToServer()) { + // Users whose startup tab is a Server / Server Room connect through the + // startup destination, not through auto-connect; retry that instead. + qCInfo(WindowMainStartupAutoconnectLog) << "URL chain ended without a connection; retrying startup destination"; + skipStartupAutoConnect = false; + applyStartupDestination(); + return; + } + + qCInfo(WindowMainStartupAutoconnectLog) << "URL chain ended without a connection; retrying startup connect"; + skipStartupAutoConnect = false; + attemptStartupAutoConnect(); +} + void MainWindow::cardDatabaseLoadingFailed() { if (askedForDbUpdater) { diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 08481fd36..9e45a4c3e 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -80,6 +80,7 @@ public slots: void actCheckClientUpdates(); void actConnect(); void actExit(); + void handleCockatriceLink(const QString &url); private slots: void updateTabMenu(const QList &newMenuList); void statusChanged(ClientStatus _status); @@ -97,7 +98,7 @@ private slots: void actOpenSettingsFolder(); void actShow(); void showWindowIfHidden(); - void handleCockatriceLink(const QString &url); + void onUrlChainFinished(bool connected); void cardUpdateError(QProcess::ProcessError err); void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus); @@ -126,6 +127,8 @@ private slots: void startupDestinationFailed(const QString &reason); [[nodiscard]] bool startupDestinationConnectsToServer() const; + void attemptStartupAutoConnect(); + private: static const QString appName; static const QStringList fileNameFilters; @@ -164,6 +167,8 @@ private: LagMonitor lagMonitor; ///< watches the main thread for event loop stalls LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph bool bHasActivated, askedForDbUpdater; + bool skipStartupAutoConnect = false; + bool startupAutoConnectAttempted = false; QProcess *cardUpdateProcess; QByteArray cardUpdateOutputBuffer; DlgViewLog *logviewDialog; @@ -177,6 +182,16 @@ public: { connectTo = QUrl(QString("cockatrice://%1").arg(url)); } + // When set, the window's own startup connection (--connect or auto-connect + // on first activation) is skipped. Used for activation launches: the intent + // chain triggered by a cockatrice:// URL owns the connection, and letting + // auto-connect race against it caused two connectToServer calls to tear + // each other down. onUrlChainFinished() clears this and retries the startup + // connection when the link's chain ended without connecting. + void setSkipStartupAutoConnect(bool skip) + { + skipStartupAutoConnect = skip; + } ~MainWindow() override; RemoteClient *getRemoteClient() const diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 829e08742..b9c30d1ad 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -26,7 +26,6 @@ #include "client/url_scheme_event_filter.h" #include "database/interface/settings_card_preference_provider.h" #include "interface/intents/intent_open_local_deck.h" -#include "interface/intents/url_parser.h" #include "interface/logger.h" #include "interface/pixel_map_generator.h" #include "interface/theme_manager.h" @@ -45,6 +44,8 @@ #include #include #include +#include +#include #include #include #include @@ -177,6 +178,18 @@ QString const generateClientID() return strClientID; } +static QString redactActivationUrl(const QString &url) +{ + // Activation URLs carry secrets in their query string (e.g. the deck share + // token); log only the scheme and the action (cockatrice://opendeck), never + // the parameters. + if (!url.startsWith(QStringLiteral("cockatrice://"))) { + return url; + } + const QUrl parsed(url); + return parsed.scheme() + "://" + parsed.host(); +} + int main(int argc, char *argv[]) { #ifdef Q_OS_WIN @@ -273,10 +286,17 @@ int main(int argc, char *argv[]) SingleInstanceManager instance; if (hasActivationFiles) { + QStringList redactedFiles; + redactedFiles.reserve(startupFiles.size()); + for (const QString &file : startupFiles) { + redactedFiles.append(redactActivationUrl(file)); + } + qCInfo(MainLog) << "Activation launch, files:" << redactedFiles; // Activation launch: hand off to the primary instance if one is // running, otherwise become the primary ourselves. Do this before // constructing the main window so a hand-off exits cheaply. if (!instance.tryRun(startupFiles)) { + qInfo() << "Handed off to a running instance, exiting"; // Sent successfully → exit return 0; } @@ -325,10 +345,30 @@ int main(int argc, char *argv[]) MainWindow ui; + // A URL launch must own the connection: the intent chain triggered by the + // URL connects to the server named in the URL, so the window's own startup + // auto-connect must not race against it (two connectToServer calls tear + // each other down via doDisconnectFromServer). + bool hasUrlActivation = std::any_of(startupFiles.begin(), startupFiles.end(), [](const QString &file) { + return file.startsWith(QStringLiteral("cockatrice://")); + }); +#ifdef Q_OS_MAC + // On macOS the launch can arrive through the URL scheme instead of as a + // positional argument (captured in pendingMacUrls); count those too or the + // window would auto-connect into the link's own connection attempt. + hasUrlActivation = hasUrlActivation || + std::any_of(pendingMacUrls.cbegin(), pendingMacUrls.cend(), + [](const QString &url) { return url.startsWith(QStringLiteral("cockatrice://")); }); +#endif + ui.setSkipStartupAutoConnect(hasUrlActivation); + auto handleActivation = [&ui](const QString &file) { if (file.startsWith("cockatrice://")) { - auto urlParser = new IntentUrlParser(&ui, &ui); - urlParser->handle(file); + qCInfo(MainLog) << "Handling URL activation:" << redactActivationUrl(file); + // Route through the window's persistent url parser: it serializes + // link chains so activations handed over while another chain is + // still connecting do not connect concurrently. + ui.handleCockatriceLink(file); } else if (QFileInfo(file).exists()) { auto openDeckIntent = new IntentOpenLocalDeck(ui.getTabSupervisor(), file); QObject::connect(openDeckIntent, &Intent::failed, &ui, [&ui](const QString &reason) { diff --git a/cockatrice/src/single_instance_manager.cpp b/cockatrice/src/single_instance_manager.cpp index aca23160c..eff0e6586 100644 --- a/cockatrice/src/single_instance_manager.cpp +++ b/cockatrice/src/single_instance_manager.cpp @@ -2,6 +2,14 @@ #include +namespace +{ +// Sent by the primary instance after it has read a forwarded payload. Without +// an acknowledgment, a second instance cannot tell a live primary apart from a +// stale socket left behind by a process that is still shutting down. +const QByteArray ACK_MESSAGE = QByteArrayLiteral("COCKATRICE_ACK"); +} // namespace + SingleInstanceManager::SingleInstanceManager(QObject *parent) : QObject(parent) { } @@ -20,9 +28,15 @@ bool SingleInstanceManager::tryRun(const QStringList &filesToSend) } serverName = QStringLiteral("CockatriceSingleInstance-%1").arg(userName); - // Hand off to an already-running primary instance if one exists. - if (forwardToPrimary(filesToSend)) { - return false; + // Hand off to an already-running primary instance if one exists. Never steal + // the socket of a busy primary: it is alive and will act on the payload. + switch (forwardToPrimary(filesToSend)) { + case ForwardResult::Delivered: + return false; + case ForwardResult::PrimaryBusy: + return false; + case ForwardResult::NoPrimary: + break; } // No primary instance is currently reachable, so become the primary. @@ -35,12 +49,18 @@ bool SingleInstanceManager::tryRun(const QStringList &filesToSend) // Another instance may have started while we were probing; hand off to it // instead of stealing its socket. - if (forwardToPrimary(filesToSend)) { - return false; + switch (forwardToPrimary(filesToSend)) { + case ForwardResult::Delivered: + return false; + case ForwardResult::PrimaryBusy: + return false; + case ForwardResult::NoPrimary: + break; } - // The socket is stale (left over by a crashed instance): remove it and - // retry. If that still fails, another instance just took the name. + // The socket is stale (left over by a crashed instance), so no primary is + // holding it: remove it and retry. If that still fails, another instance + // just took the name. QLocalServer::removeServer(serverName); if (server->listen(serverName)) { return true; @@ -50,12 +70,12 @@ bool SingleInstanceManager::tryRun(const QStringList &filesToSend) return false; } -bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) +SingleInstanceManager::ForwardResult SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) { QLocalSocket socket; socket.connectToServer(serverName); if (!socket.waitForConnected(200)) { - return false; + return ForwardResult::NoPrimary; } // Serialize payload with length prefix @@ -72,7 +92,23 @@ bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) socket.flush(); socket.waitForBytesWritten(1000); - return true; + // A plain launch has nothing for the primary to act on, so there is nothing + // to acknowledge. Waiting here would block the new instance for seconds if + // the primary is busy in a modal dialog, so only the activation path (which + // needs the ACK to avoid stealing a live primary's socket) waits below. + if (filesToSend.isEmpty()) { + return ForwardResult::Delivered; + } + + // Only report a successful hand-off once the primary has acknowledged that + // it actually read the payload. A socket that connects but is still working + // on an earlier payload is alive but busy, not dead: give it more room + // before giving up, so a slow handler does not make a live primary look + // dead (which would lead to stealing its socket). + if (!socket.waitForReadyRead(1000) && !socket.waitForReadyRead(4000)) { + return ForwardResult::PrimaryBusy; + } + return socket.readAll() == ACK_MESSAGE ? ForwardResult::Delivered : ForwardResult::PrimaryBusy; } void SingleInstanceManager::handleNewConnection() @@ -111,12 +147,23 @@ void SingleInstanceManager::handleNewConnection() QStringList files; payloadStream >> files; - emit filesReceived(files); + // Acknowledge receipt as soon as the payload is parsed, before the + // primary starts handling it. The handlers run synchronously and can + // take longer than the sender's readiness timeout (e.g. a modal + // confirmation box), which would otherwise make a live primary look + // dead and cause duplicate handling. + socket->write(ACK_MESSAGE); + socket->flush(); - // Reset buffer (single message use-case) + // Drop the payload from the buffer before handling it: the handlers + // run synchronously and can spin a nested event loop (e.g. a modal + // dialog) that re-reads this socket, which would re-parse and re-emit + // the same files. buffer->clear(); *expectedSize = 0; + emit filesReceived(files); + socket->disconnectFromServer(); return; } diff --git a/cockatrice/src/single_instance_manager.h b/cockatrice/src/single_instance_manager.h index 55bff0e80..d3884a60f 100644 --- a/cockatrice/src/single_instance_manager.h +++ b/cockatrice/src/single_instance_manager.h @@ -23,7 +23,14 @@ private slots: void handleNewConnection(); private: - bool forwardToPrimary(const QStringList &filesToSend); + enum class ForwardResult + { + Delivered, // a live primary acknowledged the payload + NoPrimary, // no connectable primary socket exists + PrimaryBusy // a primary exists but did not acknowledge in time + }; + + ForwardResult forwardToPrimary(const QStringList &filesToSend); QString serverName; QLocalServer *server = nullptr; diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp index 811b0c842..436e260c5 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp @@ -171,7 +171,12 @@ void ServersSettings::addNewServer(const QString &saveName, bool savePassword, const QString &site) { - if (updateExistingServer(saveName, serv, port, username, password, savePassword, site)) { + // Match the exact host-plus-port server the caller is adding, so a link or + // public-server list entry cannot clobber the port (and credentials) of an + // unrelated entry that happens to share the same hostname. + const int existingIndex = findServerIndex(serv, port); + if (existingIndex >= 0) { + updateServerFields(existingIndex, saveName, username, password, savePassword, site); return; } @@ -271,22 +276,7 @@ bool ServersSettings::updateExistingServer(QString saveName, for (int i = 0; i <= size; ++i) { if (serv == getValue(QString("server%1").arg(i), "server", "server_details").toString()) { setValue(port, QString("port%1").arg(i), "server", "server_details"); - if (!username.isEmpty()) { - setValue(username, QString("username%1").arg(i), "server", "server_details"); - } - - if (savePassword && !password.isEmpty()) { - setValue(password, QString("password%1").arg(i), "server", "server_details"); - } else { - setValue(QString(), QString("password%1").arg(i), "server", "server_details"); - } - - if (!site.isEmpty()) { - setValue(site, QString("site%1").arg(i), "server", "server_details"); - } - - setValue(savePassword, QString("savePassword%1").arg(i), "server", "server_details"); - setValue(saveName, QString("saveName%1").arg(i), "server", "server_details"); + updateServerFields(i, saveName, username, password, savePassword, site); return true; } @@ -294,6 +284,31 @@ bool ServersSettings::updateExistingServer(QString saveName, return false; } +void ServersSettings::updateServerFields(int index, + const QString &saveName, + const QString &username, + const QString &password, + bool savePassword, + const QString &site) +{ + if (!username.isEmpty()) { + setValue(username, QString("username%1").arg(index), "server", "server_details"); + } + + if (savePassword && !password.isEmpty()) { + setValue(password, QString("password%1").arg(index), "server", "server_details"); + } else { + setValue(QString(), QString("password%1").arg(index), "server", "server_details"); + } + + if (!site.isEmpty()) { + setValue(site, QString("site%1").arg(index), "server", "server_details"); + } + + setValue(savePassword, QString("savePassword%1").arg(index), "server", "server_details"); + setValue(saveName, QString("saveName%1").arg(index), "server", "server_details"); +} + int ServersSettings::findServerIndex(const QString &host, const QString &port) const { int size = getValue("totalServers", "server", "server_details").toInt(); @@ -310,6 +325,21 @@ int ServersSettings::findServerIndex(const QString &host, const QString &port) c return -1; } +int ServersSettings::findHostIndex(const QString &host) const +{ + int size = getValue("totalServers", "server", "server_details").toInt(); + + for (int i = 0; i <= size; ++i) { + QString storedHost = getValue(QString("server%1").arg(i), "server", "server_details").toString(); + + if (storedHost.compare(host, Qt::CaseInsensitive) == 0) { + return i; + } + } + + return -1; +} + bool ServersSettings::hasUsername(const QString &host, const QString &port) const { int index = findServerIndex(host, port); diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.h b/libcockatrice_settings/libcockatrice/settings/servers_settings.h index f9803a158..93651c813 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.h @@ -61,7 +61,14 @@ public: QString password, bool savePassword, QString site = QString()); + void updateServerFields(int index, + const QString &saveName, + const QString &username, + const QString &password, + bool savePassword, + const QString &site); int findServerIndex(const QString &host, const QString &port) const; + int findHostIndex(const QString &host) const; bool hasUsername(const QString &host, const QString &port) const; bool hasCredentials(const QString &host, const QString &port) const; bool hasLoginData(const QString &host, const QString &port) const;