From d18e28a65ba5d7cf19a193d0da8b52bfcc9979eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Fri, 4 Sep 2026 22:41:44 +0200 Subject: [PATCH 1/6] [Server] Add deck share links and public deck visibility --- .../libcockatrice/protocol/pb/CMakeLists.txt | 10 + .../pb/command_deck_download_public.proto | 9 + .../pb/command_deck_list_other_user.proto | 9 + .../pb/command_deck_set_visibility.proto | 13 + .../pb/command_deck_share_create.proto | 24 + .../pb/command_deck_share_download.proto | 10 + .../protocol/pb/command_deck_share_list.proto | 9 + .../protocol/pb/command_deck_upload.proto | 8 + .../libcockatrice/protocol/pb/response.proto | 3 + .../pb/response_deck_share_create.proto | 11 + .../pb/response_deck_share_download.proto | 9 + .../pb/response_deck_share_list.proto | 12 + .../pb/serverinfo_deck_share_item.proto | 10 + .../protocol/pb/serverinfo_deckstorage.proto | 12 + .../protocol/pb/session_commands.proto | 6 + .../migrations/servatrice_0036_to_0037.sql | 71 +++ servatrice/servatrice.ini.example | 16 + servatrice/servatrice.sql | 42 +- servatrice/src/servatrice.cpp | 29 ++ servatrice/src/servatrice.h | 5 + .../src/servatrice_database_interface.cpp | 122 +++++ .../src/servatrice_database_interface.h | 33 +- servatrice/src/serversocketinterface.cpp | 425 +++++++++++++++++- servatrice/src/serversocketinterface.h | 20 +- 24 files changed, 899 insertions(+), 19 deletions(-) create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto create mode 100644 servatrice/migrations/servatrice_0036_to_0037.sql diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index f22828f46..9df7291a8 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -15,9 +15,15 @@ set(PROTO_FILES command_deck_del.proto command_deck_del_dir.proto command_deck_download.proto + command_deck_download_public.proto command_deck_list.proto + command_deck_list_other_user.proto command_deck_new_dir.proto command_deck_select.proto + command_deck_set_visibility.proto + command_deck_share_create.proto + command_deck_share_download.proto + command_deck_share_list.proto command_deck_upload.proto command_del_counter.proto command_delete_arrow.proto @@ -135,6 +141,9 @@ set(PROTO_FILES response_card_art_rule_entry.proto response_deck_download.proto response_deck_list.proto + response_deck_share_create.proto + response_deck_share_download.proto + response_deck_share_list.proto response_deck_upload.proto response_dump_zone.proto response_forgotpasswordrequest.proto @@ -172,6 +181,7 @@ set(PROTO_FILES serverinfo_cardcounter.proto serverinfo_chat_message.proto serverinfo_counter.proto + serverinfo_deck_share_item.proto serverinfo_deckstorage.proto serverinfo_game.proto serverinfo_gametype.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto new file mode 100644 index 000000000..a5592ef51 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckDownloadPublic { + extend SessionCommand { + optional Command_DeckDownloadPublic ext = 1031; + } + optional uint32 deck_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto new file mode 100644 index 000000000..2459608e2 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckListOtherUser { + extend SessionCommand { + optional Command_DeckListOtherUser ext = 1029; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto new file mode 100644 index 000000000..3ada87973 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto @@ -0,0 +1,13 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckSetVisibility { + extend SessionCommand { + optional Command_DeckSetVisibility ext = 1030; + } + // Set the public visibility of a single deck (mutually exclusive with folder_path). + optional uint32 deck_id = 1; + // Set the public visibility of a folder (all decks under it inherit). + optional string folder_path = 2; + optional bool is_public = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto new file mode 100644 index 000000000..9cebf8eae --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto @@ -0,0 +1,24 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message DeckShareItem { + // Reference an existing deck in the sharer's personal deck storage. + // Mutually exclusive with deck_list. + optional uint32 deck_id = 1; + // Inline deck content in the native format. + // Mutually exclusive with deck_id. + optional string deck_list = 2; + // Color identity of the deck (e.g. "WUBRG"), computed by the sharing client. + optional string color_identity = 3; +} + +message Command_DeckShareCreate { + extend SessionCommand { + optional Command_DeckShareCreate ext = 1026; + } + optional string name = 1; + repeated DeckShareItem items = 2; + // Path of a folder in the sharer's personal deck storage. When set, all + // decks in that folder are shared (resolved by the server). + optional string folder_path = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto new file mode 100644 index 000000000..251a662b3 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckShareDownload { + extend SessionCommand { + optional Command_DeckShareDownload ext = 1028; + } + optional string token = 1; + optional uint32 item_id = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto new file mode 100644 index 000000000..b75fd65f9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckShareList { + extend SessionCommand { + optional Command_DeckShareList ext = 1027; + } + optional string token = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto index 63d9c80ef..a79ac35c2 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto @@ -8,4 +8,12 @@ message Command_DeckUpload { optional string path = 1; // to upload a new deck optional uint32 deck_id = 2; // to replace an existing deck optional string deck_list = 3; + optional bool is_public = 4; // mark the deck public on upload (publish) + // Preview metadata computed by the uploading client (see ServerInfo_DeckStorage_File). + optional string banner_card_name = 5; + optional string banner_card_provider = 6; + optional string color_identity = 7; + // Comma-separated list of tag names associated with the deck, used to render + // and filter another user's public decks on the client. + optional string tags = 8; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index 14ba737b5..09f6e5c7b 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -80,6 +80,9 @@ message Response { REPLAY_LIST = 1100; // Response listing replays REPLAY_DOWNLOAD = 1101; // Response for replay download REPLAY_GET_CODE = 1102; // Response containing replay code + DECK_SHARE_CREATE = 1103; // Response to deck share creation + DECK_SHARE_LIST = 1104; // Response listing shared decks + DECK_SHARE_DOWNLOAD = 1105; // Response for shared deck download CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto new file mode 100644 index 000000000..574c02eb6 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_DeckShareCreate { + extend Response { + optional Response_DeckShareCreate ext = 1103; + } + optional string token = 1; + optional uint64 expires_at = 2; + optional uint32 item_count = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto new file mode 100644 index 000000000..def0ccbe6 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_DeckShareDownload { + extend Response { + optional Response_DeckShareDownload ext = 1105; + } + optional string deck = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto new file mode 100644 index 000000000..3edffa8ac --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto @@ -0,0 +1,12 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_deck_share_item.proto"; + +message Response_DeckShareList { + extend Response { + optional Response_DeckShareList ext = 1104; + } + optional string name = 1; + optional uint64 expires_at = 2; + repeated ServerInfo_DeckShareItem items = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto new file mode 100644 index 000000000..bac025419 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; + +message ServerInfo_DeckShareItem { + optional uint32 id = 1; + optional string name = 2; + repeated string tags = 3; + optional string banner_card = 4; + optional string game_format = 5; + optional string color_identity = 6; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto index 16e3f28e3..a77dc9484 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto @@ -1,10 +1,22 @@ syntax = "proto2"; message ServerInfo_DeckStorage_File { optional uint32 creation_time = 1; + optional bool is_public = 2; + // Preview metadata computed by the uploading client, so other clients can + // render this deck (e.g. in a visual storage grid) without downloading the + // full deck list. Empty for decks uploaded before the metadata columns. + optional string banner_card_name = 3; + optional string banner_card_provider = 4; + optional string color_identity = 5; + // Comma-separated list of tag names, matching the corresponding + // ServerInfo_DeckStorage_File upload metadata. Empty for decks uploaded + // before the tags column existed. + optional string tags = 6; } message ServerInfo_DeckStorage_Folder { repeated ServerInfo_DeckStorage_TreeItem items = 1; + optional bool is_public = 2; } message ServerInfo_DeckStorage_TreeItem { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index fee8c36a8..c4b47f10f 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -28,6 +28,12 @@ message SessionCommand { FORGOT_PASSWORD_CHALLENGE = 1023; REQUEST_PASSWORD_SALT = 1024; SET_CARD_ART_PARAMS = 1025; + DECK_SHARE_CREATE = 1026; + DECK_SHARE_LIST = 1027; + DECK_SHARE_DOWNLOAD = 1028; + DECK_LIST_OTHER_USER = 1029; + DECK_SET_VISIBILITY = 1030; + DECK_DOWNLOAD_PUBLIC = 1031; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; diff --git a/servatrice/migrations/servatrice_0036_to_0037.sql b/servatrice/migrations/servatrice_0036_to_0037.sql new file mode 100644 index 000000000..3abc14fed --- /dev/null +++ b/servatrice/migrations/servatrice_0036_to_0037.sql @@ -0,0 +1,71 @@ +-- Servatrice db migration from version 36 to version 37 + +-- Deck sharing (temporary share links + permanent public decks). +-- +-- This feature was developed behind several intermediate migrations that have +-- never shipped, so they are folded into this single 36 -> 37 migration: +-- temporary share links, permanent public-deck visibility, preview metadata, +-- and per-deck tags. + +-- 1. Temporary deck shares: a named bundle of decks that can be fetched by +-- anyone who knows the (unguessable) token, until the share expires. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `token` varchar(64) NOT NULL, + `name` varchar(64) NOT NULL, + `created_by` int(7) unsigned NULL, + `created_at` datetime NOT NULL, + `expires_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token` (`token`), + KEY `expires_at` (`expires_at`), + FOREIGN KEY(`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +-- Individual decks inside a share bundle. Content is materialized at share +-- time so expiring/deleting a share can cascade cleanly. The metadata columns +-- are nullable because Qt's MySQL driver binds empty QStrings as NULL when +-- using prepared statements. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share_item` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `share_id` int(7) unsigned zerofill NOT NULL, + `name` varchar(50) NOT NULL, + `tags` text NULL, + `banner_card` varchar(255) NULL, + `game_format` varchar(50) NULL, + `color_identity` varchar(5) NULL, + `content` text NOT NULL, + `position` int(7) NOT NULL, + PRIMARY KEY (`id`), + KEY `share_id` (`share_id`), + FOREIGN KEY(`share_id`) REFERENCES `cockatrice_deck_share`(`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +-- 2. Permanent deck sharing: add public visibility flags to the deck storage +-- tables. A deck is visible to other users if it is marked public, or if any +-- ancestor folder is marked public (inherited). Existing decks default to +-- private, so the upgrade does not expose any data. +ALTER TABLE `cockatrice_decklist_files` + ADD COLUMN `is_public` tinyint(1) NOT NULL DEFAULT 0 AFTER `content`; + +ALTER TABLE `cockatrice_decklist_folders` + ADD COLUMN `is_public` tinyint(1) NOT NULL DEFAULT 0 AFTER `name`; + +-- 3. Per-deck preview metadata so clients can render another user's public +-- decks (e.g. in a visual deck storage grid) without downloading each deck +-- list. The metadata is computed by the uploading client; decks uploaded +-- before this migration have empty values until they are re-uploaded. +ALTER TABLE `cockatrice_decklist_files` + ADD COLUMN `banner_card_name` varchar(255) NULL AFTER `content`, + ADD COLUMN `banner_card_provider` varchar(32) NULL AFTER `banner_card_name`, + ADD COLUMN `color_identity` varchar(5) NULL AFTER `banner_card_provider`; + +-- 4. Per-deck tags for public decks. The uploading client sends a +-- comma-separated tag string (matching the deck's own tags), so another user's +-- public decks can render and filter by tag without downloading each deck list. +-- Decks uploaded before this migration have NULL tags until they are +-- re-uploaded. +ALTER TABLE `cockatrice_decklist_files` + ADD COLUMN `tags` text NULL AFTER `color_identity`; + +UPDATE cockatrice_schema_version SET version=37 WHERE version=36; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index c1940c22f..3e94e5790 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -439,3 +439,19 @@ ssl_cert=ssl_cert.pem ; Filename of the private key for the server-to-server certificate ssl_key=ssl_key.pem + + +[deck_share] + +; How many days a created deck share link remains valid before it expires. +; Default: 7 +expiry_days=7 + +; How often (in minutes) the server checks for and removes expired deck shares. +; A value of 0 disables the automatic cleanup. +; Default: 60 +cleanup_interval=60 + +; Maximum number of decks a single share link can contain. +; Default: 50 +max_decks_per_share=50 diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 5dbf69cbc..8baabf3bd 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` ( PRIMARY KEY (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; -INSERT INTO cockatrice_schema_version VALUES(36); +INSERT INTO cockatrice_schema_version VALUES(37); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( @@ -63,16 +63,56 @@ CREATE TABLE IF NOT EXISTS `cockatrice_decklist_files` ( `name` varchar(50) NOT NULL, `upload_time` datetime NOT NULL, `content` text NOT NULL, + `is_public` tinyint(1) NOT NULL DEFAULT 0, + `banner_card_name` varchar(255) NULL, + `banner_card_provider` varchar(32) NULL, + `color_identity` varchar(5) NULL, + `tags` text NULL, PRIMARY KEY (`id`), KEY `FolderPlusUser` (`id_folder`,`id_user`), FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; +-- Temporary deck shares: a named bundle of decks that can be fetched by +-- anyone who knows the (unguessable) token, until the share expires. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `token` varchar(64) NOT NULL, + `name` varchar(64) NOT NULL, + `created_by` int(7) unsigned NULL, + `created_at` datetime NOT NULL, + `expires_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token` (`token`), + KEY `expires_at` (`expires_at`), + FOREIGN KEY(`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +-- Individual decks inside a share bundle. Content is materialized at share +-- time so expiring/deleting a share can cascade cleanly. The metadata columns +-- are nullable because Qt's MySQL driver binds empty QStrings as NULL when +-- using prepared statements. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share_item` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `share_id` int(7) unsigned zerofill NOT NULL, + `name` varchar(50) NOT NULL, + `tags` text NULL, + `banner_card` varchar(255) NULL, + `game_format` varchar(50) NULL, + `color_identity` varchar(5) NULL, + `content` text NOT NULL, + `position` int(7) NOT NULL, + PRIMARY KEY (`id`), + KEY `share_id` (`share_id`), + FOREIGN KEY(`share_id`) REFERENCES `cockatrice_deck_share`(`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `cockatrice_decklist_folders` ( `id` int(7) unsigned zerofill NOT NULL auto_increment, `id_parent` int(7) unsigned zerofill NOT NULL, `id_user` int(7) unsigned NULL, `name` varchar(30) NOT NULL, + `is_public` tinyint(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `ParentPlusUser` (`id_parent`,`id_user`), FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index db8751658..0c1d6f18c 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -428,6 +428,14 @@ bool Servatrice::initServer() statusUpdateClock->start(getServerStatusUpdateTime()); } + deckShareCleanupClock = new QTimer(this); + connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares())); + const int deckShareCleanupInterval = getDeckShareCleanupInterval(); + if (deckShareCleanupInterval > 0) { + qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms"; + deckShareCleanupClock->start(deckShareCleanupInterval); + } + // SOCKET SERVER if (getNumberOfTCPPools() > 0) { gameServer = @@ -600,6 +608,11 @@ void Servatrice::setRequiredFeatures(const QString &featureList) qDebug() << "Set required client features to:" << serverRequiredFeatureList; } +void Servatrice::cleanupExpiredDeckShares() +{ + servatriceDatabaseInterface->cleanupExpiredDeckShares(); +} + void Servatrice::statusUpdate() { if (!servatriceDatabaseInterface->checkSql()) { @@ -1012,6 +1025,22 @@ int Servatrice::getServerStatusUpdateTime() const return settingsCache->value("server/statusupdate", 15000).toInt(); } +int Servatrice::getDeckShareExpiryDays() const +{ + return settingsCache->value("deck_share/expiry_days", 7).toInt(); +} + +int Servatrice::getDeckShareCleanupInterval() const +{ + // default: every 60 minutes + return settingsCache->value("deck_share/cleanup_interval", 60).toInt() * 60000; +} + +int Servatrice::getDeckShareMaxDecksPerShare() const +{ + return settingsCache->value("deck_share/max_decks_per_share", 50).toInt(); +} + int Servatrice::getNumberOfTCPPools() const { return settingsCache->value("server/number_pools", 1).toInt(); diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 8b0f5ad60..f4b4146a4 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -143,6 +143,7 @@ public: private slots: void statusUpdate(); void shutdownTimeout(); + void cleanupExpiredDeckShares(); protected: void doSendIslMessage(const IslMessage &msg, int _serverId) override; @@ -156,6 +157,7 @@ private: AuthenticationMethod authenticationMethod; DatabaseType databaseType; QTimer *pingClock, *statusUpdateClock; + QTimer *deckShareCleanupClock; Servatrice_GameServer *gameServer; Servatrice_WebsocketGameServer *websocketGameServer; Servatrice_IslServer *islServer; @@ -267,6 +269,9 @@ public: int getMaxGameInactivityTime() const override; int getMaxPlayerInactivityTime() const override; int getClientKeepAlive() const override; + int getDeckShareExpiryDays() const; + int getDeckShareCleanupInterval() const; + int getDeckShareMaxDecksPerShare() const; int getMaxUsersPerAddress() const; int getMessageCountingInterval() const override; int getMaxMessageCountPerInterval() const override; diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 847be61da..28c7ff2aa 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -1026,6 +1027,127 @@ DeckList *Servatrice_DatabaseInterface::getDeckFromDatabase(int deckId, int user return deck; } +bool Servatrice_DatabaseInterface::createDeckShare(const QString &token, + const QString &name, + int userId, + const QList &items, + int expiryDays) +{ + checkSql(); + + if (items.isEmpty()) { + return false; + } + + sqlDatabase.transaction(); + + QSqlQuery *query = prepareQuery("insert into {prefix}_deck_share (token, name, created_by, created_at, expires_at) " + "values (:token, :name, :created_by, NOW(), DATE_ADD(NOW(), INTERVAL :days DAY))"); + query->bindValue(":token", token); + query->bindValue(":name", name); + query->bindValue(":created_by", userId < 1 ? QVariant() : userId); + query->bindValue(":days", expiryDays); + if (!execSqlQuery(query)) { + sqlDatabase.rollback(); + return false; + } + + const int shareId = query->lastInsertId().toInt(); + for (int i = 0; i < items.size(); ++i) { + const DeckShareItemRecord &item = items.at(i); + QSqlQuery *itemQuery = prepareQuery("insert into {prefix}_deck_share_item (share_id, name, tags, banner_card, " + "game_format, color_identity, content, position) values (:share_id, :name, " + ":tags, :banner_card, :game_format, :color_identity, :content, :position)"); + itemQuery->bindValue(":share_id", shareId); + itemQuery->bindValue(":name", item.name); + QJsonArray tagArray; + for (const QString &tag : item.tags) { + tagArray.append(tag); + } + itemQuery->bindValue(":tags", QString::fromUtf8(QJsonDocument(tagArray).toJson(QJsonDocument::Compact))); + itemQuery->bindValue(":banner_card", item.bannerCard); + itemQuery->bindValue(":game_format", item.gameFormat); + itemQuery->bindValue(":color_identity", item.colorIdentity); + itemQuery->bindValue(":content", item.content); + itemQuery->bindValue(":position", i); + if (!execSqlQuery(itemQuery)) { + sqlDatabase.rollback(); + return false; + } + } + + sqlDatabase.commit(); + return true; +} + +bool Servatrice_DatabaseInterface::getDeckShareList(const QString &token, + QString &name, + qint64 &expiresAt, + QList &items) +{ + checkSql(); + + QSqlQuery *query = + prepareQuery("select id, name, UNIX_TIMESTAMP(expires_at) from {prefix}_deck_share where token = " + ":token and expires_at > now()"); + query->bindValue(":token", token); + execSqlQuery(query); + if (!query->next()) { + return false; + } + + const int shareId = query->value(0).toInt(); + name = query->value(1).toString(); + expiresAt = query->value(2).toLongLong(); + items.clear(); + + QSqlQuery *itemQuery = + prepareQuery("select id, name, tags, banner_card, game_format, color_identity from {prefix}_deck_share_item " + "where share_id = :share_id order by position"); + itemQuery->bindValue(":share_id", shareId); + execSqlQuery(itemQuery); + while (itemQuery->next()) { + DeckShareItemRecord item; + item.id = itemQuery->value(0).toInt(); + item.name = itemQuery->value(1).toString(); + const QJsonArray tagArray = QJsonDocument::fromJson(itemQuery->value(2).toString().toUtf8()).array(); + for (const QJsonValue &tag : tagArray) { + item.tags.append(tag.toString()); + } + item.bannerCard = itemQuery->value(3).toString(); + item.gameFormat = itemQuery->value(4).toString(); + item.colorIdentity = itemQuery->value(5).toString(); + items.append(item); + } + + return true; +} + +bool Servatrice_DatabaseInterface::getDeckShareItem(const QString &token, int itemId, QString &content) +{ + checkSql(); + + QSqlQuery *query = prepareQuery("select i.content from {prefix}_deck_share_item i join {prefix}_deck_share s on " + "s.id = i.share_id where s.token = :token and s.expires_at > now() and i.id = :id"); + query->bindValue(":token", token); + query->bindValue(":id", itemId); + execSqlQuery(query); + if (!query->next()) { + return false; + } + + content = query->value(0).toString(); + return true; +} + +void Servatrice_DatabaseInterface::cleanupExpiredDeckShares() +{ + checkSql(); + + QSqlQuery *query = prepareQuery("delete from {prefix}_deck_share where expires_at < now()"); + execSqlQuery(query); +} + void Servatrice_DatabaseInterface::logMessage(const int senderId, const QString &senderName, const QString &senderIp, diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index cd76ae288..c33669f0d 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -13,10 +13,22 @@ #include #include -#define DATABASE_SCHEMA_VERSION 36 +#define DATABASE_SCHEMA_VERSION 37 class Servatrice; +/** @brief Metadata of a single deck inside a temporary deck share bundle. */ +struct DeckShareItemRecord +{ + int id = -1; ///< Database id, used for downloads. + QString name; ///< Deck name. + QStringList tags; ///< Deck tags. + QString bannerCard; ///< Banner card name (deck image). + QString gameFormat; ///< Game format the deck was built for. + QString colorIdentity; ///< Color identity, e.g. "WUBRG". + QString content; ///< Deck content (native format); empty in list queries. +}; + class Servatrice_DatabaseInterface : public Server_DatabaseInterface { Q_OBJECT @@ -79,6 +91,25 @@ public: const QList &replayList) override; DeckList *getDeckFromDatabase(int deckId, int userId) override; + /** @brief Creates a new temporary deck share bundle. Returns false on failure. */ + bool createDeckShare(const QString &token, + const QString &name, + int userId, + const QList &items, + int expiryDays); + /** + * @brief Looks up a valid (non-expired) share bundle by token. + * @return false if the token is unknown or expired. + */ + bool getDeckShareList(const QString &token, QString &name, qint64 &expiresAt, QList &items); + /** + * @brief Fetches the content of one item of a valid share bundle. + * @return false if the token is unknown/expired or the item does not belong to the bundle. + */ + bool getDeckShareItem(const QString &token, int itemId, QString &content); + /** @brief Deletes all expired share bundles (cascades to their items). */ + void cleanupExpiredDeckShares(); + int getNextGameId() override; int getNextReplayId() override; int getActiveUserCount(QString connectionType = QString()) override; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 2a8b5f0a4..99f632776 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -34,18 +34,26 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include #include #include #include +#include #include +#include #include +#include +#include +#include +#include #include #include #include @@ -77,6 +85,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -201,6 +212,12 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdRemoveFromList(cmd.GetExtension(Command_RemoveFromList::ext), rc); case SessionCommand::DECK_LIST: return cmdDeckList(cmd.GetExtension(Command_DeckList::ext), rc); + case SessionCommand::DECK_LIST_OTHER_USER: + return cmdDeckListOtherUser(cmd.GetExtension(Command_DeckListOtherUser::ext), rc); + case SessionCommand::DECK_SET_VISIBILITY: + return cmdDeckSetVisibility(cmd.GetExtension(Command_DeckSetVisibility::ext), rc); + case SessionCommand::DECK_DOWNLOAD_PUBLIC: + return cmdDeckDownloadPublic(cmd.GetExtension(Command_DeckDownloadPublic::ext), rc); case SessionCommand::DECK_NEW_DIR: return cmdDeckNewDir(cmd.GetExtension(Command_DeckNewDir::ext), rc); case SessionCommand::DECK_DEL_DIR: @@ -244,6 +261,12 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdAccountImage(cmd.GetExtension(Command_AccountImage::ext), rc); case SessionCommand::SET_CARD_ART_PARAMS: return cmdSetCardArtParams(cmd.GetExtension(Command_SetCardArtParams::ext), rc); + case SessionCommand::DECK_SHARE_CREATE: + return cmdDeckShareCreate(cmd.GetExtension(Command_DeckShareCreate::ext), rc); + case SessionCommand::DECK_SHARE_LIST: + return cmdDeckShareList(cmd.GetExtension(Command_DeckShareList::ext), rc); + case SessionCommand::DECK_SHARE_DOWNLOAD: + return cmdDeckShareDownload(cmd.GetExtension(Command_DeckShareDownload::ext), rc); case SessionCommand::ACCOUNT_PASSWORD: return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc); case SessionCommand::REQUEST_PASSWORD_SALT: @@ -470,46 +493,70 @@ int AbstractServerSocketInterface::getDeckPathId(const QString &path) return getDeckPathId(0, path.split("/")); } -bool AbstractServerSocketInterface::deckListHelper(int folderId, ServerInfo_DeckStorage_Folder *folder) +bool AbstractServerSocketInterface::deckListHelper(int folderId, + ServerInfo_DeckStorage_Folder *folder, + int userId, + bool inheritedPublic, + bool publicOnly) { - QSqlQuery *query = sqlInterface->prepareQuery( - "select id, name from {prefix}_decklist_folders where id_parent = :id_parent and id_user = :id_user"); + QSqlQuery *query = sqlInterface->prepareQuery("select id, name, is_public from {prefix}_decklist_folders where " + "id_parent = :id_parent and id_user = :id_user"); query->bindValue(":id_parent", folderId); - query->bindValue(":id_user", userInfo->id()); + query->bindValue(":id_user", userId); if (!sqlInterface->execSqlQuery(query)) { return false; } - QMap results; + QList>> folderRows; while (query->next()) { - results[query->value(0).toInt()] = query->value(1).toString(); + folderRows.append({query->value(0).toInt(), {query->value(1).toString(), query->value(2).toBool()}}); } + std::sort(folderRows.begin(), folderRows.end(), [](const auto &a, const auto &b) { return a.first < b.first; }); + + for (const auto &[folderIdValue, folderInfo] : folderRows) { + const QString name = folderInfo.first; + const bool ownPublic = folderInfo.second; + const bool effectivePublic = inheritedPublic || ownPublic; + if (publicOnly && !effectivePublic) { + continue; + } - for (int key : results.keys()) { ServerInfo_DeckStorage_TreeItem *newItem = folder->add_items(); - newItem->set_id(key); - newItem->set_name(results.value(key).toStdString()); + newItem->set_id(folderIdValue); + newItem->set_name(name.toStdString()); + newItem->mutable_folder()->set_is_public(ownPublic); - if (!deckListHelper(newItem->id(), newItem->mutable_folder())) { + if (!deckListHelper(newItem->id(), newItem->mutable_folder(), userId, effectivePublic, publicOnly)) { return false; } } - query = sqlInterface->prepareQuery("select id, name, upload_time from {prefix}_decklist_files where id_folder = " - ":id_folder and id_user = :id_user"); + query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, " + "banner_card_provider, color_identity, tags from {prefix}_decklist_files where " + "id_folder = :id_folder and id_user = :id_user"); query->bindValue(":id_folder", folderId); - query->bindValue(":id_user", userInfo->id()); + query->bindValue(":id_user", userId); if (!sqlInterface->execSqlQuery(query)) { return false; } while (query->next()) { + const bool ownPublic = query->value(3).toBool(); + if (publicOnly && !(inheritedPublic || ownPublic)) { + continue; + } + ServerInfo_DeckStorage_TreeItem *newItem = folder->add_items(); newItem->set_id(query->value(0).toInt()); newItem->set_name(query->value(1).toString().toStdString()); ServerInfo_DeckStorage_File *newFile = newItem->mutable_file(); newFile->set_creation_time(query->value(2).toDateTime().toSecsSinceEpoch()); + newFile->set_is_public(ownPublic); + newFile->set_banner_card_name(query->value(4).toString().toStdString()); + newFile->set_banner_card_provider(query->value(5).toString().toStdString()); + newFile->set_color_identity(query->value(6).toString().toStdString()); + newFile->set_tags(query->value(7).toString().toStdString()); } return true; @@ -530,7 +577,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckList(const Command_ Response_DeckList *re = new Response_DeckList; ServerInfo_DeckStorage_Folder *root = re->mutable_root(); - if (!deckListHelper(0, root)) { + if (!deckListHelper(0, root, userInfo->id(), false, false)) { return Response::RespContextError; } @@ -538,6 +585,156 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckList(const Command_ return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdDeckListOtherUser(const Command_DeckListOtherUser &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + const QString userName = nameFromStdString(cmd.user_name()); + const int userId = sqlInterface->getUserIdInDB(userName); + if (userId == -1) { + return Response::RespNameNotFound; + } + + Response_DeckList *re = new Response_DeckList; + ServerInfo_DeckStorage_Folder *root = re->mutable_root(); + + if (!deckListHelper(0, root, userId, false, true)) { + return Response::RespContextError; + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +int AbstractServerSocketInterface::getDeckOwnerId(int deckId) +{ + QSqlQuery *query = sqlInterface->prepareQuery("select id_user from {prefix}_decklist_files where id = :id"); + query->bindValue(":id", deckId); + if (!sqlInterface->execSqlQuery(query)) { + return -1; + } + if (!query->next()) { + return -1; + } + return query->value(0).toInt(); +} + +bool AbstractServerSocketInterface::isDeckEffectivelyPublic(int deckId) +{ + QSqlQuery *query = + sqlInterface->prepareQuery("select is_public, id_folder from {prefix}_decklist_files where id = :id"); + query->bindValue(":id", deckId); + if (!sqlInterface->execSqlQuery(query)) { + return false; + } + if (!query->next()) { + return false; + } + if (query->value(0).toBool()) { + return true; + } + + int folderId = query->value(1).toInt(); + int guard = 0; + while (folderId != 0 && guard < 100) { + QSqlQuery *folderQuery = + sqlInterface->prepareQuery("select is_public, id_parent from {prefix}_decklist_folders where id = :id"); + folderQuery->bindValue(":id", folderId); + if (!sqlInterface->execSqlQuery(folderQuery)) { + return false; + } + if (!folderQuery->next()) { + return false; + } + if (folderQuery->value(0).toBool()) { + return true; + } + folderId = folderQuery->value(1).toInt(); + ++guard; + } + return false; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const Command_DeckSetVisibility &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + if (cmd.has_deck_id()) { + QSqlQuery *query = + sqlInterface->prepareQuery("select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user"); + query->bindValue(":id", cmd.deck_id()); + query->bindValue(":id_user", userInfo->id()); + sqlInterface->execSqlQuery(query); + if (!query->next()) { + return Response::RespNameNotFound; + } + + query = sqlInterface->prepareQuery("update {prefix}_decklist_files set is_public = :is_public where id = :id"); + query->bindValue(":is_public", cmd.is_public() ? 1 : 0); + query->bindValue(":id", cmd.deck_id()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } + } else if (cmd.has_folder_path()) { + const int folderId = getDeckPathId(nameFromStdString(cmd.folder_path())); + if (folderId == -1 || folderId == 0) { + return Response::RespNameNotFound; + } + + QSqlQuery *query = + sqlInterface->prepareQuery("update {prefix}_decklist_folders set is_public = :is_public where id = :id"); + query->bindValue(":is_public", cmd.is_public() ? 1 : 0); + query->bindValue(":id", folderId); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } + } else { + return Response::RespInvalidData; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + const int deckId = cmd.deck_id(); + const int ownerId = getDeckOwnerId(deckId); + if (ownerId == -1 || !isDeckEffectivelyPublic(deckId)) { + return Response::RespNameNotFound; + } + + DeckList *deck; + try { + deck = sqlInterface->getDeckFromDatabase(deckId, ownerId); + } catch (Response::ResponseCode &r) { + return r; + } + + Response_DeckDownload *re = new Response_DeckDownload; + re->set_deck(deck->writeToString_Native().toStdString()); + rc.setResponseExtension(re); + delete deck; + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdDeckNewDir(const Command_DeckNewDir &cmd, ResponseContainer & /*rc*/) { @@ -678,11 +875,18 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman QSqlQuery *query = sqlInterface->prepareQuery("insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, " - "content) values(:id_folder, :id_user, :name, NOW(), :content)"); + "content, is_public, banner_card_name, banner_card_provider, color_identity, " + "tags) values(:id_folder, :id_user, :name, NOW(), :content, :is_public, " + ":banner_card_name, :banner_card_provider, :color_identity, :tags)"); query->bindValue(":id_folder", folderId); query->bindValue(":id_user", userInfo->id()); query->bindValue(":name", deckName); query->bindValue(":content", deckStr); + query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0); + query->bindValue(":banner_card_name", nameFromStdString(cmd.banner_card_name())); + query->bindValue(":banner_card_provider", nameFromStdString(cmd.banner_card_provider())); + query->bindValue(":color_identity", nameFromStdString(cmd.color_identity())); + query->bindValue(":tags", nameFromStdString(cmd.tags())); sqlInterface->execSqlQuery(query); Response_DeckUpload *re = new Response_DeckUpload; @@ -690,26 +894,42 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman fileInfo->set_id(query->lastInsertId().toInt()); fileInfo->set_name(deckName.toStdString()); fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch()); + fileInfo->mutable_file()->set_is_public(cmd.has_is_public() && cmd.is_public()); rc.setResponseExtension(re); } else if (cmd.has_deck_id()) { QSqlQuery *query = sqlInterface->prepareQuery("update {prefix}_decklist_files set name=:name, upload_time=NOW(), " - "content=:content where id = :id_deck and id_user = :id_user"); + "content=:content, banner_card_name=:banner_card_name, " + "banner_card_provider=:banner_card_provider, color_identity=:color_identity, " + "tags=:tags where id = :id_deck and id_user = :id_user"); query->bindValue(":id_deck", cmd.deck_id()); query->bindValue(":id_user", userInfo->id()); query->bindValue(":name", deckName); query->bindValue(":content", deckStr); + query->bindValue(":banner_card_name", nameFromStdString(cmd.banner_card_name())); + query->bindValue(":banner_card_provider", nameFromStdString(cmd.banner_card_provider())); + query->bindValue(":color_identity", nameFromStdString(cmd.color_identity())); + query->bindValue(":tags", nameFromStdString(cmd.tags())); sqlInterface->execSqlQuery(query); if (query->numRowsAffected() == 0) { return Response::RespNameNotFound; } + QSqlQuery *visibilityQuery = + sqlInterface->prepareQuery("select is_public from {prefix}_decklist_files where id = :id and " + "id_user = :id_user"); + visibilityQuery->bindValue(":id", cmd.deck_id()); + visibilityQuery->bindValue(":id_user", userInfo->id()); + sqlInterface->execSqlQuery(visibilityQuery); + const bool isPublic = visibilityQuery->next() && visibilityQuery->value(0).toBool(); + Response_DeckUpload *re = new Response_DeckUpload; ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file(); fileInfo->set_id(cmd.deck_id()); fileInfo->set_name(deckName.toStdString()); fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch()); + fileInfo->mutable_file()->set_is_public(isPublic); rc.setResponseExtension(re); } else { return Response::RespInvalidData; @@ -740,6 +960,179 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownload(const Comm return Response::RespOk; } +namespace +{ +/** @brief Builds a cryptographically random, URL-safe share token. */ +QString generateShareToken() +{ + QByteArray bytes(32, Qt::Uninitialized); + QRandomGenerator::system()->fillRange(reinterpret_cast(bytes.data()), bytes.size() / sizeof(quint32)); + return QString::fromLatin1(bytes.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); +} + +/** @brief Extracts the share metadata for a deck, materializing its content. */ +DeckShareItemRecord makeShareItemFromDeck(const DeckList &deck, const QString &colorIdentity) +{ + DeckShareItemRecord item; + item.name = deck.getName(); + if (item.name.isEmpty()) { + item.name = "Unnamed deck"; + } + item.tags = deck.getTags(); + item.bannerCard = deck.getBannerCard().name; + item.gameFormat = deck.getGameFormat(); + QString sanitizedColorIdentity; + for (const QChar &color : colorIdentity) { + const QChar upper = color.toUpper(); + if (QStringLiteral("WUBRG").contains(upper) && !sanitizedColorIdentity.contains(upper)) { + sanitizedColorIdentity.append(upper); + } + } + item.colorIdentity = sanitizedColorIdentity; + item.content = deck.writeToString_Native(); + return item; +} +} // namespace + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const Command_DeckShareCreate &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + QList items; + if (cmd.items_size() > 0) { + for (const DeckShareItem &shareItem : cmd.items()) { + if (shareItem.has_deck_list()) { + DeckList deck; + if (!deck.loadFromString_Native(fileFromStdString(shareItem.deck_list()))) { + return Response::RespContextError; + } + items.append(makeShareItemFromDeck(deck, nameFromStdString(shareItem.color_identity()))); + } else if (shareItem.has_deck_id()) { + DeckList *deck; + try { + deck = sqlInterface->getDeckFromDatabase(shareItem.deck_id(), userInfo->id()); + } catch (Response::ResponseCode &r) { + return r; + } + items.append(makeShareItemFromDeck(*deck, nameFromStdString(shareItem.color_identity()))); + delete deck; + } else { + return Response::RespInvalidData; + } + } + } else if (cmd.has_folder_path()) { + const int folderId = getDeckPathId(nameFromStdString(cmd.folder_path())); + if (folderId == -1) { + return Response::RespNameNotFound; + } + QSqlQuery *query = sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id_folder = " + ":id_folder and id_user = :id_user"); + query->bindValue(":id_folder", folderId); + query->bindValue(":id_user", userInfo->id()); + sqlInterface->execSqlQuery(query); + while (query->next()) { + DeckList *deck; + try { + deck = sqlInterface->getDeckFromDatabase(query->value(0).toInt(), userInfo->id()); + } catch (Response::ResponseCode &r) { + return r; + } + items.append(makeShareItemFromDeck(*deck, QString())); + delete deck; + } + } else { + return Response::RespInvalidData; + } + + if (items.isEmpty()) { + return Response::RespInvalidData; + } + const int maxItems = servatrice->getDeckShareMaxDecksPerShare(); + if (items.size() > maxItems) { + return Response::RespTooManyRequests; + } + + QString shareName = nameFromStdString(cmd.name()); + if (shareName.isEmpty()) { + shareName = "Shared decks"; + } + + const QString token = generateShareToken(); + if (!sqlInterface->createDeckShare(token, shareName, userInfo->id(), items, servatrice->getDeckShareExpiryDays())) { + return Response::RespInvalidData; + } + + Response_DeckShareCreate *re = new Response_DeckShareCreate; + re->set_token(token.toStdString()); + re->set_expires_at( + QDateTime::currentDateTimeUtc().addDays(servatrice->getDeckShareExpiryDays()).toSecsSinceEpoch()); + re->set_item_count(items.size()); + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareList(const Command_DeckShareList &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + QString name; + qint64 expiresAt = 0; + QList items; + if (!sqlInterface->getDeckShareList(nameFromStdString(cmd.token()), name, expiresAt, items)) { + return Response::RespNameNotFound; + } + + Response_DeckShareList *re = new Response_DeckShareList; + re->set_name(name.toStdString()); + re->set_expires_at(expiresAt); + for (const DeckShareItemRecord &item : items) { + ServerInfo_DeckShareItem *itemInfo = re->add_items(); + itemInfo->set_id(item.id); + itemInfo->set_name(item.name.toStdString()); + for (const QString &tag : item.tags) { + itemInfo->add_tags(tag.toStdString()); + } + itemInfo->set_banner_card(item.bannerCard.toStdString()); + itemInfo->set_game_format(item.gameFormat.toStdString()); + itemInfo->set_color_identity(item.colorIdentity.toStdString()); + } + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareDownload(const Command_DeckShareDownload &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + QString content; + if (!sqlInterface->getDeckShareItem(nameFromStdString(cmd.token()), cmd.item_id(), content)) { + return Response::RespNameNotFound; + } + + Response_DeckShareDownload *re = new Response_DeckShareDownload; + re->set_deck(content.toStdString()); + rc.setResponseExtension(re); + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdReplayList(const Command_ReplayList & /*cmd*/, ResponseContainer &rc) { diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 600796b5f..47657f5ec 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -44,11 +44,17 @@ class ServerInfo_DeckStorage_Folder; class Command_AddToList; class Command_RemoveFromList; class Command_DeckList; +class Command_DeckListOtherUser; class Command_DeckNewDir; class Command_DeckDelDir; class Command_DeckDel; class Command_DeckDownload; +class Command_DeckDownloadPublic; class Command_DeckUpload; +class Command_DeckSetVisibility; +class Command_DeckShareCreate; +class Command_DeckShareList; +class Command_DeckShareDownload; class Command_ReplayList; class Command_ReplayDownload; class Command_ReplayModifyMatch; @@ -95,8 +101,16 @@ private: Response::ResponseCode cmdRemoveFromList(const Command_RemoveFromList &cmd, ResponseContainer &rc); int getDeckPathId(int basePathId, QStringList path); int getDeckPathId(const QString &path); - bool deckListHelper(int folderId, ServerInfo_DeckStorage_Folder *folder); + bool deckListHelper(int folderId, + ServerInfo_DeckStorage_Folder *folder, + int userId, + bool inheritedPublic, + bool publicOnly); + int getDeckOwnerId(int deckId); + bool isDeckEffectivelyPublic(int deckId); Response::ResponseCode cmdDeckList(const Command_DeckList &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckListOtherUser(const Command_DeckListOtherUser &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckSetVisibility(const Command_DeckSetVisibility &cmd, ResponseContainer &rc); Response::ResponseCode cmdDeckNewDir(const Command_DeckNewDir &cmd, ResponseContainer &rc); void deckDelDirHelper(int basePathId); void sendServerMessage(const QString userName, const QString message); @@ -105,6 +119,10 @@ private: Response::ResponseCode cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc); DeckList *getDeckFromDatabase(int deckId); Response::ResponseCode cmdDeckDownload(const Command_DeckDownload &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareCreate(const Command_DeckShareCreate &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareList(const Command_DeckShareList &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareDownload(const Command_DeckShareDownload &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayList(const Command_ReplayList &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayDownload(const Command_ReplayDownload &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayModifyMatch(const Command_ReplayModifyMatch &cmd, ResponseContainer &rc); From 71febfa21da154e14903a67fbe11c5e7a3db4a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Fri, 4 Sep 2026 22:44:41 +0200 Subject: [PATCH 2/6] [VDS] Decouple tag filter and fix reordered-chips crash --- ...k_preview_color_identity_filter_widget.cpp | 3 +- ...eck_preview_color_identity_filter_widget.h | 4 +- .../visual_deck_storage_search_widget.cpp | 5 + .../visual_deck_storage_search_widget.h | 2 + .../visual_deck_storage_tag_filter_widget.cpp | 109 ++++++------------ .../visual_deck_storage_tag_filter_widget.h | 25 ++-- .../visual_deck_storage_widget.cpp | 22 ++++ .../visual_deck_storage_widget.h | 1 + 8 files changed, 83 insertions(+), 88 deletions(-) diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp index fd529ff69..d1a780c38 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp @@ -1,11 +1,10 @@ #include "deck_preview_color_identity_filter_widget.h" #include "../../cards/additional_info/mana_symbol_widget.h" -#include "../visual_deck_storage_widget.h" #include -DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent) +DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(QWidget *parent) : QWidget(parent), layout(new QHBoxLayout(this)) { setLayout(layout); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h index def45de66..d54984bcf 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h @@ -14,14 +14,12 @@ #include #include -class VisualDeckStorageWidget; - class DeckPreviewColorIdentityFilterWidget : public QWidget { Q_OBJECT public: - explicit DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent); + explicit DeckPreviewColorIdentityFilterWidget(QWidget *parent = nullptr); void retranslateUi(); /** diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index baa5e5792..6b5d64060 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -39,3 +39,8 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : connect(searchDebounceTimer, &QTimer::timeout, this, [this] { emit searchTextChanged(searchBar->text()); }); } + +void VisualDeckStorageSearchWidget::setPlaceholderText(const QString &text) +{ + searchBar->setPlaceholderText(text); +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h index 7769ea911..475ee03a4 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h @@ -19,6 +19,8 @@ class VisualDeckStorageSearchWidget : public QWidget public: explicit VisualDeckStorageSearchWidget(QWidget *parent); + void setPlaceholderText(const QString &text); + signals: /** * Emitted once the debounce timer fires after the user stopped typing. diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp index ba52cf8e9..2df38b310 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp @@ -2,14 +2,10 @@ #include "../general/layout_containers/flow_widget.h" #include "deck_preview/deck_preview_tag_display_widget.h" -#include "visual_deck_storage_model.h" -#include "visual_deck_storage_sort_filter_proxy_model.h" -#include "visual_deck_storage_widget.h" #include -VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent) - : QWidget(_parent), parent(_parent) +VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(QWidget *parent) : QWidget(parent) { setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); @@ -25,97 +21,62 @@ VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckSto layout->addWidget(flowWidget); } +void VisualDeckStorageTagFilterWidget::setAllTagsProvider(const std::function()> &provider) +{ + allTagsProvider = provider; +} + void VisualDeckStorageTagFilterWidget::showEvent(QShowEvent *event) { QWidget::showEvent(event); refreshTags(); } -/** - * @brief The tags of all decks currently accepted by the proxy model. - */ -QSet VisualDeckStorageTagFilterWidget::gatherAllTags() const -{ - QSet allTags; - auto *proxy = parent->proxyModel(); - - for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) { - const QModelIndex index = proxy->index(proxyRow, 0); - if (!index.data(VisualDeckStorageRoles::FilterMatchRole).toBool()) { - continue; - } - const QStringList deckTags = index.data(VisualDeckStorageRoles::TagsRole).toStringList(); - for (const QString &tag : deckTags) { - allTags.insert(tag); - } - } - - return allTags; -} - void VisualDeckStorageTagFilterWidget::refreshTags() { - QSet allTags = gatherAllTags(); - removeTagsNotInList(allTags); - addTagsIfNotPresent(allTags); - sortTags(); -} + const QSet allTags = allTagsProvider ? allTagsProvider() : QSet(); -void VisualDeckStorageTagFilterWidget::removeTagsNotInList(const QSet &tags) -{ + // Existing chips survive if their tag is still part of the deck set, or if the chip + // is currently selected/excluded. Everything else is dropped. Dropped chips must NOT + // be re-added to the layout afterwards: they are scheduled for a deferred delete, and + // the flow layout would keep a dangling reference to them once the deletion runs on + // the next event-loop cycle. + QList chips; for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { - const QString &tagName = tagWidget->getTagName(); - - // Keep the tag widget if it is either selected or excluded - if (!tags.contains(tagName) && tagWidget->getState() == TagState::NotSelected) { + if (tagWidget->getState() != TagState::NotSelected || allTags.contains(tagWidget->getTagName())) { + chips.append(tagWidget); + } else { flowWidget->removeWidget(tagWidget); tagWidget->deleteLater(); } } -} -void VisualDeckStorageTagFilterWidget::addTagsIfNotPresent(const QSet &tags) -{ - for (const QString &tag : tags) { - addTagIfNotPresent(tag); - } -} - -void VisualDeckStorageTagFilterWidget::addTagIfNotPresent(const QString &tag) -{ - // Check if the tag already exists in the flow widget - bool tagExists = false; - for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { - if (tagWidget->getTagName() == tag) { - tagExists = true; - break; + // Add chips for tags that are not shown yet. + for (const QString &tag : allTags) { + bool tagExists = false; + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { + if (tagWidget->getTagName() == tag) { + tagExists = true; + break; + } + } + if (!tagExists) { + auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag); + connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, this, + &VisualDeckStorageTagFilterWidget::filterChanged); + flowWidget->addWidget(newTagWidget); + chips.append(newTagWidget); } } - // If the tag doesn't exist, add a new DeckPreviewTagDisplayWidget - if (!tagExists) { - auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag); - connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, parent, - &VisualDeckStorageWidget::updateTagFilter); - flowWidget->addWidget(newTagWidget); - } -} - -void VisualDeckStorageTagFilterWidget::sortTags() -{ - // Get all tag widgets - QList tagWidgets = findChildren(); - - // Sort widgets by tag name - std::sort(tagWidgets.begin(), tagWidgets.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) { + // Clear and re-add the chips in sorted order. + std::sort(chips.begin(), chips.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) { return a->getTagName().toLower() < b->getTagName().toLower(); }); - - // Clear and re-add widgets in sorted order - for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) { + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { flowWidget->removeWidget(tagWidget); } - for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) { + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { flowWidget->addWidget(tagWidget); } } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h index 337c053c7..1dad561bf 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h @@ -9,26 +9,27 @@ #include #include #include +#include class FlowWidget; class VisualDeckStorageWidget; + class VisualDeckStorageTagFilterWidget : public QWidget { Q_OBJECT - VisualDeckStorageWidget *parent; FlowWidget *flowWidget; - - [[nodiscard]] QSet gatherAllTags() const; - void removeTagsNotInList(const QSet &tags); - void addTagsIfNotPresent(const QSet &tags); - void addTagIfNotPresent(const QString &tag); - void sortTags(); + std::function()> allTagsProvider; public: - explicit VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent); + explicit VisualDeckStorageTagFilterWidget(QWidget *parent = nullptr); [[nodiscard]] QStringList getAllKnownTags() const; + /** + * @brief Sets a provider for the full set of tags to draw chips from. + */ + void setAllTagsProvider(const std::function()> &provider); + /** * @brief The tags currently in "selected" state. */ @@ -39,9 +40,15 @@ public: */ [[nodiscard]] QStringList excludedTags() const; +signals: + /** + * Emitted whenever a chip's selection/exclusion state changes. + */ + void filterChanged(); + public slots: /** - * @brief Rebuilds the tag chips from the tags of the currently visible decks. + * @brief Rebuilds the tag chips from the currently available tags. */ void refreshTags(); void showEvent(QShowEvent *event) override; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index acb0dcab2..6e33141af 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -61,6 +61,9 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare // tag filter box tagFilterWidget = new VisualDeckStorageTagFilterWidget(this); + tagFilterWidget->setAllTagsProvider([this] { return gatherVisibleTags(); }); + connect(tagFilterWidget, &VisualDeckStorageTagFilterWidget::filterChanged, this, + &VisualDeckStorageWidget::updateTagFilter); updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagFilter()); deckPreviewSelectionAnimationEnabled = @@ -216,6 +219,25 @@ void VisualDeckStorageWidget::updateTagFilter() tagFilterWidget->refreshTags(); } +/** + * @brief The tags of all decks currently accepted by the proxy model. + */ +QSet VisualDeckStorageWidget::gatherVisibleTags() const +{ + QSet allTags; + for (int proxyRow = 0; proxyRow < storageProxyModel->rowCount(); ++proxyRow) { + const QModelIndex index = storageProxyModel->index(proxyRow, 0); + if (!index.data(VisualDeckStorageRoles::FilterMatchRole).toBool()) { + continue; + } + const QStringList deckTags = index.data(VisualDeckStorageRoles::TagsRole).toStringList(); + for (const QString &tag : deckTags) { + allTags.insert(tag); + } + } + return allTags; +} + /** * Pushes the color identity filter widget's state into the proxy model. */ diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h index fe6389414..5988e5704 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h @@ -70,6 +70,7 @@ protected: private: void reapplySortAndFilters(); + [[nodiscard]] QSet gatherVisibleTags() const; private: QVBoxLayout *layout; From ad6d33c1e2535e474177c1eb9a56522ede4baaaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Fri, 4 Sep 2026 23:00:36 +0200 Subject: [PATCH 3/6] [DeckShare] Create temporary share links for local and server decks --- cockatrice/CMakeLists.txt | 4 + .../additional_info/color_identity_widget.cpp | 6 +- .../additional_info/deck_color_identity.cpp | 37 ++++ .../additional_info/deck_color_identity.h | 17 ++ .../deck_preview_card_picture_widget.cpp | 5 +- .../cards/deck_preview_card_picture_widget.h | 1 + .../widgets/deck_share/deck_share_utils.cpp | 28 +++ .../widgets/deck_share/deck_share_utils.h | 41 +++++ .../widgets/deck_share/share_bar_widget.cpp | 73 ++++++++ .../widgets/deck_share/share_bar_widget.h | 60 +++++++ .../widgets/dialogs/dlg_share_deck.cpp | 80 +++++++++ .../widgets/dialogs/dlg_share_deck.h | 41 +++++ .../widgets/menus/deck_editor_menu.cpp | 6 + .../widgets/menus/deck_editor_menu.h | 3 +- .../widgets/tabs/abstract_tab_deck_editor.cpp | 15 ++ .../widgets/tabs/abstract_tab_deck_editor.h | 3 + .../widgets/tabs/tab_deck_storage.cpp | 146 ++++++++++++++++ .../interface/widgets/tabs/tab_deck_storage.h | 14 +- .../tab_deck_storage_visual.cpp | 160 +++++++++++++++++- .../tab_deck_storage_visual.h | 39 ++++- .../deck_preview/deck_preview_widget.cpp | 89 +++++++++- .../deck_preview/deck_preview_widget.h | 20 ++- ...ual_deck_storage_folder_display_widget.cpp | 17 ++ ...isual_deck_storage_folder_display_widget.h | 1 + .../visual_deck_storage_widget.cpp | 65 +++++++ .../visual_deck_storage_widget.h | 11 ++ 26 files changed, 967 insertions(+), 15 deletions(-) create mode 100644 cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.cpp create mode 100644 cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h create mode 100644 cockatrice/src/interface/widgets/deck_share/deck_share_utils.cpp create mode 100644 cockatrice/src/interface/widgets/deck_share/deck_share_utils.h create mode 100644 cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp create mode 100644 cockatrice/src/interface/widgets/deck_share/share_bar_widget.h create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index b6050a1bd..60a4e6905 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -50,6 +50,7 @@ set(cockatrice_SOURCES 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_settings.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp @@ -57,6 +58,8 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_view_log.cpp 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/share_bar_widget.cpp src/filters/deck_filter_string.cpp src/filters/filter_builder.cpp src/filters/filter_tree_model.cpp @@ -163,6 +166,7 @@ set(cockatrice_SOURCES src/interface/palette_editor/palette_grid_widget.cpp src/interface/palette_editor/palette_editor_dialog.cpp src/interface/widgets/cards/additional_info/color_identity_widget.cpp + src/interface/widgets/cards/additional_info/deck_color_identity.cpp src/interface/widgets/cards/additional_info/mana_cost_widget.cpp src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp src/interface/widgets/cards/art_crop_attribution.cpp diff --git a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp index 1ea1bcb10..2199faf30 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp @@ -85,7 +85,7 @@ void ColorIdentityWidget::resizeEvent(QResizeEvent *event) } lastWidth = totalWidth; - const int totalHeight = totalWidth / 6; // Set height to 1/4 of the width + const int totalHeight = qMax(0, totalWidth / 6); // Set height to 1/4 of the width setFixedHeight(totalHeight); const int count = layout->count(); @@ -97,6 +97,10 @@ void ColorIdentityWidget::resizeEvent(QResizeEvent *event) const int availableWidth = totalWidth - (spacing * (count - 1)); const int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height + if (iconSize <= 0) { + lastIconSize = iconSize; + return; + } if (iconSize == lastIconSize) { return; } diff --git a/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.cpp b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.cpp new file mode 100644 index 000000000..dd6f4244b --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.cpp @@ -0,0 +1,37 @@ +#include "deck_color_identity.h" + +#include +#include +#include +#include + +QString getDeckColorIdentity(const DeckList &deck) +{ + const QStringList cardList = deck.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE}); + if (cardList.isEmpty()) { + return {}; + } + + QSet colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G) + + for (const QString &cardName : cardList) { + CardInfoPtr currentCard = CardDatabaseManager::query()->getCardInfo(cardName); + if (currentCard) { + const QString colors = currentCard->getColors(); // returns something like "WUB" + for (const QChar &color : colors) { + colorSet.insert(color); + } + } + } + + // Ensure the color identity is in WUBRG order + QString colorIdentity; + const QString wubrgOrder = "WUBRG"; + for (const QChar &color : wubrgOrder) { + if (colorSet.contains(color)) { + colorIdentity.append(color); + } + } + + return colorIdentity; +} diff --git a/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h new file mode 100644 index 000000000..93d454c58 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h @@ -0,0 +1,17 @@ +#ifndef COCKATRICE_DECK_COLOR_IDENTITY_H +#define COCKATRICE_DECK_COLOR_IDENTITY_H + +#include + +class DeckList; + +/** + * @brief Computes the color identity of a deck (e.g. "WUBRG") from the color + * symbols of all cards in the main deck and sideboard, ordered WUBRG. + * + * Shared as a free function so the deck storage previews and the deck share + * dialog compute identities identically. + */ +QString getDeckColorIdentity(const DeckList &deck); + +#endif // COCKATRICE_DECK_COLOR_IDENTITY_H 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 1614836fc..707173560 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 @@ -38,7 +38,10 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent, { singleClickTimer = new QTimer(this); singleClickTimer->setSingleShot(true); - connect(singleClickTimer, &QTimer::timeout, this, [this]() { emit imageClicked(lastMouseEvent, this); }); + connect(singleClickTimer, &QTimer::timeout, this, [this]() { + emit imageClicked(lastMouseEvent, this); + emit imageSingleClicked(); + }); connect(&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::visualDeckStorageSelectionAnimationChanged, this, &CardInfoPictureWidget::setRaiseOnEnterEnabled); 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 154e938aa..303b6bf67 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 @@ -30,6 +30,7 @@ public: signals: void imageClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void imageSingleClicked(); void imageDoubleClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); private: diff --git a/cockatrice/src/interface/widgets/deck_share/deck_share_utils.cpp b/cockatrice/src/interface/widgets/deck_share/deck_share_utils.cpp new file mode 100644 index 000000000..14c568303 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/deck_share_utils.cpp @@ -0,0 +1,28 @@ +#include "deck_share_utils.h" + +#include +#include +#include +#include + +namespace DeckShareUtils +{ + +QString buildShareLink(const AbstractClient *client, const QString &token) +{ + return QString("cockatrice://opendeck?share=%1&hostname=%2&port=%3") + .arg(token, client->serverName(), QString::number(client->serverPort())); +} + +QString copyShareLinkToClipboard(const QString &link) +{ + QGuiApplication::clipboard()->setText(link); + return link; +} + +QString formatShareExpiry(const QDateTime &expiry) +{ + return expiry.toLocalTime().toString(); +} + +} // namespace DeckShareUtils \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/deck_share/deck_share_utils.h b/cockatrice/src/interface/widgets/deck_share/deck_share_utils.h new file mode 100644 index 000000000..0034233fe --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/deck_share_utils.h @@ -0,0 +1,41 @@ +/** + * @file deck_share_utils.h + * @ingroup DeckShareWidgets + */ +//! \todo Document this file. + +#ifndef DECK_SHARE_UTILS_H +#define DECK_SHARE_UTILS_H + +#include +#include + +class AbstractClient; + +/** + * @brief Shared helpers for creating temporary deck shares. + */ +namespace DeckShareUtils +{ + +/** + * @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); + +} // namespace DeckShareUtils + +#endif // DECK_SHARE_UTILS_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp new file mode 100644 index 000000000..584e5237f --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp @@ -0,0 +1,73 @@ +#include "share_bar_widget.h" + +#include +#include +#include +#include + +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")); + hintLabel->setText(tr("Click deck tiles to select the decks you want to share.")); +} + +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::focusName() +{ + nameEdit->setFocus(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/deck_share/share_bar_widget.h b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.h new file mode 100644 index 000000000..828755fa3 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.h @@ -0,0 +1,60 @@ +/** + * @file share_bar_widget.h + * @ingroup DeckShareWidgets + */ +//! \todo Document this file. + +#ifndef SHARE_BAR_WIDGET_H +#define SHARE_BAR_WIDGET_H + +#include + +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 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 \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp new file mode 100644 index 000000000..fdef84673 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp @@ -0,0 +1,80 @@ +#include "dlg_share_deck.h" + +#include "../cards/additional_info/deck_color_identity.h" +#include "../deck_share/deck_share_utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +DlgShareDeck::DlgShareDeck(AbstractClient *_client, const QSharedPointer &_deck, QWidget *_parent) + : QDialog(_parent), client(_client), deck(_deck) +{ + setWindowTitle(tr("Share deck")); + + auto *layout = new QVBoxLayout(this); + + nameEdit = new QLineEdit(this); + nameEdit->setText(tr("Shared deck")); + + auto *form = new QFormLayout; + form->addRow(tr("Share name:"), nameEdit); + layout->addLayout(form); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Create share link")); + buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgShareDeck::actShare); + connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgShareDeck::reject); + layout->addWidget(buttonBox); +} + +void DlgShareDeck::actShare() +{ + Command_DeckShareCreate cmd; + cmd.set_name(nameEdit->text().trimmed().toStdString()); + if (cmd.name().empty()) { + cmd.set_name(tr("Shared deck").toStdString()); + } + + DeckShareItem *item = cmd.add_items(); + item->set_deck_list(deck->writeToString_Native().toStdString()); + item->set_color_identity(getDeckColorIdentity(*deck).toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgShareDeck::shareFinished); + client->sendCommand(pend); +} + +void DlgShareDeck::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + if (response.response_code() != Response::RespOk) { + QMessageBox::critical(this, tr("Share deck"), + tr("Failed to create the share link (server response code %1).") + .arg(QString::number(static_cast(response.response_code())))); + return; + } + + const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext); + const QString token = QString::fromStdString(resp.token()); + const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC); + + const QString link = DeckShareUtils::buildShareLink(client, token); + DeckShareUtils::copyShareLinkToClipboard(link); + + QMessageBox::information(this, tr("Share deck"), + tr("Share link created and copied to the clipboard:\n\n%1\n\n" + "The share expires on %2.") + .arg(link, DeckShareUtils::formatShareExpiry(expiry))); + accept(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h new file mode 100644 index 000000000..30c28fb73 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h @@ -0,0 +1,41 @@ +/** + * @file dlg_share_deck.h + * @ingroup Dialogs + */ +//! \todo Document this file. + +#ifndef DLG_SHARE_DECK_H +#define DLG_SHARE_DECK_H + +#include +#include + +class AbstractClient; +class CommandContainer; +class DeckList; +class QLineEdit; +class Response; + +/** + * @brief Slim dialog to create a temporary share for the deck open in the editor. + * + * Asks for a share name, sends Command_DeckShareCreate for the single inline + * deck, and copies the resulting link to the clipboard. + */ +class DlgShareDeck : public QDialog +{ + Q_OBJECT +public: + DlgShareDeck(AbstractClient *_client, const QSharedPointer &_deck, QWidget *parent = nullptr); + +private slots: + void actShare(); + void shareFinished(const Response &response, const CommandContainer &commandContainer); + +private: + AbstractClient *client; + QSharedPointer deck; + QLineEdit *nameEdit; +}; + +#endif // DLG_SHARE_DECK_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp b/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp index f057680be..6e27b4e42 100644 --- a/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp +++ b/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp @@ -28,6 +28,9 @@ DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), d aSaveDeckAs = new QAction(QString(), this); connect(aSaveDeckAs, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actSaveDeckAs); + aShareDeck = new QAction(QString(), this); + connect(aShareDeck, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actShareDeck); + aLoadDeckFromClipboard = new QAction(QString(), this); connect(aLoadDeckFromClipboard, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actLoadDeckFromClipboard); @@ -96,6 +99,7 @@ DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), d addMenu(loadRecentDeckMenu); addAction(aSaveDeck); addAction(aSaveDeckAs); + addAction(aShareDeck); addSeparator(); addAction(aLoadDeckFromClipboard); addMenu(editDeckInClipboardMenu); @@ -120,6 +124,7 @@ void DeckEditorMenu::setSaveStatus(bool newStatus) { aSaveDeck->setEnabled(newStatus); aSaveDeckAs->setEnabled(newStatus); + aShareDeck->setEnabled(newStatus); aSaveDeckToClipboard->setEnabled(newStatus); aSaveDeckToClipboardNoSetInfo->setEnabled(newStatus); aSaveDeckToClipboardRaw->setEnabled(newStatus); @@ -157,6 +162,7 @@ void DeckEditorMenu::retranslateUi() aClearRecents->setText(tr("Clear")); aSaveDeck->setText(tr("&Save deck")); aSaveDeckAs->setText(tr("Save deck &as...")); + aShareDeck->setText(tr("Share deck...")); aLoadDeckFromClipboard->setText(tr("Load deck from cl&ipboard...")); diff --git a/cockatrice/src/interface/widgets/menus/deck_editor_menu.h b/cockatrice/src/interface/widgets/menus/deck_editor_menu.h index eff9257bb..08ce7bba1 100644 --- a/cockatrice/src/interface/widgets/menus/deck_editor_menu.h +++ b/cockatrice/src/interface/widgets/menus/deck_editor_menu.h @@ -21,7 +21,8 @@ public: QAction *aNewDeck, *aLoadDeck, *aClearRecents, *aSaveDeck, *aSaveDeckAs, *aLoadDeckFromClipboard, *aEditDeckInClipboard, *aEditDeckInClipboardRaw, *aSaveDeckToClipboard, *aSaveDeckToClipboardNoSetInfo, *aSaveDeckToClipboardRaw, *aSaveDeckToClipboardRawNoSetInfo, *aPrintDeck, *aLoadDeckFromWebsite, - *aExportDeckDecklist, *aExportDeckDecklistXyz, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aClose; + *aExportDeckDecklist, *aExportDeckDecklistXyz, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aShareDeck, + *aClose; QMenu *loadRecentDeckMenu, *analyzeDeckMenu, *editDeckInClipboardMenu, *saveDeckToClipboardMenu; void setSaveStatus(bool newStatus); diff --git a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp index f80649eba..0b830c32d 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp @@ -19,6 +19,7 @@ #include "../interface/widgets/dialogs/dlg_load_deck.h" #include "../interface/widgets/dialogs/dlg_load_deck_from_clipboard.h" #include "../interface/widgets/dialogs/dlg_load_deck_from_website.h" +#include "../interface/widgets/dialogs/dlg_share_deck.h" #include "../utility/visibility_change_listener.h" #include "tab_supervisor.h" @@ -382,6 +383,20 @@ bool AbstractTabDeckEditor::actSaveDeckAs() return true; } +/** + * @brief Opens the deck share dialog with the current deck preselected. + */ +void AbstractTabDeckEditor::actShareDeck() +{ + const QSharedPointer deck = deckStateManager->getDeckListShared(); + if (deck->isBlankDeck()) { + return; + } + + DlgShareDeck shareDialog(tabSupervisor->getClient(), deck, this); + shareDialog.exec(); +} + /** * @brief Callback for remote deck save completion. * @param response Server response. diff --git a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h index a3cda2bfc..dae8f5b0a 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h @@ -214,6 +214,9 @@ protected slots: /** @brief Saves the current deck under a new name. */ virtual bool actSaveDeckAs(); + /** @brief Opens the deck share dialog for the current deck. */ + void actShareDeck(); + /** @brief Loads a deck from the clipboard. */ virtual void actLoadDeckFromClipboard(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 49e42e4cf..e796f798d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -1,19 +1,30 @@ #include "tab_deck_storage.h" #include "../../../client/settings/cache_settings.h" +#include "../../../main.h" #include "../../deck_loader/deck_loader.h" +#include "../deck_share/share_bar_widget.h" #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "../interface/widgets/utility/get_text_with_max.h" #include #include +#include +#include #include #include #include #include +#include +#include #include #include +#include +#include #include +#include +#include +#include #include #include #include @@ -23,9 +34,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -91,8 +104,17 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, serverDirView = new RemoteDeckList_TreeWidget(client); connect(serverDirView, &QTreeView::doubleClicked, this, &TabDeckStorage::actRemoteDoubleClick); + connect(serverDirView->selectionModel(), &QItemSelectionModel::selectionChanged, this, + [this] { onServerSelectionChanged(); }); + + // Share bar for creating a share link from the selected server decks/folders. + shareBar = new ShareBarWidget(this); + connect(shareBar, &ShareBarWidget::createRequested, this, &TabDeckStorage::actShareSelection); + connect(shareBar, &ShareBarWidget::cancelRequested, this, &TabDeckStorage::cancelShareDecks); + shareBar->setVisible(false); QVBoxLayout *rightVbox = new QVBoxLayout; + rightVbox->addWidget(shareBar); rightVbox->addWidget(serverDirView); rightVbox->addLayout(rightToolBarLayout); rightGroupBox = new QGroupBox; @@ -138,6 +160,10 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, aDeleteRemoteDeck->setIcon(QPixmap("theme:icons/remove_row")); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); + aShareDecks = new QAction(this); + aShareDecks->setIcon(QPixmap("theme:icons/share")); + connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks); + // Add actions to toolbars leftToolBar->addAction(aOpenLocalDeck); leftToolBar->addAction(aRenameLocal); @@ -149,6 +175,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, rightToolBar->addAction(aOpenRemoteDeck); rightToolBar->addAction(aDownload); + rightToolBar->addAction(aShareDecks); rightToolBar->addAction(aNewFolder); rightToolBar->addAction(aDeleteRemoteDeck); @@ -177,7 +204,9 @@ void TabDeckStorage::retranslateUi() aNewFolder->setText(tr("New folder")); aDeleteLocalDeck->setText(tr("Delete")); aDeleteRemoteDeck->setText(tr("Delete")); + aShareDecks->setText(tr("Share decks")); aOpenDecksFolder->setText(tr("Open decks folder")); + shareBar->retranslateUi(); } QString TabDeckStorage::getTargetPath() const @@ -625,3 +654,120 @@ void TabDeckStorage::deleteFolderFinished(const Response &response, const Comman serverDirView->removeNode(toDelete); } } + +void TabDeckStorage::actShareDecks() +{ + setShareModeEnabled(true); +} + +void TabDeckStorage::cancelShareDecks() +{ + setShareModeEnabled(false); +} + +void TabDeckStorage::setShareModeEnabled(bool enabled) +{ + shareBar->setVisible(enabled); + if (enabled) { + shareBar->setName(tr("Shared decks")); + onServerSelectionChanged(); + shareBar->focusName(); + } else { + serverDirView->clearSelection(); + } +} + +void TabDeckStorage::onServerSelectionChanged() +{ + if (!shareBar->isVisible()) { + return; + } + const auto selection = serverDirView->getCurrentSelection(); + int folders = 0; + int files = 0; + for (const auto *node : selection) { + if (dynamic_cast(node)) { + ++folders; + } else { + ++files; + } + } + QStringList parts; + if (folders > 0) { + parts << tr("%n folder(s)", "", folders); + } + if (files > 0) { + parts << tr("%n deck(s)", "", files); + } + shareBar->setCountText(parts.isEmpty() ? tr("No decks selected") : tr("Selected: %1").arg(parts.join(tr(", ")))); +} + +void TabDeckStorage::actShareSelection() +{ + const auto selection = serverDirView->getCurrentSelection(); + + Command_DeckShareCreate cmd; + cmd.set_name(shareBar->name().toStdString()); + if (cmd.name().empty()) { + cmd.set_name(tr("Shared decks").toStdString()); + } + + // Sharing a folder is exclusive with sharing individual decks (matches the picker's rule). + for (const auto *node : selection) { + if (const auto *dirNode = dynamic_cast(node)) { + const QString path = dirNode->getPath(); + if (path.isEmpty()) { + continue; // the root folder cannot be shared + } + cmd.set_folder_path(path.toStdString()); + break; + } + } + + if (cmd.folder_path().empty()) { + for (const auto *node : selection) { + if (const auto *fileNode = dynamic_cast(node)) { + DeckShareItem *item = cmd.add_items(); + item->set_deck_id(fileNode->getId()); + } + } + } + + if (cmd.items_size() == 0 && cmd.folder_path().empty()) { + showShareNotice(tr("Select decks to share.")); + return; + } + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabDeckStorage::shareFromTreeFinished); + client->sendCommand(pend); +} + +void TabDeckStorage::shareFromTreeFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + if (response.response_code() != Response::RespOk) { + qWarning() << "failed to create deck share:" << response.response_code(); + showShareNotice(tr("Failed to create the share link (server response code %1).") + .arg(QString::number(static_cast(response.response_code())))); + return; + } + const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext); + const QString token = QString::fromStdString(resp.token()); + const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC).toLocalTime(); + + const QString link = QString("cockatrice://opendeck?share=%1&hostname=%2&port=%3") + .arg(token, client->serverName(), QString::number(client->serverPort())); + QGuiApplication::clipboard()->setText(link); + + showShareNotice(tr("Share link copied to the clipboard.\nExpires on %1.").arg(expiry.toString())); + setShareModeEnabled(false); +} + +void TabDeckStorage::showShareNotice(const QString &message) +{ + if (trayIcon && trayIcon->isVisible()) { + trayIcon->showMessage(tr("Deck share"), message); + } else if (auto *mainWindow = qobject_cast(window())) { + mainWindow->statusBar()->showMessage(message, 10000); + } +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h index a863e0625..5f28f7d55 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h @@ -24,6 +24,7 @@ class QTreeWidgetItem; class QGroupBox; class CommandContainer; class Response; +class ShareBarWidget; class TabDeckStorage : public Tab { @@ -35,14 +36,19 @@ private: QToolBar *leftToolBar, *rightToolBar; RemoteDeckList_TreeWidget *serverDirView; QGroupBox *leftGroupBox, *rightGroupBox; + ShareBarWidget *shareBar; QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck; QAction *aOpenDecksFolder; - QAction *aOpenRemoteDeck, *aDownload, *aNewFolder, *aDeleteRemoteDeck; + QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aNewFolder, *aDeleteRemoteDeck; QString getTargetPath() const; void setRemoteEnabled(bool enabled); + void showShareNotice(const QString &message); + + void setShareModeEnabled(bool enabled); + void uploadDeck(const QString &filePath, const QString &targetPath); void deleteRemoteDeck(const RemoteDeckList_TreeModel::Node *node); @@ -75,6 +81,12 @@ private slots: void actNewFolder(); void newFolderFinished(const Response &response, const CommandContainer &commandContainer); + void actShareDecks(); + void actShareSelection(); + void cancelShareDecks(); + void onServerSelectionChanged(); + void shareFromTreeFinished(const Response &r, const CommandContainer &commandContainer); + void actDeleteRemoteDeck(); void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer); void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer); diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp index 0cbcb641a..558c080c2 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp @@ -1,10 +1,23 @@ #include "tab_deck_storage_visual.h" +#include "../../../../main.h" +#include "../../../deck_loader/deck_loader.h" +#include "../../cards/additional_info/deck_color_identity.h" +#include "../../deck_share/deck_share_utils.h" #include "../../interface/widgets/visual_deck_storage/visual_deck_storage_widget.h" #include "../tab_supervisor.h" +#include #include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor), visualDeckStorageWidget(new VisualDeckStorageWidget(this)) @@ -14,12 +27,42 @@ TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor) &TabDeckStorageVisual::actOpenLocalDeck); connect(visualDeckStorageWidget, &VisualDeckStorageWidget::openDeckEditor, this, &TabDeckStorageVisual::openDeckEditor); + connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareDeckRequested, this, + &TabDeckStorageVisual::actShareDeck); + connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareSelectionChanged, this, + &TabDeckStorageVisual::onShareSelectionChanged); + connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareRequested, this, [this] { + if (shareDeckAvailable) { + enterShareMode(); + } + }); + + AbstractClient *client = tabSupervisor->getClient(); + connect(client, &AbstractClient::statusChanged, this, &TabDeckStorageVisual::handleConnectionChanged); + shareDeckAvailable = (client->getStatus() == StatusLoggedIn); + visualDeckStorageWidget->setShareAvailable(shareDeckAvailable); auto *widget = new QWidget(this); auto *layout = new QVBoxLayout(widget); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); widget->setLayout(layout); this->setCentralWidget(widget); layout->addWidget(visualDeckStorageWidget); + + shareBar = new ShareBarWidget(this); + connect(shareBar, &ShareBarWidget::createRequested, this, &TabDeckStorageVisual::actShareSelected); + connect(shareBar, &ShareBarWidget::cancelRequested, this, &TabDeckStorageVisual::exitShareMode); + + layout->insertWidget(0, shareBar); + shareBar->setVisible(false); + + retranslateUi(); +} + +void TabDeckStorageVisual::retranslateUi() +{ + shareBar->retranslateUi(); } void TabDeckStorageVisual::actOpenLocalDeck(const QString &filePath) @@ -33,3 +76,118 @@ void TabDeckStorageVisual::actOpenLocalDeck(const QString &filePath) emit openDeckEditor(deckOpt.value()); } + +void TabDeckStorageVisual::enterShareMode(const QStringList &preselectFiles) +{ + if (!shareDeckAvailable) { + return; // sharing is gated on being logged in + } + visualDeckStorageWidget->setShareSelectable(true); + visualDeckStorageWidget->setShareSelectedFiles(preselectFiles); + shareBar->setName(tr("Shared decks")); + shareBar->setVisible(true); + updateShareHint(); + shareBar->focusName(); +} + +void TabDeckStorageVisual::exitShareMode() +{ + visualDeckStorageWidget->setShareSelectable(false); + visualDeckStorageWidget->clearShareSelection(); + shareBar->setVisible(false); +} + +void TabDeckStorageVisual::actShareDeck(const QString &filePath) +{ + enterShareMode({filePath}); +} + +void TabDeckStorageVisual::onShareSelectionChanged() +{ + if (shareBar->isVisible()) { + updateShareHint(); + } +} + +void TabDeckStorageVisual::updateShareHint() +{ + const int count = visualDeckStorageWidget->selectedFilePaths().size(); + shareBar->setCountText(tr("%n deck(s)", "", count)); + if (count == 0) { + shareBar->setHintText(tr("Click deck tiles to select the decks you want to share."), true); + } else { + shareBar->setHintText(tr("%n deck(s) selected. Create the link to share %1 with other players.", "", count) + .arg(count == 1 ? tr("it") : tr("them")), + true); + } +} + +void TabDeckStorageVisual::actShareSelected() +{ + const QStringList filePaths = visualDeckStorageWidget->selectedFilePaths(); + if (filePaths.isEmpty()) { + QMessageBox::warning(this, tr("Share decks"), tr("Select at least one deck to share.")); + return; + } + + Command_DeckShareCreate cmd; + cmd.set_name(shareBar->name().toStdString()); + if (cmd.name().empty()) { + cmd.set_name(tr("Shared decks").toStdString()); + } + + for (const QString &filePath : filePaths) { + std::optional deckOpt = + DeckLoader::loadFromFile(filePath, DeckFileFormat::getFormatFromName(filePath), true); + if (!deckOpt) { + QMessageBox::warning(this, tr("Share decks"), tr("Unable to load deck file %1").arg(filePath)); + return; + } + DeckShareItem *item = cmd.add_items(); + item->set_deck_list(deckOpt->deckList.writeToString_Native().toStdString()); + item->set_color_identity(getDeckColorIdentity(deckOpt->deckList).toStdString()); + } + + PendingCommand *pend = tabSupervisor->getClient()->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabDeckStorageVisual::shareFinished); + tabSupervisor->getClient()->sendCommand(pend); +} + +void TabDeckStorageVisual::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + if (response.response_code() != Response::RespOk) { + showShareNotice(tr("Failed to create the share link (server response code %1).") + .arg(QString::number(static_cast(response.response_code())))); + exitShareMode(); + return; + } + + const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext); + const QString token = QString::fromStdString(resp.token()); + const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC); + + const QString link = DeckShareUtils::buildShareLink(tabSupervisor->getClient(), token); + DeckShareUtils::copyShareLinkToClipboard(link); + + showShareNotice( + tr("Share link copied to the clipboard.\nExpires on %1.").arg(DeckShareUtils::formatShareExpiry(expiry))); + exitShareMode(); +} + +void TabDeckStorageVisual::handleConnectionChanged(ClientStatus status) +{ + shareDeckAvailable = (status == StatusLoggedIn); + visualDeckStorageWidget->setShareAvailable(shareDeckAvailable); + if (!shareDeckAvailable && shareBar->isVisible()) { + exitShareMode(); + } +} + +void TabDeckStorageVisual::showShareNotice(const QString &message) +{ + if (trayIcon && trayIcon->isVisible()) { + trayIcon->showMessage(tr("Deck share"), message); + } else if (auto *mainWindow = qobject_cast(window())) { + mainWindow->statusBar()->showMessage(message, 10000); + } +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h index d3f64e23d..c91977ad4 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h @@ -7,8 +7,12 @@ #ifndef TAB_DECK_STORAGE_VISUAL_H #define TAB_DECK_STORAGE_VISUAL_H +#include "../../deck_share/share_bar_widget.h" #include "../tab.h" +#include +#include + struct LoadedDeck; class AbstractClient; class CommandContainer; @@ -27,22 +31,49 @@ class TabDeckStorageVisual final : public Tab Q_OBJECT public: explicit TabDeckStorageVisual(TabSupervisor *_tabSupervisor); - void retranslateUi() override - { - } + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override { return tr("Visual Deck Storage"); } + /** + * @brief Enters share-selection mode, optionally preselecting the given deck files. + */ + void enterShareMode(const QStringList &preselectFiles = {}); + + /** + * @brief Leaves share-selection mode and clears the selection. + */ + void exitShareMode(); + + [[nodiscard]] bool isShareModeActive() const + { + return shareBar->isVisible(); + } + public slots: void actOpenLocalDeck(const QString &filePath); + void actShareDeck(const QString &filePath); signals: void openDeckEditor(const LoadedDeck &deck); +private slots: + void actShareSelected(); + void shareFinished(const Response &response, const CommandContainer &commandContainer); + void onShareSelectionChanged(); + void handleConnectionChanged(ClientStatus status); + private: + void showShareNotice(const QString &message); + void updateShareHint(); + VisualDeckStorageWidget *visualDeckStorageWidget; + + ShareBarWidget *shareBar; + bool shareDeckAvailable = false; }; -#endif +#endif \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp index 876fbf6ad..750fd287b 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp @@ -4,6 +4,7 @@ #include "../../../../interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h" #include "../../../deck_loader/deck_loader.h" #include "../../cards/additional_info/color_identity_widget.h" +#include "../../cards/additional_info/deck_color_identity.h" #include "../../cards/deck_preview_card_picture_widget.h" #include "../visual_deck_storage_quick_settings_widget.h" #include "../visual_deck_storage_tag_filter_widget.h" @@ -11,6 +12,7 @@ #include "deck_preview_deck_tags_display_widget.h" #include +#include #include #include #include @@ -27,7 +29,7 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, VisualDeckStorageWidget *_visualDeckStorageWidget, VisualDeckStorageModel *_model, const QString &_filePath) - : QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model), filePath(_filePath) + : QWidget(_parent), filePath(_filePath), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model) { layout = new QVBoxLayout(this); setLayout(layout); @@ -36,6 +38,8 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, new DeckPreviewCardPictureWidget(this, false, visualDeckStorageWidget->deckPreviewSelectionAnimationEnabled); pictureWidget->setFontSize(24); connect(pictureWidget, &DeckPreviewCardPictureWidget::imageClicked, this, &DeckPreviewWidget::imageClickedEvent); + connect(pictureWidget, &DeckPreviewCardPictureWidget::imageSingleClicked, this, + &DeckPreviewWidget::imageSingleClicked); connect(pictureWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, &DeckPreviewWidget::imageDoubleClickedEvent); bannerCardDisplayWidget = pictureWidget; @@ -99,6 +103,15 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, // to keep the resize handler from searching the widget tree on every layout pass. fixedWidthChildren = {bannerCardDisplayWidget, colorIdentityWidget, deckTagsDisplayWidget, bannerCardLabel, bannerCardComboBox}; + + // Child of the banner widget so the frame tracks the banner's selection animation + // (which animates the banner's position) instead of staying at a stale static offset. + 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); + bannerCardDisplayWidget->installEventFilter(this); } void DeckPreviewWidget::retranslateUi() @@ -122,6 +135,63 @@ void DeckPreviewWidget::resizeEvent(QResizeEvent *event) for (QWidget *widget : fixedWidthChildren) { widget->setMaximumWidth(width); } + updateSelectionFrameGeometry(); +} + +bool DeckPreviewWidget::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == bannerCardDisplayWidget && (event->type() == QEvent::Resize || event->type() == QEvent::Move)) { + updateSelectionFrameGeometry(); + } + return QWidget::eventFilter(watched, event); +} + +void DeckPreviewWidget::setShareSelectable(bool selectable) +{ + shareSelectable = selectable; + if (!selectable) { + setShareSelected(false); + } + updateSelectionStyle(); +} + +void DeckPreviewWidget::setShareSelected(bool selected) +{ + if (shareSelected == selected) { + return; + } + shareSelected = selected; + updateSelectionStyle(); + emit shareSelectionToggled(selected); +} + +bool DeckPreviewWidget::isShareSelected() const +{ + return shareSelected; +} + +bool DeckPreviewWidget::isShareSelectable() const +{ + return shareSelectable; +} + +void DeckPreviewWidget::updateSelectionFrameGeometry() +{ + if (selectionFrame == nullptr || bannerCardDisplayWidget == nullptr) { + return; + } + // Frame is a child of the banner, so it is positioned in banner coordinates and + // tracks the banner's selection animation automatically. A small inset keeps the + // highlight visible around the card art without occluding it. + selectionFrame->setGeometry(bannerCardDisplayWidget->rect().adjusted(1, 1, -1, -1)); + selectionFrame->raise(); +} + +void DeckPreviewWidget::updateSelectionStyle() +{ + if (selectionFrame != nullptr) { + selectionFrame->setVisible(shareSelectable && isShareSelected()); + } } void DeckPreviewWidget::enterEvent(QEnterEvent *event) @@ -226,10 +296,6 @@ void DeckPreviewWidget::updateTagsVisibility(bool visible) } } -/** - * Refreshes the banner card text. - * This also calls `refreshBannerCardToolTip`, since those two often need to be updated together. - */ void DeckPreviewWidget::refreshBannerCardText() { bannerCardDisplayWidget->setOverlayText(getDisplayName()); @@ -337,10 +403,20 @@ void DeckPreviewWidget::imageClickedEvent(QMouseEvent *event, DeckPreviewCardPic } } +void DeckPreviewWidget::imageSingleClicked() +{ + if (isShareSelectable()) { + setShareSelected(!isShareSelected()); + } +} + void DeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance) { Q_UNUSED(event); Q_UNUSED(instance); + if (isShareSelectable()) { + return; // in share mode a double click would just toggle a single selection + } emit deckLoadRequested(filePath); } @@ -365,6 +441,9 @@ QMenu *DeckPreviewWidget::createRightClickMenu() } }); + connect(menu->addAction(tr("Share deck...")), &QAction::triggered, this, + [this] { emit shareDeckRequested(filePath); }); + connect(menu->addAction(tr("Edit Tags")), &QAction::triggered, deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::openTagEditDlg); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h index 7bb69f9b9..f4909eda8 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h @@ -17,6 +17,7 @@ #include class QEnterEvent; +class QFrame; class QLabel; class QMenu; class QMouseEvent; @@ -41,9 +42,19 @@ public: */ DeckPreviewCardPictureWidget *bannerCardDisplayWidget; + /** @brief The path of the deck file backing this preview. */ + QString filePath; + + void setShareSelectable(bool selectable); + void setShareSelected(bool selected); + [[nodiscard]] bool isShareSelected() const; + [[nodiscard]] bool isShareSelectable() const; + signals: void deckLoadRequested(const QString &filePath); void openDeckEditor(const LoadedDeck &deck); + void shareDeckRequested(const QString &filePath); + void shareSelectionToggled(bool selected); public slots: /** @@ -66,6 +77,7 @@ public slots: protected: void enterEvent(QEnterEvent *event) override; void resizeEvent(QResizeEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; private: [[nodiscard]] int row() const; @@ -76,6 +88,7 @@ private: QMenu *createRightClickMenu(); void addSetBannerCardMenu(QMenu *menu); void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void imageSingleClicked(); void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); void actRenameDeck(); @@ -84,7 +97,6 @@ private: VisualDeckStorageWidget *visualDeckStorageWidget; VisualDeckStorageModel *model; - QString filePath; QVBoxLayout *layout; ColorIdentityWidget *colorIdentityWidget; DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget; @@ -92,6 +104,12 @@ private: QComboBox *bannerCardComboBox; QList fixedWidthChildren; ///< Children clamped to the picture width on resize. int lastKnownBannerWidth = -1; ///< The picture width last applied to the children. + QFrame *selectionFrame = nullptr; + bool shareSelectable = false; + bool shareSelected = false; + + void updateSelectionStyle(); + void updateSelectionFrameGeometry(); }; class NoScrollFilter : public QObject diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp index 22d73b604..90440beed 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp @@ -209,6 +209,11 @@ DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget &VisualDeckStorageWidget::deckLoadRequested); connect(deckPreviewWidget, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget, &VisualDeckStorageWidget::openDeckEditor); + connect(deckPreviewWidget, &DeckPreviewWidget::shareDeckRequested, visualDeckStorageWidget, + &VisualDeckStorageWidget::shareDeckRequested); + connect(deckPreviewWidget, &DeckPreviewWidget::shareSelectionToggled, visualDeckStorageWidget, + &VisualDeckStorageWidget::shareSelectionChanged); + deckPreviewWidget->setShareSelectable(visualDeckStorageWidget->isShareSelectable()); connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, deckPreviewWidget->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor); deckPreviewWidget->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize()); @@ -216,6 +221,18 @@ DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget return deckPreviewWidget; } +void VisualDeckStorageFolderDisplayWidget::setShareSelectable(bool selectable) +{ + const auto previews = flowWidget->findChildren(); + for (DeckPreviewWidget *preview : previews) { + preview->setShareSelectable(selectable); + } + const auto subFolders = findChildren(); + for (VisualDeckStorageFolderDisplayWidget *subFolder : subFolders) { + subFolder->setShareSelectable(selectable); + } +} + /** * @brief Creates, removes and keeps in sync the subfolder widgets of this folder. * diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h index 257ce1778..5a81457b0 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h @@ -51,6 +51,7 @@ public slots: */ void scheduleReconcile(); void updateShowFolders(bool enabled); + void setShareSelectable(bool selectable); signals: /** diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index 6e33141af..470e9a43e 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -47,6 +47,13 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare refreshButton->setFixedSize(32, 32); connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible); + shareButton = new QToolButton(this); + shareButton->setIcon(QPixmap("theme:icons/share")); + shareButton->setFixedSize(32, 32); + shareButton->setToolTip(tr("Share selected decks")); + shareButton->setVisible(false); + connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested); + quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this); connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::showFoldersChanged, this, &VisualDeckStorageWidget::updateShowFolders); @@ -57,6 +64,7 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare searchAndSortLayout->addWidget(sortWidget); searchAndSortLayout->addWidget(searchWidget); searchAndSortLayout->addWidget(refreshButton); + searchAndSortLayout->addWidget(shareButton); searchAndSortLayout->addWidget(quickSettingsWidget); // tag filter box @@ -162,6 +170,63 @@ void VisualDeckStorageWidget::retranslateUi() sortWidget->retranslateUi(); } +void VisualDeckStorageWidget::setShareSelectable(bool selectable) +{ + if (shareSelectable == selectable) { + return; + } + shareSelectable = selectable; + if (folderWidget != nullptr) { + folderWidget->setShareSelectable(selectable); + } + emit shareSelectionChanged(); +} + +bool VisualDeckStorageWidget::isShareSelectable() const +{ + return shareSelectable; +} + +QStringList VisualDeckStorageWidget::selectedFilePaths() const +{ + QStringList selectedPaths; + if (folderWidget != nullptr) { + const auto previews = folderWidget->findChildren(); + for (DeckPreviewWidget *preview : previews) { + if (preview->isShareSelected()) { + selectedPaths.append(preview->filePath); + } + } + } + return selectedPaths; +} + +void VisualDeckStorageWidget::clearShareSelection() +{ + if (folderWidget != nullptr) { + const auto previews = folderWidget->findChildren(); + for (DeckPreviewWidget *preview : previews) { + preview->setShareSelected(false); + } + } +} + +void VisualDeckStorageWidget::setShareAvailable(bool available) +{ + shareButton->setVisible(available); + shareButton->setEnabled(available); +} + +void VisualDeckStorageWidget::setShareSelectedFiles(const QStringList &paths) +{ + if (folderWidget != nullptr) { + const auto previews = folderWidget->findChildren(); + for (DeckPreviewWidget *preview : previews) { + preview->setShareSelected(paths.contains(preview->filePath)); + } + } +} + /** * Gets a const pointer to the quick settings so that the values can be accessed. */ diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h index 5988e5704..dfb715bea 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h @@ -33,6 +33,12 @@ public: explicit VisualDeckStorageWidget(QWidget *parent); void refreshIfPossible(); void retranslateUi(); + void setShareSelectable(bool selectable); + [[nodiscard]] bool isShareSelectable() const; + [[nodiscard]] QStringList selectedFilePaths() const; + void setShareSelectedFiles(const QStringList &paths); + void clearShareSelection(); + void setShareAvailable(bool available); VisualDeckStorageTagFilterWidget *tagFilterWidget; bool deckPreviewSelectionAnimationEnabled; @@ -63,6 +69,9 @@ public slots: signals: void deckLoadRequested(const QString &filePath); void openDeckEditor(const LoadedDeck &deck); + void shareDeckRequested(const QString &filePath); + void shareSelectionChanged(); + void shareRequested(); protected: void resizeEvent(QResizeEvent *event) override; @@ -81,12 +90,14 @@ private: VisualDeckStorageSearchWidget *searchWidget; DeckPreviewColorIdentityFilterWidget *deckPreviewColorIdentityFilterWidget; QToolButton *refreshButton; + QToolButton *shareButton; VisualDeckStorageQuickSettingsWidget *quickSettingsWidget; QScrollArea *scrollArea; VisualDeckStorageFolderDisplayWidget *folderWidget = nullptr; VisualDeckStorageModel *storageModel = nullptr; VisualDeckStorageSortFilterProxyModel *storageProxyModel = nullptr; QTimer *refreshTimer = nullptr; ///< Coalesces the re-apply/refresh burst following a batch of deck loads. + bool shareSelectable = false; }; #endif // VISUAL_DECK_STORAGE_WIDGET_H From 0bfa89b5d300945c0ca3f4089340280c425ab282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sat, 5 Sep 2026 01:13:55 +0200 Subject: [PATCH 4/6] [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 --- .../additional_info/deck_color_identity.cpp | 6 +- .../additional_info/deck_color_identity.h | 5 +- .../widgets/deck_share/share_bar_widget.cpp | 5 ++ .../widgets/deck_share/share_bar_widget.h | 3 + .../widgets/dialogs/dlg_share_deck.cpp | 7 +- .../widgets/dialogs/dlg_share_deck.h | 2 + .../widgets/tabs/abstract_tab_deck_editor.cpp | 5 ++ .../widgets/tabs/tab_deck_storage.cpp | 88 ++++++++++++------- .../interface/widgets/tabs/tab_deck_storage.h | 2 +- .../tab_deck_storage_visual.cpp | 29 +++--- .../tab_deck_storage_visual.h | 2 +- .../visual_deck_storage_widget.cpp | 2 +- 12 files changed, 103 insertions(+), 53 deletions(-) diff --git a/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.cpp b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.cpp index dd6f4244b..62b01511e 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.cpp @@ -1,11 +1,11 @@ #include "deck_color_identity.h" #include -#include +#include #include #include -QString getDeckColorIdentity(const DeckList &deck) +QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db) { const QStringList cardList = deck.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE}); if (cardList.isEmpty()) { @@ -15,7 +15,7 @@ QString getDeckColorIdentity(const DeckList &deck) QSet colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G) for (const QString &cardName : cardList) { - CardInfoPtr currentCard = CardDatabaseManager::query()->getCardInfo(cardName); + CardInfoPtr currentCard = db->getCardInfo(cardName); if (currentCard) { const QString colors = currentCard->getColors(); // returns something like "WUB" for (const QChar &color : colors) { diff --git a/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h index 93d454c58..04294cb1c 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h +++ b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h @@ -3,6 +3,7 @@ #include +class CardDatabaseQuerier; class DeckList; /** @@ -11,7 +12,9 @@ class DeckList; * * Shared as a free function so the deck storage previews and the deck share * dialog compute identities identically. + * + * @param db Card database used to look up card color symbols. */ -QString getDeckColorIdentity(const DeckList &deck); +QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db); #endif // COCKATRICE_DECK_COLOR_IDENTITY_H diff --git a/cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp index 584e5237f..3fe5917dc 100644 --- a/cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp @@ -67,6 +67,11 @@ void ShareBarWidget::setHintText(const QString &text, bool visible) hintLabel->setVisible(visible); } +void ShareBarWidget::setCreateEnabled(bool enabled) +{ + createButton->setEnabled(enabled); +} + void ShareBarWidget::focusName() { nameEdit->setFocus(); diff --git a/cockatrice/src/interface/widgets/deck_share/share_bar_widget.h b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.h index 828755fa3..f3b902da8 100644 --- a/cockatrice/src/interface/widgets/deck_share/share_bar_widget.h +++ b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.h @@ -42,6 +42,9 @@ public: /** @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(); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp index fdef84673..f5f7f295c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -36,11 +37,14 @@ DlgShareDeck::DlgShareDeck(AbstractClient *_client, const QSharedPointerbutton(QDialogButtonBox::Cancel)->setText(tr("Cancel")); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgShareDeck::actShare); connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgShareDeck::reject); + this->buttonBox = buttonBox; layout->addWidget(buttonBox); } void DlgShareDeck::actShare() { + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + Command_DeckShareCreate cmd; cmd.set_name(nameEdit->text().trimmed().toStdString()); if (cmd.name().empty()) { @@ -49,7 +53,7 @@ void DlgShareDeck::actShare() DeckShareItem *item = cmd.add_items(); item->set_deck_list(deck->writeToString_Native().toStdString()); - item->set_color_identity(getDeckColorIdentity(*deck).toStdString()); + item->set_color_identity(getDeckColorIdentity(*deck, CardDatabaseManager::query()).toStdString()); PendingCommand *pend = client->prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, &DlgShareDeck::shareFinished); @@ -59,6 +63,7 @@ void DlgShareDeck::actShare() void DlgShareDeck::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/) { if (response.response_code() != Response::RespOk) { + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); QMessageBox::critical(this, tr("Share deck"), tr("Failed to create the share link (server response code %1).") .arg(QString::number(static_cast(response.response_code())))); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h index 30c28fb73..0e8046bcd 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h @@ -13,6 +13,7 @@ class AbstractClient; class CommandContainer; class DeckList; +class QDialogButtonBox; class QLineEdit; class Response; @@ -36,6 +37,7 @@ private: AbstractClient *client; QSharedPointer deck; QLineEdit *nameEdit; + QDialogButtonBox *buttonBox; }; #endif // DLG_SHARE_DECK_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp index 0b830c32d..3bd60ad32 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp @@ -388,6 +388,11 @@ bool AbstractTabDeckEditor::actSaveDeckAs() */ void AbstractTabDeckEditor::actShareDeck() { + if (tabSupervisor->getClient()->getStatus() != StatusLoggedIn) { + QMessageBox::information(this, tr("Share deck"), tr("You must be connected to the server to share a deck.")); + return; + } + const QSharedPointer deck = deckStateManager->getDeckListShared(); if (deck->isBlankDeck()) { return; diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index e796f798d..6c3f30cda 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -1,29 +1,24 @@ #include "tab_deck_storage.h" #include "../../../client/settings/cache_settings.h" -#include "../../../main.h" #include "../../deck_loader/deck_loader.h" +#include "../deck_share/deck_share_utils.h" #include "../deck_share/share_bar_widget.h" #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "../interface/widgets/utility/get_text_with_max.h" #include #include -#include #include #include #include #include #include -#include #include #include #include #include -#include #include -#include -#include #include #include #include @@ -248,12 +243,14 @@ void TabDeckStorage::setRemoteEnabled(bool enabled) aUpload->setEnabled(enabled); aOpenRemoteDeck->setEnabled(enabled); aDownload->setEnabled(enabled); + aShareDecks->setEnabled(enabled); aNewFolder->setEnabled(enabled); aDeleteRemoteDeck->setEnabled(enabled); if (enabled) { serverDirView->refreshTree(); } else { + setShareModeEnabled(false); serverDirView->clearTree(); } } @@ -669,6 +666,7 @@ void TabDeckStorage::setShareModeEnabled(bool enabled) { shareBar->setVisible(enabled); if (enabled) { + shareBar->setCreateEnabled(true); shareBar->setName(tr("Shared decks")); onServerSelectionChanged(); shareBar->focusName(); @@ -692,6 +690,17 @@ void TabDeckStorage::onServerSelectionChanged() ++files; } } + + QString hint; + if (folders > 1) { + hint = tr("Only one folder can be shared at a time."); + } else if (folders > 0 && files > 0) { + hint = tr("Share either a folder or decks, not both."); + } else if (folders == 0 && files == 0) { + hint = tr("Select folders or decks in the tree to share."); + } + shareBar->setHintText(hint, !hint.isEmpty()); + QStringList parts; if (folders > 0) { parts << tr("%n folder(s)", "", folders); @@ -705,6 +714,30 @@ void TabDeckStorage::onServerSelectionChanged() void TabDeckStorage::actShareSelection() { const auto selection = serverDirView->getCurrentSelection(); + QString sharedFolder; + bool hasFile = false; + bool hasFolder = false; + for (const auto *node : selection) { + if (const auto *dirNode = dynamic_cast(node)) { + hasFolder = true; + if (!sharedFolder.isEmpty()) { + showShareNotice(tr("Only one folder can be shared at a time."), true); + return; + } + sharedFolder = dirNode->getPath(); + } else { + hasFile = true; + } + } + + if (hasFile && hasFolder) { + showShareNotice(tr("Share either a folder or decks, not both."), true); + return; + } + if (hasFolder && sharedFolder.isEmpty()) { + showShareNotice(tr("The root folder cannot be shared."), true); + return; + } Command_DeckShareCreate cmd; cmd.set_name(shareBar->name().toStdString()); @@ -712,19 +745,9 @@ void TabDeckStorage::actShareSelection() cmd.set_name(tr("Shared decks").toStdString()); } - // Sharing a folder is exclusive with sharing individual decks (matches the picker's rule). - for (const auto *node : selection) { - if (const auto *dirNode = dynamic_cast(node)) { - const QString path = dirNode->getPath(); - if (path.isEmpty()) { - continue; // the root folder cannot be shared - } - cmd.set_folder_path(path.toStdString()); - break; - } - } - - if (cmd.folder_path().empty()) { + if (!sharedFolder.isEmpty()) { + cmd.set_folder_path(sharedFolder.toStdString()); + } else { for (const auto *node : selection) { if (const auto *fileNode = dynamic_cast(node)) { DeckShareItem *item = cmd.add_items(); @@ -734,10 +757,11 @@ void TabDeckStorage::actShareSelection() } if (cmd.items_size() == 0 && cmd.folder_path().empty()) { - showShareNotice(tr("Select decks to share.")); + showShareNotice(tr("Select decks to share."), true); return; } + shareBar->setCreateEnabled(false); PendingCommand *pend = client->prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, &TabDeckStorage::shareFromTreeFinished); client->sendCommand(pend); @@ -745,29 +769,29 @@ void TabDeckStorage::actShareSelection() void TabDeckStorage::shareFromTreeFinished(const Response &response, const CommandContainer & /*commandContainer*/) { + shareBar->setCreateEnabled(true); if (response.response_code() != Response::RespOk) { qWarning() << "failed to create deck share:" << response.response_code(); showShareNotice(tr("Failed to create the share link (server response code %1).") - .arg(QString::number(static_cast(response.response_code())))); + .arg(QString::number(static_cast(response.response_code()))), + true); return; } const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext); const QString token = QString::fromStdString(resp.token()); - const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC).toLocalTime(); + const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC); - const QString link = QString("cockatrice://opendeck?share=%1&hostname=%2&port=%3") - .arg(token, client->serverName(), QString::number(client->serverPort())); - QGuiApplication::clipboard()->setText(link); + const QString link = DeckShareUtils::buildShareLink(client, token); + DeckShareUtils::copyShareLinkToClipboard(link); - showShareNotice(tr("Share link copied to the clipboard.\nExpires on %1.").arg(expiry.toString())); + showShareNotice( + tr("Share link copied to the clipboard.\nExpires on %1.").arg(DeckShareUtils::formatShareExpiry(expiry))); setShareModeEnabled(false); } -void TabDeckStorage::showShareNotice(const QString &message) +void TabDeckStorage::showShareNotice(const QString &message, bool warning) { - if (trayIcon && trayIcon->isVisible()) { - trayIcon->showMessage(tr("Deck share"), message); - } else if (auto *mainWindow = qobject_cast(window())) { - mainWindow->statusBar()->showMessage(message, 10000); - } + QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Deck share"), message, + QMessageBox::Ok, this); + box.exec(); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h index 5f28f7d55..201506789 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h @@ -45,7 +45,7 @@ private: void setRemoteEnabled(bool enabled); - void showShareNotice(const QString &message); + void showShareNotice(const QString &message, bool warning = false); void setShareModeEnabled(bool enabled); diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp index 558c080c2..fca9c9f0c 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp @@ -1,17 +1,15 @@ #include "tab_deck_storage_visual.h" -#include "../../../../main.h" #include "../../../deck_loader/deck_loader.h" #include "../../cards/additional_info/deck_color_identity.h" #include "../../deck_share/deck_share_utils.h" #include "../../interface/widgets/visual_deck_storage/visual_deck_storage_widget.h" #include "../tab_supervisor.h" -#include +#include #include -#include -#include #include +#include #include #include #include @@ -82,6 +80,7 @@ void TabDeckStorageVisual::enterShareMode(const QStringList &preselectFiles) if (!shareDeckAvailable) { return; // sharing is gated on being logged in } + shareBar->setCreateEnabled(true); visualDeckStorageWidget->setShareSelectable(true); visualDeckStorageWidget->setShareSelectedFiles(preselectFiles); shareBar->setName(tr("Shared decks")); @@ -99,6 +98,10 @@ void TabDeckStorageVisual::exitShareMode() void TabDeckStorageVisual::actShareDeck(const QString &filePath) { + if (!shareDeckAvailable) { + QMessageBox::information(this, tr("Share deck"), tr("You must be connected to the server to share a deck.")); + return; + } enterShareMode({filePath}); } @@ -145,9 +148,10 @@ void TabDeckStorageVisual::actShareSelected() } DeckShareItem *item = cmd.add_items(); item->set_deck_list(deckOpt->deckList.writeToString_Native().toStdString()); - item->set_color_identity(getDeckColorIdentity(deckOpt->deckList).toStdString()); + item->set_color_identity(getDeckColorIdentity(deckOpt->deckList, CardDatabaseManager::query()).toStdString()); } + shareBar->setCreateEnabled(false); PendingCommand *pend = tabSupervisor->getClient()->prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, &TabDeckStorageVisual::shareFinished); tabSupervisor->getClient()->sendCommand(pend); @@ -155,10 +159,11 @@ void TabDeckStorageVisual::actShareSelected() void TabDeckStorageVisual::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/) { + shareBar->setCreateEnabled(true); if (response.response_code() != Response::RespOk) { showShareNotice(tr("Failed to create the share link (server response code %1).") - .arg(QString::number(static_cast(response.response_code())))); - exitShareMode(); + .arg(QString::number(static_cast(response.response_code()))), + true); return; } @@ -183,11 +188,9 @@ void TabDeckStorageVisual::handleConnectionChanged(ClientStatus status) } } -void TabDeckStorageVisual::showShareNotice(const QString &message) +void TabDeckStorageVisual::showShareNotice(const QString &message, bool warning) { - if (trayIcon && trayIcon->isVisible()) { - trayIcon->showMessage(tr("Deck share"), message); - } else if (auto *mainWindow = qobject_cast(window())) { - mainWindow->statusBar()->showMessage(message, 10000); - } + QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Deck share"), message, + QMessageBox::Ok, this); + box.exec(); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h index c91977ad4..da9f037b4 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h @@ -67,7 +67,7 @@ private slots: void handleConnectionChanged(ClientStatus status); private: - void showShareNotice(const QString &message); + void showShareNotice(const QString &message, bool warning = false); void updateShareHint(); VisualDeckStorageWidget *visualDeckStorageWidget; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index 470e9a43e..28491ef73 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -50,7 +50,7 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare shareButton = new QToolButton(this); shareButton->setIcon(QPixmap("theme:icons/share")); shareButton->setFixedSize(32, 32); - shareButton->setToolTip(tr("Share selected decks")); + shareButton->setToolTip(tr("Select decks to share")); shareButton->setVisible(false); connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested); From 812f2a88eac35b028e0818f0c390b49684b9e024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sat, 5 Sep 2026 22:56:09 +0200 Subject: [PATCH 5/6] [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. --- cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp | 4 ++++ cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp | 4 ++++ .../tabs/visual_deck_storage/tab_deck_storage_visual.cpp | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp index f5f7f295c..032407e38 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp @@ -72,7 +72,11 @@ void DlgShareDeck::shareFinished(const Response &response, const CommandContaine const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext); const QString token = QString::fromStdString(resp.token()); +#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 const QString link = DeckShareUtils::buildShareLink(client, token); DeckShareUtils::copyShareLinkToClipboard(link); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 6c3f30cda..f9ccd6beb 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -779,7 +779,11 @@ void TabDeckStorage::shareFromTreeFinished(const Response &response, const Comma } const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext); const QString token = QString::fromStdString(resp.token()); +#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 const QString link = DeckShareUtils::buildShareLink(client, token); DeckShareUtils::copyShareLinkToClipboard(link); diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp index fca9c9f0c..e042211f3 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp @@ -169,7 +169,11 @@ void TabDeckStorageVisual::shareFinished(const Response &response, const Command const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext); const QString token = QString::fromStdString(resp.token()); +#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 const QString link = DeckShareUtils::buildShareLink(tabSupervisor->getClient(), token); DeckShareUtils::copyShareLinkToClipboard(link); From 7faa2fca131e5ca635698a0273142a46c1508ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sat, 5 Sep 2026 08:31:27 +0200 Subject: [PATCH 6/6] [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 --- cockatrice/CMakeLists.txt | 5 + .../intents/contexts/context_open_deck.h | 14 + cockatrice/src/interface/intents/intent.cpp | 13 +- cockatrice/src/interface/intents/intent.h | 2 + .../intents/intent_join_server_game.cpp | 11 +- .../src/interface/intents/intent_login.cpp | 27 +- .../intents/intent_open_shared_deck.cpp | 190 ++++++++++++ .../intents/intent_open_shared_deck.h | 59 ++++ .../src/interface/intents/url_parser.cpp | 288 ++++++++++++++++-- cockatrice/src/interface/intents/url_parser.h | 36 ++- ..._info_picture_with_text_overlay_widget.cpp | 6 +- .../deck_preview_card_picture_widget.cpp | 26 +- .../cards/deck_preview_card_picture_widget.h | 18 +- .../deck_share/shared_deck_preview_widget.cpp | 139 +++++++++ .../deck_share/shared_deck_preview_widget.h | 77 +++++ .../widgets/dialogs/dlg_login_prompt.cpp | 50 +++ .../widgets/dialogs/dlg_login_prompt.h | 40 +++ .../dialogs/dlg_shared_decks_preview.cpp | 178 +++++++++++ .../dialogs/dlg_shared_decks_preview.h | 68 +++++ .../general/layout_containers/flow_widget.cpp | 45 +++ .../general/layout_containers/flow_widget.h | 2 + cockatrice/src/interface/window_main.cpp | 49 ++- cockatrice/src/interface/window_main.h | 17 +- cockatrice/src/main.cpp | 20 +- cockatrice/src/single_instance_manager.cpp | 25 +- 25 files changed, 1350 insertions(+), 55 deletions(-) create mode 100644 cockatrice/src/interface/intents/contexts/context_open_deck.h create mode 100644 cockatrice/src/interface/intents/intent_open_shared_deck.cpp create mode 100644 cockatrice/src/interface/intents/intent_open_shared_deck.h create mode 100644 cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp create mode 100644 cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 60a4e6905..524497098 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 @@ -445,6 +448,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..aa06fd81b 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; @@ -46,3 +47,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..6bf46ec72 100644 --- a/cockatrice/src/interface/intents/intent_join_server_game.cpp +++ b/cockatrice/src/interface/intents/intent_join_server_game.cpp @@ -19,13 +19,10 @@ 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) { - return false; - } - if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) { + // 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 only the hostname is compared here. + if (remoteClient->serverName().compare(context->roomContext.serverContext.hostname, Qt::CaseInsensitive) != 0) { return false; } diff --git a/cockatrice/src/interface/intents/intent_login.cpp b/cockatrice/src/interface/intents/intent_login.cpp index ff871fd03..344ef4e00 100644 --- a/cockatrice/src/interface/intents/intent_login.cpp +++ b/cockatrice/src/interface/intents/intent_login.cpp @@ -1,8 +1,11 @@ #include "intent_login.h" #include "../../client/settings/cache_settings.h" +#include "../widgets/dialogs/dlg_login_prompt.h" #include "libcockatrice/settings/servers_settings.h" +#include + IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context) : Intent(), context(_context) { } @@ -29,5 +32,27 @@ void IntentGetLoginCredentials::onPreconditionSatisfied() void IntentGetLoginCredentials::onPreconditionNotSatisfied() { - emitFailed(tr("No saved credentials for this server")); + // 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(); + servers.addNewServer(context->hostname, context->hostname, context->port, context->username, context->password, + true); + } + + emitFinished(); } 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..c7d4f5469 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp @@ -0,0 +1,190 @@ +#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() 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 only the hostname is compared here. + return remoteClient->serverName().compare(context->serverContext.hostname, Qt::CaseInsensitive) == 0; +} + +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. + 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 */) +{ + 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() +{ + onItemFailure(tr("Timed out while downloading the shared deck")); +} + +void IntentOpenSharedDeck::finishAll() +{ + previewDialog->deleteLater(); + for (const LoadedDeck &deck : loadedDecks) { + tabSupervisor->openDeckInNewTab(deck); + } + emitFinished(); +} \ No newline at end of file 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..ac8d443b8 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.h @@ -0,0 +1,59 @@ +#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; + int currentItemId = 0; + int totalItems = 0; + int completedItems = 0; +}; + +#endif // COCKATRICE_INTENT_OPEN_SHARED_DECK_H \ No newline at end of file diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp index 509390611..ee5e084dd 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; + + QList 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, QList &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,32 @@ 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.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); + getLoginCredentialsIntent->setParent(joinGameIntent); + chain.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 +168,221 @@ 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, QList &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(); + + // When the link would move us away from a live session, ask first — the + // open deck download needs the connection the user already has. Remember + // the current session so a failed or cancelled chain can restore it. + const bool migrating = + client->getStatus() == StatusLoggedIn && !isConnectedTo(ctx->serverContext.hostname, ctx->serverContext.port); + if (migrating) { + const QString target = QStringLiteral("%1:%2").arg(ctx->serverContext.hostname, ctx->serverContext.port); + 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; + } + migrationTargetHost = ctx->serverContext.hostname; + migrationTargetPort = ctx->serverContext.port; + previousServerHost = client->serverName(); + previousServerPort = QString::number(client->serverPort()); + pendingRestore = true; + } + + 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.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); + getLoginCredentialsIntent->setParent(openDeckIntent); + chain.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 +{ + Q_UNUSED(port); + // Deliberately hostname-only (no port): the intents' preconditions apply the + // same rule, so a link to the same host on another port still connects + // rather than silently reusing an existing session on a different server. + RemoteClient *client = mainWindow->getRemoteClient(); + return client->getStatus() == StatusLoggedIn && client->serverName().compare(hostname, Qt::CaseInsensitive) == 0; +} + +void IntentUrlParser::startNextChain() +{ + if (chainRunning || pendingChains.isEmpty()) { + return; + } + chainRunning = true; + currentChainSucceeded = false; + + const QList chain = pendingChains.takeFirst(); + if (chain.isEmpty()) { + chainRunning = false; + return; + } + + // 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.last(); + connect(finalIntent, &Intent::finished, this, [this]() { + currentChainSucceeded = true; + chainEnded(); + }); + connect(finalIntent, &Intent::failed, this, &IntentUrlParser::chainEnded); + connect(finalIntent, &Intent::cancelled, this, &IntentUrlParser::chainEnded); + + chain.first()->execute(); +} + +void IntentUrlParser::chainEnded() +{ + chainRunning = false; + + // Only a failed or cancelled chain restores the session the link migrated + // away from; a successful one leaves the user where they are. + if (pendingRestore && !currentChainSucceeded) { + restorePreviousServer(); + } + pendingRestore = false; + + 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::restorePreviousServer() +{ + if (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(); + return; + } + auto waitConnection = std::make_shared(); + *waitConnection = connect(client, &RemoteClient::statusChanged, this, [this, client, waitConnection]() { + const ClientStatus settled = client->getStatus(); + if (settled == StatusDisconnected || settled == StatusLoggedIn) { + QObject::disconnect(*waitConnection); + restoreToPreviousServer(); + } + }); +} + +void IntentUrlParser::restoreToPreviousServer() +{ + RemoteClient *client = mainWindow->getRemoteClient(); + + // Back on the previous server already → nothing to undo. + if (client->serverName().compare(previousServerHost, Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == 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(migrationTargetHost, Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == migrationTargetPort; + if (!onMigrationTarget) { + return; + } + + ServersSettings &servers = SettingsCache::instance().servers(); + const int index = servers.findServerIndex(previousServerHost, previousServerPort); + if (index >= 0 && servers.hasLoginData(previousServerHost, 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(previousServerHost, 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(previousServerHost, previousServerPort); + if (index >= 0 && servers.hasLoginData(previousServerHost, 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(previousServerHost, previousServerPort.toUInt(), username, password); + } +} diff --git a/cockatrice/src/interface/intents/url_parser.h b/cockatrice/src/interface/intents/url_parser.h index 6d705e013..7464caf4f 100644 --- a/cockatrice/src/interface/intents/url_parser.h +++ b/cockatrice/src/interface/intents/url_parser.h @@ -1,10 +1,22 @@ #ifndef COCKATRICE_URL_PARSER_H #define COCKATRICE_URL_PARSER_H +#include #include #include +class Intent; class MainWindow; struct ContextJoinGame; + +/** + * @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 +24,34 @@ 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, QList &chain); + Intent *createOpenDeckIntent(const QUrlQuery &query, QList &chain); QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription); + [[nodiscard]] bool isConnectedTo(const QString &hostname, const QString &port) const; + void startNextChain(); + void chainEnded(); + void restorePreviousServer(); + void restoreToPreviousServer(); MainWindow *mainWindow; + QList> pendingChains; + bool chainRunning = false; + bool currentChainSucceeded = false; + + // Set when an open-deck link migrates the session to another server. If the + // chain then fails or is cancelled while still on that server, the previous + // session is restored (reconnect if credentials are saved, else disconnect). + QString migrationTargetHost; + QString migrationTargetPort; + QString previousServerHost; + QString previousServerPort; + bool pendingRestore = false; }; #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..d52fa456b --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp @@ -0,0 +1,139 @@ +#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()); + + gameFormatLabel = new QLabel(gameFormat, 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); +} \ No newline at end of file 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..fbc07b7e0 --- /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(); +} \ No newline at end of file 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..47d8586c4 --- /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 \ No newline at end of file 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..6576a3844 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp @@ -0,0 +1,178 @@ +#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); + + auto *titleLabel = new QLabel(tr("Share: %1").arg(shareName.isEmpty() ? tr("Untitled") : shareName), 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), 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->addWidget(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); +} \ No newline at end of file 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..6e04fed5a 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 @@ -177,6 +178,50 @@ QLayoutItem *FlowWidget::itemAt(int index) const return flowLayout->itemAt(index); } +void FlowWidget::keyPressEvent(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) { + QWidget::keyPressEvent(event); + return; + } + + 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()) { + QWidget::keyPressEvent(event); + return; + } + + 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(); +} + 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..3f3a2be2b 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 @@ -44,6 +45,7 @@ public slots: protected: void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; private: Qt::Orientation flowDirection; diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 4567991c8..e3341711f 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -507,6 +507,7 @@ MainWindow::MainWindow(QWidget *parent) connectionController = new ConnectionController(this, this); urlParser = new IntentUrlParser(this, this); + connect(urlParser, &IntentUrlParser::urlChainFinished, this, &MainWindow::onUrlChainFinished); createActions(); createMenus(); @@ -722,6 +723,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); @@ -862,18 +864,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(); } } @@ -899,6 +890,40 @@ 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 || !skipStartupAutoConnect || getRemoteClient()->getStatus() != StatusDisconnected) { + 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 920145552..747bb7d80 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -75,6 +75,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); @@ -92,7 +93,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); @@ -120,6 +121,8 @@ private slots: void startupDestinationFailed(const QString &reason); [[nodiscard]] bool startupDestinationConnectsToServer() const; + void attemptStartupAutoConnect(); + private: static const QString appName; static const QStringList fileNameFilters; @@ -158,6 +161,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; DlgViewLog *logviewDialog; GameReplay *replay; @@ -170,6 +175,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 d8aa1cd08..0e719d8ce 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,7 @@ #include #include #include +#include #include #include #include @@ -273,10 +273,12 @@ int main(int argc, char *argv[]) SingleInstanceManager instance; if (hasActivationFiles) { + qInfo() << "Activation launch, files:" << startupFiles; // 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 +327,22 @@ 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). + const bool hasUrlActivation = std::any_of(startupFiles.begin(), startupFiles.end(), [](const QString &file) { + return file.startsWith(QStringLiteral("cockatrice://")); + }); + ui.setSkipStartupAutoConnect(hasUrlActivation); + auto handleActivation = [&ui](const QString &file) { if (file.startsWith("cockatrice://")) { - auto urlParser = new IntentUrlParser(&ui, &ui); - urlParser->handle(file); + qInfo() << "Handling URL activation:" << 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..b207a9250 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) { } @@ -72,7 +80,14 @@ bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) socket.flush(); socket.waitForBytesWritten(1000); - return true; + // Only report a successful hand-off once the primary has acknowledged that + // it actually read the payload. A socket that connects but never answers + // belongs to a process that is dying, so the caller must not treat this as + // a hand-off (otherwise it would exit without anyone handling the files). + if (!socket.waitForReadyRead(1000)) { + return false; + } + return socket.readAll() == ACK_MESSAGE; } void SingleInstanceManager::handleNewConnection() @@ -111,6 +126,14 @@ void SingleInstanceManager::handleNewConnection() QStringList files; payloadStream >> 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(); + emit filesReceived(files); // Reset buffer (single message use-case)