diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile index e3df94ab5..fc756aac2 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -18,12 +18,12 @@ RUN apt-get update && \ libssl-dev \ ninja-build \ protobuf-compiler \ + qt6-declarative-dev \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ - qt6-declarative-dev \ - qt6-svg-dev \ qt6-shadertools-dev \ + qt6-svg-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile index 60e490c98..bdecb56df 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -19,12 +19,12 @@ RUN apt-get update && \ libssl-dev \ ninja-build \ protobuf-compiler \ + qt6-declarative-dev \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ - qt6-declarative-dev \ - qt6-svg-dev \ qt6-shadertools-dev \ + qt6-svg-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora43/Dockerfile index 4005bbf67..463da5a51 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora43/Dockerfile @@ -9,7 +9,7 @@ RUN dnf install -y \ ninja-build \ openssl-devel \ protobuf-devel \ - qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qtdeclarative,qtmultimedia,qtshadertools,qtsvg,qttools,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile index e0224cdc6..62238e760 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -9,7 +9,7 @@ RUN dnf install -y \ ninja-build \ openssl-devel \ protobuf-devel \ - qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qtdeclarative,qtmultimedia,qtshadertools,qtsvg,qttools,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile index 10adc5e64..715997474 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -18,12 +18,12 @@ RUN apt-get update && \ libssl-dev \ ninja-build \ protobuf-compiler \ + qt6-declarative-dev \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ - qt6-declarative-dev \ - qt6-svg-dev \ qt6-shadertools-dev \ + qt6-svg-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index 1b6cf825f..96dd10763 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -19,12 +19,12 @@ RUN apt-get update && \ libssl-dev \ ninja-build \ protobuf-compiler \ + qt6-declarative-dev \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ - qt6-declarative-dev \ - qt6-svg-dev \ qt6-shadertools-dev \ + qt6-svg-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/compile.sh b/.ci/compile.sh index bd8c900c8..f20432893 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -327,4 +327,32 @@ if [[ $MAKE_PACKAGE ]]; then BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX" echo "::endgroup::" fi + + if [[ $RUNNER_OS == Windows ]]; then + echo "::group::Check installer for build-tree artifacts" + cd "$BUILD_DIR" + package="$(find . -maxdepth 1 -type f -name 'Cockatrice-*.exe' -print -quit)" + if [[ ! $package ]]; then + echo "::error file=$0::Could not find installer to inspect" + exit 1 + fi + seven_zip="$(command -v 7z || true)" + if [[ ! $seven_zip ]]; then + seven_zip="/c/Program Files/7-Zip/7z.exe" + fi + if [[ ! -f $seven_zip ]]; then + echo "::warning file=$0::7-Zip not found, skipping installer content check" + else + echo "Inspecting $package" + # Fail the build if the installer contains any path left behind by the MSBuild or + # Qt AUTOMOC tooling (build-tree artifacts must live in the build dir, not the install) + if "$seven_zip" l "$package" | + grep -E "_autogen|\.dir[\\/]|\.tlog|(^|[\\/])x64[\\/]|(^|[\\/])\.qt[\\/]|(^|[\\/])\.qsb[\\/]|(^|[\\/])\.lupdate[\\/]|CMakeFiles"; then + echo "::error file=$0::Installer contains build-tree artifacts" + exit 1 + fi + echo "Installer content is clean" + fi + echo "::endgroup::" + fi fi diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 6ca634389..bd528f245 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -272,8 +272,8 @@ jobs: make_package: 1 override_target: 13 package_suffix: "-macOS13_Intel" - qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Intel type: Release use_ccache: 1 @@ -288,8 +288,8 @@ jobs: make_package: 1 override_target: 14 package_suffix: "-macOS14" - qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Release use_ccache: 1 @@ -304,8 +304,8 @@ jobs: make_package: 1 override_target: 15 package_suffix: "-macOS15" - qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Release use_ccache: 1 @@ -317,8 +317,8 @@ jobs: ccache_eviction_age: 7d cmake_generator: Ninja - qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Debug use_ccache: 1 @@ -332,8 +332,8 @@ jobs: cmake_generator_platform: x64 make_package: 1 package_suffix: "-Win10" - qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets type: Release name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} @@ -368,18 +368,20 @@ jobs: key: ccache-${{ matrix.runner }}_${{ matrix.override_target }}-Xcode${{ matrix.xcode }} path: ${{ env.CCACHE_DIR }} - - name: "Install aqtinstall" + - name: "[macOS] Install aqtinstall" + if: matrix.os == 'macOS' run: pipx install aqtinstall # Resolve given wildcard versions (e.g. Qt 6.6.*) to latest version via aqtinstall to avoid stale caches on new releases - - name: "Resolve latest Qt patch version" + - name: "[macOS] Resolve latest Qt from ${{ matrix.qt_version }} input" + if: matrix.os == 'macOS' env: QT_VERSION: ${{ matrix.qt_version }} id: resolve_qt_version shell: bash run: .ci/resolve_latest_aqt_qt_version.sh "$QT_VERSION" - - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }} libraries" + - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }}" if: matrix.os == 'macOS' id: restore_qt uses: actions/cache/restore@v6 @@ -389,21 +391,22 @@ jobs: # Using jurplel/install-qt-action to install Qt without using brew # Qt build using vcpkg either just fails or takes too long to build - - name: "[macOS] Install fat Qt ${{ steps.resolve_qt_version.outputs.version }}" + - name: "[macOS] Install fat Qt ${{ matrix.qt_version }}" if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' uses: jurplel/install-qt-action@v4 with: cache: false - dir: ${{ github.workspace }} + # cache-key-prefix: Qt + dir: ${{ github.workspace }} # thinning script depends on this location modules: ${{ matrix.qt_modules }} - version: ${{ steps.resolve_qt_version.outputs.version }} + version: ${{ matrix.qt_version }} - name: "[macOS] Create thin Qt libraries" if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' run: .ci/thin_macos_qtlib.sh - name: "[macOS] Cache thin Qt libraries" - if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' + if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' && github.ref == 'refs/heads/master' uses: actions/cache/save@v6 with: key: ${{ steps.restore_qt.outputs.cache-primary-key }} @@ -415,10 +418,10 @@ jobs: with: # Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released aqtsource: git+https://github.com/miurahr/aqtinstall.git - cache: true + cache: ${{ github.ref == 'refs/heads/master' }} cache-key-prefix: Qt modules: ${{ matrix.qt_modules }} - version: ${{ steps.resolve_qt_version.outputs.version }} + version: ${{ matrix.qt_version }} - name: "[Windows] Install NSIS" if: matrix.os == 'Windows' @@ -450,7 +453,7 @@ jobs: PACKAGE_SUFFIX: '${{ matrix.package_suffix }}' TARGET_MACOS_VERSION: ${{ matrix.override_target }} USE_CCACHE: ${{ matrix.use_ccache }} - VCPKG_BINARY_SOURCES: 'clear;files,${{ steps.vcpkg-cache.outputs.path }},readwrite' + VCPKG_BINARY_SOURCES: "clear;files,${{ steps.vcpkg-cache.outputs.path }},${{ case(github.ref == 'refs/heads/master', 'readwrite', 'read') }}" VCPKG_DISABLE_METRICS: 1 VCPKG_FEATURE_FLAGS: dependencygraph run: .ci/compile.sh --server --test --vcpkg diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 255e8b045..967d94c58 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -76,7 +76,7 @@ jobs: uses: docker/build-push-action@v7 with: cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} - cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }} + cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=max,scope={0}', env.CACHE_SCOPE), '') }} context: . platforms: ${{ matrix.platform }} push: false diff --git a/CMakeLists.txt b/CMakeLists.txt index 0da073464..293e25dd9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -293,7 +293,7 @@ if(UNIX) if(CPACK_GENERATOR STREQUAL "RPM") set(CPACK_RPM_PACKAGE_LICENSE "GPLv2") set(CPACK_RPM_MAIN_COMPONENT "cockatrice") - set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qttools, qt6-qtsvg, qt6-qtmultimedia, qt6-qtimageformats") + set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qtimageformats, qt6-qtmultimedia, qt6-qtsvg, qt6-qttools") set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games") set(CPACK_RPM_PACKAGE_URL "http://github.com/Cockatrice/Cockatrice") # stop directories from making package conflicts @@ -311,7 +311,7 @@ if(UNIX) set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) set(CPACK_DEBIAN_PACKAGE_SECTION "games") set(CPACK_DEBIAN_PACKAGE_HOMEPAGE "http://github.com/Cockatrice/Cockatrice") - set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins") + set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-image-formats-plugins, qt6-qpa-plugins") set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting servatrice to a mysql db endif() endif() diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 5af116470..f3f172693 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -131,6 +131,13 @@ ${EndIf} ; Now that $PortableMode reflects reality, commit InstDir into the correct slot Call SetModeDestinationFromInstdir +; Make sure no application instance is still running (and holding file locks) +; before the previous version is uninstalled or new files are installed. +; On a silent update (/R /S) running processes are asked to close gracefully +; and waited for, then force-closed only on timeout. On interactive installs +; the user is prompted to close them instead. +Call EnsureAppsNotRunning + ${If} $ReinstallMode = 1 ${AndIf} $PortableMode = 0 Call AutoUninstallIfNeeded @@ -144,6 +151,9 @@ ${If} ${NSIS_IS_64_BIT} == 1 SetRegView 64 ${EndIf} +; Ensure no application instance is still running before removing files. +Call un.EnsureAppsNotRunning + FunctionEnd Function RequireAdmin @@ -199,6 +209,170 @@ ${EndIf} FunctionEnd +; --- Running instance handling --- +; Cockatrice, Oracle and Servatrice must not be running while files are +; replaced or deleted. A still-running process holds locks on its .exe and +; Qt runtime DLLs, so a silent upgrade could otherwise end up with a mix of +; old and new Qt DLLs next to the new executable, failing with +; "The procedure entry point X could not be located in the dynamic link +; library ...Qt6Network.dll" on the next start. +; +; Processes are matched by image name AND by their executable path living +; under $INSTDIR, so unrelated processes that merely share an image name +; (e.g. the Oracle DB instance "oracle.exe") are never touched. + +; usage: set $R2 to the base image name (without extension, e.g. "cockatrice", +; as accepted by Get-Process -Name), call this, result in $R0 +; (1 = running from $INSTDIR, 0 = not running) +Function IsAppRunning + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Select-Object -First 1"' + Pop $R0 + ${If} $R0 = 0 + StrCpy $R0 1 + ${Else} + StrCpy $R0 0 + ${EndIf} +FunctionEnd + +Function CloseMatchingApps + ; gracefully ask every matching instance to close (sends WM_CLOSE) + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | ForEach-Object { $$null = $$_.CloseMainWindow() }"' + Pop $R3 +FunctionEnd + +Function ForceCloseMatchingApps + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Stop-Process -Force -ErrorAction SilentlyContinue"' + Pop $R3 +FunctionEnd + +Function WaitForAppToClose + ; usage: set $R1 to the display name (e.g. "cockatrice.exe") and $R2 to the + ; base image name (e.g. "cockatrice") + Call IsAppRunning + ${If} $R0 = 0 + Return + ${EndIf} + + ${If} ${Silent} + ; ask the application to close gracefully (WM_CLOSE), then wait for it to exit + DetailPrint "Closing $R1 ..." + Call CloseMatchingApps + StrCpy $R8 0 + ck_wait_loop: + Sleep 500 + IntOp $R8 $R8 + 1 + Call IsAppRunning + ${If} $R0 = 0 + DetailPrint "$R1 closed." + Return + ${EndIf} + ${If} $R8 < 60 + Goto ck_wait_loop + ${EndIf} + ; give up waiting, force close + DetailPrint "Force closing $R1 ..." + Call ForceCloseMatchingApps + Sleep 500 + ${Else} + ck_wait_prompt: + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \ + IDCANCEL ck_abort_install + Call IsAppRunning + ${If} $R0 = 0 + Return + ${EndIf} + Goto ck_wait_prompt + ck_abort_install: + Abort + ${EndIf} +FunctionEnd + +Function EnsureAppsNotRunning + StrCpy $R1 "cockatrice.exe" + StrCpy $R2 "cockatrice" + Call WaitForAppToClose + StrCpy $R1 "oracle.exe" + StrCpy $R2 "oracle" + Call WaitForAppToClose + StrCpy $R1 "servatrice.exe" + StrCpy $R2 "servatrice" + Call WaitForAppToClose +FunctionEnd + +; Uninstaller copies of the same routines (the uninstaller gets its own +; function set compiled in, it cannot call the installer functions). +Function un.IsAppRunning + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Select-Object -First 1"' + Pop $R0 + ${If} $R0 = 0 + StrCpy $R0 1 + ${Else} + StrCpy $R0 0 + ${EndIf} +FunctionEnd + +Function un.CloseMatchingApps + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | ForEach-Object { $$null = $$_.CloseMainWindow() }"' + Pop $R3 +FunctionEnd + +Function un.ForceCloseMatchingApps + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Stop-Process -Force -ErrorAction SilentlyContinue"' + Pop $R3 +FunctionEnd + +Function un.WaitForAppToClose + Call un.IsAppRunning + ${If} $R0 = 0 + Return + ${EndIf} + + ${If} ${Silent} + DetailPrint "Closing $R1 ..." + Call un.CloseMatchingApps + StrCpy $R8 0 + un_ck_wait_loop: + Sleep 500 + IntOp $R8 $R8 + 1 + Call un.IsAppRunning + ${If} $R0 = 0 + DetailPrint "$R1 closed." + Return + ${EndIf} + ${If} $R8 < 60 + Goto un_ck_wait_loop + ${EndIf} + DetailPrint "Force closing $R1 ..." + Call un.ForceCloseMatchingApps + Sleep 500 + ${Else} + un_ck_wait_prompt: + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \ + IDCANCEL un_ck_abort_install + Call un.IsAppRunning + ${If} $R0 = 0 + Return + ${EndIf} + Goto un_ck_wait_prompt + un_ck_abort_install: + Abort + ${EndIf} +FunctionEnd + +Function un.EnsureAppsNotRunning + StrCpy $R1 "cockatrice.exe" + StrCpy $R2 "cockatrice" + Call un.WaitForAppToClose + StrCpy $R1 "oracle.exe" + StrCpy $R2 "oracle" + Call un.WaitForAppToClose + StrCpy $R1 "servatrice.exe" + StrCpy $R2 "servatrice" + Call un.WaitForAppToClose +FunctionEnd + Function PortableModePageCreate ${If} $ReinstallMode = 1 @@ -318,6 +492,26 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat" RMDir "$INSTDIR" ${EndIf} +; Belt and braces: the old uninstaller may have already run in the /R path, so +; ensure no application instance is still holding file locks, then remove any +; runtime DLLs left over from older versions. A mismatched Qt/OpenSSL set next +; to the new executable is what causes "The procedure entry point X could not be +; located in the dynamic link library ...Qt6Network.dll" after an update. +Call EnsureAppsNotRunning + +${If} $PortableMode = 0 +${AndIf} ${FileExists} "$INSTDIR\cockatrice.exe" + RMDir /r "$INSTDIR\Plugins" + Delete "$INSTDIR\Qt*.dll" + Delete "$INSTDIR\libcrypto*.dll" + Delete "$INSTDIR\libssl*.dll" + Delete "$INSTDIR\zlib*.dll" + Delete "$INSTDIR\libmysql.dll" + Delete "$INSTDIR\icu*.dll" + Delete "$INSTDIR\libeay32.dll" + Delete "$INSTDIR\ssleay32.dll" +${EndIf} + @CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@ @CPACK_NSIS_FULL_INSTALL@ @@ -387,19 +581,14 @@ SectionEnd Section "un.Application" UnSecApplication SetShellVarContext all - RMDir /r "$INSTDIR\plugins" - RMDir /r "$INSTDIR\sounds" - RMDir /r "$INSTDIR\themes" - RMDir /r "$INSTDIR\translations" - Delete "$INSTDIR\*.exe" - Delete "$INSTDIR\*.dll" - Delete "$INSTDIR\qt.conf" - Delete "$INSTDIR\qdebug.txt" - Delete "$INSTDIR\servatrice.sql" - Delete "$INSTDIR\servatrice.ini.example" - RMDir "$INSTDIR" - RMDir "$SMPROGRAMS\Cockatrice" + ; Remove the entire application directory so any file that is not part of + ; the installed payload (e.g. build-tree artifacts such as *.dir folders, + ; *_autogen and *.tlog files from a build) cannot survive between an + ; uninstall and a fresh reinstall. + RMDir /r "$INSTDIR" + + RMDir /r "$SMPROGRAMS\Cockatrice" DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" SectionEnd diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 4263fc6e2..9b31310e6 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -45,11 +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 @@ -57,6 +60,9 @@ 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/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 src/filters/filter_tree_model.cpp @@ -163,6 +169,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 @@ -317,6 +324,8 @@ set(cockatrice_SOURCES src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp + src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp + src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp @@ -388,6 +397,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab_logs.cpp src/interface/widgets/tabs/tab_message.cpp src/interface/widgets/tabs/tab_moderation.cpp + src/interface/widgets/tabs/tab_public_decks.cpp src/interface/widgets/tabs/tab_report.cpp src/interface/widgets/tabs/tab_replays.cpp src/interface/widgets/tabs/tab_room.cpp @@ -444,6 +454,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 @@ -656,18 +668,35 @@ if(WIN32) set(qtconf_dest_dir .) install( - DIRECTORY "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/${CMAKE_BUILD_TYPE}/" + DIRECTORY "$/" DESTINATION ./ FILES_MATCHING PATTERN "*.dll" + PATTERN "*.pdb" EXCLUDE + PATTERN "*.dir*" EXCLUDE + PATTERN "*_autogen*" EXCLUDE + PATTERN "*.tlog*" EXCLUDE + PATTERN "CMakeFiles*" EXCLUDE + PATTERN "x64*" EXCLUDE + PATTERN ".qt*" EXCLUDE + PATTERN ".qsb*" EXCLUDE + PATTERN ".lupdate*" EXCLUDE ) install( DIRECTORY "${CMAKE_BINARY_DIR}/cockatrice/" DESTINATION ./ FILES_MATCHING - PATTERN "CMakeFiles" EXCLUDE PATTERN "*.ini" + PATTERN "CMakeFiles*" EXCLUDE + PATTERN "*.dir*" EXCLUDE + PATTERN "*_autogen*" EXCLUDE + PATTERN "*.tlog*" EXCLUDE + PATTERN "*.pdb" EXCLUDE + PATTERN "x64*" EXCLUDE + PATTERN ".qt*" EXCLUDE + PATTERN ".qsb*" EXCLUDE + PATTERN ".lupdate*" EXCLUDE ) # Qt plugins: audio, iconengines, imageformats, multimedia, platforms, printsupport, styles, tls @@ -720,10 +749,6 @@ Data = Resources\") " COMPONENT Runtime ) - - if(OPENSSL_FOUND) - install(FILES ${OPENSSL_INCLUDE_DIRS} DESTINATION ./) - endif() endif() if(Qt6LinguistTools_FOUND) diff --git a/cockatrice/src/interface/intents/contexts/context_open_deck.h b/cockatrice/src/interface/intents/contexts/context_open_deck.h new file mode 100644 index 000000000..03dca088e --- /dev/null +++ b/cockatrice/src/interface/intents/contexts/context_open_deck.h @@ -0,0 +1,14 @@ +#ifndef COCKATRICE_CONTEXT_OPEN_DECK_H +#define COCKATRICE_CONTEXT_OPEN_DECK_H + +#include "context_connect_to_server.h" + +#include + +struct ContextOpenDeck +{ + ContextConnectToServer serverContext; + QString shareToken; +}; + +#endif // COCKATRICE_CONTEXT_OPEN_DECK_H diff --git a/cockatrice/src/interface/intents/intent.cpp b/cockatrice/src/interface/intents/intent.cpp index c02a89f35..db0d13b2c 100644 --- a/cockatrice/src/interface/intents/intent.cpp +++ b/cockatrice/src/interface/intents/intent.cpp @@ -2,10 +2,11 @@ Intent::Intent(QObject *parent) : QObject(parent) { - // An intent is done as soon as it reports success or failure. Deleting it - // also tears down its dependency chain and disconnects any signal wiring. + // An intent is done as soon as it reports success, failure, or cancellation. + // Deleting it also tears down its dependency chain and disconnects any signal wiring. connect(this, &Intent::finished, this, &QObject::deleteLater); connect(this, &Intent::failed, this, &QObject::deleteLater); + connect(this, &Intent::cancelled, this, &QObject::deleteLater); } Intent::~Intent() = default; @@ -27,6 +28,7 @@ void Intent::runDependency(Intent *dependency) this->execute(); }); connect(dependency, &Intent::failed, this, &Intent::failed); + connect(dependency, &Intent::cancelled, this, &Intent::cancelled); dependency->execute(); } @@ -46,3 +48,11 @@ void Intent::emitFailed(const QString &reason) emit failed(reason); } } + +void Intent::emitCancelled() +{ + if (!completed) { + completed = true; + emit cancelled(); + } +} diff --git a/cockatrice/src/interface/intents/intent.h b/cockatrice/src/interface/intents/intent.h index 125900ecd..5d9fdd3d6 100644 --- a/cockatrice/src/interface/intents/intent.h +++ b/cockatrice/src/interface/intents/intent.h @@ -16,6 +16,7 @@ public: signals: void finished(); void failed(QString reason); + void cancelled(); protected: // --- Subclasses must implement these --- @@ -29,6 +30,7 @@ protected: // Emit the outcome exactly once; ignore late signals after the intent is done. void emitFinished(); void emitFailed(const QString &reason); + void emitCancelled(); private: bool completed = false; diff --git a/cockatrice/src/interface/intents/intent_join_server_game.cpp b/cockatrice/src/interface/intents/intent_join_server_game.cpp index 205c4dc70..b22b200ed 100644 --- a/cockatrice/src/interface/intents/intent_join_server_game.cpp +++ b/cockatrice/src/interface/intents/intent_join_server_game.cpp @@ -19,13 +19,15 @@ bool IntentJoinServerGame::checkPrecondition() const if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { return false; } - // peerPort() reflects the actual TCP peer, which may differ from the - // configured server port (e.g. when connecting through a proxy), so only - // the hostname is compared here. - if (remoteClient->peerName() != context->roomContext.serverContext.hostname) { + // serverName()/serverPort() reflect the server the client was configured + // to connect to, which may differ from the actual TCP peer (e.g. when + // connecting through a proxy), so compare those configured values. A link + // naming the same host on another port is a different server and must not + // reuse the session there. + if (remoteClient->serverName().compare(context->roomContext.serverContext.hostname, Qt::CaseInsensitive) != 0) { return false; } - if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) { + if (QString::number(remoteClient->serverPort()) != context->roomContext.serverContext.port) { return false; } diff --git a/cockatrice/src/interface/intents/intent_login.cpp b/cockatrice/src/interface/intents/intent_login.cpp index ff871fd03..7beb63e1d 100644 --- a/cockatrice/src/interface/intents/intent_login.cpp +++ b/cockatrice/src/interface/intents/intent_login.cpp @@ -1,9 +1,14 @@ #include "intent_login.h" #include "../../client/settings/cache_settings.h" +#include "../widgets/dialogs/dlg_login_prompt.h" #include "libcockatrice/settings/servers_settings.h" -IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context) : Intent(), context(_context) +#include + +IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context, + bool _promptForMissingCredentials) + : Intent(), context(_context), promptForMissingCredentials(_promptForMissingCredentials) { } @@ -29,5 +34,46 @@ void IntentGetLoginCredentials::onPreconditionSatisfied() void IntentGetLoginCredentials::onPreconditionNotSatisfied() { - emitFailed(tr("No saved credentials for this server")); + // MainWindow::applyStartupDestination runs this intent on every launch for + // users whose startup tab is Server / Server Room; keep that path quiet, as + // it was before the link-driven sign-in dialog existed. + if (!promptForMissingCredentials) { + emitFailed(tr("No saved credentials for this server")); + return; + } + + // No credentials saved for the target server: ask the user for them. They + // opt into saving them so later links to the same server connect directly. + const QString serverText = context->hostname + ":" + context->port; + DlgLoginPrompt dialog(serverText); + // ApplicationModal: the dialog has no parent (the intent is not a widget), + // so WindowModal would not actually block any other window. + dialog.setWindowModality(Qt::ApplicationModal); + + if (dialog.exec() != QDialog::Accepted) { + emitCancelled(); + return; + } + + context->username = dialog.username(); + context->password = dialog.password(); + + if (dialog.savePassword() && !context->username.isEmpty()) { + ServersSettings &servers = SettingsCache::instance().servers(); + // The host may already be saved under a friendly name (e.g. a public-server + // list entry) with no credentials; reuse that name instead of overwriting + // it with the raw hostname when addNewServer updates the entry in place. + QString saveName = context->hostname; + const int existingIndex = servers.findServerIndex(context->hostname, context->port); + if (existingIndex >= 0) { + saveName = + servers.getValue(QString("saveName%1").arg(existingIndex), "server", "server_details").toString(); + if (saveName.isEmpty()) { + saveName = context->hostname; + } + } + servers.addNewServer(saveName, context->hostname, context->port, context->username, context->password, true); + } + + emitFinished(); } diff --git a/cockatrice/src/interface/intents/intent_login.h b/cockatrice/src/interface/intents/intent_login.h index c7fec92b7..8ffd91a0a 100644 --- a/cockatrice/src/interface/intents/intent_login.h +++ b/cockatrice/src/interface/intents/intent_login.h @@ -9,7 +9,10 @@ class IntentGetLoginCredentials : public Intent Q_OBJECT public: - IntentGetLoginCredentials(ContextConnectToServer *_context); + // When promptForMissingCredentials is false (the default) a server without + // saved credentials fails silently; only intent chains from cockatrice:// + // links opt into the interactive sign-in dialog. + explicit IntentGetLoginCredentials(ContextConnectToServer *_context, bool _promptForMissingCredentials = false); protected: bool checkPrecondition() const override; @@ -18,6 +21,7 @@ protected: private: ContextConnectToServer *context; + bool promptForMissingCredentials; }; #endif // COCKATRICE_INTENT_LOGIN_H diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.cpp b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp new file mode 100644 index 000000000..016de63e6 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp @@ -0,0 +1,208 @@ +#include "intent_open_shared_deck.h" + +#include "../deck_loader/deck_loader.h" +#include "../widgets/dialogs/dlg_shared_decks_preview.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "intent_connect_to_server.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +IntentOpenSharedDeck::IntentOpenSharedDeck(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + const CardDatabaseQuerier *_querier, + std::unique_ptr _context) + : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), querier(_querier), + context(_context.release()) +{ + downloadTimer = new QTimer(this); + downloadTimer->setSingleShot(true); + downloadTimer->setInterval(15000); + connect(downloadTimer, &QTimer::timeout, this, &IntentOpenSharedDeck::onDownloadTimeout); +} + +bool IntentOpenSharedDeck::checkPrecondition() const +{ + if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { + return false; + } + // serverName()/serverPort() reflect the server the client was configured + // to connect to, which may differ from the actual TCP peer (e.g. when + // connecting through a proxy), so compare those configured values. The + // share token must be resolved against the host the link named — a link to + // the same host on another port is a different server. + if (remoteClient->serverName().compare(context->serverContext.hostname, Qt::CaseInsensitive) != 0) { + return false; + } + return QString::number(remoteClient->serverPort()) == context->serverContext.port; +} + +void IntentOpenSharedDeck::onPreconditionSatisfied() +{ + // Resolve the share token to its items first; a share can contain more than + // one deck, and each item is downloaded by id. Time the round trip like the + // downloads, so a silent server cannot hang the chain forever. + listPhase = true; + downloadTimer->start(); + + Command_DeckShareList cmd; + cmd.set_token(context->shareToken.toStdString()); + + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::listShareFinished); + remoteClient->sendCommand(pend); +} + +void IntentOpenSharedDeck::onPreconditionNotSatisfied() +{ + runDependency(new IntentConnectToServer(remoteClient, &context->serverContext)); +} + +void IntentOpenSharedDeck::listShareFinished(const Response &response, const CommandContainer & /* commandContainer */) +{ + downloadTimer->stop(); + listPhase = false; + + if (response.response_code() != Response::RespOk) { + emitFailed(tr("The shared deck could not be found or has expired")); + return; + } + + const Response_DeckShareList &resp = response.GetExtension(Response_DeckShareList::ext); + if (resp.items_size() == 0) { + emitFailed(tr("The shared deck is empty")); + return; + } + + QList items; + items.reserve(resp.items_size()); + for (const ServerInfo_DeckShareItem &item : resp.items()) { + items.append(item); + itemNames.insert(item.id(), QString::fromStdString(item.name())); + } + + const QString serverText = context->serverContext.hostname + ":" + context->serverContext.port; + + // Ask the user which decks to open before downloading anything. + previewDialog = new DlgSharedDecksPreview(tabSupervisor, querier, QString::fromStdString(resp.name()), + resp.expires_at(), serverText, items); + connect(previewDialog, &DlgSharedDecksPreview::openRequested, this, &IntentOpenSharedDeck::startDownloads); + connect(previewDialog, &DlgSharedDecksPreview::cancelled, this, &IntentOpenSharedDeck::emitCancelled); + connect(previewDialog, &DlgSharedDecksPreview::cancelled, previewDialog, &QWidget::deleteLater); + previewDialog->show(); + previewDialog->raise(); + previewDialog->activateWindow(); +} + +void IntentOpenSharedDeck::startDownloads(const QList &itemIds) +{ + pendingItemIds = itemIds; + totalItems = itemIds.size(); + completedItems = 0; + loadedDecks.clear(); + downloadNextItem(); +} + +void IntentOpenSharedDeck::downloadNextItem() +{ + if (pendingItemIds.isEmpty()) { + finishAll(); + return; + } + + currentItemId = pendingItemIds.takeFirst(); + downloadTimer->start(); + + Command_DeckShareDownload cmd; + cmd.set_token(context->shareToken.toStdString()); + cmd.set_item_id(currentItemId); + + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::downloadShareFinished); + remoteClient->sendCommand(pend); +} + +void IntentOpenSharedDeck::downloadShareFinished(const Response &response, + const CommandContainer & /* commandContainer */) +{ + downloadTimer->stop(); + + QString failureReason; + if (response.response_code() != Response::RespOk) { + failureReason = tr("Failed to download the shared deck"); + } else { + const Response_DeckShareDownload &resp = response.GetExtension(Response_DeckShareDownload::ext); + const QString deckString = QString::fromStdString(resp.deck()); + if (deckString.isEmpty()) { + failureReason = tr("The shared deck is empty"); + } else { + std::optional deckOpt = + DeckLoader::loadFromRemote(deckString, LoadedDeck::LoadInfo::NON_REMOTE_ID); + if (!deckOpt) { + failureReason = tr("The shared deck could not be loaded"); + } else { + loadedDecks.append(deckOpt.value()); + ++completedItems; + previewDialog->setDownloadProgress(completedItems, totalItems, + itemNames.value(currentItemId, tr("Unknown deck"))); + downloadNextItem(); + return; + } + } + } + + onItemFailure(failureReason); +} + +void IntentOpenSharedDeck::onItemFailure(const QString &reason) +{ + downloadTimer->stop(); + + if (loadedDecks.isEmpty()) { + previewDialog->deleteLater(); + emitFailed(reason); + return; + } + + const int downloadedCount = loadedDecks.size(); + const QMessageBox::StandardButton answer = QMessageBox::question( + previewDialog, tr("Open shared decks"), + tr("Could not download the deck \"%1\".\n\n%n deck(s) were already downloaded. Open them?", "", downloadedCount) + .arg(itemNames.value(currentItemId, tr("Unknown deck"))), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + + if (answer == QMessageBox::Yes) { + finishAll(); + } else { + previewDialog->deleteLater(); + emitCancelled(); + } +} + +void IntentOpenSharedDeck::onDownloadTimeout() +{ + // The list phase has no preview dialog yet to report progress into; fail the + // whole intent instead of letting the shared deck hang in limbo. + if (listPhase) { + emitFailed(tr("Timed out while loading the shared deck")); + return; + } + onItemFailure(tr("Timed out while downloading the shared deck")); +} + +void IntentOpenSharedDeck::finishAll() +{ + previewDialog->deleteLater(); + for (const LoadedDeck &deck : loadedDecks) { + tabSupervisor->openDeckInNewTab(deck); + } + emitFinished(); +} diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.h b/cockatrice/src/interface/intents/intent_open_shared_deck.h new file mode 100644 index 000000000..87812bdea --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.h @@ -0,0 +1,60 @@ +#ifndef COCKATRICE_INTENT_OPEN_SHARED_DECK_H +#define COCKATRICE_INTENT_OPEN_SHARED_DECK_H + +#include "contexts/context_open_deck.h" +#include "intent.h" +#include "remote_client.h" + +#include +#include +#include +#include + +class TabSupervisor; +struct LoadedDeck; +class CardDatabaseQuerier; +class DlgSharedDecksPreview; +class QTimer; + +class IntentOpenSharedDeck : public Intent +{ + Q_OBJECT + +public: + IntentOpenSharedDeck(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + const CardDatabaseQuerier *_querier, + std::unique_ptr _context); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private slots: + void listShareFinished(const Response &response, const CommandContainer &commandContainer); + void downloadShareFinished(const Response &response, const CommandContainer &commandContainer); + void onDownloadTimeout(); + +private: + void startDownloads(const QList &itemIds); + void downloadNextItem(); + void onItemFailure(const QString &reason); + void finishAll(); + + TabSupervisor *tabSupervisor; + RemoteClient *remoteClient; + const CardDatabaseQuerier *querier; + QScopedPointer context; + DlgSharedDecksPreview *previewDialog = nullptr; + QTimer *downloadTimer; + QMap itemNames; + QList pendingItemIds; + QList loadedDecks; + bool listPhase = true; + int currentItemId = 0; + int totalItems = 0; + int completedItems = 0; +}; + +#endif // COCKATRICE_INTENT_OPEN_SHARED_DECK_H diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp index 509390611..707863354 100644 --- a/cockatrice/src/interface/intents/url_parser.cpp +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -1,19 +1,28 @@ #include "url_parser.h" +#include "../../client/settings/cache_settings.h" #include "../widgets/tabs/tab_room.h" #include "../widgets/tabs/tab_supervisor.h" #include "../window_main.h" #include "contexts/context_join_game.h" +#include "contexts/context_open_deck.h" +#include "intent.h" #include "intent_join_server_game.h" #include "intent_login.h" +#include "intent_open_shared_deck.h" #include +#include #include #include #include +#include #include +#include #include +inline Q_LOGGING_CATEGORY(UrlParserLog, "url_parser"); + IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow) { } @@ -29,16 +38,33 @@ void IntentUrlParser::handle(const QString &urlStr) const QString action = url.host(); QUrlQuery query(url); + qCDebug(UrlParserLog) << "Parsing intent URL, action:" << action; + + PendingIntentChain chain; + Intent *firstIntent = nullptr; if (action == "joingame") { - handleJoinGame(query); + firstIntent = createJoinGameIntent(query, chain); } else if (action == "opendeck") { - // handleOpenDeck(query); + firstIntent = createOpenDeckIntent(query, chain); } else { qWarning() << "Unknown intent:" << action; } + + if (firstIntent == nullptr) { + // The link was invalid or the user declined the confirm: nothing runs. + // Report the idle state when no other chain is queued so that a startup + // launch (which skipped its own connection for this URL) falls back to it. + if (!chainRunning && pendingChains.isEmpty()) { + emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn); + } + return; + } + + pendingChains.append(chain); + startNextChain(); } -void IntentUrlParser::handleJoinGame(const QUrlQuery &query) +Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, PendingIntentChain &chain) { auto showError = [this](const QString &message) { QMessageBox::warning(mainWindow, tr("Open game"), message); }; @@ -49,21 +75,21 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) if (ctx->roomContext.serverContext.hostname.isEmpty()) { showError(tr("Missing or empty hostname in the game link")); - return; + return nullptr; } bool ok = false; ctx->roomContext.serverContext.port.toUShort(&ok); if (!ok) { showError(tr("Invalid or missing port in the game link")); - return; + return nullptr; } ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok); if (!ok) { showError(tr("Invalid or missing room id in the game link")); - return; + return nullptr; } ok = false; @@ -71,7 +97,7 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) if (!ok) { showError(tr("Invalid or missing game id in the game link")); - return; + return nullptr; } const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded); @@ -80,24 +106,33 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) const QMessageBox::StandardButton answer = QMessageBox::question( mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); if (answer != QMessageBox::Yes) { - return; + return nullptr; } + RemoteClient *client = mainWindow->getRemoteClient(); + ContextConnectToServer *serverContext = &ctx->roomContext.serverContext; + // The join game intent owns the context and the credential lookup; once the // chain finishes (or fails) it deletes the whole tree. - ContextConnectToServer *serverContext = &ctx->roomContext.serverContext; - auto joinGameIntent = - new IntentJoinServerGame(mainWindow->getTabSupervisor(), mainWindow->getRemoteClient(), std::move(ctx)); + auto joinGameIntent = new IntentJoinServerGame(mainWindow->getTabSupervisor(), client, std::move(ctx)); joinGameIntent->setParent(this); - - auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext); - getLoginCredentialsIntent->setParent(joinGameIntent); - - connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute); - connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed); + chain.intents.append(joinGameIntent); connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); }); - getLoginCredentialsIntent->execute(); + Intent *firstIntent = joinGameIntent; + if (!isConnectedTo(serverContext->hostname, serverContext->port)) { + auto getLoginCredentialsIntent = + new IntentGetLoginCredentials(serverContext, /*promptForMissingCredentials=*/true); + getLoginCredentialsIntent->setParent(joinGameIntent); + chain.intents.insert(0, getLoginCredentialsIntent); + + connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute); + connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed); + connect(getLoginCredentialsIntent, &Intent::cancelled, joinGameIntent, &Intent::cancelled); + firstIntent = getLoginCredentialsIntent; + } + + return firstIntent; } QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription) @@ -134,3 +169,270 @@ QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, .arg(gameDescription, gameIdStr, roomTab->getRoomName(), server) : tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, server); } + +Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, PendingIntentChain &chain) +{ + auto showError = [this](const QString &message) { + QMessageBox::warning(mainWindow, tr("Open shared deck"), message); + }; + + auto ctx = std::make_unique(); + + ctx->serverContext.hostname = query.queryItemValue("hostname"); + ctx->serverContext.port = query.queryItemValue("port"); + ctx->shareToken = query.queryItemValue("share"); + + qCDebug(UrlParserLog) << "Open-deck intent: host" << ctx->serverContext.hostname << "port" + << ctx->serverContext.port << "token length" << ctx->shareToken.length(); + + if (ctx->serverContext.hostname.isEmpty()) { + showError(tr("Missing or empty hostname in the share link")); + return nullptr; + } + + bool ok = false; + const quint16 port = ctx->serverContext.port.toUShort(&ok); + if (!ok || port == 0) { + showError(tr("Invalid or missing port in the share link")); + return nullptr; + } + + if (ctx->shareToken.isEmpty()) { + showError(tr("Missing or empty share value in the share link")); + return nullptr; + } + + RemoteClient *client = mainWindow->getRemoteClient(); + + // The open deck download needs a connection to the link's server. Ask before + // taking the session anywhere it isn't already, naming the host we would + // connect to. Remember the link's target when it moves us away from a live + // session so a failed or cancelled chain can restore the session it left. + // The hostname is link-supplied and percent-decoded, so escape it: QMessageBox + // renders AutoText, and markup in a hostname would otherwise flip the whole + // prompt to rich text and let a link pad the message the user is shown. + const bool alreadyConnected = isConnectedTo(ctx->serverContext.hostname, ctx->serverContext.port); + if (!alreadyConnected) { + const QString target = + QStringLiteral("%1:%2").arg(ctx->serverContext.hostname.toHtmlEscaped(), ctx->serverContext.port); + + if (client->getStatus() == StatusLoggedIn) { + const QString current = + QStringLiteral("%1:%2").arg(client->serverName(), QString::number(client->serverPort())); + const QMessageBox::StandardButton answer = QMessageBox::question( + mainWindow, tr("Open shared deck"), + tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + if (answer != QMessageBox::Yes) { + return nullptr; + } + chain.migrationTargetHost = ctx->serverContext.hostname; + chain.migrationTargetPort = ctx->serverContext.port; + chain.pendingRestore = true; + } else { + // Fresh connection is harmless to wander away from, but a server the + // client has never been configured for deserves a harder warning (no + // by default) so a stray link cannot silently steer the client there. + const bool knownHost = SettingsCache::instance().servers().findHostIndex(ctx->serverContext.hostname) >= 0; + const QMessageBox::StandardButton answer = + knownHost + ? QMessageBox::question(mainWindow, tr("Open shared deck"), + tr("Opening this share link connects you to %1.\n\nContinue?").arg(target)) + : QMessageBox::warning(mainWindow, tr("Open shared deck"), + tr("Opening this share link connects you to %1, a server you have " + "never connected to before.\n\nContinue?") + .arg(target), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (answer != QMessageBox::Yes) { + return nullptr; + } + } + } + + ContextConnectToServer *serverContext = &ctx->serverContext; + + // The open deck intent owns the context and the credential lookup; once + // the chain finishes (or fails) it deletes the whole tree. + auto openDeckIntent = + new IntentOpenSharedDeck(mainWindow->getTabSupervisor(), client, CardDatabaseManager::query(), std::move(ctx)); + openDeckIntent->setParent(this); + chain.intents.append(openDeckIntent); + connect(openDeckIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); }); + + Intent *firstIntent = openDeckIntent; + if (!isConnectedTo(serverContext->hostname, serverContext->port)) { + auto getLoginCredentialsIntent = + new IntentGetLoginCredentials(serverContext, /*promptForMissingCredentials=*/true); + getLoginCredentialsIntent->setParent(openDeckIntent); + chain.intents.insert(0, getLoginCredentialsIntent); + + connect(getLoginCredentialsIntent, &Intent::finished, openDeckIntent, &Intent::execute); + connect(getLoginCredentialsIntent, &Intent::failed, openDeckIntent, &Intent::failed); + connect(getLoginCredentialsIntent, &Intent::cancelled, openDeckIntent, &Intent::cancelled); + firstIntent = getLoginCredentialsIntent; + } + + return firstIntent; +} + +bool IntentUrlParser::isConnectedTo(const QString &hostname, const QString &port) const +{ + // serverName() reflects the server the client was configured to connect to, + // which may differ from the actual TCP peer (e.g. when connecting through a + // proxy), so compare the configured host and port — exactly what a link + // names. A link to the same host on another port is a different server and + // must not silently reuse an existing session there. + RemoteClient *client = mainWindow->getRemoteClient(); + return client->getStatus() == StatusLoggedIn && client->serverName().compare(hostname, Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == port; +} + +void IntentUrlParser::startNextChain() +{ + if (chainRunning || pendingChains.isEmpty()) { + return; + } + chainRunning = true; + + PendingIntentChain &chain = pendingChains.first(); + if (chain.intents.isEmpty()) { + pendingChains.removeFirst(); + chainRunning = false; + startNextChain(); + return; + } + + // Snapshot the session this chain moves away from now that it actually + // runs. Chains are parsed while earlier ones are still queued, so a capture + // at parse time would follow whichever server the chain before it settled + // on, not the one the user is really on when this link is handled. + if (chain.pendingRestore) { + RemoteClient *client = mainWindow->getRemoteClient(); + chain.previousServerHost = client->serverName(); + chain.previousServerPort = QString::number(client->serverPort()); + } + + // Only the last intent completes the chain; its terminal signal ends the + // whole run. Cancellation of an intermediate intent (e.g. declined login + // prompt) is forwarded onto the last intent in the chain builders above. + Intent *finalIntent = chain.intents.last(); + connect(finalIntent, &Intent::finished, this, [this]() { chainEnded(true); }); + connect(finalIntent, &Intent::failed, this, [this]() { chainEnded(false); }); + connect(finalIntent, &Intent::cancelled, this, [this]() { chainEnded(false); }); + // Backstop: if the final intent is destroyed without emitting a terminal + // signal (e.g. a network error dropped it while running), end the chain so + // later links are not queued and dropped for the rest of the session. + chainBackstopConnection = connect(finalIntent, &QObject::destroyed, this, &IntentUrlParser::onChainIntentDestroyed); + + chain.intents.first()->execute(); +} + +void IntentUrlParser::chainEnded(bool chainSucceeded) +{ + chainRunning = false; + QObject::disconnect(chainBackstopConnection); + + const PendingIntentChain chain = pendingChains.takeFirst(); + + // Only a failed or cancelled chain restores the session the link migrated + // away from; a successful one leaves the user where they are. + if (chain.pendingRestore && !chainSucceeded) { + restorePreviousServer(chain); + } + + startNextChain(); + + // Only report the terminal state once the queue has fully drained, so a + // queued follow-up link keeps the startup fallback out of the picture. + if (!chainRunning && pendingChains.isEmpty()) { + emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn); + } +} + +void IntentUrlParser::onChainIntentDestroyed() +{ + if (!chainRunning) { + return; + } + qCWarning(UrlParserLog) << "Share-link intent destroyed without a terminal signal; ending its chain"; + chainEnded(false); +} + +void IntentUrlParser::restorePreviousServer(const PendingIntentChain &chain) +{ + if (chain.previousServerHost.isEmpty()) { + return; + } + + RemoteClient *client = mainWindow->getRemoteClient(); + const ClientStatus status = client->getStatus(); + + // A failed/cancelled chain can fire while the client is still settling the + // in-flight connection attempt (wrong password, connect timeout). Only + // decide once the client has settled into logged-in or disconnected; + // deciding mid-connect would strand the user offline from their previous + // server. + if (status == StatusDisconnected || status == StatusLoggedIn) { + restoreToPreviousServer(chain); + return; + } + auto waitConnection = std::make_shared(); + *waitConnection = connect(client, &RemoteClient::statusChanged, this, [this, chain, client, waitConnection]() { + const ClientStatus settled = client->getStatus(); + if (settled == StatusDisconnected || settled == StatusLoggedIn) { + QObject::disconnect(*waitConnection); + restoreToPreviousServer(chain); + } + }); +} + +void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain) +{ + RemoteClient *client = mainWindow->getRemoteClient(); + + // Back on the previous server already → nothing to undo. + if (client->serverName().compare(chain.previousServerHost, Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == chain.previousServerPort) { + return; + } + + // When logged in somewhere, only intervene if that somewhere is the server + // the link moved us to; if the user went elsewhere on their own, leave them. + if (client->getStatus() == StatusLoggedIn) { + const bool onMigrationTarget = + client->serverName().compare(chain.migrationTargetHost, Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == chain.migrationTargetPort; + if (!onMigrationTarget) { + return; + } + + ServersSettings &servers = SettingsCache::instance().servers(); + const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort); + if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { + const QString username = + servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); + const QString password = + servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); + client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password); + return; + } + client->disconnectFromServer(); + return; + } + + if (client->getStatus() != StatusDisconnected) { + return; + } + + // The link's connection attempt failed: reconnect to the previous server + // when credentials are saved, otherwise stay offline. + ServersSettings &servers = SettingsCache::instance().servers(); + const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort); + if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { + const QString username = + servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); + const QString password = + servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); + client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password); + } +} diff --git a/cockatrice/src/interface/intents/url_parser.h b/cockatrice/src/interface/intents/url_parser.h index 6d705e013..ea29fed53 100644 --- a/cockatrice/src/interface/intents/url_parser.h +++ b/cockatrice/src/interface/intents/url_parser.h @@ -1,10 +1,46 @@ #ifndef COCKATRICE_URL_PARSER_H #define COCKATRICE_URL_PARSER_H + +#include #include #include +class Intent; class MainWindow; struct ContextJoinGame; + +/** + * @brief One queued intent chain with the session-migration bookkeeping for it. + * + * The restore fields are per-chain on purpose: chains are parsed while earlier + * ones are still queued, so parser-wide state would let one chain's failure + * consume the restore data another chain recorded. + */ +struct PendingIntentChain +{ + QList intents; + + // Snapshot of the session in place when this chain started running, so a + // queued chain follows whichever server the chain before it settled on. + QString previousServerHost; + QString previousServerPort; + + // Recorded at parse time when the user confirmed migrating away from a live + // session to the host/port named by the link. + QString migrationTargetHost; + QString migrationTargetPort; + bool pendingRestore = false; +}; + +/** + * @brief Parses cockatrice:// links and runs them as serialized intent chains. + * + * Links are parsed by action (joingame/opendeck) and translated into an intent + * chain. Chains are queued and run one at a time: a document can hand multiple + * links to the window while an earlier chain still connects, and running two + * connect chains concurrently tears the connection down. urlChainFinished is + * emitted once the queue has fully drained. + */ class IntentUrlParser : public QObject { Q_OBJECT @@ -12,12 +48,28 @@ class IntentUrlParser : public QObject public: IntentUrlParser(QObject *parent, MainWindow *mainWindow); void handle(const QString &urlStr); - void handleJoinGame(const QUrlQuery &query); + +signals: + /** @brief Emitted when the last queued chain ended; carries whether the client is logged in. */ + void urlChainFinished(bool connected); private: + Intent *createJoinGameIntent(const QUrlQuery &query, PendingIntentChain &chain); + Intent *createOpenDeckIntent(const QUrlQuery &query, PendingIntentChain &chain); QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription); + [[nodiscard]] bool isConnectedTo(const QString &hostname, const QString &port) const; + void startNextChain(); + void chainEnded(bool chainSucceeded); + void onChainIntentDestroyed(); + void restorePreviousServer(const PendingIntentChain &chain); + void restoreToPreviousServer(const PendingIntentChain &chain); MainWindow *mainWindow; + QList pendingChains; + bool chainRunning = false; + // Disconnects the destroyed-signal backstop once a chain ends, so an old + // intent's deferred deletion cannot end the chain that runs after it. + QMetaObject::Connection chainBackstopConnection; }; #endif // COCKATRICE_URL_PARSER_H diff --git a/cockatrice/src/interface/widgets/cards/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..62b01511e --- /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 CardDatabaseQuerier *db) +{ + 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 = db->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..04294cb1c --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h @@ -0,0 +1,20 @@ +#ifndef COCKATRICE_DECK_COLOR_IDENTITY_H +#define COCKATRICE_DECK_COLOR_IDENTITY_H + +#include + +class CardDatabaseQuerier; +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. + * + * @param db Card database used to look up card color symbols. + */ +QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db); + +#endif // COCKATRICE_DECK_COLOR_IDENTITY_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 1614836fc..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,18 +27,23 @@ 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); - 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); @@ -47,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(); @@ -58,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 154e938aa..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,21 +20,38 @@ 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); + void imageSingleClicked(); void imageDoubleClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); 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_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index b0b31074c..4b141e255 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -520,8 +520,11 @@ void DeckEditorDeckDockWidget::syncBannerCardComboBoxSelectionWithDeck() void DeckEditorDeckDockWidget::setSelectedIndex(const QModelIndex &newCardIndex, bool preserveWidgetFocus) { + const QModelIndex proxyIndex = proxy->mapFromSource(newCardIndex); + deckView->clearSelection(); - deckView->setCurrentIndex(newCardIndex); + deckView->setCurrentIndex(proxyIndex); + deckView->scrollTo(proxyIndex); recursiveExpand(newCardIndex); if (!preserveWidgetFocus) { 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..4343f0aab --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/deck_share_utils.cpp @@ -0,0 +1,59 @@ +#include "deck_share_utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DeckShareUtils +{ + +QString buildShareLink(const AbstractClient *client, const QString &token) +{ + QUrl url; + url.setScheme(QStringLiteral("cockatrice")); + url.setHost(QStringLiteral("opendeck")); + + QUrlQuery query; + query.addQueryItem(QStringLiteral("share"), token); + query.addQueryItem(QStringLiteral("hostname"), client->serverName()); + query.addQueryItem(QStringLiteral("port"), QString::number(client->serverPort())); + url.setQuery(query); + + return url.toString(QUrl::FullyEncoded); +} + +QString copyShareLinkToClipboard(const QString &link) +{ + QGuiApplication::clipboard()->setText(link); + return link; +} + +QString formatShareExpiry(const QDateTime &expiry) +{ + return QLocale().toString(expiry.toLocalTime(), QLocale::ShortFormat); +} + +ShareResponse handleShareResponse(const AbstractClient *client, const Response &response) +{ + const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext); + const QString token = QString::fromStdString(resp.token()); + + const QString link = buildShareLink(client, token); + copyShareLinkToClipboard(link); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) + const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC); +#else + const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), Qt::UTC); +#endif + + return {link, expiry}; +} + +} // namespace DeckShareUtils 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..c9f7afa8b --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/deck_share_utils.h @@ -0,0 +1,59 @@ +/** + * @file deck_share_utils.h + * @ingroup DeckShareWidgets + */ +//! \todo Document this file. + +#ifndef DECK_SHARE_UTILS_H +#define DECK_SHARE_UTILS_H + +#include +#include + +class AbstractClient; +class Response; + +/** + * @brief Shared helpers for creating temporary deck shares. + */ +namespace DeckShareUtils +{ + +/** + * @brief The outcome of a successful share-create response. + */ +struct ShareResponse +{ + QString link; ///< The share link that was copied to the clipboard. + QDateTime expiry; ///< When the share expires (UTC). +}; + +/** + * @brief Builds the cockatrice:// link for a freshly created deck share. + * @param client Used to embed the target server's hostname and port. + * @param token The share token from Response_DeckShareCreate. + */ +QString buildShareLink(const AbstractClient *client, const QString &token); + +/** + * @brief Copies the share link to the clipboard. + * @return The link that was copied. + */ +QString copyShareLinkToClipboard(const QString &link); + +/** + * @brief Formats the expiration timestamp for a share. + */ +QString formatShareExpiry(const QDateTime &expiry); + +/** + * @brief Handles a successful Response_DeckShareCreate: builds the share link, + * copies it to the clipboard, and derives the share expiry. + * @param client Used to embed the target server's hostname and port. + * @param response The successful response carrying the share token and expiry. + */ +ShareResponse handleShareResponse(const AbstractClient *client, const Response &response); + +} // namespace DeckShareUtils + +#endif // DECK_SHARE_UTILS_H 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..5aca343ca --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp @@ -0,0 +1,77 @@ +#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")); +} + +QString ShareBarWidget::name() const +{ + return nameEdit->text().trimmed(); +} + +void ShareBarWidget::setName(const QString &value) +{ + nameEdit->setText(value); +} + +void ShareBarWidget::setCountText(const QString &text) +{ + countLabel->setText(text); +} + +void ShareBarWidget::setHintText(const QString &text, bool visible) +{ + hintLabel->setText(text); + hintLabel->setVisible(visible); +} + +void ShareBarWidget::setCreateEnabled(bool enabled) +{ + createButton->setEnabled(enabled); +} + +void ShareBarWidget::focusName() +{ + nameEdit->setFocus(); +} 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..ede9fb41b --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.h @@ -0,0 +1,63 @@ +/** + * @file share_bar_widget.h + * @ingroup DeckShareWidgets + */ +//! \todo Document this file. + +#ifndef SHARE_BAR_WIDGET_H +#define SHARE_BAR_WIDGET_H + +#include + +class QLabel; +class QLineEdit; +class QPushButton; + +/** + * @brief The activated toolbar used to create a temporary deck share. + * + * A single reusable component shared by the local visual deck storage and the + * remote server deck storage tabs, so the share workflow renders identically in + * both places. It owns its own widgets, strings, and layout; the owning tab only + * sets the count/hint text and reacts to the create/cancel signals. + */ +class ShareBarWidget final : public QWidget +{ + Q_OBJECT + +public: + explicit ShareBarWidget(QWidget *parent = nullptr); + + void retranslateUi(); + + /** @return The trimmed name entered by the user. */ + [[nodiscard]] QString name() const; + + /** @brief Resets the name field to the given default. */ + void setName(const QString &name); + + /** @brief Sets the selected-count summary label text. */ + void setCountText(const QString &text); + + /** @brief Sets the explainer hint text, showing it when @p visible is true. */ + void setHintText(const QString &text, bool visible); + + /** @brief Enables or disables the create-share-link button (guards double submission). */ + void setCreateEnabled(bool enabled); + + /** @brief Moves keyboard focus to the name field. */ + void focusName(); + +signals: + void createRequested(); + void cancelRequested(); + +private: + QLabel *hintLabel; + QLineEdit *nameEdit; + QLabel *countLabel; + QPushButton *cancelButton; + QPushButton *createButton; +}; + +#endif // SHARE_BAR_WIDGET_H diff --git a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp new file mode 100644 index 000000000..21ec80e08 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp @@ -0,0 +1,140 @@ +#include "shared_deck_preview_widget.h" + +#include "../cards/additional_info/color_identity_widget.h" +#include "../cards/deck_preview_card_picture_widget.h" + +#include +#include +#include +#include +#include +#include +#include + +SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &deckName, + const QString &bannerCardName, + const QString &colorIdentity, + const QString &gameFormat, + const QString &deckToolTip) + : QWidget(parent) +{ + bannerCardDisplayWidget = + new DeckPreviewCardPictureWidget(this, false, false, Qt::white, Qt::black, 12, Qt::AlignCenter, true); + bannerCardDisplayWidget->setScaleFactor(100); + const ExactCard bannerCard = bannerCardName.isEmpty() ? ExactCard() : querier->getCard(CardRef{bannerCardName, {}}); + bannerCardDisplayWidget->setCard(bannerCard); + bannerCardDisplayWidget->setOverlayText(deckName); + setToolTip(deckToolTip.isEmpty() ? deckName : deckToolTip); + setFocusPolicy(Qt::StrongFocus); + setBaseAccessibleName(deckName); + + colorIdentityWidget = new ColorIdentityWidget(this, colorIdentity); + colorIdentityWidget->setVisible(!colorIdentity.isEmpty()); + + // gameFormat is server-supplied and the QLabel renders AutoText, so escape it. + gameFormatLabel = new QLabel(gameFormat.toHtmlEscaped(), this); + gameFormatLabel->setAlignment(Qt::AlignCenter); + gameFormatLabel->setVisible(!gameFormat.isEmpty()); + + selectionCheckBox = new QCheckBox(this); + selectionCheckBox->setToolTip(tr("Select this deck")); + // The tile itself is focusable (Space/Enter toggles); keep the checkbox + // from creating a second tab stop per tile. + selectionCheckBox->setFocusPolicy(Qt::NoFocus); + + // Selection frame reused from the deck-preview selection covenant: a + // palette(highlight) border around the banner card, shown while selected. + selectionFrame = new QFrame(bannerCardDisplayWidget); + selectionFrame->setAttribute(Qt::WA_TransparentForMouseEvents); + selectionFrame->setStyleSheet(QStringLiteral( + "QFrame { border: 2px solid palette(highlight); border-radius: 4px; background: transparent; }")); + selectionFrame->setVisible(false); + + auto *selectionRow = new QHBoxLayout; + selectionRow->addWidget(selectionCheckBox); + selectionRow->addStretch(1); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addLayout(selectionRow); + layout->addWidget(bannerCardDisplayWidget, 0, Qt::AlignHCenter); + layout->addWidget(colorIdentityWidget, 0, Qt::AlignHCenter); + layout->addWidget(gameFormatLabel, 0, Qt::AlignHCenter); + setLayout(layout); + + connect(selectionCheckBox, &QCheckBox::toggled, this, [this](bool checked) { + updateSelectionVisual(checked); + emit selectionToggled(checked); + }); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this, + &SharedDeckPreviewWidget::toggleSelection); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, + &SharedDeckPreviewWidget::activate); +} + +bool SharedDeckPreviewWidget::isSelected() const +{ + return selectionCheckBox->isChecked(); +} + +void SharedDeckPreviewWidget::setSelected(bool selected) +{ + if (isSelected() == selected) { + return; + } + selectionCheckBox->setChecked(selected); +} + +void SharedDeckPreviewWidget::updateSelectionVisual(bool selected) +{ + selectionFrame->setVisible(selected); + selectionFrame->raise(); + if (selected) { + setAccessibleName(baseAccessibleName + tr(" (selected)")); + } else { + setAccessibleName(baseAccessibleName); + } +} + +void SharedDeckPreviewWidget::setBaseAccessibleName(const QString &name) +{ + baseAccessibleName = name; + setAccessibleName(name); +} + +void SharedDeckPreviewWidget::toggleSelection() +{ + setSelected(!isSelected()); +} + +void SharedDeckPreviewWidget::activate() +{ + setSelected(true); + emit activated(); +} + +void SharedDeckPreviewWidget::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + updateSelectionFrameGeometry(); +} + +void SharedDeckPreviewWidget::updateSelectionFrameGeometry() +{ + if (selectionFrame == nullptr || bannerCardDisplayWidget == nullptr) { + return; + } + selectionFrame->setGeometry(bannerCardDisplayWidget->rect().adjusted(1, 1, -1, -1)); +} + +void SharedDeckPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) { + toggleSelection(); + event->accept(); + return; + } + QWidget::keyPressEvent(event); +} diff --git a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h new file mode 100644 index 000000000..bd4c4ee5f --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h @@ -0,0 +1,77 @@ +/** + * @file shared_deck_preview_widget.h + * @ingroup DeckShareWidgets + */ +//! \todo Document this file. + +#ifndef SHARED_DECK_PREVIEW_WIDGET_H +#define SHARED_DECK_PREVIEW_WIDGET_H + +#include + +class ColorIdentityWidget; +class DeckPreviewCardPictureWidget; +class QCheckBox; +class QFrame; +class QKeyEvent; +class QLabel; +class QResizeEvent; +class CardDatabaseQuerier; + +/** + * @brief A selectable preview tile for a deck that has no local file. + * + * Renders a banner card picture (looked up by name in the card database), the + * deck name, color identity and game format. Used to preview decks shared via a + * cockatrice:// link (metadata from Command_DeckShareList) and the deck + * currently open in the deck editor. + * + * Selection follows the deck-preview covenant: the tile reports its click + * immediately (no double-click interval delay), a palette(highlight) frame + * marks the selected tile, and Space/Enter toggles selection from the keyboard. + * A double click selects the tile and emits activated() so the caller can open + * just that deck. + */ +class SharedDeckPreviewWidget : public QWidget +{ + Q_OBJECT + +public: + explicit SharedDeckPreviewWidget(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &deckName, + const QString &bannerCardName, + const QString &colorIdentity, + const QString &gameFormat = QString(), + const QString &deckToolTip = QString()); + + [[nodiscard]] bool isSelected() const; + void setSelected(bool selected); + + void setBaseAccessibleName(const QString &name); + +signals: + void selectionToggled(bool selected); + void activated(); + +protected: + void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + +private slots: + void toggleSelection(); + void activate(); + +private: + void updateSelectionVisual(bool selected); + void updateSelectionFrameGeometry(); + + DeckPreviewCardPictureWidget *bannerCardDisplayWidget; + ColorIdentityWidget *colorIdentityWidget; + QLabel *gameFormatLabel; + QCheckBox *selectionCheckBox; + QFrame *selectionFrame; + QString baseAccessibleName; +}; + +#endif // SHARED_DECK_PREVIEW_WIDGET_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp new file mode 100644 index 000000000..86647ef26 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp @@ -0,0 +1,50 @@ +#include "dlg_login_prompt.h" + +#include +#include +#include +#include +#include +#include + +DlgLoginPrompt::DlgLoginPrompt(const QString &serverText, QWidget *parent) : QDialog(parent) +{ + setWindowTitle(tr("Sign in")); + + auto *mainLayout = new QVBoxLayout(this); + mainLayout->addWidget( + new QLabel(tr("This link requires you to be signed in.\nSign in to %1:").arg(serverText), this)); + + auto *formLayout = new QFormLayout; + usernameEdit = new QLineEdit(this); + passwordEdit = new QLineEdit(this); + passwordEdit->setEchoMode(QLineEdit::Password); + formLayout->addRow(tr("Username:"), usernameEdit); + formLayout->addRow(tr("Password:"), passwordEdit); + mainLayout->addLayout(formLayout); + + savePasswordCheckBox = new QCheckBox(tr("Save password for this server"), this); + mainLayout->addWidget(savePasswordCheckBox); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + mainLayout->addWidget(buttonBox); + + usernameEdit->setFocus(); +} + +QString DlgLoginPrompt::username() const +{ + return usernameEdit->text().trimmed(); +} + +QString DlgLoginPrompt::password() const +{ + return passwordEdit->text(); +} + +bool DlgLoginPrompt::savePassword() const +{ + return savePasswordCheckBox->isChecked(); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h new file mode 100644 index 000000000..e47924058 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h @@ -0,0 +1,40 @@ +/** + * @file dlg_login_prompt.h + * @ingroup ConnectionDialogs + */ +//! \todo Document this file. + +#ifndef DLG_LOGIN_PROMPT_H +#define DLG_LOGIN_PROMPT_H + +#include + +class QCheckBox; +class QLineEdit; + +/** + * @brief Small sign-in dialog used when a cockatrice:// link needs credentials + * that are not saved for the target server. + * + * The entered name and password are handed to the intent chain; when the user + * opts to save them, they are stored in the server settings so that later links + * to the same server connect seamlessly. + */ +class DlgLoginPrompt : public QDialog +{ + Q_OBJECT + +public: + explicit DlgLoginPrompt(const QString &serverText, QWidget *parent = nullptr); + + [[nodiscard]] QString username() const; + [[nodiscard]] QString password() const; + [[nodiscard]] bool savePassword() const; + +private: + QLineEdit *usernameEdit; + QLineEdit *passwordEdit; + QCheckBox *savePasswordCheckBox; +}; + +#endif // DLG_LOGIN_PROMPT_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp new file mode 100644 index 000000000..897d32062 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp @@ -0,0 +1,96 @@ +#include "dlg_share_deck.h" + +#include "../../../client/settings/cache_settings.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 +#include +#include + +DlgShareDeck::DlgShareDeck(AbstractClient *_client, const QSharedPointer &_deck, QWidget *_parent) + : QDialog(_parent), client(_client), deck(_deck), shareTimeoutTimer(new QTimer(this)) +{ + 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); + this->buttonBox = buttonBox; + layout->addWidget(buttonBox); + + shareTimeoutTimer->setSingleShot(true); + shareTimeoutTimer->setInterval( + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000)); + connect(shareTimeoutTimer, &QTimer::timeout, this, &DlgShareDeck::onShareTimeout); +} + +void DlgShareDeck::actShare() +{ + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + + 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, CardDatabaseManager::query()).toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgShareDeck::shareFinished); + client->sendCommand(pend); + shareTimeoutTimer->start(); +} + +void DlgShareDeck::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + shareTimeoutTimer->stop(); + 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())))); + return; + } + + const DeckShareUtils::ShareResponse share = DeckShareUtils::handleShareResponse(client, response); + + 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(share.link, DeckShareUtils::formatShareExpiry(share.expiry))); + accept(); +} + +void DlgShareDeck::onShareTimeout() +{ + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + QMessageBox::warning(this, tr("Share deck"), tr("The server did not respond in time. Try again.")); +} 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..162fa3677 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h @@ -0,0 +1,46 @@ +/** + * @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 QDialogButtonBox; +class QLineEdit; +class QTimer; +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); + void onShareTimeout(); + +private: + AbstractClient *client; + QSharedPointer deck; + QLineEdit *nameEdit; + QDialogButtonBox *buttonBox; + QTimer *shareTimeoutTimer; +}; + +#endif // DLG_SHARE_DECK_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp new file mode 100644 index 000000000..cd7ecdcdd --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp @@ -0,0 +1,181 @@ +#include "dlg_shared_decks_preview.h" + +#include "../deck_share/shared_deck_preview_widget.h" +#include "../general/layout_containers/flow_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &shareName, + qint64 expiresAt, + const QString &serverText, + const QList &items) + : QDialog(parent) +{ + setWindowTitle(tr("Open shared decks")); + resize(700, 500); + + auto *mainLayout = new QVBoxLayout(this); + + // shareName and serverText come from the share server, so escape them: the + // QLabels render AutoText and markup would otherwise be shown as rich text. + auto *titleLabel = + new QLabel(tr("Share: %1").arg((shareName.isEmpty() ? tr("Untitled") : shareName).toHtmlEscaped()), this); + QFont titleFont = titleLabel->font(); + titleFont.setBold(true); + titleFont.setPointSize(titleFont.pointSize() + 2); + titleLabel->setFont(titleFont); + mainLayout->addWidget(titleLabel); + + if (!serverText.isEmpty()) { + mainLayout->addWidget(new QLabel(tr("From %1").arg(serverText.toHtmlEscaped()), this)); + } + + if (expiresAt > 0) { + const QString expiryText = QDateTime::fromSecsSinceEpoch(expiresAt).toLocalTime().toString(Qt::TextDate); + mainLayout->addWidget(new QLabel(tr("This share link expires on %1").arg(expiryText), this)); + } + + downloadStatusLabel = new QLabel(this); + downloadStatusLabel->setVisible(false); + mainLayout->addWidget(downloadStatusLabel); + + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + mainLayout->addWidget(flowWidget, 1); + + for (const ServerInfo_DeckShareItem &item : items) { + QStringList tags; + for (const auto &tag : item.tags()) { + tags.append(QString::fromStdString(tag)); + } + + auto *tile = new SharedDeckPreviewWidget( + this, querier, QString::fromStdString(item.name()), QString::fromStdString(item.banner_card()), + QString::fromStdString(item.color_identity()), QString::fromStdString(item.game_format()), tags.join(", ")); + flowWidget->addNavigableWidget(tile); + tiles.append(tile); + itemIds.append(item.id()); + } + + if (tiles.size() == 1) { + tiles.first()->setSelected(true); + } + + auto *buttonBox = new QDialogButtonBox(this); + openSelectedButton = buttonBox->addButton(tr("Open selected"), QDialogButtonBox::AcceptRole); + openAllButton = buttonBox->addButton(tr("Open all"), QDialogButtonBox::ActionRole); + buttonBox->addButton(tr("Cancel"), QDialogButtonBox::RejectRole); + mainLayout->addWidget(buttonBox); + + connect(buttonBox, &QDialogButtonBox::rejected, this, [this]() { + onCancel(); + close(); + }); + + // Esc calls QDialog::reject() directly (which hides the dialog without a + // close event), so route it through the same guarded cancel as the button. + connect(this, &QDialog::rejected, this, [this]() { + onCancel(); + close(); + }); + + connect(openSelectedButton, &QPushButton::clicked, this, &DlgSharedDecksPreview::openSelected); + connect(buttonBox, &QDialogButtonBox::clicked, this, [this, buttonBox](QAbstractButton *button) { + if (buttonBox->buttonRole(button) == QDialogButtonBox::ActionRole) { + openAll(); + } + }); + + for (SharedDeckPreviewWidget *tile : tiles) { + connect(tile, &SharedDeckPreviewWidget::selectionToggled, this, + &DlgSharedDecksPreview::updateOpenSelectedEnabled); + } + for (int i = 0; i < tiles.size(); ++i) { + const int itemId = itemIds.at(i); + // Double-clicking a tile selects it and opens just that deck. + connect(tiles.at(i), &SharedDeckPreviewWidget::activated, this, [this, itemId]() { + resultEmitted = true; + setDownloading(true); + emit openRequested(QList{itemId}); + }); + } + updateOpenSelectedEnabled(); +} + +QList DlgSharedDecksPreview::selectedItemIds() const +{ + QList selectedIds; + for (int i = 0; i < tiles.size(); ++i) { + if (tiles.at(i)->isSelected()) { + selectedIds.append(itemIds.at(i)); + } + } + return selectedIds; +} + +void DlgSharedDecksPreview::openSelected() +{ + const QList selectedIds = selectedItemIds(); + if (selectedIds.isEmpty()) { + return; + } + resultEmitted = true; + setDownloading(true); + emit openRequested(selectedIds); +} + +void DlgSharedDecksPreview::openAll() +{ + resultEmitted = true; + setDownloading(true); + emit openRequested(itemIds); +} + +void DlgSharedDecksPreview::setDownloading(bool downloading) +{ + if (downloadInProgress == downloading) { + return; + } + downloadInProgress = downloading; + downloadStatusLabel->setVisible(downloading); + for (SharedDeckPreviewWidget *tile : tiles) { + tile->setEnabled(!downloading); + } + openSelectedButton->setEnabled(!downloading); + openAllButton->setEnabled(!downloading); +} + +void DlgSharedDecksPreview::setDownloadProgress(int done, int total, const QString ¤tDeckName) +{ + if (!downloadInProgress) { + return; + } + downloadStatusLabel->setText(tr("Downloading deck %1 of %2: %3").arg(done).arg(total).arg(currentDeckName)); +} + +void DlgSharedDecksPreview::updateOpenSelectedEnabled() +{ + openSelectedButton->setEnabled(!selectedItemIds().isEmpty()); +} + +void DlgSharedDecksPreview::onCancel() +{ + if (!resultEmitted || downloadInProgress) { + resultEmitted = true; + emit cancelled(); + } +} + +void DlgSharedDecksPreview::closeEvent(QCloseEvent *event) +{ + onCancel(); + QDialog::closeEvent(event); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h new file mode 100644 index 000000000..31820e3a0 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h @@ -0,0 +1,68 @@ +#ifndef COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H +#define COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H + +#include +#include + +class FlowWidget; +class QCloseEvent; +class QLabel; +class QPushButton; +class ServerInfo_DeckShareItem; +class SharedDeckPreviewWidget; +class CardDatabaseQuerier; + +/** + * @brief Non-modal preview of the decks contained in a shared-deck link. + * + * Lets the user pick which of the shared decks to open before anything is + * downloaded. Emits openRequested with the ids of the chosen decks, or + * cancelled when the user closes the dialog without choosing. Once the user + * picks, the dialog switches into a "downloading" state: the tiles and open + * buttons are disabled, a progress label shows the current download and Cancel + * stays functional so the download can be aborted. + */ +class DlgSharedDecksPreview : public QDialog +{ + Q_OBJECT + +public: + explicit DlgSharedDecksPreview(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &shareName, + qint64 expiresAt, + const QString &serverText, + const QList &items); + + void setDownloadProgress(int done, int total, const QString ¤tDeckName); + +public slots: + void setDownloading(bool downloading); + +signals: + void openRequested(const QList &itemIds); + void cancelled(); + +protected: + void closeEvent(QCloseEvent *event) override; + +private slots: + void openSelected(); + void openAll(); + void updateOpenSelectedEnabled(); + void onCancel(); + +private: + QList selectedItemIds() const; + + FlowWidget *flowWidget; + QList tiles; + QList itemIds; + QPushButton *openSelectedButton; + QPushButton *openAllButton; + QLabel *downloadStatusLabel; + bool resultEmitted = false; + bool downloadInProgress = false; +}; + +#endif // COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index 7cf58d3e0..46151481c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -5,11 +5,13 @@ #include "../client/network/update/client/release_channel.h" #include "../interface/window_main.h" +#include #include #include #include #include #include +#include #include #include #include @@ -240,8 +242,23 @@ void DlgUpdate::downloadSuccessful(const QUrl &filepath) // Try to open the installer. If it opens, quit Cockatrice if (process.startDetached()) { - QMetaObject::invokeMethod(static_cast(parent()), "close", Qt::QueuedConnection); qCInfo(DlgUpdateLog) << "Opened downloaded update file successfully - closing Cockatrice"; + // Close the main window synchronously so file locks are released before the NSIS installer + // (already launched) starts replacing files. This also flushes settings and shuts down the + // tabs, but only when the close is actually accepted: MainWindow may veto it for a running + // card DB update, an open game, or an unsaved deck. In that case keep running so the user + // can resolve the blocker, and tell them the installer is already waiting. + if (auto *window = qobject_cast(parent())) { + if (window->close()) { + QTimer::singleShot(0, qApp, &QCoreApplication::quit); + } else { + QMessageBox::warning(this, tr("Update"), + tr("The update installer is already running and will finish the update once " + "Cockatrice closes. Cockatrice is still busy, so it stays open for now.")); + } + } else { + QTimer::singleShot(0, qApp, &QCoreApplication::quit); + } close(); } else { setLabel(tr("Error")); diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp index 025f457bd..01c9ac34e 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp @@ -7,6 +7,7 @@ #include "flow_widget.h" #include +#include #include #include #include @@ -80,13 +81,35 @@ FlowWidget::FlowWidget(QWidget *parent, /** * @brief Adds a widget to the flow layout within the FlowWidget. * + * Plain widgets are not filtered for arrow keys: intercepting them would steal + * Up/Down/Left/Right from controls that use them (combo boxes, spin boxes + * etc.). Widgets that want keyboard navigation between flow items must be + * added via addNavigableWidget instead. + * * @param widget_to_add The widget to add to the flow layout. */ -void FlowWidget::addWidget(QWidget *widget_to_add) const +void FlowWidget::addWidget(QWidget *widget_to_add) { flowLayout->addWidget(widget_to_add); } +/** + * @brief Adds a widget and routes its arrow keys to FlowWidget focus navigation. + * + * The widget is filtered for arrow-key events so keyboard navigation between + * the flow items keeps working even when the flow sits inside a QScrollArea, + * which swallows arrow keys before they can reach FlowWidget::keyPressEvent. + * Only widgets added through this method are affected; anything that needs its + * own arrow keys should use plain addWidget. + * + * @param widget_to_add The widget to add to the flow layout. + */ +void FlowWidget::addNavigableWidget(QWidget *widget_to_add) +{ + widget_to_add->installEventFilter(this); + flowLayout->addWidget(widget_to_add); +} + void FlowWidget::insertWidgetAtIndex(QWidget *toInsert, int index) { flowLayout->insertWidgetAtIndex(toInsert, index); @@ -177,6 +200,66 @@ QLayoutItem *FlowWidget::itemAt(int index) const return flowLayout->itemAt(index); } +void FlowWidget::keyPressEvent(QKeyEvent *event) +{ + if (moveFocus(event)) { + event->accept(); + return; + } + QWidget::keyPressEvent(event); +} + +bool FlowWidget::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::KeyPress && moveFocus(static_cast(event))) { + return true; + } + return QWidget::eventFilter(watched, event); +} + +bool FlowWidget::moveFocus(QKeyEvent *event) +{ + // Keyboard navigation between the flow items: arrow keys move focus just + // like clicking the sibling tiles would. Only items that can take keyboard + // focus (e.g. the deck-preview tiles in shared-deck links) are visited. + const bool moveForward = event->key() == Qt::Key_Right || event->key() == Qt::Key_Down; + const bool moveBackward = event->key() == Qt::Key_Left || event->key() == Qt::Key_Up; + if (!moveForward && !moveBackward) { + return false; + } + + QList focusableItems; + for (int i = 0; i < flowLayout->count(); ++i) { + QWidget *item = flowLayout->itemAt(i)->widget(); + if (item != nullptr && (item->focusPolicy() & Qt::TabFocus)) { + focusableItems.append(item); + } + } + + if (focusableItems.isEmpty()) { + return false; + } + + int currentIndex = -1; + for (int i = 0; i < focusableItems.size(); ++i) { + if (focusableItems.at(i)->hasFocus()) { + currentIndex = i; + break; + } + } + + const int delta = moveForward ? 1 : -1; + int nextIndex; + if (currentIndex < 0) { + nextIndex = moveForward ? 0 : focusableItems.size() - 1; + } else { + nextIndex = (currentIndex + delta + focusableItems.size()) % focusableItems.size(); + } + focusableItems.value(nextIndex)->setFocus(); + event->accept(); + return true; +} + int FlowWidget::count() const { return flowLayout->count(); diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h index a232336d8..4d52db3f1 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h @@ -11,6 +11,7 @@ #include "../../../layouts/flow_layout.h" #include +#include #include #include #include @@ -28,7 +29,8 @@ public: Qt::ScrollBarPolicy horizontalPolicy, Qt::ScrollBarPolicy verticalPolicy); - void addWidget(QWidget *widget_to_add) const; + void addWidget(QWidget *widget_to_add); + void addNavigableWidget(QWidget *widget_to_add); void insertWidgetAtIndex(QWidget *toInsert, int index); void removeWidget(QWidget *widgetToRemove) const; void clearLayout(); @@ -43,9 +45,15 @@ public slots: void setSpacing(int hSpacing, int vSpacing); protected: + bool eventFilter(QObject *watched, QEvent *event) override; void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; private: + /// @brief Moves keyboard focus to an adjacent flow item for an arrow-key event. + /// @return True when the event was an arrow key and was handled. + bool moveFocus(QKeyEvent *event); + Qt::Orientation flowDirection; QHBoxLayout *mainLayout; FlowLayout *flowLayout; diff --git a/cockatrice/src/interface/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/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index a8bf54e91..659325987 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -369,7 +369,7 @@ void GameSelector::joinGame(const ServerInfo_Game &game, const bool asSpectator, return; } - bool overrideRestrictions = !tabSupervisor->getAdminLocked(); + bool overrideRestrictions = tabSupervisor->canOverrideGameRestrictions(); // Joining a full game without override privileges silently becomes a // spectator join, so ask first instead of surprising the player. @@ -462,7 +462,7 @@ void GameSelector::enableButtonsForIndex(const QModelIndex ¤t) } const ServerInfo_Game &game = gameListModel->getGame(current.data(Qt::UserRole).toInt()); - bool overrideRestrictions = !tabSupervisor->getAdminLocked(); + bool overrideRestrictions = tabSupervisor->canOverrideGameRestrictions(); spectateButton->setEnabled(game.spectators_allowed() || overrideRestrictions); joinButton->setEnabled(game.player_count() < game.max_players() || overrideRestrictions); diff --git a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp index a6add3fca..df9dfbf53 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp +++ b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp @@ -113,7 +113,7 @@ int RemoteDeckList_TreeModel::rowCount(const QModelIndex &parent) const int RemoteDeckList_TreeModel::columnCount(const QModelIndex & /*parent*/) const { - return 3; + return 4; } QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) const @@ -121,7 +121,7 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons if (!index.isValid()) { return QVariant(); } - if (index.column() >= 3) { + if (index.column() >= 4) { return QVariant(); } @@ -134,12 +134,29 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons switch (index.column()) { case 0: return node->getName(); + case 3: + // Report the node's own bit, not the inherited effective + // state, so it stays in step with what publishing toggles. + if (node->isPublic()) { + return tr("Public"); + } + return isEffectivelyPublic(node) ? tr("Public (inherited)") : tr("Private"); default: return QVariant(); } } case Qt::DecorationRole: return index.column() == 0 ? dirIcon : QVariant(); + case Qt::ToolTipRole: + if (index.column() == 3) { + if (node->isPublic()) { + return tr("This folder is visible to other users"); + } + return isEffectivelyPublic(node) + ? tr("This folder is private, but a parent folder is public (inherited).") + : tr("This folder is only visible to you"); + } + return QVariant(); default: return QVariant(); } @@ -153,6 +170,13 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons return file->getId(); case 2: return file->getUploadTime(); + case 3: + // Report the node's own bit, not the inherited effective + // state, so it stays in step with what publishing toggles. + if (file->isPublic()) { + return tr("Public"); + } + return isEffectivelyPublic(file) ? tr("Public (inherited)") : tr("Private"); default: return QVariant(); } @@ -161,6 +185,16 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons return index.column() == 0 ? fileIcon : QVariant(); case Qt::TextAlignmentRole: return index.column() == 1 ? Qt::AlignRight : Qt::AlignLeft; + case Qt::ToolTipRole: + if (index.column() == 3) { + if (file->isPublic()) { + return tr("This deck is visible to other users"); + } + return isEffectivelyPublic(file) + ? tr("This deck is private, but a parent folder is public (inherited).") + : tr("This deck is only visible to you"); + } + return QVariant(); default: return QVariant(); } @@ -183,6 +217,8 @@ QVariant RemoteDeckList_TreeModel::headerData(int section, Qt::Orientation orien return tr("ID"); case 2: return tr("Upload time"); + case 3: + return tr("Visibility"); default: return QVariant(); } @@ -239,13 +275,14 @@ void RemoteDeckList_TreeModel::addFileToTree(const ServerInfo_DeckStorage_TreeIt time.setSecsSinceEpoch(fileInfo.creation_time()); beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); - parent->append(new FileNode(QString::fromStdString(file.name()), file.id(), time, parent)); + parent->append(new FileNode(QString::fromStdString(file.name()), file.id(), time, parent, fileInfo.is_public())); endInsertRows(); } void RemoteDeckList_TreeModel::addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent) { DirectoryNode *newItem = addNamedFolderToTree(QString::fromStdString(folder.name()), parent); + newItem->setIsPublic(folder.folder().is_public()); const ServerInfo_DeckStorage_Folder &folderInfo = folder.folder(); const int folderItemsSize = folderInfo.items_size(); for (int i = 0; i < folderItemsSize; ++i) { @@ -285,6 +322,21 @@ void RemoteDeckList_TreeModel::refreshTree() client->sendCommand(pend); } +bool RemoteDeckList_TreeModel::isEffectivelyPublic(const Node *node) const +{ + if (node == nullptr || node == root) { + return false; + } + const Node *current = node; + while (current != nullptr) { + if (current->isPublic()) { + return true; + } + current = current->getParent(); + } + return false; +} + void RemoteDeckList_TreeModel::clearTree() { beginResetModel(); diff --git a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h index 3dd91d7a4..2cf09aff7 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h +++ b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h @@ -27,9 +27,11 @@ public: protected: DirectoryNode *parent; QString name; + bool publicFlag; public: - explicit Node(const QString &_name, DirectoryNode *_parent = nullptr) : parent(_parent), name(_name) + explicit Node(const QString &_name, DirectoryNode *_parent = nullptr) + : parent(_parent), name(_name), publicFlag(false) { } virtual ~Node() = default; @@ -41,6 +43,14 @@ public: { return name; } + [[nodiscard]] bool isPublic() const + { + return publicFlag; + } + void setIsPublic(bool _public) + { + publicFlag = _public; + } }; class DirectoryNode : public Node, public QList { @@ -59,9 +69,14 @@ public: QDateTime uploadTime; public: - FileNode(const QString &_name, int _id, const QDateTime &_uploadTime, DirectoryNode *_parent = nullptr) + FileNode(const QString &_name, + int _id, + const QDateTime &_uploadTime, + DirectoryNode *_parent = nullptr, + bool _isPublic = false) : Node(_name, _parent), id(_id), uploadTime(_uploadTime) { + setIsPublic(_isPublic); } [[nodiscard]] int getId() const { @@ -109,6 +124,11 @@ public: { return root; } + /** + * @brief Whether a node is visible to other users (own flag or inherited + * from any ancestor folder). + */ + [[nodiscard]] bool isEffectivelyPublic(const Node *node) const; void addFileToTree(const ServerInfo_DeckStorage_TreeItem &file, DirectoryNode *parent); void addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent); DirectoryNode *addNamedFolderToTree(const QString &name, DirectoryNode *parent); diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 0d2267a63..f95ad88e4 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -37,6 +37,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aDetails = new QAction(QString(), this); aChat = new QAction(QString(), this); aShowGames = new QAction(QString(), this); + aViewPublicDecks = new QAction(QString(), this); aAddToBuddyList = new QAction(QString(), this); aRemoveFromBuddyList = new QAction(QString(), this); aAddToIgnoreList = new QAction(QString(), this); @@ -64,6 +65,7 @@ void UserContextMenu::retranslateUi() aDetails->setText(tr("User &details")); aChat->setText(tr("Private &chat")); aShowGames->setText(tr("Show this user's &games")); + aViewPublicDecks->setText(tr("View this user's &public decks")); aAddToBuddyList->setText(tr("Add to &buddy list")); aRemoveFromBuddyList->setText(tr("Remove from &buddy list")); aAddToIgnoreList->setText(tr("Add to &ignore list")); @@ -376,6 +378,9 @@ void UserContextMenu::showContextMenu(const QPoint &pos, } menu->addAction(aDetails); menu->addAction(aShowGames); + if (userLevel.testFlag(ServerInfo_User::IsRegistered)) { + menu->addAction(aViewPublicDecks); + } menu->addAction(aChat); const QList inviteOptions = inviteOptionsForUser(userName); if (!inviteOptions.isEmpty()) { @@ -455,6 +460,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName)); aShowGames->setEnabled(online); aReport->setEnabled(anotherUser); + aViewPublicDecks->setEnabled(anotherUser); aAddToBuddyList->setEnabled(anotherUser); aRemoveFromBuddyList->setEnabled(anotherUser); aAddToIgnoreList->setEnabled(anotherUser); @@ -481,6 +487,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos, execChat(userName); } else if (actionClicked == aShowGames) { execShowGames(userName); + } else if (actionClicked == aViewPublicDecks) { + execViewPublicDecks(userName); } else if (actionClicked == aAddToBuddyList) { execAddToBuddy(userName); } else if (actionClicked == aRemoveFromBuddyList) { @@ -604,6 +612,11 @@ void UserContextMenu::execShowGames(const QString &userName) client->sendCommand(pend); } +void UserContextMenu::execViewPublicDecks(const QString &userName) +{ + tabSupervisor->openTabPublicDecks(userName); +} + void UserContextMenu::execAddToBuddy(const QString &userName) { Command_AddToList cmd; diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index 6abbc057a..0922eae94 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -37,6 +37,7 @@ private: QAction *aUserName; QAction *aDetails; QAction *aShowGames; + QAction *aViewPublicDecks; QAction *aChat; QAction *aAddToBuddyList, *aRemoveFromBuddyList; QAction *aAddToIgnoreList, *aRemoveFromIgnoreList; @@ -111,6 +112,7 @@ public: void execInvite(const QString &userName); void execDetails(const QString &userName); void execShowGames(const QString &userName); + void execViewPublicDecks(const QString &userName); void execAddToBuddy(const QString &userName); void execRemoveFromBuddy(const QString &userName); void execAddToIgnore(const QString &userName); 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..6423c581b 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp @@ -11,6 +11,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../cards/additional_info/deck_color_identity.h" #include "../client/network/interfaces/deck_stats_interface.h" #include "../client/network/interfaces/tapped_out_interface.h" #include "../deck_editor/deck_state_manager.h" @@ -19,6 +20,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" @@ -323,6 +325,7 @@ bool AbstractTabDeckEditor::actSaveDeck() Command_DeckUpload cmd; cmd.set_deck_id(static_cast(loadedDeck.lastLoadInfo.remoteDeckId)); cmd.set_deck_list(deckString.toStdString()); + cmd.set_color_identity(getDeckColorIdentity(loadedDeck.deckList, CardDatabaseManager::query()).toStdString()); PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, &AbstractTabDeckEditor::saveDeckRemoteFinished); @@ -382,6 +385,27 @@ bool AbstractTabDeckEditor::actSaveDeckAs() return true; } +/** + * @brief Opens the deck share dialog with the current deck preselected. + */ +void AbstractTabDeckEditor::actShareDeck() +{ + AbstractClient *client = tabSupervisor->getServerClient(); + if (client->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()) { + QMessageBox::information(this, tr("Share deck"), tr("The deck is empty. Add cards before sharing it.")); + return; + } + + DlgShareDeck shareDialog(client, 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 f3535d850..62769d0e3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -3,35 +3,54 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" #include "../../pixel_map_generator.h" +#include "../cards/additional_info/deck_color_identity.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 #include #include #include +#include +#include #include #include #include +#include #include #include +#include #include #include +namespace +{ +// How long to wait after the last visibility change before reading back the +// Public/Private column, in milliseconds. +constexpr int VISIBILITY_REFRESH_DELAY = 500; +} // namespace + TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo) @@ -92,8 +111,34 @@ 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); + + shareTimeoutTimer = new QTimer(this); + shareTimeoutTimer->setSingleShot(true); + shareTimeoutTimer->setInterval( + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000)); + connect(shareTimeoutTimer, &QTimer::timeout, this, &TabDeckStorage::onShareFromTreeTimeout); + + // Restartable single-shot refresh for the Public/Private column. It is + // armed with the full network timeout when a publish is sent (so a dropped + // reply still drains once) and re-armed with the short delay every time a + // reply lands, so the drain cannot fire while a slow round trip is still in + // flight. Either way the tree is re-read once things quiet down. + visibilityRefreshTimer = new QTimer(this); + visibilityRefreshTimer->setSingleShot(true); + visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY); + connect(visibilityRefreshTimer, &QTimer::timeout, this, &TabDeckStorage::onVisibilityRefreshTimeout); QVBoxLayout *rightVbox = new QVBoxLayout; + rightVbox->addWidget(shareBar); rightVbox->addWidget(serverDirView); rightVbox->addLayout(rightToolBarLayout); rightGroupBox = new QGroupBox; @@ -139,6 +184,14 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, aDeleteRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); + aShareDecks = new QAction(this); + aShareDecks->setIcon(themePixmap(QStringLiteral("icons/share"))); + connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks); + + aPublishDeck = new QAction(this); + aPublishDeck->setIcon(QPixmap("theme:icons/lock")); + connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck); + // Add actions to toolbars leftToolBar->addAction(aOpenLocalDeck); leftToolBar->addAction(aRenameLocal); @@ -150,6 +203,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, rightToolBar->addAction(aOpenRemoteDeck); rightToolBar->addAction(aDownload); + rightToolBar->addAction(aShareDecks); + rightToolBar->addAction(aPublishDeck); rightToolBar->addAction(aNewFolder); rightToolBar->addAction(aDeleteRemoteDeck); @@ -178,7 +233,13 @@ void TabDeckStorage::retranslateUi() aNewFolder->setText(tr("New folder")); aDeleteLocalDeck->setText(tr("Delete")); aDeleteRemoteDeck->setText(tr("Delete")); + aShareDecks->setText(tr("Share decks")); + aPublishDeck->setText(tr("Publish/unpublish deck")); aOpenDecksFolder->setText(tr("Open decks folder")); + shareBar->retranslateUi(); + if (shareBar->isVisible()) { + onServerSelectionChanged(); + } } QString TabDeckStorage::getTargetPath() const @@ -211,6 +272,8 @@ void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo) void TabDeckStorage::handleConnectionChanged(ClientStatus status) { if (status == StatusDisconnected) { + visibilityRefreshTimer->stop(); + visibilityRefreshStarted = false; setRemoteEnabled(false); } } @@ -220,12 +283,15 @@ void TabDeckStorage::setRemoteEnabled(bool enabled) aUpload->setEnabled(enabled); aOpenRemoteDeck->setEnabled(enabled); aDownload->setEnabled(enabled); + aShareDecks->setEnabled(enabled); + aPublishDeck->setEnabled(enabled); aNewFolder->setEnabled(enabled); aDeleteRemoteDeck->setEnabled(enabled); if (enabled) { serverDirView->refreshTree(); } else { + setShareModeEnabled(false); serverDirView->clearTree(); } } @@ -347,6 +413,8 @@ void TabDeckStorage::uploadDeck(const QString &filePath, const QString &targetPa cmd.set_path(targetPath.toStdString()); cmd.set_deck_list(deckString.toStdString()); + cmd.set_color_identity(getDeckColorIdentity(deck, CardDatabaseManager::query()).toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, &TabDeckStorage::uploadFinished); client->sendCommand(pend); @@ -626,3 +694,241 @@ 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->setCreateEnabled(true); + shareBar->setName(tr("Shared decks")); + onServerSelectionChanged(); + shareBar->focusName(); + } else { + // Abandon any in-flight request: otherwise the timer keeps running and a late + // response reports the share as created after the user already backed out. + shareTimeoutTimer->stop(); + shareInFlightSeq = 0; + 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; + } + } + + 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); + } + if (files > 0) { + parts << tr("%n deck(s)", "", files); + } + shareBar->setCountText(parts.isEmpty() ? tr("No decks selected") + : tr("Selected: %1").arg(parts.join(QStringLiteral(", ")))); +} + +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()); + if (cmd.name().empty()) { + cmd.set_name(tr("Shared decks").toStdString()); + } + + 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(); + item->set_deck_id(fileNode->getId()); + } + } + } + + if (cmd.items_size() == 0 && cmd.folder_path().empty()) { + showShareNotice(tr("Select decks to share."), true); + return; + } + + shareBar->setCreateEnabled(false); + const int seq = ++shareRequestSeq; + shareInFlightSeq = seq; + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, seq](const Response &response, const CommandContainer &commandContainer) { + if (shareInFlightSeq != seq) { + return; // the user cancelled or a newer request superseded this one + } + shareInFlightSeq = 0; + shareFromTreeFinished(response, commandContainer); + }); + client->sendCommand(pend); + shareTimeoutTimer->start(); +} + +void TabDeckStorage::shareFromTreeFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + shareTimeoutTimer->stop(); + 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()))), + true); + return; + } + const DeckShareUtils::ShareResponse share = DeckShareUtils::handleShareResponse(client, response); + + showShareNotice( + tr("Share link copied to the clipboard.\nExpires on %1.").arg(DeckShareUtils::formatShareExpiry(share.expiry))); + setShareModeEnabled(false); +} + +void TabDeckStorage::showShareNotice(const QString &message, bool warning) +{ + QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message, + QMessageBox::Ok, this); + box.exec(); +} + +void TabDeckStorage::onShareFromTreeTimeout() +{ + if (shareInFlightSeq == 0) { + return; // share mode was left while the request was still outstanding + } + shareInFlightSeq = 0; + shareBar->setCreateEnabled(true); + showShareNotice(tr("The server did not respond in time. Try again."), true); +} + +void TabDeckStorage::actPublishDeck() +{ + visibilityFailures.clear(); + // Arm the drain with the full network timeout so a lost reply still costs + // one refresh instead of a dead column; each reply shrinks it to the short + // delay below, so a slow round trip is never drained before it lands. + const int visibilityFailSafeDelay = + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000); + + const auto selection = serverDirView->getCurrentSelection(); + for (const auto *node : selection) { + Command_DeckSetVisibility cmd; + if (const auto *fileNode = dynamic_cast(node)) { + cmd.set_deck_id(fileNode->getId()); + } else if (const auto *dirNode = dynamic_cast(node)) { + const QString path = dirNode->getPath(); + if (path.isEmpty()) { + continue; // the root folder cannot be published + } + cmd.set_folder_path(path.toStdString()); + } else { + continue; + } + // Toggle the node's own visibility bit (what the server persists); the + // effective visibility shown by the column may additionally be inherited + // from a parent folder. + cmd.set_is_public(!node->isPublic()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabDeckStorage::setVisibilityFinished); + visibilityRefreshStarted = true; + visibilityRefreshTimer->setInterval(visibilityFailSafeDelay); + visibilityRefreshTimer->start(); + client->sendCommand(pend); + } +} + +void TabDeckStorage::setVisibilityFinished(const Response &r, const CommandContainer & /*commandContainer*/) +{ + if (r.response_code() == Response::RespOk) { + if (visibilityRefreshStarted) { + visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY); + visibilityRefreshTimer->start(); + } + return; + } + + // Collect batch failures and surface them once, when publishing quiets + // down, instead of stacking one modal dialog per rejected node. + const QString message = tr("Failed to change deck visibility on server (response code %1).") + .arg(QString::number(static_cast(r.response_code()))); + if (visibilityRefreshStarted) { + visibilityFailures.append(message); + visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY); + visibilityRefreshTimer->start(); + } else { + QMessageBox::critical(this, tr("Error"), message); + } +} + +void TabDeckStorage::onVisibilityRefreshTimeout() +{ + visibilityRefreshStarted = false; + if (!visibilityFailures.isEmpty()) { + QMessageBox::critical( + this, tr("Error"), + tr("Failed to change the visibility of %n selected deck(s).", "", visibilityFailures.size())); + visibilityFailures.clear(); + } + serverDirView->refreshTree(); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h index a863e0625..f8d585880 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h @@ -11,6 +11,7 @@ #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "tab.h" +#include #include struct LoadedDeck; @@ -22,8 +23,10 @@ class QToolBar; class QTreeWidget; class QTreeWidgetItem; class QGroupBox; +class QTimer; class CommandContainer; class Response; +class ShareBarWidget; class TabDeckStorage : public Tab { @@ -35,14 +38,25 @@ private: QToolBar *leftToolBar, *rightToolBar; RemoteDeckList_TreeWidget *serverDirView; QGroupBox *leftGroupBox, *rightGroupBox; + ShareBarWidget *shareBar; + QTimer *shareTimeoutTimer; + int shareRequestSeq = 0; + int shareInFlightSeq = 0; QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck; QAction *aOpenDecksFolder; - QAction *aOpenRemoteDeck, *aDownload, *aNewFolder, *aDeleteRemoteDeck; + QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck; + bool visibilityRefreshStarted = false; + QTimer *visibilityRefreshTimer; + QStringList visibilityFailures; QString getTargetPath() const; void setRemoteEnabled(bool enabled); + void showShareNotice(const QString &message, bool warning = false); + + void setShareModeEnabled(bool enabled); + void uploadDeck(const QString &filePath, const QString &targetPath); void deleteRemoteDeck(const RemoteDeckList_TreeModel::Node *node); @@ -75,6 +89,17 @@ 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 onShareFromTreeTimeout(); + + void actPublishDeck(); + void setVisibilityFinished(const Response &r, const CommandContainer &commandContainer); + void onVisibilityRefreshTimeout(); + 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/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp new file mode 100644 index 000000000..35389d3bf --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -0,0 +1,251 @@ +#include "tab_public_decks.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../deck_loader/deck_loader.h" +#include "../general/layout_containers/flow_widget.h" +#include "../visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h" +#include "../visual_deck_storage/deck_preview/public_deck_preview_widget.h" +#include "../visual_deck_storage/remote_public_decks_model.h" +#include "../visual_deck_storage/visual_deck_storage_quick_settings_widget.h" +#include "../visual_deck_storage/visual_deck_storage_search_widget.h" +#include "../visual_deck_storage/visual_deck_storage_tag_filter_widget.h" +#include "tab_supervisor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &_userName) + : Tab(_tabSupervisor), client(_client), userName(_userName) +{ + model = new RemotePublicDecksModel(client, this); + cardSize = SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize(); + + titleLabel = new QLabel(tr("Public decks of %1").arg(userName.toHtmlEscaped()), this); + QFont titleFont = titleLabel->font(); + titleFont.setBold(true); + titleLabel->setFont(titleFont); + + auto *headerLayout = new QHBoxLayout; + headerLayout->addWidget(titleLabel); + headerLayout->addStretch(1); + + // Filter/toolbar row, matching the Visual Deck Storage: color identity filter + // first, the search bar stretching in the middle, and the quick settings + // cogwheel at the end. The card size slider lives inside the cogwheel popup. + emptyLabel = new QLabel(tr("This user has not published any decks."), this); + emptyLabel->setAlignment(Qt::AlignCenter); + emptyLabel->setVisible(false); + + statusLabel = new QLabel(this); + statusLabel->setAlignment(Qt::AlignCenter); + statusLabel->setVisible(false); + + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + flowWidget->setSpacing(8, 8); + + colorIdentityFilter = new DeckPreviewColorIdentityFilterWidget(this); + searchWidget = new VisualDeckStorageSearchWidget(this); + refreshButton = new QToolButton(this); + refreshButton->setIcon(QPixmap("theme:icons/reload")); + refreshButton->setFixedSize(32, 32); + quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this); + quickSettingsWidget->setPublicDecksMode(true); + + auto *filterLayout = new QHBoxLayout; + filterLayout->addWidget(colorIdentityFilter); + filterLayout->addWidget(searchWidget, 1); + filterLayout->addWidget(refreshButton); + filterLayout->addWidget(quickSettingsWidget); + + tagFilterWidget = new VisualDeckStorageTagFilterWidget(this); + tagFilterWidget->setAllTagsProvider([this] { return model->allTags(); }); + updateTagsVisibility(quickSettingsWidget->getShowTagFilter()); + + auto *layout = new QVBoxLayout; + layout->addLayout(headerLayout); + layout->addLayout(filterLayout); + layout->addWidget(tagFilterWidget); + layout->addWidget(statusLabel); + layout->addWidget(emptyLabel); + layout->addWidget(flowWidget, 1); + + auto *mainWidget = new QWidget(this); + mainWidget->setLayout(layout); + setCentralWidget(mainWidget); + + connect(refreshButton, &QToolButton::clicked, this, [this] { model->refresh(userName); }); + connect(model, &QAbstractItemModel::modelReset, this, &TabPublicDecks::rebuildGrid); + connect(model, &RemotePublicDecksModel::loadingChanged, this, &TabPublicDecks::updateLoadingState); + connect(model, &RemotePublicDecksModel::loadFailed, this, [this](const QString &message) { + lastFailureMessage = message; + statusLabel->setText(message); + statusLabel->setVisible(true); + flowWidget->setVisible(false); + emptyLabel->setVisible(false); + }); + connect(searchWidget, &VisualDeckStorageSearchWidget::searchTextChanged, this, + [this](const QString &text) { model->setSearchText(text); }); + connect(colorIdentityFilter, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, this, + &TabPublicDecks::updateColorFilter); + connect(colorIdentityFilter, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, this, + &TabPublicDecks::updateColorFilter); + connect(tagFilterWidget, &VisualDeckStorageTagFilterWidget::filterChanged, this, &TabPublicDecks::updateTagFilter); + connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, this, + &TabPublicDecks::updateCardSize); + connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::showTagFilterChanged, this, + &TabPublicDecks::updateTagsVisibility); + + retranslateUi(); + model->refresh(userName); +} + +QString TabPublicDecks::getTabText() const +{ + return tr("Public decks of %1").arg(userName); +} + +void TabPublicDecks::retranslateUi() +{ + // The username is another user's data, so escape it for the AutoText QLabel. + titleLabel->setText(tr("Public decks of %1").arg(userName.toHtmlEscaped())); + // The same choice rebuildGrid makes, so a language change does not swap + // the "no match" variant for the "nothing published" one. + emptyLabel->setText(model->totalCount() > 0 ? tr("No decks match your filters.") + : tr("This user has not published any decks.")); + refreshButton->setToolTip(tr("Refresh")); + refreshButton->setAccessibleName(tr("Refresh")); + quickSettingsWidget->setToolTip(tr("Public Decks Settings")); + // Re-show whatever the status label is showing so a language change picks up + // the new language or, for a failure message, at least does not hide it. + if (model->isLoading()) { + updateLoadingState(true); + } else if (!lastFailureMessage.isEmpty()) { + statusLabel->setText(lastFailureMessage); + statusLabel->setVisible(true); + flowWidget->setVisible(false); + emptyLabel->setVisible(false); + } else { + updateLoadingState(false); + } + emit tabTextChanged(this, getTabText()); +} + +bool TabPublicDecks::closeRequest() +{ + emit closing(this); + return Tab::closeRequest(); +} + +void TabPublicDecks::rebuildGrid() +{ + flowWidget->clearLayout(); + + const int count = model->rowCount(); + if (count == 0) { + emptyLabel->setText(model->totalCount() > 0 ? tr("No decks match your filters.") + : tr("This user has not published any decks.")); + } + emptyLabel->setVisible(count == 0); + for (int i = 0; i < count; ++i) { + auto *tile = new PublicDeckPreviewWidget(flowWidget, model->entryAt(i)); + tile->setScaleFactor(cardSize); + connect(tile, &PublicDeckPreviewWidget::openDeckRequested, this, &TabPublicDecks::openDeck); + flowWidget->addWidget(tile); + } + + // The deck set changed, so the tag filter chips are re-gathered from it. + tagFilterWidget->refreshTags(); +} + +void TabPublicDecks::updateColorFilter() +{ + model->setColorFilter(colorIdentityFilter->getFilterMode(), colorIdentityFilter->getActiveColors()); +} + +void TabPublicDecks::updateTagFilter() +{ + const QStringList selectedTags = tagFilterWidget->selectedTags(); + const QStringList excludedTags = tagFilterWidget->excludedTags(); + model->setTagFilter(QSet(selectedTags.cbegin(), selectedTags.cend()), + QSet(excludedTags.cbegin(), excludedTags.cend())); + tagFilterWidget->refreshTags(); +} + +void TabPublicDecks::updateTagsVisibility(bool visible) +{ + tagFilterWidget->setVisible(visible); +} + +void TabPublicDecks::updateLoadingState(bool loading) +{ + if (loading) { + // A new attempt is under way, so the previously shown failure, if any, + // no longer describes the current state. + lastFailureMessage.clear(); + statusLabel->setText(tr("Loading public decks…")); + statusLabel->setVisible(true); + flowWidget->setVisible(false); + emptyLabel->setVisible(false); + } else { + statusLabel->setVisible(false); + flowWidget->setVisible(true); + } +} + +void TabPublicDecks::updateCardSize(int scale) +{ + cardSize = scale; + applyCardSize(scale); +} + +void TabPublicDecks::applyCardSize(int scale) +{ + const auto tiles = flowWidget->findChildren(); + for (PublicDeckPreviewWidget *tile : tiles) { + tile->setScaleFactor(scale); + } + flowWidget->setMinimumSizeToMaxSizeHint(); +} + +void TabPublicDecks::openDeck(int deckId) +{ + Command_DeckDownloadPublic cmd; + cmd.set_deck_id(deckId); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabPublicDecks::openDeckFinished); + client->sendCommand(pend); +} + +void TabPublicDecks::openDeckFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + if (response.response_code() != Response::RespOk) { + QMessageBox::warning(this, tr("Open public deck"), + tr("Failed to open the public deck (server response code %1).") + .arg(QString::number(static_cast(response.response_code())))); + return; + } + + const Response_DeckDownload &resp = response.GetExtension(Response_DeckDownload::ext); + std::optional deckOpt = + DeckLoader::loadFromRemote(QString::fromStdString(resp.deck()), LoadedDeck::LoadInfo::NON_REMOTE_ID); + if (!deckOpt) { + QMessageBox::warning(this, tr("Open public deck"), tr("The public deck could not be parsed.")); + return; + } + + tabSupervisor->openDeckInNewTab(deckOpt.value()); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.h b/cockatrice/src/interface/widgets/tabs/tab_public_decks.h new file mode 100644 index 000000000..492fdeafa --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.h @@ -0,0 +1,80 @@ +/** + * @file tab_public_decks.h + * @ingroup Tabs + */ + +#ifndef TAB_PUBLIC_DECKS_H +#define TAB_PUBLIC_DECKS_H + +#include "tab.h" + +class AbstractClient; +class CommandContainer; +class DeckPreviewColorIdentityFilterWidget; +class FlowWidget; +class PublicDeckPreviewWidget; +class QLabel; +class QToolButton; +class RemotePublicDecksModel; +class Response; +class VisualDeckStorageQuickSettingsWidget; +class VisualDeckStorageSearchWidget; +class VisualDeckStorageTagFilterWidget; + +/** + * @brief A visual grid of the public decks published by another user. + * + * The grid is rendered from the preview metadata the server stores for the + * decks, so browsing costs no downloads; the deck list is fetched via + * Command_DeckDownloadPublic only when the user opens a deck. Multiple users + * can be browsed simultaneously; each gets its own tab. + */ +class TabPublicDecks final : public Tab +{ + Q_OBJECT + +public: + TabPublicDecks(TabSupervisor *tabSupervisor, AbstractClient *client, const QString &userName); + + [[nodiscard]] QString getTabText() const override; + void retranslateUi() override; + bool closeRequest() override; + + [[nodiscard]] QString getUserName() const + { + return userName; + } + +signals: + void closing(TabPublicDecks *tab); + +private slots: + void openDeck(int deckId); + void openDeckFinished(const Response &response, const CommandContainer &commandContainer); + void updateColorFilter(); + void updateTagFilter(); + void updateCardSize(int scale); + void updateTagsVisibility(bool visible); + void updateLoadingState(bool loading); + +private: + void rebuildGrid(); + void applyCardSize(int scale); + + AbstractClient *client; + QString userName; + RemotePublicDecksModel *model; + FlowWidget *flowWidget; + VisualDeckStorageSearchWidget *searchWidget; + DeckPreviewColorIdentityFilterWidget *colorIdentityFilter; + VisualDeckStorageTagFilterWidget *tagFilterWidget; + QToolButton *refreshButton; + VisualDeckStorageQuickSettingsWidget *quickSettingsWidget; + QLabel *titleLabel; + QLabel *statusLabel; + QLabel *emptyLabel; + QString lastFailureMessage; ///< Last load-failure text, re-shown on retranslate. + int cardSize = 100; +}; + +#endif // TAB_PUBLIC_DECKS_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_server.cpp b/cockatrice/src/interface/widgets/tabs/tab_server.cpp index 13a77e957..fca32094c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_server.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_server.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -185,25 +186,37 @@ void TabServer::processServerMessageEvent(const Event_ServerMessage &event) void TabServer::joinRoom(int id, bool setCurrent) { TabRoom *room = tabSupervisor->getRoomTabs().value(id); - if (!room) { - Command_JoinRoom cmd; - cmd.set_room_id(id); - - PendingCommand *pend = client->prepareSessionCommand(cmd); - pend->setExtraData(setCurrent); - connect(pend, &PendingCommand::finished, this, - [this, id](const Response &r, const CommandContainer &c, const QVariant &v) { - joinRoomFinished(r, c, v, id); - }); - - client->sendCommand(pend); - + if (room) { + if (setCurrent) { + tabSupervisor->setCurrentWidget((QWidget *)room); + } return; } - if (setCurrent) { - tabSupervisor->setCurrentWidget((QWidget *)room); + auto pendingIt = pendingRoomJoins.find(id); + if (pendingIt != pendingRoomJoins.end()) { + // A join for this room is already in flight: the room tab opens when its response + // arrives. Fold the new request into the pending one so that, for example, clicking + // a room the selector is auto-joining does not send a second Command_JoinRoom - the + // server would reject that duplicate with RespContextError. + if (setCurrent) { + pendingIt.value() = true; + } + return; } + + pendingRoomJoins.insert(id, setCurrent); + + Command_JoinRoom cmd; + cmd.set_room_id(id); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + pend->setExtraData(setCurrent); + connect( + pend, &PendingCommand::finished, this, + [this, id](const Response &r, const CommandContainer &c, const QVariant &v) { joinRoomFinished(r, c, v, id); }); + + client->sendCommand(pend); } void TabServer::joinRoomFinished(const Response &r, @@ -211,34 +224,72 @@ void TabServer::joinRoomFinished(const Response &r, const QVariant &extraData, int roomId) { + const bool setCurrent = pendingRoomJoins.value(roomId, extraData.toBool()); + pendingRoomJoins.remove(roomId); + const bool healedJoin = healedRoomJoins.contains(roomId); + healedRoomJoins.remove(roomId); + switch (r.response_code()) { case Response::RespOk: break; case Response::RespNameNotFound: - QMessageBox::critical(this, tr("Error"), - tr("Failed to join the server room: it doesn't exist on the server.")); + if (setCurrent) { + QMessageBox::critical(this, tr("Error"), + tr("Failed to join the server room: it doesn't exist on the server.")); + } emit roomJoinFailed(roomId); return; case Response::RespContextError: - QMessageBox::critical( - this, tr("Error"), - tr("The server thinks you are in the server room but your client is unable to display it. " - "Try restarting your client.")); - emit roomJoinFailed(roomId); + if (healedJoin) { + // The rejoin below was already answered and the server still rejects the join, so + // the stale-membership heal cannot help: surface the error. The guard was already + // released above so a later user-initiated join may try a fresh heal. + if (setCurrent) { + QMessageBox::critical( + this, tr("Error"), + tr("The server thinks you are in the server room but your client is unable to display it. " + "Try restarting your client.")); + } + emit roomJoinFailed(roomId); + return; + } + // The server already had us registered in the room even though no tab was open, + // usually because two join attempts for the same room overlapped. Leaving and + // rejoining makes the server reply with a fresh RespOk so the tab is displayed + // without requiring a client restart. The guard above covers exactly the rejoin that + // leaveAndRejoinRoom triggers, so a server that keeps replying with RespContextError + // gets one heal attempt per join instead of an endless recursion. + healedRoomJoins.insert(roomId); + leaveAndRejoinRoom(roomId, setCurrent); return; case Response::RespUserLevelTooLow: - QMessageBox::critical(this, tr("Error"), - tr("You do not have the required permission to join this server room.")); + if (setCurrent) { + QMessageBox::critical(this, tr("Error"), + tr("You do not have the required permission to join this server room.")); + } emit roomJoinFailed(roomId); return; default: - QMessageBox::critical( - this, tr("Error"), - tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code())); + if (setCurrent) { + QMessageBox::critical( + this, tr("Error"), + tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code())); + } emit roomJoinFailed(roomId); return; } const Response_JoinRoom &resp = r.GetExtension(Response_JoinRoom::ext); - emit roomJoined(resp.room_info(), extraData.toBool()); + emit roomJoined(resp.room_info(), setCurrent); +} + +void TabServer::leaveAndRejoinRoom(int roomId, bool setCurrent) +{ + // Clear the stale room membership server-side. The leave is sent before the rejoin below, + // so the server no longer considers us a member by the time the join arrives. The leave + // response is intentionally not awaited: commands are processed in send order on the + // connection, and a failed leave (RespNotInRoom) only means the membership was already gone. + client->sendCommand(client->prepareRoomCommand(Command_LeaveRoom(), roomId)); + + joinRoom(roomId, setCurrent); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_server.h b/cockatrice/src/interface/widgets/tabs/tab_server.h index c10b7945b..121ff814d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_server.h +++ b/cockatrice/src/interface/widgets/tabs/tab_server.h @@ -10,6 +10,8 @@ #include "tab.h" #include +#include +#include #include #include @@ -58,10 +60,17 @@ private slots: int roomId); private: + void leaveAndRejoinRoom(int roomId, bool setCurrent); + AbstractClient *client; RoomSelector *roomSelector; QTextBrowser *serverInfoBox; bool shouldEmitUpdate = false; + /** Room ids with a join command in flight, mapped to whether the tab should be focused once it opens. */ + QHash pendingRoomJoins; + /** Room ids for which a stale-membership heal (leave + rejoin) is currently in flight. Released as soon as the + * rejoin has been answered, so a heal is attempted at most once per join. */ + QSet healedRoomJoins; public: TabServer(TabSupervisor *_tabSupervisor, AbstractClient *_client); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 462aa420b..c9bda7703 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -21,6 +21,7 @@ #include "tab_logs.h" #include "tab_message.h" #include "tab_moderation.h" +#include "tab_public_decks.h" #include "tab_replays.h" #include "tab_report.h" #include "tab_room.h" @@ -274,6 +275,10 @@ void TabSupervisor::retranslateUi() while (gameIterator.hasNext()) { tabs.append(gameIterator.next().value()); } + QMapIterator publicDecksIterator(publicDecksTabs); + while (publicDecksIterator.hasNext()) { + tabs.append(publicDecksIterator.next().value()); + } QListIterator replayIterator(replayTabs); while (replayIterator.hasNext()) { tabs.append(replayIterator.next()); @@ -626,6 +631,10 @@ void TabSupervisor::stop() tabsToDelete << i.value(); } + for (auto i = publicDecksTabs.cbegin(), end = publicDecksTabs.cend(); i != end; ++i) { + tabsToDelete << i.value(); + } + for (const auto tab : tabsToDelete) { tab->close(); } @@ -672,7 +681,7 @@ void TabSupervisor::actTabVisualDeckStorage(bool checked) void TabSupervisor::openTabVisualDeckStorage() { - tabVisualDeckStorage = new TabDeckStorageVisual(this); + tabVisualDeckStorage = new TabDeckStorageVisual(this, client); myAddTab(tabVisualDeckStorage, aTabVisualDeckStorage); connect(tabVisualDeckStorage, &QObject::destroyed, this, [this] { tabVisualDeckStorage = nullptr; @@ -1037,6 +1046,30 @@ void TabSupervisor::roomLeft(TabRoom *tab) removeTab(indexOf(tab)); } +void TabSupervisor::openTabPublicDecks(const QString &userName) +{ + if (auto *existing = publicDecksTabs.value(userName, nullptr)) { + setCurrentWidget(existing); + return; + } + + auto *tab = new TabPublicDecks(this, client, userName); + connect(tab, &TabPublicDecks::closing, this, &TabSupervisor::publicDecksClosed); + myAddTab(tab); + publicDecksTabs.insert(userName, tab); + setCurrentWidget(tab); +} + +void TabSupervisor::publicDecksClosed(TabPublicDecks *tab) +{ + if (tab == currentWidget()) { + emit setMenu(); + } + + publicDecksTabs.remove(tab->getUserName()); + removeTab(indexOf(tab)); +} + void TabSupervisor::switchToFirstAvailableNetworkTab() { if (!roomTabs.isEmpty()) { @@ -1451,6 +1484,11 @@ bool TabSupervisor::getAdminLocked() const return tabAdmin->getLocked(); } +bool TabSupervisor::canOverrideGameRestrictions() const +{ + return !getAdminLocked() || (userInfo->user_level() & ServerInfo_User::IsJudge); +} + void TabSupervisor::processNotifyUserEvent(const Event_NotifyUser &event) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index aec1d7418..066c84a77 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -47,6 +47,7 @@ class TabAccount; class TabDeckEditor; class TabDeveloper; class TabLog; +class TabPublicDecks; class RoomEvent; class GameEventContainer; class Event_GameJoined; @@ -114,6 +115,7 @@ private: QMap gameTabs; QList replayTabs; QMap messageTabs; + QMap publicDecksTabs; QList deckEditorTabs; bool isLocalGame; @@ -152,6 +154,10 @@ public: return userInfo; } [[nodiscard]] AbstractClient *getClient() const; + [[nodiscard]] AbstractClient *getServerClient() const + { + return client; + } [[nodiscard]] UserListManager *getUserListManager() const { return userListManager; @@ -171,6 +177,7 @@ public: [[nodiscard]] QList getGameInviteLinksForRoom(int roomId) const; void sendInviteToUser(const QString &userName, const QString &inviteText); [[nodiscard]] bool getAdminLocked() const; + [[nodiscard]] bool canOverrideGameRestrictions() const; void closeEvent(QCloseEvent *event) override; bool switchToGameTabIfAlreadyExists(const int gameId); static void actShowPopup(const QString &message); @@ -198,6 +205,7 @@ public slots: void actTabReplays(bool checked); void openTabServer(); void addRoomTab(const ServerInfo_Room &info, bool setCurrent); + void openTabPublicDecks(const QString &userName); private slots: void refreshShortcuts(); @@ -230,6 +238,7 @@ private slots: void localGameJoined(const Event_GameJoined &event); void gameLeft(TabGame *tab); void roomLeft(TabRoom *tab); + void publicDecksClosed(TabPublicDecks *tab); TabMessage *addMessageTab(const QString &userName, bool focus); void replayLeft(TabGame *tab); void processUserLeft(const QString &userName); 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..03df76b03 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,25 +1,78 @@ #include "tab_deck_storage_visual.h" +#include "../../../../client/settings/cache_settings.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)) +TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor, AbstractClient *_client) + : Tab(_tabSupervisor), visualDeckStorageWidget(new VisualDeckStorageWidget(this)), client(_client), + shareTimeoutTimer(new QTimer(this)) { connect(this, &TabDeckStorageVisual::openDeckEditor, tabSupervisor, &TabSupervisor::openDeckInNewTab); connect(visualDeckStorageWidget, &VisualDeckStorageWidget::deckLoadRequested, this, &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(); + } + }); 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); + + connect(client, &AbstractClient::statusChanged, this, &TabDeckStorageVisual::handleConnectionChanged); + shareDeckAvailable = (client->getStatus() == StatusLoggedIn); + visualDeckStorageWidget->setShareAvailable(shareDeckAvailable); + + shareTimeoutTimer->setSingleShot(true); + shareTimeoutTimer->setInterval( + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000)); + connect(shareTimeoutTimer, &QTimer::timeout, this, &TabDeckStorageVisual::onShareTimeout); + + retranslateUi(); +} + +void TabDeckStorageVisual::retranslateUi() +{ + visualDeckStorageWidget->retranslateUi(); + shareBar->retranslateUi(); + if (shareBar->isVisible()) { + updateShareHint(); + } } void TabDeckStorageVisual::actOpenLocalDeck(const QString &filePath) @@ -33,3 +86,144 @@ 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 + } + shareBar->setCreateEnabled(true); + visualDeckStorageWidget->setShareSelectable(true); + visualDeckStorageWidget->setShareSelectedFiles(preselectFiles); + shareBar->setName(tr("Shared decks")); + shareBar->setVisible(true); + updateShareHint(); + shareBar->focusName(); +} + +void TabDeckStorageVisual::exitShareMode() +{ + // Abandon any in-flight request: otherwise the timer keeps running and a late + // response reports the share as created after the user already backed out. + shareTimeoutTimer->stop(); + shareInFlightSeq = 0; + visualDeckStorageWidget->setShareSelectable(false); + visualDeckStorageWidget->clearShareSelection(); + shareBar->setVisible(false); +} + +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}); +} + +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 if (count == 1) { + shareBar->setHintText(tr("One deck selected. Create the link to share it with other players."), true); + } else { + shareBar->setHintText(tr("%n decks selected. Create the link to share them with other players.", "", count), + 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, CardDatabaseManager::query()).toStdString()); + } + + shareBar->setCreateEnabled(false); + const int seq = ++shareRequestSeq; + shareInFlightSeq = seq; + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, seq](const Response &response, const CommandContainer &commandContainer) { + if (shareInFlightSeq != seq) { + return; // the user cancelled or a newer request superseded this one + } + shareInFlightSeq = 0; + shareFinished(response, commandContainer); + }); + client->sendCommand(pend); + shareTimeoutTimer->start(); +} + +void TabDeckStorageVisual::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + shareTimeoutTimer->stop(); + 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()))), + true); + return; + } + + const DeckShareUtils::ShareResponse share = DeckShareUtils::handleShareResponse(client, response); + + showShareNotice( + tr("Share link copied to the clipboard.\nExpires on %1.").arg(DeckShareUtils::formatShareExpiry(share.expiry))); + exitShareMode(); +} + +void TabDeckStorageVisual::handleConnectionChanged(ClientStatus status) +{ + shareDeckAvailable = (status == StatusLoggedIn); + visualDeckStorageWidget->setShareAvailable(shareDeckAvailable); + if (!shareDeckAvailable && shareBar->isVisible()) { + exitShareMode(); + } +} + +void TabDeckStorageVisual::showShareNotice(const QString &message, bool warning) +{ + QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message, + QMessageBox::Ok, this); + box.exec(); +} + +void TabDeckStorageVisual::onShareTimeout() +{ + if (shareInFlightSeq == 0) { + return; // share mode was left while the request was still outstanding + } + shareInFlightSeq = 0; + shareBar->setCreateEnabled(true); + showShareNotice(tr("The server did not respond in time. Try again."), true); +} 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..7cd4e7d17 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,14 +7,19 @@ #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; class DeckPreviewWidget; class QFileSystemModel; class QGroupBox; +class QTimer; class QToolBar; class QTreeView; class QTreeWidget; @@ -26,23 +31,55 @@ class TabDeckStorageVisual final : public Tab { Q_OBJECT public: - explicit TabDeckStorageVisual(TabSupervisor *_tabSupervisor); - void retranslateUi() override - { - } + explicit TabDeckStorageVisual(TabSupervisor *_tabSupervisor, AbstractClient *_client); + 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 onShareTimeout(); + void onShareSelectionChanged(); + void handleConnectionChanged(ClientStatus status); + private: + void showShareNotice(const QString &message, bool warning = false); + void updateShareHint(); + VisualDeckStorageWidget *visualDeckStorageWidget; + + ShareBarWidget *shareBar = nullptr; + AbstractClient *client; + QTimer *shareTimeoutTimer; + int shareRequestSeq = 0; + int shareInFlightSeq = 0; + bool shareDeckAvailable = false; }; #endif 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/deck_preview/deck_preview_tag_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp index db466b77a..a7fef5031 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp @@ -48,6 +48,7 @@ QSize DeckPreviewTagDisplayWidget::sizeHint() const void DeckPreviewTagDisplayWidget::mousePressEvent(QMouseEvent *event) { + const TagState previousState = state; switch (event->button()) { case Qt::LeftButton: setState(state != TagState::Selected ? TagState::Selected : TagState::NotSelected); @@ -62,7 +63,12 @@ void DeckPreviewTagDisplayWidget::mousePressEvent(QMouseEvent *event) break; } - emit tagClicked(); + // Only announce a change when the state was actually toggled, so a click that falls + // through the switch (e.g. a button the widget does not react to) does not drive a + // full tag-filter update and layout pass for nothing. + if (state != previousState) { + emit tagClicked(); + } QWidget::mousePressEvent(event); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h index 980741c5d..df2a6b404 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h @@ -48,7 +48,10 @@ public: signals: /** - * @brief Emitted when the tag is clicked. + * @brief Emitted when a click toggles the chip's selection/exclusion state. + * + * Not emitted for clicks that leave the state unchanged. Connected handlers use + * this as the trigger to update filters built from selectedTags()/excludedTags(). */ void tagClicked(); 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..53fe32314 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 @@ -11,6 +11,7 @@ #include "deck_preview_deck_tags_display_widget.h" #include +#include #include #include #include @@ -27,7 +28,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 +37,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 +102,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 +134,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) @@ -337,10 +406,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 +444,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/deck_preview/public_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp new file mode 100644 index 000000000..b49d6f9f4 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp @@ -0,0 +1,167 @@ +#include "public_deck_preview_widget.h" + +#include "../../../../client/settings/cache_settings.h" +#include "../../cards/additional_info/color_identity_widget.h" +#include "../../cards/deck_preview_card_picture_widget.h" +#include "../../general/layout_containers/flow_widget.h" +#include "deck_preview_tag_display_widget.h" + +#include +#include +#include +#include +#include +#include +#include + +PublicDeckPreviewWidget::PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry) + : QWidget(parent) +{ + bannerCardDisplayWidget = new DeckPreviewCardPictureWidget(this); + bannerCardDisplayWidget->setFontSize(24); + + // The whole tile is a single focusable, keyboard-operable control: Tab lands + // on it and Space/Enter opens the deck, mirroring the shared-deck preview tile. + setFocusPolicy(Qt::StrongFocus); + + uploadTimeLabel = new QLabel(this); + uploadTimeLabel->setAlignment(Qt::AlignHCenter); + + colorIdentityWidget = new ColorIdentityWidget(this); + + tagsFlowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + tagsFlowWidget->setSpacing(3, 3); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(bannerCardDisplayWidget); + layout->addWidget(uploadTimeLabel); + layout->addWidget(colorIdentityWidget); + layout->addWidget(tagsFlowWidget); + setLayout(layout); + + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowColorIdentityChanged, this, + &PublicDeckPreviewWidget::updateColorIdentityVisibility); + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowTagsOnDeckPreviewsChanged, this, + &PublicDeckPreviewWidget::updateTagsVisibility); + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowUploadTimeChanged, this, + &PublicDeckPreviewWidget::updateUploadTimeVisibility); + + setEntry(entry); + + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this, + &PublicDeckPreviewWidget::imageClickedEvent); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, + &PublicDeckPreviewWidget::imageDoubleClickedEvent); + + // resizeEvent clamps every child to the banner picture's width, so collect them + // once here to keep the resize handler from searching the widget tree on every pass. + fixedWidthChildren = {bannerCardDisplayWidget, uploadTimeLabel, colorIdentityWidget, tagsFlowWidget}; +} + +void PublicDeckPreviewWidget::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + if (bannerCardDisplayWidget == nullptr) { + return; + } + + const int width = bannerCardDisplayWidget->width(); + if (width == lastKnownBannerWidth) { + return; + } + lastKnownBannerWidth = width; + + for (QWidget *widget : fixedWidthChildren) { + widget->setMaximumWidth(width); + } +} + +void PublicDeckPreviewWidget::setEntry(const RemotePublicDecksModel::DeckEntry &entry) +{ + deckId = entry.id; + + hasColorIdentity = !entry.colorIdentity.isEmpty(); + colorIdentityWidget->setColorIdentity(entry.colorIdentity); + updateColorIdentityVisibility(); + + const ExactCard bannerCard = + entry.bannerCardName.isEmpty() + ? ExactCard() + : CardDatabaseManager::query()->getCard(CardRef{entry.bannerCardName, entry.bannerCardProvider}); + bannerCardDisplayWidget->setCard(bannerCard); + + // The deck name is the overlay text on the banner, like the local preview. + bannerCardDisplayWidget->setOverlayText(entry.name); + // The deck name comes from another user's record, and Qt tooltips are + // rendered as AutoText, so escape and bound it to keep it readable text + // (the overlay painted onto the banner is already a plain painter draw). + setToolTip(entry.name.left(200).toHtmlEscaped()); + setBaseAccessibleName(entry.name); + + tagsFlowWidget->clearLayout(); + for (const QString &tag : entry.tags) { + auto *chip = new DeckPreviewTagDisplayWidget(tagsFlowWidget, tag); + chip->setAttribute(Qt::WA_TransparentForMouseEvents); + tagsFlowWidget->addWidget(chip); + } + hasTags = !entry.tags.isEmpty(); + updateTagsVisibility(); + + uploadTimeLabel->setText(tr("Uploaded %1").arg(entry.uploadTime.toString(Qt::TextDate))); + hasUploadTime = !entry.uploadTime.isNull(); + updateUploadTimeVisibility(); +} + +void PublicDeckPreviewWidget::updateColorIdentityVisibility() +{ + colorIdentityWidget->setVisible( + hasColorIdentity && SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity()); +} + +void PublicDeckPreviewWidget::updateTagsVisibility() +{ + tagsFlowWidget->setVisible( + hasTags && SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews()); +} + +void PublicDeckPreviewWidget::updateUploadTimeVisibility() +{ + uploadTimeLabel->setVisible(hasUploadTime && + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowUploadTime()); +} + +void PublicDeckPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Enter || event->key() == Qt::Key_Return) { + event->accept(); + emit openDeckRequested(deckId); + return; + } + QWidget::keyPressEvent(event); +} + +void PublicDeckPreviewWidget::setBaseAccessibleName(const QString &name) +{ + baseAccessibleName = name; + setAccessibleName(name); +} + +void PublicDeckPreviewWidget::setScaleFactor(int scale) +{ + bannerCardDisplayWidget->setScaleFactor(scale); +} + +void PublicDeckPreviewWidget::imageClickedEvent(QMouseEvent * /*event*/, DeckPreviewCardPictureWidget * /*instance*/) +{ + // Reserved: clicking could show a card popup for the banner card. +} + +void PublicDeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent * /*event*/, + DeckPreviewCardPictureWidget * /*instance*/) +{ + emit openDeckRequested(deckId); +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.h new file mode 100644 index 000000000..a0e5dd43a --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.h @@ -0,0 +1,75 @@ +/** + * @file public_deck_preview_widget.h + * @ingroup VisualDeckPreviewWidgets + */ + +#ifndef PUBLIC_DECK_PREVIEW_WIDGET_H +#define PUBLIC_DECK_PREVIEW_WIDGET_H + +#include "../remote_public_decks_model.h" + +#include +#include +#include + +class ColorIdentityWidget; +class DeckPreviewCardPictureWidget; +class FlowWidget; +class QKeyEvent; +class QLabel; +class QMouseEvent; +class QResizeEvent; + +/** + * @brief A preview tile for a public deck published by another user. + * + * Renders the banner card picture (looked up by name/provider in the card + * database) with the deck name overlaid, the color identity, the deck's tags + * (read-only) and its upload time, all from the metadata the server stores for + * the deck, so no deck list is downloaded until the user actually opens the + * deck. Double-clicking the banner requests opening it. + */ +class PublicDeckPreviewWidget final : public QWidget +{ + Q_OBJECT + +public: + explicit PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry); + + void setEntry(const RemotePublicDecksModel::DeckEntry &entry); + + /** @brief Sets the accessible name announced to assistive technologies. */ + void setBaseAccessibleName(const QString &name); + + /** @brief Scales the banner card picture, mirroring the Visual Deck Storage. */ + void setScaleFactor(int scale); + +signals: + void openDeckRequested(int deckId); + +protected: + void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + +private slots: + void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void updateColorIdentityVisibility(); + void updateTagsVisibility(); + void updateUploadTimeVisibility(); + +private: + int deckId = 0; + QString baseAccessibleName; + bool hasColorIdentity = false; + bool hasTags = false; + bool hasUploadTime = false; + int lastKnownBannerWidth = 0; + QList fixedWidthChildren; + DeckPreviewCardPictureWidget *bannerCardDisplayWidget; + ColorIdentityWidget *colorIdentityWidget; + FlowWidget *tagsFlowWidget; + QLabel *uploadTimeLabel; +}; + +#endif // PUBLIC_DECK_PREVIEW_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp new file mode 100644 index 000000000..a1cabd729 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp @@ -0,0 +1,220 @@ +#include "remote_public_decks_model.h" + +#include "../../../client/settings/cache_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +RemotePublicDecksModel::RemotePublicDecksModel(AbstractClient *_client, QObject *parent) + : QAbstractListModel(parent), client(_client) +{ + // The ping sweep can drop a pending command without ever emitting finished, + // so loading must not be a latch: time it out and clear it when the client + // goes away, or the tab is stuck on the loading state for the session. + loadingTimeoutTimer = new QTimer(this); + loadingTimeoutTimer->setSingleShot(true); + loadingTimeoutTimer->setInterval( + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000)); + connect(loadingTimeoutTimer, &QTimer::timeout, this, &RemotePublicDecksModel::onLoadingTimeout); + connect(client, &AbstractClient::statusChanged, this, [this](ClientStatus status) { + if (status == StatusDisconnected) { + loadingTimeoutTimer->stop(); + setLoading(false); + } + }); +} + +int RemotePublicDecksModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : visibleIndices.size(); +} + +QVariant RemotePublicDecksModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= visibleIndices.size()) { + return QVariant(); + } + if (role == Qt::DisplayRole || role == Qt::ToolTipRole) { + return decks.at(visibleIndices.at(index.row())).name; + } + return QVariant(); +} + +RemotePublicDecksModel::DeckEntry RemotePublicDecksModel::entryAt(int row) const +{ + if (row < 0 || row >= visibleIndices.size()) { + return DeckEntry{}; + } + return decks.at(visibleIndices.at(row)); +} + +void RemotePublicDecksModel::setSearchText(const QString &text) +{ + searchText = text.trimmed(); + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::setColorFilter(VisualDeckStorageSortFilterProxyModel::FilterMode mode, + const QSet &colors) +{ + colorFilterMode = mode; + activeColors = colors; + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::setTagFilter(const QSet &selected, const QSet &excluded) +{ + includedTags = selected; + excludedTags = excluded; + rebuildVisibleIndices(); +} + +QSet RemotePublicDecksModel::allTags() const +{ + QSet all; + for (const DeckEntry &entry : decks) { + all.unite(QSet(entry.tags.cbegin(), entry.tags.cend())); + } + return all; +} + +void RemotePublicDecksModel::rebuildVisibleIndices() +{ + QList newIndices; + newIndices.reserve(decks.size()); + for (int row = 0; row < decks.size(); ++row) { + const DeckEntry &entry = decks.at(row); + + if (!searchText.isEmpty() && !entry.name.contains(searchText, Qt::CaseInsensitive)) { + continue; + } + + if (!activeColors.isEmpty()) { + const QString &identity = entry.colorIdentity; + if (!colorIdentityMatches(colorFilterMode, activeColors, identity)) { + continue; + } + } + + if (!includedTags.isEmpty()) { + const QSet entryTags(entry.tags.cbegin(), entry.tags.cend()); + bool hasAll = std::all_of(includedTags.begin(), includedTags.end(), + [&entryTags](const QString &tag) { return entryTags.contains(tag); }); + if (!hasAll) { + continue; + } + } + + if (!excludedTags.isEmpty() && std::any_of(excludedTags.begin(), excludedTags.end(), + [&entry](const QString &tag) { return entry.tags.contains(tag); })) { + continue; + } + + newIndices.append(row); + } + + beginResetModel(); + visibleIndices = newIndices; + endResetModel(); +} + +void RemotePublicDecksModel::refresh(const QString &userName) +{ + if (loading) { + return; + } + // Every refresh captures its own request id so a reply that lands after its + // loading timeout (the reverse of the ping sweep dropping the command) is + // recognised as stale: it must not stop the newer request's timer or paint + // the grid with out-of-date data. + const int seq = ++requestSequence; + setLoading(true); + loadingTimeoutTimer->start(); + Command_DeckListOtherUser cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, seq](const Response &response, const CommandContainer &commandContainer) { + if (seq != requestSequence) { + return; // a newer refresh superseded this one + } + decksReceived(response, commandContainer); + }); + client->sendCommand(pend); +} + +void RemotePublicDecksModel::onLoadingTimeout() +{ + setLoading(false); + emit loadFailed(tr("The server did not respond in time. Try again.")); +} + +void RemotePublicDecksModel::clear() +{ + decks.clear(); + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::setLoading(bool value) +{ + if (loading == value) { + return; + } + loading = value; + emit loadingChanged(loading); +} + +void RemotePublicDecksModel::decksReceived(const Response &response, const CommandContainer & /*commandContainer*/) +{ + setLoading(false); + loadingTimeoutTimer->stop(); + if (response.response_code() != Response::RespOk) { + emit loadFailed(tr("Failed to load the user's public decks (server response code %1).") + .arg(QString::number(static_cast(response.response_code())))); + return; + } + + const Response_DeckList &resp = response.GetExtension(Response_DeckList::ext); + decks.clear(); + addFolder(resp.root()); + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::addFolder(const ServerInfo_DeckStorage_Folder &folder) +{ + const int itemCount = folder.items_size(); + for (int i = 0; i < itemCount; ++i) { + addTreeItem(folder.items(i)); + } +} + +void RemotePublicDecksModel::addTreeItem(const ServerInfo_DeckStorage_TreeItem &item) +{ + if (item.has_folder()) { + addFolder(item.folder()); + return; + } + + const ServerInfo_DeckStorage_File &file = item.file(); + DeckEntry entry; + entry.id = item.id(); + entry.name = QString::fromStdString(item.name()); + entry.uploadTime = QDateTime::fromSecsSinceEpoch(file.creation_time()); + entry.bannerCardName = QString::fromStdString(file.banner_card_name()); + entry.bannerCardProvider = QString::fromStdString(file.banner_card_provider()); + entry.colorIdentity = QString::fromStdString(file.color_identity()); + QStringList tags; + for (const auto &tag : file.tags()) { + tags.append(QString::fromStdString(tag)); + } + entry.tags = tags; + decks.append(entry); +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.h new file mode 100644 index 000000000..eeb606442 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.h @@ -0,0 +1,129 @@ +/** + * @file remote_public_decks_model.h + * @ingroup DeckStorageWidgets + */ + +#ifndef REMOTE_PUBLIC_DECKS_MODEL_H +#define REMOTE_PUBLIC_DECKS_MODEL_H + +#include "visual_deck_storage_sort_filter_proxy_model.h" + +#include +#include +#include +#include +#include + +class AbstractClient; +class CommandContainer; +class QTimer; +class Response; +class ServerInfo_DeckStorage_Folder; +class ServerInfo_DeckStorage_TreeItem; + +/** + * @brief Flat, read-only list of the public decks published by another user. + * + * Fetches the target user's public decks via Command_DeckListOtherUser and + * flattens the response tree into entries carrying the preview metadata stored + * on the server (banner card name/provider and color identity). No deck list is + * downloaded until the user actually opens a deck. + * + * Name and color-identity filtering is applied against this metadata, mirroring + * the Visual Deck Storage's filter semantics, so the grid can be narrowed like + * the local deck storage. + */ +class RemotePublicDecksModel : public QAbstractListModel +{ + Q_OBJECT + +public: + struct DeckEntry + { + int id = 0; + QString name; + QDateTime uploadTime; + QString bannerCardName; + QString bannerCardProvider; + QString colorIdentity; + QStringList tags; + }; + + /** + * @brief The color identity filter mode, shared with the Visual Deck Storage. + */ + using FilterMode = VisualDeckStorageSortFilterProxyModel::FilterMode; + + explicit RemotePublicDecksModel(AbstractClient *client, QObject *parent = nullptr); + + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** @brief Fetches the public decks of another user, replacing the current contents. */ + void refresh(const QString &userName); + void clear(); + + /** @brief Sets a case-insensitive substring filter on the deck name. */ + void setSearchText(const QString &text); + + /** @brief Sets the active color identity filter and mode. */ + void setColorFilter(FilterMode mode, const QSet &colors); + + /** @brief Filters decks by required (`selected`) and forbidden (`excluded`) tags. */ + void setTagFilter(const QSet &selected, const QSet &excluded); + + /** @brief All tags present across all loaded decks, for building filter chips. */ + [[nodiscard]] QSet allTags() const; + + /** @brief The number of decks after filtering. */ + [[nodiscard]] int filteredCount() const + { + return visibleIndices.size(); + } + + /** @brief The number of decks before filtering. */ + [[nodiscard]] int totalCount() const + { + return decks.size(); + } + + /** @brief True while a refresh request is in flight and the grid has no data yet. */ + [[nodiscard]] bool isLoading() const + { + return loading; + } + + [[nodiscard]] DeckEntry entryAt(int row) const; + +signals: + /** @brief Emitted when a refresh starts, completes, or fails (see loading()). */ + void loadingChanged(bool loading); + + /** @brief Emitted when the last refresh failed; contains a user-facing message. */ + void loadFailed(const QString &message); + +private slots: + void decksReceived(const Response &response, const CommandContainer &commandContainer); + void onLoadingTimeout(); + +private: + void addFolder(const ServerInfo_DeckStorage_Folder &folder); + void addTreeItem(const ServerInfo_DeckStorage_TreeItem &item); + void rebuildVisibleIndices(); + void setLoading(bool value); + + AbstractClient *client; + QTimer *loadingTimeoutTimer; + QList decks; + QList visibleIndices; ///< Row indices into `decks` that pass the current filters. + bool loading = false; + int requestSequence = 0; ///< Monotonically increases per refresh; only the newest request may update the grid. + + QString searchText; + VisualDeckStorageSortFilterProxyModel::FilterMode colorFilterMode = VisualDeckStorageSortFilterProxyModel::Includes; + QSet activeColors; + QSet includedTags; + QSet excludedTags; +}; + +#endif // REMOTE_PUBLIC_DECKS_MODEL_H 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..911f3dee9 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 @@ -126,6 +126,11 @@ void VisualDeckStorageFolderDisplayWidget::continueDeckPass() const bool matches = index.data(VisualDeckStorageRoles::FilterMatchRole).toBool(); deckPreviewWidget->setVisible(matches); + if (!matches) { + // A deck that no longer matches the filters is dropped from the selection so its + // highlight cannot linger on an invisible preview or be counted in the share. + deckPreviewWidget->setShareSelected(false); + } if (matches) { ++visibleDeckCount; } @@ -209,6 +214,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 +226,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_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp index 5d0006539..daca55fc8 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp @@ -1,6 +1,7 @@ #include "visual_deck_storage_model.h" #include "../../deck_loader/deck_loader.h" +#include "../cards/additional_info/deck_color_identity.h" #include #include @@ -119,8 +120,6 @@ DeckScanResult scanDeckDirectory(const QString &deckPath) } } // namespace -static QString computeColorIdentity(const LoadedDeck &deck); - VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent) { } @@ -306,7 +305,7 @@ void VisualDeckStorageModel::beginLoad(int row) } // Color identity walks every card through the database, so compute it here to // keep the completion handler on the UI thread cheap. - const QString colorIdentity = computeColorIdentity(*deck); + const QString colorIdentity = getDeckColorIdentity(deck->deckList, CardDatabaseManager::query()); return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity}; })); } @@ -367,40 +366,6 @@ void VisualDeckStorageModel::drainPendingLoads() } } -/** - * @brief Computes the color identity of a deck in WUBRG order. - */ -static QString computeColorIdentity(const LoadedDeck &deck) -{ - QStringList cardList = deck.deckList.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(); // 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; -} - /** * @brief Recomputes all derived metadata of a row from its loaded deck. */ @@ -414,7 +379,7 @@ void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data, bool r data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp()); data.bannerCard = deckList.getBannerCard(); if (recomputeColorIdentity) { - data.colorIdentity = computeColorIdentity(data.deck); + data.colorIdentity = getDeckColorIdentity(data.deck.deckList, CardDatabaseManager::query()); } } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp index 478431703..c19ac0fec 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp @@ -50,6 +50,15 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg &SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews); + // show upload time on DeckPreviewWidget checkbox + showUploadTimeCheckBox = new QCheckBox(this); + showUploadTimeCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowUploadTime()); + connect(showUploadTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &VisualDeckStorageQuickSettingsWidget::showUploadTimeChanged); + connect(showUploadTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowUploadTime); + // show banner card selector checkbox showBannerCardComboBoxCheckBox = new QCheckBox(this); showBannerCardComboBoxCheckBox->setChecked( @@ -94,7 +103,7 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg unusedColorIdentityOpacityLayout->addWidget(unusedColorIdentitiesOpacitySpinBox); // tooltip selector - auto deckPreviewTooltipWidget = new QWidget(this); + deckPreviewTooltipWidget = new QWidget(this); deckPreviewTooltipLabel = new QLabel(deckPreviewTooltipWidget); deckPreviewTooltipComboBox = new QComboBox(deckPreviewTooltipWidget); @@ -128,6 +137,7 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg this->addSettingsWidget(showTagFilterCheckBox); this->addSettingsWidget(showColorIdentityCheckBox); this->addSettingsWidget(showTagsOnDeckPreviewsCheckBox); + this->addSettingsWidget(showUploadTimeCheckBox); this->addSettingsWidget(showBannerCardComboBoxCheckBox); this->addSettingsWidget(drawUnusedColorIdentitiesCheckBox); this->addSettingsWidget(unusedColorIdentityOpacityWidget); @@ -145,6 +155,7 @@ void VisualDeckStorageQuickSettingsWidget::retranslateUi() showTagFilterCheckBox->setText(tr("Show Tag Filter")); showColorIdentityCheckBox->setText(tr("Show Color Identity")); showTagsOnDeckPreviewsCheckBox->setText(tr("Show Tags On Deck Previews")); + showUploadTimeCheckBox->setText(tr("Show Upload Time")); showBannerCardComboBoxCheckBox->setText(tr("Show Banner Card Selection Option")); drawUnusedColorIdentitiesCheckBox->setText(tr("Draw unused Color Identities")); unusedColorIdentitiesOpacityLabel->setText(tr("Unused Color Identities Opacity")); @@ -155,6 +166,14 @@ void VisualDeckStorageQuickSettingsWidget::retranslateUi() deckPreviewTooltipComboBox->setItemText(1, tr("Filepath")); } +void VisualDeckStorageQuickSettingsWidget::setPublicDecksMode(bool enabled) +{ + const bool hidden = enabled; + showFoldersCheckBox->setVisible(!hidden); + showBannerCardComboBoxCheckBox->setVisible(!hidden); + deckPreviewTooltipWidget->setVisible(!hidden); +} + bool VisualDeckStorageQuickSettingsWidget::getShowFolders() const { return showFoldersCheckBox->isChecked(); @@ -185,6 +204,11 @@ bool VisualDeckStorageQuickSettingsWidget::getShowTagsOnDeckPreviews() const return showTagsOnDeckPreviewsCheckBox->isChecked(); } +bool VisualDeckStorageQuickSettingsWidget::getShowUploadTime() const +{ + return showUploadTimeCheckBox->isChecked(); +} + int VisualDeckStorageQuickSettingsWidget::getUnusedColorIdentitiesOpacity() const { return unusedColorIdentitiesOpacitySpinBox->value(); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.h index ea4330a15..fc250cb20 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.h @@ -27,10 +27,12 @@ class VisualDeckStorageQuickSettingsWidget : public SettingsButtonWidget QCheckBox *showBannerCardComboBoxCheckBox; QCheckBox *showTagFilterCheckBox; QCheckBox *showTagsOnDeckPreviewsCheckBox; + QCheckBox *showUploadTimeCheckBox; QLabel *unusedColorIdentitiesOpacityLabel; QSpinBox *unusedColorIdentitiesOpacitySpinBox; QLabel *deckPreviewTooltipLabel; QComboBox *deckPreviewTooltipComboBox; + QWidget *deckPreviewTooltipWidget; CardSizeWidget *cardSizeWidget; public: @@ -46,6 +48,15 @@ public: explicit VisualDeckStorageQuickSettingsWidget(QWidget *parent = nullptr); + /** + * @brief Hides the controls that do not apply to the public decks tab. + * + * The public decks tab reuses this widget for its quick settings menu but + * has no folders, banner selection or per-deck tooltip, so those controls + * are hidden while every shared key keeps syncing with SettingsCache. + */ + void setPublicDecksMode(bool enabled); + void retranslateUi(); [[nodiscard]] bool getShowFolders() const; @@ -54,6 +65,7 @@ public: [[nodiscard]] bool getShowBannerCardComboBox() const; [[nodiscard]] bool getShowTagFilter() const; [[nodiscard]] bool getShowTagsOnDeckPreviews() const; + [[nodiscard]] bool getShowUploadTime() const; [[nodiscard]] int getUnusedColorIdentitiesOpacity() const; [[nodiscard]] TooltipType getDeckPreviewTooltip() const; [[nodiscard]] int getCardSize() const; @@ -65,6 +77,7 @@ signals: void showBannerCardComboBoxChanged(bool enabled); void showTagFilterChanged(bool enabled); void showTagsOnDeckPreviewsChanged(bool enabled); + void showUploadTimeChanged(bool enabled); void unusedColorIdentitiesOpacityChanged(int opacity); void deckPreviewTooltipChanged(TooltipType tooltip); void cardSizeChanged(int scale); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp index c05da1cb3..4b3a1ac29 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp @@ -11,6 +11,34 @@ VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QOb setDynamicSortFilter(false); } +bool colorIdentityMatches(VisualDeckStorageSortFilterProxyModel::FilterMode mode, + const QSet &colors, + const QString &identity) +{ + switch (mode) { + case VisualDeckStorageSortFilterProxyModel::ExactMatch: { + QSet activeColorSet; + for (const QChar &color : colors) { + activeColorSet.insert(color.toUpper()); + } + + QSet colorIdentitySet; + for (const QChar &color : identity) { + colorIdentitySet.insert(color.toUpper()); + } + + return activeColorSet == colorIdentitySet; + } + case VisualDeckStorageSortFilterProxyModel::Includes: + return std::all_of(colors.begin(), colors.end(), + [&identity](const QChar &color) { return identity.contains(color); }); + case VisualDeckStorageSortFilterProxyModel::Excludes: + return std::none_of(colors.begin(), colors.end(), + [&identity](const QChar &color) { return identity.contains(color); }); + } + return false; +} + void VisualDeckStorageSortFilterProxyModel::setSourceModel(QAbstractItemModel *model) { if (QAbstractItemModel *oldModel = sourceModel()) { @@ -255,34 +283,7 @@ void VisualDeckStorageSortFilterProxyModel::updateColorMatches() for (int row = 0; row < count; ++row) { const QString colorIdentity = source->dataForRow(row).colorIdentity; - - bool matches = true; - switch (colorFilterMode) { - case ExactMatch: { - QSet activeColorSet; - for (const QChar &color : activeColors) { - activeColorSet.insert(color.toUpper()); - } - - QSet colorIdentitySet; - for (const QChar &color : colorIdentity) { - colorIdentitySet.insert(color.toUpper()); - } - - matches = activeColorSet == colorIdentitySet; - break; - } - case Includes: - matches = std::all_of(activeColors.begin(), activeColors.end(), - [&colorIdentity](const QChar &color) { return colorIdentity.contains(color); }); - break; - case Excludes: - matches = std::none_of(activeColors.begin(), activeColors.end(), - [&colorIdentity](const QChar &color) { return colorIdentity.contains(color); }); - break; - } - - colorMatches[row] = matches; + colorMatches[row] = colorIdentityMatches(colorFilterMode, activeColors, colorIdentity); } } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h index 7e771f6a9..a6c40a2d7 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h @@ -102,4 +102,14 @@ private: QList colorMatches; ///< Per-row color identity match. }; +/** + * @brief Whether an identity string matches the active color-identity filter. + * + * The single source of truth for the color identity matching rule, shared by + * the Visual Deck Storage proxy and the remote public decks model. + */ +[[nodiscard]] bool colorIdentityMatches(VisualDeckStorageSortFilterProxyModel::FilterMode mode, + const QSet &colors, + const QString &identity); + #endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H 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..6f954b01b 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,99 +21,72 @@ 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 reparented to nullptr and scheduled + // for a deferred delete (QWidget::setParent(nullptr) also hides them), 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->setParent(nullptr); tagWidget->deleteLater(); } } -} -void VisualDeckStorageTagFilterWidget::addTagsIfNotPresent(const QSet &tags) -{ - for (const QString &tag : tags) { - addTagIfNotPresent(tag); + // Add chips for tags that are not shown yet. + QSet existingTags; + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { + existingTags.insert(tagWidget->getTagName()); } -} - -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; + for (const QString &tag : allTags) { + if (!existingTags.contains(tag)) { + 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) { - return a->getTagName().toLower() < b->getTagName().toLower(); + // Sort, but skip the full remove/re-add when the order already matches the layout. + // FlowWidget inherits QLayout::removeWidget's linear scan, so rebuilding an unchanged + // order would be quadratic plus a full relayout on every chip click and load batch. + std::sort(chips.begin(), chips.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) { + const QString aName = a->getTagName(); + const QString bName = b->getTagName(); + const int compared = aName.compare(bName, Qt::CaseInsensitive); + return compared != 0 ? compared < 0 : aName < bName; }); - - // Clear and re-add widgets in sorted order - for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) { + if (chips == currentChipOrder) { + return; + } + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { flowWidget->removeWidget(tagWidget); } - for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) { + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { flowWidget->addWidget(tagWidget); } + currentChipOrder = chips; } QStringList VisualDeckStorageTagFilterWidget::selectedTags() const 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..5e3cb398c 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,28 @@ #include #include #include +#include +class DeckPreviewTagDisplayWidget; 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; + QList currentChipOrder; 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 +41,18 @@ public: */ [[nodiscard]] QStringList excludedTags() const; +signals: + /** + * Emitted when a chip's selection or exclusion state changes. + * + * The chip only emits when its state actually changed, so this fires once per + * effective toggle rather than on every click. + */ + 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 da7ddc368..bd167ec5b 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 @@ -48,6 +48,12 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare refreshButton->setFixedSize(32, 32); connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible); + shareButton = new QToolButton(this); + shareButton->setIcon(themePixmap(QStringLiteral("icons/share"))); + shareButton->setFixedSize(32, 32); + shareButton->setVisible(false); + connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested); + quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this); connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::showFoldersChanged, this, &VisualDeckStorageWidget::updateShowFolders); @@ -58,10 +64,14 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare searchAndSortLayout->addWidget(sortWidget); searchAndSortLayout->addWidget(searchWidget); searchAndSortLayout->addWidget(refreshButton); + searchAndSortLayout->addWidget(shareButton); searchAndSortLayout->addWidget(quickSettingsWidget); // 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 = @@ -155,11 +165,71 @@ void VisualDeckStorageWidget::retranslateUi() databaseLoadIndicator->setText(tr("Loading database ...")); refreshButton->setToolTip(tr("Refresh loaded files")); + shareButton->setToolTip(tr("Select decks to share")); quickSettingsWidget->setToolTip(tr("Visual Deck Storage Settings")); 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) { + // Filtered-out previews stay alive hidden in their sorted place, so only decks the + // user can actually see are part of the share. + if (preview->isVisible() && 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. */ @@ -217,6 +287,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..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; @@ -70,6 +79,7 @@ protected: private: void reapplySortAndFilters(); + [[nodiscard]] QSet gatherVisibleTags() const; private: QVBoxLayout *layout; @@ -80,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 diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 722df90ef..595d38d5b 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -512,6 +512,7 @@ MainWindow::MainWindow(QWidget *parent) connectionController = new ConnectionController(this, this); urlParser = new IntentUrlParser(this, this); + connect(urlParser, &IntentUrlParser::urlChainFinished, this, &MainWindow::onUrlChainFinished); createActions(); createMenus(); @@ -707,6 +708,12 @@ void MainWindow::applyStartupDestination() return; } + // A cockatrice:// link owns the startup connection while its chain runs; + // connecting here would race (and tear down) the link's own connection. + if (skipStartupAutoConnect) { + return; + } + const int destination = SettingsCache::instance().tabs().getStartupTabIndex(); if (destination != StartupTab::StartupTabServer && destination != StartupTab::StartupTabServerRoom) { return; @@ -728,6 +735,7 @@ void MainWindow::applyStartupDestination() connect(credentials, &Intent::finished, connector, &Intent::execute); connect(credentials, &Intent::failed, this, &MainWindow::startupDestinationFailed); + connect(credentials, &Intent::cancelled, this, [this]() { startupDestinationFailed(tr("Sign-in cancelled")); }); connect(connector, &Intent::finished, this, [this, destination, serverContext]() { onStartupDestinationConnected(destination, *serverContext); }); connect(connector, &Intent::failed, this, &MainWindow::startupDestinationFailed); @@ -879,18 +887,7 @@ void MainWindow::changeEvent(QEvent *event) } else if (event->type() == QEvent::ActivationChange) { if (isActiveWindow() && !bHasActivated) { bHasActivated = true; - if (!connectTo.isEmpty()) { - qCInfo(WindowMainStartupAutoconnectLog) << "Command line connect to " << connectTo; - connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(), - connectTo.password()); - } else if (SettingsCache::instance().servers().getAutoConnect() && - !SettingsCache::instance().debug().getLocalGameOnStartup() && - !startupDestinationConnectsToServer()) { - qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; - DlgConnect dlg(this); - connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), - dlg.getPlayerName(), dlg.getPassword()); - } + attemptStartupAutoConnect(); } } @@ -916,6 +913,59 @@ void MainWindow::handleCockatriceLink(const QString &url) urlParser->handle(url); } +void MainWindow::attemptStartupAutoConnect() +{ + if (startupAutoConnectAttempted || skipStartupAutoConnect) { + return; + } + startupAutoConnectAttempted = true; + + if (!connectTo.isEmpty()) { + qCInfo(WindowMainStartupAutoconnectLog) << "Command line connect to " << connectTo; + connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(), + connectTo.password()); + } else if (SettingsCache::instance().servers().getAutoConnect() && + !SettingsCache::instance().debug().getLocalGameOnStartup() && !startupDestinationConnectsToServer()) { + qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; + DlgConnect dlg(this); + connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), + dlg.getPlayerName(), dlg.getPassword()); + } +} + +void MainWindow::onUrlChainFinished(bool connected) +{ + // A cockatrice:// link owns the startup connection while it runs. When its + // chain ended without connecting (declined, invalid, offline), fall back to + // the startup connection so the activation launch still behaves like a + // normal launch. + if (connected) { + // The launch link connected, so the startup fallback has served its + // purpose: drop the skip so a later mid-session link that ends declined + // or offline cannot silently fire auto-connect or applyStartupDestination + // again. + skipStartupAutoConnect = false; + return; + } + + if (!skipStartupAutoConnect || getRemoteClient()->getStatus() != StatusDisconnected) { + return; + } + + if (startupDestinationConnectsToServer()) { + // Users whose startup tab is a Server / Server Room connect through the + // startup destination, not through auto-connect; retry that instead. + qCInfo(WindowMainStartupAutoconnectLog) << "URL chain ended without a connection; retrying startup destination"; + skipStartupAutoConnect = false; + applyStartupDestination(); + return; + } + + qCInfo(WindowMainStartupAutoconnectLog) << "URL chain ended without a connection; retrying startup connect"; + skipStartupAutoConnect = false; + attemptStartupAutoConnect(); +} + void MainWindow::cardDatabaseLoadingFailed() { if (askedForDbUpdater) { diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 08481fd36..9e45a4c3e 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -80,6 +80,7 @@ public slots: void actCheckClientUpdates(); void actConnect(); void actExit(); + void handleCockatriceLink(const QString &url); private slots: void updateTabMenu(const QList &newMenuList); void statusChanged(ClientStatus _status); @@ -97,7 +98,7 @@ private slots: void actOpenSettingsFolder(); void actShow(); void showWindowIfHidden(); - void handleCockatriceLink(const QString &url); + void onUrlChainFinished(bool connected); void cardUpdateError(QProcess::ProcessError err); void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus); @@ -126,6 +127,8 @@ private slots: void startupDestinationFailed(const QString &reason); [[nodiscard]] bool startupDestinationConnectsToServer() const; + void attemptStartupAutoConnect(); + private: static const QString appName; static const QStringList fileNameFilters; @@ -164,6 +167,8 @@ private: LagMonitor lagMonitor; ///< watches the main thread for event loop stalls LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph bool bHasActivated, askedForDbUpdater; + bool skipStartupAutoConnect = false; + bool startupAutoConnectAttempted = false; QProcess *cardUpdateProcess; QByteArray cardUpdateOutputBuffer; DlgViewLog *logviewDialog; @@ -177,6 +182,16 @@ public: { connectTo = QUrl(QString("cockatrice://%1").arg(url)); } + // When set, the window's own startup connection (--connect or auto-connect + // on first activation) is skipped. Used for activation launches: the intent + // chain triggered by a cockatrice:// URL owns the connection, and letting + // auto-connect race against it caused two connectToServer calls to tear + // each other down. onUrlChainFinished() clears this and retries the startup + // connection when the link's chain ended without connecting. + void setSkipStartupAutoConnect(bool skip) + { + skipStartupAutoConnect = skip; + } ~MainWindow() override; RemoteClient *getRemoteClient() const diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 829e08742..b9c30d1ad 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -26,7 +26,6 @@ #include "client/url_scheme_event_filter.h" #include "database/interface/settings_card_preference_provider.h" #include "interface/intents/intent_open_local_deck.h" -#include "interface/intents/url_parser.h" #include "interface/logger.h" #include "interface/pixel_map_generator.h" #include "interface/theme_manager.h" @@ -45,6 +44,8 @@ #include #include #include +#include +#include #include #include #include @@ -177,6 +178,18 @@ QString const generateClientID() return strClientID; } +static QString redactActivationUrl(const QString &url) +{ + // Activation URLs carry secrets in their query string (e.g. the deck share + // token); log only the scheme and the action (cockatrice://opendeck), never + // the parameters. + if (!url.startsWith(QStringLiteral("cockatrice://"))) { + return url; + } + const QUrl parsed(url); + return parsed.scheme() + "://" + parsed.host(); +} + int main(int argc, char *argv[]) { #ifdef Q_OS_WIN @@ -273,10 +286,17 @@ int main(int argc, char *argv[]) SingleInstanceManager instance; if (hasActivationFiles) { + QStringList redactedFiles; + redactedFiles.reserve(startupFiles.size()); + for (const QString &file : startupFiles) { + redactedFiles.append(redactActivationUrl(file)); + } + qCInfo(MainLog) << "Activation launch, files:" << redactedFiles; // Activation launch: hand off to the primary instance if one is // running, otherwise become the primary ourselves. Do this before // constructing the main window so a hand-off exits cheaply. if (!instance.tryRun(startupFiles)) { + qInfo() << "Handed off to a running instance, exiting"; // Sent successfully → exit return 0; } @@ -325,10 +345,30 @@ int main(int argc, char *argv[]) MainWindow ui; + // A URL launch must own the connection: the intent chain triggered by the + // URL connects to the server named in the URL, so the window's own startup + // auto-connect must not race against it (two connectToServer calls tear + // each other down via doDisconnectFromServer). + bool hasUrlActivation = std::any_of(startupFiles.begin(), startupFiles.end(), [](const QString &file) { + return file.startsWith(QStringLiteral("cockatrice://")); + }); +#ifdef Q_OS_MAC + // On macOS the launch can arrive through the URL scheme instead of as a + // positional argument (captured in pendingMacUrls); count those too or the + // window would auto-connect into the link's own connection attempt. + hasUrlActivation = hasUrlActivation || + std::any_of(pendingMacUrls.cbegin(), pendingMacUrls.cend(), + [](const QString &url) { return url.startsWith(QStringLiteral("cockatrice://")); }); +#endif + ui.setSkipStartupAutoConnect(hasUrlActivation); + auto handleActivation = [&ui](const QString &file) { if (file.startsWith("cockatrice://")) { - auto urlParser = new IntentUrlParser(&ui, &ui); - urlParser->handle(file); + qCInfo(MainLog) << "Handling URL activation:" << redactActivationUrl(file); + // Route through the window's persistent url parser: it serializes + // link chains so activations handed over while another chain is + // still connecting do not connect concurrently. + ui.handleCockatriceLink(file); } else if (QFileInfo(file).exists()) { auto openDeckIntent = new IntentOpenLocalDeck(ui.getTabSupervisor(), file); QObject::connect(openDeckIntent, &Intent::failed, &ui, [&ui](const QString &reason) { diff --git a/cockatrice/src/single_instance_manager.cpp b/cockatrice/src/single_instance_manager.cpp index aca23160c..eff0e6586 100644 --- a/cockatrice/src/single_instance_manager.cpp +++ b/cockatrice/src/single_instance_manager.cpp @@ -2,6 +2,14 @@ #include +namespace +{ +// Sent by the primary instance after it has read a forwarded payload. Without +// an acknowledgment, a second instance cannot tell a live primary apart from a +// stale socket left behind by a process that is still shutting down. +const QByteArray ACK_MESSAGE = QByteArrayLiteral("COCKATRICE_ACK"); +} // namespace + SingleInstanceManager::SingleInstanceManager(QObject *parent) : QObject(parent) { } @@ -20,9 +28,15 @@ bool SingleInstanceManager::tryRun(const QStringList &filesToSend) } serverName = QStringLiteral("CockatriceSingleInstance-%1").arg(userName); - // Hand off to an already-running primary instance if one exists. - if (forwardToPrimary(filesToSend)) { - return false; + // Hand off to an already-running primary instance if one exists. Never steal + // the socket of a busy primary: it is alive and will act on the payload. + switch (forwardToPrimary(filesToSend)) { + case ForwardResult::Delivered: + return false; + case ForwardResult::PrimaryBusy: + return false; + case ForwardResult::NoPrimary: + break; } // No primary instance is currently reachable, so become the primary. @@ -35,12 +49,18 @@ bool SingleInstanceManager::tryRun(const QStringList &filesToSend) // Another instance may have started while we were probing; hand off to it // instead of stealing its socket. - if (forwardToPrimary(filesToSend)) { - return false; + switch (forwardToPrimary(filesToSend)) { + case ForwardResult::Delivered: + return false; + case ForwardResult::PrimaryBusy: + return false; + case ForwardResult::NoPrimary: + break; } - // The socket is stale (left over by a crashed instance): remove it and - // retry. If that still fails, another instance just took the name. + // The socket is stale (left over by a crashed instance), so no primary is + // holding it: remove it and retry. If that still fails, another instance + // just took the name. QLocalServer::removeServer(serverName); if (server->listen(serverName)) { return true; @@ -50,12 +70,12 @@ bool SingleInstanceManager::tryRun(const QStringList &filesToSend) return false; } -bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) +SingleInstanceManager::ForwardResult SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) { QLocalSocket socket; socket.connectToServer(serverName); if (!socket.waitForConnected(200)) { - return false; + return ForwardResult::NoPrimary; } // Serialize payload with length prefix @@ -72,7 +92,23 @@ bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) socket.flush(); socket.waitForBytesWritten(1000); - return true; + // A plain launch has nothing for the primary to act on, so there is nothing + // to acknowledge. Waiting here would block the new instance for seconds if + // the primary is busy in a modal dialog, so only the activation path (which + // needs the ACK to avoid stealing a live primary's socket) waits below. + if (filesToSend.isEmpty()) { + return ForwardResult::Delivered; + } + + // Only report a successful hand-off once the primary has acknowledged that + // it actually read the payload. A socket that connects but is still working + // on an earlier payload is alive but busy, not dead: give it more room + // before giving up, so a slow handler does not make a live primary look + // dead (which would lead to stealing its socket). + if (!socket.waitForReadyRead(1000) && !socket.waitForReadyRead(4000)) { + return ForwardResult::PrimaryBusy; + } + return socket.readAll() == ACK_MESSAGE ? ForwardResult::Delivered : ForwardResult::PrimaryBusy; } void SingleInstanceManager::handleNewConnection() @@ -111,12 +147,23 @@ void SingleInstanceManager::handleNewConnection() QStringList files; payloadStream >> files; - emit filesReceived(files); + // Acknowledge receipt as soon as the payload is parsed, before the + // primary starts handling it. The handlers run synchronously and can + // take longer than the sender's readiness timeout (e.g. a modal + // confirmation box), which would otherwise make a live primary look + // dead and cause duplicate handling. + socket->write(ACK_MESSAGE); + socket->flush(); - // Reset buffer (single message use-case) + // Drop the payload from the buffer before handling it: the handlers + // run synchronously and can spin a nested event loop (e.g. a modal + // dialog) that re-reads this socket, which would re-parse and re-emit + // the same files. buffer->clear(); *expectedSize = 0; + emit filesReceived(files); + socket->disconnectFromServer(); return; } diff --git a/cockatrice/src/single_instance_manager.h b/cockatrice/src/single_instance_manager.h index 55bff0e80..d3884a60f 100644 --- a/cockatrice/src/single_instance_manager.h +++ b/cockatrice/src/single_instance_manager.h @@ -23,7 +23,14 @@ private slots: void handleNewConnection(); private: - bool forwardToPrimary(const QStringList &filesToSend); + enum class ForwardResult + { + Delivered, // a live primary acknowledged the payload + NoPrimary, // no connectable primary socket exists + PrimaryBusy // a primary exists but did not acknowledge in time + }; + + ForwardResult forwardToPrimary(const QStringList &filesToSend); QString serverName; QLocalServer *server = nullptr; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index 0013cb475..90f63c09d 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -38,6 +38,48 @@ static double parseClampedParam(const QString &valueString, double fallback, dou return qBound(min, value, max); } +/** + * @brief Reads a `bannerCard` element from the XML stream. + * + * @param xml Reader positioned at the element. + * @return The referenced card. + */ +static CardRef readBannerCard(QXmlStreamReader *xml) +{ + QString providerId = xml->attributes().value("providerId").toString(); + QString cardName = xml->readElementText(); + return {cardName, providerId}; +} + +/** + * @brief Reads a `playmatCard` element from the XML stream. + * + * Attribute values are read before readElementText consumes the element, and + * the params are clamped to the same ranges as the settings dialog and the + * remote player-properties path so malformed deck files cannot produce + * degenerate art rectangles (e.g. a zoom of 0 dividing by zero). + * + * @param xml Reader positioned at the element. + * @return The referenced card plus its clamped positioning parameters. + */ +static PlaymatInfo readPlaymatCard(QXmlStreamReader *xml) +{ + QString providerId = xml->attributes().value("providerId").toString(); + QString marginLStr = xml->attributes().value("marginPctL").toString(); + QString marginRStr = xml->attributes().value("marginPctR").toString(); + QString vOffStr = xml->attributes().value("verticalOffset").toString(); + QString zoomStr = xml->attributes().value("zoom").toString(); + QString cardName = xml->readElementText(); + + return { + .card = {cardName, providerId}, + .params = {.marginPctL = parseClampedParam(marginLStr, 0.07, 0.0, 0.95), + .marginPctR = parseClampedParam(marginRStr, 0.07, 0.0, 0.95), + .verticalOffset = parseClampedParam(vOffStr, 0.33, 0.0, 1.0), + .zoom = parseClampedParam(zoomStr, 1.0, 0.1, 4.0)}, + }; +} + bool DeckList::Metadata::isEmpty() const { return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty() && playmat.card.isEmpty(); @@ -54,25 +96,9 @@ bool DeckList::Metadata::readElement(QXmlStreamReader *xml, const QString &child } else if (childName == "comments") { comments = xml->readElementText(); } else if (childName == "bannerCard") { - QString providerId = xml->attributes().value("providerId").toString(); - QString cardName = xml->readElementText(); - bannerCard = {cardName, providerId}; + bannerCard = readBannerCard(xml); } else if (childName == "playmatCard") { - QString providerId = xml->attributes().value("providerId").toString(); - // Attributes are read before readElementText consumes the element. - QString marginLStr = xml->attributes().value("marginPctL").toString(); - QString marginRStr = xml->attributes().value("marginPctR").toString(); - QString vOffStr = xml->attributes().value("verticalOffset").toString(); - QString zoomStr = xml->attributes().value("zoom").toString(); - QString cardName = xml->readElementText(); - playmat.card = {cardName, providerId}; - // Clamp to the same ranges as the settings dialog and the remote - // player-properties path so malformed deck files cannot produce - // degenerate art rectangles (e.g. a zoom of 0 dividing by zero). - playmat.params.marginPctL = parseClampedParam(marginLStr, 0.07, 0.0, 0.95); - playmat.params.marginPctR = parseClampedParam(marginRStr, 0.07, 0.0, 0.95); - playmat.params.verticalOffset = parseClampedParam(vOffStr, 0.33, 0.0, 1.0); - playmat.params.zoom = parseClampedParam(zoomStr, 1.0, 0.1, 4.0); + playmat = readPlaymatCard(xml); } else if (childName == "tags") { tags.clear(); // Clear existing tags while (xml->readNextStartElement()) { @@ -184,6 +210,27 @@ void DeckList::write(QXmlStreamWriter *xml) const xml->writeEndElement(); // Close "cockatrice_deck" } +bool DeckList::seekToNextElement(QXmlStreamReader *xml) +{ + while (!xml->atEnd()) { + xml->readNext(); + if (xml->isStartElement()) { + return true; + } + } + return false; +} + +void DeckList::readDeckBody(QXmlStreamReader *xml) +{ + while (!xml->atEnd()) { + xml->readNext(); + if (!readElement(xml)) { + break; + } + } +} + bool DeckList::loadFromXml(QXmlStreamReader *xml) { if (xml->error()) { @@ -192,19 +239,11 @@ bool DeckList::loadFromXml(QXmlStreamReader *xml) } cleanList(); - while (!xml->atEnd()) { - xml->readNext(); - if (xml->isStartElement()) { - if (xml->name().toString() != "cockatrice_deck") { - return false; - } - while (!xml->atEnd()) { - xml->readNext(); - if (!readElement(xml)) { - break; - } - } + while (seekToNextElement(xml)) { + if (xml->name().toString() != "cockatrice_deck") { + return false; } + readDeckBody(xml); } refreshDeckHash(); if (xml->error()) { diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h index 229e2077c..199d3a9a8 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h @@ -106,6 +106,22 @@ private: */ mutable QString cachedDeckHash; + /** @name XML load helpers */ + ///@{ + /** + * @brief Advances to the next element in the XML stream. + * @param xml Reader to advance past non-element tokens. + * @return true when a start element was reached, false at end of stream. + */ + bool seekToNextElement(QXmlStreamReader *xml); + + /** + * @brief Reads the contents of a `cockatrice_deck` element into this deck. + * @param xml Reader positioned at the deck element, stopped at its end. + */ + void readDeckBody(QXmlStreamReader *xml); + ///@} + public: /** @name Metadata setters */ ///@{ diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp index 35c7943b4..66f228d19 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp @@ -7,6 +7,84 @@ static constexpr int MAX_DECK_SIZE = 1e5; +namespace +{ + +/** + * @brief Expands card nodes into one lowercase name entry per copy. + * + * @param nodes The card nodes to expand. + * @param prefix Optional prefix prepended to every entry (e.g. "SB:" for the sideboard). + * @return One entry per copy, in node order. + */ +QStringList cardNodesToCopies(const QList &nodes, const QString &prefix = {}) +{ + QStringList result; + for (auto node : nodes) { + for (int i = 0; i < node->getNumber(); ++i) { + result.append(prefix + node->getName().toLower()); + } + } + return result; +} + +/** + * @brief Packs the first five bytes of a SHA-1 digest into a compact base-32 number. + * + * The bytes are placed in most-significant-byte-first order (byte 0 shifted by 32, + * byte 4 unshifted) so decks that only differ in their low-order hash bytes still + * produce distinct identifiers. + * + * @return The 8-character base-32 representation of the packed number. + */ +QString encodeDeckHash(const QByteArray &digest) +{ + quint64 number = 0; + for (int i = 0; i < 5; ++i) { + number |= static_cast(static_cast(digest[i])) << (32 - 8 * i); + } + return QString::number(number, 32).rightJustified(8, '0'); +} + +/** + * @brief Collects every card node in @p node's subtree, in tree order. + * + * @return The collected card nodes. + */ +QList collectCardsRecursive(const InnerDecklistNode *node) +{ + QList result; + for (int i = 0; i < node->size(); i++) { + if (auto *card = dynamic_cast(node->at(i))) { + result.append(card); + } else if (auto *inner = dynamic_cast(node->at(i))) { + result.append(collectCardsRecursive(inner)); + } + } + return result; +} + +/** + * @brief Invokes @p func on every card in @p node's subtree. + * + * Cards nested in custom zones are reported with their top-level @p boardZone + * so that callers can classify cards by board (main/side/maybeboard/tokens). + */ +void forEachCardInNode(InnerDecklistNode *boardZone, + InnerDecklistNode *node, + const std::function &func) +{ + for (int i = 0; i < node->size(); i++) { + if (auto *card = dynamic_cast(node->at(i))) { + func(boardZone, card); + } else if (auto *inner = dynamic_cast(node->at(i))) { + forEachCardInNode(boardZone, inner, func); + } + } +} + +} // namespace + DecklistNodeTree::DecklistNodeTree() : root(new InnerDecklistNode()) { } @@ -43,19 +121,8 @@ QList DecklistNodeTree::getCardNodes(const QSet result; - std::function collectCards = [&collectCards, - &result](const InnerDecklistNode *node) { - for (int i = 0; i < node->size(); i++) { - if (auto *card = dynamic_cast(node->at(i))) { - result.append(card); - } else if (auto *inner = dynamic_cast(node->at(i))) { - collectCards(inner); - } - } - }; - for (auto *zoneNode : getZoneNodes(restrictToZones)) { - collectCards(zoneNode); + result.append(collectCardsRecursive(zoneNode)); } return result; @@ -83,25 +150,11 @@ QString DecklistNodeTree::computeDeckHash() const auto mainDeckNodes = getCardNodes({DECK_ZONE_MAIN}); auto sideDeckNodes = getCardNodes({DECK_ZONE_SIDE}); - static auto nodesToCardList = [](const QList &nodes, const QString &prefix = {}) { - QStringList result; - for (auto node : nodes) { - for (int i = 0; i < node->getNumber(); ++i) { - result.append(prefix + node->getName().toLower()); - } - } - return result; - }; - - QStringList cardList = nodesToCardList(mainDeckNodes) + nodesToCardList(sideDeckNodes, "SB:"); + QStringList cardList = cardNodesToCopies(mainDeckNodes) + cardNodesToCopies(sideDeckNodes, "SB:"); cardList.sort(); QByteArray deckHashArray = QCryptographicHash::hash(cardList.join(";").toUtf8(), QCryptographicHash::Sha1); - quint64 number = (((quint64)(unsigned char)deckHashArray[0]) << 32) + - (((quint64)(unsigned char)deckHashArray[1]) << 24) + - (((quint64)(unsigned char)deckHashArray[2] << 16)) + - (((quint64)(unsigned char)deckHashArray[3]) << 8) + (quint64)(unsigned char)deckHashArray[4]; - return QString::number(number, 32).rightJustified(8, '0'); + return encodeDeckHash(deckHashArray); } void DecklistNodeTree::write(QXmlStreamWriter *xml) const @@ -148,12 +201,7 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode int index = rootNode->indexOf(node); if (index != -1) { delete rootNode->takeAt(index); - - // Empty custom zones are kept while empty board zones get pruned. - if (rootNode->empty() && rootNode->getParent() == root) { - deleteNode(rootNode, rootNode->getParent()); - } - + pruneEmptyBoardZone(rootNode); return true; } @@ -169,24 +217,18 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode return false; } +void DecklistNodeTree::pruneEmptyBoardZone(InnerDecklistNode *container) +{ + if (container->isEmpty() && container->getParent() == root) { + deleteNode(container, container->getParent()); + } +} + void DecklistNodeTree::forEachCard(const std::function &func) const { - // Cards nested in custom zones are reported with their top-level board zone - // so that callers can classify cards by board (main/side/maybeboard/tokens). - std::function walk = [&func, &walk](InnerDecklistNode *boardZone, - InnerDecklistNode *node) { - for (int i = 0; i < node->size(); i++) { - if (auto *card = dynamic_cast(node->at(i))) { - func(boardZone, card); - } else if (auto *inner = dynamic_cast(node->at(i))) { - walk(boardZone, inner); - } - } - }; - for (int i = 0; i < root->size(); i++) { if (auto *zone = dynamic_cast(root->at(i))) { - walk(zone, zone); + forEachCardInNode(zone, zone, func); } } } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h index 94fd87680..2c66b34ff 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h @@ -147,6 +147,14 @@ private: InnerDecklistNode *getZoneObjFromName(const QString &zoneName); InnerDecklistNode *findBoardZone(const QString &boardZoneName) const; InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName); + + /** + * @brief Recursively removes @p container when it is an empty board zone. + * + * Empty custom zones are kept while empty board zones get pruned, so a + * board zone disappears once its last card or custom zone goes away. + */ + void pruneEmptyBoardZone(InnerDecklistNode *container); }; #endif // COCKATRICE_DECKLIST_NODE_TREE_H diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp index a76fed619..855062c18 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp @@ -2,6 +2,32 @@ #include +namespace +{ + +void readMoveCardToZone(QXmlStreamReader *xml, QList &moveList) +{ + MoveCard_ToZone move; + while (!xml->atEnd()) { + xml->readNext(); + const QString childName = xml->name().toString(); + if (xml->isStartElement()) { + if (childName == "card_name") { + move.set_card_name(xml->readElementText().toStdString()); + } else if (childName == "start_zone") { + move.set_start_zone(xml->readElementText().toStdString()); + } else if (childName == "target_zone") { + move.set_target_zone(xml->readElementText().toStdString()); + } + } else if (xml->isEndElement() && (childName == "move_card_to_zone")) { + moveList.append(move); + return; + } + } +} + +} // namespace + SideboardPlan::SideboardPlan(const QString &_name, const QList &_moveList) : name(_name), moveList(_moveList) { @@ -21,23 +47,7 @@ bool SideboardPlan::readElement(QXmlStreamReader *xml) if (childName == "name") { name = xml->readElementText(); } else if (childName == "move_card_to_zone") { - MoveCard_ToZone m; - while (!xml->atEnd()) { - xml->readNext(); - const QString childName2 = xml->name().toString(); - if (xml->isStartElement()) { - if (childName2 == "card_name") { - m.set_card_name(xml->readElementText().toStdString()); - } else if (childName2 == "start_zone") { - m.set_start_zone(xml->readElementText().toStdString()); - } else if (childName2 == "target_zone") { - m.set_target_zone(xml->readElementText().toStdString()); - } - } else if (xml->isEndElement() && (childName2 == "move_card_to_zone")) { - moveList.append(m); - break; - } - } + readMoveCardToZone(xml, moveList); } } else if (xml->isEndElement() && (childName == "sideboard_plan")) { return true; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp index 510a710cd..86f7f5363 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp @@ -151,24 +151,29 @@ bool InnerDecklistNode::compareName(AbstractDecklistNode *other) const } } +int InnerDecklistNode::readCardElement(QXmlStreamReader *xml, int remainingBudget) +{ + const int amount = qMin(xml->attributes().value("number").toString().toInt(), remainingBudget); + new DecklistCardNode(xml->attributes().value("name").toString(), amount, this, -1, + xml->attributes().value("setShortName").toString(), + xml->attributes().value("collectorNumber").toString(), + xml->attributes().value("uuid").toString()); + return amount; +} + int InnerDecklistNode::readElement(QXmlStreamReader *xml, int limit) { int totalCards = 0; while (!xml->atEnd()) { xml->readNext(); const QString childName = xml->name().toString(); + const int remainingBudget = limit - totalCards; if (xml->isStartElement()) { if (childName == "zone") { auto *newZone = new InnerDecklistNode(xml->attributes().value("name").toString(), this); - totalCards += newZone->readElement(xml, limit - totalCards); + totalCards += newZone->readElement(xml, remainingBudget); } else if (childName == "card") { - int amount = xml->attributes().value("number").toString().toInt(); - amount = qMin(amount, limit - totalCards); - new DecklistCardNode(xml->attributes().value("name").toString(), amount, this, -1, - xml->attributes().value("setShortName").toString(), - xml->attributes().value("collectorNumber").toString(), - xml->attributes().value("uuid").toString()); - totalCards += amount; + totalCards += readCardElement(xml, remainingBudget); } } else if (xml->isEndElement() && (childName == "zone")) { return totalCards; @@ -187,31 +192,35 @@ void InnerDecklistNode::writeElement(QXmlStreamWriter *xml) xml->writeEndElement(); // zone } -QVector> InnerDecklistNode::sort(Qt::SortOrder order) +QVector> InnerDecklistNode::indexedSnapshot() const +{ + QVector> snapshot(size()); + for (int i = size() - 1; i >= 0; --i) { + snapshot[i].first = i; + snapshot[i].second = at(i); + } + return snapshot; +} + +QVector> InnerDecklistNode::applySortedOrder(const QVector> &sorted) { QVector> result(size()); - - // Initialize temporary list with contents of current list - QVector> tempList(size()); for (int i = size() - 1; i >= 0; --i) { - tempList[i].first = i; - tempList[i].second = at(i); + result[i].first = sorted[i].first; + result[i].second = i; + replace(i, sorted[i].second); } + return result; +} + +QVector> InnerDecklistNode::sort(Qt::SortOrder order) +{ + auto snapshot = indexedSnapshot(); - // Sort temporary list auto cmp = [order](const auto &a, const auto &b) { return (order == Qt::AscendingOrder) ? (b.second->compare(a.second)) : (a.second->compare(b.second)); }; + std::sort(snapshot.begin(), snapshot.end(), cmp); - std::sort(tempList.begin(), tempList.end(), cmp); - - // Map old indexes to new indexes and - // copy temporary list to the current one - for (int i = size() - 1; i >= 0; --i) { - result[i].first = tempList[i].first; - result[i].second = i; - replace(i, tempList[i].second); - } - - return result; + return applySortedOrder(snapshot); } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h index 958229883..8404d7116 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h @@ -223,6 +223,20 @@ public: */ QVector> sort(Qt::SortOrder order = Qt::AscendingOrder); +private: + /** + * @brief Snapshots the current children as (old index, node) pairs. + */ + QVector> indexedSnapshot() const; + + /** + * @brief Replaces this node's children with @p sorted and maps old indexes to new ones. + * + * @return A list of (old index, new index) pairs for each reordered child. + */ + QVector> applySortedOrder(const QVector> &sorted); + +public: /** * @brief Deserialize this node and its children from XML. * @param xml Reader positioned at this element. @@ -236,6 +250,18 @@ public: * @param xml Writer to append elements to. */ void writeElement(QXmlStreamWriter *xml) override; + +private: + /** + * @brief Reads a single `card` element and appends it to this node. + * + * The card's quantity is capped at @p remainingBudget so a malicious or + * oversized deck file cannot push the total card count past the deck size + * limit. + * + * @return The amount of cards actually added. + */ + int readCardElement(QXmlStreamReader *xml, int remainingBudget); }; #endif // COCKATRICE_INNER_DECK_LIST_NODE_H diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 799b1e7ee..131ff1077 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -465,7 +465,7 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user, if (asJudge && !(user->user_level() & ServerInfo_User::IsJudge)) { return Response::RespUserLevelTooLow; } - if (!(overrideRestrictions && (user->user_level() & ServerInfo_User::IsModerator))) { + if (!(overrideRestrictions && (user->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsJudge)))) { if ((_password != password) && !(spectator && !spectatorsNeedPassword)) { return Response::RespWrongPassword; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 0791f8c14..bc4814d5e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -15,9 +15,17 @@ 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_share_list_mine.proto + command_deck_share_remove.proto command_deck_upload.proto command_del_counter.proto command_delete_arrow.proto @@ -137,6 +145,10 @@ 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_share_list_mine.proto response_deck_upload.proto response_dump_zone.proto response_forgotpasswordrequest.proto @@ -175,6 +187,8 @@ set(PROTO_FILES serverinfo_cardcounter.proto serverinfo_chat_message.proto serverinfo_counter.proto + serverinfo_deck_share_item.proto + serverinfo_deck_share_summary.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_share_list_mine.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list_mine.proto new file mode 100644 index 000000000..75bc30714 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list_mine.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "session_commands.proto"; + +// Requests the list of share bundles created by the calling user, so they can +// be reviewed and revoked before they expire. +message Command_DeckShareListMine { + extend SessionCommand { + optional Command_DeckShareListMine ext = 1032; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto new file mode 100644 index 000000000..348996da9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "session_commands.proto"; + +// Revokes one of the calling user's own share bundles. The referenced items +// are removed by cascade. +message Command_DeckShareRemove { + extend SessionCommand { + optional Command_DeckShareRemove ext = 1033; + } + optional uint32 share_id = 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..1a5d44e9e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto @@ -8,4 +8,10 @@ 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) + // The server derives the banner card and tags from deck_list, so clients only + // need to send the color identity, which cannot be computed server-side. + reserved 5, 6, 8; + reserved "banner_card_name", "banner_card_provider", "tags"; + optional string color_identity = 7; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index 42a42fcc0..caf9febde 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -81,6 +81,10 @@ 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 + DECK_SHARE_LIST_MINE = 1106; // Response listing the caller's own shares 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/response_deck_share_list_mine.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list_mine.proto new file mode 100644 index 000000000..e3cbf83b9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list_mine.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_deck_share_summary.proto"; + +message Response_DeckShareListMine { + extend Response { + optional Response_DeckShareListMine ext = 1106; + } + repeated ServerInfo_DeckShareSummary shares = 1; +} 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_deck_share_summary.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_summary.proto new file mode 100644 index 000000000..43273a54f --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_summary.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; + +// A share bundle created by a user, as reported by a "list my shares" query. +message ServerInfo_DeckShareSummary { + optional uint32 id = 1; + optional string name = 2; + optional uint64 creation_time = 3; + optional uint64 expires_at = 4; + optional uint32 item_count = 5; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto index 16e3f28e3..b04d676d8 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto @@ -1,10 +1,21 @@ 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; + // Tag names associated with the deck. Empty for decks uploaded before the + // tags column existed. + repeated 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..4b7fe9c85 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -28,6 +28,14 @@ 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; + DECK_SHARE_LIST_MINE = 1032; + DECK_SHARE_REMOVE = 1033; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp index 811b0c842..436e260c5 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp @@ -171,7 +171,12 @@ void ServersSettings::addNewServer(const QString &saveName, bool savePassword, const QString &site) { - if (updateExistingServer(saveName, serv, port, username, password, savePassword, site)) { + // Match the exact host-plus-port server the caller is adding, so a link or + // public-server list entry cannot clobber the port (and credentials) of an + // unrelated entry that happens to share the same hostname. + const int existingIndex = findServerIndex(serv, port); + if (existingIndex >= 0) { + updateServerFields(existingIndex, saveName, username, password, savePassword, site); return; } @@ -271,22 +276,7 @@ bool ServersSettings::updateExistingServer(QString saveName, for (int i = 0; i <= size; ++i) { if (serv == getValue(QString("server%1").arg(i), "server", "server_details").toString()) { setValue(port, QString("port%1").arg(i), "server", "server_details"); - if (!username.isEmpty()) { - setValue(username, QString("username%1").arg(i), "server", "server_details"); - } - - if (savePassword && !password.isEmpty()) { - setValue(password, QString("password%1").arg(i), "server", "server_details"); - } else { - setValue(QString(), QString("password%1").arg(i), "server", "server_details"); - } - - if (!site.isEmpty()) { - setValue(site, QString("site%1").arg(i), "server", "server_details"); - } - - setValue(savePassword, QString("savePassword%1").arg(i), "server", "server_details"); - setValue(saveName, QString("saveName%1").arg(i), "server", "server_details"); + updateServerFields(i, saveName, username, password, savePassword, site); return true; } @@ -294,6 +284,31 @@ bool ServersSettings::updateExistingServer(QString saveName, return false; } +void ServersSettings::updateServerFields(int index, + const QString &saveName, + const QString &username, + const QString &password, + bool savePassword, + const QString &site) +{ + if (!username.isEmpty()) { + setValue(username, QString("username%1").arg(index), "server", "server_details"); + } + + if (savePassword && !password.isEmpty()) { + setValue(password, QString("password%1").arg(index), "server", "server_details"); + } else { + setValue(QString(), QString("password%1").arg(index), "server", "server_details"); + } + + if (!site.isEmpty()) { + setValue(site, QString("site%1").arg(index), "server", "server_details"); + } + + setValue(savePassword, QString("savePassword%1").arg(index), "server", "server_details"); + setValue(saveName, QString("saveName%1").arg(index), "server", "server_details"); +} + int ServersSettings::findServerIndex(const QString &host, const QString &port) const { int size = getValue("totalServers", "server", "server_details").toInt(); @@ -310,6 +325,21 @@ int ServersSettings::findServerIndex(const QString &host, const QString &port) c return -1; } +int ServersSettings::findHostIndex(const QString &host) const +{ + int size = getValue("totalServers", "server", "server_details").toInt(); + + for (int i = 0; i <= size; ++i) { + QString storedHost = getValue(QString("server%1").arg(i), "server", "server_details").toString(); + + if (storedHost.compare(host, Qt::CaseInsensitive) == 0) { + return i; + } + } + + return -1; +} + bool ServersSettings::hasUsername(const QString &host, const QString &port) const { int index = findServerIndex(host, port); diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.h b/libcockatrice_settings/libcockatrice/settings/servers_settings.h index f9803a158..93651c813 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.h @@ -61,7 +61,14 @@ public: QString password, bool savePassword, QString site = QString()); + void updateServerFields(int index, + const QString &saveName, + const QString &username, + const QString &password, + bool savePassword, + const QString &site); int findServerIndex(const QString &host, const QString &port) const; + int findHostIndex(const QString &host) const; bool hasUsername(const QString &host, const QString &port) const; bool hasCredentials(const QString &host, const QString &port) const; bool hasLoginData(const QString &host, const QString &port) const; diff --git a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp index 1b21af58e..3320598d8 100644 --- a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp @@ -130,6 +130,11 @@ bool VisualDeckStorageSettings::getVisualDeckStorageShowTagsOnDeckPreviews() con return getValue("showTagsOnDeckPreviews", "interface", "visualDeckStorage", true).toBool(); } +bool VisualDeckStorageSettings::getVisualDeckStorageShowUploadTime() const +{ + return getValue("showUploadTime", "interface", "visualDeckStorage", true).toBool(); +} + bool VisualDeckStorageSettings::getVisualDeckStorageDrawUnusedColorIdentities() const { return getValue("drawUnusedColorIdentities", "interface", "visualDeckStorage", true).toBool(); @@ -220,6 +225,12 @@ void VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews(bool emit visualDeckStorageShowTagsOnDeckPreviewsChanged(_showTags); } +void VisualDeckStorageSettings::setVisualDeckStorageShowUploadTime(bool value) +{ + setValue(value, "showUploadTime", "interface", "visualDeckStorage"); + emit visualDeckStorageShowUploadTimeChanged(value); +} + void VisualDeckStorageSettings::setVisualDeckStorageDrawUnusedColorIdentities(bool _draw) { setValue(_draw, "drawUnusedColorIdentities", "interface", "visualDeckStorage"); diff --git a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h index fd2a76663..9bc9d4172 100644 --- a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h @@ -20,6 +20,7 @@ public: [[nodiscard]] bool getVisualDeckStorageShowColorIdentity() const override; [[nodiscard]] bool getVisualDeckStorageShowBannerCardComboBox() const override; [[nodiscard]] bool getVisualDeckStorageShowTagsOnDeckPreviews() const override; + [[nodiscard]] bool getVisualDeckStorageShowUploadTime() const; [[nodiscard]] bool getVisualDeckStorageDrawUnusedColorIdentities() const override; [[nodiscard]] int getVisualDeckStorageUnusedColorIdentitiesOpacity() const override; [[nodiscard]] int getVisualDeckStorageTooltipType() const override; @@ -38,6 +39,7 @@ public: void setVisualDeckStorageShowColorIdentity(bool value); void setVisualDeckStorageShowBannerCardComboBox(bool _showBannerCardComboBox); void setVisualDeckStorageShowTagsOnDeckPreviews(bool _showTags); + void setVisualDeckStorageShowUploadTime(bool value); void setVisualDeckStorageDrawUnusedColorIdentities(bool _draw); void setVisualDeckStorageUnusedColorIdentitiesOpacity(int _opacity); void setVisualDeckStorageTooltipType(int value); @@ -54,6 +56,7 @@ signals: void visualDeckStorageShowColorIdentityChanged(bool _visible); void visualDeckStorageShowBannerCardComboBoxChanged(bool _visible); void visualDeckStorageShowTagsOnDeckPreviewsChanged(bool _visible); + void visualDeckStorageShowUploadTimeChanged(bool _visible); void visualDeckStorageDrawUnusedColorIdentitiesChanged(bool _visible); void visualDeckStorageUnusedColorIdentitiesOpacityChanged(bool value); void visualDeckStorageInGameChanged(bool enabled); diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 392184b6e..a942870b7 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -213,10 +213,19 @@ if(WIN32) list(APPEND libSearchDirs ${QT_LIBRARY_DIR}) install( - DIRECTORY "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/${CMAKE_BUILD_TYPE}/" + DIRECTORY "$/" DESTINATION ./ FILES_MATCHING PATTERN "*.dll" + PATTERN "*.pdb" EXCLUDE + PATTERN "*.dir*" EXCLUDE + PATTERN "*_autogen*" EXCLUDE + PATTERN "*.tlog*" EXCLUDE + PATTERN "CMakeFiles*" EXCLUDE + PATTERN "x64*" EXCLUDE + PATTERN ".qt*" EXCLUDE + PATTERN ".qsb*" EXCLUDE + PATTERN ".lupdate*" EXCLUDE ) # Qt plugins: iconengines, platforms, styles, tls (Qt6) diff --git a/oracle/src/pagetemplates.cpp b/oracle/src/pagetemplates.cpp index 4a27fef30..0ffeabbb3 100644 --- a/oracle/src/pagetemplates.cpp +++ b/oracle/src/pagetemplates.cpp @@ -112,7 +112,7 @@ bool SimpleDownloadFilePage::validatePage() return false; } - progressLabel->setText(tr("Downloading (0MB)")); + progressLabel->setText(tr("Downloading (0 MB)")); // show an infinite progressbar progressBar->setMaximum(0); progressBar->setMinimum(0); diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index 68e422d8c..21f71a908 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -184,10 +184,19 @@ if(WIN32) set(qtconf_dest_dir .) install( - DIRECTORY "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/${CMAKE_BUILD_TYPE}/" + DIRECTORY "$/" DESTINATION ./ FILES_MATCHING PATTERN "*.dll" + PATTERN "*.pdb" EXCLUDE + PATTERN "*.dir*" EXCLUDE + PATTERN "*_autogen*" EXCLUDE + PATTERN "*.tlog*" EXCLUDE + PATTERN "CMakeFiles*" EXCLUDE + PATTERN "x64*" EXCLUDE + PATTERN ".qt*" EXCLUDE + PATTERN ".qsb*" EXCLUDE + PATTERN ".lupdate*" EXCLUDE ) # Qt plugins: platforms, sqldrivers, tls (Qt6) diff --git a/servatrice/migrations/servatrice_0036_to_0037.sql b/servatrice/migrations/servatrice_0036_to_0037.sql new file mode 100644 index 000000000..576ab53b4 --- /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) COLLATE utf8mb4_bin 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 derived by the server from the deck content (the color +-- identity is supplied 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 `is_public`, + 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 server renders the deck's own tags +-- into a JSON array, so another user's public decks can 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 23eca3f1b..ccd4f3c3f 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -452,3 +452,24 @@ 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 + +; Maximum number of share links a single user may create per day. +; A value of 0 disables the limit. +; Default: 50 +max_shares_per_day=50 diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index cfb1ef5d8..4fbc1d8bd 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` ( @@ -66,16 +66,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) COLLATE utf8mb4_bin 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/deck_tag_serialization.h b/servatrice/src/deck_tag_serialization.h new file mode 100644 index 000000000..162b0604d --- /dev/null +++ b/servatrice/src/deck_tag_serialization.h @@ -0,0 +1,39 @@ +#ifndef DECK_TAG_SERIALIZATION_H +#define DECK_TAG_SERIALIZATION_H + +#include +#include +#include +#include +#include + +/** + * @brief Encodes deck tags as a compact JSON array for storage in a text column. + * + * Deck tags are stored as JSON (rather than a delimited string) so tag names may + * contain any character, and decoded uniformly everywhere they are read. + */ +inline QString serializeDeckTags(const QStringList &tags) +{ + QJsonArray array; + for (const QString &tag : tags) { + array.append(tag); + } + return QString::fromUtf8(QJsonDocument(array).toJson(QJsonDocument::Compact)); +} + +/** @brief Decodes deck tags previously written by serializeDeckTags. */ +inline QStringList deserializeDeckTags(const QString &serialized) +{ + QStringList tags; + if (serialized.isEmpty()) { + return tags; + } + const QJsonArray array = QJsonDocument::fromJson(serialized.toUtf8()).array(); + for (const QJsonValue &tag : array) { + tags.append(tag.toString()); + } + return tags; +} + +#endif // DECK_TAG_SERIALIZATION_H diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 26352ccd7..3dd7ab510 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -437,6 +437,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 = @@ -655,6 +663,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()) { @@ -1067,6 +1080,27 @@ int Servatrice::getServerStatusUpdateTime() const return settingsCache->value("server/statusupdate", 15000).toInt(); } +int Servatrice::getDeckShareExpiryDays() const +{ + return qMax(1, 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::getDeckShareMaxSharesPerDay() const +{ + return settingsCache->value("deck_share/max_shares_per_day", 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 8d964a52b..f39a44d51 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -146,6 +146,7 @@ public: private slots: void statusUpdate(); void shutdownTimeout(); + void cleanupExpiredDeckShares(); protected: void doSendIslMessage(const IslMessage &msg, int _serverId) override; @@ -159,6 +160,7 @@ private: AuthenticationMethod authenticationMethod; DatabaseType databaseType; QTimer *pingClock, *statusUpdateClock; + QTimer *deckShareCleanupClock; Servatrice_GameServer *gameServer; Servatrice_WebsocketGameServer *websocketGameServer; Servatrice_IslServer *islServer; @@ -276,6 +278,10 @@ public: int getMaxGameInactivityTime() const override; int getMaxPlayerInactivityTime() const override; int getClientKeepAlive() const override; + int getDeckShareExpiryDays() const; + int getDeckShareCleanupInterval() const; + int getDeckShareMaxDecksPerShare() const; + int getDeckShareMaxSharesPerDay() 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 af6118646..bb67d78eb 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -1,5 +1,6 @@ #include "servatrice_database_interface.h" +#include "deck_tag_serialization.h" #include "servatrice.h" #include "serversocketinterface.h" #include "settingscache.h" @@ -7,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -1079,6 +1081,183 @@ 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, + qint64 &expiresAt) +{ + checkSql(); + + if (items.isEmpty()) { + return false; + } + + if (!sqlDatabase.transaction()) { + return false; + } + + 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)) { + // A failed execSqlQuery has already closed and reopened the connection, + // which implicitly discards the transaction; rollback below is a no-op. + sqlDatabase.rollback(); + return false; + } + + const int shareId = query->lastInsertId().toInt(); + + // Read the expiry back from the database so the value returned to the client + // matches the server clock rather than being approximated client-side. + QSqlQuery *expiryQuery = prepareQuery("select UNIX_TIMESTAMP(expires_at) from {prefix}_deck_share where id = :id"); + expiryQuery->bindValue(":id", shareId); + if (!execSqlQuery(expiryQuery) || !expiryQuery->next()) { + // See the note above: after a failed execSqlQuery the transaction is + // already gone because the connection was torn down. + sqlDatabase.rollback(); + return false; + } + expiresAt = expiryQuery->value(0).toLongLong(); + + 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); + itemQuery->bindValue(":tags", serializeDeckTags(item.tags)); + 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)) { + // See the note above: the transaction is already gone after the + // reconnect performed by a failed execSqlQuery. + sqlDatabase.rollback(); + return false; + } + } + + if (!sqlDatabase.commit()) { + sqlDatabase.rollback(); + return false; + } + 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(); + item.tags = deserializeDeckTags(itemQuery->value(2).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); +} + +bool Servatrice_DatabaseInterface::getDeckSharesForUser(int userId, QList &shares) +{ + checkSql(); + + QSqlQuery *query = prepareQuery("select s.id, s.name, UNIX_TIMESTAMP(s.created_at), " + "UNIX_TIMESTAMP(s.expires_at), count(i.id) from {prefix}_deck_share s left join " + "{prefix}_deck_share_item i on i.share_id = s.id " + "where s.created_by = :created_by and s.expires_at > now() " + "group by s.id, s.name, s.created_at, s.expires_at order by s.created_at desc"); + query->bindValue(":created_by", userId); + if (!execSqlQuery(query)) { + return false; + } + + shares.clear(); + while (query->next()) { + DeckShareSummaryRecord summary; + summary.id = query->value(0).toInt(); + summary.name = query->value(1).toString(); + summary.creationTime = query->value(2).toLongLong(); + summary.expiresAt = query->value(3).toLongLong(); + summary.itemCount = query->value(4).toInt(); + shares.append(summary); + } + return true; +} + +bool Servatrice_DatabaseInterface::deleteDeckShare(int shareId, int userId) +{ + checkSql(); + + QSqlQuery *query = prepareQuery("delete from {prefix}_deck_share where id = :id and created_by = :created_by"); + query->bindValue(":id", shareId); + query->bindValue(":created_by", userId); + if (!execSqlQuery(query)) { + return false; + } + return query->numRowsAffected() > 0; +} + 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 f0e369449..ce51a2474 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -13,10 +13,32 @@ #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. +}; + +/** @brief Summary of a share bundle owned by a user. */ +struct DeckShareSummaryRecord +{ + int id = -1; ///< Database id, used for revocation. + QString name; ///< Share name. + qint64 creationTime = 0; ///< Unix timestamp at which the share was created. + qint64 expiresAt = 0; ///< Unix timestamp at which the share expires. + int itemCount = 0; ///< Number of decks in the bundle. +}; + class Servatrice_DatabaseInterface : public Server_DatabaseInterface { Q_OBJECT @@ -80,6 +102,37 @@ public: const QList &replayList) override; DeckList *getDeckFromDatabase(int deckId, int userId) override; + /** + * @brief Creates a new temporary deck share bundle. + * @param expiresAt Receives the actual expiry read back from the database. + * @return false on failure. + */ + bool createDeckShare(const QString &token, + const QString &name, + int userId, + const QList &items, + int expiryDays, + qint64 &expiresAt); + /** @brief Lists the share bundles created by a user, newest first. */ + bool getDeckSharesForUser(int userId, QList &shares); + /** + * @brief Deletes one of a user's own share bundles (cascades to its items). + * @return false if no such bundle belongs to the user. + */ + bool deleteDeckShare(int shareId, int userId); + /** + * @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 aeffd7081..c82a8dd73 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -20,6 +20,7 @@ #include "serversocketinterface.h" +#include "deck_tag_serialization.h" #include "email_parser.h" #include "main.h" #include "servatrice.h" @@ -35,10 +36,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -47,8 +50,16 @@ #include #include #include +#include #include +#include #include +#include +#include +#include +#include +#include +#include #include #include #include @@ -81,6 +92,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -277,6 +292,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: @@ -320,6 +341,16 @@ 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_LIST_MINE: + return cmdDeckShareListMine(cmd.GetExtension(Command_DeckShareListMine::ext), rc); + case SessionCommand::DECK_SHARE_REMOVE: + return cmdDeckShareRemove(cmd.GetExtension(Command_DeckShareRemove::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: @@ -566,46 +597,73 @@ 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; - 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; } + + if (publicOnly && !effectivePublic && newItem->mutable_folder()->items_size() == 0) { + folder->mutable_items()->RemoveLast(); + } } - 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()); + for (const QString &tag : deserializeDeckTags(query->value(7).toString())) { + newFile->add_tags(tag.toStdString()); + } } return true; @@ -626,7 +684,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; } @@ -634,6 +692,160 @@ 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 " + "and id_user = :id_user"); + query->bindValue(":is_public", cmd.is_public() ? 1 : 0); + query->bindValue(":id", cmd.deck_id()); + query->bindValue(":id_user", userInfo->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 " + "and id_user = :id_user"); + query->bindValue(":is_public", cmd.is_public() ? 1 : 0); + query->bindValue(":id", folderId); + query->bindValue(":id_user", userInfo->id()); + 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*/) { @@ -742,6 +954,22 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDel(const Command_D return Response::RespOk; } +namespace +{ +/** @brief Keeps only the WUBRG colors from a color identity string, deduplicated. */ +QString sanitizeColorIdentity(const QString &colorIdentity) +{ + QString sanitized; + for (const QChar &color : colorIdentity) { + const QChar upper = color.toUpper(); + if (QStringLiteral("WUBRG").contains(upper) && !sanitized.contains(upper)) { + sanitized.append(upper); + } + } + return sanitized; +} +} // namespace + Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc) { @@ -766,6 +994,14 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman deckName = "Unnamed deck"; } + // The server derives the banner card and tags from the deck itself. Only the + // color identity must come from the client, since the server has no card + // database to compute it. All values are bounded to the column sizes. + const QString bannerCardName = deck.getBannerCard().name.left(255); + const QString bannerCardProvider = deck.getBannerCard().providerId.left(32); + const QString tagsJson = serializeDeckTags(deck.getTags()); + const QString colorIdentity = sanitizeColorIdentity(nameFromStdString(cmd.color_identity())); + if (cmd.has_path()) { int folderId = getDeckPathId(nameFromStdString(cmd.path())); if (folderId == -1) { @@ -774,38 +1010,74 @@ 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); - sqlInterface->execSqlQuery(query); + query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0); + query->bindValue(":banner_card_name", bannerCardName); + query->bindValue(":banner_card_provider", bannerCardProvider); + query->bindValue(":color_identity", colorIdentity); + query->bindValue(":tags", tagsJson); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } Response_DeckUpload *re = new Response_DeckUpload; ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file(); 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"); + QString updateQuery = "update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, " + "banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, " + "color_identity=:color_identity, tags=:tags"; + if (cmd.has_is_public()) { + updateQuery += ", is_public=:is_public"; + } + updateQuery += " where id = :id_deck and id_user = :id_user"; + + QSqlQuery *query = sqlInterface->prepareQuery(updateQuery); query->bindValue(":id_deck", cmd.deck_id()); query->bindValue(":id_user", userInfo->id()); query->bindValue(":name", deckName); query->bindValue(":content", deckStr); - sqlInterface->execSqlQuery(query); + query->bindValue(":banner_card_name", bannerCardName); + query->bindValue(":banner_card_provider", bannerCardProvider); + query->bindValue(":color_identity", colorIdentity); + query->bindValue(":tags", tagsJson); + if (cmd.has_is_public()) { + query->bindValue(":is_public", cmd.is_public() ? 1 : 0); + } + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } 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()); + if (!sqlInterface->execSqlQuery(visibilityQuery)) { + return Response::RespContextError; + } + 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; @@ -836,6 +1108,248 @@ 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(); + item.colorIdentity = sanitizeColorIdentity(colorIdentity); + 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(); + + const int maxItems = servatrice->getDeckShareMaxDecksPerShare(); + if (maxItems > 0 && cmd.items_size() > maxItems) { + return Response::RespInvalidData; + } + + // Per-user rate limit so share links cannot be used to build an unbounded + // word-of-mouth leak of public decks. + const int maxSharesPerDay = servatrice->getDeckShareMaxSharesPerDay(); + if (maxSharesPerDay > 0) { + QSqlQuery *countQuery = sqlInterface->prepareQuery("select count(*) from {prefix}_deck_share where " + "created_by = :created_by and created_at >= " + "DATE_SUB(NOW(), INTERVAL 1 DAY)"); + countQuery->bindValue(":created_by", userInfo->id()); + if (!sqlInterface->execSqlQuery(countQuery) || !countQuery->next()) { + return Response::RespContextError; + } + if (countQuery->value(0).toInt() >= maxSharesPerDay) { + return Response::RespTooManyRequests; + } + } + + 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; + } + + // Drain the deck list before resolving each deck: getDeckFromDatabase + // issues its own query on the same cached statement set. + QSqlQuery *query = + sqlInterface->prepareQuery("select id, color_identity 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()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } + QList> deckRows; + while (query->next()) { + deckRows.append({query->value(0).toInt(), query->value(1).toString()}); + } + for (const auto &[deckId, colorIdentity] : deckRows) { + DeckList *deck; + try { + deck = sqlInterface->getDeckFromDatabase(deckId, userInfo->id()); + } catch (Response::ResponseCode &r) { + return r; + } + items.append(makeShareItemFromDeck(*deck, colorIdentity)); + delete deck; + } + } else { + return Response::RespInvalidData; + } + + if (items.isEmpty() || (maxItems > 0 && items.size() > maxItems)) { + return Response::RespInvalidData; + } + + QString shareName = nameFromStdString(cmd.name()); + if (shareName.isEmpty()) { + shareName = "Shared decks"; + } + + const QString token = generateShareToken(); + qint64 expiresAt = 0; + if (!sqlInterface->createDeckShare(token, shareName, userInfo->id(), items, servatrice->getDeckShareExpiryDays(), + expiresAt)) { + return Response::RespInvalidData; + } + + Response_DeckShareCreate *re = new Response_DeckShareCreate; + re->set_token(token.toStdString()); + re->set_expires_at(expiresAt); + re->set_item_count(items.size()); + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareListMine(const Command_DeckShareListMine & /*cmd*/, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + QList shares; + if (!sqlInterface->getDeckSharesForUser(userInfo->id(), shares)) { + return Response::RespContextError; + } + + Response_DeckShareListMine *re = new Response_DeckShareListMine; + for (const DeckShareSummaryRecord &share : shares) { + ServerInfo_DeckShareSummary *summary = re->add_shares(); + summary->set_id(share.id); + summary->set_name(share.name.toStdString()); + summary->set_creation_time(share.creationTime); + summary->set_expires_at(share.expiresAt); + summary->set_item_count(share.itemCount); + } + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareRemove(const Command_DeckShareRemove &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!cmd.has_share_id()) { + return Response::RespInvalidData; + } + + sqlInterface->checkSql(); + + if (!sqlInterface->deleteDeckShare(cmd.share_id(), userInfo->id())) { + return Response::RespNameNotFound; + } + + 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 b464e6a9b..36b900d28 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -45,11 +45,19 @@ 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_DeckShareListMine; +class Command_DeckShareRemove; +class Command_DeckShareDownload; class Command_ReplayList; class Command_ReplayDownload; class Command_ReplayModifyMatch; @@ -97,8 +105,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); @@ -107,6 +123,12 @@ 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 cmdDeckShareListMine(const Command_DeckShareListMine &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareRemove(const Command_DeckShareRemove &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); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dfbdabb5e..7bb834d7e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -12,6 +12,7 @@ add_test(NAME server_card_counter_test COMMAND server_card_counter_test) add_test(NAME server_counter_test COMMAND server_counter_test) add_test(NAME server_rate_limiter_test COMMAND server_rate_limiter_test) add_test(NAME server_developer_role_test COMMAND server_developer_role_test) +add_test(NAME server_game_join_test COMMAND server_game_join_test) add_test(NAME warning_categories_test COMMAND warning_categories_test) add_test(NAME lag_monitor_test COMMAND lag_monitor_test) add_test(NAME latency_tracker_test COMMAND latency_tracker_test) @@ -34,6 +35,7 @@ add_executable(server_card_counter_test server_card_counter_test.cpp) add_executable(server_counter_test server_counter_test.cpp) add_executable(server_rate_limiter_test server_rate_limiter_test.cpp) add_executable(server_developer_role_test server_developer_role_test.cpp) +add_executable(server_game_join_test server_game_join_test.cpp) add_executable(warning_categories_test warning_categories_test.cpp) add_executable(lag_monitor_test ${CMAKE_SOURCE_DIR}/cockatrice/src/client/lag_monitor.cpp lag_monitor_test.cpp) target_include_directories(lag_monitor_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) @@ -87,6 +89,7 @@ if(NOT GTEST_FOUND) add_dependencies(server_counter_test gtest) add_dependencies(server_rate_limiter_test gtest) add_dependencies(server_developer_role_test gtest) + add_dependencies(server_game_join_test gtest) add_dependencies(warning_categories_test gtest) add_dependencies(lag_monitor_test gtest) add_dependencies(latency_tracker_test gtest) @@ -127,6 +130,10 @@ target_link_libraries( server_developer_role_test libcockatrice_network libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) +target_link_libraries( + server_game_join_test libcockatrice_network_server_remote libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) target_link_libraries( warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) diff --git a/tests/server_game_join_test.cpp b/tests/server_game_join_test.cpp new file mode 100644 index 000000000..c84e4e066 --- /dev/null +++ b/tests/server_game_join_test.cpp @@ -0,0 +1,174 @@ +/** @file server_game_join_test.cpp + * @brief Tests for the moderator/judge game-entry restriction override in Server_Game::checkJoin. + * @ingroup Tests + */ + +#include "game/server_game.h" +#include "server.h" +#include "server_database_interface.h" +#include "server_room.h" + +#include +#include +#include + +RNG_Abstract *rng = nullptr; // referenced by the server_remote library + +namespace +{ + +class MockDatabaseInterface : public Server_DatabaseInterface +{ +public: + AuthenticationResult checkUserPassword(Server_ProtocolHandler *, + const QString &, + const QString &, + const QString &, + QString &, + int &, + bool) override + { + return NotLoggedIn; + } + int getNextReplayId() override + { + return 1; + } + int getNextGameId() override + { + return 1; + } + int getActiveUserCount(QString) override + { + return 0; + } + ServerInfo_User getUserData(const QString &, bool) override + { + return ServerInfo_User(); + } +}; + +class FakeServer : public Server +{ +public: + FakeServer() + { + setDatabaseInterface(new MockDatabaseInterface()); + } +}; + +class GameJoinOverrideTest : public ::testing::Test +{ +protected: + FakeServer server; + Server_Room room{0, 0, "", "", "", "", false, "", {}, &server}; + ServerInfo_User creator; + ServerInfo_User plainUser; + ServerInfo_User unregisteredJudge; + ServerInfo_User moderator; + ServerInfo_User judge; + Server_Game *game = nullptr; + + void SetUp() override + { + creator.set_name("creator"); + creator.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + plainUser.set_name("plain-user"); + plainUser.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + unregisteredJudge.set_name("unregistered-judge"); + unregisteredJudge.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsJudge); + moderator.set_name("moderator"); + moderator.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | + ServerInfo_User::IsModerator); + judge.set_name("judge"); + judge.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsJudge); + } + + void TearDown() override + { + delete game; + } + + Server_Game *makeGame(bool passwordProtected, bool onlyRegistered, bool onlyBuddies, bool spectatorsAllowed) + { + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = passwordProtected ? "secret" : QString(), + .maxPlayers = 2, + .gameTypes = QList(), + .onlyBuddies = onlyBuddies, + .onlyRegistered = onlyRegistered, + .spectatorsAllowed = spectatorsAllowed, + .spectatorsNeedPassword = true, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false}; + return new Server_Game(config, &room); + } +}; + +TEST_F(GameJoinOverrideTest, StaffBypassPasswordRestriction) +{ + game = makeGame(true, false, false, true); + + // A plain user cannot override the password even with the override flag set. + EXPECT_EQ(game->checkJoin(&plainUser, "wrong", false, true, false), Response::RespWrongPassword); + // Moderators and judges may enter any game regardless of the password. + EXPECT_EQ(game->checkJoin(&moderator, "wrong", false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", false, true, false), Response::RespOk); + // Without the override flag judges are still subject to the password. + EXPECT_EQ(game->checkJoin(&judge, "wrong", false, false, true), Response::RespWrongPassword); + EXPECT_EQ(game->checkJoin(&judge, "secret", false, false, true), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, StaffBypassRegisteredOnlyRestriction) +{ + game = makeGame(false, true, false, true); + + // Without the override flag the only-registered restriction still applies. + EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, false, false), Response::RespUserLevelTooLow); + // An unregistered judge may enter when overriding restrictions. + EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, true, false), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, StaffBypassBuddiesOnlyRestriction) +{ + game = makeGame(false, false, true, true); + + // A plain user who is not on the creator's buddy list gets rejected. + EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, false), Response::RespOnlyBuddies); + // Moderators and judges bypass the buddies-only restriction. + EXPECT_EQ(game->checkJoin(&moderator, QString(), false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, false), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, StaffBypassSpectatorsNotAllowedRestriction) +{ + game = makeGame(false, false, false, false); + + // A plain user cannot spectate when the game disallows spectators. + EXPECT_EQ(game->checkJoin(&plainUser, QString(), true, false, false), Response::RespSpectatorsNotAllowed); + // Moderators and judges may spectate any game regardless of the password + // and the spectator restriction. + EXPECT_EQ(game->checkJoin(&moderator, "wrong", true, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", true, true, false), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, JudgeOverrideDoesNotGrantJudgeJoinToPlainUser) +{ + game = makeGame(false, false, false, true); + + // joining with join_as_judge still requires the judge flag even when overriding. + EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, true), Response::RespUserLevelTooLow); + EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, true), Response::RespOk); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index c8c5ad4c8..0341b2b99 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -603,6 +603,19 @@ TEST_F(SettingsDefaultsTest, VisualDeckStorage_DefaultTagsList_SetAndGet) ASSERT_EQ(s.getVisualDeckStorageDefaultTagsList(), custom); } +TEST_F(SettingsDefaultsTest, VisualDeckStorage_ShowUploadTime_Default) +{ + VisualDeckStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVisualDeckStorageShowUploadTime(), true); +} + +TEST_F(SettingsDefaultsTest, VisualDeckStorage_ShowUploadTime_SetAndGet) +{ + VisualDeckStorageSettings s(settingsPath, nullptr); + s.setVisualDeckStorageShowUploadTime(false); + ASSERT_EQ(s.getVisualDeckStorageShowUploadTime(), false); +} + } // namespace int main(int argc, char **argv)