[DeckShare] Create temporary share links for local and server decks (#7243)

* [DeckShare] Create temporary share links for local and server decks

* [DeckShare] Address review findings and harden the share flows

Gate every share entry point on login, de-duplicate the share-link and
color-identity logic behind DeckShareUtils and an injected querier, and
replace the silent tray/status-bar notices with always-visible dialogs.

- abstract_tab_deck_editor: explain that sharing requires a connection
  instead of silently doing nothing when logged out
- tab_deck_storage: disable the share action on disconnect, reject
  folder/deck mixes and the root folder with clear warnings, re-enable
  Create on every entry/response so a dropped connection cannot leave
  the button disabled
- tab_deck_storage_visual: same login gate for the context-menu entry,
  visible success/error dialogs, and a symmetric in-flight guard
- getDeckColorIdentity now takes a CardDatabaseQuerier, dropping the
  CardDatabaseManager singleton access and enabling unit tests

* [DeckShare] Fix share-link expiry build on the minimum-supported Qt

QTimeZone::UTC (the Initialization enum) only exists since Qt 6.7, so
Debian 12 and Ubuntu 24.04 (Qt 6.4) fail to compile the share-link expiry
handling in the share dialog and the two deck-storage tabs. Mirror the
existing games_model guard and fall back to Qt::UTC on older Qt.

* [DeckShare] Use the stable server client for the visual deck storage tab

* [DeckShare] Extract the share-creation response handling into DeckShareUtils

* [DeckShare] Drop includes left unused by the share-response extraction

* [DeckShare] Format share expiry with the locale-aware short format

* [DeckShare] Build share links with QUrl and QUrlQuery for percent-encoding

* [DeckShare] Replace the duplicate computeColorIdentity with the shared getDeckColorIdentity

* [DeckShare] Recover the share controls when the server never answers

* [DeckShare] Provide the full share hint in each plural form

* [DeckShare] Join the selected-count label with a non-translatable separator

* [DeckShare] Retranslate the share button tooltip with the storage widget

* [DeckShare] Forward retranslateUi to the visual deck storage widget

* [DeckShare] Let the share bar owners supply the hint text

* [DeckShare] End the share-related headers and sources with a trailing newline

* [DeckShare] Keep the settings include in the project include block

* [DeckShare] Include the network settings header used by the share timeout

* [DeckShare] Resolve the share theme icon through themePixmap

QPixmap("theme:icons/share") has no file extension, so ThemeManager::assetPath()
is bypassed and the pixmap is always null. Use themePixmap(QStringLiteral("icons/share"))
like every other toolbar action, so the .svg (and dark/light variants) resolves.

* [DeckShare] Keep the share selection consistent with the visible decks

Filtered-out previews are hidden but kept alive, so selectedFilePaths() counted
them in the share and the selection highlight. Only decks the user can see are
now shared, and a deck that stops matching the filters is deselectd as the deck
pass runs, keeping the %n count and the highlight in sync with the screen.

* [DeckShare] Abandon an in-flight tree share on cancel

Leaving share mode never stopped the timeout timer, and a late response still
ran shareFromTreeFinished, copying the link and announcing success for a share
the user backed out of. Stopping the timer and tracking the outstanding request
by sequence number means a stale reply (or a timed-out one) after cancel is
ignored, and cancelling + re-entering share mode can no longer confuse the two
requests.

* [DeckShare] Abandon an in-flight tile share on cancel

exitShareMode() left shareTimeoutTimer running and did not abandon the pending
Command_DeckShareCreate, so a timer pop or a late success still reported the
share after the user cancelled. Stop the timer and ignore stale responses via a
sequence number, mirroring the tree tab.

* [DeckShare] Wire the status-changed handler after shareBar exists

handleConnectionChanged() dereferences shareBar->isVisible(), but the connection
was set up before shareBar was constructed and shareBar had no in-class
initializer. On any status change delivered before construction the slot read an
indeterminate pointer. Seed the connection (and the initial share availability)
after shareBar exists and give shareBar a = nullptr initializer.

* [DeckShare] Explain why a blank deck cannot be shared

A blank deck exited the share flow silently. The menu only disables the entry
via setSaveStatus(), a different predicate, so the path is reachable (e.g. add a
card and remove it again). Mirror the not-logged-in branch with a short
information dialog.

* [DeckShare] Restore the banner-text doc comment

Re-add the doc block above refreshBannerCardText() that was removed as part of
the share-selection work; it documents the coupling to refreshBannerCardToolTip.

* [DeckShare] Resolve the stable server client in the deck editor gate

actShareDeck went through tabSupervisor->getClient(), which hands back a
LocalClient while an offline game is running. LocalClient never sets its status,
so a logged-in user could not share from the deck editor during a local game,
and got a misleading "You must be connected" message. Expose the supervisor's
stable remote client and use it for the gate and the dialog, matching the other
share tabs.

---------

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

View file

@ -0,0 +1,59 @@
#include "deck_share_utils.h"
#include <QClipboard>
#include <QGuiApplication>
#include <QLocale>
#include <QTimeZone>
#include <QUrl>
#include <QUrlQuery>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
namespace DeckShareUtils
{
QString buildShareLink(const AbstractClient *client, const QString &token)
{
QUrl url;
url.setScheme(QStringLiteral("cockatrice"));
url.setHost(QStringLiteral("opendeck"));
QUrlQuery query;
query.addQueryItem(QStringLiteral("share"), token);
query.addQueryItem(QStringLiteral("hostname"), client->serverName());
query.addQueryItem(QStringLiteral("port"), QString::number(client->serverPort()));
url.setQuery(query);
return url.toString(QUrl::FullyEncoded);
}
QString copyShareLinkToClipboard(const QString &link)
{
QGuiApplication::clipboard()->setText(link);
return link;
}
QString formatShareExpiry(const QDateTime &expiry)
{
return QLocale().toString(expiry.toLocalTime(), QLocale::ShortFormat);
}
ShareResponse handleShareResponse(const AbstractClient *client, const Response &response)
{
const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext);
const QString token = QString::fromStdString(resp.token());
const QString link = buildShareLink(client, token);
copyShareLinkToClipboard(link);
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC);
#else
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), Qt::UTC);
#endif
return {link, expiry};
}
} // namespace DeckShareUtils

View file

@ -0,0 +1,59 @@
/**
* @file deck_share_utils.h
* @ingroup DeckShareWidgets
*/
//! \todo Document this file.
#ifndef DECK_SHARE_UTILS_H
#define DECK_SHARE_UTILS_H
#include <QDateTime>
#include <QString>
class AbstractClient;
class Response;
/**
* @brief Shared helpers for creating temporary deck shares.
*/
namespace DeckShareUtils
{
/**
* @brief The outcome of a successful share-create response.
*/
struct ShareResponse
{
QString link; ///< The share link that was copied to the clipboard.
QDateTime expiry; ///< When the share expires (UTC).
};
/**
* @brief Builds the cockatrice:// link for a freshly created deck share.
* @param client Used to embed the target server's hostname and port.
* @param token The share token from Response_DeckShareCreate.
*/
QString buildShareLink(const AbstractClient *client, const QString &token);
/**
* @brief Copies the share link to the clipboard.
* @return The link that was copied.
*/
QString copyShareLinkToClipboard(const QString &link);
/**
* @brief Formats the expiration timestamp for a share.
*/
QString formatShareExpiry(const QDateTime &expiry);
/**
* @brief Handles a successful Response_DeckShareCreate: builds the share link,
* copies it to the clipboard, and derives the share expiry.
* @param client Used to embed the target server's hostname and port.
* @param response The successful response carrying the share token and expiry.
*/
ShareResponse handleShareResponse(const AbstractClient *client, const Response &response);
} // namespace DeckShareUtils
#endif // DECK_SHARE_UTILS_H

View file

@ -0,0 +1,77 @@
#include "share_bar_widget.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
ShareBarWidget::ShareBarWidget(QWidget *parent) : QWidget(parent)
{
auto *layout = new QHBoxLayout(this);
layout->setContentsMargins(12, 10, 12, 10);
layout->setSpacing(8);
hintLabel = new QLabel(this);
hintLabel->setWordWrap(true);
nameEdit = new QLineEdit(this);
nameEdit->setMaximumWidth(260);
countLabel = new QLabel(this);
cancelButton = new QPushButton(this);
connect(cancelButton, &QPushButton::clicked, this, &ShareBarWidget::cancelRequested);
createButton = new QPushButton(this);
createButton->setDefault(true);
connect(createButton, &QPushButton::clicked, this, &ShareBarWidget::createRequested);
layout->addWidget(hintLabel, 1);
layout->addWidget(nameEdit);
layout->addWidget(countLabel);
layout->addStretch();
layout->addWidget(cancelButton);
layout->addWidget(createButton);
setLayout(layout);
retranslateUi();
}
void ShareBarWidget::retranslateUi()
{
nameEdit->setPlaceholderText(tr("Share name"));
cancelButton->setText(tr("Cancel"));
createButton->setText(tr("Create share link"));
}
QString ShareBarWidget::name() const
{
return nameEdit->text().trimmed();
}
void ShareBarWidget::setName(const QString &value)
{
nameEdit->setText(value);
}
void ShareBarWidget::setCountText(const QString &text)
{
countLabel->setText(text);
}
void ShareBarWidget::setHintText(const QString &text, bool visible)
{
hintLabel->setText(text);
hintLabel->setVisible(visible);
}
void ShareBarWidget::setCreateEnabled(bool enabled)
{
createButton->setEnabled(enabled);
}
void ShareBarWidget::focusName()
{
nameEdit->setFocus();
}

View file

@ -0,0 +1,63 @@
/**
* @file share_bar_widget.h
* @ingroup DeckShareWidgets
*/
//! \todo Document this file.
#ifndef SHARE_BAR_WIDGET_H
#define SHARE_BAR_WIDGET_H
#include <QWidget>
class QLabel;
class QLineEdit;
class QPushButton;
/**
* @brief The activated toolbar used to create a temporary deck share.
*
* A single reusable component shared by the local visual deck storage and the
* remote server deck storage tabs, so the share workflow renders identically in
* both places. It owns its own widgets, strings, and layout; the owning tab only
* sets the count/hint text and reacts to the create/cancel signals.
*/
class ShareBarWidget final : public QWidget
{
Q_OBJECT
public:
explicit ShareBarWidget(QWidget *parent = nullptr);
void retranslateUi();
/** @return The trimmed name entered by the user. */
[[nodiscard]] QString name() const;
/** @brief Resets the name field to the given default. */
void setName(const QString &name);
/** @brief Sets the selected-count summary label text. */
void setCountText(const QString &text);
/** @brief Sets the explainer hint text, showing it when @p visible is true. */
void setHintText(const QString &text, bool visible);
/** @brief Enables or disables the create-share-link button (guards double submission). */
void setCreateEnabled(bool enabled);
/** @brief Moves keyboard focus to the name field. */
void focusName();
signals:
void createRequested();
void cancelRequested();
private:
QLabel *hintLabel;
QLineEdit *nameEdit;
QLabel *countLabel;
QPushButton *cancelButton;
QPushButton *createButton;
};
#endif // SHARE_BAR_WIDGET_H