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

* [DeckShare] Open shared decks via links with a gated preview flow

- Serialized url-chain dispatcher in IntentUrlParser; queue-drained
  urlChainFinished(bool) drives the startup auto-connect fallback
- Open-shared-deck intent with sequential download state machine,
  15s per-item timeout, partial-success offer, livable Cancel via
  ApplicationModal dlg_login_prompt interactive fallback
- Preview dialog: download progress label, share vocab sweep,
  palette-highlight selection frame, Space/Enter keyboard toggle,
  NoFocus checkbox, double-click tile opens immediately
- Confirm-before-server-migration with one-shot restore to the
  previous server on failed/cancelled chains (statusChanged settle
  deferral), hostname-only identity comparisons
- Skip credential link when already connected; arrow-key navigation
  in FlowWidget; card glows use palette highlight
- Address code-review M1-M4 and UI/UX QA blockers 1-2

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

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

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

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

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

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

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

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

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

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

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

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

* [Client] Redact share secrets from activation URL logs

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

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

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

---------

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

View file

@ -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<unsigned int>(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<unsigned int>(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) {