[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

@ -2,6 +2,14 @@
#include <QDir>
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;
}