Compare commits
50 commits
2026-09-18
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1365f3cb49 | ||
|
|
2632f8a040 | ||
|
|
fa8220df3c | ||
|
|
28d684f8aa | ||
|
|
7c9dc303f8 | ||
|
|
26a74c96c5 | ||
|
|
e9cd9453ea | ||
|
|
bc26e19564 | ||
|
|
329a6ba94c | ||
|
|
0955388d50 | ||
|
|
a9b38ec0e1 | ||
|
|
712ca61a9e | ||
|
|
eae9591a17 | ||
|
|
a1d8ce6165 | ||
|
|
71ef36374d | ||
|
|
7a41dfe175 | ||
|
|
e97ef5a617 | ||
|
|
fe83966087 | ||
|
|
e01912d3c8 | ||
|
|
9f66a098ac | ||
|
|
14acf3bf64 | ||
|
|
3d5eb84d81 | ||
|
|
334a743953 | ||
|
|
e2a4556546 | ||
|
|
7a2492ac67 | ||
|
|
ef68a7bdcc | ||
|
|
12299abcc8 | ||
|
|
6823d54c1e | ||
|
|
8ca749c07d | ||
|
|
ba2900dcb9 | ||
|
|
a289d61765 | ||
|
|
073ec29c4d | ||
|
|
a5e94d8a4f | ||
|
|
b3c426cd43 | ||
|
|
c97e1c4149 | ||
|
|
ec41c103d1 | ||
|
|
d9cd2d1750 | ||
|
|
1405952f1b | ||
|
|
db2e159dca | ||
|
|
cb19922e55 | ||
|
|
ec39ec611b | ||
|
|
662f1b79cc | ||
|
|
eb1e34c5a6 | ||
|
|
dce9efcaa3 | ||
|
|
c45cb8ac32 | ||
|
|
1a6d9d7749 | ||
|
|
faffb5a837 | ||
|
|
9acb9739b2 | ||
|
|
59dd052143 | ||
|
|
51365960fe |
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
43
.github/workflows/desktop-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/docker-release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ Var NormalDestDir
|
|||
Var PortableDestDir
|
||||
Var PortableMode
|
||||
Var ReinstallMode
|
||||
Var AllowForceClose
|
||||
Var GuardDir
|
||||
Var OldUninstallString
|
||||
|
||||
!include LogicLib.nsh
|
||||
!include FileFunc.nsh
|
||||
|
|
@ -131,6 +134,20 @@ ${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. On a silent update (/R /S) the
|
||||
; running processes are asked to close gracefully and waited for; Cockatrice
|
||||
; itself is never force-killed (data loss, e.g. an unsaved deck) so the update
|
||||
; is aborted instead, while the console applications Oracle and Servatrice are
|
||||
; force-stopped by PID once the grace period is over. On interactive installs
|
||||
; the user is prompted to close them instead.
|
||||
; Only needed this early for /R, where AutoUninstallIfNeeded runs below; for
|
||||
; interactive installs the section re-checks after the directory page, when
|
||||
; $INSTDIR is the directory the user actually picked.
|
||||
${If} $ReinstallMode = 1
|
||||
Call EnsureAppsNotRunning
|
||||
${EndIf}
|
||||
|
||||
${If} $ReinstallMode = 1
|
||||
${AndIf} $PortableMode = 0
|
||||
Call AutoUninstallIfNeeded
|
||||
|
|
@ -144,6 +161,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 +219,473 @@ ${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.
|
||||
;
|
||||
; On a silent update (/R /S) matching processes are asked to close gracefully
|
||||
; (WM_CLOSE) and waited for up to 60 s. Cockatrice is never force-stopped: it
|
||||
; may be holding an unsaved deck or showing a "really leave this game?"
|
||||
; confirmation, so if it refuses to close the install is aborted - nothing has
|
||||
; been replaced yet, so the update is simply deferred. Oracle and Servatrice
|
||||
; are console applications without a main window, so CloseMainWindow() does
|
||||
; nothing for them and waiting cannot help; they hold no unsaved user data and
|
||||
; are force-stopped by PID once the grace period is over. On interactive
|
||||
; installs and uninstalls the user is prompted to close them instead.
|
||||
;
|
||||
; Processes are matched by image name AND by their executable path living
|
||||
; under the guarded directory ($GuardDir, normally $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 $GuardDir, 0 = not running, 2 = could not determine)
|
||||
Function IsAppRunning
|
||||
; Every caller has to set $GuardDir; an empty one would make the path check
|
||||
; below match every process that merely shares the image name, which is the
|
||||
; opposite of what this guard is for.
|
||||
${If} $GuardDir == ""
|
||||
StrCpy $R0 2
|
||||
Return
|
||||
${EndIf}
|
||||
; Pass the directory to guard to PowerShell via an environment variable so
|
||||
; it never has to be embedded into the -Command string: apostrophes in a
|
||||
; path would terminate the command early, and the path in a -like pattern
|
||||
; would treat [ and ] as wildcard characters.
|
||||
System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
|
||||
; Exit the process explicitly so nsExec's pop reflects whether a matching
|
||||
; process was found: powershell.exe exits 0 whether or not the pipeline
|
||||
; produced objects. 0 = not running, 1 = running, anything else = could not
|
||||
; be determined (see IsProcessLookupAvailable).
|
||||
; A process is only a match when its path is readable and lives under
|
||||
; $GuardDir. A process we cannot inspect is not one of ours: the updater is
|
||||
; always started by the application it updates, so it runs in the same user
|
||||
; session, and an interactive install is elevated and can read the paths of
|
||||
; the user's own processes. Assuming a match instead would be a hard stop
|
||||
; rather than a fail-closed guard - an Oracle database instance running as
|
||||
; SYSTEM has a process named "oracle" whose path raises Access denied, and
|
||||
; that would make every silent update abort and every interactive install
|
||||
; loop on the Retry prompt with no way out but Cancel.
|
||||
nsExec::ExecToLog 'powershell -NoProfile -Command "if (Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | Select-Object -First 1) { exit 1 } else { exit 0 }"'
|
||||
Pop $R0
|
||||
${If} $R0 == "0"
|
||||
StrCpy $R0 0
|
||||
${ElseIf} $R0 == "1"
|
||||
StrCpy $R0 1
|
||||
${Else}
|
||||
; powershell.exe could not be run at all (nsExec pushes the string
|
||||
; "error"), or died with an unexpected code. That is "could not
|
||||
; determine", which has to stay distinguishable from "definitely
|
||||
; running": a state that can never clear would block the installer
|
||||
; forever. IsProcessLookupAvailable warns about that case once.
|
||||
StrCpy $R0 2
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
; Sets $R0 to 1 if PowerShell can be started at all, 0 if it cannot. The
|
||||
; running instance check is built on top of it, so a machine where
|
||||
; powershell.exe is missing, blocked by policy or refused by antivirus is
|
||||
; reported once instead of being reported as "Cockatrice is running" on every
|
||||
; single check.
|
||||
Function IsProcessLookupAvailable
|
||||
nsExec::ExecToLog 'powershell -NoProfile -Command "exit 0"'
|
||||
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)
|
||||
;
|
||||
; The filter here is deliberately the strict one: a process is only asked to
|
||||
; close when its path is readable and lives under $GuardDir. Treating a
|
||||
; process whose path cannot be read (an unrelated oracle.exe / servatrice.exe,
|
||||
; or one owned by another account running as SYSTEM) as a match would make
|
||||
; the installer close a third party application, and it runs elevated, so
|
||||
; CloseMainWindow() would succeed on it.
|
||||
System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
|
||||
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { $$null = $$_.CloseMainWindow() }"'
|
||||
Pop $R3
|
||||
FunctionEnd
|
||||
|
||||
Function ForceCloseApps
|
||||
; stop the matching instances by PID once the grace period is over. Only
|
||||
; used for the console applications ($AllowForceClose), which have no window
|
||||
; to close and hold no unsaved user data. Same strict filter as above.
|
||||
System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
|
||||
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { Stop-Process -Id $$_.Id -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
|
||||
; Only a definite "running" is worth waiting for. "Not running" is done, and
|
||||
; "could not determine" must not keep the installer waiting on an answer it
|
||||
; will never get - EnsureAppsNotRunning has warned about that case already.
|
||||
${If} $R0 != 1
|
||||
Return
|
||||
${EndIf}
|
||||
|
||||
${If} ${Silent}
|
||||
; ask the application to close gracefully (WM_CLOSE), then wait for it to exit
|
||||
DetailPrint "Closing $R1 ..."
|
||||
Call CloseMatchingApps
|
||||
; wall-clock deadline: each poll has to spawn a fresh powershell.exe, so
|
||||
; a bare loop counter would not bound the real wait time to 60 s.
|
||||
System::Call 'kernel32::GetTickCount()i.s'
|
||||
Pop $R8
|
||||
ck_wait_loop:
|
||||
Sleep 500
|
||||
Call IsAppRunning
|
||||
${If} $R0 = 0
|
||||
DetailPrint "$R1 closed."
|
||||
Return
|
||||
${EndIf}
|
||||
${If} $R0 != 1
|
||||
Return
|
||||
${EndIf}
|
||||
System::Call 'kernel32::GetTickCount()i.s'
|
||||
Pop $R9
|
||||
IntOp $R9 $R9 - $R8
|
||||
${If} $R9 < 60000
|
||||
Goto ck_wait_loop
|
||||
${EndIf}
|
||||
; The grace period is over. Cockatrice is never force-stopped: it may be
|
||||
; showing a "really leave this game?" confirmation or holding an unsaved
|
||||
; deck, and nothing has been replaced yet, so aborting just defers the
|
||||
; update and preserves the user's data.
|
||||
${If} $AllowForceClose = 0
|
||||
DetailPrint "$R1 is still running after 60 s; aborting the update so that no data is lost."
|
||||
; The in-app updater runs this installer silent and detached and
|
||||
; never looks at its exit code, so a plain Abort would leave the user
|
||||
; with no update and no explanation. A /S install without /R on the
|
||||
; other hand is unattended - by a package manager, for instance -
|
||||
; and a message box there would block forever, so that case only
|
||||
; reports through the log and the exit code.
|
||||
${If} $ReinstallMode = 1
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION \
|
||||
"$R1 did not close within 60 seconds, so the installation was aborted to avoid losing unsaved data.$\r$\n$\r$\nPlease close $R1 and run the installer again."
|
||||
${EndIf}
|
||||
SetErrorLevel 1
|
||||
Abort
|
||||
${EndIf}
|
||||
; Oracle and Servatrice are console applications: CloseMainWindow()
|
||||
; returned false for them and waiting longer cannot help. They hold no
|
||||
; unsaved user data, so stop them by PID.
|
||||
DetailPrint "Force-stopping $R1 ..."
|
||||
Call ForceCloseApps
|
||||
Sleep 2000
|
||||
Call IsAppRunning
|
||||
${If} $R0 = 0
|
||||
DetailPrint "$R1 closed."
|
||||
Return
|
||||
${EndIf}
|
||||
${If} $R0 != 1
|
||||
Return
|
||||
${EndIf}
|
||||
DetailPrint "$R1 is still running after 60 s; aborting the update."
|
||||
${If} $ReinstallMode = 1
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION \
|
||||
"$R1 could not be stopped, so the installation was aborted.$\r$\n$\r$\nPlease close $R1 and run the installer again."
|
||||
${EndIf}
|
||||
SetErrorLevel 1
|
||||
Abort
|
||||
${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}
|
||||
${If} $R0 != 1
|
||||
Return
|
||||
${EndIf}
|
||||
Goto ck_wait_prompt
|
||||
ck_abort_install:
|
||||
Abort
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function EnsureAppsNotRunning
|
||||
; guards the install directory
|
||||
StrCpy $GuardDir $INSTDIR
|
||||
Call GuardAppsNotRunning
|
||||
FunctionEnd
|
||||
|
||||
Function EnsureOldInstallNotRunning
|
||||
; Guards the directory of a *previous* installation, as recorded in the
|
||||
; registry. Needed by componentsPagePre, which runs the old uninstaller
|
||||
; before the directory page, so $INSTDIR is still the default there and not
|
||||
; the directory the old install lives in. Every uninstaller shipped before
|
||||
; this change had no running instance guard of its own, so the old one would
|
||||
; delete files while an instance of the application is still holding them.
|
||||
ReadRegStr $R3 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "InstallLocation"
|
||||
${If} $R3 == ""
|
||||
; no usable record of where the old install is; $INSTDIR is the default
|
||||
; install directory at this point, which is where it usually is anyway
|
||||
DetailPrint "Could not read the previous installation directory from the registry; checking $INSTDIR instead."
|
||||
StrCpy $R3 $INSTDIR
|
||||
${EndIf}
|
||||
StrCpy $GuardDir $R3
|
||||
Call GuardAppsNotRunning
|
||||
FunctionEnd
|
||||
|
||||
Function GuardAppsNotRunning
|
||||
; usage: set $GuardDir to the directory to guard, then call this
|
||||
Call IsProcessLookupAvailable
|
||||
${If} $R0 = 0
|
||||
; Without PowerShell we cannot tell whether a running instance is
|
||||
; holding locks on the files we are about to replace. Warn once and
|
||||
; continue: refusing to install would leave the user with an installer
|
||||
; that can never complete, which is worse than the locked-file failure
|
||||
; this check exists to prevent. MessageBox is shown even in a silent
|
||||
; install - the user has to be told to close the applications by hand.
|
||||
DetailPrint "Could not start powershell.exe; skipping the running instance check. Close Cockatrice, Oracle and Servatrice manually if the installation reports a problem."
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "This installer could not check whether Cockatrice is still running, because powershell.exe could not be started.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice manually before continuing."
|
||||
Return
|
||||
${EndIf}
|
||||
StrCpy $R1 "cockatrice.exe"
|
||||
StrCpy $R2 "cockatrice"
|
||||
; a GUI application with unsaved user data: graceful close only
|
||||
StrCpy $AllowForceClose 0
|
||||
Call WaitForAppToClose
|
||||
StrCpy $R1 "oracle.exe"
|
||||
StrCpy $R2 "oracle"
|
||||
StrCpy $AllowForceClose 1
|
||||
Call WaitForAppToClose
|
||||
StrCpy $R1 "servatrice.exe"
|
||||
StrCpy $R2 "servatrice"
|
||||
StrCpy $AllowForceClose 1
|
||||
Call WaitForAppToClose
|
||||
FunctionEnd
|
||||
|
||||
; Sets $R0 to 1 if $INSTDIR looks like an existing Cockatrice install. Used to
|
||||
; guard the stale-runtime-DLL purge so a first-time install into a pre-existing
|
||||
; directory with an unrelated "Plugins" folder is never touched, while an
|
||||
; install that only ever held e.g. servatrice.exe is still purged.
|
||||
Function IsExistingCockatriceInstall
|
||||
StrCpy $R0 0
|
||||
${If} ${FileExists} "$INSTDIR\cockatrice.exe"
|
||||
StrCpy $R0 1
|
||||
${EndIf}
|
||||
${If} ${FileExists} "$INSTDIR\oracle.exe"
|
||||
StrCpy $R0 1
|
||||
${EndIf}
|
||||
${If} ${FileExists} "$INSTDIR\servatrice.exe"
|
||||
StrCpy $R0 1
|
||||
${EndIf}
|
||||
${If} ${FileExists} "$INSTDIR\uninstall.exe"
|
||||
StrCpy $R0 1
|
||||
${EndIf}
|
||||
; A leftover runtime DLL counts as an install as well. A previously failed
|
||||
; update can leave a directory in which the old uninstaller removed the
|
||||
; executables and uninstall.exe but silently skipped the DLLs its running
|
||||
; instance had locked - which is exactly the broken install the purge is
|
||||
; meant to heal, and without these markers none of the files above would be
|
||||
; there to prove that it is one.
|
||||
${If} ${FileExists} "$INSTDIR\Qt6Core.dll"
|
||||
StrCpy $R0 1
|
||||
${EndIf}
|
||||
${If} ${FileExists} "$INSTDIR\qt.conf"
|
||||
StrCpy $R0 1
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
; Sets $R0 to 1 if a runtime file of a previous version is still present after
|
||||
; the purge. NSIS silently skips whatever it cannot delete, so one surviving DLL
|
||||
; is enough to prove that something still holds a lock on the install
|
||||
; directory. The purge works on wildcards, which the file existence checks do
|
||||
; not, so this looks for files that are certainly part of the payload.
|
||||
Function HasStaleRuntimeFiles
|
||||
StrCpy $R0 0
|
||||
${If} ${FileExists} "$INSTDIR\Qt6Core.dll"
|
||||
StrCpy $R0 1
|
||||
${EndIf}
|
||||
${If} ${FileExists} "$INSTDIR\Qt6Network.dll"
|
||||
StrCpy $R0 1
|
||||
${EndIf}
|
||||
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
|
||||
; An unset $GuardDir would match every process that merely shares the image
|
||||
; name, see IsAppRunning in the installer.
|
||||
${If} $GuardDir == ""
|
||||
StrCpy $R0 2
|
||||
Return
|
||||
${EndIf}
|
||||
; Pass the directory to guard to PowerShell via an environment variable so
|
||||
; it never has to be embedded into the -Command string: apostrophes in a
|
||||
; path would terminate the command early, and the path in a -like pattern
|
||||
; would treat [ and ] as wildcard characters.
|
||||
System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
|
||||
; Exit the process explicitly so nsExec's pop reflects whether a matching
|
||||
; process was found: powershell.exe exits 0 whether or not the pipeline
|
||||
; produced objects. 0 = not running, 1 = running, anything else = could not
|
||||
; be determined (see un.IsProcessLookupAvailable).
|
||||
; Strict match: a readable path under $GuardDir, same as in the installer.
|
||||
nsExec::ExecToLog 'powershell -NoProfile -Command "if (Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | Select-Object -First 1) { exit 1 } else { exit 0 }"'
|
||||
Pop $R0
|
||||
${If} $R0 == "0"
|
||||
StrCpy $R0 0
|
||||
${ElseIf} $R0 == "1"
|
||||
StrCpy $R0 1
|
||||
${Else}
|
||||
; powershell.exe could not be run at all (nsExec pushes the string
|
||||
; "error"), or died with an unexpected code. That is "could not
|
||||
; determine", which has to stay distinguishable from "definitely
|
||||
; running": a state that can never clear would block the uninstaller
|
||||
; forever. un.IsProcessLookupAvailable warns about that case once.
|
||||
StrCpy $R0 2
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function un.IsProcessLookupAvailable
|
||||
nsExec::ExecToLog 'powershell -NoProfile -Command "exit 0"'
|
||||
Pop $R0
|
||||
${If} $R0 == "0"
|
||||
StrCpy $R0 1
|
||||
${Else}
|
||||
StrCpy $R0 0
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function un.CloseMatchingApps
|
||||
; gracefully ask every matching instance to close (sends WM_CLOSE)
|
||||
;
|
||||
; Strict filter, same as in the installer: never close a process whose path
|
||||
; cannot be read, it is not necessarily one of ours.
|
||||
System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
|
||||
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { $$null = $$_.CloseMainWindow() }"'
|
||||
Pop $R3
|
||||
FunctionEnd
|
||||
|
||||
Function un.ForceCloseApps
|
||||
; stop the matching instances by PID once the grace period is over, see
|
||||
; ForceCloseApps in the installer.
|
||||
System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")'
|
||||
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { Stop-Process -Id $$_.Id -Force -ErrorAction SilentlyContinue }"'
|
||||
Pop $R3
|
||||
FunctionEnd
|
||||
|
||||
Function un.WaitForAppToClose
|
||||
Call un.IsAppRunning
|
||||
; Only a definite "running" is worth waiting for, see WaitForAppToClose.
|
||||
${If} $R0 != 1
|
||||
Return
|
||||
${EndIf}
|
||||
|
||||
${If} ${Silent}
|
||||
DetailPrint "Closing $R1 ..."
|
||||
Call un.CloseMatchingApps
|
||||
; wall-clock deadline: each poll has to spawn a fresh powershell.exe, so
|
||||
; a bare loop counter would not bound the real wait time to 60 s.
|
||||
System::Call 'kernel32::GetTickCount()i.s'
|
||||
Pop $R8
|
||||
un_ck_wait_loop:
|
||||
Sleep 500
|
||||
Call un.IsAppRunning
|
||||
${If} $R0 = 0
|
||||
DetailPrint "$R1 closed."
|
||||
Return
|
||||
${EndIf}
|
||||
${If} $R0 != 1
|
||||
Return
|
||||
${EndIf}
|
||||
System::Call 'kernel32::GetTickCount()i.s'
|
||||
Pop $R9
|
||||
IntOp $R9 $R9 - $R8
|
||||
${If} $R9 < 60000
|
||||
Goto un_ck_wait_loop
|
||||
${EndIf}
|
||||
; The grace period is over. Cockatrice is never force-stopped, see
|
||||
; WaitForAppToClose in the installer. A silent uninstall only reports
|
||||
; through the log and its exit code: it is also run unattended (the /R
|
||||
; updater uses it via QuietUninstallString, package managers use it
|
||||
; directly), and a message box would block there. The installer that ran
|
||||
; us reports the incomplete result to the user itself.
|
||||
${If} $AllowForceClose = 0
|
||||
DetailPrint "$R1 is still running after 60 s; aborting so that no data is lost."
|
||||
SetErrorLevel 1
|
||||
Abort
|
||||
${EndIf}
|
||||
DetailPrint "Force-stopping $R1 ..."
|
||||
Call un.ForceCloseApps
|
||||
Sleep 2000
|
||||
Call un.IsAppRunning
|
||||
${If} $R0 = 0
|
||||
DetailPrint "$R1 closed."
|
||||
Return
|
||||
${EndIf}
|
||||
${If} $R0 != 1
|
||||
Return
|
||||
${EndIf}
|
||||
DetailPrint "$R1 is still running after 60 s; aborting."
|
||||
SetErrorLevel 1
|
||||
Abort
|
||||
${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}
|
||||
${If} $R0 != 1
|
||||
Return
|
||||
${EndIf}
|
||||
Goto un_ck_wait_prompt
|
||||
un_ck_abort_install:
|
||||
Abort
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function un.EnsureAppsNotRunning
|
||||
; guards the install directory; the uninstaller only ever has to check this one
|
||||
StrCpy $GuardDir $INSTDIR
|
||||
Call un.GuardAppsNotRunning
|
||||
FunctionEnd
|
||||
|
||||
Function un.GuardAppsNotRunning
|
||||
Call un.IsProcessLookupAvailable
|
||||
${If} $R0 = 0
|
||||
; Same reasoning as in the installer: without PowerShell we cannot tell
|
||||
; whether a running instance still holds file locks, and refusing to
|
||||
; uninstall would leave the user with an uninstaller that never
|
||||
; completes. Warn once and continue.
|
||||
DetailPrint "Could not start powershell.exe; skipping the running instance check. Close Cockatrice, Oracle and Servatrice manually if the uninstall reports a problem."
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "This uninstaller could not check whether Cockatrice is still running, because powershell.exe could not be started.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice manually before continuing."
|
||||
Return
|
||||
${EndIf}
|
||||
StrCpy $R1 "cockatrice.exe"
|
||||
StrCpy $R2 "cockatrice"
|
||||
; a GUI application with unsaved user data: graceful close only
|
||||
StrCpy $AllowForceClose 0
|
||||
Call un.WaitForAppToClose
|
||||
StrCpy $R1 "oracle.exe"
|
||||
StrCpy $R2 "oracle"
|
||||
StrCpy $AllowForceClose 1
|
||||
Call un.WaitForAppToClose
|
||||
StrCpy $R1 "servatrice.exe"
|
||||
StrCpy $R2 "servatrice"
|
||||
StrCpy $AllowForceClose 1
|
||||
Call un.WaitForAppToClose
|
||||
FunctionEnd
|
||||
|
||||
Function PortableModePageCreate
|
||||
|
||||
${If} $ReinstallMode = 1
|
||||
|
|
@ -258,8 +745,15 @@ ${If} $PortableMode = 0
|
|||
${EndIf}
|
||||
|
||||
uninst32:
|
||||
; the old uninstaller is run here, before the directory page, and every
|
||||
; uninstaller predating this change has no guard of its own: make sure no
|
||||
; instance of the application is still running out of the *old* install
|
||||
; directory before letting it delete anything. The uninstall string is
|
||||
; stashed away first, the check works on $R0.
|
||||
StrCpy $OldUninstallString $R0
|
||||
Call EnsureOldInstallNotRunning
|
||||
ClearErrors
|
||||
ExecWait "$R0"
|
||||
ExecWait "$OldUninstallString"
|
||||
|
||||
done32:
|
||||
|
||||
|
|
@ -278,8 +772,10 @@ ${If} $PortableMode = 0
|
|||
${EndIf}
|
||||
|
||||
uninst64:
|
||||
StrCpy $OldUninstallString $R0
|
||||
Call EnsureOldInstallNotRunning
|
||||
ClearErrors
|
||||
ExecWait "$R0"
|
||||
ExecWait "$OldUninstallString"
|
||||
|
||||
done64:
|
||||
${EndIf}
|
||||
|
|
@ -294,6 +790,26 @@ Section "Application" SecApplication
|
|||
SetShellVarContext all
|
||||
SetOutPath "$INSTDIR"
|
||||
|
||||
; No application instance may be running while any file below is deleted or
|
||||
; replaced: both upgrade blocks start by removing the old executables and
|
||||
; runtime DLLs, and NSIS silently skips whatever is locked, which is how an
|
||||
; install ends up with a mix of old and new Qt DLLs next to the new executable
|
||||
; ("The procedure entry point X could not be located in the dynamic link
|
||||
; library ...Qt6Network.dll"). So the check has to come before the first
|
||||
; Delete/RMDir and not after it - an interactive install into an existing
|
||||
; portable directory with cockatrice.exe still running would otherwise delete
|
||||
; the old files under its feet.
|
||||
;
|
||||
; It is skipped on the /R path: .onInit already ran it before
|
||||
; AutoUninstallIfNeeded removed the previous installation, so by the time this
|
||||
; section starts the old files are gone and a second Abort here would leave the
|
||||
; user with the old version uninstalled and the new one not installed at all,
|
||||
; which is worse than the locked files being prevented. The leftover runtime
|
||||
; files are reported at the end of the purge instead.
|
||||
${If} $ReinstallMode = 0
|
||||
Call EnsureAppsNotRunning
|
||||
${EndIf}
|
||||
|
||||
${If} $PortableMode = 1
|
||||
${AndIf} ${FileExists} "$INSTDIR\portable.dat"
|
||||
; upgrade portable mode
|
||||
|
|
@ -308,6 +824,8 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat"
|
|||
Delete "$INSTDIR\Qt*.dll"
|
||||
Delete "$INSTDIR\libmysql.dll"
|
||||
Delete "$INSTDIR\icu*.dll"
|
||||
Delete "$INSTDIR\libcrypto*.dll"
|
||||
Delete "$INSTDIR\libssl*.dll"
|
||||
Delete "$INSTDIR\libeay32.dll"
|
||||
Delete "$INSTDIR\ssleay32.dll"
|
||||
Delete "$INSTDIR\qt.conf"
|
||||
|
|
@ -318,6 +836,35 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat"
|
|||
RMDir "$INSTDIR"
|
||||
${EndIf}
|
||||
|
||||
; Remove any runtime DLLs left over from older versions, so a mismatched
|
||||
; Qt/OpenSSL set cannot survive next to the new executables.
|
||||
Call IsExistingCockatriceInstall
|
||||
${If} $PortableMode = 0
|
||||
${AndIf} $R0 = 1
|
||||
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}
|
||||
|
||||
; Whatever was locked during the purge - by an instance that started while the
|
||||
; installer was running, or because the running instance check could not be
|
||||
; performed at all - is still on disk, and the new executables are about to be
|
||||
; written next to it. That mix is exactly what makes the next start fail with
|
||||
; "The procedure entry point X could not be located in the dynamic link library
|
||||
; ...Qt6Network.dll", so say so instead of leaving it to the user to find out.
|
||||
Call HasStaleRuntimeFiles
|
||||
${If} $R0 = 1
|
||||
DetailPrint "Files of a previous version are still present; the installation may be incomplete."
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION \
|
||||
"The installation could not remove all files of a previous version of Cockatrice, most likely because it is still running or was started as administrator.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again."
|
||||
${EndIf}
|
||||
|
||||
@CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@
|
||||
@CPACK_NSIS_FULL_INSTALL@
|
||||
|
||||
|
|
@ -345,6 +892,16 @@ ${If} $PortableMode = 0
|
|||
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "VersionMajor" "@CPACK_PACKAGE_VERSION_MAJOR@"
|
||||
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "VersionMinor" "@CPACK_PACKAGE_VERSION_MINOR@"
|
||||
|
||||
; --- Register .cod file type ---
|
||||
WriteRegStr HKCR ".cod" "" "Cockatrice"
|
||||
WriteRegStr HKCR "Cockatrice" "" "Cockatrice Deck File"
|
||||
WriteRegStr HKCR "Cockatrice\shell\open\command" "" '"$INSTDIR\cockatrice.exe" "%1"'
|
||||
|
||||
; --- Register custom URI protocol ---
|
||||
WriteRegStr HKCR "cockatrice" "" "URL: Cockatrice Protocol"
|
||||
WriteRegStr HKCR "cockatrice" "URL Protocol" ""
|
||||
WriteRegStr HKCR "cockatrice\shell\open\command" "" '"$INSTDIR\cockatrice.exe" "%1"'
|
||||
|
||||
IfFileExists "$INSTDIR\vc_redist.x86.exe" VcRedist86Exists PastVcRedist86Check
|
||||
VcRedist86Exists:
|
||||
ExecWait '"$INSTDIR\vc_redist.x86.exe" /passive /norestart'
|
||||
|
|
@ -387,21 +944,34 @@ 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"
|
||||
StrCpy $R1 0
|
||||
|
||||
RMDir "$SMPROGRAMS\Cockatrice"
|
||||
; Record whether this was a portable install before the directory (and the
|
||||
; portable.dat marker inside it) is removed below.
|
||||
IfFileExists "$INSTDIR\portable.dat" 0 +2
|
||||
StrCpy $R1 1
|
||||
|
||||
; 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"
|
||||
|
||||
; Only remove the file/protocol associations if we registered them (i.e. the
|
||||
; install was not portable) and .cod is still owned by Cockatrice, so we don't
|
||||
; clobber a .cod association installed by another application.
|
||||
${If} $R1 == 0
|
||||
ReadRegStr $0 HKCR ".cod" ""
|
||||
${If} $0 == "Cockatrice"
|
||||
DeleteRegKey HKCR ".cod"
|
||||
DeleteRegKey HKCR "Cockatrice"
|
||||
DeleteRegKey HKCR "cockatrice"
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
SectionEnd
|
||||
|
||||
; unselected because it is /o
|
||||
|
|
|
|||
|
|
@ -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 "$<TARGET_FILE_DIR:cockatrice>/"
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -62,9 +62,9 @@
|
|||
<file>resources/icons/mana/U.svg</file>
|
||||
<file>resources/icons/mana/W.svg</file>
|
||||
|
||||
<file>resources/backgrounds/home.png</file>
|
||||
<file>resources/backgrounds/home-dark.png</file>
|
||||
<file>resources/backgrounds/home-light.png</file>
|
||||
<file>resources/backgrounds/home.jpg</file>
|
||||
<file>resources/backgrounds/home-dark.jpg</file>
|
||||
<file>resources/backgrounds/home-light.jpg</file>
|
||||
<file>resources/backgrounds/card_triplet.svg</file>
|
||||
<file>resources/backgrounds/placeholder_printing_selector.svg</file>
|
||||
|
||||
|
|
@ -365,6 +365,8 @@
|
|||
|
||||
<file>resources/usericons/pawn_single.svg</file>
|
||||
<file>resources/usericons/pawn_double.svg</file>
|
||||
<file>resources/usericons/pawn_dev_single.svg</file>
|
||||
<file>resources/usericons/pawn_dev_double.svg</file>
|
||||
<file>resources/usericons/pawn_donator_single.svg</file>
|
||||
<file>resources/usericons/pawn_donator_double.svg</file>
|
||||
<file>resources/usericons/pawn_judge_single.svg</file>
|
||||
|
|
|
|||
BIN
cockatrice/resources/backgrounds/home-dark.jpg
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 12 MiB |
BIN
cockatrice/resources/backgrounds/home-light.jpg
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 16 MiB |
BIN
cockatrice/resources/backgrounds/home.jpg
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 16 MiB |
343
cockatrice/resources/usericons/pawn_dev_double.svg
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="100"
|
||||
height="100"
|
||||
id="svg5322"
|
||||
version="1.1"
|
||||
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||
sodipodi:docname="pawn_dev_double.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs3">
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : 526.18109 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_z="744.09448 : 526.18109 : 1"
|
||||
inkscape:persp3d-origin="372.04724 : 350.78739 : 1"
|
||||
id="perspective5328"/>
|
||||
<inkscape:perspective
|
||||
id="perspective5305"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d"/>
|
||||
<linearGradient
|
||||
id="linearGradient5181">
|
||||
<stop
|
||||
style="stop-color:#0fbb00;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5183"/>
|
||||
<stop
|
||||
style="stop-color:#064400;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5185"/>
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600-2"
|
||||
id="radialGradient3606-7"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"/>
|
||||
<linearGradient
|
||||
id="linearGradient3600-2">
|
||||
<stop
|
||||
style="stop-color:#ffc33d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602-4"/>
|
||||
<stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604-9"/>
|
||||
</linearGradient>
|
||||
<inkscape:perspective
|
||||
id="perspective5478"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d"/>
|
||||
<linearGradient
|
||||
id="linearGradient5189">
|
||||
<stop
|
||||
style="stop-color:#000ec9;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5191"/>
|
||||
<stop
|
||||
style="stop-color:#000657;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5193"/>
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600-4"
|
||||
id="radialGradient3606-1"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"/>
|
||||
<linearGradient
|
||||
id="linearGradient3600-4">
|
||||
<stop
|
||||
style="stop-color:#ffc33d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602-3"/>
|
||||
<stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604-5"/>
|
||||
</linearGradient>
|
||||
<inkscape:perspective
|
||||
id="perspective5559"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d"/>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient5173"
|
||||
id="linearGradient5179"
|
||||
x1="167.33386"
|
||||
y1="178.83276"
|
||||
x2="244.78181"
|
||||
y2="178.83276"
|
||||
gradientUnits="userSpaceOnUse"/>
|
||||
<linearGradient
|
||||
id="linearGradient5173">
|
||||
<stop
|
||||
style="stop-color:#f50000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5175"/>
|
||||
<stop
|
||||
style="stop-color:#950000;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5177"/>
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600"
|
||||
id="radialGradient5169"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"/>
|
||||
<linearGradient
|
||||
id="linearGradient3600">
|
||||
<stop
|
||||
style="stop-color:#ffc13d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602"/>
|
||||
<stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604"/>
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
r="25.501276"
|
||||
fy="131.40274"
|
||||
fx="324.32715"
|
||||
cy="131.40274"
|
||||
cx="324.32715"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="radialGradient5574"
|
||||
xlink:href="#linearGradient3600"
|
||||
inkscape:collect="always"/>
|
||||
<inkscape:perspective
|
||||
id="perspective5663"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d"/>
|
||||
<radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600-7"
|
||||
id="radialGradient3606-8"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"/>
|
||||
<linearGradient
|
||||
id="linearGradient3600-7">
|
||||
<stop
|
||||
style="stop-color:#ffc13d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602-7"/>
|
||||
<stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604-6"/>
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600-7"
|
||||
id="radialGradient5254"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"/>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient5189-1"
|
||||
id="linearGradient5394"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="385.03503"
|
||||
y1="180.09546"
|
||||
x2="462.48297"
|
||||
y2="180.09546"
|
||||
gradientTransform="matrix(0.96839241,0,0,0.96839241,-360.365,847.52359)"/>
|
||||
<linearGradient
|
||||
id="linearGradient5189-1">
|
||||
<stop
|
||||
style="stop-color:#000ec9;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5191-0"/>
|
||||
<stop
|
||||
style="stop-color:#000657;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5193-4"/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient5173-1"
|
||||
id="linearGradient5581"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="167.33386"
|
||||
y1="178.83276"
|
||||
x2="244.78181"
|
||||
y2="178.83276"
|
||||
gradientTransform="matrix(0.96839241,0,0,0.96839241,-149.54484,848.74636)"/>
|
||||
<linearGradient
|
||||
id="linearGradient5173-1">
|
||||
<stop
|
||||
style="stop-color:#f50000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5175-5"/>
|
||||
<stop
|
||||
style="stop-color:#950000;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5177-3"/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient5181-9"
|
||||
id="linearGradient5782"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="282.50455"
|
||||
y1="181.61069"
|
||||
x2="359.95248"
|
||||
y2="181.61069"
|
||||
gradientTransform="matrix(0.96839241,0,0,0.96839241,-261.07526,846.05625)"/>
|
||||
<linearGradient
|
||||
id="linearGradient5181-9">
|
||||
<stop
|
||||
style="stop-color:#80d600;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5183-3"/>
|
||||
<stop
|
||||
style="stop-color:#80d600;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5185-0"/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
y2="181.61069"
|
||||
x2="359.95248"
|
||||
y1="181.61069"
|
||||
x1="282.50455"
|
||||
gradientTransform="matrix(0.96839241,0,0,0.96839241,-175.71812,893.2775)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="linearGradient5799"
|
||||
xlink:href="#linearGradient5181-9"
|
||||
inkscape:collect="always"/>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
inkscape:document-units="mm"
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="6.0735294"
|
||||
inkscape:cx="53.757869"
|
||||
inkscape:cy="53.840194"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1696"
|
||||
inkscape:window-height="1051"
|
||||
inkscape:window-x="98"
|
||||
inkscape:window-y="1118"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showguides="true">
|
||||
<sodipodi:guide
|
||||
position="49.829627,61.114263"
|
||||
orientation="1,0"
|
||||
id="guide1"
|
||||
inkscape:locked="false"/>
|
||||
</sodipodi:namedview>
|
||||
<metadata
|
||||
id="metadata4">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
<dc:title/>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(0,-952.36218)">
|
||||
<path
|
||||
style="fill-opacity:1;stroke:black;stroke-width:2.78220296;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
|
||||
d="M 49.84375 1.71875 C 36.719738 1.71875 26.0625 12.375988 26.0625 25.5 C 26.0625 32.977454 29.538325 39.612734 34.9375 43.96875 C 24.439951 49.943698 17.919149 62.196126 14.3125 75.65625 C 9.0380874 95.34065 30.224013 98.21875 49.84375 98.21875 C 69.463486 98.21875 90.549327 94.96715 85.375 75.65625 C 81.693381 61.916246 75.224585 49.827177 64.8125 43.9375 C 70.181573 39.580662 73.59375 32.953205 73.59375 25.5 C 73.59375 12.375988 62.967762 1.71875 49.84375 1.71875 z "
|
||||
transform="translate(0,952.36218)"
|
||||
id="left"/>
|
||||
<path
|
||||
style="opacity:1;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:1.73577702;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 51.28696,1001.834 0,-46.98372 1.434151,0.16768 c 5.155008,0.60274 9.462857,2.72154 12.938257,6.36366 4.74393,4.9715 6.87913,11.35611 6.16464,18.43328 -0.53702,5.31935 -3.09008,10.59498 -6.83833,14.13074 l -1.94072,1.83069 3.04083,2.20427 c 3.58084,2.5957 7.18975,6.4912 9.55296,10.3116 4.89572,7.9144 9.23593,21.4918 8.50487,26.6055 -0.81312,5.6877 -5.43872,9.6977 -13.62216,11.8093 -3.80822,0.9826 -7.68056,1.4713 -14.763321,1.8633 l -4.471177,0.2474 0,-46.9837 z"
|
||||
id="right"
|
||||
inkscape:connector-curvature="0"/>
|
||||
<path
|
||||
d="m 478.409,116.617 c -0.368,-4.271 -3.181,-7.94 -7.2,-9.403 -4.029,-1.472 -8.539,-0.47 -11.57,2.556 l -62.015,62.011 -68.749,-21.768 -21.768,-68.748 62.016,-62.016 c 3.035,-3.032 4.025,-7.543 2.563,-11.565 -1.477,-4.03 -5.137,-6.837 -9.417,-7.207 -37.663,-3.245 -74.566,10.202 -101.247,36.887 -36.542,36.545 -46.219,89.911 -29.083,135.399 -1.873,1.578 -3.721,3.25 -5.544,5.053 L 19.386,373.152 c -0.073,0.071 -0.145,0.149 -0.224,0.219 -24.345,24.346 -24.345,63.959 0,88.309 24.349,24.344 63.672,24.048 88.013,-0.298 0.105,-0.098 0.201,-0.196 0.297,-0.305 L 301.104,252.456 c 1.765,-1.773 3.404,-3.628 4.949,-5.532 45.5,17.167 98.9,7.513 135.474,-29.056 26.675,-26.687 40.131,-63.593 36.882,-101.251 z M 75.98,435.38 c -8.971,8.969 -23.5,8.963 -32.47,0 -8.967,-8.961 -8.967,-23.502 0,-32.466 8.97,-8.963 23.499,-8.963 32.47,0 8.967,8.964 8.967,23.505 0,32.466 z"
|
||||
id="path1"
|
||||
style="display:inline;fill:#A1A1A1;stroke-width:9.87059588;stroke-dasharray:none;fill-opacity:1;stroke:#000000;stroke-opacity:1"
|
||||
transform="matrix(0.19145387,0,0,0.19145387,4.0816072,956.99677)"
|
||||
inkscape:label="wrench" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
211
cockatrice/resources/usericons/pawn_dev_single.svg
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="100"
|
||||
height="100"
|
||||
id="svg5322"
|
||||
version="1.1"
|
||||
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||
sodipodi:docname="pawn_dev_single.svg"
|
||||
xml:space="preserve"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"><defs
|
||||
id="defs3"><inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : 526.18109 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_z="744.09448 : 526.18109 : 1"
|
||||
inkscape:persp3d-origin="372.04724 : 350.78739 : 1"
|
||||
id="perspective5328" /><inkscape:perspective
|
||||
id="perspective5305"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d" /><linearGradient
|
||||
id="linearGradient5181"><stop
|
||||
style="stop-color:#0fbb00;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5183" /><stop
|
||||
style="stop-color:#064400;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5185" /></linearGradient><radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600-2"
|
||||
id="radialGradient3606-7"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)" /><linearGradient
|
||||
id="linearGradient3600-2"><stop
|
||||
style="stop-color:#ffc33d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602-4" /><stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604-9" /></linearGradient><inkscape:perspective
|
||||
id="perspective5478"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d" /><linearGradient
|
||||
id="linearGradient5189"><stop
|
||||
style="stop-color:#000ec9;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5191" /><stop
|
||||
style="stop-color:#000657;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5193" /></linearGradient><radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600-4"
|
||||
id="radialGradient3606-1"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)" /><linearGradient
|
||||
id="linearGradient3600-4"><stop
|
||||
style="stop-color:#ffc33d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602-3" /><stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604-5" /></linearGradient><inkscape:perspective
|
||||
id="perspective5559"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient5173"
|
||||
id="linearGradient5179"
|
||||
x1="167.33386"
|
||||
y1="178.83276"
|
||||
x2="244.78181"
|
||||
y2="178.83276"
|
||||
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||
id="linearGradient5173"><stop
|
||||
style="stop-color:#f50000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5175" /><stop
|
||||
style="stop-color:#950000;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop5177" /></linearGradient><radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600"
|
||||
id="radialGradient5169"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276" /><linearGradient
|
||||
id="linearGradient3600"><stop
|
||||
style="stop-color:#ffc13d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602" /><stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604" /></linearGradient><radialGradient
|
||||
r="25.501276"
|
||||
fy="131.40274"
|
||||
fx="324.32715"
|
||||
cy="131.40274"
|
||||
cx="324.32715"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="radialGradient5574"
|
||||
xlink:href="#linearGradient3600"
|
||||
inkscape:collect="always" /><inkscape:perspective
|
||||
id="perspective5663"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
sodipodi:type="inkscape:persp3d" /><radialGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3600-7"
|
||||
id="radialGradient3606-8"
|
||||
cx="324.32715"
|
||||
cy="131.40274"
|
||||
fx="324.32715"
|
||||
fy="131.40274"
|
||||
r="25.501276"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)" /><linearGradient
|
||||
id="linearGradient3600-7"><stop
|
||||
style="stop-color:#ffc13d;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3602-7" /><stop
|
||||
style="stop-color:#e09900;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3604-6" /></linearGradient><radialGradient
|
||||
r="25.501276"
|
||||
fy="131.40274"
|
||||
fx="324.32715"
|
||||
cy="131.40274"
|
||||
cx="324.32715"
|
||||
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="radialGradient5676"
|
||||
xlink:href="#linearGradient3600-7"
|
||||
inkscape:collect="always" /></defs><sodipodi:namedview
|
||||
inkscape:document-units="mm"
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="3.1582091"
|
||||
inkscape:cx="104.64791"
|
||||
inkscape:cy="56.04442"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1027"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showguides="true"><sodipodi:guide
|
||||
position="50.002551,111.99556"
|
||||
orientation="1,0"
|
||||
id="guide3"
|
||||
inkscape:locked="false" /></sodipodi:namedview><metadata
|
||||
id="metadata4"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(0,-952.36218)"
|
||||
style="display:inline">
|
||||
<path
|
||||
style="display:inline;opacity:1;fill-opacity:1;stroke:#000000;stroke-width:2.7822;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 49.84375,1.71875 C 36.719738,1.71875 26.0625,12.375988 26.0625,25.5 c 0,7.477454 3.475825,14.112734 8.875,18.46875 -10.497549,5.974948 -17.018351,18.227376 -20.625,31.6875 -5.2744126,19.6844 15.911513,22.5625 35.53125,22.5625 19.619736,0 40.705577,-3.2516 35.53125,-22.5625 C 81.693381,61.916246 75.224585,49.827177 64.8125,43.9375 70.181573,39.580662 73.59375,32.953205 73.59375,25.5 c 0,-13.124012 -10.625988,-23.78125 -23.75,-23.78125 z"
|
||||
id="left"
|
||||
sodipodi:insensitive="true"
|
||||
transform="translate(0,952.36218)" />
|
||||
<path
|
||||
d="m 478.409,116.617 c -0.368,-4.271 -3.181,-7.94 -7.2,-9.403 -4.029,-1.472 -8.539,-0.47 -11.57,2.556 l -62.015,62.011 -68.749,-21.768 -21.768,-68.748 62.016,-62.016 c 3.035,-3.032 4.025,-7.543 2.563,-11.565 -1.477,-4.03 -5.137,-6.837 -9.417,-7.207 -37.663,-3.245 -74.566,10.202 -101.247,36.887 -36.542,36.545 -46.219,89.911 -29.083,135.399 -1.873,1.578 -3.721,3.25 -5.544,5.053 L 19.386,373.152 c -0.073,0.071 -0.145,0.149 -0.224,0.219 -24.345,24.346 -24.345,63.959 0,88.309 24.349,24.344 63.672,24.048 88.013,-0.298 0.105,-0.098 0.201,-0.196 0.297,-0.305 L 301.104,252.456 c 1.765,-1.773 3.404,-3.628 4.949,-5.532 45.5,17.167 98.9,7.513 135.474,-29.056 26.675,-26.687 40.131,-63.593 36.882,-101.251 z M 75.98,435.38 c -8.971,8.969 -23.5,8.963 -32.47,0 -8.967,-8.961 -8.967,-23.502 0,-32.466 8.97,-8.963 23.499,-8.963 32.47,0 8.967,8.964 8.967,23.505 0,32.466 z"
|
||||
id="path1"
|
||||
style="display:inline;fill:#A1A1A1;stroke-width:9.87059588;stroke-dasharray:none;fill-opacity:1;stroke:#000000;stroke-opacity:1"
|
||||
transform="matrix(0.19145387,0,0,0.19145387,4.0816072,956.99677)"
|
||||
inkscape:label="wrench" /></g></svg>
|
||||
|
After Width: | Height: | Size: 9.1 KiB |
|
|
@ -58,33 +58,33 @@ signals:
|
|||
void themeChanged();
|
||||
|
||||
private:
|
||||
QSettings *settings;
|
||||
ShortcutsSettings *shortcutsSettings;
|
||||
CardDatabaseSettings *cardDatabaseSettings;
|
||||
ServersSettings *serversSettings;
|
||||
MessageSettings *messageSettings;
|
||||
GameFiltersSettings *gameFiltersSettings;
|
||||
LayoutsSettings *layoutsSettings;
|
||||
DownloadSettings *downloadSettings;
|
||||
RecentsSettings *recentsSettings;
|
||||
CardOverrideSettings *cardOverrideSettings;
|
||||
DebugSettings *debugSettings;
|
||||
CardCounterSettings *cardCounterSettings;
|
||||
TabsSettings *tabsSettings;
|
||||
SoundSettings *soundSettings;
|
||||
GameSettings *gameSettings;
|
||||
ChatSettings *chatSettings;
|
||||
CacheStorageSettings *cacheStorageSettings;
|
||||
UpdatesSettings *updatesSettings;
|
||||
PersonalSettings *personalSettings;
|
||||
CardsDisplaySettings *cardsDisplaySettings;
|
||||
InterfaceSettings *interfaceSettings;
|
||||
DeckEditorSettings *deckEditorSettings;
|
||||
PathsSettings *pathsSettings;
|
||||
VisualDeckStorageSettings *visualDeckStorageSettings;
|
||||
AppearanceSettings *appearanceSettings;
|
||||
NetworkSettings *networkSettings;
|
||||
CommanderBracketSettings *commanderBracketSettings;
|
||||
QSettings *settings = nullptr;
|
||||
ShortcutsSettings *shortcutsSettings = nullptr;
|
||||
CardDatabaseSettings *cardDatabaseSettings = nullptr;
|
||||
ServersSettings *serversSettings = nullptr;
|
||||
MessageSettings *messageSettings = nullptr;
|
||||
GameFiltersSettings *gameFiltersSettings = nullptr;
|
||||
LayoutsSettings *layoutsSettings = nullptr;
|
||||
DownloadSettings *downloadSettings = nullptr;
|
||||
RecentsSettings *recentsSettings = nullptr;
|
||||
CardOverrideSettings *cardOverrideSettings = nullptr;
|
||||
DebugSettings *debugSettings = nullptr;
|
||||
CardCounterSettings *cardCounterSettings = nullptr;
|
||||
TabsSettings *tabsSettings = nullptr;
|
||||
SoundSettings *soundSettings = nullptr;
|
||||
GameSettings *gameSettings = nullptr;
|
||||
ChatSettings *chatSettings = nullptr;
|
||||
CacheStorageSettings *cacheStorageSettings = nullptr;
|
||||
UpdatesSettings *updatesSettings = nullptr;
|
||||
PersonalSettings *personalSettings = nullptr;
|
||||
CardsDisplaySettings *cardsDisplaySettings = nullptr;
|
||||
InterfaceSettings *interfaceSettings = nullptr;
|
||||
DeckEditorSettings *deckEditorSettings = nullptr;
|
||||
PathsSettings *pathsSettings = nullptr;
|
||||
VisualDeckStorageSettings *visualDeckStorageSettings = nullptr;
|
||||
AppearanceSettings *appearanceSettings = nullptr;
|
||||
NetworkSettings *networkSettings = nullptr;
|
||||
CommanderBracketSettings *commanderBracketSettings = nullptr;
|
||||
|
||||
QString themeName;
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ NumericValue <- [0-9]+
|
|||
|
||||
static std::once_flag init;
|
||||
|
||||
// The peglib parser is a single permanent object, so the rule actions below cannot see
|
||||
// per-instance state. The card language that the nested [[card name]] search matches
|
||||
// against is passed through this thread-local context, which is live only while a
|
||||
// DeckFilterString is being parsed, and copied into the nested FilterString closures.
|
||||
thread_local CardSearchLanguage deckSearchLanguageContext;
|
||||
|
||||
static void setupParserRules()
|
||||
{
|
||||
// plumbing
|
||||
|
|
@ -116,7 +122,7 @@ static void setupParserRules()
|
|||
|
||||
// actual functionality
|
||||
search["DeckContentQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
|
||||
auto cardFilter = FilterString(std::any_cast<QString>(sv[0]));
|
||||
auto cardFilter = FilterString(std::any_cast<QString>(sv[0]), deckSearchLanguageContext);
|
||||
auto numberMatcher = sv.size() > 1 ? std::any_cast<NumberMatcher>(sv[1]) : [](int count) { return count > 0; };
|
||||
|
||||
return [=](const DeckSearchData &data) -> bool {
|
||||
|
|
@ -186,7 +192,7 @@ DeckFilterString::DeckFilterString()
|
|||
_error = "Not initialized";
|
||||
}
|
||||
|
||||
DeckFilterString::DeckFilterString(const QString &expr)
|
||||
DeckFilterString::DeckFilterString(const QString &expr, const CardSearchLanguage &searchLanguage)
|
||||
{
|
||||
QByteArray ba = expr.simplified().toUtf8();
|
||||
|
||||
|
|
@ -199,6 +205,8 @@ DeckFilterString::DeckFilterString(const QString &expr)
|
|||
return;
|
||||
}
|
||||
|
||||
deckSearchLanguageContext = searchLanguage;
|
||||
|
||||
search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) {
|
||||
_error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
#include <QLoggingCategory>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
#include <libcockatrice/card/card_localization.h>
|
||||
|
||||
inline Q_LOGGING_CATEGORY(DeckFilterStringLog, "deck_filter_string");
|
||||
|
||||
|
|
@ -35,7 +36,7 @@ class DeckFilterString
|
|||
{
|
||||
public:
|
||||
DeckFilterString();
|
||||
explicit DeckFilterString(const QString &expr);
|
||||
explicit DeckFilterString(const QString &expr, const CardSearchLanguage &searchLanguage = {});
|
||||
bool check(const DeckSearchData &data) const
|
||||
{
|
||||
return filter(data);
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@
|
|||
#include <QLineEdit>
|
||||
#include <QRadioButton>
|
||||
#include <QTreeView>
|
||||
#include <libcockatrice/card/card_localization.h>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/models/database/card_database_model.h>
|
||||
#include <libcockatrice/models/database/token/token_display_model.h>
|
||||
#include <libcockatrice/settings/card_override_settings.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
#include <libcockatrice/settings/interface_settings.h>
|
||||
#include <libcockatrice/settings/layouts_settings.h>
|
||||
#include <libcockatrice/utility/string_limits.h>
|
||||
|
|
@ -88,6 +90,17 @@ DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *pa
|
|||
cardDatabaseDisplayModel = new TokenDisplayModel(this);
|
||||
cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel);
|
||||
|
||||
const auto applyCardSearchLanguage = [this]() {
|
||||
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();
|
||||
cardDatabaseDisplayModel->setSearchLanguage(CardSearchLanguage{
|
||||
cardsDisplay.getCardLang(), static_cast<SearchLanguageMode>(cardsDisplay.getCardSearchLanguage())});
|
||||
};
|
||||
applyCardSearchLanguage();
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
|
||||
applyCardSearchLanguage);
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this,
|
||||
applyCardSearchLanguage);
|
||||
|
||||
chooseTokenFromAllRadioButton = new QRadioButton(tr("Show &all tokens"));
|
||||
connect(chooseTokenFromAllRadioButton, &QRadioButton::toggled, this, &DlgCreateToken::actChooseTokenFromAll);
|
||||
chooseTokenFromDeckRadioButton = new QRadioButton(tr("Show tokens from this &deck"));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "dlg_move_top_cards_until.h"
|
||||
|
||||
#include <QCompleter>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
|
|
@ -19,6 +20,7 @@ DlgMoveTopCardsUntil::DlgMoveTopCardsUntil(QWidget *parent, const MoveTopCardsUn
|
|||
exprComboBox = new QComboBox(this);
|
||||
exprComboBox->setFocus();
|
||||
exprComboBox->setEditable(true);
|
||||
exprComboBox->completer()->setCaseSensitivity(Qt::CaseSensitive);
|
||||
exprComboBox->setInsertPolicy(QComboBox::InsertAtTop);
|
||||
exprComboBox->insertItems(0, options.exprs);
|
||||
exprLabel->setBuddy(exprComboBox);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "view_zone.h"
|
||||
|
||||
#include "../../client/settings/cache_settings.h"
|
||||
#include "../../game/player/player_actions.h"
|
||||
#include "../../game/player/player_logic.h"
|
||||
#include "../../game/zones/view_zone_logic.h"
|
||||
|
|
@ -11,11 +12,13 @@
|
|||
#include <QGraphicsSceneWheelEvent>
|
||||
#include <QPainter>
|
||||
#include <QtMath>
|
||||
#include <libcockatrice/card/card_localization.h>
|
||||
#include <libcockatrice/protocol/pb/command_dump_zone.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_dump_zone.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_card.pb.h>
|
||||
#include <libcockatrice/protocol/pending_command.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
|
||||
/**
|
||||
* @param parent the parent QGraphicsWidget containing the reveal zone
|
||||
|
|
@ -253,7 +256,10 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
|
|||
|
||||
void ZoneViewZone::setFilterString(const QString &_filterString)
|
||||
{
|
||||
filterString = FilterString(_filterString);
|
||||
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();
|
||||
filterString = FilterString(
|
||||
_filterString, CardSearchLanguage{cardsDisplay.getCardLang(),
|
||||
static_cast<SearchLanguageMode>(cardsDisplay.getCardSearchLanguage())});
|
||||
reorganizeCards();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
#include <QStyle>
|
||||
#include <QStyleOption>
|
||||
#include <libcockatrice/protocol/pb/command_shuffle.pb.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
#include <libcockatrice/settings/interface_settings.h>
|
||||
|
||||
namespace
|
||||
|
|
@ -168,6 +169,12 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
|
|||
}
|
||||
|
||||
connect(&searchEdit, &QLineEdit::textChanged, zone, &ZoneViewZone::setFilterString);
|
||||
|
||||
const auto applyCardSearchLanguage = [this] { zone->setFilterString(searchEdit.text()); };
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
|
||||
applyCardSearchLanguage);
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this,
|
||||
applyCardSearchLanguage);
|
||||
}
|
||||
|
||||
setLayout(vbox);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QMainWindow>
|
||||
#include <QMetaObject>
|
||||
#include <QMovie>
|
||||
#include <QNetworkRequest>
|
||||
#include <QPainter>
|
||||
|
|
@ -43,6 +44,7 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr)
|
|||
|
||||
qRegisterMetaType<ExactCard>("ExactCard");
|
||||
connect(worker, &CardPictureLoaderWorker::imageLoaded, this, &CardPictureLoader::imageLoaded);
|
||||
connect(worker, &CardPictureLoaderWorker::networkCacheCleared, this, &CardPictureLoader::networkCacheCleared);
|
||||
|
||||
statusBar = new CardPictureLoaderStatusBar(nullptr);
|
||||
QMainWindow *mainWindow = qobject_cast<QMainWindow *>(QApplication::activeWindow());
|
||||
|
|
@ -58,7 +60,18 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr)
|
|||
|
||||
CardPictureLoader::~CardPictureLoader()
|
||||
{
|
||||
worker->deleteLater();
|
||||
if (worker) {
|
||||
// Capture the thread first: shutdownThread() blocks until the worker has been freed by the
|
||||
// finished() -> deleteLater chain, after which the worker pointer must not be dereferenced.
|
||||
QThread *pictureLoaderThread = worker->workerThread();
|
||||
const bool stopped = worker->shutdownThread();
|
||||
worker = nullptr;
|
||||
// Deleting a QThread that is still running is undefined behaviour, so only free it once the
|
||||
// bounded wait in shutdownThread() confirmed that it stopped.
|
||||
if (stopped) {
|
||||
delete pictureLoaderThread;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CardPictureLoader::getCardBackPixmap(QPixmap &pixmap, QSize size)
|
||||
|
|
@ -457,7 +470,17 @@ void CardPictureLoader::clearPixmapCache()
|
|||
|
||||
void CardPictureLoader::clearNetworkCache()
|
||||
{
|
||||
getInstance().worker->clearNetworkCache();
|
||||
// During teardown the worker is released before this singleton, so a queued clear may still
|
||||
// arrive with no worker left to run it.
|
||||
CardPictureLoaderWorker *worker = getInstance().worker;
|
||||
if (!worker) {
|
||||
return;
|
||||
}
|
||||
// The disk cache and redirect cache are owned by the worker thread, so the clear has to run
|
||||
// there. Invoke it asynchronously to keep the GUI responsive while the worker may be walking
|
||||
// the user's picture directories or recursively deleting the cache directory; callers that
|
||||
// need to know when it is done can listen for networkCacheCleared().
|
||||
QMetaObject::invokeMethod(worker, &CardPictureLoaderWorker::clearNetworkCache, Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
void CardPictureLoader::cacheCardPixmaps(const QList<ExactCard> &cards)
|
||||
|
|
|
|||
|
|
@ -117,6 +117,9 @@ public:
|
|||
public slots:
|
||||
/**
|
||||
* @brief Clears the network disk cache of the worker.
|
||||
*
|
||||
* The clear runs on the worker thread, so this returns before it has completed; connect to
|
||||
* networkCacheCleared() to act once it is done.
|
||||
*/
|
||||
static void clearNetworkCache();
|
||||
|
||||
|
|
@ -131,6 +134,10 @@ public slots:
|
|||
void installPrintingOverride(const ExactCard &originalCard, const ExactCard &overrideCard);
|
||||
void installPrintingOverrideOnLoad(const ExactCard &originalCard, const ExactCard &overrideCard);
|
||||
|
||||
signals:
|
||||
/** @brief Emitted after the worker has finished clearing the network and redirect caches. */
|
||||
void networkCacheCleared();
|
||||
|
||||
private slots:
|
||||
/**
|
||||
* @brief Triggered when the user changes the picture download settings.
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName,
|
|||
for (const QString &path : candidatePaths) {
|
||||
QFileInfo fileInfo(path);
|
||||
QDir dir = fileInfo.dir();
|
||||
QString baseName = fileInfo.fileName();
|
||||
QString baseName = fileInfo.completeBaseName();
|
||||
|
||||
if (!dir.exists()) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -16,13 +16,16 @@
|
|||
#include <utility>
|
||||
#include <version_string.h>
|
||||
|
||||
static constexpr int MAX_REQUESTS_PER_SEC = 10;
|
||||
static constexpr int MIN_HOST_QUOTA = 1; ///< Floor for the per-host request allowance
|
||||
static constexpr int MAX_REQUESTS_PER_SEC = DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT;
|
||||
static constexpr int MIN_HOST_QUOTA = DownloadSettings::MIN_HOST_REQUEST_LIMIT;
|
||||
static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering
|
||||
static constexpr int DISPATCH_INTERVAL_MS = 100; ///< Pacing between individual network requests
|
||||
static constexpr qint64 QUOTA_RESET_INTERVAL_MS = 1000; ///< Interval at which the request quota resets
|
||||
static constexpr int THREAD_SHUTDOWN_WAIT_MS = 5000; ///< Bounded wait for the worker thread to stop at exit
|
||||
|
||||
CardPictureLoaderWorker::CardPictureLoaderWorker()
|
||||
: QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()),
|
||||
requestQuota(MAX_REQUESTS_PER_SEC)
|
||||
hostRequestLimits(SettingsCache::instance().downloads().getHostRequestLimits())
|
||||
{
|
||||
networkManager = new QNetworkAccessManager(this);
|
||||
// We need a timeout to ensure requests don't hang indefinitely in case of
|
||||
|
|
@ -57,20 +60,59 @@ CardPictureLoaderWorker::CardPictureLoaderWorker()
|
|||
localLoader = new CardPictureLoaderLocal(this);
|
||||
|
||||
pictureLoaderThread = new QThread;
|
||||
// The worker object frees itself once its thread finishes, so no event loop is left
|
||||
// running and the QThread is never destroyed while still executing.
|
||||
connect(pictureLoaderThread, &QThread::finished, this, &QObject::deleteLater);
|
||||
pictureLoaderThread->start(QThread::LowPriority);
|
||||
moveToThread(pictureLoaderThread);
|
||||
|
||||
// QTimer value members are not QObject children, so moveToThread on the worker doesn't move
|
||||
// them. They must live in the worker's thread to be started from the slot code that runs there.
|
||||
requestTimer.moveToThread(pictureLoaderThread);
|
||||
dispatchTimer.moveToThread(pictureLoaderThread);
|
||||
|
||||
connect(this, &CardPictureLoaderWorker::imageLoadEnqueued, this, &CardPictureLoaderWorker::handleImageLoadEnqueued);
|
||||
|
||||
connect(&requestTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::resetRequestQuota);
|
||||
requestTimer.setInterval(1000);
|
||||
requestTimer.start();
|
||||
requestTimer.setInterval(static_cast<int>(QUOTA_RESET_INTERVAL_MS));
|
||||
|
||||
connect(&dispatchTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::dispatchQueuedRequest);
|
||||
dispatchTimer.setInterval(DISPATCH_INTERVAL_MS);
|
||||
|
||||
connect(&SettingsCache::instance().downloads(), &DownloadSettings::hostRequestLimitsChanged, this,
|
||||
[this] { hostRequestLimits = SettingsCache::instance().downloads().getHostRequestLimits(); });
|
||||
}
|
||||
|
||||
CardPictureLoaderWorker::~CardPictureLoaderWorker()
|
||||
{
|
||||
saveRedirectCache();
|
||||
pictureLoaderThread->deleteLater();
|
||||
}
|
||||
|
||||
bool CardPictureLoaderWorker::shutdownThread()
|
||||
{
|
||||
// The finished() -> deleteLater chain (wired in the constructor) frees this worker as soon as
|
||||
// its event loop exits, so nothing - not even a member read - may run once wait() returns.
|
||||
// QThread::quit() and QThread::wait() are thread-safe and may be called from the owning thread.
|
||||
QThread *thread = pictureLoaderThread;
|
||||
if (!thread) {
|
||||
return true;
|
||||
}
|
||||
thread->quit();
|
||||
// Only an unbounded wait() would guarantee the thread stops, but this runs from a function-local
|
||||
// static destructor after main() has returned, with no UI left to interrupt a worker stuck in a
|
||||
// slow slot or on a stalled filesystem. Bound the wait and leave such a thread to the OS rather
|
||||
// than hanging the process forever.
|
||||
if (!thread->wait(THREAD_SHUTDOWN_WAIT_MS)) {
|
||||
qCWarning(CardPictureLoaderWorkerLog) << "Picture loader worker thread did not stop within"
|
||||
<< THREAD_SHUTDOWN_WAIT_MS << "ms; leaving it to be torn down by the OS";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QThread *CardPictureLoaderWorker::workerThread() const
|
||||
{
|
||||
return pictureLoaderThread;
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker)
|
||||
|
|
@ -99,7 +141,17 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
|
|||
// Check for cached redirects
|
||||
QUrl cachedRedirect = getCachedRedirect(url);
|
||||
if (!cachedRedirect.isEmpty()) {
|
||||
// The status bar still needs to reclaim this URL's widget even when we hand the request back
|
||||
// for a deferred retry instead of dispatching it onto the network.
|
||||
emit imageRequestSucceeded(url);
|
||||
// The redirect target is a different host, which may itself be in 429 backoff; hand the
|
||||
// entry back to its worker so it waits the backoff out instead of dispatching straight
|
||||
// onto the backed-off host.
|
||||
if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(cachedRedirect.host(),
|
||||
QDateTime::currentDateTime())) {
|
||||
worker->scheduleDeferredRetry(cachedRedirect.host());
|
||||
return nullptr;
|
||||
}
|
||||
return makeRequest(cachedRedirect, worker);
|
||||
}
|
||||
|
||||
|
|
@ -110,67 +162,258 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
|
|||
// Cached entries are served straight from the disk cache even when picture downloads are
|
||||
// enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a
|
||||
// genuine cache miss goes to the network, and only when downloads are enabled.
|
||||
bool useNetworkCache = static_cast<CardPictureLoaderCacheMethod::CacheMethod>(
|
||||
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
|
||||
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE &&
|
||||
(cache->metaData(url).isValid() || !picDownload);
|
||||
bool useNetworkCache = !requestTouchesNetwork(url);
|
||||
|
||||
req.setAttribute(QNetworkRequest::CacheLoadControlAttribute,
|
||||
useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork);
|
||||
|
||||
QNetworkReply *reply = networkManager->get(req);
|
||||
|
||||
// Connect reply handling
|
||||
connect(reply, &QNetworkReply::finished, worker, [reply, worker] { worker->handleNetworkReply(reply); });
|
||||
// Track in-flight replies per host so the unlocked fast path can bound how many requests it
|
||||
// issues at once, instead of creating replies that time out before Qt opens a connection.
|
||||
const QString host = url.host();
|
||||
hostInFlight.insert(host, hostInFlight.value(host) + 1);
|
||||
|
||||
// Release the in-flight slot when the reply is destroyed, not when it emits `finished`, and use
|
||||
// the worker (not the work object) as the context object: a reply can go away without ever
|
||||
// finishing (aborted, or a work object deleted while a reply is still pending), and a connection
|
||||
// bound to that work object's lifetime would then never run, permanently shrinking the fast
|
||||
// path's concurrency until it wedges. This way the slot is released exactly once.
|
||||
connect(reply, &QObject::destroyed, this,
|
||||
[this, host] { hostInFlight.insert(host, qMax(0, hostInFlight.value(host) - 1)); });
|
||||
|
||||
// Connect reply handling; the work object is the context so its handler dies with it.
|
||||
connect(reply, &QNetworkReply::finished, worker, [worker, reply] { worker->handleNetworkReply(reply); });
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorker::resetRequestQuota()
|
||||
{
|
||||
requestQuota = MAX_REQUESTS_PER_SEC;
|
||||
|
||||
QDateTime now = QDateTime::currentDateTime();
|
||||
for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end(); ++it) {
|
||||
for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end();) {
|
||||
if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) {
|
||||
it.value() = qMin(MAX_REQUESTS_PER_SEC, it.value() + 1);
|
||||
if (hostAllowanceCeiling(it.key()) == DownloadSettings::UNLIMITED_HOST_QUOTA) {
|
||||
// A developer-unlocked host that fell back after a 429 recovers towards the default
|
||||
// allowance; once it gets there it becomes unlocked (fast-path) again.
|
||||
if (it.value() + 1 >= DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT) {
|
||||
it = hostRequestQuota.erase(it);
|
||||
continue;
|
||||
}
|
||||
it.value() += 1;
|
||||
} else {
|
||||
// Recover towards the host's effective allowance ceiling, which may be
|
||||
// lowered by the user's per-host request limits.
|
||||
it.value() = qMin(hostAllowanceCeiling(it.key()), it.value() + 1);
|
||||
}
|
||||
}
|
||||
++it;
|
||||
}
|
||||
|
||||
for (const auto &request : requestLoadQueue) {
|
||||
const QString host = request.first.host();
|
||||
hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC));
|
||||
}
|
||||
// Forget the per-second allowances; each host's allowance is re-seeded lazily from its
|
||||
// reduced sustained quota the first time it is dispatched in the new second, so a host that
|
||||
// enters the queue mid-second no longer falls through to a fresh full quota.
|
||||
hostQuotaRemaining.clear();
|
||||
|
||||
processQueuedRequests();
|
||||
updateTimerState();
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorker::processQueuedRequests()
|
||||
{
|
||||
while (requestQuota > 0 && processSingleRequest()) {
|
||||
--requestQuota;
|
||||
// QTimer must be started from the thread it lives in; if this public slot is ever reached from
|
||||
// another thread, replay it on the worker's event loop instead of letting start() fail silently.
|
||||
if (thread() != QThread::currentThread()) {
|
||||
QMetaObject::invokeMethod(this, &CardPictureLoaderWorker::processQueuedRequests, Qt::QueuedConnection);
|
||||
return;
|
||||
}
|
||||
updateTimerState();
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorker::dispatchQueuedRequest()
|
||||
{
|
||||
if (requestLoadQueue.isEmpty()) {
|
||||
// All queued requests have been dispatched; stop the pacing timers.
|
||||
updateTimerState();
|
||||
return;
|
||||
}
|
||||
|
||||
QDateTime now = QDateTime::currentDateTime();
|
||||
bool dispatched = false;
|
||||
// Set while an unlocked host still has queued work blocked only by the in-flight cap; the
|
||||
// timer must keep running so it gets another try as soon as a slot frees. A host blocked by
|
||||
// its 429 backoff instead waits for the next quota-reset tick to restart the dispatcher.
|
||||
bool unlockedCapped = false;
|
||||
|
||||
// Unlocked hosts (developer cap UNLIMITED_HOST_QUOTA) skip the pacing and the per-host
|
||||
// allowance: dispatch their queued requests back-to-back, bounded by their 429 backoff and the
|
||||
// per-host in-flight cap so a large burst can't queue replies that time out before Qt opens a
|
||||
// connection for them.
|
||||
for (int i = 0; i < requestLoadQueue.size();) {
|
||||
const auto &request = requestLoadQueue.at(i);
|
||||
// Dispatch decisions must key on the host the request will actually go to, not the URL that
|
||||
// merely redirects to it: a redirect learned after this URL was queued would otherwise
|
||||
// bypass the in-flight cap and drain the whole queue onto the target host unchecked.
|
||||
const QUrl resolvedUrl = resolveCachedRedirect(request.first);
|
||||
const QString host = resolvedUrl.host();
|
||||
if (isUnlockedHost(host)) {
|
||||
if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) {
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (hostInFlight.value(host) < MAX_IN_FLIGHT_PER_HOST) {
|
||||
makeRequest(resolvedUrl, request.second);
|
||||
requestLoadQueue.removeAt(i);
|
||||
dispatched = true;
|
||||
continue;
|
||||
}
|
||||
unlockedCapped = true;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
|
||||
if (requestLoadQueue.isEmpty()) {
|
||||
dispatchTimer.stop();
|
||||
requestTimer.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (processSingleRequest()) {
|
||||
dispatched = true;
|
||||
}
|
||||
|
||||
// Keep the timer running while there is progress to make or unlocked work waiting on a free
|
||||
// in-flight slot; otherwise no host has allowance left this second, so wait for the quota reset.
|
||||
if (!dispatched && !unlockedCapped) {
|
||||
dispatchTimer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorker::updateTimerState()
|
||||
{
|
||||
// Never restart an active timer: that would reset the pacing countdown and a burst of enqueues
|
||||
// could keep starving the dispatcher, so only (re)start a timer that has actually stopped.
|
||||
if (requestLoadQueue.isEmpty()) {
|
||||
dispatchTimer.stop();
|
||||
// Forget per-second allowances once nothing is pending: a stale zero would otherwise delay
|
||||
// the next single request by a full quota-reset interval.
|
||||
hostQuotaRemaining.clear();
|
||||
} else if (!dispatchTimer.isActive()) {
|
||||
dispatchTimer.start();
|
||||
}
|
||||
|
||||
// The quota timer resets allowances every second and is also the only thing that heals a host
|
||||
// after a 429 (see resetRequestQuota). It must keep ticking while work is queued or a host is
|
||||
// still recovering below the ceiling, and only winds down once no host needs recovery anymore.
|
||||
// Keeping it alive during such idle periods lets reduced quotas recover as intended.
|
||||
bool hostRecovering = false;
|
||||
for (auto it = hostRequestQuota.cbegin(); it != hostRequestQuota.cend(); ++it) {
|
||||
if (it.value() < MAX_REQUESTS_PER_SEC) {
|
||||
hostRecovering = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!requestLoadQueue.isEmpty() || hostRecovering) {
|
||||
if (!requestTimer.isActive()) {
|
||||
requestTimer.start();
|
||||
}
|
||||
} else if (requestTimer.isActive()) {
|
||||
requestTimer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
bool CardPictureLoaderWorker::processSingleRequest()
|
||||
{
|
||||
QDateTime now = QDateTime::currentDateTime();
|
||||
for (int i = 0; i < requestLoadQueue.size(); ++i) {
|
||||
const auto &request = requestLoadQueue.at(i);
|
||||
QString host = request.first.host();
|
||||
int allowance = hostQuotaRemaining.value(host, MAX_REQUESTS_PER_SEC);
|
||||
// Copy the entry: takeAt(i) below erases within the list this reference points into.
|
||||
const auto request = requestLoadQueue.at(i);
|
||||
// Resolve cached redirects so the rate-limit and allowance arithmetic keys on the host the
|
||||
// request will actually hit (see resolveCachedRedirect).
|
||||
const QUrl resolvedUrl = resolveCachedRedirect(request.first);
|
||||
const QString host = resolvedUrl.host();
|
||||
// Don't dispatch requests to a host that is currently in its 429 backoff; hand the entry
|
||||
// back to its worker so it can wait the backoff out or fall through to another source,
|
||||
// instead of leaving it parked in the queue with no reply pending. Only applies to
|
||||
// requests that will actually touch the network: one that will be served from the disk
|
||||
// cache costs nothing and shouldn't wait out the 429.
|
||||
if (requestTouchesNetwork(request.first) &&
|
||||
CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) {
|
||||
// The queued URL is usually a cached-redirect target whose host differs from
|
||||
// cardToDownload.getCurrentUrl(), so scheduleDeferredRetry() (which waits out the
|
||||
// blocked host's deadline) is used instead of startNextPicDownload() looping on the
|
||||
// original host.
|
||||
auto entry = requestLoadQueue.takeAt(i);
|
||||
if (host != entry.first.host()) {
|
||||
// A cached redirect target is what is blocked, which the work object would not
|
||||
// discover from its own URL; wait out that specific host (with jitter) instead.
|
||||
entry.second->scheduleDeferredRetry(host);
|
||||
} else {
|
||||
entry.second->startNextPicDownload();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Unlocked hosts are handled by dispatchQueuedRequest's fast path, bounded by the in-flight
|
||||
// cap; they must not fall through to the per-host allowance arithmetic below.
|
||||
if (isUnlockedHost(host)) {
|
||||
continue;
|
||||
}
|
||||
int ceiling = hostAllowanceCeiling(host);
|
||||
if (ceiling == DownloadSettings::UNLIMITED_HOST_QUOTA) {
|
||||
// A 429 dropped this unlocked host out of the fast path and installed a concrete
|
||||
// allowance; pace it against that allowance until the recovery loop unlocks it again.
|
||||
ceiling = hostRequestQuota.value(host, DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT);
|
||||
}
|
||||
// Seed the allowance lazily so a host that enters the queue mid-second gets its reduced
|
||||
// per-host allowance, clamped against the ceiling so a lowered user cap applies from this
|
||||
// second onward.
|
||||
if (!hostQuotaRemaining.contains(host)) {
|
||||
hostQuotaRemaining.insert(host, qMin(ceiling, hostRequestQuota.value(host, ceiling)));
|
||||
}
|
||||
int allowance = hostQuotaRemaining.value(host);
|
||||
if (allowance > 0) {
|
||||
hostQuotaRemaining.insert(host, allowance - 1);
|
||||
makeRequest(request.first, request.second);
|
||||
requestLoadQueue.removeAt(i);
|
||||
auto entry = requestLoadQueue.takeAt(i);
|
||||
makeRequest(resolvedUrl, entry.second);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool CardPictureLoaderWorker::requestTouchesNetwork(const QUrl &url) const
|
||||
{
|
||||
bool useNetworkCache = static_cast<CardPictureLoaderCacheMethod::CacheMethod>(
|
||||
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
|
||||
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE &&
|
||||
(cache->metaData(url).isValid() || !picDownload);
|
||||
return !useNetworkCache;
|
||||
}
|
||||
|
||||
int CardPictureLoaderWorker::hostAllowanceCeiling(const QString &host) const
|
||||
{
|
||||
const int devCap = DownloadSettings::getDeveloperHostCaps().value(host, MAX_REQUESTS_PER_SEC);
|
||||
if (devCap == DownloadSettings::UNLIMITED_HOST_QUOTA && !hostRequestLimits.contains(host)) {
|
||||
return DownloadSettings::UNLIMITED_HOST_QUOTA;
|
||||
}
|
||||
const int requested = hostRequestLimits.value(host, devCap);
|
||||
return SettingsCache::instance().downloads().clampHostRequestLimit(host, requested);
|
||||
}
|
||||
|
||||
bool CardPictureLoaderWorker::isUnlockedHost(const QString &host) const
|
||||
{
|
||||
return hostAllowanceCeiling(host) == DownloadSettings::UNLIMITED_HOST_QUOTA && !hostRequestQuota.contains(host);
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorker::onHostRateLimited(const QString &host)
|
||||
{
|
||||
hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC) / 2));
|
||||
const int ceiling = hostAllowanceCeiling(host);
|
||||
// An unlocked host has no per-host allowance to halve. Install one instead so it drops out of
|
||||
// the unlocked fast path and is paced like a throttled host; the recovery loop in
|
||||
// resetRequestQuota() then walks it back up and unlocks it again.
|
||||
const int base =
|
||||
ceiling == DownloadSettings::UNLIMITED_HOST_QUOTA ? DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT : ceiling;
|
||||
hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, base) / 2));
|
||||
hostLast429.insert(host, QDateTime::currentDateTime());
|
||||
}
|
||||
|
||||
|
|
@ -228,6 +471,22 @@ QUrl CardPictureLoaderWorker::getCachedRedirect(const QUrl &originalUrl) const
|
|||
return {};
|
||||
}
|
||||
|
||||
QUrl CardPictureLoaderWorker::resolveCachedRedirect(const QUrl &url) const
|
||||
{
|
||||
// Follow the whole cached-redirect chain so dispatch keys on the host that is really hit. The
|
||||
// depth bound keeps a corrupt or self-referencing cache entry from spinning us forever.
|
||||
QUrl resolved = url;
|
||||
int depth = 0;
|
||||
while (depth++ < MAX_REDIRECT_CHAIN_DEPTH) {
|
||||
QUrl target = getCachedRedirect(resolved);
|
||||
if (target.isEmpty() || target == resolved) {
|
||||
break;
|
||||
}
|
||||
resolved = target;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorker::loadRedirectCache()
|
||||
{
|
||||
QSettings settings(cacheFilePath, QSettings::IniFormat);
|
||||
|
|
@ -280,4 +539,5 @@ void CardPictureLoaderWorker::clearNetworkCache()
|
|||
{
|
||||
networkManager->cache()->clear();
|
||||
redirectCache.clear();
|
||||
emit networkCacheCleared();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,10 +74,37 @@ public:
|
|||
*/
|
||||
void onHostRateLimited(const QString &host);
|
||||
|
||||
/** @brief Clears the network cache and redirect cache. */
|
||||
void clearNetworkCache();
|
||||
/**
|
||||
* @brief Stops the worker thread and reports whether it stopped.
|
||||
*
|
||||
* Called from the owning thread (CardPictureLoader) on its way out. QThread::quit() posts an
|
||||
* exit request to the worker's event loop and QThread::wait() blocks (bounded) until the loop
|
||||
* has returned and the thread finished. Only QThread members are touched here, so this method
|
||||
* is safe to call from the owning thread. The worker object itself is freed by the finished()
|
||||
* -> deleteLater chain (see the constructor); the QThread object is deleted afterwards by the
|
||||
* owner (CardPictureLoader::~CardPictureLoader), not by this method.
|
||||
*
|
||||
* @return true if the thread stopped within the timeout, false if it is still running (in
|
||||
* which case the owner must not delete the QThread).
|
||||
*/
|
||||
[[nodiscard]] bool shutdownThread();
|
||||
|
||||
/**
|
||||
* @brief Returns the worker's QThread.
|
||||
* @return The worker thread
|
||||
*
|
||||
* Only meaningful while the worker object is alive; capture it before calling shutdownThread().
|
||||
*/
|
||||
QThread *workerThread() const;
|
||||
|
||||
public slots:
|
||||
/**
|
||||
* @brief Clears the network cache and redirect cache.
|
||||
*
|
||||
* Runs on the worker thread; invoke it via a queued call when coming from another thread,
|
||||
* since both caches are owned by the worker thread.
|
||||
*/
|
||||
void clearNetworkCache();
|
||||
/**
|
||||
* @brief Makes a network request for the given URL using the specified worker.
|
||||
* @param url URL to load
|
||||
|
|
@ -86,9 +113,12 @@ public slots:
|
|||
*/
|
||||
QNetworkReply *makeRequest(const QUrl &url, CardPictureLoaderWorkerWork *workThread);
|
||||
|
||||
/** @brief Processes all queued requests respecting the request quota. */
|
||||
/** @brief Ensures the pacing and quota-reset timers reflect the current queue and recovery state. */
|
||||
void processQueuedRequests();
|
||||
|
||||
/** @brief Chooses a request from the queue and starts it, respecting the quota and pacing. */
|
||||
void dispatchQueuedRequest();
|
||||
|
||||
/**
|
||||
* @brief Processes a single queued request.
|
||||
* @return true if a request was processed, false if queue is empty.
|
||||
|
|
@ -118,18 +148,56 @@ private:
|
|||
bool picDownload; ///< Whether downloading images from network is enabled
|
||||
QQueue<QPair<QUrl, CardPictureLoaderWorkerWork *>> requestLoadQueue; ///< Queue of pending network requests
|
||||
|
||||
int requestQuota; ///< Remaining requests allowed per second
|
||||
QTimer requestTimer; ///< Timer to reset the request quota
|
||||
QTimer dispatchTimer; ///< Timer pacing individual network requests
|
||||
QHash<QString, int> hostRequestQuota; ///< Sustained per-host request allowance
|
||||
QHash<QString, int> hostRequestLimits; ///< User-set per-host request allowances
|
||||
QHash<QString, int> hostQuotaRemaining; ///< Per-host allowance left in the current second
|
||||
QHash<QString, QDateTime> hostLast429; ///< When each host was last rate limited
|
||||
QHash<QString, int> hostInFlight; ///< Network replies currently in flight, per host
|
||||
|
||||
/** @brief Maximum concurrent in-flight network replies per host. */
|
||||
static constexpr int MAX_IN_FLIGHT_PER_HOST = 6;
|
||||
|
||||
/** @brief Bound on how many cached-redirect hops dispatch resolution will follow. */
|
||||
static constexpr int MAX_REDIRECT_CHAIN_DEPTH = 10;
|
||||
|
||||
CardPictureLoaderLocal *localLoader; ///< Loader for local images
|
||||
QSet<QString> currentlyLoading; ///< Deduplication: contains pixmapCacheKey currently being loaded
|
||||
|
||||
/**
|
||||
* @brief Effective per-host allowance ceiling for a host.
|
||||
* @param host The host to look up
|
||||
* @return The allowance ceiling in requests/second, or DownloadSettings::UNLIMITED_HOST_QUOTA
|
||||
* when the developer unlocked the host and no user limit is set for it.
|
||||
*/
|
||||
[[nodiscard]] int hostAllowanceCeiling(const QString &host) const;
|
||||
|
||||
/**
|
||||
* @brief Whether a host may skip dispatch pacing and per-host allowance entirely.
|
||||
*
|
||||
* A host is unlocked while it has no user limit and no reduced allowance installed by a 429.
|
||||
* A 429 drops it out of the fast path until resetRequestQuota() walks the allowance back up.
|
||||
*/
|
||||
[[nodiscard]] bool isUnlockedHost(const QString &host) const;
|
||||
|
||||
/** @brief Returns cached redirect URL for the given original URL, if available. */
|
||||
[[nodiscard]] QUrl getCachedRedirect(const QUrl &originalUrl) const;
|
||||
|
||||
/** @brief Whether a request for this URL would actually touch the network, rather than being served from the disk
|
||||
* cache. */
|
||||
[[nodiscard]] bool requestTouchesNetwork(const QUrl &url) const;
|
||||
|
||||
/**
|
||||
* @brief Follows the cached-redirect chain to the URL that will actually be requested.
|
||||
* @param url The URL to resolve
|
||||
* @return The final URL after chasing cached redirects, or @p url itself if none lead elsewhere
|
||||
*
|
||||
* Dispatch decisions (unlocked-host fast path, 429 backoff, in-flight cap) must key on the host
|
||||
* a request really goes to, not the URL that merely redirects to it.
|
||||
*/
|
||||
[[nodiscard]] QUrl resolveCachedRedirect(const QUrl &url) const;
|
||||
|
||||
/** @brief Loads redirect cache from disk. */
|
||||
void loadRedirectCache();
|
||||
|
||||
|
|
@ -139,6 +207,9 @@ private:
|
|||
/** @brief Removes stale redirect entries older than TTL. */
|
||||
void cleanStaleEntries();
|
||||
|
||||
/** @brief Starts or stops the pacing and quota-reset timers to match the queue and recovery state. */
|
||||
void updateTimerState();
|
||||
|
||||
private slots:
|
||||
/** @brief Resets the request quota for rate-limiting. */
|
||||
void resetRequestQuota();
|
||||
|
|
@ -158,6 +229,9 @@ signals:
|
|||
|
||||
/** @brief Emitted when a network request successfully completes. */
|
||||
void imageRequestSucceeded(const QUrl &url);
|
||||
|
||||
/** @brief Emitted after clearNetworkCache() has finished clearing both caches. */
|
||||
void networkCacheCleared();
|
||||
};
|
||||
|
||||
#endif // PICTURE_LOADER_WORKER_H
|
||||
|
|
|
|||
|
|
@ -22,8 +22,13 @@ static const QStringList MD5_BLACKLIST = {
|
|||
"fbc7d763c08771c260b39e2115414eeb" // Current card back hash
|
||||
};
|
||||
|
||||
CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad)
|
||||
: QObject(nullptr), cardToDownload(CardPictureToLoad(toLoad)),
|
||||
const ServerRateLimiter &CardPictureLoaderWorkerWork::rateLimiter()
|
||||
{
|
||||
return s_rateLimiter;
|
||||
}
|
||||
|
||||
CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(CardPictureLoaderWorker *worker, const ExactCard &toLoad)
|
||||
: QObject(worker), cardToDownload(CardPictureToLoad(toLoad)),
|
||||
picDownload(SettingsCache::instance().downloads().getPicDownload())
|
||||
{
|
||||
// Hook up signals to the orchestrator
|
||||
|
|
@ -168,7 +173,7 @@ void CardPictureLoaderWorkerWork::handleFailedReply(const QNetworkReply *reply)
|
|||
<< "PictureLoader: [card: " << cardToDownload.getCard().getName()
|
||||
<< " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host
|
||||
<< ", backing off until " << backoffUntil.toString(Qt::ISODate) << ", retrying the same url";
|
||||
scheduleDeferredRetry();
|
||||
scheduleDeferredRetry(host);
|
||||
} else {
|
||||
qCWarning(CardPictureLoaderWorkerWorkLog).nospace()
|
||||
<< "PictureLoader: [card: " << cardToDownload.getCard().getName()
|
||||
|
|
@ -273,14 +278,16 @@ QImage CardPictureLoaderWorkerWork::tryLoadImageFromReply(QNetworkReply *reply)
|
|||
return imgReader.read();
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorkerWork::scheduleDeferredRetry()
|
||||
void CardPictureLoaderWorkerWork::scheduleDeferredRetry(const QString &preferredHost)
|
||||
{
|
||||
QDateTime now = QDateTime::currentDateTime();
|
||||
|
||||
// Prefer waiting on the current URL's server so we retry the same source.
|
||||
QString currentHost = QUrl(cardToDownload.getCurrentUrl()).host();
|
||||
QDateTime backoffUntil = s_rateLimiter.deadline(currentHost);
|
||||
if (!s_rateLimiter.isRateLimited(currentHost, now)) {
|
||||
// Prefer waiting on the server that is actually blocking the request: callers hand in the
|
||||
// rate-limited host when it differs from the current URL (e.g. a cached redirect target still
|
||||
// in backoff), otherwise fall back to the current URL's server so we retry the same source.
|
||||
QString waitHost = preferredHost.isEmpty() ? QUrl(cardToDownload.getCurrentUrl()).host() : preferredHost;
|
||||
QDateTime backoffUntil = s_rateLimiter.deadline(waitHost);
|
||||
if (!s_rateLimiter.isRateLimited(waitHost, now)) {
|
||||
backoffUntil = s_rateLimiter.earliestDeadline(now);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QRandomGenerator>
|
||||
#include <QString>
|
||||
#include <QThread>
|
||||
#include <libcockatrice/card/database/card_database.h>
|
||||
#include <libcockatrice/utility/server_rate_limiter.h>
|
||||
|
|
@ -36,13 +37,36 @@ class CardPictureLoaderWorkerWork : public QObject
|
|||
public:
|
||||
/**
|
||||
* @brief Constructs a worker for downloading a specific card image.
|
||||
* @param worker The orchestrating CardPictureLoaderWorker
|
||||
* @param worker The orchestrating CardPictureLoaderWorker; the work object becomes its child so
|
||||
* it is destroyed with the worker even if it never reaches concludeImageLoad().
|
||||
* @param toLoad The ExactCard to download
|
||||
*/
|
||||
explicit CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad);
|
||||
explicit CardPictureLoaderWorkerWork(CardPictureLoaderWorker *worker, const ExactCard &toLoad);
|
||||
|
||||
CardPictureToLoad cardToDownload; ///< The card and associated URLs to try downloading
|
||||
|
||||
/** @brief Shared per-server 429 backoff state. */
|
||||
static const ServerRateLimiter &rateLimiter();
|
||||
|
||||
/**
|
||||
* @brief Starts downloading the next URL for this card.
|
||||
*
|
||||
* Skips URLs whose server is currently in 429 backoff, either waiting the
|
||||
* backoff out or falling through to the other configured sources.
|
||||
*/
|
||||
void startNextPicDownload();
|
||||
|
||||
/**
|
||||
* @brief Schedules a deferred retry after the relevant server backoff expires.
|
||||
* @param preferredHost The server that is actually blocking the request, or an empty
|
||||
* string to use the current URL's server
|
||||
*
|
||||
* Waits on the blocking server's backoff deadline, otherwise on the earliest active
|
||||
* backoff. If no servers are in backoff, concludes with failure. Otherwise resets the
|
||||
* CardPictureToLoad indices and retries after the backoff period.
|
||||
*/
|
||||
void scheduleDeferredRetry(const QString &preferredHost = {});
|
||||
|
||||
public slots:
|
||||
/**
|
||||
* @brief Handles a finished network reply for the card image.
|
||||
|
|
@ -55,9 +79,6 @@ private:
|
|||
|
||||
static ServerRateLimiter s_rateLimiter; ///< Shared per-server 429 backoff state
|
||||
|
||||
/** @brief Starts downloading the next URL for this card. */
|
||||
void startNextPicDownload();
|
||||
|
||||
/** @brief Called when all URLs have been exhausted or download failed. */
|
||||
void picDownloadFailed();
|
||||
|
||||
|
|
@ -82,16 +103,6 @@ private:
|
|||
*/
|
||||
void concludeImageLoad(const QImage &image);
|
||||
|
||||
/**
|
||||
* @brief Schedules a deferred retry after the relevant server backoff expires.
|
||||
*
|
||||
* Waits on the current URL's server when it is the reason we are blocked,
|
||||
* otherwise on the earliest active backoff. If no servers are in backoff,
|
||||
* concludes with failure. Otherwise resets the CardPictureToLoad indices and
|
||||
* retries after the backoff period.
|
||||
*/
|
||||
void scheduleDeferredRetry();
|
||||
|
||||
private slots:
|
||||
/** @brief Updates the picDownload setting when it changes. */
|
||||
void picDownloadChanged();
|
||||
|
|
|
|||
|
|
@ -94,8 +94,7 @@ void CardPictureToLoad::populateSetUrls()
|
|||
}
|
||||
}
|
||||
|
||||
const QStringList orderedTemplates = urlTemplates;
|
||||
for (const QString &urlTemplate : orderedTemplates) {
|
||||
for (const QString &urlTemplate : urlTemplates) {
|
||||
QString transformedUrl = transformUrl(urlTemplate);
|
||||
|
||||
if (!transformedUrl.isEmpty()) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
#ifndef COCKATRICE_CONTEXT_OPEN_DECK_H
|
||||
#define COCKATRICE_CONTEXT_OPEN_DECK_H
|
||||
|
||||
#include "context_connect_to_server.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
struct ContextOpenDeck
|
||||
{
|
||||
ContextConnectToServer serverContext;
|
||||
QString shareToken;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_CONTEXT_OPEN_DECK_H
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <QDialog>
|
||||
|
||||
IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context,
|
||||
bool _promptForMissingCredentials)
|
||||
: Intent(), context(_context), promptForMissingCredentials(_promptForMissingCredentials)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -29,5 +34,46 @@ void IntentGetLoginCredentials::onPreconditionSatisfied()
|
|||
|
||||
void IntentGetLoginCredentials::onPreconditionNotSatisfied()
|
||||
{
|
||||
// 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
208
cockatrice/src/interface/intents/intent_open_shared_deck.cpp
Normal file
|
|
@ -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 <QMessageBox>
|
||||
#include <QTimer>
|
||||
#include <libcockatrice/card/database/card_database_querier.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_share_download.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_share_list.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_deck_share_download.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_deck_share_list.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_deck_share_item.pb.h>
|
||||
#include <libcockatrice/protocol/pending_command.h>
|
||||
|
||||
IntentOpenSharedDeck::IntentOpenSharedDeck(TabSupervisor *_tabSupervisor,
|
||||
RemoteClient *_remoteClient,
|
||||
const CardDatabaseQuerier *_querier,
|
||||
std::unique_ptr<ContextOpenDeck> _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<ServerInfo_DeckShareItem> 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<int> &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<LoadedDeck> 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();
|
||||
}
|
||||
60
cockatrice/src/interface/intents/intent_open_shared_deck.h
Normal file
|
|
@ -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 <QList>
|
||||
#include <QMap>
|
||||
#include <QScopedPointer>
|
||||
#include <memory>
|
||||
|
||||
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<ContextOpenDeck> _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<int> &itemIds);
|
||||
void downloadNextItem();
|
||||
void onItemFailure(const QString &reason);
|
||||
void finishAll();
|
||||
|
||||
TabSupervisor *tabSupervisor;
|
||||
RemoteClient *remoteClient;
|
||||
const CardDatabaseQuerier *querier;
|
||||
QScopedPointer<ContextOpenDeck> context;
|
||||
DlgSharedDecksPreview *previewDialog = nullptr;
|
||||
QTimer *downloadTimer;
|
||||
QMap<int, QString> itemNames;
|
||||
QList<int> pendingItemIds;
|
||||
QList<LoadedDeck> loadedDecks;
|
||||
bool listPhase = true;
|
||||
int currentItemId = 0;
|
||||
int totalItems = 0;
|
||||
int completedItems = 0;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_INTENT_OPEN_SHARED_DECK_H
|
||||
|
|
@ -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 <QDebug>
|
||||
#include <QLoggingCategory>
|
||||
#include <QMessageBox>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||
#include <libcockatrice/settings/servers_settings.h>
|
||||
#include <memory>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
|
||||
pendingChains.append(chain);
|
||||
startNextChain();
|
||||
}
|
||||
|
||||
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);
|
||||
chain.intents.append(joinGameIntent);
|
||||
connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
|
||||
|
||||
auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext);
|
||||
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(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
|
||||
connect(getLoginCredentialsIntent, &Intent::cancelled, joinGameIntent, &Intent::cancelled);
|
||||
firstIntent = getLoginCredentialsIntent;
|
||||
}
|
||||
|
||||
getLoginCredentialsIntent->execute();
|
||||
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<ContextOpenDeck>();
|
||||
|
||||
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<QMetaObject::Connection>();
|
||||
*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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,46 @@
|
|||
#ifndef COCKATRICE_URL_PARSER_H
|
||||
#define COCKATRICE_URL_PARSER_H
|
||||
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QUrlQuery>
|
||||
|
||||
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<Intent *> 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<PendingIntentChain> 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
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
#define DEFAULT_COLOR_MODERATOR_LEFT "#ffffff";
|
||||
#define DEFAULT_COLOR_MODERATOR_RIGHT "#000000";
|
||||
#define DEFAULT_COLOR_ADMIN "#ff2701";
|
||||
#define DEFAULT_COLOR_DEVELOPER "#B8B8B8"
|
||||
|
||||
/**
|
||||
* Clamps an svg render size so that rendering does not exceed a multiple of the requested size.
|
||||
|
|
@ -362,6 +361,10 @@ static QString getIconType(const bool isBuddy, const UserLevelFlags &userLevelFl
|
|||
return "pawn_judge";
|
||||
}
|
||||
|
||||
if (userLevelFlags.testFlag(ServerInfo_User::IsDeveloper)) {
|
||||
return "pawn_dev";
|
||||
}
|
||||
|
||||
if (!privLevel.isEmpty() && privLevel.toLower() != "none") {
|
||||
return QString("pawn_%1").arg(privLevel.toLower());
|
||||
}
|
||||
|
|
@ -382,8 +385,6 @@ QIcon UserLevelPixmapGenerator::generateIconDefault(int height,
|
|||
|
||||
if (userLevel.testFlag(ServerInfo_User::IsAdmin)) {
|
||||
colorLeft = DEFAULT_COLOR_ADMIN;
|
||||
} else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) {
|
||||
colorLeft = DEFAULT_COLOR_DEVELOPER;
|
||||
} else if (userLevel.testFlag(ServerInfo_User::IsModerator)) {
|
||||
colorLeft = DEFAULT_COLOR_MODERATOR_LEFT;
|
||||
colorRight = DEFAULT_COLOR_MODERATOR_RIGHT;
|
||||
|
|
|
|||
|
|
@ -95,9 +95,11 @@ struct PaletteColorInfo
|
|||
|
||||
static QString usableDefaultStyle(const QString &style)
|
||||
{
|
||||
// The Windows 11 native style is broken: when the OS default
|
||||
// ("System" theme selection) would use it, fall back to the Vista style.
|
||||
// Explicitly choosing "windows11" in a theme is still honored.
|
||||
// The Windows 11 native style is broken: dragging cards across zones can
|
||||
// shrink the board to a tiny grey window that is unfixable without
|
||||
// rejoining. It is never usable, so guard against it no matter how it was
|
||||
// requested (OS default or an explicit "windows11" theme choice) and fall
|
||||
// back to the Vista style.
|
||||
return style.compare("windows11", Qt::CaseInsensitive) == 0 ? QStringLiteral("windowsvista") : style;
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +111,10 @@ ThemeManager::ThemeManager(QObject *parent) : QObject(parent)
|
|||
ensureThemeDirectoryExists();
|
||||
#if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0))
|
||||
connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] {
|
||||
defaultPalette = qApp->palette();
|
||||
// Reload so scheme-qualified assets and palettes follow the OS, but do
|
||||
// NOT recapture defaultPalette: qApp->palette() already carries the
|
||||
// currently-applied theme palette at this point, so recapturing it
|
||||
// would contaminate the base for every later theme switch.
|
||||
themeChangedSlot();
|
||||
});
|
||||
#endif
|
||||
|
|
@ -407,6 +412,10 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName,
|
|||
}
|
||||
}
|
||||
|
||||
// The Windows 11 style is broken even when selected explicitly in a theme,
|
||||
// so sanitize the resolved name here rather than trusting the theme config.
|
||||
styleName = usableDefaultStyle(styleName);
|
||||
|
||||
QStyle *style = QStyleFactory::create(styleName);
|
||||
if (!style) {
|
||||
style = QStyleFactory::create(usableDefaultStyle(defaultStyleName));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
#include "deck_color_identity.h"
|
||||
|
||||
#include <QSet>
|
||||
#include <libcockatrice/card/database/card_database_querier.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
|
||||
QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db)
|
||||
{
|
||||
const QStringList cardList = deck.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE});
|
||||
if (cardList.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QSet<QChar> 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
#ifndef COCKATRICE_DECK_COLOR_IDENTITY_H
|
||||
#define COCKATRICE_DECK_COLOR_IDENTITY_H
|
||||
|
||||
#include <QString>
|
||||
|
||||
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
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
#include "card_group_display_widget.h"
|
||||
|
||||
#include "../../../../client/settings/cache_settings.h"
|
||||
#include "../card_info_picture_with_text_overlay_widget.h"
|
||||
|
||||
#include <QResizeEvent>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
||||
#include <libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
|
||||
CardGroupDisplayWidget::CardGroupDisplayWidget(QWidget *parent,
|
||||
DeckListModel *_deckListModel,
|
||||
|
|
@ -30,6 +32,7 @@ CardGroupDisplayWidget::CardGroupDisplayWidget(QWidget *parent,
|
|||
layout->addWidget(banner);
|
||||
|
||||
CardGroupDisplayWidget::updateCardDisplays();
|
||||
updateCardCount();
|
||||
|
||||
connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &CardGroupDisplayWidget::onCardAddition);
|
||||
if (selectionModel) {
|
||||
|
|
@ -38,6 +41,13 @@ CardGroupDisplayWidget::CardGroupDisplayWidget(QWidget *parent,
|
|||
}
|
||||
connect(deckListModel, &QAbstractItemModel::rowsRemoved, this, &CardGroupDisplayWidget::onCardRemoval);
|
||||
connect(deckListModel, &QAbstractItemModel::dataChanged, this, &CardGroupDisplayWidget::onDataChanged);
|
||||
connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &CardGroupDisplayWidget::updateCardCount);
|
||||
connect(deckListModel, &QAbstractItemModel::rowsRemoved, this, &CardGroupDisplayWidget::updateCardCount);
|
||||
connect(deckListModel, &QAbstractItemModel::dataChanged, this, &CardGroupDisplayWidget::updateCardCount);
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::visualDeckEditorShowCardCountsChanged,
|
||||
this, &CardGroupDisplayWidget::updateCardCount);
|
||||
|
||||
cardSizeWidget->enableCtrlScrollResize(this);
|
||||
}
|
||||
|
||||
// Just here so it can get overwritten in subclasses.
|
||||
|
|
@ -349,3 +359,21 @@ void CardGroupDisplayWidget::onActiveSortCriteriaChanged(QStringList _activeSort
|
|||
clearAllDisplayWidgets();
|
||||
updateCardDisplays();
|
||||
}
|
||||
|
||||
void CardGroupDisplayWidget::updateCardCount()
|
||||
{
|
||||
if (!banner || !deckListModel || !trackedIndex.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QString text = cardGroupCategory;
|
||||
if (SettingsCache::instance().cardsDisplay().getVisualDeckEditorShowCardCounts()) {
|
||||
int total = 0;
|
||||
for (int i = 0; i < deckListModel->rowCount(trackedIndex); ++i) {
|
||||
total +=
|
||||
deckListModel->index(i, DeckListModelColumns::CARD_AMOUNT, trackedIndex).data(Qt::EditRole).toInt();
|
||||
}
|
||||
text += QStringLiteral(" (%1)").arg(total);
|
||||
}
|
||||
banner->setText(text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ public slots:
|
|||
virtual void onCardRemoval(const QModelIndex &parent, int first, int last);
|
||||
void onDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector<int> &roles);
|
||||
void onActiveSortCriteriaChanged(QStringList activeSortCriteria);
|
||||
void updateCardCount();
|
||||
void resizeEvent(QResizeEvent *event) override;
|
||||
|
||||
signals:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
#include "../printing_selector/printing_selector.h"
|
||||
#include "../visual_deck_storage/visual_deck_storage_widget.h"
|
||||
|
||||
#include <QScrollArea>
|
||||
#include <QWheelEvent>
|
||||
|
||||
/**
|
||||
* @class CardSizeWidget
|
||||
* @brief A widget for adjusting card sizes using a slider.
|
||||
|
|
@ -58,3 +61,39 @@ QSlider *CardSizeWidget::getSlider() const
|
|||
{
|
||||
return cardSizeSlider;
|
||||
}
|
||||
|
||||
void CardSizeWidget::enableCtrlScrollResize(QWidget *host)
|
||||
{
|
||||
host->installEventFilter(this);
|
||||
|
||||
const auto scrollAreas = host->findChildren<QScrollArea *>();
|
||||
for (QScrollArea *scrollArea : scrollAreas) {
|
||||
if (QWidget *content = scrollArea->widget()) {
|
||||
content->installEventFilter(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool CardSizeWidget::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (event->type() == QEvent::Wheel && adjustSliderForWheel(static_cast<QWheelEvent *>(event))) {
|
||||
return true;
|
||||
}
|
||||
return QWidget::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
bool CardSizeWidget::adjustSliderForWheel(QWheelEvent *event)
|
||||
{
|
||||
if (!(event->modifiers() & Qt::ControlModifier)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const int angleDelta = event->angleDelta().y();
|
||||
if (angleDelta == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const int wheelSteps = angleDelta > 0 ? qMax(1, angleDelta / 120) : qMin(-1, angleDelta / 120);
|
||||
cardSizeSlider->setValue(cardSizeSlider->value() + wheelSteps * CARD_SIZE_WHEEL_STEP);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@
|
|||
#include <QTimer>
|
||||
#include <QWidget>
|
||||
|
||||
class QWheelEvent;
|
||||
|
||||
class CardSizeWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
|
@ -25,6 +27,18 @@ public:
|
|||
explicit CardSizeWidget(QWidget *parent, FlowWidget *flowWidget = nullptr, int defaultValue = 100);
|
||||
[[nodiscard]] QSlider *getSlider() const;
|
||||
|
||||
/**
|
||||
* @brief Resizes the cards when the user Ctrl + scrolls over @p host or any of its descendants.
|
||||
*
|
||||
* Installs this widget as an event filter on the given host widget. If the host contains a
|
||||
* scroll area the filter is also installed on the scroll area's content widget, so the resize
|
||||
* intercepts the wheel event before the scroll area would scroll the view.
|
||||
*/
|
||||
void enableCtrlScrollResize(QWidget *host);
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void updateCardSizeSetting(int newValue);
|
||||
|
||||
|
|
@ -44,6 +58,10 @@ private:
|
|||
QSlider *cardSizeSlider;
|
||||
QTimer debounceTimer; // Debounce timer
|
||||
int pendingValue; // Stores the latest slider value
|
||||
|
||||
bool adjustSliderForWheel(QWheelEvent *event);
|
||||
|
||||
static constexpr int CARD_SIZE_WHEEL_STEP = 10; ///< Slider step applied per Ctrl + scroll notch.
|
||||
};
|
||||
|
||||
#endif // CARD_SIZE_WIDGET_H
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
#include "deck_card_zone_display_widget.h"
|
||||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "card_group_display_widgets/flat_card_group_display_widget.h"
|
||||
#include "card_group_display_widgets/overlapped_card_group_display_widget.h"
|
||||
#include "libcockatrice/card/database/card_database_manager.h"
|
||||
#include "libcockatrice/settings/cards_display_settings.h"
|
||||
|
||||
#include <QResizeEvent>
|
||||
#include <algorithm>
|
||||
|
|
@ -39,6 +41,7 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
|
|||
banner->setBuddy(cardGroupContainer);
|
||||
|
||||
displayCards();
|
||||
updateZoneCardCount();
|
||||
|
||||
connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &DeckCardZoneDisplayWidget::onCategoryAddition);
|
||||
if (selectionModel) {
|
||||
|
|
@ -46,6 +49,11 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
|
|||
&DeckCardZoneDisplayWidget::onSelectionChanged);
|
||||
}
|
||||
connect(deckListModel, &QAbstractItemModel::rowsRemoved, this, &DeckCardZoneDisplayWidget::onCategoryRemoval);
|
||||
connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &DeckCardZoneDisplayWidget::updateZoneCardCount);
|
||||
connect(deckListModel, &QAbstractItemModel::rowsRemoved, this, &DeckCardZoneDisplayWidget::updateZoneCardCount);
|
||||
connect(deckListModel, &QAbstractItemModel::dataChanged, this, &DeckCardZoneDisplayWidget::updateZoneCardCount);
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::visualDeckEditorShowCardCountsChanged,
|
||||
this, &DeckCardZoneDisplayWidget::updateZoneCardCount);
|
||||
}
|
||||
|
||||
// =====================================================================================================================
|
||||
|
|
@ -245,3 +253,21 @@ QList<QString> DeckCardZoneDisplayWidget::getGroupCriteriaValueList()
|
|||
|
||||
return groupCriteriaValues;
|
||||
}
|
||||
|
||||
void DeckCardZoneDisplayWidget::updateZoneCardCount()
|
||||
{
|
||||
if (!banner || !deckListModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
QString text = zoneName;
|
||||
if (SettingsCache::instance().cardsDisplay().getVisualDeckEditorShowCardCounts()) {
|
||||
int total = 0;
|
||||
const auto cardNodes = deckListModel->getCardNodesForZone(zoneName);
|
||||
for (const auto *node : cardNodes) {
|
||||
total += node->getNumber();
|
||||
}
|
||||
text += QStringLiteral(" (%1)").arg(total);
|
||||
}
|
||||
banner->setText(text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ public slots:
|
|||
QList<QString> getGroupCriteriaValueList();
|
||||
void onCategoryAddition(const QModelIndex &parent, int first, int last);
|
||||
void onCategoryRemoval(const QModelIndex &parent, int first, int last);
|
||||
void updateZoneCardCount();
|
||||
|
||||
signals:
|
||||
void cardClicked(QMouseEvent *event, const ExactCard &card, const QString &zoneName);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@
|
|||
#include <QHeaderView>
|
||||
#include <QToolButton>
|
||||
#include <QTreeView>
|
||||
#include <libcockatrice/card/card_localization.h>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/card/relation/card_relation.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
|
||||
DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent, CardDatabaseModel *databaseModel)
|
||||
: QWidget(parent)
|
||||
|
|
@ -40,6 +42,17 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent
|
|||
databaseDisplayModel->setSourceModel(databaseModel);
|
||||
databaseDisplayModel->setFilterKeyColumn(0);
|
||||
|
||||
const auto applyCardSearchLanguage = [this]() {
|
||||
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();
|
||||
databaseDisplayModel->setSearchLanguage(CardSearchLanguage{
|
||||
cardsDisplay.getCardLang(), static_cast<SearchLanguageMode>(cardsDisplay.getCardSearchLanguage())});
|
||||
};
|
||||
applyCardSearchLanguage();
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
|
||||
applyCardSearchLanguage);
|
||||
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this,
|
||||
applyCardSearchLanguage);
|
||||
|
||||
databaseView = new CardDatabaseView(this, databaseDisplayModel);
|
||||
databaseView->setObjectName("databaseView");
|
||||
databaseView->setFocusProxy(searchEdit);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
#include "deck_share_utils.h"
|
||||
|
||||
#include <QClipboard>
|
||||
#include <QGuiApplication>
|
||||
#include <QLocale>
|
||||
#include <QTimeZone>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
|
||||
|
||||
namespace DeckShareUtils
|
||||
{
|
||||
|
||||
QString buildShareLink(const AbstractClient *client, const QString &token)
|
||||
{
|
||||
QUrl url;
|
||||
url.setScheme(QStringLiteral("cockatrice"));
|
||||
url.setHost(QStringLiteral("opendeck"));
|
||||
|
||||
QUrlQuery query;
|
||||
query.addQueryItem(QStringLiteral("share"), token);
|
||||
query.addQueryItem(QStringLiteral("hostname"), client->serverName());
|
||||
query.addQueryItem(QStringLiteral("port"), QString::number(client->serverPort()));
|
||||
url.setQuery(query);
|
||||
|
||||
return url.toString(QUrl::FullyEncoded);
|
||||
}
|
||||
|
||||
QString copyShareLinkToClipboard(const QString &link)
|
||||
{
|
||||
QGuiApplication::clipboard()->setText(link);
|
||||
return link;
|
||||
}
|
||||
|
||||
QString formatShareExpiry(const QDateTime &expiry)
|
||||
{
|
||||
return QLocale().toString(expiry.toLocalTime(), QLocale::ShortFormat);
|
||||
}
|
||||
|
||||
ShareResponse handleShareResponse(const AbstractClient *client, const Response &response)
|
||||
{
|
||||
const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext);
|
||||
const QString token = QString::fromStdString(resp.token());
|
||||
|
||||
const QString link = buildShareLink(client, token);
|
||||
copyShareLinkToClipboard(link);
|
||||
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
|
||||
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC);
|
||||
#else
|
||||
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), Qt::UTC);
|
||||
#endif
|
||||
|
||||
return {link, expiry};
|
||||
}
|
||||
|
||||
} // namespace DeckShareUtils
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* @file deck_share_utils.h
|
||||
* @ingroup DeckShareWidgets
|
||||
*/
|
||||
//! \todo Document this file.
|
||||
|
||||
#ifndef DECK_SHARE_UTILS_H
|
||||
#define DECK_SHARE_UTILS_H
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
|
||||
class AbstractClient;
|
||||
class Response;
|
||||
|
||||
/**
|
||||
* @brief Shared helpers for creating temporary deck shares.
|
||||
*/
|
||||
namespace DeckShareUtils
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief The outcome of a successful share-create response.
|
||||
*/
|
||||
struct ShareResponse
|
||||
{
|
||||
QString link; ///< The share link that was copied to the clipboard.
|
||||
QDateTime expiry; ///< When the share expires (UTC).
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Builds the cockatrice:// link for a freshly created deck share.
|
||||
* @param client Used to embed the target server's hostname and port.
|
||||
* @param token The share token from Response_DeckShareCreate.
|
||||
*/
|
||||
QString buildShareLink(const AbstractClient *client, const QString &token);
|
||||
|
||||
/**
|
||||
* @brief Copies the share link to the clipboard.
|
||||
* @return The link that was copied.
|
||||
*/
|
||||
QString copyShareLinkToClipboard(const QString &link);
|
||||
|
||||
/**
|
||||
* @brief Formats the expiration timestamp for a share.
|
||||
*/
|
||||
QString formatShareExpiry(const QDateTime &expiry);
|
||||
|
||||
/**
|
||||
* @brief Handles a successful Response_DeckShareCreate: builds the share link,
|
||||
* copies it to the clipboard, and derives the share expiry.
|
||||
* @param client Used to embed the target server's hostname and port.
|
||||
* @param response The successful response carrying the share token and expiry.
|
||||
*/
|
||||
ShareResponse handleShareResponse(const AbstractClient *client, const Response &response);
|
||||
|
||||
} // namespace DeckShareUtils
|
||||
|
||||
#endif // DECK_SHARE_UTILS_H
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
#include "share_bar_widget.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
|
||||
ShareBarWidget::ShareBarWidget(QWidget *parent) : QWidget(parent)
|
||||
{
|
||||
auto *layout = new QHBoxLayout(this);
|
||||
layout->setContentsMargins(12, 10, 12, 10);
|
||||
layout->setSpacing(8);
|
||||
|
||||
hintLabel = new QLabel(this);
|
||||
hintLabel->setWordWrap(true);
|
||||
|
||||
nameEdit = new QLineEdit(this);
|
||||
nameEdit->setMaximumWidth(260);
|
||||
|
||||
countLabel = new QLabel(this);
|
||||
|
||||
cancelButton = new QPushButton(this);
|
||||
connect(cancelButton, &QPushButton::clicked, this, &ShareBarWidget::cancelRequested);
|
||||
|
||||
createButton = new QPushButton(this);
|
||||
createButton->setDefault(true);
|
||||
connect(createButton, &QPushButton::clicked, this, &ShareBarWidget::createRequested);
|
||||
|
||||
layout->addWidget(hintLabel, 1);
|
||||
layout->addWidget(nameEdit);
|
||||
layout->addWidget(countLabel);
|
||||
layout->addStretch();
|
||||
layout->addWidget(cancelButton);
|
||||
layout->addWidget(createButton);
|
||||
|
||||
setLayout(layout);
|
||||
|
||||
retranslateUi();
|
||||
}
|
||||
|
||||
void ShareBarWidget::retranslateUi()
|
||||
{
|
||||
nameEdit->setPlaceholderText(tr("Share name"));
|
||||
cancelButton->setText(tr("Cancel"));
|
||||
createButton->setText(tr("Create share link"));
|
||||
}
|
||||
|
||||
QString ShareBarWidget::name() const
|
||||
{
|
||||
return nameEdit->text().trimmed();
|
||||
}
|
||||
|
||||
void ShareBarWidget::setName(const QString &value)
|
||||
{
|
||||
nameEdit->setText(value);
|
||||
}
|
||||
|
||||
void ShareBarWidget::setCountText(const QString &text)
|
||||
{
|
||||
countLabel->setText(text);
|
||||
}
|
||||
|
||||
void ShareBarWidget::setHintText(const QString &text, bool visible)
|
||||
{
|
||||
hintLabel->setText(text);
|
||||
hintLabel->setVisible(visible);
|
||||
}
|
||||
|
||||
void ShareBarWidget::setCreateEnabled(bool enabled)
|
||||
{
|
||||
createButton->setEnabled(enabled);
|
||||
}
|
||||
|
||||
void ShareBarWidget::focusName()
|
||||
{
|
||||
nameEdit->setFocus();
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* @file share_bar_widget.h
|
||||
* @ingroup DeckShareWidgets
|
||||
*/
|
||||
//! \todo Document this file.
|
||||
|
||||
#ifndef SHARE_BAR_WIDGET_H
|
||||
#define SHARE_BAR_WIDGET_H
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QPushButton;
|
||||
|
||||
/**
|
||||
* @brief The activated toolbar used to create a temporary deck share.
|
||||
*
|
||||
* A single reusable component shared by the local visual deck storage and the
|
||||
* remote server deck storage tabs, so the share workflow renders identically in
|
||||
* both places. It owns its own widgets, strings, and layout; the owning tab only
|
||||
* sets the count/hint text and reacts to the create/cancel signals.
|
||||
*/
|
||||
class ShareBarWidget final : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ShareBarWidget(QWidget *parent = nullptr);
|
||||
|
||||
void retranslateUi();
|
||||
|
||||
/** @return The trimmed name entered by the user. */
|
||||
[[nodiscard]] QString name() const;
|
||||
|
||||
/** @brief Resets the name field to the given default. */
|
||||
void setName(const QString &name);
|
||||
|
||||
/** @brief Sets the selected-count summary label text. */
|
||||
void setCountText(const QString &text);
|
||||
|
||||
/** @brief Sets the explainer hint text, showing it when @p visible is true. */
|
||||
void setHintText(const QString &text, bool visible);
|
||||
|
||||
/** @brief Enables or disables the create-share-link button (guards double submission). */
|
||||
void setCreateEnabled(bool enabled);
|
||||
|
||||
/** @brief Moves keyboard focus to the name field. */
|
||||
void focusName();
|
||||
|
||||
signals:
|
||||
void createRequested();
|
||||
void cancelRequested();
|
||||
|
||||
private:
|
||||
QLabel *hintLabel;
|
||||
QLineEdit *nameEdit;
|
||||
QLabel *countLabel;
|
||||
QPushButton *cancelButton;
|
||||
QPushButton *createButton;
|
||||
};
|
||||
|
||||
#endif // SHARE_BAR_WIDGET_H
|
||||
|
|
@ -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 <QCheckBox>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QKeyEvent>
|
||||
#include <QLabel>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/card/database/card_database_querier.h>
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -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 <QWidget>
|
||||
|
||||
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
|
||||
|
|
@ -19,6 +19,9 @@ DlgEditAvatar::DlgEditAvatar(QWidget *parent) : QDialog(parent), image()
|
|||
|
||||
textLabel = new QLabel(tr("To change your avatar, choose a new image.\nTo remove your current avatar, confirm "
|
||||
"without choosing a new image."));
|
||||
hintLabel = new QLabel(
|
||||
tr("Images are automatically downscaled to at most %1x%1 pixels.").arg(QString::number(MAX_AVATAR_DIMENSION)));
|
||||
|
||||
browseButton = new QPushButton(tr("Browse..."));
|
||||
connect(browseButton, &QPushButton::clicked, this, &DlgEditAvatar::actBrowse);
|
||||
|
||||
|
|
@ -26,6 +29,7 @@ DlgEditAvatar::DlgEditAvatar(QWidget *parent) : QDialog(parent), image()
|
|||
grid->addWidget(imageLabel, 0, 0, 1, 2);
|
||||
grid->addWidget(textLabel, 1, 0);
|
||||
grid->addWidget(browseButton, 1, 1);
|
||||
grid->addWidget(hintLabel, 2, 0, 1, 2);
|
||||
|
||||
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgEditAvatar::actOk);
|
||||
|
|
@ -72,6 +76,10 @@ QByteArray DlgEditAvatar::getImage()
|
|||
return QByteArray();
|
||||
}
|
||||
|
||||
if (image.width() > MAX_AVATAR_DIMENSION || image.height() > MAX_AVATAR_DIMENSION) {
|
||||
image = image.scaled(MAX_AVATAR_DIMENSION, MAX_AVATAR_DIMENSION, Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
QByteArray ba;
|
||||
QBuffer buffer(&ba);
|
||||
|
|
|
|||
|
|
@ -29,8 +29,9 @@ private slots:
|
|||
void actBrowse();
|
||||
|
||||
private:
|
||||
static constexpr int MAX_AVATAR_DIMENSION = 1024;
|
||||
QImage image;
|
||||
QLabel *textLabel, *imageLabel;
|
||||
QLabel *textLabel, *imageLabel, *hintLabel;
|
||||
QPushButton *browseButton;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
#include "dlg_login_prompt.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
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();
|
||||
}
|
||||
40
cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h
Normal file
|
|
@ -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 <QDialog>
|
||||
|
||||
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
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/protocol/pb/command_report.pb.h>
|
||||
#include <libcockatrice/protocol/pending_command.h>
|
||||
#include <libcockatrice/utility/report_categories.h>
|
||||
|
||||
DlgReportUser::DlgReportUser(AbstractClient *_client,
|
||||
const QString &_reportedUser,
|
||||
|
|
@ -56,22 +57,14 @@ DlgReportUser::DlgReportUser(AbstractClient *_client,
|
|||
auto *categoryGrid = new QGridLayout(categoryGroup);
|
||||
|
||||
categoryBox = new QComboBox;
|
||||
categoryBox->addItem(tr("Cheating / Unsporting behavior"), "cheating");
|
||||
categoryBox->setItemData(categoryBox->count() - 1,
|
||||
tr("Using external tools, card marked manipulation, or exploiting game bugs"),
|
||||
Qt::ToolTipRole);
|
||||
categoryBox->addItem(tr("Harassment / Abuse"), "harassment");
|
||||
categoryBox->setItemData(categoryBox->count() - 1, tr("Threatening, bullying, or persistent unwanted contact"),
|
||||
Qt::ToolTipRole);
|
||||
categoryBox->addItem(tr("Hate speech"), "hate_speech");
|
||||
categoryBox->setItemData(categoryBox->count() - 1,
|
||||
tr("Discriminatory language targeting race, gender, religion, etc."), Qt::ToolTipRole);
|
||||
categoryBox->addItem(tr("Spam"), "spam");
|
||||
categoryBox->setItemData(categoryBox->count() - 1, tr("Repeated unwanted messages or advertisements"),
|
||||
Qt::ToolTipRole);
|
||||
categoryBox->addItem(tr("Other"), "other");
|
||||
categoryBox->setItemData(categoryBox->count() - 1, tr("Any behavior not covered by the above categories"),
|
||||
Qt::ToolTipRole);
|
||||
for (const QString &key : ReportCategories::keys()) {
|
||||
const QString label = categoryLabel(key);
|
||||
if (label.isEmpty()) {
|
||||
continue; // skip keys without a dialog label
|
||||
}
|
||||
categoryBox->addItem(label, key);
|
||||
categoryBox->setItemData(categoryBox->count() - 1, categoryToolTip(key), Qt::ToolTipRole);
|
||||
}
|
||||
|
||||
categoryGrid->addWidget(new QLabel(tr("Category:")), 0, 0);
|
||||
categoryGrid->addWidget(categoryBox, 0, 1);
|
||||
|
|
@ -189,3 +182,55 @@ void DlgReportUser::reportResponse(const Response &response)
|
|||
QMessageBox::warning(this, tr("Submission Failed"), tr("Failed to submit report. Please try again."));
|
||||
}
|
||||
}
|
||||
|
||||
QString DlgReportUser::categoryLabel(const QString &key)
|
||||
{
|
||||
if (key == QLatin1String("cheating")) {
|
||||
return tr("Cheating / Unsporting behavior");
|
||||
}
|
||||
if (key == QLatin1String("bug_abuse")) {
|
||||
return tr("Bug Abuse");
|
||||
}
|
||||
if (key == QLatin1String("harassment")) {
|
||||
return tr("Harassment / Abuse");
|
||||
}
|
||||
if (key == QLatin1String("verbal_abuse")) {
|
||||
return tr("Verbal Abuse");
|
||||
}
|
||||
if (key == QLatin1String("hate_speech")) {
|
||||
return tr("Hate speech");
|
||||
}
|
||||
if (key == QLatin1String("spam")) {
|
||||
return tr("Spam");
|
||||
}
|
||||
if (key == QLatin1String("other")) {
|
||||
return tr("Other");
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString DlgReportUser::categoryToolTip(const QString &key)
|
||||
{
|
||||
if (key == QLatin1String("cheating")) {
|
||||
return tr("Using external tools, cheat programs, or exploiting game bugs");
|
||||
}
|
||||
if (key == QLatin1String("bug_abuse")) {
|
||||
return tr("Exploiting a bug or glitch to gain an unfair advantage");
|
||||
}
|
||||
if (key == QLatin1String("harassment")) {
|
||||
return tr("Threatening, bullying, or persistent unwanted contact");
|
||||
}
|
||||
if (key == QLatin1String("verbal_abuse")) {
|
||||
return tr("Abusive or offensive language directed at another player");
|
||||
}
|
||||
if (key == QLatin1String("hate_speech")) {
|
||||
return tr("Discriminatory language targeting race, gender, religion, etc.");
|
||||
}
|
||||
if (key == QLatin1String("spam")) {
|
||||
return tr("Repeated unwanted messages or advertisements");
|
||||
}
|
||||
if (key == QLatin1String("other")) {
|
||||
return tr("Any behavior not covered by the above categories");
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ private slots:
|
|||
void reportResponse(const Response &response);
|
||||
|
||||
private:
|
||||
static QString categoryLabel(const QString &key);
|
||||
static QString categoryToolTip(const QString &key);
|
||||
|
||||
AbstractClient *client;
|
||||
QString reportedUser;
|
||||
int gameId;
|
||||
|
|
|
|||
96
cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp
Normal file
|
|
@ -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 <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_share_create.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
|
||||
#include <libcockatrice/protocol/pending_command.h>
|
||||
#include <libcockatrice/settings/network_settings.h>
|
||||
|
||||
DlgShareDeck::DlgShareDeck(AbstractClient *_client, const QSharedPointer<DeckList> &_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<int>((static_cast<qint64>(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<int>(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."));
|
||||
}
|
||||
46
cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h
Normal file
|
|
@ -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 <QDialog>
|
||||
#include <QSharedPointer>
|
||||
|
||||
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<DeckList> &_deck, QWidget *parent = nullptr);
|
||||
|
||||
private slots:
|
||||
void actShare();
|
||||
void shareFinished(const Response &response, const CommandContainer &commandContainer);
|
||||
void onShareTimeout();
|
||||
|
||||
private:
|
||||
AbstractClient *client;
|
||||
QSharedPointer<DeckList> deck;
|
||||
QLineEdit *nameEdit;
|
||||
QDialogButtonBox *buttonBox;
|
||||
QTimer *shareTimeoutTimer;
|
||||
};
|
||||
|
||||
#endif // DLG_SHARE_DECK_H
|
||||
|
|
@ -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 <QCloseEvent>
|
||||
#include <QDateTime>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/card/database/card_database_querier.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_deck_share_item.pb.h>
|
||||
|
||||
DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent,
|
||||
const CardDatabaseQuerier *querier,
|
||||
const QString &shareName,
|
||||
qint64 expiresAt,
|
||||
const QString &serverText,
|
||||
const QList<ServerInfo_DeckShareItem> &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<int>{itemId});
|
||||
});
|
||||
}
|
||||
updateOpenSelectedEnabled();
|
||||
}
|
||||
|
||||
QList<int> DlgSharedDecksPreview::selectedItemIds() const
|
||||
{
|
||||
QList<int> 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<int> 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);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
#ifndef COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H
|
||||
#define COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QList>
|
||||
|
||||
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<ServerInfo_DeckShareItem> &items);
|
||||
|
||||
void setDownloadProgress(int done, int total, const QString ¤tDeckName);
|
||||
|
||||
public slots:
|
||||
void setDownloading(bool downloading);
|
||||
|
||||
signals:
|
||||
void openRequested(const QList<int> &itemIds);
|
||||
void cancelled();
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void openSelected();
|
||||
void openAll();
|
||||
void updateOpenSelectedEnabled();
|
||||
void onCancel();
|
||||
|
||||
private:
|
||||
QList<int> selectedItemIds() const;
|
||||
|
||||
FlowWidget *flowWidget;
|
||||
QList<SharedDeckPreviewWidget *> tiles;
|
||||
QList<int> itemIds;
|
||||
QPushButton *openSelectedButton;
|
||||
QPushButton *openAllButton;
|
||||
QLabel *downloadStatusLabel;
|
||||
bool resultEmitted = false;
|
||||
bool downloadInProgress = false;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H
|
||||
|
|
@ -5,15 +5,25 @@
|
|||
#include "../client/network/update/client/release_channel.h"
|
||||
#include "../interface/window_main.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDesktopServices>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QProgressBar>
|
||||
#include <QPushButton>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <QtNetwork>
|
||||
#include <version_string.h>
|
||||
|
||||
// Executable that, when it sits next to the downloaded update installer, is installed instead of
|
||||
// it. A packager shipping a custom build - or someone testing one - can drop the file there and
|
||||
// have Cockatrice run it rather than the official installer, without the release channel having to
|
||||
// host an installer for that build. It is a drop-in and is run with the same arguments.
|
||||
static const QString UPDATE_INSTALLER_OVERRIDE = "Cockatrice-Update-Override.exe";
|
||||
|
||||
DlgUpdate::DlgUpdate(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
|
||||
|
|
@ -203,6 +213,24 @@ void DlgUpdate::setLabel(const QString &newText)
|
|||
statusLabel->setText(newText);
|
||||
}
|
||||
|
||||
void DlgUpdate::warnInstallerIsWaiting()
|
||||
{
|
||||
// Modeless on purpose: the installer asks the application to close exactly once (see
|
||||
// CloseMatchingApps in NSIS.template.in) and only polls afterwards, while Qt drops spontaneous
|
||||
// close events - a WM_CLOSE from the installer - for windows that are blocked by a modal
|
||||
// widget (QGuiApplicationPrivate::processCloseEvent). An application modal message box here
|
||||
// would therefore swallow the installer's one and only request for the rest of its wait.
|
||||
auto *notification = new QMessageBox(QMessageBox::Warning, tr("Update"),
|
||||
tr("The update installer is already running and waits about a "
|
||||
"minute for Cockatrice to close. Cockatrice is still busy, so "
|
||||
"save your work and close it before then. Otherwise the "
|
||||
"installer gives up and the update is cancelled."),
|
||||
QMessageBox::Ok, parentWidget());
|
||||
notification->setWindowModality(Qt::NonModal);
|
||||
notification->setAttribute(Qt::WA_DeleteOnClose);
|
||||
notification->show();
|
||||
}
|
||||
|
||||
void DlgUpdate::updateCheckError(const QString &errorString)
|
||||
{
|
||||
setLabel(tr("Error"));
|
||||
|
|
@ -224,6 +252,12 @@ void DlgUpdate::downloadSuccessful(const QUrl &filepath)
|
|||
|
||||
QString installerPath = filepath.toLocalFile();
|
||||
|
||||
const QString overridePath = QDir(QFileInfo(installerPath).absolutePath()).filePath(UPDATE_INSTALLER_OVERRIDE);
|
||||
if (QFileInfo::exists(overridePath)) {
|
||||
qCInfo(DlgUpdateLog) << "Installing the update installer override instead of the download:" << overridePath;
|
||||
installerPath = overridePath;
|
||||
}
|
||||
|
||||
QString appDir = QDir::toNativeSeparators(QCoreApplication::applicationDirPath());
|
||||
QProcess process;
|
||||
process.setProgram(installerPath);
|
||||
|
|
@ -240,8 +274,31 @@ void DlgUpdate::downloadSuccessful(const QUrl &filepath)
|
|||
|
||||
// Try to open the installer. If it opens, quit Cockatrice
|
||||
if (process.startDetached()) {
|
||||
QMetaObject::invokeMethod(static_cast<MainWindow *>(parent()), "close", Qt::QueuedConnection);
|
||||
qCInfo(DlgUpdateLog) << "Opened downloaded update file successfully - closing Cockatrice";
|
||||
qCInfo(DlgUpdateLog) << "Opened update installer 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, and closeForUpdate() also reports a
|
||||
// close already in progress (reached from a nested event loop while a shutdown prompt is
|
||||
// up). Only quit when the shutdown really ran - otherwise keep running so the user can
|
||||
// resolve the blocker, and warn them that the installer only waits about a minute
|
||||
// before it gives up and cancels the update.
|
||||
if (auto *window = qobject_cast<MainWindow *>(parent())) {
|
||||
if (window->closeForUpdate()) {
|
||||
QTimer::singleShot(0, qApp, [] { QCoreApplication::exit(0); });
|
||||
} else {
|
||||
warnInstallerIsWaiting();
|
||||
}
|
||||
} else {
|
||||
// Not a MainWindow, so no faithful close can be requested - but the parent still gets
|
||||
// its close() call, which is what the code before this change did and lets it flush
|
||||
// settings and shut down its tabs. Leaving is then unconditional: the installer is
|
||||
// already running against a process that still holds locks on the files it replaces.
|
||||
if (auto *widget = parentWidget()) {
|
||||
widget->close();
|
||||
}
|
||||
QTimer::singleShot(0, qApp, [] { QCoreApplication::exit(0); });
|
||||
}
|
||||
close();
|
||||
} else {
|
||||
setLabel(tr("Error"));
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ private:
|
|||
void addStopDownloadAndRemoveOthers(bool enable);
|
||||
void beginUpdateCheck();
|
||||
void setLabel(const QString &text);
|
||||
void warnInstallerIsWaiting();
|
||||
QLabel *statusLabel, *descriptionLabel;
|
||||
QProgressBar *progress;
|
||||
QPushButton *manualDownload, *gotoDownload, *ok, *stopDownload;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
#include "flow_widget.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QKeyEvent>
|
||||
#include <QResizeEvent>
|
||||
#include <QScrollArea>
|
||||
#include <QSizePolicy>
|
||||
|
|
@ -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<QKeyEvent *>(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<QWidget *> 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();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
#include "../../../layouts/flow_layout.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QKeyEvent>
|
||||
#include <QLoggingCategory>
|
||||
#include <QScrollArea>
|
||||
#include <QWidget>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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..."));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck
|
|||
SettingsCache::instance().cardsDisplay().getPrintingSelectorCardSize());
|
||||
connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(),
|
||||
&CardsDisplaySettings::setPrintingSelectorCardSize);
|
||||
cardSizeWidget->enableCtrlScrollResize(flowWidget);
|
||||
|
||||
displayOptionsWidget->addSettingsWidget(sortToolBar);
|
||||
displayOptionsWidget->addSettingsWidget(navigationCheckBox);
|
||||
|
|
|
|||
|
|
@ -340,15 +340,26 @@ void ChatView::appendMessage(QString message,
|
|||
pos.relativePosition = match.captured(0).length(); // set message start
|
||||
auto before = match.captured(1);
|
||||
auto sentBy = match.captured(2);
|
||||
|
||||
// The user level is not carried in the room chat history, so history
|
||||
// entries used to render as fixed-level user tags. Resolve online users
|
||||
// against the user list to turn their history entries into full user
|
||||
// tags (correct level, name casing and moderation context menu).
|
||||
QString displayName = sentBy;
|
||||
// Offline users have no known level; render them as zero-level tags.
|
||||
QString levelMarker = "0";
|
||||
if (const ServerInfo_User *onlineUser = userListProxy->getOnlineUser(sentBy)) {
|
||||
displayName = QString::fromStdString(onlineUser->name());
|
||||
levelMarker = QString::number(onlineUser->user_level());
|
||||
}
|
||||
|
||||
cursor.insertText(before); // add message timestamp
|
||||
QTextCharFormat senderFormat(defaultFormat);
|
||||
senderFormat.setAnchor(true);
|
||||
// this underscore is important, it is used to add the user level, but in this case the level is
|
||||
// unknown, if the name contains an underscore it would split up the name
|
||||
senderFormat.setAnchorHref("user://_" + sentBy);
|
||||
senderFormat.setAnchorHref("user://" + levelMarker + "_" + displayName);
|
||||
cursor.setCharFormat(senderFormat);
|
||||
cursor.insertText(sentBy); // add username with href so it shows the menu
|
||||
userMessagePositions[sentBy].append(pos); // save message position
|
||||
cursor.insertText(displayName); // add username with href so it shows the menu
|
||||
userMessagePositions[displayName].append(pos); // save message position
|
||||
message.remove(0, pos.relativePosition - 2); // do not remove semicolon
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<Node *>
|
||||
{
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<GameInviteOption> 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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -218,9 +218,10 @@ void UserListTWI::setOnline(bool online)
|
|||
* 2) Admins, judge/vip/donator status ignored
|
||||
* 3) Moderators, judge/vip/donator status ignored
|
||||
* 4) Judges
|
||||
* 5) VIPs
|
||||
* 6) Donators
|
||||
* 7) Everyone else
|
||||
* 5) Developers
|
||||
* 6) VIPs
|
||||
* 7) Donators
|
||||
* 8) Everyone else
|
||||
* @param other RHS to compare to
|
||||
* @return Left is less than the Right
|
||||
*/
|
||||
|
|
@ -234,11 +235,10 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const
|
|||
const auto &lhsUserLevelFlags = UserLevelFlags(data(0, Qt::UserRole).toInt());
|
||||
const auto &rhsUserLevelFlags = UserLevelFlags(other.data(0, Qt::UserRole).toInt());
|
||||
|
||||
// Admins, Developers & Mods need no additional comparison checks, just to see if they're an admin, a developer
|
||||
// Admins & Moderators need no additional comparison checks, just to see if they're an admin
|
||||
// or a moderator
|
||||
static const QList<ServerInfo_User_UserLevelFlag> userLevelWithNoOtherPrefOrder = {
|
||||
ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsDeveloper,
|
||||
ServerInfo_User_UserLevelFlag_IsModerator};
|
||||
ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsModerator};
|
||||
for (const auto &userLevelEntry : userLevelWithNoOtherPrefOrder) {
|
||||
if (lhsUserLevelFlags.testFlag(userLevelEntry) &&
|
||||
lhsUserLevelFlags.testFlag(userLevelEntry) == rhsUserLevelFlags.testFlag(userLevelEntry)) {
|
||||
|
|
@ -249,10 +249,10 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const
|
|||
}
|
||||
}
|
||||
|
||||
// Judges can be sorted by their additional ranks
|
||||
static const QList<ServerInfo_User_UserLevelFlag> userLevelOrder = {ServerInfo_User_UserLevelFlag_IsJudge,
|
||||
ServerInfo_User_UserLevelFlag_IsRegistered,
|
||||
ServerInfo_User_UserLevelFlag_IsUser};
|
||||
// Judges and developers can be sorted by their additional ranks
|
||||
static const QList<ServerInfo_User_UserLevelFlag> userLevelOrder = {
|
||||
ServerInfo_User_UserLevelFlag_IsJudge, ServerInfo_User_UserLevelFlag_IsDeveloper,
|
||||
ServerInfo_User_UserLevelFlag_IsRegistered, ServerInfo_User_UserLevelFlag_IsUser};
|
||||
for (const auto &userLevelEntry : userLevelOrder) {
|
||||
if (lhsUserLevelFlags.testFlag(userLevelEntry) != rhsUserLevelFlags.testFlag(userLevelEntry)) {
|
||||
return lhsUserLevelFlags.testFlag(userLevelEntry) > rhsUserLevelFlags.testFlag(userLevelEntry);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,12 @@ AppearanceSettingsPage::AppearanceSettingsPage()
|
|||
// Qt widget style; "System" lets the application decide
|
||||
styleCombo.addItem(tr("System"), QStringLiteral("System"));
|
||||
for (const QString &key : QStyleFactory::keys()) {
|
||||
// The Windows 11 native style is broken (board rendering glitches when
|
||||
// moving cards), so never offer it; it is already sanitized at apply
|
||||
// time in ThemeManager.
|
||||
if (key.compare("windows11", Qt::CaseInsensitive) == 0) {
|
||||
continue;
|
||||
}
|
||||
styleCombo.addItem(key, key);
|
||||
}
|
||||
|
||||
|
|
@ -505,7 +511,7 @@ void AppearanceSettingsPage::retranslateUi()
|
|||
openThemeButton.setText(tr("Open themes folder"));
|
||||
schemeComboLabel.setText(tr("Active theme palette:"));
|
||||
styleComboLabel.setText(tr("Active theme style:"));
|
||||
styleCombo.setToolTip(tr("Qt widget style saved to this theme (\"Default\" lets the application decide)"));
|
||||
styleCombo.setToolTip(tr("Qt widget style saved to this theme (\"System\" lets the application decide)"));
|
||||
editPaletteButton.setText(tr("Edit theme palette"));
|
||||
|
||||
homeTabGroupBox->setTitle(tr("Home tab settings"));
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@
|
|||
#include <QInputDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QSet>
|
||||
#include <QToolBar>
|
||||
#include <QUrl>
|
||||
#include <libcockatrice/settings/download_settings.h>
|
||||
#include <libcockatrice/settings/paths_settings.h>
|
||||
#include <libcockatrice/settings/personal_settings.h>
|
||||
|
|
@ -51,7 +53,9 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
|
|||
urlList->setDragDropMode(QAbstractItemView::InternalMove);
|
||||
connect(urlList->model(), &QAbstractItemModel::rowsMoved, this, &DeckEditorSettingsPage::urlListChanged);
|
||||
|
||||
urlList->addItems(SettingsCache::instance().downloads().getAllURLs());
|
||||
for (const QString &url : SettingsCache::instance().downloads().getAllURLs()) {
|
||||
addUrlItem(url);
|
||||
}
|
||||
|
||||
aAdd = new QAction(this);
|
||||
aAdd->setIcon(themePixmap(QStringLiteral("icons/increment")));
|
||||
|
|
@ -65,11 +69,16 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
|
|||
aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement")));
|
||||
connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL);
|
||||
|
||||
aRateLimit = new QAction(this);
|
||||
aRateLimit->setIcon(themePixmap(QStringLiteral("icons/cogwheel")));
|
||||
connect(aRateLimit, &QAction::triggered, this, &DeckEditorSettingsPage::actAdjustRateLimit);
|
||||
|
||||
auto *urlToolBar = new QToolBar;
|
||||
urlToolBar->setOrientation(Qt::Vertical);
|
||||
urlToolBar->addAction(aAdd);
|
||||
urlToolBar->addAction(aRemove);
|
||||
urlToolBar->addAction(aEdit);
|
||||
urlToolBar->addAction(aRateLimit);
|
||||
urlToolBar->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding);
|
||||
|
||||
auto *urlListLayout = new QHBoxLayout;
|
||||
|
|
@ -117,7 +126,9 @@ void DeckEditorSettingsPage::resetDownloadedURLsButtonClicked()
|
|||
{
|
||||
SettingsCache::instance().downloads().resetToDefaultURLs();
|
||||
urlList->clear();
|
||||
urlList->addItems(SettingsCache::instance().downloads().getAllURLs());
|
||||
for (const QString &url : SettingsCache::instance().downloads().getAllURLs()) {
|
||||
addUrlItem(url);
|
||||
}
|
||||
QMessageBox::information(this, tr("Success"), tr("Download URLs have been reset."));
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +137,7 @@ void DeckEditorSettingsPage::actAddURL()
|
|||
bool ok;
|
||||
QString msg = QInputDialog::getText(this, tr("Add URL"), tr("URL:"), QLineEdit::Normal, QString(), &ok);
|
||||
if (ok) {
|
||||
urlList->addItem(msg);
|
||||
addUrlItem(msg);
|
||||
storeSettings();
|
||||
}
|
||||
}
|
||||
|
|
@ -141,12 +152,14 @@ void DeckEditorSettingsPage::actRemoveURL()
|
|||
|
||||
void DeckEditorSettingsPage::actEditURL()
|
||||
{
|
||||
if (urlList->currentItem()) {
|
||||
QString oldText = urlList->currentItem()->text();
|
||||
QListWidgetItem *item = urlList->currentItem();
|
||||
if (item) {
|
||||
const QString oldText = urlForItem(item);
|
||||
bool ok;
|
||||
QString msg = QInputDialog::getText(this, tr("Edit URL"), tr("URL:"), QLineEdit::Normal, oldText, &ok);
|
||||
if (ok) {
|
||||
urlList->currentItem()->setText(msg);
|
||||
item->setData(Qt::UserRole, msg);
|
||||
item->setText(urlLabel(msg));
|
||||
storeSettings();
|
||||
}
|
||||
}
|
||||
|
|
@ -158,10 +171,133 @@ void DeckEditorSettingsPage::storeSettings()
|
|||
|
||||
QStringList downloadUrls;
|
||||
for (int i = 0; i < urlList->count(); i++) {
|
||||
qInfo() << "Priority" << i << ":" << urlList->item(i)->text();
|
||||
downloadUrls << urlList->item(i)->text();
|
||||
const QString url = urlForItem(urlList->item(i));
|
||||
qInfo() << "Priority" << i << ":" << url;
|
||||
downloadUrls << url;
|
||||
}
|
||||
SettingsCache::instance().downloads().setDownloadUrls(downloadUrls);
|
||||
|
||||
// Drop per-host limits whose host is no longer referenced by any configured URL, so removing
|
||||
// a URL doesn't leave a stale throttle behind that reactivates if the host is re-added.
|
||||
QSet<QString> usedHosts;
|
||||
for (const QString &url : downloadUrls) {
|
||||
const QString host = QUrl(url).host();
|
||||
if (!host.isEmpty()) {
|
||||
usedHosts.insert(host);
|
||||
}
|
||||
}
|
||||
QHash<QString, int> limits = SettingsCache::instance().downloads().getHostRequestLimits();
|
||||
bool limitsChanged = false;
|
||||
for (auto it = limits.begin(); it != limits.end();) {
|
||||
// Prune only limits for hosts that are neither referenced by a configured URL nor carry a
|
||||
// developer cap. Capped hosts are often redirect targets (e.g. api.scryfall.com redirects
|
||||
// to cards.scryfall.io) that never appear in the URL list, yet they are exactly the hosts
|
||||
// the throttle applies to, so dropping them when a URL is removed would silently re-enable
|
||||
// free-running traffic to a rate-sensitive server.
|
||||
if (!usedHosts.contains(it.key()) && !DownloadSettings::getDeveloperHostCaps().contains(it.key())) {
|
||||
it = limits.erase(it);
|
||||
limitsChanged = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if (limitsChanged) {
|
||||
SettingsCache::instance().downloads().setHostRequestLimits(limits);
|
||||
}
|
||||
|
||||
refreshUrlItems();
|
||||
}
|
||||
|
||||
QListWidgetItem *DeckEditorSettingsPage::addUrlItem(const QString &url)
|
||||
{
|
||||
auto *item = new QListWidgetItem(urlLabel(url));
|
||||
item->setData(Qt::UserRole, url);
|
||||
urlList->addItem(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
QString DeckEditorSettingsPage::urlForItem(const QListWidgetItem *item) const
|
||||
{
|
||||
return item->data(Qt::UserRole).toString();
|
||||
}
|
||||
|
||||
QString DeckEditorSettingsPage::urlLabel(const QString &url) const
|
||||
{
|
||||
const QString host = QUrl(url).host();
|
||||
if (host.isEmpty()) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const QHash<QString, int> limits = SettingsCache::instance().downloads().getHostRequestLimits();
|
||||
const int devCap =
|
||||
DownloadSettings::getDeveloperHostCaps().value(host, DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT);
|
||||
if (devCap == DownloadSettings::UNLIMITED_HOST_QUOTA && !limits.contains(host)) {
|
||||
return tr("%1 (unlimited)").arg(url);
|
||||
}
|
||||
|
||||
const int requested = limits.value(
|
||||
host, devCap == DownloadSettings::UNLIMITED_HOST_QUOTA ? DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT : devCap);
|
||||
const int effective = SettingsCache::instance().downloads().clampHostRequestLimit(host, requested);
|
||||
return tr("%1 (%2/s)").arg(url).arg(effective);
|
||||
}
|
||||
|
||||
void DeckEditorSettingsPage::refreshUrlItems()
|
||||
{
|
||||
for (int i = 0; i < urlList->count(); ++i) {
|
||||
QListWidgetItem *item = urlList->item(i);
|
||||
item->setText(urlLabel(urlForItem(item)));
|
||||
}
|
||||
}
|
||||
|
||||
void DeckEditorSettingsPage::actAdjustRateLimit()
|
||||
{
|
||||
if (urlList->currentItem() == nullptr) {
|
||||
QMessageBox::information(this, tr("Adjust Rate Limit"), tr("Select a URL in the list first."));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString host = QUrl(urlForItem(urlList->currentItem())).host();
|
||||
if (host.isEmpty()) {
|
||||
QMessageBox::information(this, tr("Adjust Rate Limit"), tr("The selected URL does not have a valid host."));
|
||||
return;
|
||||
}
|
||||
|
||||
const QHash<QString, int> &devCaps = DownloadSettings::getDeveloperHostCaps();
|
||||
const QHash<QString, int> currentLimits = SettingsCache::instance().downloads().getHostRequestLimits();
|
||||
const int devCap = devCaps.value(host, DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT);
|
||||
const bool unlocked = devCap == DownloadSettings::UNLIMITED_HOST_QUOTA;
|
||||
|
||||
bool ok = false;
|
||||
int minimum;
|
||||
int maximum;
|
||||
int defaultValue;
|
||||
QString prompt;
|
||||
if (unlocked) {
|
||||
minimum = 0; // 0 means "unlimited"
|
||||
maximum = DownloadSettings::UNLOCKED_HOST_LIMIT_MAX;
|
||||
defaultValue = currentLimits.value(host, 0);
|
||||
prompt = tr("Requests per second (0 = unlimited, fastest; up to %1):").arg(maximum);
|
||||
} else {
|
||||
minimum = DownloadSettings::MIN_HOST_REQUEST_LIMIT;
|
||||
maximum = devCap;
|
||||
defaultValue = currentLimits.value(host, devCap);
|
||||
prompt = tr("Requests per second (developer maximum is %1):").arg(maximum);
|
||||
}
|
||||
|
||||
const int value = QInputDialog::getInt(this, tr("Adjust Rate Limit for %1").arg(host), prompt, defaultValue,
|
||||
minimum, maximum, 1, &ok);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
QHash<QString, int> limits = currentLimits;
|
||||
if (unlocked ? value == 0 : value == devCap) {
|
||||
limits.remove(host);
|
||||
} else {
|
||||
limits.insert(host, value);
|
||||
}
|
||||
SettingsCache::instance().downloads().setHostRequestLimits(limits);
|
||||
refreshUrlItems();
|
||||
}
|
||||
|
||||
void DeckEditorSettingsPage::urlListChanged(const QModelIndex &, int, int, const QModelIndex &, int)
|
||||
|
|
@ -244,4 +380,8 @@ void DeckEditorSettingsPage::retranslateUi()
|
|||
aAdd->setText(tr("Add New URL"));
|
||||
aEdit->setText(tr("Edit URL"));
|
||||
aRemove->setText(tr("Remove URL"));
|
||||
aRateLimit->setText(tr("Adjust Rate Limit"));
|
||||
|
||||
// The per-URL rate limit suffixes are translated, so refresh them when the language changes.
|
||||
refreshUrlItems();
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ private slots:
|
|||
void actAddURL();
|
||||
void actRemoveURL();
|
||||
void actEditURL();
|
||||
void actAdjustRateLimit();
|
||||
void resetDownloadedURLsButtonClicked();
|
||||
|
||||
private:
|
||||
|
|
@ -34,7 +35,7 @@ private:
|
|||
QLabel urlLinkLabel;
|
||||
QCheckBox picDownloadCheckBox;
|
||||
QListWidget *urlList;
|
||||
QAction *aAdd, *aEdit, *aRemove;
|
||||
QAction *aAdd, *aEdit, *aRemove, *aRateLimit;
|
||||
QCheckBox mcDownloadSpoilersCheckBox;
|
||||
QLabel msDownloadSpoilersLabel;
|
||||
QGroupBox *mpGeneralGroupBox;
|
||||
|
|
@ -46,6 +47,18 @@ private:
|
|||
QLabel infoOnSpoilersLabel;
|
||||
QPushButton *mpSpoilerPathButton;
|
||||
QPushButton *updateNowButton;
|
||||
|
||||
/** @brief Adds a list item for the given URL, storing the raw URL alongside its displayed label. */
|
||||
QListWidgetItem *addUrlItem(const QString &url);
|
||||
|
||||
/** @brief Returns the raw URL stored on a list item. */
|
||||
[[nodiscard]] QString urlForItem(const QListWidgetItem *item) const;
|
||||
|
||||
/** @brief Returns the display label for a URL, including its current effective rate limit. */
|
||||
[[nodiscard]] QString urlLabel(const QString &url) const;
|
||||
|
||||
/** @brief Refreshes the displayed label of every URL item after limits or settings change. */
|
||||
void refreshUrlItems();
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_DECK_EDITOR_SETTINGS_PAGE_H
|
||||
|
|
|
|||
|
|
@ -63,13 +63,26 @@ GeneralSettingsPage::GeneralSettingsPage()
|
|||
connect(&cardLanguageBox, qOverload<int>(&QComboBox::currentIndexChanged), this,
|
||||
&GeneralSettingsPage::cardLanguageBoxChanged);
|
||||
|
||||
// card search language, independent of the card display language
|
||||
cardSearchLanguageBox.addItem(""); // texts set in retranslateUi
|
||||
cardSearchLanguageBox.addItem("");
|
||||
cardSearchLanguageBox.addItem("");
|
||||
const int cardSearchLanguageIndex = SettingsCache::instance().cardsDisplay().getCardSearchLanguage();
|
||||
cardSearchLanguageBox.setCurrentIndex(cardSearchLanguageIndex < 0 ? static_cast<int>(SearchLanguageMode::English)
|
||||
: cardSearchLanguageIndex);
|
||||
|
||||
connect(&cardSearchLanguageBox, qOverload<int>(&QComboBox::currentIndexChanged), this,
|
||||
&GeneralSettingsPage::cardSearchLanguageBoxChanged);
|
||||
|
||||
auto *languageGrid = new QGridLayout;
|
||||
languageGrid->addWidget(&languageLabel, 0, 0);
|
||||
languageGrid->addWidget(&languageBox, 0, 1);
|
||||
languageGrid->addWidget(&cardLanguageLabel, 1, 0);
|
||||
languageGrid->addWidget(&cardLanguageBox, 1, 1);
|
||||
languageGrid->addWidget(&cardLanguageNoteLabel, 2, 1);
|
||||
languageGrid->addWidget(&advertiseTranslationPageLabel, 3, 1, Qt::AlignRight);
|
||||
languageGrid->addWidget(&cardSearchLanguageLabel, 3, 0);
|
||||
languageGrid->addWidget(&cardSearchLanguageBox, 3, 1);
|
||||
languageGrid->addWidget(&advertiseTranslationPageLabel, 4, 1, Qt::AlignRight);
|
||||
|
||||
cardLanguageNoteLabel.setWordWrap(true);
|
||||
cardLanguageNoteLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
|
||||
|
|
@ -481,6 +494,11 @@ void GeneralSettingsPage::cardLanguageBoxChanged(int index)
|
|||
}
|
||||
}
|
||||
|
||||
void GeneralSettingsPage::cardSearchLanguageBoxChanged(int index)
|
||||
{
|
||||
SettingsCache::instance().cardsDisplay().setCardSearchLanguage(index);
|
||||
}
|
||||
|
||||
void GeneralSettingsPage::updateStartupServerControlsVisibility()
|
||||
{
|
||||
const int index = startupTabSelector.currentIndex();
|
||||
|
|
@ -502,6 +520,12 @@ void GeneralSettingsPage::retranslateUi()
|
|||
cardLanguageLabel.setText(tr("Card text & images language:"));
|
||||
cardLanguageNoteLabel.setText(
|
||||
tr("Foreign card names, text and art apply after you update the card database (Oracle)."));
|
||||
cardSearchLanguageLabel.setText(tr("Language used in card search:"));
|
||||
cardSearchLanguageBox.setItemText(static_cast<int>(SearchLanguageMode::English), tr("English"));
|
||||
cardSearchLanguageBox.setItemText(static_cast<int>(SearchLanguageMode::Selected),
|
||||
tr("Selected card language (untranslated cards still match in English)"));
|
||||
cardSearchLanguageBox.setItemText(static_cast<int>(SearchLanguageMode::Both),
|
||||
tr("English and selected card language"));
|
||||
advertiseTranslationPageLabel.setText(
|
||||
QString("<a href='%1'>%2</a>").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations")));
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ private slots:
|
|||
void resetAllPathsClicked();
|
||||
void languageBoxChanged(int index);
|
||||
void cardLanguageBoxChanged(int index);
|
||||
void cardSearchLanguageBoxChanged(int index);
|
||||
void updateStartupServerControlsVisibility();
|
||||
|
||||
private:
|
||||
|
|
@ -55,6 +56,9 @@ private:
|
|||
QComboBox cardLanguageBox;
|
||||
QLabel cardLanguageNoteLabel;
|
||||
|
||||
QLabel cardSearchLanguageLabel;
|
||||
QComboBox cardSearchLanguageBox;
|
||||
|
||||
QLabel updateReleaseChannelLabel;
|
||||
QComboBox updateReleaseChannelBox;
|
||||
QCheckBox startupUpdateCheckCheckBox;
|
||||
|
|
|
|||
|
|
@ -181,9 +181,14 @@ StorageSettingsPage::StorageSettingsPage()
|
|||
|
||||
void StorageSettingsPage::clearDownloadedPicsButtonClicked()
|
||||
{
|
||||
CardPictureLoader::clearNetworkCache();
|
||||
// The network cache is cleared asynchronously on the worker thread, so wait for the completion
|
||||
// signal before confirming; the in-memory pixmap cache is cleared synchronously right away.
|
||||
connect(
|
||||
&CardPictureLoader::getInstance(), &CardPictureLoader::networkCacheCleared, this,
|
||||
[this] { QMessageBox::information(this, tr("Success"), tr("Cached card pictures have been reset.")); },
|
||||
Qt::SingleShotConnection);
|
||||
CardPictureLoader::clearPixmapCache();
|
||||
QMessageBox::information(this, tr("Success"), tr("Cached card pictures have been reset."));
|
||||
CardPictureLoader::clearNetworkCache();
|
||||
}
|
||||
|
||||
void StorageSettingsPage::clearImageBackupsButtonClicked()
|
||||
|
|
|
|||
|
|
@ -149,6 +149,11 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
|
|||
connect(&openDeckInNewTabCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(),
|
||||
&DeckEditorSettings::setOpenDeckInNewTab);
|
||||
|
||||
visualDeckEditorShowCardCountsCheckBox.setChecked(
|
||||
SettingsCache::instance().cardsDisplay().getVisualDeckEditorShowCardCounts());
|
||||
connect(&visualDeckEditorShowCardCountsCheckBox, &QCheckBox::QT_STATE_CHANGED,
|
||||
&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::setVisualDeckEditorShowCardCounts);
|
||||
|
||||
visualDeckStorageInGameCheckBox.setChecked(
|
||||
SettingsCache::instance().visualDeckStorage().getVisualDeckStorageInGame());
|
||||
connect(&visualDeckStorageInGameCheckBox, &QCheckBox::QT_STATE_CHANGED,
|
||||
|
|
@ -244,18 +249,19 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
|
|||
|
||||
auto *deckEditorGrid = new QGridLayout;
|
||||
deckEditorGrid->addWidget(&openDeckInNewTabCheckBox, 0, 0);
|
||||
deckEditorGrid->addWidget(&visualDeckStorageInGameCheckBox, 1, 0);
|
||||
deckEditorGrid->addWidget(&visualDeckStorageSelectionAnimationCheckBox, 2, 0);
|
||||
deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionLabel, 3, 0);
|
||||
deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionSelector, 3, 1);
|
||||
deckEditorGrid->addWidget(&defaultDeckEditorTypeLabel, 4, 0);
|
||||
deckEditorGrid->addWidget(&defaultDeckEditorTypeSelector, 4, 1);
|
||||
deckEditorGrid->addWidget(&vdeStartupTabLabel, 5, 0);
|
||||
deckEditorGrid->addWidget(&vdeStartupTabSelector, 5, 1);
|
||||
deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledLabel, 6, 0);
|
||||
deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledSelector, 6, 1);
|
||||
deckEditorGrid->addWidget(labelWidget, 7, 0);
|
||||
deckEditorGrid->addWidget(&commanderSpellbookIntegrationBracketNamingSelector, 7, 1);
|
||||
deckEditorGrid->addWidget(&visualDeckEditorShowCardCountsCheckBox, 1, 0);
|
||||
deckEditorGrid->addWidget(&visualDeckStorageInGameCheckBox, 2, 0);
|
||||
deckEditorGrid->addWidget(&visualDeckStorageSelectionAnimationCheckBox, 3, 0);
|
||||
deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionLabel, 4, 0);
|
||||
deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionSelector, 4, 1);
|
||||
deckEditorGrid->addWidget(&defaultDeckEditorTypeLabel, 5, 0);
|
||||
deckEditorGrid->addWidget(&defaultDeckEditorTypeSelector, 5, 1);
|
||||
deckEditorGrid->addWidget(&vdeStartupTabLabel, 6, 0);
|
||||
deckEditorGrid->addWidget(&vdeStartupTabSelector, 6, 1);
|
||||
deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledLabel, 7, 0);
|
||||
deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledSelector, 7, 1);
|
||||
deckEditorGrid->addWidget(labelWidget, 8, 0);
|
||||
deckEditorGrid->addWidget(&commanderSpellbookIntegrationBracketNamingSelector, 8, 1);
|
||||
|
||||
deckEditorGroupBox = new QGroupBox;
|
||||
deckEditorGroupBox->setLayout(deckEditorGrid);
|
||||
|
|
@ -367,6 +373,7 @@ void UserInterfaceSettingsPage::retranslateUi()
|
|||
|
||||
deckEditorGroupBox->setTitle(tr("Deck editor/storage settings"));
|
||||
openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default"));
|
||||
visualDeckEditorShowCardCountsCheckBox.setText(tr("Show card counts in Visual Deck Editor"));
|
||||
visualDeckStorageInGameCheckBox.setText(tr("Use visual deck storage in game lobby"));
|
||||
visualDeckStorageSelectionAnimationCheckBox.setText(tr("Use selection animation for Visual Deck Storage"));
|
||||
visualDeckStoragePromptForConversionLabel.setText(
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ private:
|
|||
QComboBox visualDeckStoragePromptForConversionSelector;
|
||||
QCheckBox visualDeckStorageInGameCheckBox;
|
||||
QCheckBox visualDeckStorageSelectionAnimationCheckBox;
|
||||
QCheckBox visualDeckEditorShowCardCountsCheckBox;
|
||||
QLabel defaultDeckEditorTypeLabel;
|
||||
QComboBox defaultDeckEditorTypeSelector;
|
||||
QLabel vdeStartupTabLabel;
|
||||
|
|
|
|||