mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
Merge development
Assisted-by: Claude:claude-opus-5
This commit is contained in:
commit
679cd094d3
350 changed files with 63629 additions and 7680 deletions
8
.gitattributes
vendored
Normal file
8
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# CI/http-fixtures/ is served byte for byte to the networking specs, which
|
||||
# assert the exact bodies that come back, so a Windows checkout must not turn
|
||||
# its newlines into CRLF.
|
||||
CI/http-fixtures/** -text
|
||||
|
||||
# Each .mpackage archive stores a copy of the sources beside it, byte for byte,
|
||||
# so a Windows checkout must not rewrite the newlines of one and not the other.
|
||||
src/packages/** -text
|
||||
2
.github/repo-metadata.yml
vendored
2
.github/repo-metadata.yml
vendored
|
|
@ -2,4 +2,4 @@
|
|||
|
||||
milestones:
|
||||
# assign new PRs to this milestone
|
||||
next-milestone: 4.23.0
|
||||
next-milestone: 5.0.0
|
||||
|
|
|
|||
50
.github/scripts/resolve-milestone.sh
vendored
Executable file
50
.github/scripts/resolve-milestone.sh
vendored
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Turns the version in .github/repo-metadata.yml into the number of the matching
|
||||
# open milestone, and prints "<number> <title>".
|
||||
#
|
||||
# Milestone titles carry a suffix in practice - "4.23.0 next release" for a
|
||||
# metadata value of "4.23.0" - so the exact-title match this replaces found
|
||||
# nothing, set an empty number, and exited 0, leaving every pull request in the
|
||||
# repository unassigned without ever failing (#9671). Anything short of exactly
|
||||
# one match is now an error, so a renamed milestone cannot go unnoticed again.
|
||||
#
|
||||
# Diagnostics go to stderr, because stdout is this script's answer.
|
||||
#
|
||||
# Inputs, all from the environment:
|
||||
# REPO owner/name of the repository (required)
|
||||
# NEXT_MILESTONE version to look for (required)
|
||||
# GH_TOKEN token for the gh call
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${REPO:?REPO must be set}"
|
||||
: "${NEXT_MILESTONE:?NEXT_MILESTONE must be set}"
|
||||
|
||||
if ! milestones=$(gh api "repos/${REPO}/milestones?state=open&per_page=100"); then
|
||||
echo "::error::Could not read the open milestones of ${REPO}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# An exact title wins outright; otherwise the version has to be the leading word
|
||||
# of exactly one title, so a genuinely ambiguous set is refused rather than
|
||||
# guessed at
|
||||
matches=$(jq -c --arg wanted "${NEXT_MILESTONE}" '[.[] | select(.title == $wanted)]' <<< "${milestones}")
|
||||
if [ "$(jq 'length' <<< "${matches}")" -eq 0 ]; then
|
||||
matches=$(jq -c --arg wanted "${NEXT_MILESTONE}" \
|
||||
'[.[] | select(.title | startswith($wanted + " "))]' <<< "${milestones}")
|
||||
fi
|
||||
|
||||
match_count=$(jq 'length' <<< "${matches}")
|
||||
|
||||
if [ "${match_count}" -eq 0 ]; then
|
||||
echo "::error::No open milestone matches '${NEXT_MILESTONE}' from .github/repo-metadata.yml. Open milestones: $(jq -r '[.[].title] | join(", ")' <<< "${milestones}")" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${match_count}" -gt 1 ]; then
|
||||
echo "::error::'${NEXT_MILESTONE}' matches several open milestones, so which one to use is not clear: $(jq -r '[.[].title] | join(", ")' <<< "${matches}")" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
jq -r '"\(.[0].number) \(.[0].title)"' <<< "${matches}"
|
||||
229
.github/workflows/build-mudlet-pr.yml
vendored
229
.github/workflows/build-mudlet-pr.yml
vendored
|
|
@ -5,10 +5,38 @@ on:
|
|||
pull_request:
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: detect buildable changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'Mudlet' }}
|
||||
outputs:
|
||||
build: ${{steps.filter.outputs.build}}
|
||||
steps:
|
||||
- name: Check whether anything affecting the build changed
|
||||
id: filter
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Only genuinely inert paths are listed - CI, CMake and packaging scripts
|
||||
# all affect the build. Anything unexpected falls through to building.
|
||||
files=$(gh api --paginate \
|
||||
"/repos/${{github.repository}}/pulls/${{github.event.pull_request.number}}/files" \
|
||||
-q '.[].filename')
|
||||
echo "Changed files:"
|
||||
printf '%s\n' "$files"
|
||||
|
||||
if printf '%s\n' "$files" | grep -qvE '(^docs/|^\.github/ISSUE_TEMPLATE/|\.md$)'; then
|
||||
echo "build=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Documentation-only change - skipping the build matrix"
|
||||
fi
|
||||
|
||||
compile-mudlet:
|
||||
name: ${{matrix.buildname}}
|
||||
runs-on: ${{matrix.os}}
|
||||
if: ${{ github.repository_owner == 'Mudlet' }}
|
||||
needs: changes
|
||||
if: ${{ github.repository_owner == 'Mudlet' && needs.changes.outputs.build == 'true' }}
|
||||
concurrency:
|
||||
group: ${{github.workflow}}-${{matrix.buildname}}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
|
@ -22,6 +50,9 @@ jobs:
|
|||
# Note: using / in the "buildname" has significance - replacing the
|
||||
# ',' in some of the following with a '/' seemed to cause extra
|
||||
# spurious steps in the build and broke things! - SlySven
|
||||
# Smaller than build-mudlet.yml's on purpose - only builds that produce
|
||||
# an artifact. 'ubuntu (x86_64)' and 'ubuntu / clang' still run there on
|
||||
# every push to development and nightly.
|
||||
# oldest OS supported for maximum compatibility of built AppImage
|
||||
- os: ubuntu-22.04
|
||||
buildname: 'ubuntu / gcc / lua tests + leak detection'
|
||||
|
|
@ -33,17 +64,6 @@ jobs:
|
|||
# Enable AddressSanitizer for PTB and testing builds, but not release
|
||||
# builds (tagged Mudlet-*) - ASAN adds significant runtime overhead
|
||||
enable_asan: 'true'
|
||||
- os: ubuntu-22.04
|
||||
# Another try to use GCC12
|
||||
buildname: 'ubuntu (x86_64)'
|
||||
compiler: gcc_64
|
||||
gcc_compiler_version: 12
|
||||
qt: '6.9.0'
|
||||
- os: ubuntu-latest
|
||||
buildname: 'ubuntu / clang'
|
||||
compiler: clang_64
|
||||
gcc_compiler_version: 10
|
||||
qt: '6.9.0'
|
||||
- os: macos-15-intel
|
||||
buildname: 'macos (x86_64) / c++, lua tests'
|
||||
compiler: clang_64
|
||||
|
|
@ -72,8 +92,15 @@ jobs:
|
|||
cache: true
|
||||
modules: qt5compat qtmultimedia qtspeech
|
||||
|
||||
- name: Use CMake 3.30.3
|
||||
uses: lukka/get-cmake@v4.4.0
|
||||
- name: Cache Lua build
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: .lua
|
||||
# Not gh-actions-lua's own buildCache: that shares one key across
|
||||
# runner images, and a .lua built on ubuntu-latest cannot run on
|
||||
# ubuntu-22.04 (newer glibc). The action skips its lua.org download
|
||||
# whenever .lua already exists.
|
||||
key: lua-${{ env.LUA_VERSION }}-${{ matrix.os }}-${{ runner.arch }}
|
||||
|
||||
- name: (macOS) Install Lua via GitHub Actions
|
||||
if: runner.os == 'macOS'
|
||||
|
|
@ -215,21 +242,37 @@ jobs:
|
|||
echo "LUA_PATH=$LUA_PATH" >> $GITHUB_ENV
|
||||
echo "LUA_CPATH=$LUA_CPATH" >> $GITHUB_ENV
|
||||
|
||||
- name: (Linux) Cache xcb-util-cursor 0.1.5
|
||||
id: cache-xcb-cursor
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{runner.workspace}}/xcb-util-cursor-stage
|
||||
key: xcb-util-cursor-0.1.5-${{matrix.os}}-${{runner.arch}}
|
||||
|
||||
- name: (Linux) Build xcb-util-cursor 0.1.5
|
||||
timeout-minutes: 5
|
||||
if: runner.os == 'Linux'
|
||||
if: runner.os == 'Linux' && steps.cache-xcb-cursor.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
# Download and extract xcb-util-cursor 0.1.5
|
||||
# This version fixes the off-by-one heap buffer overflow in _XcursorThemeInherits
|
||||
# that causes PTB builds to crash with AddressSanitizer
|
||||
# Fall back to the xcb project's dist host: xorg.freedesktop.org served an
|
||||
# expired TLS certificate on 2026-07-28, breaking every CI run
|
||||
# Several mirrors, each retried: xorg.freedesktop.org served an expired
|
||||
# TLS certificate on 2026-07-28, breaking every CI run
|
||||
for url in \
|
||||
https://xorg.freedesktop.org/archive/individual/lib/xcb-util-cursor-0.1.5.tar.xz \
|
||||
https://xcb.freedesktop.org/dist/xcb-util-cursor-0.1.5.tar.xz; do
|
||||
wget -q --connect-timeout=10 --read-timeout=30 --tries=3 --waitretry=5 -O xcb-util-cursor-0.1.5.tar.xz "$url" && break
|
||||
done || echo "all xcb-util-cursor mirrors failed" >&2
|
||||
https://www.x.org/releases/individual/lib/xcb-util-cursor-0.1.5.tar.xz \
|
||||
https://xcb.freedesktop.org/dist/xcb-util-cursor-0.1.5.tar.xz
|
||||
do
|
||||
for attempt in 1 2 3; do
|
||||
if wget -q --connect-timeout=10 --read-timeout=30 --tries=3 --waitretry=5 -O xcb-util-cursor-0.1.5.tar.xz "$url"; then
|
||||
break 2
|
||||
fi
|
||||
echo "::warning::download attempt $attempt from $url failed, retrying..."
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
done
|
||||
# Checksum from https://lists.x.org/archives/xorg-announce/2023-October/003428.html
|
||||
# also fails the step if every mirror was unreachable
|
||||
echo "0caf99b0d60970f81ce41c7ba694e5eaaf833227bb2cbcdb2f6dc9666a663c57 xcb-util-cursor-0.1.5.tar.xz" | sha256sum -c
|
||||
tar xf xcb-util-cursor-0.1.5.tar.xz
|
||||
cd xcb-util-cursor-0.1.5
|
||||
|
|
@ -238,8 +281,14 @@ jobs:
|
|||
./configure --prefix=/usr --libdir=/usr/lib/x86_64-linux-gnu
|
||||
make -j$(nproc)
|
||||
|
||||
# staged rather than installed directly so the result can be cached
|
||||
make DESTDIR="${{runner.workspace}}/xcb-util-cursor-stage" install
|
||||
|
||||
- name: (Linux) Install xcb-util-cursor 0.1.5
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
# Install system-wide (replaces Ubuntu 22.04's buggy 0.1.1)
|
||||
sudo make install
|
||||
sudo cp -a "${{runner.workspace}}/xcb-util-cursor-stage/." /
|
||||
|
||||
# Update library cache so linker finds new version
|
||||
sudo ldconfig
|
||||
|
|
@ -368,16 +417,21 @@ jobs:
|
|||
run: ctest --output-on-failure
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
# This runner has Qt's FFmpeg backend and can decode, so TMediaLoopTest must
|
||||
# demonstrate every media behaviour rather than skip any of them. Without a floor
|
||||
# somewhere, a lost codec or a changed default backend would turn the whole file
|
||||
# green-by-skip on every platform and say nothing about it.
|
||||
MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK: 1
|
||||
|
||||
- name: (macOS) Run C++ tests
|
||||
if: runner.os == 'macOS'
|
||||
working-directory: '${{runner.workspace}}/b/ninja'
|
||||
run: ctest --output-on-failure
|
||||
|
||||
# the full ctest run above already covers every functional-labelled test
|
||||
- name: Run QTest
|
||||
if: matrix.run_tests != 'true'
|
||||
run: ctest --test-dir ${{runner.workspace}}/b/ninja -L functional --output-on-failure
|
||||
env:
|
||||
QT_FORCE_STDERR_LOGGING: 1
|
||||
|
||||
- name: add ssh-agent for release uploads
|
||||
if: (runner.os == 'Linux' || runner.os == 'macOS') && matrix.deploy == 'deploy' && startsWith(github.ref, 'refs/tags/Mudlet-')
|
||||
|
|
@ -448,13 +502,121 @@ jobs:
|
|||
cd ~/Desktop
|
||||
sudo codesign --remove-signature ~/Desktop/Mudlet.app
|
||||
|
||||
- name: (Linux/macOS) Start fixture HTTP server for Lua tests
|
||||
if: matrix.run_tests == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
port_file="${{runner.temp}}/mudlet-http-fixture-port"
|
||||
rm -f "${port_file}"
|
||||
MUDLET_TEST_HTTP_PORT_FILE="${port_file}" nohup python3 "${{github.workspace}}/CI/http-fixture-server.py" > "${{runner.temp}}/http-fixture-server.log" 2>&1 &
|
||||
for _ in $(seq 1 50); do
|
||||
[ -s "${port_file}" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
if [ ! -s "${port_file}" ]; then
|
||||
echo "fixture HTTP server failed to start" >&2
|
||||
cat "${{runner.temp}}/http-fixture-server.log" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
port="$(cat "${port_file}")"
|
||||
if ! curl -fsS "http://127.0.0.1:${port}/fixture.txt" > /dev/null; then
|
||||
echo "fixture HTTP server is not serving fixtures" >&2
|
||||
cat "${{runner.temp}}/http-fixture-server.log" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "MUDLET_TEST_HTTP_PORT=${port}" >> "${GITHUB_ENV}"
|
||||
echo "fixture HTTP server ready on 127.0.0.1:${port}"
|
||||
|
||||
- name: (Linux) Start fake Discord IPC server for Lua tests
|
||||
if: matrix.run_tests == 'true' && runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
# A short runtime directory of its own: the whole discord-ipc-0 socket
|
||||
# path has to fit into sockaddr_un's 108 character sun_path, and
|
||||
# runner.temp does not leave room for it.
|
||||
runtime_dir="$(mktemp -d /tmp/mdxdg-XXXX)"
|
||||
ready_file="${{runner.temp}}/mudlet-discord-fixture-ready"
|
||||
rm -f "${ready_file}"
|
||||
nohup python3 "${{github.workspace}}/CI/discord-ipc-fixture.py" \
|
||||
--runtime-dir "${runtime_dir}" \
|
||||
--capture-file "${{runner.temp}}/mudlet-discord-frames.jsonl" \
|
||||
--ready-file "${ready_file}" > "${{runner.temp}}/discord-ipc-fixture.log" 2>&1 &
|
||||
for _ in $(seq 1 50); do
|
||||
[ -s "${ready_file}" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
if [ ! -s "${ready_file}" ] || [ ! -S "${runtime_dir}/discord-ipc-0" ]; then
|
||||
echo "fake Discord IPC server failed to start" >&2
|
||||
cat "${{runner.temp}}/discord-ipc-fixture.log" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
cat "${ready_file}" >> "${GITHUB_ENV}"
|
||||
# Prepended rather than replacing: the Qt install action puts Qt's own
|
||||
# libraries on this path.
|
||||
echo "MUDLET_TEST_DISCORD_LIB_PATH=${{github.workspace}}/3rdparty/discord/rpc/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" >> "${GITHUB_ENV}"
|
||||
echo "fake Discord IPC server ready in ${runtime_dir}"
|
||||
|
||||
- name: (Linux/macOS) Start MMCP peer fixture for Lua tests
|
||||
if: matrix.run_tests == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
peer_dir="${{runner.temp}}/mudlet-mmcp-peer"
|
||||
rm -rf "${peer_dir}"
|
||||
mkdir -p "${peer_dir}"
|
||||
MUDLET_TEST_MMCP_DIR="${peer_dir}" nohup python3 "${{github.workspace}}/CI/mmcp-peer.py" > "${{runner.temp}}/mmcp-peer.log" 2>&1 &
|
||||
for _ in $(seq 1 50); do
|
||||
[ -s "${peer_dir}/port" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
if [ ! -s "${peer_dir}/port" ]; then
|
||||
echo "MMCP peer fixture failed to start" >&2
|
||||
cat "${{runner.temp}}/mmcp-peer.log" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
port="$(cat "${peer_dir}/port")"
|
||||
if ! python3 -c "import socket, sys; socket.create_connection(('127.0.0.1', int(sys.argv[1])), 5).close()" "${port}"; then
|
||||
echo "MMCP peer fixture is not accepting connections" >&2
|
||||
cat "${{runner.temp}}/mmcp-peer.log" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "MUDLET_TEST_MMCP_DIR=${peer_dir}" >> "${GITHUB_ENV}"
|
||||
echo "MMCP peer fixture ready on 127.0.0.1:$(cat "${peer_dir}/port")"
|
||||
|
||||
- name: (Linux) Run Lua tests
|
||||
if: matrix.run_tests == 'true' && runner.os == 'Linux'
|
||||
timeout-minutes: 1
|
||||
# This leg drives the real discord-rpc library, whose reconnects
|
||||
# happen in wall-clock time.
|
||||
timeout-minutes: 3
|
||||
run: xvfb-run --auto-servernum ${{github.workspace}}/src/mudlet --profile "Mudlet self-test" --mirror
|
||||
env:
|
||||
AUTORUN_BUSTED_TESTS: 'true'
|
||||
MUDLET_TEST_MODE: 1
|
||||
MUDLET_TEST_REQUIRE_TTS_MOCK: 1
|
||||
MUDLET_TEST_REQUIRE_HTTP_FIXTURE: 1
|
||||
MUDLET_TEST_REQUIRE_MMCP_PEER: 1
|
||||
# Qt Multimedia runs a player here even without an audio device, so a
|
||||
# media effect spec that cannot play is a regression rather than an
|
||||
# environment quirk. macOS runners start no player at all, so the gate
|
||||
# stays off there.
|
||||
MUDLET_TEST_REQUIRE_MEDIA: 1
|
||||
# Xvfb has no window manager to overrule a resize request, so the main
|
||||
# window here follows setMainWindowSize exactly and one that stops
|
||||
# following it is a regression rather than an environment quirk. The
|
||||
# macOS runners are not gated: their window server can refuse a resize.
|
||||
MUDLET_TEST_REQUIRE_WINDOW_RESIZE: 1
|
||||
# The fake Discord IPC server's socket lives in this runtime directory,
|
||||
# and the bundled discord-rpc library is only reachable through this
|
||||
# library path - without both the Discord specs have nothing to talk to.
|
||||
MUDLET_TEST_REQUIRE_DISCORD: 1
|
||||
XDG_RUNTIME_DIR: ${{env.MUDLET_TEST_DISCORD_RUNTIME_DIR}}
|
||||
LD_LIBRARY_PATH: ${{env.MUDLET_TEST_DISCORD_LIB_PATH}}
|
||||
# The session bus socket lives in the runtime directory replaced above,
|
||||
# so Qt's D-Bus platform theme can no longer find it and would fall back
|
||||
# to spawning dbus-launch. Point it at nothing instead: libdbus leaks
|
||||
# the buffer it reads the autolaunch reply into (1032 bytes, reported
|
||||
# against this job by LeakSanitizer with no Mudlet frames in the stack),
|
||||
# and no spec needs a session bus.
|
||||
DBUS_SESSION_BUS_ADDRESS: 'disabled:'
|
||||
TESTS_DIRECTORY: ${{github.workspace}}/src/mudlet-lua/tests
|
||||
QUIT_MUDLET_AFTER_TESTS: 'true'
|
||||
ASAN_OPTIONS: ${{ matrix.enable_asan == 'true' && 'detect_leaks=1' || '' }}
|
||||
|
|
@ -462,15 +624,30 @@ jobs:
|
|||
|
||||
- name: (macOS) Run Lua tests
|
||||
if: matrix.run_tests == 'true' && runner.os == 'macOS'
|
||||
timeout-minutes: 1
|
||||
# The suite alone can run past a minute on the slower Intel runners
|
||||
timeout-minutes: 3
|
||||
run: ~/Desktop/Mudlet.app/Contents/MacOS/mudlet --profile "Mudlet self-test" --mirror
|
||||
env:
|
||||
AUTORUN_BUSTED_TESTS: 'true'
|
||||
MUDLET_TEST_MODE: 1
|
||||
MUDLET_TEST_REQUIRE_TTS_MOCK: 1
|
||||
MUDLET_TEST_REQUIRE_HTTP_FIXTURE: 1
|
||||
MUDLET_TEST_REQUIRE_MMCP_PEER: 1
|
||||
# No MUDLET_TEST_REQUIRE_MEDIA: Qt Multimedia starts no player on the
|
||||
# macOS runners, so the media effect specs pend here by design.
|
||||
TESTS_DIRECTORY: ${{github.workspace}}/src/mudlet-lua/tests
|
||||
QUIT_MUDLET_AFTER_TESTS: 'true'
|
||||
ASAN_OPTIONS: detect_leaks=0
|
||||
|
||||
- name: (Linux) Show the captured Discord frames on failure
|
||||
if: failure() && matrix.run_tests == 'true' && runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "--- fake Discord IPC server log ---"
|
||||
cat "${{runner.temp}}/discord-ipc-fixture.log" 2>/dev/null || true
|
||||
echo "--- frames it captured ---"
|
||||
cat "${{runner.temp}}/mudlet-discord-frames.jsonl" 2>/dev/null || true
|
||||
|
||||
- name: Passed Lua tests
|
||||
if: matrix.run_tests == 'true'
|
||||
run: |
|
||||
|
|
|
|||
85
.github/workflows/build-mudlet-win-pr.yml
vendored
85
.github/workflows/build-mudlet-win-pr.yml
vendored
|
|
@ -9,12 +9,41 @@ permissions:
|
|||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: detect buildable changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'Mudlet' }}
|
||||
outputs:
|
||||
build: ${{steps.filter.outputs.build}}
|
||||
steps:
|
||||
- name: Check whether anything affecting the build changed
|
||||
id: filter
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Only genuinely inert paths are listed - CI, CMake and packaging scripts
|
||||
# all affect the build. Anything unexpected falls through to building.
|
||||
files=$(gh api --paginate \
|
||||
"/repos/${{github.repository}}/pulls/${{github.event.pull_request.number}}/files" \
|
||||
-q '.[].filename')
|
||||
echo "Changed files:"
|
||||
printf '%s\n' "$files"
|
||||
|
||||
if printf '%s\n' "$files" | grep -qvE '(^docs/|^\.github/ISSUE_TEMPLATE/|\.md$)'; then
|
||||
echo "build=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Documentation-only change - skipping the build matrix"
|
||||
fi
|
||||
|
||||
compile-mudlet:
|
||||
name: ${{matrix.buildname}}
|
||||
runs-on: ${{matrix.os}}
|
||||
if: ${{ github.repository_owner == 'Mudlet' }}
|
||||
needs: changes
|
||||
if: ${{ github.repository_owner == 'Mudlet' && needs.changes.outputs.build == 'true' }}
|
||||
concurrency:
|
||||
group: ${{github.workflow}}-${{matrix.buildname}}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
|
@ -103,29 +132,71 @@ jobs:
|
|||
- name: (Windows) Run QTest
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
LUA_PATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-path)" )
|
||||
LUA_PATH=$(luarocks --lua-version 5.1 path --lr-path)
|
||||
export LUA_PATH
|
||||
LUA_CPATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-cpath)" )
|
||||
LUA_CPATH=$(luarocks --lua-version 5.1 path --lr-cpath)
|
||||
export LUA_CPATH
|
||||
|
||||
ctest --test-dir $GITHUB_WORKSPACE/build-$MSYSTEM/test --output-on-failure
|
||||
env:
|
||||
QT_FORCE_STDERR_LOGGING: 1
|
||||
# This runner has Qt's FFmpeg backend and can decode, so TMediaLoopTest must
|
||||
# demonstrate every media behaviour rather than skip any of them. Without a floor
|
||||
# somewhere, a lost codec or a changed default backend would turn the whole file
|
||||
# green-by-skip on every platform and say nothing about it.
|
||||
MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK: 1
|
||||
|
||||
- name: (Windows) Run Lua tests
|
||||
timeout-minutes: 1
|
||||
timeout-minutes: 2
|
||||
shell: msys2 {0}
|
||||
env:
|
||||
AUTORUN_BUSTED_TESTS: 'true'
|
||||
MUDLET_TEST_MODE: 1
|
||||
MUDLET_TEST_REQUIRE_TTS_MOCK: 1
|
||||
MUDLET_TEST_REQUIRE_HTTP_FIXTURE: 1
|
||||
TESTS_DIRECTORY: ${{github.workspace}}/src/mudlet-lua/tests
|
||||
QUIT_MUDLET_AFTER_TESTS: 'true'
|
||||
run: |
|
||||
LUA_PATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-path)" )
|
||||
LUA_PATH=$(luarocks --lua-version 5.1 path --lr-path)
|
||||
export LUA_PATH
|
||||
LUA_CPATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-cpath)" )
|
||||
LUA_CPATH=$(luarocks --lua-version 5.1 path --lr-cpath)
|
||||
export LUA_CPATH
|
||||
|
||||
# Linux and macOS start the fixture HTTP server in a step of their own,
|
||||
# but each msys2 step here is its own shell and a process backgrounded
|
||||
# in one is not guaranteed to still be around in the next, so start the
|
||||
# server in the very shell that runs the tests and stop it on the way
|
||||
# out. Paths are handed over in mixed form (C:/...) because bash and
|
||||
# the native Windows python both understand that.
|
||||
temp_dir="$(cygpath -m "${RUNNER_TEMP}")"
|
||||
port_file="${temp_dir}/mudlet-http-fixture-port"
|
||||
fixture_log="${temp_dir}/http-fixture-server.log"
|
||||
rm -f "${port_file}"
|
||||
python_bin=python3
|
||||
command -v "${python_bin}" > /dev/null 2>&1 || python_bin=python
|
||||
MUDLET_TEST_HTTP_PORT_FILE="${port_file}" "${python_bin}" "$GITHUB_WORKSPACE/CI/http-fixture-server.py" > "${fixture_log}" 2>&1 &
|
||||
fixture_pid=$!
|
||||
# Disowned so that stopping it is not reported as a job crash, and -9
|
||||
# because a plain SIGTERM does not reach a native Windows process from
|
||||
# msys2. The server's log is one line when all is well, so print it
|
||||
# either way rather than only where trouble is expected.
|
||||
disown "${fixture_pid}" 2>/dev/null || true
|
||||
trap 'kill -9 "${fixture_pid}" > /dev/null 2>&1 || true; cat "${fixture_log}" 2>/dev/null || true' EXIT
|
||||
for _ in $(seq 1 100); do
|
||||
[ -s "${port_file}" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
if [ ! -s "${port_file}" ]; then
|
||||
echo "fixture HTTP server failed to start" >&2
|
||||
exit 1
|
||||
fi
|
||||
MUDLET_TEST_HTTP_PORT="$(cat "${port_file}")"
|
||||
export MUDLET_TEST_HTTP_PORT
|
||||
if ! curl -fsS --max-time 10 "http://127.0.0.1:${MUDLET_TEST_HTTP_PORT}/fixture.txt" > /dev/null; then
|
||||
echo "fixture HTTP server is not serving fixtures" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "fixture HTTP server ready on 127.0.0.1:${MUDLET_TEST_HTTP_PORT}"
|
||||
|
||||
$GITHUB_WORKSPACE/build-$MSYSTEM/release/mudlet.exe --profile "Mudlet self-test"
|
||||
|
||||
- name: Passed Lua tests
|
||||
|
|
|
|||
50
.github/workflows/build-mudlet-win.yml
vendored
50
.github/workflows/build-mudlet-win.yml
vendored
|
|
@ -109,29 +109,65 @@ jobs:
|
|||
- name: (Windows) Run QTest
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
LUA_PATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-path)" )
|
||||
LUA_PATH=$(luarocks --lua-version 5.1 path --lr-path)
|
||||
export LUA_PATH
|
||||
LUA_CPATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-cpath)" )
|
||||
LUA_CPATH=$(luarocks --lua-version 5.1 path --lr-cpath)
|
||||
export LUA_CPATH
|
||||
|
||||
ctest --test-dir $GITHUB_WORKSPACE/build-$MSYSTEM/test --output-on-failure
|
||||
env:
|
||||
QT_FORCE_STDERR_LOGGING: 1
|
||||
|
||||
- name: (Windows) Run Lua tests
|
||||
timeout-minutes: 1
|
||||
timeout-minutes: 2
|
||||
shell: msys2 {0}
|
||||
env:
|
||||
AUTORUN_BUSTED_TESTS: 'true'
|
||||
MUDLET_TEST_MODE: 1
|
||||
MUDLET_TEST_REQUIRE_TTS_MOCK: 1
|
||||
MUDLET_TEST_REQUIRE_HTTP_FIXTURE: 1
|
||||
TESTS_DIRECTORY: ${{github.workspace}}/src/mudlet-lua/tests
|
||||
QUIT_MUDLET_AFTER_TESTS: 'true'
|
||||
run: |
|
||||
LUA_PATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-path)" )
|
||||
LUA_PATH=$(luarocks --lua-version 5.1 path --lr-path)
|
||||
export LUA_PATH
|
||||
LUA_CPATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-cpath)" )
|
||||
LUA_CPATH=$(luarocks --lua-version 5.1 path --lr-cpath)
|
||||
export LUA_CPATH
|
||||
|
||||
# Linux and macOS start the fixture HTTP server in a step of their own,
|
||||
# but each msys2 step here is its own shell and a process backgrounded
|
||||
# in one is not guaranteed to still be around in the next, so start the
|
||||
# server in the very shell that runs the tests and stop it on the way
|
||||
# out. Paths are handed over in mixed form (C:/...) because bash and
|
||||
# the native Windows python both understand that.
|
||||
temp_dir="$(cygpath -m "${RUNNER_TEMP}")"
|
||||
port_file="${temp_dir}/mudlet-http-fixture-port"
|
||||
fixture_log="${temp_dir}/http-fixture-server.log"
|
||||
rm -f "${port_file}"
|
||||
python_bin=python3
|
||||
command -v "${python_bin}" > /dev/null 2>&1 || python_bin=python
|
||||
MUDLET_TEST_HTTP_PORT_FILE="${port_file}" "${python_bin}" "$GITHUB_WORKSPACE/CI/http-fixture-server.py" > "${fixture_log}" 2>&1 &
|
||||
fixture_pid=$!
|
||||
# Disowned so that stopping it is not reported as a job crash, and -9
|
||||
# because a plain SIGTERM does not reach a native Windows process from
|
||||
# msys2. The server's log is one line when all is well, so print it
|
||||
# either way rather than only where trouble is expected.
|
||||
disown "${fixture_pid}" 2>/dev/null || true
|
||||
trap 'kill -9 "${fixture_pid}" > /dev/null 2>&1 || true; cat "${fixture_log}" 2>/dev/null || true' EXIT
|
||||
for _ in $(seq 1 100); do
|
||||
[ -s "${port_file}" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
if [ ! -s "${port_file}" ]; then
|
||||
echo "fixture HTTP server failed to start" >&2
|
||||
exit 1
|
||||
fi
|
||||
MUDLET_TEST_HTTP_PORT="$(cat "${port_file}")"
|
||||
export MUDLET_TEST_HTTP_PORT
|
||||
if ! curl -fsS --max-time 10 "http://127.0.0.1:${MUDLET_TEST_HTTP_PORT}/fixture.txt" > /dev/null; then
|
||||
echo "fixture HTTP server is not serving fixtures" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "fixture HTTP server ready on 127.0.0.1:${MUDLET_TEST_HTTP_PORT}"
|
||||
|
||||
$GITHUB_WORKSPACE/build-$MSYSTEM/release/mudlet.exe --profile "Mudlet self-test"
|
||||
|
||||
- name: Passed Lua tests
|
||||
|
|
|
|||
175
.github/workflows/build-mudlet.yml
vendored
175
.github/workflows/build-mudlet.yml
vendored
|
|
@ -78,8 +78,15 @@ jobs:
|
|||
cache: true
|
||||
modules: qt5compat qtmultimedia qtspeech
|
||||
|
||||
- name: Use CMake 3.30.3
|
||||
uses: lukka/get-cmake@v4.4.0
|
||||
- name: Cache Lua build
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: .lua
|
||||
# Not gh-actions-lua's own buildCache: that shares one key across
|
||||
# runner images, and a .lua built on ubuntu-latest cannot run on
|
||||
# ubuntu-22.04 (newer glibc). The action skips its lua.org download
|
||||
# whenever .lua already exists.
|
||||
key: lua-${{ env.LUA_VERSION }}-${{ matrix.os }}-${{ runner.arch }}
|
||||
|
||||
- name: (macOS) Install Lua via GitHub Actions
|
||||
if: runner.os == 'macOS'
|
||||
|
|
@ -221,21 +228,37 @@ jobs:
|
|||
echo "LUA_PATH=$LUA_PATH" >> $GITHUB_ENV
|
||||
echo "LUA_CPATH=$LUA_CPATH" >> $GITHUB_ENV
|
||||
|
||||
- name: (Linux) Cache xcb-util-cursor 0.1.5
|
||||
id: cache-xcb-cursor
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{runner.workspace}}/xcb-util-cursor-stage
|
||||
key: xcb-util-cursor-0.1.5-${{matrix.os}}-${{runner.arch}}
|
||||
|
||||
- name: (Linux) Build xcb-util-cursor 0.1.5
|
||||
timeout-minutes: 5
|
||||
if: runner.os == 'Linux'
|
||||
if: runner.os == 'Linux' && steps.cache-xcb-cursor.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
# Download and extract xcb-util-cursor 0.1.5
|
||||
# This version fixes the off-by-one heap buffer overflow in _XcursorThemeInherits
|
||||
# that causes PTB builds to crash with AddressSanitizer
|
||||
# Fall back to the xcb project's dist host: xorg.freedesktop.org served an
|
||||
# expired TLS certificate on 2026-07-28, breaking every CI run
|
||||
# Several mirrors, each retried: xorg.freedesktop.org served an expired
|
||||
# TLS certificate on 2026-07-28, breaking every CI run
|
||||
for url in \
|
||||
https://xorg.freedesktop.org/archive/individual/lib/xcb-util-cursor-0.1.5.tar.xz \
|
||||
https://xcb.freedesktop.org/dist/xcb-util-cursor-0.1.5.tar.xz; do
|
||||
wget -q --connect-timeout=10 --read-timeout=30 --tries=3 --waitretry=5 -O xcb-util-cursor-0.1.5.tar.xz "$url" && break
|
||||
done || echo "all xcb-util-cursor mirrors failed" >&2
|
||||
https://www.x.org/releases/individual/lib/xcb-util-cursor-0.1.5.tar.xz \
|
||||
https://xcb.freedesktop.org/dist/xcb-util-cursor-0.1.5.tar.xz
|
||||
do
|
||||
for attempt in 1 2 3; do
|
||||
if wget -q --connect-timeout=10 --read-timeout=30 --tries=3 --waitretry=5 -O xcb-util-cursor-0.1.5.tar.xz "$url"; then
|
||||
break 2
|
||||
fi
|
||||
echo "::warning::download attempt $attempt from $url failed, retrying..."
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
done
|
||||
# Checksum from https://lists.x.org/archives/xorg-announce/2023-October/003428.html
|
||||
# also fails the step if every mirror was unreachable
|
||||
echo "0caf99b0d60970f81ce41c7ba694e5eaaf833227bb2cbcdb2f6dc9666a663c57 xcb-util-cursor-0.1.5.tar.xz" | sha256sum -c
|
||||
tar xf xcb-util-cursor-0.1.5.tar.xz
|
||||
cd xcb-util-cursor-0.1.5
|
||||
|
|
@ -244,8 +267,14 @@ jobs:
|
|||
./configure --prefix=/usr --libdir=/usr/lib/x86_64-linux-gnu
|
||||
make -j$(nproc)
|
||||
|
||||
# staged rather than installed directly so the result can be cached
|
||||
make DESTDIR="${{runner.workspace}}/xcb-util-cursor-stage" install
|
||||
|
||||
- name: (Linux) Install xcb-util-cursor 0.1.5
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
# Install system-wide (replaces Ubuntu 22.04's buggy 0.1.1)
|
||||
sudo make install
|
||||
sudo cp -a "${{runner.workspace}}/xcb-util-cursor-stage/." /
|
||||
|
||||
# Update library cache so linker finds new version
|
||||
sudo ldconfig
|
||||
|
|
@ -380,10 +409,10 @@ jobs:
|
|||
working-directory: '${{runner.workspace}}/b/ninja'
|
||||
run: ctest --output-on-failure
|
||||
|
||||
# the full ctest run above already covers every functional-labelled test
|
||||
- name: Run QTest
|
||||
if: matrix.run_tests != 'true'
|
||||
run: ctest --test-dir ${{runner.workspace}}/b/ninja -L functional --output-on-failure
|
||||
env:
|
||||
QT_FORCE_STDERR_LOGGING: 1
|
||||
|
||||
- name: add ssh-agent for release uploads
|
||||
if: (runner.os == 'Linux' || runner.os == 'macOS') && matrix.deploy == 'deploy' && startsWith(github.ref, 'refs/tags/Mudlet-')
|
||||
|
|
@ -484,13 +513,116 @@ jobs:
|
|||
cd ~/Desktop
|
||||
sudo codesign --remove-signature ~/Desktop/Mudlet.app
|
||||
|
||||
- name: (Linux/macOS) Start fixture HTTP server for Lua tests
|
||||
if: matrix.run_tests == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
port_file="${{runner.temp}}/mudlet-http-fixture-port"
|
||||
rm -f "${port_file}"
|
||||
MUDLET_TEST_HTTP_PORT_FILE="${port_file}" nohup python3 "${{github.workspace}}/CI/http-fixture-server.py" > "${{runner.temp}}/http-fixture-server.log" 2>&1 &
|
||||
for _ in $(seq 1 50); do
|
||||
[ -s "${port_file}" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
if [ ! -s "${port_file}" ]; then
|
||||
echo "fixture HTTP server failed to start" >&2
|
||||
cat "${{runner.temp}}/http-fixture-server.log" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
port="$(cat "${port_file}")"
|
||||
if ! curl -fsS "http://127.0.0.1:${port}/fixture.txt" > /dev/null; then
|
||||
echo "fixture HTTP server is not serving fixtures" >&2
|
||||
cat "${{runner.temp}}/http-fixture-server.log" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "MUDLET_TEST_HTTP_PORT=${port}" >> "${GITHUB_ENV}"
|
||||
echo "fixture HTTP server ready on 127.0.0.1:${port}"
|
||||
|
||||
- name: (Linux) Start fake Discord IPC server for Lua tests
|
||||
if: matrix.run_tests == 'true' && runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
# A short runtime directory of its own: the whole discord-ipc-0 socket
|
||||
# path has to fit into sockaddr_un's 108 character sun_path, and
|
||||
# runner.temp does not leave room for it.
|
||||
runtime_dir="$(mktemp -d /tmp/mdxdg-XXXX)"
|
||||
ready_file="${{runner.temp}}/mudlet-discord-fixture-ready"
|
||||
rm -f "${ready_file}"
|
||||
nohup python3 "${{github.workspace}}/CI/discord-ipc-fixture.py" \
|
||||
--runtime-dir "${runtime_dir}" \
|
||||
--capture-file "${{runner.temp}}/mudlet-discord-frames.jsonl" \
|
||||
--ready-file "${ready_file}" > "${{runner.temp}}/discord-ipc-fixture.log" 2>&1 &
|
||||
for _ in $(seq 1 50); do
|
||||
[ -s "${ready_file}" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
if [ ! -s "${ready_file}" ] || [ ! -S "${runtime_dir}/discord-ipc-0" ]; then
|
||||
echo "fake Discord IPC server failed to start" >&2
|
||||
cat "${{runner.temp}}/discord-ipc-fixture.log" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
cat "${ready_file}" >> "${GITHUB_ENV}"
|
||||
# Prepended rather than replacing: the Qt install action puts Qt's own
|
||||
# libraries on this path.
|
||||
echo "MUDLET_TEST_DISCORD_LIB_PATH=${{github.workspace}}/3rdparty/discord/rpc/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" >> "${GITHUB_ENV}"
|
||||
echo "fake Discord IPC server ready in ${runtime_dir}"
|
||||
|
||||
- name: (Linux/macOS) Start MMCP peer fixture for Lua tests
|
||||
if: matrix.run_tests == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
peer_dir="${{runner.temp}}/mudlet-mmcp-peer"
|
||||
rm -rf "${peer_dir}"
|
||||
mkdir -p "${peer_dir}"
|
||||
MUDLET_TEST_MMCP_DIR="${peer_dir}" nohup python3 "${{github.workspace}}/CI/mmcp-peer.py" > "${{runner.temp}}/mmcp-peer.log" 2>&1 &
|
||||
for _ in $(seq 1 50); do
|
||||
[ -s "${peer_dir}/port" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
if [ ! -s "${peer_dir}/port" ]; then
|
||||
echo "MMCP peer fixture failed to start" >&2
|
||||
cat "${{runner.temp}}/mmcp-peer.log" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
port="$(cat "${peer_dir}/port")"
|
||||
if ! python3 -c "import socket, sys; socket.create_connection(('127.0.0.1', int(sys.argv[1])), 5).close()" "${port}"; then
|
||||
echo "MMCP peer fixture is not accepting connections" >&2
|
||||
cat "${{runner.temp}}/mmcp-peer.log" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "MUDLET_TEST_MMCP_DIR=${peer_dir}" >> "${GITHUB_ENV}"
|
||||
echo "MMCP peer fixture ready on 127.0.0.1:$(cat "${peer_dir}/port")"
|
||||
|
||||
- name: (Linux) Run Lua tests
|
||||
if: matrix.run_tests == 'true' && runner.os == 'Linux'
|
||||
timeout-minutes: 1
|
||||
# This leg drives the real discord-rpc library, whose reconnects
|
||||
# happen in wall-clock time.
|
||||
timeout-minutes: 3
|
||||
run: xvfb-run --auto-servernum ${{github.workspace}}/src/mudlet --profile "Mudlet self-test" --mirror
|
||||
env:
|
||||
AUTORUN_BUSTED_TESTS: 'true'
|
||||
MUDLET_TEST_MODE: 1
|
||||
MUDLET_TEST_REQUIRE_TTS_MOCK: 1
|
||||
MUDLET_TEST_REQUIRE_HTTP_FIXTURE: 1
|
||||
MUDLET_TEST_REQUIRE_MMCP_PEER: 1
|
||||
# Qt Multimedia runs a player here even without an audio device, so a
|
||||
# media effect spec that cannot play is a regression rather than an
|
||||
# environment quirk. macOS runners start no player at all, so the gate
|
||||
# stays off there.
|
||||
MUDLET_TEST_REQUIRE_MEDIA: 1
|
||||
# The fake Discord IPC server's socket lives in this runtime directory,
|
||||
# and the bundled discord-rpc library is only reachable through this
|
||||
# library path - without both the Discord specs have nothing to talk to.
|
||||
MUDLET_TEST_REQUIRE_DISCORD: 1
|
||||
XDG_RUNTIME_DIR: ${{env.MUDLET_TEST_DISCORD_RUNTIME_DIR}}
|
||||
LD_LIBRARY_PATH: ${{env.MUDLET_TEST_DISCORD_LIB_PATH}}
|
||||
# The session bus socket lives in the runtime directory replaced above,
|
||||
# so Qt's D-Bus platform theme can no longer find it and would fall back
|
||||
# to spawning dbus-launch. Point it at nothing instead: libdbus leaks
|
||||
# the buffer it reads the autolaunch reply into (1032 bytes, reported
|
||||
# against this job by LeakSanitizer with no Mudlet frames in the stack),
|
||||
# and no spec needs a session bus.
|
||||
DBUS_SESSION_BUS_ADDRESS: 'disabled:'
|
||||
TESTS_DIRECTORY: ${{github.workspace}}/src/mudlet-lua/tests
|
||||
QUIT_MUDLET_AFTER_TESTS: 'true'
|
||||
ASAN_OPTIONS: ${{ matrix.enable_asan == 'true' && 'detect_leaks=1' || '' }}
|
||||
|
|
@ -498,15 +630,30 @@ jobs:
|
|||
|
||||
- name: (macOS) Run Lua tests
|
||||
if: matrix.run_tests == 'true' && runner.os == 'macOS'
|
||||
timeout-minutes: 1
|
||||
# The suite alone can run past a minute on the slower Intel runners
|
||||
timeout-minutes: 3
|
||||
run: ~/Desktop/Mudlet.app/Contents/MacOS/mudlet --profile "Mudlet self-test" --mirror
|
||||
env:
|
||||
AUTORUN_BUSTED_TESTS: 'true'
|
||||
MUDLET_TEST_MODE: 1
|
||||
MUDLET_TEST_REQUIRE_TTS_MOCK: 1
|
||||
MUDLET_TEST_REQUIRE_HTTP_FIXTURE: 1
|
||||
MUDLET_TEST_REQUIRE_MMCP_PEER: 1
|
||||
# No MUDLET_TEST_REQUIRE_MEDIA: Qt Multimedia starts no player on the
|
||||
# macOS runners, so the media effect specs pend here by design.
|
||||
TESTS_DIRECTORY: ${{github.workspace}}/src/mudlet-lua/tests
|
||||
QUIT_MUDLET_AFTER_TESTS: 'true'
|
||||
ASAN_OPTIONS: detect_leaks=0
|
||||
|
||||
- name: (Linux) Show the captured Discord frames on failure
|
||||
if: failure() && matrix.run_tests == 'true' && runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "--- fake Discord IPC server log ---"
|
||||
cat "${{runner.temp}}/discord-ipc-fixture.log" 2>/dev/null || true
|
||||
echo "--- frames it captured ---"
|
||||
cat "${{runner.temp}}/mudlet-discord-frames.jsonl" 2>/dev/null || true
|
||||
|
||||
- name: Passed Lua tests
|
||||
if: matrix.run_tests == 'true'
|
||||
run: |
|
||||
|
|
|
|||
39
.github/workflows/check-mpackages.yml
vendored
Normal file
39
.github/workflows/check-mpackages.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Mudlet installs the .mpackage archives, not the loose sources next to them,
|
||||
# so an edit that isn't re-zipped silently does nothing. See CI/check-mpackage-sync.lua.
|
||||
name: Check mpackages
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/packages/**'
|
||||
- 'CI/check-mpackage-sync.lua'
|
||||
- '.github/workflows/check-mpackages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{github.workflow}}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-mpackage-sync:
|
||||
# Skip release PRs (base main): a package that changed during the cycle was
|
||||
# already version-checked when it landed on development.
|
||||
if: github.event.pull_request.base.ref != 'main'
|
||||
name: Check archives match their sources
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Lua 5.1.5
|
||||
uses: leafo/gh-actions-lua@v13
|
||||
with:
|
||||
luaVersion: "5.1.5"
|
||||
|
||||
- name: Fetch base branch
|
||||
if: github.event_name == 'pull_request'
|
||||
run: git fetch --no-tags origin ${{ github.base_ref }}
|
||||
|
||||
- name: Check mpackage archives
|
||||
run: lua CI/check-mpackage-sync.lua ${{ github.event_name == 'pull_request' && format('--base-ref origin/{0}', github.base_ref) || '' }}
|
||||
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
|
|
@ -88,7 +88,7 @@ jobs:
|
|||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@v4.37.6
|
||||
with:
|
||||
config-file: ./.github/codeql/codeql-config.yml
|
||||
languages: ${{ matrix.language }}
|
||||
|
|
@ -98,7 +98,7 @@ jobs:
|
|||
queries: security-extended, security-and-quality
|
||||
|
||||
- name: Use CMake 3.30.3
|
||||
uses: lukka/get-cmake@v4.4.0
|
||||
uses: lukka/get-cmake@v4.4.2
|
||||
|
||||
- name: (Linux) Install Lua via GitHub Actions
|
||||
uses: leafo/gh-actions-lua@v13
|
||||
|
|
@ -156,7 +156,7 @@ jobs:
|
|||
NINJA_STATUS: '[%f/%t %o/sec] '
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@v4.37.6
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
upload: false
|
||||
|
|
@ -171,6 +171,6 @@ jobs:
|
|||
output: sarif-results/${{ matrix.language }}.sarif
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@v4.37.6
|
||||
with:
|
||||
sarif_file: sarif-results/${{ matrix.language }}.sarif
|
||||
|
|
|
|||
159
.github/workflows/create-github-release.yml
vendored
159
.github/workflows/create-github-release.yml
vendored
|
|
@ -80,6 +80,16 @@ jobs:
|
|||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
# workflow_run always runs this file from the default branch, while the
|
||||
# checkout above is the commit that was built - which may predate the release
|
||||
# scripts below. Take them from the same ref as this workflow file.
|
||||
- uses: actions/checkout@v7
|
||||
if: steps.check.outputs.ready == 'true'
|
||||
with:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
path: release-scripts
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: leafo/gh-actions-lua@v13
|
||||
if: steps.check.outputs.ready == 'true'
|
||||
with:
|
||||
|
|
@ -150,6 +160,10 @@ jobs:
|
|||
COMMIT=$(echo "$META" | jq -r '.commit')
|
||||
|
||||
if [[ "$REF" == refs/tags/Mudlet-* ]]; then
|
||||
# release-scripts/ is this workflow's own ref, so the guard is present
|
||||
# even when the tagged commit predates it
|
||||
bash release-scripts/CI/check-release-tag.sh "${VERSION}" "${REF#refs/tags/}"
|
||||
|
||||
echo "type=release" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
echo "title=Mudlet ${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
|
@ -159,6 +173,10 @@ jobs:
|
|||
echo "::error::COMMIT field is empty or missing in release metadata for PTB"
|
||||
exit 1
|
||||
fi
|
||||
# Nothing validates APP_VERSION on development, and a two-component one
|
||||
# makes a PTB unofferable the same way
|
||||
bash release-scripts/CI/check-release-tag.sh "${VERSION}"
|
||||
|
||||
PTB_TAG="Mudlet-${VERSION}${BUILD_SUFFIX}-${COMMIT}"
|
||||
echo "type=ptb" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${PTB_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
|
@ -191,55 +209,57 @@ jobs:
|
|||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: assets/
|
||||
|
||||
- name: Verify release assets
|
||||
# A download that fails leaves the release short of a platform, so surface it
|
||||
# rather than letting continue-on-error hide it
|
||||
- name: Report asset download failures
|
||||
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
|
||||
env:
|
||||
RELEASE_TYPE: ${{ steps.release-type.outputs.type }}
|
||||
LINUX_MACOS_OUTCOME: ${{ steps.download-linux-macos.outcome }}
|
||||
WINDOWS_OUTCOME: ${{ steps.download-windows.outcome }}
|
||||
run: |
|
||||
mkdir -p assets/
|
||||
echo "Downloaded assets:"
|
||||
find assets/ -type f | sort
|
||||
|
||||
MISSING=()
|
||||
if ! find assets/ -name '*.AppImage.tar' -type f | grep -q .; then
|
||||
MISSING+=("Linux (.AppImage.tar)")
|
||||
if [[ "${LINUX_MACOS_OUTCOME}" == "failure" ]]; then
|
||||
echo "::warning::Downloading the Linux/macOS release assets failed - they will be missing from this release"
|
||||
fi
|
||||
if ! find assets/ -name '*.dmg' -type f | grep -q .; then
|
||||
MISSING+=("macOS (.dmg)")
|
||||
fi
|
||||
if ! find assets/ -name '*.exe' -type f | grep -q .; then
|
||||
MISSING+=("Windows (.exe)")
|
||||
if [[ "${WINDOWS_OUTCOME}" == "failure" ]]; then
|
||||
echo "::warning::Downloading the Windows release assets failed - it will be missing from this release"
|
||||
fi
|
||||
|
||||
if [[ ${#MISSING[@]} -gt 0 ]]; then
|
||||
echo "::warning::Missing release assets for: ${MISSING[*]}"
|
||||
fi
|
||||
- name: Prepare release assets
|
||||
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.release-type.outputs.tag }}
|
||||
RELEASE_TYPE: ${{ steps.release-type.outputs.type }}
|
||||
run: bash release-scripts/CI/prepare-release-assets.sh assets/ "${RELEASE_TAG}" "${RELEASE_TYPE}"
|
||||
|
||||
# Stable releases must have all platforms; PTB tolerates partial
|
||||
if [[ "${RELEASE_TYPE}" == "release" && ${#MISSING[@]} -gt 0 ]]; then
|
||||
echo "::error::Stable release is missing assets for: ${MISSING[*]}"
|
||||
# The release may already carry assets from the other build workflow's run of
|
||||
# this job, so its SHA256SUMS.txt has to be merged rather than overwritten
|
||||
- name: Fetch published SHA256SUMS.txt
|
||||
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.release-type.outputs.tag }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p published/
|
||||
if ! gh release view "${RELEASE_TAG}" 2> view-error.txt; then
|
||||
if grep -qi 'release not found' view-error.txt; then
|
||||
echo "Release ${RELEASE_TAG} does not exist yet - no checksums to merge"
|
||||
exit 0
|
||||
fi
|
||||
cat view-error.txt
|
||||
echo "::error::Could not read release ${RELEASE_TAG} - refusing to rebuild its checksums from a partial view"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ${#MISSING[@]} -eq 3 ]]; then
|
||||
echo "::error::No release assets found for any platform"
|
||||
exit 1
|
||||
if gh release download "${RELEASE_TAG}" --pattern SHA256SUMS.txt --dir published/; then
|
||||
echo "Already published on ${RELEASE_TAG}:"
|
||||
cat published/SHA256SUMS.txt
|
||||
else
|
||||
echo "::warning::${RELEASE_TAG} exists but its SHA256SUMS.txt could not be downloaded - any entry only it covers will be lost"
|
||||
fi
|
||||
|
||||
# Assemble SHA256SUMS.txt from per-platform .sha256 sidecar files
|
||||
- name: Assemble SHA256SUMS.txt
|
||||
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
|
||||
run: |
|
||||
mapfile -t SHA_FILES < <(find assets/ -name '*.sha256' -type f)
|
||||
|
||||
if [[ ${#SHA_FILES[@]} -eq 0 ]]; then
|
||||
echo "::error::No .sha256 checksum files found in assets/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat "${SHA_FILES[@]}" > assets/SHA256SUMS.txt
|
||||
echo "Generated SHA256SUMS.txt (${#SHA_FILES[@]} entries):"
|
||||
cat assets/SHA256SUMS.txt
|
||||
run: bash release-scripts/CI/assemble-release-checksums.sh assets/ published/SHA256SUMS.txt
|
||||
|
||||
- name: Generate changelog
|
||||
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
|
||||
|
|
@ -352,21 +372,43 @@ jobs:
|
|||
TARGET_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Collect release files and combined checksums (exclude per-platform .sha256 sidecars)
|
||||
mapfile -t FILES < <(find assets/ -type f \
|
||||
\( -name '*.AppImage.tar' -o -name '*.exe' -o -name '*.dmg' -o -name 'SHA256SUMS.txt' \))
|
||||
# Everything in assets/ except the per-platform sidecars, which only feed
|
||||
# SHA256SUMS.txt. Not a list of binary suffixes: a new asset type must not
|
||||
# be able to reach the release without the checksum gate below seeing it.
|
||||
mapfile -t BINARIES < <(find assets/ -type f ! -name '*.sha256' ! -name 'SHA256SUMS.txt')
|
||||
|
||||
if [[ ${#FILES[@]} -eq 0 ]]; then
|
||||
if [[ ${#BINARIES[@]} -eq 0 ]]; then
|
||||
echo "::error::No release files found to upload"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Files to upload:"
|
||||
printf '%s\n' "${FILES[@]}"
|
||||
printf '%s\n' assets/SHA256SUMS.txt "${BINARIES[@]}"
|
||||
|
||||
if gh release view "${RELEASE_TAG}" &>/dev/null; then
|
||||
# Guard against publishing a binary SHA256SUMS.txt does not cover: the
|
||||
# release ends up holding what is already on it plus what we upload now
|
||||
: > final-assets.txt
|
||||
if gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' >> final-assets.txt 2> view-error.txt; then
|
||||
RELEASE_EXISTS=yes
|
||||
elif grep -qi 'release not found' view-error.txt; then
|
||||
RELEASE_EXISTS=no
|
||||
else
|
||||
cat view-error.txt
|
||||
echo "::error::Could not list the assets already published on ${RELEASE_TAG} - refusing to publish without checking checksum coverage"
|
||||
exit 1
|
||||
fi
|
||||
basename -a -- "${BINARIES[@]}" >> final-assets.txt
|
||||
|
||||
echo "Assets the release will hold afterwards:"
|
||||
sort -u final-assets.txt
|
||||
bash release-scripts/CI/verify-release-checksums.sh assets/SHA256SUMS.txt final-assets.txt assets/
|
||||
|
||||
# SHA256SUMS.txt first: it is a superset of the old and new binaries, so if
|
||||
# the upload dies partway the release is never left holding an uncovered one
|
||||
if [[ "${RELEASE_EXISTS}" == "yes" ]]; then
|
||||
echo "Release ${RELEASE_TAG} already exists - uploading any missing assets"
|
||||
gh release upload "${RELEASE_TAG}" "${FILES[@]}" --clobber
|
||||
gh release upload "${RELEASE_TAG}" assets/SHA256SUMS.txt --clobber
|
||||
gh release upload "${RELEASE_TAG}" "${BINARIES[@]}" --clobber
|
||||
else
|
||||
ARGS=(
|
||||
"${RELEASE_TAG}"
|
||||
|
|
@ -378,10 +420,39 @@ jobs:
|
|||
ARGS+=(--prerelease --target "${TARGET_SHA}")
|
||||
fi
|
||||
|
||||
gh release create "${ARGS[@]}" "${FILES[@]}" \
|
||||
|| gh release upload "${RELEASE_TAG}" "${FILES[@]}" --clobber
|
||||
gh release create "${ARGS[@]}" assets/SHA256SUMS.txt \
|
||||
|| gh release upload "${RELEASE_TAG}" assets/SHA256SUMS.txt --clobber
|
||||
gh release upload "${RELEASE_TAG}" "${BINARIES[@]}" --clobber
|
||||
fi
|
||||
|
||||
# Confirm against the live release, not just the files we meant to upload
|
||||
- name: Verify published release checksums
|
||||
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.release-type.outputs.tag }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# A freshly uploaded asset is not always immediately readable, so retry
|
||||
# rather than declaring a good release broken
|
||||
for attempt in 1 2 3 4 5; do
|
||||
rm -rf published-final/
|
||||
mkdir -p published-final/
|
||||
if gh release download "${RELEASE_TAG}" --pattern SHA256SUMS.txt --dir published-final/ \
|
||||
&& gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' > published-final/assets.txt; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 5 ]]; then
|
||||
echo "::error::Could not read back the published ${RELEASE_TAG} assets to verify them"
|
||||
exit 1
|
||||
fi
|
||||
echo "Attempt ${attempt} could not read the published release yet - retrying"
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "Published assets:"
|
||||
cat published-final/assets.txt
|
||||
bash release-scripts/CI/verify-release-checksums.sh published-final/SHA256SUMS.txt published-final/assets.txt
|
||||
|
||||
# Generate and upload Sparkle appcast XML for macOS updates
|
||||
- name: Add SSH agent for appcast upload
|
||||
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
|
||||
|
|
|
|||
2
.github/workflows/performance-analysis.yml
vendored
2
.github/workflows/performance-analysis.yml
vendored
|
|
@ -28,7 +28,7 @@ jobs:
|
|||
submodules: recursive
|
||||
|
||||
- name: Use CMake 3.30.3
|
||||
uses: lukka/get-cmake@v4.4.0
|
||||
uses: lukka/get-cmake@v4.4.2
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
|
|
|||
70
.github/workflows/tag-pull-requests.yml
vendored
70
.github/workflows/tag-pull-requests.yml
vendored
|
|
@ -3,15 +3,23 @@ name: Pull request
|
|||
on:
|
||||
pull_request_target:
|
||||
|
||||
# issues covers reading the milestone list and setting the milestone on the
|
||||
# pull request, which the REST API treats as an issue; pull-requests is granted
|
||||
# as well because GitHub gates issue endpoints on it when the target is a PR
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
add-milestone:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'Mudlet' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get next milestone
|
||||
id: next-milestone-string
|
||||
|
|
@ -20,30 +28,54 @@ jobs:
|
|||
cmd: yq eval '.milestones.next-milestone' '.github/repo-metadata.yml'
|
||||
|
||||
- name: 'Convert milestone to Github #'
|
||||
id: next-milestone-number
|
||||
env:
|
||||
# authenticated so this does not share the per-IP anonymous rate limit
|
||||
# with every other job on the runner's address
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
NEXT_MILESTONE: ${{ steps.next-milestone-string.outputs.result }}
|
||||
run: |
|
||||
MILESTONE_NUMBER=$(curl --silent --request GET \
|
||||
--url https://api.github.com/repos/Mudlet/Mudlet/milestones \
|
||||
-H "Accept: application/vnd.github.v3+json" | \
|
||||
jq '.[] | select(.title == "${{ steps.next-milestone-string.outputs.result }}").number')
|
||||
set -euo pipefail
|
||||
|
||||
echo "MILESTONE_NUMBER=$MILESTONE_NUMBER" >> "$GITHUB_ENV"
|
||||
# Plain assignment, not a process substitution, so a failure to resolve
|
||||
# the milestone stops the job instead of being read as an empty answer
|
||||
if ! resolved=$(.github/scripts/resolve-milestone.sh); then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r number title <<< "${resolved}"
|
||||
echo "Milestone: ${title}"
|
||||
echo "MILESTONE_NUMBER=${number}" >> "$GITHUB_ENV"
|
||||
echo "MILESTONE_TITLE=${title}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Assign PR to milestone
|
||||
env:
|
||||
TOKEN: ${{ secrets.GH_PAT_UPDATE_PULL_REQUESTS }}
|
||||
# The built-in token has write access here because pull_request_target
|
||||
# runs in the base repository; the fine-grained PAT this replaces was
|
||||
# blocked by the organisation's 366-day token lifetime policy
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
# Fetch pull request details
|
||||
PR_DETAILS=$(curl -s --request GET \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
--url ${{ github.event.pull_request.issue_url }} \
|
||||
--header "authorization: token $TOKEN")
|
||||
set -euo pipefail
|
||||
|
||||
# Check if the pull request has a milestone already
|
||||
if [ $(echo "$PR_DETAILS" | jq '.milestone == null') == "true" ]; then
|
||||
curl -s --request PATCH \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
--url ${{ github.event.pull_request.issue_url }} \
|
||||
--header "authorization: token $TOKEN" \
|
||||
--data '{"milestone": '"$MILESTONE_NUMBER"'}'
|
||||
if [ -z "${MILESTONE_NUMBER:-}" ]; then
|
||||
echo "::error::No milestone number was resolved by the previous step"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Captured rather than tested inline, because a failed read inside the
|
||||
# test would look identical to "has no milestone" and overwrite one that
|
||||
# somebody set on purpose
|
||||
if ! existing=$(gh api "repos/${REPO}/issues/${PR_NUMBER}" --jq '.milestone.number // empty'); then
|
||||
echo "::error::Could not read the current milestone of #${PR_NUMBER}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "${existing}" ]; then
|
||||
echo "#${PR_NUMBER} is already on milestone ${existing}, leaving it as it is"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh api --method PATCH "repos/${REPO}/issues/${PR_NUMBER}" -F "milestone=${MILESTONE_NUMBER}" --silent
|
||||
echo "Assigned #${PR_NUMBER} to milestone ${MILESTONE_TITLE}"
|
||||
|
|
|
|||
2
.github/workflows/update-3rdparty.yml
vendored
2
.github/workflows/update-3rdparty.yml
vendored
|
|
@ -18,7 +18,7 @@ jobs:
|
|||
type: download
|
||||
url: https://raw.githubusercontent.com/Mudlet/mudlet-package-repository/refs/heads/main/packages/mpkg.mpackage
|
||||
file: mpkg.mpackage
|
||||
path: src/
|
||||
path: src/packages/mpkg/
|
||||
branch: update-mpkg-mpackage
|
||||
title: "Infrastructure: Update bundled mpkg.mpackage to latest upstream"
|
||||
body: |
|
||||
|
|
|
|||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -31,10 +31,7 @@ CMakeLists.txt.user
|
|||
|
||||
#CLion
|
||||
.idea
|
||||
cmake-build-debug
|
||||
cmake-build-minsizerel
|
||||
cmake-build-release
|
||||
cmake-build-relwithdebinfo
|
||||
cmake-build-*/
|
||||
|
||||
#VS Code
|
||||
.vscode/*.code-workspace
|
||||
|
|
@ -54,3 +51,7 @@ CMakeSettings.json
|
|||
# flatpak builder
|
||||
CI/.flatpak-builder
|
||||
CI/build-dir
|
||||
|
||||
# python bytecode (CI helper scripts)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
|
|||
2
3rdparty/sentry-native
vendored
2
3rdparty/sentry-native
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit a1827544e2da7e50517615003288c25380f8d457
|
||||
Subproject commit a185ce80ba2416b0a0bb04b4ee8f11f1117ae08f
|
||||
127
CI/assemble-release-checksums.sh
Executable file
127
CI/assemble-release-checksums.sh
Executable file
|
|
@ -0,0 +1,127 @@
|
|||
#!/bin/bash
|
||||
# Assembles SHA256SUMS.txt for a GitHub Release from the per-platform .sha256
|
||||
# sidecar files, merged over the SHA256SUMS.txt already published on the release.
|
||||
#
|
||||
# Merging matters because create-github-release.yml runs once per platform build
|
||||
# workflow and uploads with --clobber. A run that sees fewer platforms than an
|
||||
# earlier run - or a re-run of one platform whose binary is named differently -
|
||||
# used to regenerate SHA256SUMS.txt from its own subset of sidecars and overwrite
|
||||
# the complete file, while the binaries from the earlier run stayed published. That
|
||||
# left release binaries with no checksum line, which the updater's download path
|
||||
# refuses to install (see Feed::findChecksum and UpdateDialog::startDownload).
|
||||
# Entries are keyed by filename and the freshly built sidecars win, so the
|
||||
# published file only ever gains coverage.
|
||||
#
|
||||
# This is a read-modify-write of a file shared by both platform triggers; it is
|
||||
# only safe because create-github-release.yml serialises them with a `concurrency`
|
||||
# group keyed on the build's head_sha.
|
||||
#
|
||||
# Usage: assemble-release-checksums.sh <assets-dir> [published-sums-file]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ASSETS_DIR="${1:?assets directory required}"
|
||||
PUBLISHED_SUMS="${2:-}"
|
||||
|
||||
OUTPUT="${ASSETS_DIR%/}/SHA256SUMS.txt"
|
||||
|
||||
# no mapfile: macOS ships bash 3.2, and test/ci/release-checksums-test.sh runs
|
||||
# these scripts under ctest there
|
||||
SHA_FILES=()
|
||||
while IFS= read -r sha_file; do
|
||||
SHA_FILES+=("${sha_file}")
|
||||
done < <(find "${ASSETS_DIR}" -name '*.sha256' -type f | sort)
|
||||
|
||||
if [[ ${#SHA_FILES[@]} -eq 0 ]]; then
|
||||
echo "::error::No .sha256 checksum files found in ${ASSETS_DIR}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RECORDS="$(mktemp)"
|
||||
trap 'rm -f "${RECORDS}"' EXIT
|
||||
|
||||
# Appends "<filename>\t<priority>\t<hash>\t<binary marker>" to ${RECORDS} for
|
||||
# every checksum line on stdin, so the lines can be deduplicated by filename with
|
||||
# the lowest priority number winning. The output line is rebuilt from these fields
|
||||
# rather than carried through verbatim, because a checksum line may itself contain
|
||||
# a tab and would then be truncated by the tab-delimited dedupe.
|
||||
collect() {
|
||||
local priority="$1"
|
||||
local source_label="$2"
|
||||
local line
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
line="${line%$'\r'}"
|
||||
if [[ -z "${line}" ]]; then
|
||||
continue
|
||||
fi
|
||||
# sha256sum writes "<hash> <name>" in text mode and "<hash> *<name>" in
|
||||
# binary mode; MSYS2's defaults to binary, so the Windows entry uses " *"
|
||||
if [[ ! "${line}" =~ ^([0-9a-fA-F]{64})[[:space:]]+(\*?)(.+)$ ]]; then
|
||||
echo "::warning::Ignoring unparseable checksum line from ${source_label}: ${line}"
|
||||
continue
|
||||
fi
|
||||
printf '%s\t%s\t%s\t%s\n' "${BASH_REMATCH[3]}" "${priority}" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" >> "${RECORDS}"
|
||||
done
|
||||
}
|
||||
|
||||
record_count() {
|
||||
wc -l < "${RECORDS}" | tr -d ' '
|
||||
}
|
||||
|
||||
if [[ -n "${PUBLISHED_SUMS}" && -f "${PUBLISHED_SUMS}" ]]; then
|
||||
echo "Merging over the SHA256SUMS.txt already published on the release"
|
||||
collect 2 "the published SHA256SUMS.txt" < "${PUBLISHED_SUMS}"
|
||||
fi
|
||||
|
||||
# one sidecar at a time: concatenating them would join two records whenever a
|
||||
# sidecar lacks a trailing newline
|
||||
for sha_file in "${SHA_FILES[@]}"; do
|
||||
before="$(record_count)"
|
||||
sidecar_name="$(basename "${sha_file}")"
|
||||
collect 1 "${sidecar_name}" < "${sha_file}"
|
||||
if [[ "$(record_count)" -eq "${before}" ]]; then
|
||||
echo "::error::${sha_file} contributed no checksum entry, so the binary it describes would be published uncovered"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ! -s "${RECORDS}" ]]; then
|
||||
echo "::error::No valid checksum lines found in ${SHA_FILES[*]} ${PUBLISHED_SUMS}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# First record per filename wins after sorting by filename then priority, so a
|
||||
# freshly built sidecar (1) beats an already published entry (2). Two different
|
||||
# hashes for one filename at the winning priority mean the inputs disagree, which
|
||||
# must not be resolved by picking one arbitrarily.
|
||||
if ! sort -t $'\t' -k1,1 -k2,2n "${RECORDS}" | awk -F '\t' '
|
||||
{
|
||||
filename = $1; priority = $2 + 0; hash = $3; marker = $4
|
||||
if (!(filename in winningPriority)) {
|
||||
winningPriority[filename] = priority
|
||||
winningHash[filename] = hash
|
||||
winningMarker[filename] = marker
|
||||
order[++count] = filename
|
||||
} else if (priority == winningPriority[filename] && hash != winningHash[filename]) {
|
||||
conflicting[filename] = 1
|
||||
}
|
||||
}
|
||||
END {
|
||||
failed = 0
|
||||
for (i = 1; i <= count; i++) {
|
||||
filename = order[i]
|
||||
if (filename in conflicting) {
|
||||
printf "::error::Conflicting checksums for %s - refusing to guess which is current\n", filename > "/dev/stderr"
|
||||
failed = 1
|
||||
continue
|
||||
}
|
||||
separator = (winningMarker[filename] == "*") ? " *" : " "
|
||||
printf "%s%s%s\n", winningHash[filename], separator, filename
|
||||
}
|
||||
exit failed
|
||||
}' > "${OUTPUT}"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Generated SHA256SUMS.txt ($(wc -l < "${OUTPUT}" | tr -d ' ') entries):"
|
||||
cat "${OUTPUT}"
|
||||
|
|
@ -112,10 +112,9 @@ mkdir -p "build-${MSYSTEM}"
|
|||
cd "${GITHUB_WORKSPACE}"/build-"${MSYSTEM}" || exit 1
|
||||
|
||||
#### Lua environment setup ####
|
||||
# Set up Lua 5.1 paths for translation processing and runtime
|
||||
LUA_PATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-path)" )
|
||||
LUA_PATH=$(luarocks --lua-version 5.1 path --lr-path)
|
||||
export LUA_PATH
|
||||
LUA_CPATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-cpath)" )
|
||||
LUA_CPATH=$(luarocks --lua-version 5.1 path --lr-cpath)
|
||||
export LUA_CPATH
|
||||
|
||||
echo ""
|
||||
|
|
|
|||
194
CI/check-mpackage-sync.lua
Executable file
194
CI/check-mpackage-sync.lua
Executable file
|
|
@ -0,0 +1,194 @@
|
|||
#!/usr/bin/env lua
|
||||
--[[
|
||||
Check packaged .mpackage archives against their checked-in sources.
|
||||
|
||||
Mudlet installs the .mpackage archive, not the loose config.lua/.xml files
|
||||
sitting next to it, so editing a source file without rebuilding the archive
|
||||
silently changes nothing at all.
|
||||
|
||||
Some of these packages are also published to the package repository
|
||||
(Mudlet/mudlet-package-repository), which offers updates by comparing the
|
||||
version in config.lua. A content change that keeps the old version number
|
||||
never reaches players who installed the package with mpkg.
|
||||
|
||||
Run with no arguments to check archive contents. Pass --base-ref to also
|
||||
require a version bump for any package whose contents changed:
|
||||
|
||||
lua CI/check-mpackage-sync.lua --base-ref origin/development
|
||||
]]
|
||||
|
||||
-- packages the package repository syncs weekly, where mpkg needs a version bump
|
||||
-- to offer the update - see update-core-packages.yml over there
|
||||
local publishedPackages = {
|
||||
"src/packages/deleteOldProfiles/deleteOldProfiles.mpackage",
|
||||
"src/packages/echo/echo.mpackage",
|
||||
"src/packages/enable-accessibility/enable-accessibility.mpackage",
|
||||
"src/packages/generic_mapper/generic_mapper.mpackage",
|
||||
"src/packages/mudlet-base-ui/mudlet-base-ui.mpackage",
|
||||
"src/packages/run-lua-code/run-lua-code.mpackage",
|
||||
}
|
||||
|
||||
local errors = {}
|
||||
|
||||
local function contains(list, wanted)
|
||||
for _, item in ipairs(list) do
|
||||
if item == wanted then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function quote(argument)
|
||||
return "'" .. argument:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function capture(command)
|
||||
local pipe = assert(io.popen(command, "r"))
|
||||
local output = pipe:read("*a")
|
||||
pipe:close()
|
||||
return output
|
||||
end
|
||||
|
||||
local function readFile(path)
|
||||
local file = io.open(path, "rb")
|
||||
if not file then return nil end
|
||||
local contents = file:read("*a")
|
||||
file:close()
|
||||
return contents
|
||||
end
|
||||
|
||||
-- Every entry in the archive, mapped to its bytes. Directory entries, which
|
||||
-- zipinfo lists with a trailing slash, are not files and are skipped.
|
||||
local function contentsOf(archive)
|
||||
local members = {}
|
||||
for name in capture("unzip -Z1 " .. quote(archive)):gmatch("[^\n]+") do
|
||||
if not name:match("/$") then
|
||||
members[name] = capture(string.format("unzip -p %s %s", quote(archive), quote(name)))
|
||||
end
|
||||
end
|
||||
return members
|
||||
end
|
||||
|
||||
local function sameContents(one, other)
|
||||
for name, bytes in pairs(one) do
|
||||
if other[name] ~= bytes then return false end
|
||||
end
|
||||
for name in pairs(other) do
|
||||
if one[name] == nil then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function versionOf(members)
|
||||
for line in (members["config.lua"] or ""):gmatch("[^\n]+") do
|
||||
local version = line:match("^version%s*=%s*(.-)%s*$")
|
||||
if version then
|
||||
return (version:gsub("^[%[\"']+", ""):gsub("[%]\"']+$", ""))
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Sortable form of a version, tolerating parts like "2" or "1.0.0rc1"
|
||||
local function versionParts(version)
|
||||
local parts = {}
|
||||
for part in version:gmatch("[^.]+") do
|
||||
parts[#parts + 1] = {tonumber(part:match("%d+")) or 0, part}
|
||||
end
|
||||
return parts
|
||||
end
|
||||
|
||||
local function isNewer(candidate, existing)
|
||||
local new, old = versionParts(candidate), versionParts(existing)
|
||||
for index = 1, math.max(#new, #old) do
|
||||
local newPart = new[index] or {0, ""}
|
||||
local oldPart = old[index] or {0, ""}
|
||||
if newPart[1] ~= oldPart[1] then return newPart[1] > oldPart[1] end
|
||||
if newPart[2] ~= oldPart[2] then return newPart[2] > oldPart[2] end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Archive contents at baseRef, or nil if the package is new there
|
||||
local function contentsAtBaseRef(path, baseRef)
|
||||
local temporary = os.tmpname()
|
||||
local archive = capture(string.format("git show %s 2>/dev/null", quote(baseRef .. ":" .. path)))
|
||||
if archive == "" then
|
||||
os.remove(temporary)
|
||||
return nil
|
||||
end
|
||||
|
||||
local file = assert(io.open(temporary, "wb"))
|
||||
file:write(archive)
|
||||
file:close()
|
||||
local members = contentsOf(temporary)
|
||||
os.remove(temporary)
|
||||
return members
|
||||
end
|
||||
|
||||
-- Every member with a file of the same name beside the archive must match it
|
||||
local function checkSourcesMatch(path, members)
|
||||
local directory = path:match("^(.*)/[^/]+$")
|
||||
for name, packaged in pairs(members) do
|
||||
local source = directory .. "/" .. name
|
||||
local onDisk = readFile(source)
|
||||
if onDisk and onDisk ~= packaged then
|
||||
table.insert(errors, string.format("%s does not match %s - rebuild the archive after editing the source", path, source))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function checkVersionBumped(path, members, baseRef)
|
||||
local was = contentsAtBaseRef(path, baseRef)
|
||||
if not was or sameContents(was, members) then return end
|
||||
|
||||
local old, new = versionOf(was), versionOf(members)
|
||||
if not new then
|
||||
table.insert(errors, string.format("%s has no version in its config.lua", path))
|
||||
elseif old and not isNewer(new, old) then
|
||||
table.insert(errors, string.format("%s changed but is still version %s - bump it so mpkg offers the update", path, new))
|
||||
end
|
||||
end
|
||||
|
||||
local baseRef
|
||||
for index = 1, #arg do
|
||||
if arg[index] == "--base-ref" then
|
||||
baseRef = arg[index + 1]
|
||||
elseif arg[index]:match("^%-%-base%-ref=") then
|
||||
baseRef = arg[index]:match("=(.*)$")
|
||||
end
|
||||
end
|
||||
|
||||
-- every default package lives in its own directory under src/packages, named
|
||||
-- after the package, holding the archive and the sources it was built from
|
||||
local packages = {}
|
||||
for name in capture("ls -1 src/packages"):gmatch("[^\n]+") do
|
||||
local archive = string.format("src/packages/%s/%s.mpackage", name, name)
|
||||
if readFile(archive) then table.insert(packages, archive) end
|
||||
end
|
||||
table.sort(packages)
|
||||
|
||||
for _, path in ipairs(publishedPackages) do
|
||||
if not contains(packages, path) then
|
||||
table.insert(errors, string.format("%s is listed as published but is not in src/packages", path))
|
||||
end
|
||||
end
|
||||
|
||||
for _, path in ipairs(packages) do
|
||||
local members = contentsOf(path)
|
||||
checkSourcesMatch(path, members)
|
||||
if baseRef and contains(publishedPackages, path) then
|
||||
checkVersionBumped(path, members, baseRef)
|
||||
end
|
||||
end
|
||||
|
||||
for _, message in ipairs(errors) do
|
||||
print("error: " .. message)
|
||||
end
|
||||
|
||||
if #errors > 0 then
|
||||
print(string.format("\n%d problem(s) found. Rebuild an archive from its sources with:", #errors))
|
||||
print(" cd src/packages/<name> && zip <name>.mpackage config.lua <name>.xml")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
print(string.format("%d mpackage archives match their sources.", #packages))
|
||||
74
CI/check-release-tag.sh
Executable file
74
CI/check-release-tag.sh
Executable file
|
|
@ -0,0 +1,74 @@
|
|||
#!/bin/bash
|
||||
# Usage: check-release-tag.sh <version> [tag]
|
||||
#
|
||||
# The updater offers the version from the release tag, not from APP_VERSION
|
||||
# (src/updater/Release.cpp), and SemVer needs three components - so "Mudlet-5.0" is
|
||||
# never offered to anyone, silently. CI/prepare-release-assets.sh cannot see it: that
|
||||
# check matches the tag as a prefix of the asset names, and "Mudlet-5.0.0-linux-x64"
|
||||
# does start with "Mudlet-5.0". Only the opposite mistake fails there.
|
||||
#
|
||||
# "Mudlet-5.0.0-rc1" is rejected as well - SemVer would accept it, but APP_VERSION
|
||||
# cannot carry a suffix, so the assets named after it would not match the tag.
|
||||
#
|
||||
# A PTB passes no tag, its tag being generated rather than pushed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-}"
|
||||
TAG="${2:-}"
|
||||
|
||||
if [ $# -lt 1 ] || [ $# -gt 2 ] || [ -z "${VERSION}" ]; then
|
||||
echo "usage: $(basename "$0") <version> [tag]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# A multi-line message cannot become a GitHub annotation, so summarise in one line
|
||||
annotate() {
|
||||
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
|
||||
echo "::error::$1"
|
||||
fi
|
||||
}
|
||||
|
||||
# Same shape SemVer::getRegExp() accepts, leading zeros and all
|
||||
if ! [[ "${VERSION}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
annotate "APP_VERSION '${VERSION}' is not a three-component version - nothing built from it would ever be offered as an update to anyone who already has Mudlet installed"
|
||||
cat >&2 <<EOF
|
||||
error: APP_VERSION '${VERSION}' is not a three-component version like 5.0.0.
|
||||
|
||||
Mudlet's updater only recognises three-component semantic versions, so a build
|
||||
carrying this version is never offered to anyone who already has Mudlet installed -
|
||||
including public test builds. Set a three-component version in
|
||||
set(APP_VERSION ...) in CMakeLists.txt.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "${TAG}" ] && [ "${TAG}" != "Mudlet-${VERSION}" ]; then
|
||||
annotate "Release tag '${TAG}' does not match APP_VERSION '${VERSION}' - it has to be exactly 'Mudlet-${VERSION}', or auto-update stops working for every existing user without saying so"
|
||||
cat >&2 <<EOF
|
||||
error: release tag '${TAG}' does not match APP_VERSION '${VERSION}'.
|
||||
The tag has to be exactly 'Mudlet-${VERSION}'.
|
||||
|
||||
Publishing under a mismatched tag breaks auto-update, without saying so. The
|
||||
updater reads the version it offers from the tag rather than from the binary, so a
|
||||
tag like 'Mudlet-5.0' offers version '5.0' - not a three-component semantic
|
||||
version, therefore never newer than the installed 4.22.0, therefore never offered.
|
||||
No error is shown and the update check logs "0 update(s) available", the same line
|
||||
it logs when there is genuinely nothing new.
|
||||
|
||||
It also desynchronises macOS: create-github-release.yml puts the tag's version into
|
||||
<sparkle:version> while the app reports APP_VERSION as its CFBundleVersion, so a
|
||||
tag ahead of APP_VERSION leaves Sparkle re-offering an update the installed app can
|
||||
never satisfy.
|
||||
|
||||
Delete the tag and push it again as 'Mudlet-${VERSION}'. If '${VERSION}' is not the
|
||||
version you meant to release, change set(APP_VERSION ...) in CMakeLists.txt first.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A release log with no line here reads the same whether the tag was compared or the
|
||||
# guard was never reached
|
||||
if [ -n "${TAG}" ]; then
|
||||
echo "Release tag '${TAG}' matches APP_VERSION '${VERSION}'."
|
||||
fi
|
||||
210
CI/discord-ipc-fixture.py
Normal file
210
CI/discord-ipc-fixture.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fake Discord IPC server for Mudlet's Lua self-tests.
|
||||
|
||||
Speaks enough of the discord-rpc wire protocol that the bundled
|
||||
libdiscord-rpc library believes a Discord client is running, reports a
|
||||
logged-in user, and accepts rich presence updates. Every frame the library
|
||||
sends is appended to a capture file so the Lua specs can assert on the
|
||||
SET_ACTIVITY payload that actually reached "Discord", rather than on the
|
||||
return value of the setter that produced it.
|
||||
|
||||
Wire framing: [opcode:uint32 LE][length:uint32 LE][json payload]
|
||||
opcode 0 = HANDSHAKE (client -> server)
|
||||
opcode 1 = FRAME (both directions)
|
||||
opcode 2 = CLOSE
|
||||
opcode 3 = PING
|
||||
opcode 4 = PONG
|
||||
|
||||
Capture file format: one JSON object per line,
|
||||
{"op": <opcode>, "payload": <parsed frame> | {"raw": "<undecodable text>"}}
|
||||
Records are appended under a lock to an O_APPEND descriptor so a spec reading
|
||||
the file concurrently never sees a half-written or spliced line.
|
||||
|
||||
The C++ equivalent used by TDiscordModeTest is
|
||||
test/functional_tests/DiscordIpcServerStub.cpp - keep the two in step.
|
||||
|
||||
Usage:
|
||||
discord-ipc-fixture.py --ready-file <path> [--runtime-dir <dir>]
|
||||
[--capture-file <path>] [--username <name>]
|
||||
|
||||
It prints (and, with --ready-file, writes) shell-style KEY=VALUE lines naming
|
||||
the runtime directory and capture file once it is listening. Point
|
||||
XDG_RUNTIME_DIR at the former before Mudlet starts: discord-rpc's reconnect
|
||||
backoff is process-global and survives Discord_Shutdown, so a server that only
|
||||
appears after the first failed connection attempt can cost up to ~120s.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
SOCKET_FILE_NAME = "discord-ipc-0"
|
||||
|
||||
OP_HANDSHAKE = 0
|
||||
OP_FRAME = 1
|
||||
OP_CLOSE = 2
|
||||
OP_PING = 3
|
||||
OP_PONG = 4
|
||||
|
||||
# discord-rpc's own send buffer is 16KB, so anything near this is nonsense:
|
||||
MAXIMUM_FRAME_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
def ready_payload(username):
|
||||
return {
|
||||
"cmd": "DISPATCH",
|
||||
"evt": "READY",
|
||||
"data": {
|
||||
"v": 1,
|
||||
"config": {
|
||||
"cdn_host": "cdn.discordapp.com",
|
||||
"api_endpoint": "//discord.com/api",
|
||||
"environment": "production",
|
||||
},
|
||||
"user": {
|
||||
"id": "111111111111111111",
|
||||
"username": username,
|
||||
"discriminator": "0",
|
||||
"global_name": username,
|
||||
"avatar": None,
|
||||
"bot": False,
|
||||
"flags": 0,
|
||||
"premium_type": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CaptureLog:
|
||||
def __init__(self, path):
|
||||
self._fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def append(self, opcode, payload_bytes):
|
||||
try:
|
||||
payload = json.loads(payload_bytes.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
payload = {"raw": payload_bytes.decode("utf-8", "replace")}
|
||||
line = (json.dumps({"op": opcode, "payload": payload}, sort_keys=True) + "\n").encode("utf-8")
|
||||
# os.write() may write less than it was given, and discord-rpc's
|
||||
# reconnects overlap connections, so two serve_connection() threads can
|
||||
# be here at once. Without the lock one record's remainder could land
|
||||
# after another record's first write, splicing both into one malformed
|
||||
# line that framesAfter() would silently drop.
|
||||
with self._lock:
|
||||
while line:
|
||||
line = line[os.write(self._fd, line):]
|
||||
|
||||
|
||||
def frame(opcode, payload_bytes):
|
||||
return struct.pack("<II", opcode, len(payload_bytes)) + payload_bytes
|
||||
|
||||
|
||||
def read_exact(conn, count):
|
||||
buf = b""
|
||||
while len(buf) < count:
|
||||
chunk = conn.recv(count - len(buf))
|
||||
if not chunk:
|
||||
return None
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def serve_connection(conn, capture, username):
|
||||
# discord-rpc drops the socket without a CLOSE frame when it shuts down or
|
||||
# switches application ID, so an aborted connection is routine here.
|
||||
with conn:
|
||||
try:
|
||||
while True:
|
||||
header = read_exact(conn, 8)
|
||||
if not header:
|
||||
return
|
||||
opcode, length = struct.unpack("<II", header)
|
||||
if length > MAXIMUM_FRAME_BYTES:
|
||||
# A desynchronised stream would otherwise have us block on a
|
||||
# length that never arrives, with the specs polling a capture
|
||||
# file that has quietly stopped growing.
|
||||
sys.stdout.write("discord-ipc-fixture: refusing a %d byte frame, closing the connection\n" % length)
|
||||
sys.stdout.flush()
|
||||
return
|
||||
payload = read_exact(conn, length) if length else b""
|
||||
if payload is None:
|
||||
return
|
||||
capture.append(opcode, payload)
|
||||
if opcode == OP_HANDSHAKE:
|
||||
conn.sendall(frame(OP_FRAME, json.dumps(ready_payload(username)).encode("utf-8")))
|
||||
elif opcode == OP_PING:
|
||||
conn.sendall(frame(OP_PONG, payload))
|
||||
elif opcode == OP_CLOSE:
|
||||
return
|
||||
# opcode 1 (FRAME, e.g. SET_ACTIVITY) needs no reply - the real
|
||||
# Discord client answers it, but discord-rpc ignores the answer.
|
||||
except OSError as error:
|
||||
sys.stdout.write("discord-ipc-fixture: connection ended: %s\n" % error)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fake Discord IPC server for Mudlet's Lua self-tests")
|
||||
parser.add_argument("--runtime-dir", help="directory to create the discord-ipc-0 socket in (default: a fresh short temporary directory)")
|
||||
parser.add_argument("--capture-file", help="file to append captured frames to (default: discord-frames.jsonl inside the runtime directory)")
|
||||
parser.add_argument("--username", default="MudletSelfTest", help="username the READY dispatch reports as logged in")
|
||||
parser.add_argument("--ready-file", help="file to write the KEY=VALUE handover lines to once listening")
|
||||
args = parser.parse_args()
|
||||
|
||||
runtime_dir = args.runtime_dir
|
||||
if runtime_dir:
|
||||
os.makedirs(runtime_dir, exist_ok=True)
|
||||
# Qt refuses to use an XDG_RUNTIME_DIR that anyone else can read, and
|
||||
# falls back with a warning:
|
||||
os.chmod(runtime_dir, 0o700)
|
||||
else:
|
||||
# Deliberately short: sizeof(sockaddr_un::sun_path) is 108 on Linux and
|
||||
# 104 on macOS, and the whole socket path has to fit.
|
||||
runtime_dir = tempfile.mkdtemp(prefix="mdxdg-")
|
||||
|
||||
socket_path = os.path.join(runtime_dir, SOCKET_FILE_NAME)
|
||||
if len(socket_path) >= 100:
|
||||
sys.stderr.write("discord-ipc-fixture: socket path %s is too long for AF_UNIX\n" % socket_path)
|
||||
return 1
|
||||
|
||||
capture_file = args.capture_file or os.path.join(runtime_dir, "discord-frames.jsonl")
|
||||
# Truncate any capture left over from an earlier run so the specs never see
|
||||
# frames from a previous suite:
|
||||
with open(capture_file, "w"):
|
||||
pass
|
||||
capture = CaptureLog(capture_file)
|
||||
|
||||
if os.path.exists(socket_path):
|
||||
os.unlink(socket_path)
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(socket_path)
|
||||
server.listen(8)
|
||||
|
||||
handover = "MUDLET_TEST_DISCORD_RUNTIME_DIR=%s\nMUDLET_TEST_DISCORD_CAPTURE_FILE=%s\n" % (runtime_dir, capture_file)
|
||||
if args.ready_file:
|
||||
with open(args.ready_file, "w") as handle:
|
||||
handle.write(handover)
|
||||
sys.stdout.write(handover)
|
||||
sys.stdout.write("discord-ipc-fixture: listening on %s as Discord user %s\n" % (socket_path, args.username))
|
||||
sys.stdout.flush()
|
||||
|
||||
while True:
|
||||
try:
|
||||
conn, _ = server.accept()
|
||||
except OSError as error:
|
||||
# Staying up matters: every spec still to run would otherwise wait
|
||||
# out its timeout against a capture file nobody is writing to.
|
||||
sys.stdout.write("discord-ipc-fixture: accept failed: %s\n" % error)
|
||||
sys.stdout.flush()
|
||||
continue
|
||||
threading.Thread(target=serve_connection, args=(conn, capture, args.username), daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
113
CI/http-fixture-server.py
Normal file
113
CI/http-fixture-server.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Minimal fixture HTTP server for Mudlet's busted networking specs.
|
||||
|
||||
Serves the sibling ``http-fixtures/`` directory over localhost so the Lua test
|
||||
suite can exercise getHTTP/downloadFile against a real, local endpoint instead
|
||||
of the public internet.
|
||||
|
||||
Requests below ``/echo`` are answered by an echo endpoint instead of from disk:
|
||||
it accepts GET and every verb this handler has no method of its own for
|
||||
(postHTTP/putHTTP/deleteHTTP/customHTTP all need one) and reports the method,
|
||||
path, request headers and body it received back in the response body, which is
|
||||
what lets a spec prove that what Mudlet put on the wire is what the caller
|
||||
asked for. HEAD is the one exception: it keeps serving files from disk.
|
||||
|
||||
An OS-assigned (ephemeral) port is used rather than a fixed one: Mudlet CI may
|
||||
run several jobs on the same machine, and a hard-coded port would risk
|
||||
collisions there. The chosen port is written to the file named by the
|
||||
``MUDLET_TEST_HTTP_PORT_FILE`` environment variable so the launching CI step can
|
||||
forward it to Mudlet as ``MUDLET_TEST_HTTP_PORT``.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import os
|
||||
import socketserver
|
||||
|
||||
FIXTURES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "http-fixtures")
|
||||
|
||||
ECHO_PATH = "/echo"
|
||||
|
||||
|
||||
class QuietHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=FIXTURES_DIR, **kwargs)
|
||||
|
||||
def log_message(self, *args):
|
||||
# Keep CI logs quiet; the tests assert on effects, not on server chatter.
|
||||
pass
|
||||
|
||||
def end_headers(self):
|
||||
# Both are sent on every response, including the static file ones, so a
|
||||
# spec can assert that the response headers and cookies tables Mudlet
|
||||
# builds reach Lua.
|
||||
self.send_header("X-Mudlet-Fixture", "1")
|
||||
self.send_header("Set-Cookie", "mudlet-fixture=1; Path=/")
|
||||
super().end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
if self.echo_requested():
|
||||
self.echo()
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def __getattr__(self, name):
|
||||
# BaseHTTPRequestHandler dispatches "VERB /path" to a do_VERB method and
|
||||
# answers 501 when there is none. Only GET and HEAD have one, so every
|
||||
# other verb - POST/PUT/DELETE plus whatever customHTTP() invents - is
|
||||
# routed here and handled by the echo endpoint. Matching against the
|
||||
# verb being dispatched keeps a mistyped attribute elsewhere in this
|
||||
# class an AttributeError instead of silently becoming an echo.
|
||||
# __dict__ rather than self.command: the attribute only exists once a
|
||||
# request line has been parsed, and reading it through the instance
|
||||
# would come straight back here.
|
||||
if name.startswith("do_") and name == "do_%s" % self.__dict__.get("command"):
|
||||
return self.echo
|
||||
raise AttributeError(name)
|
||||
|
||||
def echo_requested(self):
|
||||
return self.path == ECHO_PATH or self.path.startswith(ECHO_PATH + "/") or self.path.startswith(ECHO_PATH + "?")
|
||||
|
||||
def echo(self):
|
||||
if not self.echo_requested():
|
||||
self.send_error(404, "Not Found", "only %s answers this method" % ECHO_PATH)
|
||||
return
|
||||
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
except ValueError:
|
||||
length = 0
|
||||
body = self.rfile.read(length) if length > 0 else b""
|
||||
|
||||
lines = ["method=%s" % self.command, "path=%s" % self.path]
|
||||
for name, value in self.headers.items():
|
||||
lines.append("header:%s=%s" % (name.lower(), value))
|
||||
# Body last: it is the only part that may itself contain newlines.
|
||||
lines.append("body=%s" % body.decode("utf-8", "replace"))
|
||||
payload = "\n".join(lines).encode("utf-8")
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
|
||||
def main():
|
||||
# Single-threaded on purpose: the specs issue one request at a time, and
|
||||
# HTTP/1.0 (the default here) closes each connection, so no request can
|
||||
# block another.
|
||||
with socketserver.TCPServer(("127.0.0.1", 0), QuietHandler) as httpd:
|
||||
port = httpd.server_address[1]
|
||||
port_file = os.environ.get("MUDLET_TEST_HTTP_PORT_FILE")
|
||||
if port_file:
|
||||
# Write then rename so the launcher never reads a torn/empty port.
|
||||
tmp_file = port_file + ".tmp"
|
||||
with open(tmp_file, "w", encoding="utf-8") as handle:
|
||||
handle.write(str(port))
|
||||
os.replace(tmp_file, port_file)
|
||||
print(f"Serving Mudlet test fixtures on http://127.0.0.1:{port}", flush=True)
|
||||
httpd.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
CI/http-fixtures/fixture.txt
Normal file
1
CI/http-fixtures/fixture.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Mudlet self-test HTTP fixture.
|
||||
387
CI/mmcp-peer.py
Normal file
387
CI/mmcp-peer.py
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Scripted MMCP chat peer for Mudlet's busted networking specs.
|
||||
|
||||
Mudlet's mmcp.* Lua API only has observable effects when a chat peer is on the
|
||||
other end of a socket, so the specs need a real peer rather than a mock. This is
|
||||
that peer: it accepts the call Mudlet places with mmcp.call(), completes the
|
||||
MudMaster handshake, records every protocol command Mudlet sends, and sends
|
||||
commands back when the specs ask it to.
|
||||
|
||||
It lives in its own file rather than inside http-fixture-server.py because the
|
||||
two share nothing: that one is a stock static file server, this one is a
|
||||
stateful binary protocol peer. A single process would also mean one fixture
|
||||
failing takes the other down with it.
|
||||
|
||||
Three channels, all inside the directory named by ``MUDLET_TEST_MMCP_DIR`` (one
|
||||
environment variable, read by both this process and Mudlet):
|
||||
|
||||
port the OS-assigned listening port, written once the socket is
|
||||
accepting. Ephemeral rather than MMCP's default 4050: CI jobs
|
||||
and parallel local worktrees would collide on a fixed port.
|
||||
capture.json everything this peer has seen, rewritten atomically after every
|
||||
change so a reader never gets a torn file.
|
||||
commands/ one JSON file per instruction from the specs, picked up in
|
||||
numeric order and deleted once carried out.
|
||||
|
||||
Only one call is held at a time: a new one replaces the old. Effects that need
|
||||
two peers at once (mmcp.setPrivate's filtering, mmcp.serve's forwarding, a
|
||||
non-empty connection or peek list) are out of reach until this grows a second
|
||||
listening port.
|
||||
|
||||
Wire format, from src/MMCP.h and src/MMCPClient.cpp:
|
||||
|
||||
Mudlet -> peer on connect: "CHAT:<name>\\n<address><port padded to width 5>"
|
||||
peer -> Mudlet, accepting: "YES:<name>\\n" (or "NO:<name>\\n" to refuse)
|
||||
either direction after: <command byte><payload><0xff>
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import selectors
|
||||
import socket
|
||||
import sys
|
||||
|
||||
# Command bytes, from the MMCPChatCommand enum in src/MMCP.h. Commands not
|
||||
# listed here are still recorded, by their numeric code.
|
||||
COMMAND_NAMES = {
|
||||
1: "NameChange",
|
||||
2: "RequestConnections",
|
||||
3: "ConnectionList",
|
||||
4: "TextEveryone",
|
||||
5: "TextPersonal",
|
||||
6: "TextGroup",
|
||||
7: "Message",
|
||||
8: "DoNotDisturb",
|
||||
19: "Version",
|
||||
26: "PingRequest",
|
||||
27: "PingResponse",
|
||||
28: "PeekConnections",
|
||||
29: "PeekList",
|
||||
30: "Snoop",
|
||||
31: "SnoopData",
|
||||
32: "SnoopColor",
|
||||
40: "SideChannel",
|
||||
}
|
||||
|
||||
END = 0xFF
|
||||
PING_REQUEST = 26
|
||||
PING_RESPONSE = 27
|
||||
VERSION = 19
|
||||
|
||||
# Mudlet only sends side channel data to peers whose version string says they
|
||||
# are Mudlet (MMCPServer::sendSideChannel), so claim to be one.
|
||||
PEER_VERSION = "Mudlet 0.0.0-busted-peer"
|
||||
PEER_NAME = "BustedPeer"
|
||||
|
||||
# Enough history for a spec to look back over a few steps without the capture
|
||||
# file growing without bound over a whole suite run.
|
||||
MAX_EVENTS = 200
|
||||
POLL_SECONDS = 0.01
|
||||
|
||||
|
||||
class MMCPPeer:
|
||||
def __init__(self, directory):
|
||||
self.directory = directory
|
||||
self.commands_dir = os.path.join(directory, "commands")
|
||||
self.capture_path = os.path.join(directory, "capture.json")
|
||||
self.port_path = os.path.join(directory, "port")
|
||||
|
||||
self.events = []
|
||||
self.seq = 0
|
||||
self.connections = 0
|
||||
self.caller = None
|
||||
self.name = PEER_NAME
|
||||
self.version = PEER_VERSION
|
||||
self.accept_calls = True
|
||||
|
||||
self.selector = selectors.DefaultSelector()
|
||||
self.connection = None
|
||||
self.state = "idle"
|
||||
self.buffer = bytearray()
|
||||
|
||||
self.listener = self.listen()
|
||||
self.port = self.listener.getsockname()[1]
|
||||
# A second address that only ever records who dialled it and hangs up.
|
||||
# It is how a spec sees that Mudlet acted on an address a peer handed it
|
||||
# (a connection list), which is otherwise reported to the console alone.
|
||||
self.sink = self.listen()
|
||||
self.dial_port = self.sink.getsockname()[1]
|
||||
|
||||
@staticmethod
|
||||
def listen():
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(4)
|
||||
server.setblocking(False)
|
||||
return server
|
||||
|
||||
# -- capture ------------------------------------------------------------
|
||||
|
||||
def record(self, event):
|
||||
self.seq += 1
|
||||
event["seq"] = self.seq
|
||||
self.events.append(event)
|
||||
del self.events[:-MAX_EVENTS]
|
||||
self.write_capture()
|
||||
|
||||
def write_capture(self):
|
||||
payload = {
|
||||
"seq": self.seq,
|
||||
"port": self.port,
|
||||
"dial_port": self.dial_port,
|
||||
"connections": self.connections,
|
||||
"connected": self.connection is not None,
|
||||
"accepting": self.accept_calls,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"caller": self.caller,
|
||||
"events": self.events,
|
||||
}
|
||||
# Write then rename so a spec reading mid-update sees the old file
|
||||
# rather than half of the new one.
|
||||
tmp_path = self.capture_path + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle)
|
||||
os.replace(tmp_path, self.capture_path)
|
||||
|
||||
# -- connection ---------------------------------------------------------
|
||||
|
||||
def accept(self):
|
||||
connection, address = self.listener.accept()
|
||||
connection.setblocking(False)
|
||||
connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
if self.connection is not None:
|
||||
# Only one call at a time: an earlier caller that never went away
|
||||
# would otherwise keep receiving what the specs meant for this one.
|
||||
self.close_connection("replaced by a new call")
|
||||
self.connection = connection
|
||||
self.selector.register(connection, selectors.EVENT_READ)
|
||||
self.state = "handshake"
|
||||
self.buffer.clear()
|
||||
self.caller = None
|
||||
self.connections += 1
|
||||
self.record({"type": "connect", "from": "%s:%d" % address})
|
||||
|
||||
def accept_sink(self):
|
||||
connection, address = self.sink.accept()
|
||||
connection.close()
|
||||
self.record({"type": "dialled", "from": "%s:%d" % address})
|
||||
|
||||
def close_connection(self, reason):
|
||||
if self.connection is None:
|
||||
return
|
||||
try:
|
||||
self.selector.unregister(self.connection)
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
try:
|
||||
self.connection.close()
|
||||
except OSError:
|
||||
pass
|
||||
self.connection = None
|
||||
self.state = "idle"
|
||||
self.buffer.clear()
|
||||
self.record({"type": "disconnect", "reason": reason})
|
||||
|
||||
def send(self, data):
|
||||
if self.connection is None:
|
||||
self.record({"type": "send_failed", "reason": "no connection"})
|
||||
return False
|
||||
try:
|
||||
self.connection.sendall(data)
|
||||
except OSError as error:
|
||||
self.record({"type": "send_failed", "reason": str(error)})
|
||||
return False
|
||||
return True
|
||||
|
||||
def send_command(self, code, payload):
|
||||
return self.send(bytes([code]) + payload + bytes([END]))
|
||||
|
||||
def read(self):
|
||||
try:
|
||||
chunk = self.connection.recv(4096)
|
||||
except BlockingIOError:
|
||||
# BlockingIOError is an OSError, so it has to be let through before
|
||||
# the catch-all below turns a socket error into an end of call.
|
||||
return
|
||||
except OSError:
|
||||
chunk = b""
|
||||
if not chunk:
|
||||
self.close_connection("closed by Mudlet")
|
||||
return
|
||||
self.buffer.extend(chunk)
|
||||
if self.state == "handshake":
|
||||
self.handle_handshake()
|
||||
if self.state == "connected":
|
||||
self.handle_commands()
|
||||
|
||||
def handle_handshake(self):
|
||||
newline = self.buffer.find(b"\n")
|
||||
if newline == -1:
|
||||
return
|
||||
if not self.buffer.startswith(b"CHAT:"):
|
||||
self.record({"type": "bad_handshake", "raw": self.buffer.decode("latin-1")})
|
||||
self.close_connection("handshake did not start with CHAT:")
|
||||
return
|
||||
# The address and port that follow the newline have no terminator of
|
||||
# their own; Mudlet writes them in the same call as the name and parses
|
||||
# an incoming call the same way, taking the last 5 bytes as the port.
|
||||
rest = bytes(self.buffer[newline + 1:])
|
||||
if len(rest) < 5:
|
||||
return
|
||||
|
||||
caller_name = bytes(self.buffer[5:newline]).decode("latin-1")
|
||||
raw = bytes(self.buffer).decode("latin-1")
|
||||
self.buffer.clear()
|
||||
self.caller = {
|
||||
"name": caller_name,
|
||||
"address": rest[:-5].decode("latin-1"),
|
||||
"port": rest[-5:].decode("latin-1").strip(),
|
||||
"raw": raw,
|
||||
}
|
||||
self.record({"type": "handshake", "caller": self.caller})
|
||||
|
||||
if not self.accept_calls:
|
||||
self.send(("NO:%s\n" % self.name).encode("latin-1"))
|
||||
self.close_connection("call refused")
|
||||
return
|
||||
|
||||
self.state = "connected"
|
||||
# One write: Mudlet reads whatever has arrived when it handles the
|
||||
# acceptance, and only understands a command tacked onto the end of it
|
||||
# if the command is complete.
|
||||
self.send(("YES:%s\n" % self.name).encode("latin-1")
|
||||
+ bytes([VERSION]) + self.version.encode("latin-1") + bytes([END]))
|
||||
|
||||
def handle_commands(self):
|
||||
while True:
|
||||
end = self.buffer.find(bytes([END]))
|
||||
if end == -1:
|
||||
return
|
||||
code = self.buffer[0]
|
||||
payload = bytes(self.buffer[1:end])
|
||||
del self.buffer[:end + 1]
|
||||
self.on_command(code, payload)
|
||||
|
||||
def on_command(self, code, payload):
|
||||
self.record({
|
||||
"type": "command",
|
||||
"code": code,
|
||||
"name": COMMAND_NAMES.get(code, "Unknown"),
|
||||
"text": payload.decode("latin-1"),
|
||||
"hex": payload.hex(),
|
||||
})
|
||||
if code == PING_REQUEST:
|
||||
# What a real peer does, and what lets a spec watch a full ping
|
||||
# round trip rather than only the outgoing half.
|
||||
self.send_command(PING_RESPONSE, payload)
|
||||
|
||||
# -- commands from the specs -------------------------------------------
|
||||
|
||||
def poll_commands(self):
|
||||
try:
|
||||
names = os.listdir(self.commands_dir)
|
||||
except OSError:
|
||||
return
|
||||
queued = []
|
||||
for name in names:
|
||||
stem, extension = os.path.splitext(name)
|
||||
# Specs write "<n>.json.tmp" and rename it into place, so a partly
|
||||
# written command is never picked up.
|
||||
if extension == ".json" and stem.isdigit():
|
||||
queued.append((int(stem), name))
|
||||
for _, name in sorted(queued):
|
||||
path = os.path.join(self.commands_dir, name)
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
command = json.load(handle)
|
||||
except (OSError, ValueError) as error:
|
||||
command = None
|
||||
self.record({"type": "command_error", "file": name, "error": str(error)})
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
if command is not None:
|
||||
self.run_command(command)
|
||||
|
||||
def run_command(self, command):
|
||||
try:
|
||||
self.dispatch_command(command)
|
||||
except (OSError, TypeError, ValueError) as error:
|
||||
# A malformed command is one spec's problem. Dying over it would
|
||||
# leave every later spec waiting on a peer that is no longer there.
|
||||
self.record({"type": "command_error", "error": str(error)})
|
||||
|
||||
def dispatch_command(self, command):
|
||||
action = command.get("action")
|
||||
if action == "send":
|
||||
text = command.get("text", "")
|
||||
self.send_command(int(command.get("code", 0)), text.encode("latin-1", "replace"))
|
||||
elif action == "send_hex":
|
||||
self.send(bytes.fromhex(command.get("hex", "")))
|
||||
elif action == "close":
|
||||
self.close_connection("closed on request")
|
||||
elif action == "accept":
|
||||
self.accept_calls = bool(command.get("accept", True))
|
||||
else:
|
||||
self.record({"type": "command_error", "error": "unknown action: %r" % (action,)})
|
||||
return
|
||||
self.record({"type": "command_done", "action": action})
|
||||
|
||||
# -- main loop ----------------------------------------------------------
|
||||
|
||||
def run(self):
|
||||
os.makedirs(self.commands_dir, exist_ok=True)
|
||||
self.selector.register(self.listener, selectors.EVENT_READ)
|
||||
self.selector.register(self.sink, selectors.EVENT_READ)
|
||||
self.write_capture()
|
||||
self.write_port()
|
||||
print("MMCP peer '%s' listening on 127.0.0.1:%d, sink on %d"
|
||||
% (self.name, self.port, self.dial_port), flush=True)
|
||||
while True:
|
||||
for key, _ in self.selector.select(POLL_SECONDS):
|
||||
if key.fileobj is self.listener:
|
||||
self.accept()
|
||||
elif key.fileobj is self.sink:
|
||||
self.accept_sink()
|
||||
elif key.fileobj is self.connection:
|
||||
self.read()
|
||||
self.poll_commands()
|
||||
|
||||
def write_port(self):
|
||||
# Written last, and atomically, so its presence means the peer is
|
||||
# already accepting connections.
|
||||
tmp_path = self.port_path + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(str(self.port))
|
||||
os.replace(tmp_path, self.port_path)
|
||||
|
||||
def forget_port(self):
|
||||
# The specs read the port file to decide whether there is a peer worth
|
||||
# talking to, so a peer that is going away has to take it with it:
|
||||
# otherwise every spec waits out its handshake timeout against a socket
|
||||
# nobody is listening on.
|
||||
try:
|
||||
os.remove(self.port_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
directory = os.environ.get("MUDLET_TEST_MMCP_DIR")
|
||||
if not directory:
|
||||
print("MUDLET_TEST_MMCP_DIR is not set", file=sys.stderr)
|
||||
return 1
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
peer = MMCPPeer(directory)
|
||||
try:
|
||||
peer.run()
|
||||
finally:
|
||||
peer.forget_port()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
105
CI/prepare-release-assets.sh
Executable file
105
CI/prepare-release-assets.sh
Executable file
|
|
@ -0,0 +1,105 @@
|
|||
#!/bin/bash
|
||||
# Validates the assets downloaded from the platform build runs before they are
|
||||
# published to a GitHub Release, and sets aside any that do not belong.
|
||||
#
|
||||
# A PTB tag is "Mudlet-<VERSION><-ptb-DATE>-<COMMIT>" and its asset filenames are
|
||||
# built from the same values, so they share that prefix; a stable release tag is
|
||||
# the pushed git tag, which spells "Mudlet-<VERSION>" and so shares the prefix
|
||||
# too. Anything else came from a different build: the two platform build workflows
|
||||
# can be re-run independently, a re-run recomputes the PTB date and so carries a
|
||||
# newer prefix than the tag, and create-github-release.yml always pairs the newest
|
||||
# successful run of each platform. Publishing such a file would put a binary from
|
||||
# build B into the release for build A, whose in-app version does not match the
|
||||
# release tag - and would leave the release holding a binary that no SHA256SUMS.txt
|
||||
# line can cover, because its checksum sidecar belongs to the other build.
|
||||
#
|
||||
# Setting an asset aside does not remove anything already published: a binary from
|
||||
# another build that an earlier run uploaded stays on the release, and stays
|
||||
# covered because assemble-release-checksums.sh merges its published entry
|
||||
# forward.
|
||||
#
|
||||
# Usage: prepare-release-assets.sh <assets-dir> <release-tag> <release-type>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ASSETS_DIR="${1:?assets directory required}"
|
||||
RELEASE_TAG="${2:?release tag required}"
|
||||
RELEASE_TYPE="${3:?release type required}"
|
||||
|
||||
mkdir -p "${ASSETS_DIR}"
|
||||
|
||||
echo "Downloaded assets:"
|
||||
find "${ASSETS_DIR}" -type f | sort
|
||||
|
||||
# Everything published alongside the binaries, rather than a list of binary
|
||||
# suffixes: an unrecognised suffix has to be treated as a binary that needs
|
||||
# checking, or adding an asset type would silently exempt it
|
||||
is_release_binary() {
|
||||
local name="$1"
|
||||
[[ "${name}" != "SHA256SUMS.txt" && "${name}" != *.sha256 ]]
|
||||
}
|
||||
|
||||
# Whether any file matching the pattern exists. `find | grep -q .` would be
|
||||
# simpler but exits non-zero under `set -o pipefail` when find is still writing as
|
||||
# grep leaves, which would report a present asset as missing.
|
||||
have_asset() {
|
||||
[[ -n "$(find "${ASSETS_DIR}" -name "$1" -type f -print -quit)" ]]
|
||||
}
|
||||
|
||||
# Set aside assets belonging to a different build. A .sha256 sidecar is judged by
|
||||
# the binary it describes, so a rejected binary takes its sidecar with it.
|
||||
# CI/set-build-info.sh lowercases VERSION while a git tag keeps its case, so the
|
||||
# prefix has to be compared case-insensitively.
|
||||
REJECTED_DIR="${ASSETS_DIR%/}-rejected"
|
||||
REJECTED=()
|
||||
shopt -s nocasematch
|
||||
while IFS= read -r asset_path; do
|
||||
asset_name="$(basename "${asset_path}")"
|
||||
binary_name="${asset_name%.sha256}"
|
||||
if ! is_release_binary "${binary_name}"; then
|
||||
continue
|
||||
fi
|
||||
if [[ "${binary_name}" == "${RELEASE_TAG}"* ]]; then
|
||||
continue
|
||||
fi
|
||||
mkdir -p "${REJECTED_DIR}"
|
||||
mv "${asset_path}" "${REJECTED_DIR}/"
|
||||
REJECTED+=("${asset_name}")
|
||||
done < <(find "${ASSETS_DIR}" -type f | sort)
|
||||
shopt -u nocasematch
|
||||
|
||||
if [[ ${#REJECTED[@]} -gt 0 ]]; then
|
||||
echo "::warning::Ignoring ${#REJECTED[@]} asset(s) from a different build than ${RELEASE_TAG}: ${REJECTED[*]}"
|
||||
fi
|
||||
|
||||
MISSING=()
|
||||
if ! have_asset '*.AppImage.tar'; then
|
||||
MISSING+=("Linux (.AppImage.tar)")
|
||||
fi
|
||||
if ! have_asset '*-arm64.dmg'; then
|
||||
MISSING+=("macOS (arm64 .dmg)")
|
||||
fi
|
||||
if ! have_asset '*-x86_64.dmg'; then
|
||||
MISSING+=("macOS (x86_64 .dmg)")
|
||||
fi
|
||||
if ! have_asset '*.exe'; then
|
||||
MISSING+=("Windows (.exe)")
|
||||
fi
|
||||
|
||||
if [[ ${#MISSING[@]} -gt 0 ]]; then
|
||||
echo "::warning::Missing release assets for: ${MISSING[*]}"
|
||||
fi
|
||||
|
||||
# Stable releases must have all platforms; PTB tolerates partial
|
||||
if [[ "${RELEASE_TYPE}" == "release" && ${#MISSING[@]} -gt 0 ]]; then
|
||||
echo "::error::Stable release is missing assets for: ${MISSING[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! have_asset '*.AppImage.tar' && ! have_asset '*.dmg' && ! have_asset '*.exe'; then
|
||||
echo "::error::No release assets found for any platform"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Assets to publish for ${RELEASE_TAG}:"
|
||||
find "${ASSETS_DIR}" -type f | sort
|
||||
|
|
@ -19,7 +19,9 @@
|
|||
# 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. #
|
||||
###########################################################################
|
||||
|
||||
# Version: 2.3.0 Switch from MINGW64 to CLANG64
|
||||
# Version: 2.4.0 Add Python, needed by the fixture HTTP server the Lua
|
||||
# tests run against
|
||||
# 2.3.0 Switch from MINGW64 to CLANG64
|
||||
# 2.2.0 Add CMake package for CMake-based builds
|
||||
# 2.1.0 Remove MINGW32 since upstream no longer supports it
|
||||
# 2.0.0 Rework to build on an MSYS2 MINGW64 Github workflow
|
||||
|
|
@ -128,6 +130,7 @@ while true; do
|
|||
"${MINGW_PACKAGE_PREFIX}-ninja" \
|
||||
"${MINGW_PACKAGE_PREFIX}-assimp" \
|
||||
"${MINGW_PACKAGE_PREFIX}-curl" \
|
||||
"${MINGW_PACKAGE_PREFIX}-python" \
|
||||
"${MINGW_PACKAGE_PREFIX}-uasm" \
|
||||
"${MINGW_PACKAGE_PREFIX}-cmake" \
|
||||
"${MINGW_PACKAGE_PREFIX}-jq"; then
|
||||
|
|
|
|||
|
|
@ -25,12 +25,31 @@ function validate_cmake() {
|
|||
# there is no static "APP_BUILD" line left to validate here.
|
||||
}
|
||||
|
||||
function validate_release_tag() {
|
||||
local TAG_NAME=""
|
||||
if [[ "${GITHUB_REF:-}" =~ ^refs/tags/ ]]; then
|
||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
if [ -z "${TAG_NAME}" ]; then
|
||||
error "This is a release build, but the tag being built could not be determined from GITHUB_REF."
|
||||
fi
|
||||
|
||||
local APP_VERSION
|
||||
APP_VERSION=$(pcre2grep --only-matching=1 "set\(APP_VERSION (.+)\)$" < CMakeLists.txt)
|
||||
if [ -z "${APP_VERSION}" ]; then
|
||||
error "No set(APP_VERSION ...) line could be read out of CMakeLists.txt."
|
||||
fi
|
||||
|
||||
bash "$(dirname "${BASH_SOURCE[0]}")/check-release-tag.sh" "${APP_VERSION}" "${TAG_NAME}" || exit $?
|
||||
}
|
||||
|
||||
function validate_updater_environment_variable() {
|
||||
if [ "$WITH_UPDATER" == "NO" ]; then
|
||||
error "Updater is disabled in a release build."
|
||||
fi
|
||||
}
|
||||
|
||||
validate_release_tag
|
||||
validate_cmake
|
||||
validate_updater_environment_variable
|
||||
|
||||
|
|
|
|||
|
|
@ -26,12 +26,31 @@ else
|
|||
# there is no static "APP_BUILD" line left to validate here.
|
||||
}
|
||||
|
||||
function validate_release_tag() {
|
||||
local TAG_NAME="${TRAVIS_TAG:-}"
|
||||
if [[ "${GITHUB_REF:-}" =~ ^refs/tags/ ]]; then
|
||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
if [ -z "${TAG_NAME}" ]; then
|
||||
error "This is a release build, but the tag being built could not be determined from GITHUB_REF or TRAVIS_TAG."
|
||||
fi
|
||||
|
||||
local APP_VERSION
|
||||
APP_VERSION=$(pcre2grep --only-matching=1 "set\(APP_VERSION (.+)\)$" < CMakeLists.txt)
|
||||
if [ -z "${APP_VERSION}" ]; then
|
||||
error "No set(APP_VERSION ...) line could be read out of CMakeLists.txt."
|
||||
fi
|
||||
|
||||
bash "$(dirname "${BASH_SOURCE[0]}")/check-release-tag.sh" "${APP_VERSION}" "${TAG_NAME}" || exit $?
|
||||
}
|
||||
|
||||
function validate_updater_environment_variable() {
|
||||
if [ "$WITH_UPDATER" == "NO" ]; then
|
||||
error "Updater is disabled in a release build."
|
||||
fi
|
||||
}
|
||||
|
||||
validate_release_tag
|
||||
validate_cmake
|
||||
validate_updater_environment_variable
|
||||
fi
|
||||
|
|
|
|||
113
CI/verify-release-checksums.sh
Executable file
113
CI/verify-release-checksums.sh
Executable file
|
|
@ -0,0 +1,113 @@
|
|||
#!/bin/bash
|
||||
# Fails if any release binary lacks a SHA256SUMS.txt entry, or if a binary we still
|
||||
# have on disk does not hash to the value listed for it.
|
||||
#
|
||||
# The updater's download path refuses a download it cannot verify (see
|
||||
# UpdateDialog::startDownload, which passes requireChecksums), so a release binary
|
||||
# without a checksum line cannot be installed through the update dialog - see
|
||||
# assemble-release-checksums.sh for how that used to happen. This is the gate that
|
||||
# keeps it from being published.
|
||||
#
|
||||
# The check covers assets already on the release, not just the ones being uploaded,
|
||||
# because those are what a user's updater will see. A release that is *already*
|
||||
# missing a checksum therefore fails every later run of the publishing job and
|
||||
# cannot be repaired by re-running it: the sidecar for an older run's binary is no
|
||||
# longer downloadable, so the stale asset has to be deleted from the release (or
|
||||
# its platform build re-run) by hand.
|
||||
#
|
||||
# Usage: verify-release-checksums.sh <sums-file> <asset-names-file> [assets-dir]
|
||||
# asset-names-file lists one asset filename per line: every release binary among
|
||||
# them must have an entry in sums-file.
|
||||
# assets-dir, when given, is searched for each of those binaries, and any that is
|
||||
# found has its actual SHA256 compared against the listed one.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SUMS_FILE="${1:?SHA256SUMS.txt path required}"
|
||||
ASSET_NAMES_FILE="${2:?asset names file required}"
|
||||
ASSETS_DIR="${3:-}"
|
||||
|
||||
if [[ ! -f "${SUMS_FILE}" ]]; then
|
||||
echo "::error::${SUMS_FILE} does not exist - cannot verify release checksum coverage"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Everything published alongside the binaries, rather than a list of binary
|
||||
# suffixes: an unrecognised suffix has to be treated as a binary that needs
|
||||
# checking, or adding an asset type would silently exempt it
|
||||
is_release_binary() {
|
||||
local name="$1"
|
||||
[[ "${name}" != "SHA256SUMS.txt" && "${name}" != *.sha256 ]]
|
||||
}
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum > /dev/null 2>&1; then
|
||||
sha256sum "$1" | cut -d ' ' -f 1
|
||||
else
|
||||
shasum -a 256 "$1" | cut -d ' ' -f 1
|
||||
fi
|
||||
}
|
||||
|
||||
# "<filename>\t<hash>" for every entry in the checksum file
|
||||
COVERED="$(mktemp)"
|
||||
trap 'rm -f "${COVERED}"' EXIT
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
line="${line%$'\r'}"
|
||||
if [[ "${line}" =~ ^([0-9a-fA-F]{64})[[:space:]]+\*?(.+)$ ]]; then
|
||||
printf '%s\t%s\n' "${BASH_REMATCH[2]}" "${BASH_REMATCH[1]}" >> "${COVERED}"
|
||||
fi
|
||||
done < "${SUMS_FILE}"
|
||||
|
||||
if [[ ! -s "${COVERED}" ]]; then
|
||||
echo "::error::${SUMS_FILE} contains no usable checksum entries - it is empty or was not downloaded correctly"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UNCOVERED=()
|
||||
MISMATCHED=()
|
||||
CHECKED=0
|
||||
while IFS= read -r asset_name || [[ -n "${asset_name}" ]]; do
|
||||
asset_name="${asset_name%$'\r'}"
|
||||
if [[ -z "${asset_name}" ]] || ! is_release_binary "${asset_name}"; then
|
||||
continue
|
||||
fi
|
||||
CHECKED=$((CHECKED + 1))
|
||||
|
||||
expected="$(awk -F '\t' -v name="${asset_name}" '$1 == name { print $2; exit }' "${COVERED}")"
|
||||
if [[ -z "${expected}" ]]; then
|
||||
UNCOVERED+=("${asset_name}")
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ -z "${ASSETS_DIR}" ]]; then
|
||||
continue
|
||||
fi
|
||||
asset_path="$(find "${ASSETS_DIR}" -name "${asset_name}" -type f -print -quit 2> /dev/null || true)"
|
||||
if [[ -z "${asset_path}" ]]; then
|
||||
continue
|
||||
fi
|
||||
actual="$(sha256_of "${asset_path}")"
|
||||
if [[ "${actual}" != "${expected}" ]]; then
|
||||
MISMATCHED+=("${asset_name} (listed ${expected}, actual ${actual})")
|
||||
fi
|
||||
done < "${ASSET_NAMES_FILE}"
|
||||
|
||||
if [[ ${#UNCOVERED[@]} -gt 0 ]]; then
|
||||
echo "::error::${#UNCOVERED[@]} release binary/binaries have no SHA256SUMS.txt entry, so Mudlet's updater cannot install them: ${UNCOVERED[*]}"
|
||||
echo "Delete the stale asset from the release, or re-run the platform build that produced it, then re-run this job."
|
||||
echo "SHA256SUMS.txt covers:"
|
||||
cut -f 1 "${COVERED}" | sort
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ${#MISMATCHED[@]} -gt 0 ]]; then
|
||||
echo "::error::${#MISMATCHED[@]} release binary/binaries do not match their SHA256SUMS.txt entry, so Mudlet's updater would reject the download: ${MISMATCHED[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ${CHECKED} -eq 0 ]]; then
|
||||
echo "::error::No release binaries found to verify - expected at least one"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All ${CHECKED} release binary/binaries have a SHA256SUMS.txt entry"
|
||||
|
|
@ -15,6 +15,27 @@
|
|||
# Qt modules) - a suppression matches if ANY frame does, so those can hide
|
||||
# genuine Mudlet leaks too.
|
||||
#
|
||||
# A leak: line can only match a frame that still resolves to a module path or a
|
||||
# symbol name. Leaks reported as "<unknown module>" with no symbols cannot be
|
||||
# matched by any leak: line: the allocating library was dlclose()d (or the code
|
||||
# was JITed) before LeakSanitizer runs at exit, so there is no name left to
|
||||
# compare against. Those have to be handled at their source, not here - see the
|
||||
# GPU driver note below for the recurring example.
|
||||
#
|
||||
# Do not suppress a Qt allocation primitive to quieten a report. By default
|
||||
# AddressSanitizer captures allocation stacks with the frame-pointer unwinder,
|
||||
# and the Qt binaries CI installs are built without frame pointers, so the walk
|
||||
# stops at the first Qt frame. Every leaked QString in the program then reports
|
||||
# as the same three-frame stack
|
||||
# malloc / allocateHelper / QArrayData::allocate2
|
||||
# with no caller to identify it, and every leaked QByteArray as the allocate1
|
||||
# equivalent. Suppressing those symbols hides the whole class - which is what
|
||||
# `leak:QArrayData::allocate2` did here until it was removed. To get the real
|
||||
# callers of such a report, re-run with the accurate unwinder:
|
||||
# ASAN_OPTIONS=fast_unwind_on_malloc=0:malloc_context_size=30
|
||||
# It costs roughly 3x the runtime of the test run, which is why CI does not use
|
||||
# it by default, but the stacks it produces name the leaking function.
|
||||
#
|
||||
# See: https://github.com/google/sanitizers/wiki/AddressSanitizerLeakSanitizer
|
||||
|
||||
# ==============================================================================
|
||||
|
|
@ -26,6 +47,34 @@ leak:libcuda.so
|
|||
leak:nvidia
|
||||
leak:_dri.so
|
||||
|
||||
# leak:_dri.so above can only match when the driver is still mapped at process
|
||||
# exit, where LeakSanitizer runs its check. Mesa dlopen()s a DRI driver on first
|
||||
# GL use and dlclose()s it when the last GL context - e.g. the 3D map view's
|
||||
# QOpenGLWidget - is torn down. A driver-init allocation that leaks and is then
|
||||
# unloaded before exit is reported as "<unknown module>" (the .so is gone by the
|
||||
# time LeakSanitizer runs), so NO leak: line can match it. On the CI leak job
|
||||
# (Ubuntu 22.04) this recurs as a small (~240 byte) realloc+calloc leak during
|
||||
# mudlet teardown and flakes across unrelated PRs; it does not reproduce on newer
|
||||
# Mesa. Do not try to silence it with a leak: line here (there is no name to
|
||||
# match) - keep the GL context from being created/destroyed under the sanitizer
|
||||
# test-side instead, as PR #9534 did for the show3dMapView test.
|
||||
#
|
||||
# A second un-suppressible flake lives on the same CI job: LeakSanitizer's
|
||||
# stop-the-world tracer - the helper process that ptrace-attaches at exit to
|
||||
# scan memory for live pointers - itself segfaults during exit-time teardown,
|
||||
# apparently racing driver/X cleanup. The job log then ends with
|
||||
# Tracer caught signal 11: addr=0x... pc=0x... sp=0x...
|
||||
# ==NNNNN==LeakSanitizer has encountered a fatal error.
|
||||
# right after "mudlet::~mudlet() INFO - uninstalling translation...", with no
|
||||
# leak report at all, and the busted summary line goes missing too (LSan's
|
||||
# Die() skips the stdio flush - the Lua tests themselves passed). Since
|
||||
# exitcode=1 the job fails. Suppressions filter leak *reports*, and this
|
||||
# aborts before reporting begins, so nothing in this file can help; it is
|
||||
# also unrelated to the change under test (the same faulting instruction was
|
||||
# observed on development-branch runs 26705214438 and 28641460889 and on PR
|
||||
# run 30744002320, at a rate of roughly 1 in 250-500 leak-job runs). If a
|
||||
# leak job fails with this signature, just re-run it.
|
||||
|
||||
# ==============================================================================
|
||||
# Fontconfig library leaks
|
||||
# These are typically one-time initialization leaks in the font system
|
||||
|
|
@ -68,6 +117,15 @@ leak:libgobject-2.0.so
|
|||
# ==============================================================================
|
||||
leak:libexpat.so
|
||||
|
||||
# ==============================================================================
|
||||
# speech-dispatcher client library leak
|
||||
# One-time allocation in the speech-dispatcher client (libspeechd) when its
|
||||
# connection is first opened, reached via QtTextToSpeech's speechd engine.
|
||||
# Only surfaces when a speech-dispatcher daemon is reachable and something
|
||||
# touches the tts* API; no Mudlet frames are involved.
|
||||
# ==============================================================================
|
||||
leak:libspeechd.so
|
||||
|
||||
# ==============================================================================
|
||||
# Qt internal leaks (Qt's integration with system libraries)
|
||||
# These are typically initialization leaks in Qt's platform integration
|
||||
|
|
@ -83,9 +141,6 @@ leak:QFontEngineMultiFontConfig
|
|||
# Qt translation system (one-time initialization)
|
||||
leak:QTranslatorPrivate::do_load
|
||||
|
||||
# Qt array data allocation (internal caching)
|
||||
leak:QArrayData::allocate2
|
||||
|
||||
# ==============================================================================
|
||||
# OpenSSL 3 provider initialization leaks
|
||||
# One-time allocations made when Qt's OpenSSL TLS backend loads the default
|
||||
|
|
|
|||
|
|
@ -78,9 +78,56 @@ METRIC text_mb_per_sec 0.41
|
|||
METRIC trigger_lines_per_sec 3323.25
|
||||
METRIC trigger_overhead_ms 1683.64
|
||||
METRIC peak_rss_kb 1402384
|
||||
METRIC defaults_root_triggers ...
|
||||
METRIC defaults_text_lines_per_sec ...
|
||||
METRIC defaults_text_best_pass_ms ...
|
||||
METRIC defaults_peak_rss_kb ...
|
||||
...
|
||||
```
|
||||
|
||||
### Two profile configurations, and why the split matters
|
||||
|
||||
The benchmark feeds the corpus under two profile configurations (one slot per
|
||||
phase, so four profiles are created in all):
|
||||
|
||||
- **`text_*`, `trigger_*`, `peak_rss_kb`** come from a profile with the default
|
||||
packages suppressed. They describe the pipeline itself, which is what the
|
||||
libmudlet gate is about.
|
||||
- **`defaults_*`** comes from a profile carrying the shipped default packages,
|
||||
the way a new user's profile does. `defaults_root_triggers` records how many
|
||||
root triggers those packages left armed.
|
||||
|
||||
Keeping them separate means a package regression moves `defaults_*` while the
|
||||
pipeline numbers stay flat, instead of the two being indistinguishable.
|
||||
|
||||
`defaults_peak_rss_kb` is read after `peak_rss_kb`, and VmHWM is process-wide
|
||||
and monotonic, so the two are not independent: read `defaults_peak_rss_kb` as
|
||||
the whole-run high-water mark and its **excess** over `peak_rss_kb` as what the
|
||||
default packages cost.
|
||||
|
||||
**Run the benchmark under a fresh `HOME` and `XDG_CONFIG_HOME`.** Part of what a
|
||||
new profile gets - the starter UI - is gated on
|
||||
`mudlet::experiencedMudletPlayer()`, which answers from the machine's own Mudlet
|
||||
history, so on a developer machine the `defaults_*` profile would quietly not
|
||||
get it and `defaults_text_lines_per_sec` would become a second copy of
|
||||
`text_lines_per_sec`. `benchDefaultPackages` checks the starter UI is installed
|
||||
and fails the run rather than report that, and `defaults_root_triggers` records
|
||||
how many root triggers the packages between them armed:
|
||||
|
||||
```bash
|
||||
scratch=$(mktemp -d)
|
||||
HOME=$scratch XDG_CONFIG_HOME=$scratch/.config QT_QPA_PLATFORM=offscreen \
|
||||
./test/functional_tests/PipelineBenchmark
|
||||
```
|
||||
|
||||
Comparing a build from before this split against one from after it will abort
|
||||
with "gated metric defaults_text_lines_per_sec is missing from the before run".
|
||||
That is the script working as intended - the two harnesses are not comparable.
|
||||
Pass `--gate text_lines_per_sec,trigger_lines_per_sec` to compare across the
|
||||
change, bearing in mind the older run's `text_lines_per_sec` includes whichever
|
||||
default packages that machine's `experiencedMudletPlayer()` allowed it - the
|
||||
older harness had no guard - while the newer one includes none.
|
||||
|
||||
## The before/after workflow (the 10% gate)
|
||||
|
||||
The gate is a **relative, same-machine** comparison. Never compare numbers taken
|
||||
|
|
@ -115,8 +162,9 @@ on different hardware, or from an ASan build against a release build - only ever
|
|||
test/compare-perf-baseline.py before.txt after.txt
|
||||
```
|
||||
|
||||
`compare-perf-baseline.py` gates on `text_lines_per_sec` and
|
||||
`trigger_lines_per_sec` by default (the two throughput numbers); every other
|
||||
`compare-perf-baseline.py` gates on `text_lines_per_sec`,
|
||||
`trigger_lines_per_sec` and `defaults_text_lines_per_sec` by default (pipeline
|
||||
throughput, plus the shipped default packages on the same corpus); every other
|
||||
metric is reported for context. It exits non-zero if any gated metric regressed
|
||||
by more than the threshold, so it drops straight into a script or CI step. Tune
|
||||
it with `--threshold 0.10` and `--gate metric,metric,...`. A `--threshold` of 1
|
||||
|
|
@ -153,7 +201,7 @@ ideally over a couple of runs or with a slightly relaxed threshold.
|
|||
`PipelineBenchmark` deliberately stops at the core pipeline: it runs offscreen
|
||||
and never paints a widget, so it does not measure the on-screen rendering and
|
||||
echo path. That path needs a live window and is covered by the **Stressinator
|
||||
display benchmark** (`src/mudlet-lua/lua/stressinator/StressinatorDisplayBench.xml`),
|
||||
display benchmark** (`src/packages/StressinatorDisplayBench/`),
|
||||
pre-installed into the `mudlet.org` self-test profile.
|
||||
|
||||
- Interactively, in a running profile, type `stresstest 100000` to feed that many
|
||||
|
|
@ -176,6 +224,11 @@ shape and rough ratios of the output**. Do not treat any figure here as a target
|
|||
or a committed baseline - capture your own "before" on the machine you are
|
||||
testing on and compare against that.
|
||||
|
||||
It predates the two-profile split above, so its `text_lines_per_sec` includes
|
||||
the default packages and there are no `defaults_*` rows. Read the ratios between
|
||||
the `text_*` and `trigger_*` rows; do not compare any figure here against a
|
||||
current run.
|
||||
|
||||
| Metric | Example value |
|
||||
| --- | --- |
|
||||
| `text_lines_per_sec` | ~4,270 |
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@
|
|||
#include "TToolBar.h"
|
||||
#include "mudlet.h"
|
||||
|
||||
#include <QSet>
|
||||
|
||||
#include <functional>
|
||||
|
||||
/* We need an explicit constructor in this file as the Host class is forward
|
||||
|
|
@ -75,12 +77,48 @@ void ActionUnit::uninstall(const QString& packageName)
|
|||
uninstallList.append(rootAction);
|
||||
}
|
||||
}
|
||||
for (auto& action : uninstallList) {
|
||||
delete action;
|
||||
// Re-entrant uninstall (#9337): a button's own script (e.g. a package
|
||||
// auto-updater calling uninstallPackage()) is removing its package while
|
||||
// TAction::execute() is still on the call stack for that button. Deleting
|
||||
// now would be a use-after-free, so defer to doCleanup() at depth 0.
|
||||
// Deactivating stops the buttons from firing again in the meantime.
|
||||
if (mProcessingDepth > 0) {
|
||||
for (auto action : uninstallList) {
|
||||
action->setIsActive(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Not inside a button script - delete now. Route through doCleanup() rather
|
||||
// than an inline loop so the same seen-set guards against a double free if a
|
||||
// re-entrant uninstall of the same package queued any action twice.
|
||||
doCleanup();
|
||||
}
|
||||
|
||||
void ActionUnit::doCleanup()
|
||||
{
|
||||
if (mProcessingDepth > 0) {
|
||||
return;
|
||||
}
|
||||
// Flush the deletes uninstall() deferred (#9337). uninstallList is ordered
|
||||
// children-before-parents and each ~Tree unlinks from its parent, so deleting
|
||||
// children first empties the parent's child list (no double free); the seen
|
||||
// set guards a node queued twice by re-entrant uninstalls.
|
||||
QSet<TAction*> deletedActions;
|
||||
for (auto action : uninstallList) {
|
||||
if (!deletedActions.contains(action)) {
|
||||
deletedActions.insert(action);
|
||||
delete action;
|
||||
}
|
||||
}
|
||||
uninstallList.clear();
|
||||
}
|
||||
|
||||
void ActionUnit::endProcessing()
|
||||
{
|
||||
--mProcessingDepth;
|
||||
Q_ASSERT(mProcessingDepth >= 0);
|
||||
}
|
||||
|
||||
void ActionUnit::compileAll()
|
||||
{
|
||||
for (auto action : mActionRootNodeList) {
|
||||
|
|
@ -92,13 +130,11 @@ void ActionUnit::compileAll()
|
|||
|
||||
TAction* ActionUnit::findAction(const QString& name)
|
||||
{
|
||||
//QMap<int, TAction *> mActionMap;
|
||||
|
||||
QMapIterator<int, TAction*> it(mActionMap);
|
||||
while (it.hasNext()) {
|
||||
it.next();
|
||||
if (it.value()->getName() == name) {
|
||||
qDebug() << it.value()->getName();
|
||||
// qDebug().nospace().noquote() << "ActionUnit::findAction(const QString&) INFO - found: \"" << it.value()->getName() << "\".";
|
||||
TAction* pT = it.value();
|
||||
return pT;
|
||||
}
|
||||
|
|
@ -531,8 +567,9 @@ void ActionUnit::constructToolbar(TAction* pAction, TToolBar* pToolBar)
|
|||
pToolBar->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable);
|
||||
if (pAction->mLocation == 4) {
|
||||
if (pAction->mToolbarLastDockArea == Qt::NoDockWidgetArea) {
|
||||
qWarning() << "ActionUnit::constructToolbar(TAction*, TToolBar*) WARNING - no last dockarea was set for the TAction (\"" << pAction->getName()
|
||||
<< "\"), for this toolbar forcing it to the Left one!";
|
||||
qWarning().nospace().noquote() << "ActionUnit::constructToolbar(TAction*, TToolBar*) WARNING - no last dockarea was set for the TAction (\""
|
||||
<< pAction->getName()
|
||||
<< "\"), for this toolbar forcing it to the Left one!";
|
||||
}
|
||||
mudlet::self()->addDockWidget(((pAction->mToolbarLastDockArea != Qt::NoDockWidgetArea) ? pAction->mToolbarLastDockArea : Qt::LeftDockWidgetArea), pToolBar);
|
||||
if (pAction->mToolbarLastFloatingState) {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,15 @@ public:
|
|||
int getNewID();
|
||||
void uninstall(const QString&);
|
||||
void _uninstall(TAction* pChild, const QString& packageName);
|
||||
void doCleanup();
|
||||
void beginProcessing() { ++mProcessingDepth; }
|
||||
// Only decrements the depth - deliberately no doCleanup() here: that would
|
||||
// delete `this` (and other deferred actions) while a caller of
|
||||
// TAction::execute() may still hold the pointer. Deferred deletes are
|
||||
// flushed once no button script is executing - by the dispatchers right
|
||||
// after execute() returns and by Host's catch-all doCleanup() calls.
|
||||
void endProcessing();
|
||||
int processingDepth() const { return mProcessingDepth; }
|
||||
void updateAllToolbars();
|
||||
std::list<QPointer<TToolBar>> getToolBarList() { return mToolBarList; }
|
||||
TAction* getHeadAction(TToolBar*);
|
||||
|
|
@ -92,6 +101,9 @@ private:
|
|||
QMap<int, TAction*> mActionMap;
|
||||
std::list<TAction*> mActionRootNodeList;
|
||||
int mMaxID = 0;
|
||||
// > 0 whilst a TAction::execute() is on the call stack; uninstall() and
|
||||
// doCleanup() must not delete actions then - see ActionUnit::uninstall():
|
||||
int mProcessingDepth = 0;
|
||||
bool mModuleMember = false;
|
||||
std::list<QPointer<TToolBar>> mToolBarList;
|
||||
std::list<QPointer<TEasyButtonBar>> mEasyButtonBarList;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
#include "Host.h"
|
||||
#include "TAlias.h"
|
||||
|
||||
#include <QScopeGuard>
|
||||
|
||||
#include <functional>
|
||||
|
||||
/* We need an explicit constructor in this file as the Host class is forward
|
||||
|
|
@ -89,6 +91,9 @@ void AliasUnit::uninstall(const QString& packageName)
|
|||
return;
|
||||
}
|
||||
for (auto& alias : uninstallList) {
|
||||
// in case the alias was also queued for the markCleanup()/doCleanup()
|
||||
// path - deleting it here would otherwise leave a dangling pointer there:
|
||||
mCleanupSet.remove(alias);
|
||||
delete alias;
|
||||
}
|
||||
uninstallList.clear();
|
||||
|
|
@ -177,11 +182,12 @@ void AliasUnit::removeAliasRootNode(TAlias* pT)
|
|||
if (!pT) {
|
||||
return;
|
||||
}
|
||||
if (!pT->isTemporary()) {
|
||||
mLookupTable.remove(pT->mName, pT);
|
||||
} else {
|
||||
mLookupTable.remove(pT->getName());
|
||||
}
|
||||
// Names are not unique - the lookup table is a QMultiMap - so drop this one
|
||||
// alias' entry rather than every entry filed under the name. The
|
||||
// single-argument remove() used to be taken for temporary aliases, which
|
||||
// evicted live same-named aliases and left them unreachable by name for the
|
||||
// rest of the session
|
||||
mLookupTable.remove(pT->getName(), pT);
|
||||
mAliasMap.remove(pT->getID());
|
||||
mAliasRootNodeList.remove(pT);
|
||||
}
|
||||
|
|
@ -257,11 +263,8 @@ void AliasUnit::removeAlias(TAlias* pT)
|
|||
if (!pT) {
|
||||
return;
|
||||
}
|
||||
if (!pT->isTemporary()) {
|
||||
mLookupTable.remove(pT->mName, pT);
|
||||
} else {
|
||||
mLookupTable.remove(pT->getName());
|
||||
}
|
||||
// see removeAliasRootNode(): one entry, not every same-named one
|
||||
mLookupTable.remove(pT->getName(), pT);
|
||||
|
||||
mAliasMap.remove(pT->getID());
|
||||
}
|
||||
|
|
@ -281,6 +284,13 @@ bool AliasUnit::processDataStream(const QString& data)
|
|||
auto copyOfNodeList = mAliasRootNodeList;
|
||||
|
||||
mProcessingDepth++;
|
||||
const auto processingGuard = qScopeGuard([this] {
|
||||
mProcessingDepth--;
|
||||
Q_ASSERT(mProcessingDepth >= 0);
|
||||
if (mProcessingDepth == 0) {
|
||||
doCleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (auto alias : copyOfNodeList) {
|
||||
if (!alias->isActive() && !alias->shouldBeActive()) {
|
||||
|
|
@ -292,12 +302,6 @@ bool AliasUnit::processDataStream(const QString& data)
|
|||
}
|
||||
}
|
||||
|
||||
mProcessingDepth--;
|
||||
Q_ASSERT(mProcessingDepth >= 0);
|
||||
if (mProcessingDepth == 0) {
|
||||
doCleanup();
|
||||
}
|
||||
|
||||
// the idea to get "command" after alias processing is finished and send its value
|
||||
// was too difficult for users because if multiple alias change the value of command it becomes too difficult to handle for many users
|
||||
// it's easier if we simply intercepts the command and hand responsibility for
|
||||
|
|
@ -380,15 +384,27 @@ bool AliasUnit::disableAlias(const QString& name)
|
|||
bool AliasUnit::killAlias(const QString& name)
|
||||
{
|
||||
for (auto alias : mAliasRootNodeList) {
|
||||
if (alias->getName() == name) {
|
||||
// only temporary Aliases can be killed
|
||||
if (!alias->isTemporary()) {
|
||||
return false;
|
||||
}
|
||||
alias->setIsActive(false);
|
||||
markCleanup(alias);
|
||||
return true;
|
||||
if (alias->getName() != name) {
|
||||
continue;
|
||||
}
|
||||
// Names are not unique, so keep looking rather than give up on the first
|
||||
// same-named alias that cannot be killed - a permanent alias loaded from
|
||||
// the profile precedes this session's temporaries in this list, and
|
||||
// reporting a failure over it would strand a killable alias
|
||||
if (!alias->isTemporary()) {
|
||||
// only temporary Aliases can be killed
|
||||
continue;
|
||||
}
|
||||
// An already killed alias is only unlinked from this list once doCleanup()
|
||||
// gets to free it, which cannot happen while an alias script is on the
|
||||
// call stack - so until then it is still findable by name. Killing it a
|
||||
// second time achieves nothing:
|
||||
if (mCleanupSet.contains(alias)) {
|
||||
continue;
|
||||
}
|
||||
alias->setIsActive(false);
|
||||
markCleanup(alias);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -433,17 +449,22 @@ void AliasUnit::doCleanup()
|
|||
return;
|
||||
}
|
||||
|
||||
QSet<TAlias*> deletedAliases;
|
||||
QMutableSetIterator<TAlias*> itAlias(mCleanupSet);
|
||||
while (itAlias.hasNext()) {
|
||||
auto pAlias = itAlias.next();
|
||||
itAlias.remove();
|
||||
deletedAliases.insert(pAlias);
|
||||
delete pAlias;
|
||||
}
|
||||
// Flush the deletes uninstall() deferred (#9337). uninstallList is ordered
|
||||
// children-before-parents and each ~Tree unlinks from its parent, so deleting
|
||||
// children first empties the parent's child list (no double free); the seen
|
||||
// set guards a node queued twice by re-entrant uninstalls.
|
||||
QSet<TAlias*> deletedAliases;
|
||||
// set guards a node queued twice by re-entrant uninstalls and is shared with
|
||||
// the mCleanupSet loop above so an object that ended up in both containers is
|
||||
// freed once. It matches on pointer identity only: a node freed indirectly, as
|
||||
// a child of a queued parent, is not in the set (not reachable today - only
|
||||
// temporary root nodes are ever queued, and those have no children).
|
||||
for (auto alias : uninstallList) {
|
||||
if (!deletedAliases.contains(alias)) {
|
||||
deletedAliases.insert(alias);
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ public:
|
|||
int getNewID();
|
||||
void markCleanup(TAlias* pT);
|
||||
void doCleanup();
|
||||
int processingDepth() const { return mProcessingDepth; }
|
||||
|
||||
QMultiMap<QString, TAlias*> mLookupTable;
|
||||
QSet<TAlias*> mCleanupSet;
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ set(mudlet_SRCS
|
|||
EditorMoveItemCommand.cpp
|
||||
EditorToggleActiveCommand.cpp
|
||||
EditorUndoStack.cpp
|
||||
EventLoopPump.cpp
|
||||
exitstreewidget.cpp
|
||||
FileOpenHandler.cpp
|
||||
FontManager.cpp
|
||||
|
|
@ -98,6 +99,7 @@ set(mudlet_SRCS
|
|||
KeyUnit.cpp
|
||||
LabelInteractionHandler.cpp
|
||||
LuaInterface.cpp
|
||||
LuaLiteral.cpp
|
||||
main.cpp
|
||||
mapInfoContributorManager.cpp
|
||||
MiddleMousePanHandler.cpp
|
||||
|
|
@ -211,6 +213,7 @@ set(mudlet_SRCS
|
|||
TTrigger.cpp
|
||||
TUiTour.cpp
|
||||
TVar.cpp
|
||||
UntrustedText.cpp
|
||||
VarUnit.cpp
|
||||
WideComboBox.cpp
|
||||
XMLexport.cpp
|
||||
|
|
@ -315,6 +318,7 @@ set(mudlet_HDRS
|
|||
EditorToggleActiveCommand.h
|
||||
EditorUndoStack.h
|
||||
enums.h
|
||||
EventLoopPump.h
|
||||
exitstreewidget.h
|
||||
FileOpenHandler.h
|
||||
FontManager.h
|
||||
|
|
@ -326,6 +330,7 @@ set(mudlet_HDRS
|
|||
KeyUnit.h
|
||||
LabelInteractionHandler.h
|
||||
LuaInterface.h
|
||||
LuaLiteral.h
|
||||
mapInfoContributorManager.h
|
||||
MiddleMousePanHandler.h
|
||||
MMCP.h
|
||||
|
|
@ -444,6 +449,7 @@ set(mudlet_HDRS
|
|||
TTrigger.h
|
||||
TUiTour.h
|
||||
TVar.h
|
||||
UntrustedText.h
|
||||
utils.h
|
||||
VarUnit.h
|
||||
widechar_width.h
|
||||
|
|
@ -766,7 +772,7 @@ endif(USE_UPDATER)
|
|||
# Embed the LeakSanitizer suppression list into the binary so that
|
||||
# sanitizer-enabled builds (PTBs, testing AppImages) filter third-party noise
|
||||
# out of leak reports without needing LSAN_OPTIONS set at runtime; picked up
|
||||
# by __lsan_default_suppressions() in main.cpp
|
||||
# by __lsan_default_suppressions() in LsanHooks.cpp
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${mudlet_SOURCE_DIR}/asan-suppressions.txt")
|
||||
file(READ "${mudlet_SOURCE_DIR}/asan-suppressions.txt" MUDLET_LSAN_SUPPRESSIONS_CONTENT)
|
||||
string(REPLACE "\\" "\\\\" MUDLET_LSAN_SUPPRESSIONS_CONTENT "${MUDLET_LSAN_SUPPRESSIONS_CONTENT}")
|
||||
|
|
@ -775,6 +781,15 @@ string(REPLACE "\n" "\\n" MUDLET_LSAN_SUPPRESSIONS_CONTENT "${MUDLET_LSAN_SUPPRE
|
|||
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/LsanSuppressions.h.in" "${CMAKE_CURRENT_BINARY_DIR}/LsanSuppressions.h" @ONLY)
|
||||
target_include_directories(${LIB_MUDLET_TARGET} PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
|
||||
|
||||
# LeakSanitizer resolves its hooks as weak symbols and a weak reference never
|
||||
# pulls a member out of a static archive, so they cannot live in
|
||||
# ${LIB_MUDLET_TARGET}: binaries that bring their own main() (the Qt Test ones,
|
||||
# via QTEST_MAIN) would link no suppressions at all. An OBJECT library puts the
|
||||
# definitions on the link line of every target that links it.
|
||||
add_library(mudlet_lsan_hooks OBJECT LsanHooks.cpp)
|
||||
target_include_directories(mudlet_lsan_hooks PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
|
||||
set_target_properties(mudlet_lsan_hooks PROPERTIES AUTOMOC OFF POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
if(USE_3DMAPPER)
|
||||
target_link_libraries(${LIB_MUDLET_TARGET} OpenGL::GLU)
|
||||
target_link_libraries(${LIB_MUDLET_TARGET} assimp::assimp)
|
||||
|
|
@ -790,7 +805,7 @@ target_compile_options(${LIB_MUDLET_TARGET} PUBLIC -Wno-deprecated)
|
|||
|
||||
add_executable(${EXE_MUDLET_TARGET} emptyFile.cpp)
|
||||
set_target_properties(${EXE_MUDLET_TARGET} PROPERTIES OUTPUT_NAME ${EXE_MUDLET_NAME})
|
||||
target_link_libraries(${EXE_MUDLET_TARGET} ${LIB_MUDLET_TARGET})
|
||||
target_link_libraries(${EXE_MUDLET_TARGET} ${LIB_MUDLET_TARGET} mudlet_lsan_hooks)
|
||||
|
||||
# Enable symbol exports from the main executable so that dynamically loaded Lua modules
|
||||
# can find and use Lua API symbols (like lua_gettop) at runtime. Without this, modules
|
||||
|
|
@ -887,8 +902,11 @@ if(APPLE)
|
|||
endif()
|
||||
endif()
|
||||
|
||||
file(GLOB_RECURSE MUDLET_LUA_FILES LIST_DIRECTORIES true "mudlet-lua/*")
|
||||
file(GLOB_RECURSE LUA_TRANSLATIONS LIST_DIRECTORIES true "../translations/lua/*")
|
||||
# LIST_DIRECTORIES must stay false: globbing directories too makes CMake emit a
|
||||
# copy_directory rule that races the per-file bundling rules under Ninja,
|
||||
# failing with "Error copying ...: No such file or directory"
|
||||
file(GLOB_RECURSE MUDLET_LUA_FILES LIST_DIRECTORIES false "mudlet-lua/*")
|
||||
file(GLOB_RECURSE LUA_TRANSLATIONS LIST_DIRECTORIES false "../translations/lua/*")
|
||||
file(GLOB DIC_FILES "*.dic")
|
||||
file(GLOB AFF_FILES "*.aff")
|
||||
target_sources(${EXE_MUDLET_TARGET} PUBLIC ${MUDLET_LUA_FILES} ${LUA_TRANSLATIONS} ${DIC_FILES} ${AFF_FILES} ${ICON_FILE})
|
||||
|
|
@ -921,7 +939,12 @@ if(UNIX AND NOT APPLE)
|
|||
DIRECTORY "mudlet-lua/tests"
|
||||
DESTINATION "share/mudlet"
|
||||
FILES_MATCHING
|
||||
PATTERN "*.lua" PERMISSIONS OWNER_READ OWNER_WRITE GROUP_READ WORLD_READ)
|
||||
PATTERN "*.lua" PERMISSIONS OWNER_READ OWNER_WRITE GROUP_READ WORLD_READ
|
||||
# the package specs install these fixtures, so they have to travel with them
|
||||
PATTERN "*.mpackage" PERMISSIONS OWNER_READ OWNER_WRITE GROUP_READ
|
||||
WORLD_READ
|
||||
PATTERN "*.xml" PERMISSIONS OWNER_READ OWNER_WRITE GROUP_READ WORLD_READ
|
||||
PATTERN "*.txt" PERMISSIONS OWNER_READ OWNER_WRITE GROUP_READ WORLD_READ)
|
||||
install(
|
||||
DIRECTORY "../3rdparty/lcf"
|
||||
DESTINATION "share/mudlet/lua"
|
||||
|
|
|
|||
|
|
@ -42,4 +42,9 @@ void EAction::slot_execute(bool checked)
|
|||
{
|
||||
mpHost->getActionUnit()->getAction(mID)->mButtonState = checked;
|
||||
mpHost->getActionUnit()->getAction(mID)->execute();
|
||||
// Deliberately no doCleanup() here: a menu item runs nested inside a bar's
|
||||
// slot_pressed() (TEasyButtonBar::showMenu() spins a modal event loop), which
|
||||
// dereferences its own button after this returns - flushing here could free
|
||||
// an action that ancestor frame still holds. The deferred deletes are cleared
|
||||
// by that ancestor bar dispatcher and by Host's catch-all doCleanup() calls.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1179,8 +1179,8 @@ TAction* importActionFromXML(const QString& xmlSnapshot, TAction* pParent, Host*
|
|||
// Read attributes
|
||||
pA->setIsActive(QString::fromStdString(actionNode.attribute("isActive").value()) == "yes");
|
||||
pA->setIsFolder(QString::fromStdString(actionNode.attribute("isFolder").value()) == "yes");
|
||||
pA->mIsPushDownButton = QString::fromStdString(actionNode.attribute("isPushButton").value()) == "yes";
|
||||
pA->mButtonFlat = QString::fromStdString(actionNode.attribute("isFlatButton").value()) == "yes";
|
||||
pA->setIsPushDownButton(QString::fromStdString(actionNode.attribute("isPushButton").value()) == "yes");
|
||||
pA->setButtonFlat(QString::fromStdString(actionNode.attribute("isFlatButton").value()) == "yes");
|
||||
pA->mUseCustomLayout = QString::fromStdString(actionNode.attribute("useCustomLayout").value()) == "yes";
|
||||
|
||||
// Read child elements
|
||||
|
|
@ -1207,13 +1207,15 @@ TAction* importActionFromXML(const QString& xmlSnapshot, TAction* pParent, Host*
|
|||
} else if (nodeName == "location") {
|
||||
pA->mLocation = nodeValue.toInt();
|
||||
} else if (nodeName == "buttonRotation") {
|
||||
pA->mButtonRotation = nodeValue.toInt();
|
||||
pA->setButtonRotation(nodeValue.toInt());
|
||||
} else if (nodeName == "sizeX") {
|
||||
pA->mSizeX = nodeValue.toInt();
|
||||
pA->setSizeX(nodeValue.toInt());
|
||||
} else if (nodeName == "sizeY") {
|
||||
pA->mSizeY = nodeValue.toInt();
|
||||
pA->setSizeY(nodeValue.toInt());
|
||||
} else if (nodeName == "buttonColumn") {
|
||||
pA->mButtonColumns = nodeValue.toInt();
|
||||
pA->setButtonColumns(nodeValue.toInt());
|
||||
} else if (nodeName == "buttonFillerOffset") {
|
||||
pA->setButtonFillerOffset(nodeValue.toInt());
|
||||
} else if (nodeName == "buttonColor") {
|
||||
// Deprecated - skip this element
|
||||
} else if (nodeName == "posX") {
|
||||
|
|
@ -1280,8 +1282,8 @@ bool updateActionFromXML(TAction* pA, const QString& xmlSnapshot)
|
|||
// Update attributes
|
||||
pA->setIsActive(QString::fromStdString(actionNode.attribute("isActive").value()) == "yes");
|
||||
pA->setIsFolder(QString::fromStdString(actionNode.attribute("isFolder").value()) == "yes");
|
||||
pA->mIsPushDownButton = QString::fromStdString(actionNode.attribute("isPushButton").value()) == "yes";
|
||||
pA->mButtonFlat = QString::fromStdString(actionNode.attribute("isFlatButton").value()) == "yes";
|
||||
pA->setIsPushDownButton(QString::fromStdString(actionNode.attribute("isPushButton").value()) == "yes");
|
||||
pA->setButtonFlat(QString::fromStdString(actionNode.attribute("isFlatButton").value()) == "yes");
|
||||
pA->mUseCustomLayout = QString::fromStdString(actionNode.attribute("useCustomLayout").value()) == "yes";
|
||||
|
||||
// Update child elements
|
||||
|
|
@ -1306,13 +1308,15 @@ bool updateActionFromXML(TAction* pA, const QString& xmlSnapshot)
|
|||
} else if (nodeName == "location") {
|
||||
pA->mLocation = nodeValue.toInt();
|
||||
} else if (nodeName == "buttonRotation") {
|
||||
pA->mButtonRotation = nodeValue.toInt();
|
||||
pA->setButtonRotation(nodeValue.toInt());
|
||||
} else if (nodeName == "sizeX") {
|
||||
pA->mSizeX = nodeValue.toInt();
|
||||
pA->setSizeX(nodeValue.toInt());
|
||||
} else if (nodeName == "sizeY") {
|
||||
pA->mSizeY = nodeValue.toInt();
|
||||
pA->setSizeY(nodeValue.toInt());
|
||||
} else if (nodeName == "buttonColumn") {
|
||||
pA->mButtonColumns = nodeValue.toInt();
|
||||
pA->setButtonColumns(nodeValue.toInt());
|
||||
} else if (nodeName == "buttonFillerOffset") {
|
||||
pA->setButtonFillerOffset(nodeValue.toInt());
|
||||
} else if (nodeName == "buttonColor") {
|
||||
// Deprecated - skip this element
|
||||
} else if (nodeName == "posX") {
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ QString EditorModifyPropertyCommand::generateText(EditorViewType viewType, const
|
|||
return QObject::tr("modify key \"%1\"").arg(itemName);
|
||||
case EditorViewType::cmActionView:
|
||||
//: Undo/redo menu text for modifying a button's properties
|
||||
return QObject::tr("modify button \"%1\"").arg(itemName);
|
||||
return QObject::tr("modify button/menu/toolbar \"%1\"").arg(itemName);
|
||||
default:
|
||||
//: Undo/redo menu text for modifying an unknown item's properties
|
||||
return QObject::tr("modify item \"%1\"").arg(itemName);
|
||||
|
|
|
|||
60
src/EventLoopPump.cpp
Normal file
60
src/EventLoopPump.cpp
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/***************************************************************************
|
||||
* Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org *
|
||||
* *
|
||||
* This program is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation; either version 2 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* This program is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program; if not, write to the *
|
||||
* Free Software Foundation, Inc., *
|
||||
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
||||
***************************************************************************/
|
||||
|
||||
#include "EventLoopPump.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDeadlineTimer>
|
||||
#include <QDebug>
|
||||
#include <QEventLoop>
|
||||
#include <QThread>
|
||||
|
||||
// A nested QEventLoop::exec() cannot be used here. exec() sets
|
||||
// QEventLoop::EventLoopExec, which QCocoaEventDispatcher answers by re-entering
|
||||
// -[NSApplication run] and leaving Qt's timers to the platform run loop; nested
|
||||
// inside a Qt timer callback that run loop never wakes it again, so not even
|
||||
// the wait's own timeout fires (issue #9670). processEvents() instead takes the
|
||||
// branch that drives the dispatcher's processTimers() on every pass.
|
||||
bool EventLoopPump::pumpFor(const int timeoutMs, const std::function<bool()>& stopCondition)
|
||||
{
|
||||
// processEvents() returns silently without a dispatcher, which would make
|
||||
// this a plain sleep that then reports a timeout as though it had waited.
|
||||
if (!QThread::currentThread()->eventDispatcher()) {
|
||||
qWarning() << "EventLoopPump::pumpFor() called with no event dispatcher on this thread, no events can be delivered";
|
||||
return false;
|
||||
}
|
||||
|
||||
QDeadlineTimer deadline(qMax(timeoutMs, 0));
|
||||
if (stopCondition && stopCondition()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
QCoreApplication::processEvents(QEventLoop::AllEvents);
|
||||
if (stopCondition && stopCondition()) {
|
||||
return true;
|
||||
}
|
||||
if (deadline.hasExpired()) {
|
||||
return false;
|
||||
}
|
||||
// A pass returns as soon as nothing is pending, so without this the loop
|
||||
// spins a core flat for the whole timeout.
|
||||
QThread::msleep(1);
|
||||
}
|
||||
}
|
||||
34
src/EventLoopPump.h
Normal file
34
src/EventLoopPump.h
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#ifndef MUDLET_EVENTLOOPPUMP_H
|
||||
#define MUDLET_EVENTLOOPPUMP_H
|
||||
|
||||
/***************************************************************************
|
||||
* Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org *
|
||||
* *
|
||||
* This program is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation; either version 2 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* This program is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program; if not, write to the *
|
||||
* Free Software Foundation, Inc., *
|
||||
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
||||
***************************************************************************/
|
||||
|
||||
#include <functional>
|
||||
|
||||
class EventLoopPump
|
||||
{
|
||||
public:
|
||||
// Delivers Qt events for up to timeoutMs. True means stopCondition became
|
||||
// true, false means the time ran out. Not a nested QEventLoop::exec(): see
|
||||
// EventLoopPump.cpp for why exec() cannot be used here.
|
||||
[[nodiscard]] static bool pumpFor(int timeoutMs, const std::function<bool()>& stopCondition = {});
|
||||
};
|
||||
|
||||
#endif // MUDLET_EVENTLOOPPUMP_H
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
#include "CredentialManager.h"
|
||||
#include "OAuthClientFlow.h"
|
||||
#include "SecureStringUtils.h"
|
||||
#include "UntrustedText.h"
|
||||
#include "ctelnet.h"
|
||||
#include "mudlet.h"
|
||||
#include <QAccessible>
|
||||
|
|
@ -70,6 +71,25 @@ GMCPAuthenticator::GMCPAuthenticator(Host* pHost)
|
|||
: mpHost(pHost)
|
||||
{
|
||||
resetPerConnectionState();
|
||||
// mTelnet is declared before this authenticator in Host, so it is already constructed here.
|
||||
QObject::connect(&pHost->mTelnet, &cTelnet::signal_connected, pHost, [this]() {
|
||||
resetForNewConnection();
|
||||
});
|
||||
QObject::connect(&pHost->mTelnet, &cTelnet::signal_disconnected, pHost, [this]() {
|
||||
resetForNewConnection();
|
||||
});
|
||||
}
|
||||
|
||||
void GMCPAuthenticator::resetForNewConnection()
|
||||
{
|
||||
// Anything still in flight belongs to the connection that started it: a deferred attempt would
|
||||
// cancel the new connection's login timers and sign in with capabilities the new server never
|
||||
// advertised, and a credential read landing there would replay a token nobody asked for.
|
||||
++mSignInScheduleGeneration;
|
||||
++mAuthAttemptGeneration;
|
||||
mSignInAttemptPending = false;
|
||||
mLastSignInAttempt.invalidate();
|
||||
mUnpromptedBrowserOpenAvailable = true;
|
||||
}
|
||||
|
||||
void GMCPAuthenticator::resetPerConnectionState()
|
||||
|
|
@ -224,8 +244,19 @@ void GMCPAuthenticator::sendCredentials(bool interactiveHandoff)
|
|||
#endif
|
||||
}
|
||||
|
||||
void GMCPAuthenticator::sendReconnect(const QString& account, QString token)
|
||||
bool GMCPAuthenticator::sendReconnect(const QString& account, QString token)
|
||||
{
|
||||
// The token signs in to the account without the player's password, and is replayed on whatever
|
||||
// transport is live now rather than the one it was earned on: in the clear that hands the account
|
||||
// to anyone on the path.
|
||||
if (!mpHost->mTelnet.currentlySecure()) {
|
||||
SecureStringUtils::secureStringClear(token);
|
||||
qWarning().noquote() << "GMCP Char.Login.Reconnect - refusing to replay the saved sign-in token over an unencrypted connection.";
|
||||
//: Shown when a saved password-less sign-in cannot be reused because this connection to the game is not encrypted.
|
||||
mpHost->postMessage(tr("[ WARN ] - Not using your saved sign-in because this connection is not encrypted; please sign in again."));
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject payload;
|
||||
payload[qsl("account")] = account;
|
||||
payload[qsl("token")] = token;
|
||||
|
|
@ -263,6 +294,7 @@ void GMCPAuthenticator::sendReconnect(const QString& account, QString token)
|
|||
#if defined(DEBUG_GMCP_AUTHENTICATION)
|
||||
qDebug() << "Sent GMCP reconnect for account:" << account;
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
void GMCPAuthenticator::storeReconnectToken(const QString& account, QString token)
|
||||
|
|
@ -421,26 +453,34 @@ void GMCPAuthenticator::handleAuthUrl(const QString& packageMessage, const QStri
|
|||
return;
|
||||
}
|
||||
|
||||
// This message can arrive unsolicited, so only auto-open the browser when the player has sent input
|
||||
// this connection (evidence they acted on the game's sign-in screen); otherwise offer the link to
|
||||
// open deliberately, so a misbehaving server cannot pop a browser at an idle player.
|
||||
if (mpHost->userSentInputThisConnection()) {
|
||||
openSignInUrl(parsedUrl, provider);
|
||||
// Char.Login.URL can arrive at any moment in a session, so it only auto-opens against user input.
|
||||
offerOrOpenSignInUrl(parsedUrl, provider, false);
|
||||
}
|
||||
|
||||
void GMCPAuthenticator::offerOrOpenSignInUrl(const QUrl& url, const QString& provider, bool answersTheGamesSignInOffer)
|
||||
{
|
||||
const bool mayOpen = mpHost->userSentInputThisConnection() || (answersTheGamesSignInOffer && mUnpromptedBrowserOpenAvailable);
|
||||
if (mayOpen) {
|
||||
if (openSignInUrl(url, provider)) {
|
||||
mUnpromptedBrowserOpenAvailable = false;
|
||||
mpHost->setUserSentInputThisConnection(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
//: %1 is the sign-in web address the user should open in their browser to sign in.
|
||||
mpHost->postMessage(tr("[ INFO ] - To sign in, open this link in your browser: %1").arg(url));
|
||||
mpHost->postMessage(tr("[ INFO ] - To sign in, open this link in your browser: %1").arg(UntrustedText::forTarget(url.toString())));
|
||||
}
|
||||
|
||||
void GMCPAuthenticator::openSignInUrl(const QUrl& url, const QString& provider)
|
||||
bool GMCPAuthenticator::openSignInUrl(const QUrl& url, const QString& provider)
|
||||
{
|
||||
if (!QDesktopServices::openUrl(url)) {
|
||||
//: %1 is the sign-in web address the user should open manually in their browser.
|
||||
mpHost->postMessage(tr("[ WARN ] - Could not open your browser. Open this link manually to sign in: %1").arg(url.toString()));
|
||||
return;
|
||||
mpHost->postMessage(tr("[ WARN ] - Could not open your browser. Open this link manually to sign in: %1").arg(UntrustedText::forTarget(url.toString())));
|
||||
return false;
|
||||
}
|
||||
announceBrowserHandoff(provider);
|
||||
return true;
|
||||
}
|
||||
|
||||
void GMCPAuthenticator::announceBrowserHandoff(const QString& provider)
|
||||
|
|
@ -465,15 +505,12 @@ void GMCPAuthenticator::startClientDrivenOAuth()
|
|||
|
||||
// Parented to the Host so the flow (and its loopback listener) cannot outlive the profile.
|
||||
mpOAuthFlow = new OAuthClientFlow(mpHost);
|
||||
QObject::connect(mpOAuthFlow, &OAuthClientFlow::authorizationCaptured, mpHost, [this](const QString& code, const QString& codeVerifier, const QString& redirectUri) {
|
||||
sendAuthCode(code, codeVerifier, redirectUri);
|
||||
QObject::connect(mpOAuthFlow, &OAuthClientFlow::authorizationCaptured, mpHost, [this](const QString& code, const QString& codeVerifier, const QString& redirectUri, const QString& nonce) {
|
||||
sendAuthCode(code, codeVerifier, redirectUri, nonce);
|
||||
});
|
||||
QObject::connect(mpOAuthFlow, &OAuthClientFlow::browserOpened, mpHost, [this]() {
|
||||
announceBrowserHandoff(QString());
|
||||
});
|
||||
QObject::connect(mpOAuthFlow, &OAuthClientFlow::browserOpenFailed, mpHost, [this](const QString& url) {
|
||||
//: %1 is the sign-in web address the user should open manually in their browser.
|
||||
mpHost->postMessage(tr("[ WARN ] - Could not open your browser. Open this link manually to sign in: %1").arg(url));
|
||||
// This flow only starts from the game's own sign-in offer, so connecting is itself the request.
|
||||
QObject::connect(mpOAuthFlow, &OAuthClientFlow::authorizationUrlReady, mpHost, [this](const QUrl& authorizationUrl) {
|
||||
offerOrOpenSignInUrl(authorizationUrl, QString(), true);
|
||||
});
|
||||
QObject::connect(mpOAuthFlow, &OAuthClientFlow::flowFailed, mpHost, [this](const QString& logDetail) {
|
||||
qWarning().noquote() << "GMCP Char.Login client-driven OAuth failed:" << logDetail;
|
||||
|
|
@ -494,7 +531,7 @@ void GMCPAuthenticator::cancelClientDrivenOAuth()
|
|||
}
|
||||
}
|
||||
|
||||
void GMCPAuthenticator::sendAuthCode(QString code, QString codeVerifier, const QString& redirectUri)
|
||||
void GMCPAuthenticator::sendAuthCode(QString code, QString codeVerifier, const QString& redirectUri, QString nonce)
|
||||
{
|
||||
// The spec forbids Char.Login.AuthCode on a cleartext connection: the authorization code and PKCE
|
||||
// verifier together would let an eavesdropper redeem the code at the provider. The flow only starts
|
||||
|
|
@ -502,6 +539,7 @@ void GMCPAuthenticator::sendAuthCode(QString code, QString codeVerifier, const Q
|
|||
if (!mpHost->mTelnet.currentlySecure()) {
|
||||
SecureStringUtils::secureStringClear(code);
|
||||
SecureStringUtils::secureStringClear(codeVerifier);
|
||||
SecureStringUtils::secureStringClear(nonce);
|
||||
qWarning().noquote() << "GMCP Char.Login.AuthCode - refusing to send the authorization code over an unencrypted connection.";
|
||||
mpHost->mTelnet.setDontReconnect(true);
|
||||
// Tear down the in-flight flow and its loopback listener immediately: the sign-in is doomed, so
|
||||
|
|
@ -516,6 +554,13 @@ void GMCPAuthenticator::sendAuthCode(QString code, QString codeVerifier, const Q
|
|||
payload[qsl("code")] = code;
|
||||
payload[qsl("code_verifier")] = codeVerifier;
|
||||
payload[qsl("redirect_uri")] = redirectUri;
|
||||
// The server, not this client, receives and validates the ID token, so it is the only party that
|
||||
// can check the nonce claim - and only against the value chosen here.
|
||||
if (!nonce.isEmpty()) {
|
||||
payload[qsl("nonce")] = nonce;
|
||||
} else if (mOAuthNonceRequired) {
|
||||
qWarning().noquote() << "GMCP Char.Login.AuthCode - the server asked for a nonce but none was generated, so it cannot verify the ID token's nonce claim.";
|
||||
}
|
||||
payload[qsl("version")] = mNegotiatedVersion;
|
||||
QByteArray json = QJsonDocument(payload).toJson(QJsonDocument::Compact);
|
||||
QString gmcpMessage = QString::fromUtf8(json);
|
||||
|
|
@ -543,6 +588,7 @@ void GMCPAuthenticator::sendAuthCode(QString code, QString codeVerifier, const Q
|
|||
// payloads, and the assembled telnet frame.
|
||||
SecureStringUtils::secureStringClear(code);
|
||||
SecureStringUtils::secureStringClear(codeVerifier);
|
||||
SecureStringUtils::secureStringClear(nonce);
|
||||
SecureStringUtils::secureStringClear(gmcpMessage);
|
||||
SecureStringUtils::secureStdStringClear(plaintext);
|
||||
SecureStringUtils::secureStdStringClear(encoded);
|
||||
|
|
@ -625,91 +671,149 @@ void GMCPAuthenticator::retryOrDropRejectedToken()
|
|||
QPointer<Host> safeHost = mpHost;
|
||||
QPointer<CredentialManager> credentialManager = new CredentialManager();
|
||||
const auto attemptGeneration = mAuthAttemptGeneration;
|
||||
credentialManager->retrievePassword(mpHost->getName(), qsl("reconnect"), [this, safeHost, credentialManager, attemptGeneration](bool success, QString value, const QString& errorMessage) {
|
||||
if (credentialManager) {
|
||||
credentialManager->deleteLater();
|
||||
}
|
||||
if (!safeHost) {
|
||||
return;
|
||||
}
|
||||
if (attemptGeneration != mAuthAttemptGeneration) {
|
||||
return;
|
||||
}
|
||||
// A read failure (locked/denied/timed-out keychain) is not "no token": log it so a dropped
|
||||
// token that was actually unreadable can be told apart from a genuinely dead one. The recovery
|
||||
// below still proceeds - a fresh sign-in is the safe outcome either way.
|
||||
if (!success) {
|
||||
qWarning().noquote() << "GMCP Char.Login - could not read the stored sign-in while recovering a rejected token:" << errorMessage;
|
||||
}
|
||||
// Capture the per-connection facts this recovery needs before awaiting the keychain: a
|
||||
// Char.Login.Default arriving while the read is in flight resets mConn, and the callback would then
|
||||
// have no hash to recognise the rejected token by and no account or provider to keep a resume hint
|
||||
// from - silently downgrading the drop below into discarding the whole entry.
|
||||
const auto sentTokenHash = mConn.sentReconnectTokenHash;
|
||||
const auto retriedRotatedToken = mConn.retriedRotatedToken;
|
||||
const auto reconnectAccount = mConn.reconnectAccount;
|
||||
const auto accountProvider = mConn.accountProvider;
|
||||
// Latch synchronously rather than when the read returns; see mReconnectRejected's declaration.
|
||||
mReconnectRejected = true;
|
||||
credentialManager->retrievePassword(
|
||||
mpHost->getName(),
|
||||
qsl("reconnect"),
|
||||
[this, safeHost, credentialManager, attemptGeneration, sentTokenHash, retriedRotatedToken, reconnectAccount, accountProvider](bool success, QString value, const QString& errorMessage) {
|
||||
if (credentialManager) {
|
||||
credentialManager->deleteLater();
|
||||
}
|
||||
if (!safeHost) {
|
||||
return;
|
||||
}
|
||||
// A newer sign-in attempt began while the read was in flight; it owns the connection now, so
|
||||
// this recovery must not send anything or reconnect. It may still rewrite the stored entry,
|
||||
// but only on positive evidence that the rejected token is the one stored - see the drop below.
|
||||
const bool superseded = (attemptGeneration != mAuthAttemptGeneration);
|
||||
// A read failure (locked/denied/timed-out keychain) is not "no token": log it so a dropped
|
||||
// token that was actually unreadable can be told apart from a genuinely dead one. The recovery
|
||||
// below still proceeds - a fresh sign-in is the safe outcome either way.
|
||||
if (!success) {
|
||||
qWarning().noquote() << "GMCP Char.Login - could not read the stored sign-in while recovering a rejected token:" << errorMessage;
|
||||
}
|
||||
|
||||
// Shared-store rotation check: if the stored token no longer hashes to what this connection
|
||||
// sent, another running instance rotated it (single-use) - replay the fresh one once, rather
|
||||
// than discarding its token. A rejection whose stored token still matches (a genuinely dead
|
||||
// token, or a non-rotation rejection) falls through to drop-and-re-sign-in below.
|
||||
if (!mConn.retriedRotatedToken && success && !value.isEmpty()) {
|
||||
// The stored JSON may hold a bearer token; parse from an owned buffer and scrub every owned
|
||||
// copy - the QByteArray and the QString value (retrievePassword moved it in) - on all paths,
|
||||
// including a corrupt entry that never parses.
|
||||
QByteArray valueBytes = value.toUtf8();
|
||||
const auto doc = QJsonDocument::fromJson(valueBytes);
|
||||
SecureStringUtils::secureByteArrayClear(valueBytes);
|
||||
SecureStringUtils::secureStringClear(value);
|
||||
if (doc.isObject()) {
|
||||
const auto account = doc.object()[qsl("account")].toString();
|
||||
auto token = doc.object()[qsl("token")].toString();
|
||||
if (!account.isEmpty() && !token.isEmpty()) {
|
||||
QByteArray tokenBytes = token.toUtf8();
|
||||
const QByteArray storedHash = QCryptographicHash::hash(tokenBytes, QCryptographicHash::Sha256);
|
||||
SecureStringUtils::secureByteArrayClear(tokenBytes);
|
||||
if (!mConn.sentReconnectTokenHash.isEmpty() && storedHash != mConn.sentReconnectTokenHash) {
|
||||
bool rejectedTokenStillStored = false;
|
||||
|
||||
// Shared-store rotation check: if the stored token no longer hashes to what this connection
|
||||
// sent, another running instance rotated it (single-use) - replay the fresh one once, rather
|
||||
// than discarding its token. A rejection whose stored token still matches (a genuinely dead
|
||||
// token, or a non-rotation rejection) falls through to drop-and-re-sign-in below.
|
||||
if (!retriedRotatedToken && success && !value.isEmpty()) {
|
||||
// The stored JSON may hold a bearer token; parse from an owned buffer and scrub every owned
|
||||
// copy - the QByteArray and the QString value (retrievePassword moved it in) - on all paths,
|
||||
// including a corrupt entry that never parses.
|
||||
QByteArray valueBytes = value.toUtf8();
|
||||
const auto doc = QJsonDocument::fromJson(valueBytes);
|
||||
SecureStringUtils::secureByteArrayClear(valueBytes);
|
||||
SecureStringUtils::secureStringClear(value);
|
||||
if (doc.isObject()) {
|
||||
const auto account = doc.object()[qsl("account")].toString();
|
||||
auto token = doc.object()[qsl("token")].toString();
|
||||
if (!account.isEmpty() && !token.isEmpty()) {
|
||||
QByteArray tokenBytes = token.toUtf8();
|
||||
const QByteArray storedHash = QCryptographicHash::hash(tokenBytes, QCryptographicHash::Sha256);
|
||||
SecureStringUtils::secureByteArrayClear(tokenBytes);
|
||||
if (!sentTokenHash.isEmpty() && storedHash != sentTokenHash) {
|
||||
if (superseded) {
|
||||
// The newer attempt reads the store for itself, so leave the other
|
||||
// instance's fresh token in place and let it decide.
|
||||
SecureStringUtils::secureStringClear(token);
|
||||
return;
|
||||
}
|
||||
#if defined(DEBUG_GMCP_AUTHENTICATION)
|
||||
qDebug() << "GMCP reconnect token was rotated by another instance; replaying the fresh token";
|
||||
qDebug() << "GMCP reconnect token was rotated by another instance; replaying the fresh token";
|
||||
#endif
|
||||
mConn.retriedRotatedToken = true;
|
||||
mConn.sentReconnectTokenHash = storedHash;
|
||||
mConn.reconnectingWithToken = true;
|
||||
mConn.awaitingReconnectResult = true;
|
||||
mConn.reconnectAccount = account;
|
||||
sendReconnect(account, std::move(token));
|
||||
return;
|
||||
if (sendReconnect(account, std::move(token))) {
|
||||
mConn.retriedRotatedToken = true;
|
||||
mConn.sentReconnectTokenHash = storedHash;
|
||||
mConn.reconnectingWithToken = true;
|
||||
mConn.awaitingReconnectResult = true;
|
||||
mConn.reconnectAccount = account;
|
||||
// This attempt is replaying a live token rather than recovering from a dead
|
||||
// one, so release the latch: the next Char.Login.Default is an ordinary
|
||||
// sign-in again and may use a stored token.
|
||||
mReconnectRejected = false;
|
||||
return;
|
||||
}
|
||||
// The other instance's token is live, so leave the stored entry alone. The
|
||||
// rejection latch stays armed: this connection cannot use a token at all.
|
||||
selectAuthMethod();
|
||||
return;
|
||||
}
|
||||
// Both branches above return, so reaching here means the stored token is not a
|
||||
// rotation. That only counts as evidence the rejected token is still stored when
|
||||
// this connection recorded what it sent; with no hash there is nothing to match.
|
||||
rejectedTokenStillStored = !sentTokenHash.isEmpty();
|
||||
}
|
||||
// Catch-all: scrub the parsed token on every path that did not move it into
|
||||
// sendReconnect - including a stored entry with a token but an empty account - so a
|
||||
// bearer secret is never dropped un-zeroed. Mirrors readStoredSignIn.
|
||||
SecureStringUtils::secureStringClear(token);
|
||||
}
|
||||
}
|
||||
// Catch-all: scrub the parsed token on every path that did not move it into
|
||||
// sendReconnect - including a stored entry with a token but an empty account - so a
|
||||
// bearer secret is never dropped un-zeroed. Mirrors readStoredSignIn.
|
||||
SecureStringUtils::secureStringClear(token);
|
||||
}
|
||||
}
|
||||
// Scrub the retrieved store copy on the fall-through too: when the rotation block was skipped
|
||||
// (a second rejection, or an empty read) value may still hold token JSON. Idempotent when the
|
||||
// block above already cleared it.
|
||||
SecureStringUtils::secureStringClear(value);
|
||||
// Scrub the retrieved store copy on the fall-through too: when the rotation block was skipped
|
||||
// (a second rejection, or an empty read) value may still hold token JSON. Idempotent when the
|
||||
// block above already cleared it.
|
||||
SecureStringUtils::secureStringClear(value);
|
||||
|
||||
// The token really is dead. Keep the account+provider resume hint (dropping only the token) so
|
||||
// the next attempt restarts the same provider's browser sign-in with no menu, then reconnect:
|
||||
// servers commonly drop the connection right after rejecting a reconnect, so the fresh sign-in
|
||||
// needs a fresh, stable connection. mReconnectRejected makes that next connection read the entry
|
||||
// without replaying a possibly not-yet-rewritten token.
|
||||
dropTokenKeepResumeHint();
|
||||
mReconnectRejected = true;
|
||||
//: Shown when a saved password-less sign-in is no longer accepted; Mudlet reconnects so the user can sign in again.
|
||||
mpHost->postMessage(tr("[ INFO ] - Your saved sign-in has expired; reconnecting so you can sign in again."));
|
||||
QTimer::singleShot(0ms, mpHost, [safeHost]() {
|
||||
if (safeHost) {
|
||||
safeHost->mTelnet.reconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
// A superseded recovery rewrites the entry only when this read positively saw the rejected
|
||||
// token still stored. Without that evidence - a failed read, an entry that never parsed, or a
|
||||
// second rejection after a rotation replay - the newer attempt may already have saved its own
|
||||
// fresh token (its Char.Login.Token can land while this read is in flight), and a token-less
|
||||
// rewrite here would erase it and force another browser sign-in. Leaving the entry alone is
|
||||
// self-correcting instead: the latch keeps the newer attempt from replaying a dead token, and
|
||||
// if that attempt never completes, the next connection's rejection runs this recovery again
|
||||
// un-superseded and drops it then.
|
||||
if (superseded && !rejectedTokenStillStored) {
|
||||
// Leaving the rejected token stored means re-arming the latch. The Char.Login.Default
|
||||
// that superseded this recovery already consumed it in attemptReconnect(), so without
|
||||
// this the *next* Char.Login.Default would be free to replay a token the server has
|
||||
// already rejected - the very thing this whole path exists to prevent. Costs at most one
|
||||
// extra resume on the connection after that.
|
||||
mReconnectRejected = true;
|
||||
return;
|
||||
}
|
||||
// The token really is dead. Keep the account+provider resume hint (dropping only the token) so
|
||||
// the next attempt restarts the same provider's browser sign-in with no menu. The captured
|
||||
// account and provider are used rather than mConn's, which a newer Char.Login.Default may have
|
||||
// cleared - that would silently downgrade this to discarding the whole entry.
|
||||
dropTokenKeepResumeHint(reconnectAccount, accountProvider);
|
||||
if (superseded) {
|
||||
// A newer attempt is already driving the sign-in; it consumes the latch set above and
|
||||
// resumes (or hands off) without the dead token, so there is nothing left to do here.
|
||||
return;
|
||||
}
|
||||
// Reconnect: servers commonly drop the connection right after rejecting a reconnect, so the
|
||||
// fresh sign-in needs a fresh, stable connection. The latch set above makes that next
|
||||
// connection read the entry without replaying a possibly not-yet-rewritten token.
|
||||
//: Shown when a saved password-less sign-in is no longer accepted; Mudlet reconnects so the user can sign in again.
|
||||
mpHost->postMessage(tr("[ INFO ] - Your saved sign-in has expired; reconnecting so you can sign in again."));
|
||||
QTimer::singleShot(0ms, mpHost, [safeHost]() {
|
||||
if (safeHost) {
|
||||
safeHost->mTelnet.reconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void GMCPAuthenticator::dropTokenKeepResumeHint()
|
||||
void GMCPAuthenticator::dropTokenKeepResumeHint(const QString& account, const QString& provider)
|
||||
{
|
||||
// Without a remembered provider there is nothing to resume, so remove the whole entry.
|
||||
if (mConn.reconnectAccount.isEmpty() || mConn.accountProvider.isEmpty()) {
|
||||
if (account.isEmpty() || provider.isEmpty()) {
|
||||
discardReconnectToken();
|
||||
return;
|
||||
}
|
||||
storeResumeHint(mConn.reconnectAccount, mConn.accountProvider);
|
||||
storeResumeHint(account, provider);
|
||||
}
|
||||
|
||||
// controller for GMCP authentication
|
||||
|
|
@ -722,7 +826,7 @@ void GMCPAuthenticator::handleAuthGMCP(const QString& packageMessage, const QStr
|
|||
// deciding how to authenticate.
|
||||
resetPerConnectionState();
|
||||
|
||||
attemptReconnect();
|
||||
scheduleSignInAttempt();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -796,6 +900,34 @@ void GMCPAuthenticator::handleAuthToken(const QString& packageMessage, const QSt
|
|||
#endif
|
||||
}
|
||||
|
||||
void GMCPAuthenticator::scheduleSignInAttempt()
|
||||
{
|
||||
if (mSignInAttemptPending) {
|
||||
#if defined(DEBUG_GMCP_AUTHENTICATION)
|
||||
qDebug() << "GMCP Char.Login.Default arrived while a sign-in attempt was already scheduled; folding it into that attempt";
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
if (!mLastSignInAttempt.isValid() || mLastSignInAttempt.durationElapsed() >= scmSignInAttemptInterval) {
|
||||
mLastSignInAttempt.start();
|
||||
attemptReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Inside the throttle window: serve the whole burst with one attempt when it closes, using the
|
||||
// capabilities the last frame left behind, and drop it if that connection has gone by then.
|
||||
mSignInAttemptPending = true;
|
||||
const auto scheduleGeneration = mSignInScheduleGeneration;
|
||||
QTimer::singleShot(scmSignInAttemptInterval - mLastSignInAttempt.durationElapsed(), mpHost, [this, scheduleGeneration]() {
|
||||
if (scheduleGeneration != mSignInScheduleGeneration) {
|
||||
return;
|
||||
}
|
||||
mSignInAttemptPending = false;
|
||||
mLastSignInAttempt.start();
|
||||
attemptReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
void GMCPAuthenticator::attemptReconnect()
|
||||
{
|
||||
mpHost->mTelnet.cancelLoginTimers();
|
||||
|
|
@ -874,20 +1006,23 @@ void GMCPAuthenticator::readStoredSignIn(bool allowToken)
|
|||
mConn.accountProvider = provider;
|
||||
}
|
||||
if (allowToken && !account.isEmpty() && !token.isEmpty()) {
|
||||
// This connection is logging in by replaying a saved token, so a Char.Login.Token
|
||||
// that comes back is a silent rotation rather than a first-time save to announce.
|
||||
mConn.reconnectingWithToken = true;
|
||||
mConn.awaitingReconnectResult = true;
|
||||
mConn.reconnectAccount = account;
|
||||
// Remember only a hash of what we send: if the reconnect is rejected, comparing it
|
||||
// against a fresh read tells a dead token apart from one another running instance
|
||||
// (sharing this profile's keychain) rotated while ours was in flight.
|
||||
QByteArray tokenBytes = token.toUtf8();
|
||||
mConn.sentReconnectTokenHash = QCryptographicHash::hash(tokenBytes, QCryptographicHash::Sha256);
|
||||
const QByteArray sentHash = QCryptographicHash::hash(tokenBytes, QCryptographicHash::Sha256);
|
||||
SecureStringUtils::secureByteArrayClear(tokenBytes);
|
||||
// Move the token in so sendReconnect owns the sole copy and can scrub it after sending.
|
||||
sendReconnect(account, std::move(token));
|
||||
return;
|
||||
// Move the token in so sendReconnect owns the sole copy and can scrub it either way.
|
||||
if (sendReconnect(account, std::move(token))) {
|
||||
// This connection is logging in by replaying a saved token, so a Char.Login.Token
|
||||
// that comes back is a silent rotation rather than a first-time save to announce.
|
||||
mConn.reconnectingWithToken = true;
|
||||
mConn.awaitingReconnectResult = true;
|
||||
mConn.reconnectAccount = account;
|
||||
mConn.sentReconnectTokenHash = sentHash;
|
||||
return;
|
||||
}
|
||||
// Not sent, so nothing awaits a result on it; fall through to resume or hand-off.
|
||||
}
|
||||
if (!account.isEmpty() && !provider.isEmpty()) {
|
||||
// No usable token, but we remember how this account signs in: ask the game to
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
#include "Host.h"
|
||||
#include "utils.h"
|
||||
|
||||
#include <QElapsedTimer>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
|
||||
class OAuthClientFlow;
|
||||
|
|
@ -58,19 +60,28 @@ public:
|
|||
|
||||
private:
|
||||
void handleAuthUrl(const QString& packageMessage, const QString& data);
|
||||
void openSignInUrl(const QUrl& url, const QString& provider);
|
||||
// The single place where a sign-in web address may reach the system browser, for both the
|
||||
// server-driven (Char.Login.URL) and the client-driven flow. Auto-opens only against evidence that
|
||||
// the player wants to sign in and consumes it, otherwise offers the address as a link.
|
||||
// answersTheGamesSignInOffer marks an address reached from Char.Login.Default, where connecting is
|
||||
// itself that evidence - once per connection.
|
||||
void offerOrOpenSignInUrl(const QUrl& url, const QString& provider, bool answersTheGamesSignInOffer);
|
||||
bool openSignInUrl(const QUrl& url, const QString& provider);
|
||||
void startClientDrivenOAuth();
|
||||
void cancelClientDrivenOAuth();
|
||||
void announceBrowserHandoff(const QString& provider);
|
||||
void sendAuthCode(QString code, QString codeVerifier, const QString& redirectUri);
|
||||
void sendAuthCode(QString code, QString codeVerifier, const QString& redirectUri, QString nonce);
|
||||
void selectAuthMethod();
|
||||
void scheduleSignInAttempt();
|
||||
void attemptReconnect();
|
||||
// Reads the stored sign-in entry ({account, provider?, token?}) and acts on it: replay the token
|
||||
// (when allowToken), else send the resume form for a remembered provider, else fall through to
|
||||
// selectAuthMethod(). allowToken is false on the connection straight after a rejection, so a
|
||||
// not-yet-rewritten entry cannot loop us back into another rejected reconnect.
|
||||
void readStoredSignIn(bool allowToken);
|
||||
void sendReconnect(const QString& account, QString token);
|
||||
// Returns false when it refused to send: the token is a bearer secret and never goes out over a
|
||||
// cleartext transport. It is scrubbed either way, and only a true return means a result is awaited.
|
||||
bool sendReconnect(const QString& account, QString token);
|
||||
// Sends the resume form of Char.Login.Credentials: {account, provider, version}, no password -
|
||||
// asking the game to restart the browser sign-in for the provider remembered from an earlier
|
||||
// Char.Login.URL. The absence of a password (not the presence of provider) is what distinguishes it.
|
||||
|
|
@ -82,12 +93,16 @@ private:
|
|||
// replayed once instead of destroyed. Only a genuinely dead token is dropped, keeping the
|
||||
// account+provider resume hint so the next sign-in needs no provider menu.
|
||||
void retryOrDropRejectedToken();
|
||||
void dropTokenKeepResumeHint();
|
||||
// Takes the account and provider explicitly: the caller captures them before its keychain read, so
|
||||
// a Char.Login.Default arriving mid-read cannot clear mConn and turn this into a full discard.
|
||||
void dropTokenKeepResumeHint(const QString& account, const QString& provider);
|
||||
// Rewrites the stored entry as {account, provider} with no token: enough to resume later, nothing
|
||||
// any longer a bearer secret.
|
||||
void storeResumeHint(const QString& account, const QString& provider);
|
||||
void discardReconnectToken(std::function<void(bool success)> callback = {});
|
||||
void resetPerConnectionState();
|
||||
// Per socket connection, unlike resetPerConnectionState() which runs per Char.Login.Default.
|
||||
void resetForNewConnection();
|
||||
|
||||
bool clientDrivenOAuthAvailable() const;
|
||||
|
||||
|
|
@ -144,17 +159,37 @@ private:
|
|||
};
|
||||
PerConnectionState mConn;
|
||||
|
||||
// Set when a reconnect token is rejected and we reconnect for a clean sign-in. Deliberately NOT part
|
||||
// of mConn: it is a one-shot latch consumed by attemptReconnect() on the very next connection, so it
|
||||
// must survive the per-connection reset that the reconnect it triggers performs. The saved token is
|
||||
// cleared asynchronously, so this makes that next connection skip a token replay rather than racing
|
||||
// the keychain rewrite and looping back into another rejected reconnect.
|
||||
// Set when a reconnect token is rejected, before the keychain read that decides what to do about it.
|
||||
// Deliberately NOT part of mConn: attemptReconnect() consumes it on the next Char.Login.Default, so it
|
||||
// must survive the per-connection reset that Default performs. The saved token is cleared
|
||||
// asynchronously, so this makes the next attempt skip a token replay rather than racing the keychain
|
||||
// rewrite and looping back into another rejected reconnect. That next Default usually arrives on the
|
||||
// connection we reconnect to, but a server is also permitted to re-offer one on this connection
|
||||
// instead, and Char.Login 2 forbids replaying a token rejected on it - hence latching synchronously at
|
||||
// the rejection rather than when the read returns.
|
||||
//
|
||||
// Consumed in one place (attemptReconnect()) but cleared or re-armed in two others, so audit all three
|
||||
// together: retryOrDropRejectedToken() clears it when it replays a live rotated token, and re-arms it
|
||||
// when a superseded recovery leaves the rejected token stored - by then the superseding Default has
|
||||
// already consumed the latch, so without re-arming the Default after that could replay the dead token.
|
||||
bool mReconnectRejected = false;
|
||||
// Incremented on every per-connection auth reset (each Char.Login.Default). The asynchronous
|
||||
// reconnect-token keychain read captures the value current when it started and re-checks it in its
|
||||
// callback, so a result arriving after a newer connection began is discarded instead of driving a
|
||||
// sign-in on the wrong attempt. Not part of mConn: it must monotonically increase, never reset.
|
||||
unsigned int mAuthAttemptGeneration = 0;
|
||||
|
||||
// A server can pack thousands of Char.Login.Default frames into one packet and every sign-in
|
||||
// attempt reads the credential store. Throttling bounds that cost by wall clock rather than by how
|
||||
// much the server sent, and unlike a hard per-connection cap never refuses a legitimate re-offer.
|
||||
inline static constexpr std::chrono::milliseconds scmSignInAttemptInterval = std::chrono::seconds(1);
|
||||
QElapsedTimer mLastSignInAttempt;
|
||||
bool mSignInAttemptPending = false;
|
||||
// Bumped whenever a connection begins or ends, so a deferred attempt from a previous one is dropped.
|
||||
unsigned int mSignInScheduleGeneration = 0;
|
||||
// One automatic browser hand-off per connection for an address reached from the game's sign-in
|
||||
// offer, so a server cannot turn a burst of frames into a burst of tabs.
|
||||
bool mUnpromptedBrowserOpenAvailable = true;
|
||||
};
|
||||
|
||||
#endif // MUDLET_AUTHENTICATOR_H
|
||||
|
|
|
|||
589
src/Host.cpp
589
src/Host.cpp
|
|
@ -60,18 +60,22 @@
|
|||
|
||||
#include <chrono>
|
||||
#include <QtConcurrentRun>
|
||||
#include <QApplication>
|
||||
#include <QCoreApplication>
|
||||
#include <QDataStream>
|
||||
#include <QDialog>
|
||||
#include <QDirIterator>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QKeyEvent>
|
||||
#include <QtUiTools>
|
||||
#include <QMovie>
|
||||
#include <QNetworkProxy>
|
||||
#include <QRandomGenerator>
|
||||
#include <QRegularExpression>
|
||||
#include <QSaveFile>
|
||||
#include <QScopeGuard>
|
||||
#include <QSettings>
|
||||
#include <QTemporaryFile>
|
||||
#include <QTextStream>
|
||||
#include <zip.h>
|
||||
#include <memory>
|
||||
|
|
@ -242,7 +246,6 @@ Host::Host(int port, const QString& hostname, const QString& login, const QStrin
|
|||
, mpMap(new TMap(this, hostname))
|
||||
, mpMedia(new TMedia(this, hostname))
|
||||
, mpAuth(new GMCPAuthenticator(this))
|
||||
, mpDockableMapWidget()
|
||||
, mTimerDebugOutputSuppressionInterval(QTime())
|
||||
, mSearchOptions(dlgTriggerEditor::SearchOption::SearchOptionNone)
|
||||
, mBufferSearchOptions(TConsole::SearchOption::SearchOptionNone)
|
||||
|
|
@ -409,6 +412,8 @@ Host::Host(int port, const QString& hostname, const QString& login, const QStrin
|
|||
}
|
||||
});
|
||||
connect(&purgeTimer, &QTimer::timeout, this, &Host::slot_purgeTemps);
|
||||
mDeferredSaveTimer.setSingleShot(true);
|
||||
connect(&mDeferredSaveTimer, &QTimer::timeout, this, &Host::slot_saveProfileAfterPackageChange);
|
||||
connect(this, &Host::signal_forceMXPProcessorOnChanged, this, [this](bool enabled) {
|
||||
if (enabled) {
|
||||
if (!mMxpProcessor.isEnabled()) {
|
||||
|
|
@ -446,6 +451,38 @@ Host::~Host()
|
|||
// Mark the host as closing down to prevent keybinding processing during destruction
|
||||
mIsClosingDown = true;
|
||||
|
||||
// closeChildren() normally does this, but a Host whose console has already
|
||||
// gone never gets there - and a package save left to fire from a Host that
|
||||
// is being taken apart runs against freed members (#9653):
|
||||
mDeferredSaveTimer.stop();
|
||||
|
||||
// The editor is a parentless top-level window, so delete it here while the
|
||||
// units it references are still alive. Null the QPointer first: it only
|
||||
// clears itself once ~QObject is reached, so anything looking at
|
||||
// mpEditorDialog mid-teardown would find a half-destroyed widget:
|
||||
if (auto* pEditor = mpEditorDialog.data()) {
|
||||
mpEditorDialog = nullptr;
|
||||
delete pEditor;
|
||||
}
|
||||
|
||||
if (auto* pNotePad = mpNotePad.data()) {
|
||||
if (mudlet::self()) {
|
||||
pNotePad->save();
|
||||
pNotePad->close();
|
||||
}
|
||||
mpNotePad = nullptr;
|
||||
delete pNotePad;
|
||||
}
|
||||
|
||||
if (auto* pDlgIRC = mpDlgIRC.data()) {
|
||||
mpDlgIRC = nullptr;
|
||||
delete pDlgIRC;
|
||||
}
|
||||
|
||||
for (const auto& pToolBar : mActionUnit.getToolBarList()) {
|
||||
delete pToolBar.data();
|
||||
}
|
||||
|
||||
// This needs to be cleared here while the Host object is still valid,
|
||||
// otherwise it'll be cleared when the Host object is being destroyed,
|
||||
// which can lead to a crash when closing multiple profiles at once.
|
||||
|
|
@ -453,11 +490,6 @@ Host::~Host()
|
|||
|
||||
mStopWatchMap.clear();
|
||||
|
||||
if (mpDockableMapWidget) {
|
||||
mpDockableMapWidget->deleteLater();
|
||||
}
|
||||
|
||||
|
||||
mErrorLogStream.flush();
|
||||
mErrorLogFile.close();
|
||||
// Since this is a destructor, it's risky to rely on member variables within the destructor itself.
|
||||
|
|
@ -516,6 +548,14 @@ bool Host::requestClose()
|
|||
void Host::closeChildren()
|
||||
{
|
||||
mIsClosingDown = true;
|
||||
// Drop the profile save a package install/uninstall put off: the close path
|
||||
// has already saved the profile with that change in it (or the user declined
|
||||
// to save at all), and a save that outlives the profile runs on a destroyed
|
||||
// Host (#9653).
|
||||
if (mDeferredSaveTimer.isActive()) {
|
||||
qDebug().nospace().noquote() << "Host::closeChildren() INFO - dropping the profile save that a package change owed \"" << getName() << "\": the close saves the profile itself.";
|
||||
mDeferredSaveTimer.stop();
|
||||
}
|
||||
const auto hostToolBarMap = getActionUnit()->getToolBarList();
|
||||
// disconnect before removing objects from memory as sysDisconnectionEvent needs that stuff.
|
||||
mTelnet.terminateConnection();
|
||||
|
|
@ -632,58 +672,95 @@ void Host::createModuleBackup(const QString& filename, const QString& saveName)
|
|||
QFile::copy(filename, saveName + time);
|
||||
}
|
||||
|
||||
void Host::writeModule(const QString& moduleName, const QString& filename)
|
||||
QList<QFuture<bool>> Host::pendingXmlSaveFutures() const
|
||||
{
|
||||
QString xml_filename = filename;
|
||||
if (filename.endsWith(qsl("mpackage"), Qt::CaseInsensitive) || filename.endsWith(qsl("zip"), Qt::CaseInsensitive)) {
|
||||
xml_filename = mudlet::getMudletPath(enums::profilePackagePathFileName, mHostName, moduleName);
|
||||
QList<QFuture<bool>> futures;
|
||||
for (const auto& writer : writers) {
|
||||
futures += writer->saveFutures;
|
||||
}
|
||||
auto writer = std::make_shared<XMLexport>(this);
|
||||
writers.insert(xml_filename, writer);
|
||||
writer->writeModuleXML(moduleName, xml_filename);
|
||||
updateModuleZips(filename, moduleName);
|
||||
return futures;
|
||||
}
|
||||
|
||||
void Host::waitForAsyncXmlSave()
|
||||
{
|
||||
// writers and futures are copied to prevent deletion during for loop (which would mean crash)
|
||||
auto myWriters = writers;
|
||||
for (auto& writer : myWriters) {
|
||||
auto myFutures = writer->saveFutures;
|
||||
for (auto& future : myFutures) {
|
||||
future.waitForFinished();
|
||||
}
|
||||
// Snapshot on the main thread - see pendingXmlSaveFutures()
|
||||
const auto futures = pendingXmlSaveFutures();
|
||||
for (auto future : futures) {
|
||||
future.waitForFinished();
|
||||
}
|
||||
}
|
||||
|
||||
void Host::saveModules(bool backup)
|
||||
QList<Host::ModuleWriteJob> Host::prepareModuleSaves(bool backup)
|
||||
{
|
||||
QMapIterator<QString, QStringList> it(modulesToWrite);
|
||||
// Runs on the main thread so it can safely read the live trigger/timer/... lists
|
||||
// (via writeModuleXML) and mutate the `writers`/`mModulesToSync`/`modulesToWrite`
|
||||
// bookkeeping. The returned jobs carry everything writeModuleFiles() needs - the
|
||||
// document and every path - so the background task touches neither that shared
|
||||
// state nor the Host, which may well be destroyed before the task even starts.
|
||||
QList<ModuleWriteJob> jobs;
|
||||
mModulesToSync.clear();
|
||||
const QString savePath = mudlet::getMudletPath(enums::moduleBackupsPath);
|
||||
auto savePathDir = QDir(savePath);
|
||||
if (!savePathDir.exists()) {
|
||||
savePathDir.mkpath(savePath);
|
||||
}
|
||||
const QString backupPath = backup ? mudlet::getMudletPath(enums::moduleBackupsPath) : QString();
|
||||
QMapIterator<QString, QStringList> it(modulesToWrite);
|
||||
while (it.hasNext()) {
|
||||
it.next();
|
||||
QStringList entry = it.value();
|
||||
const QStringList entry = it.value();
|
||||
const QString moduleName = it.key();
|
||||
const QString filename = entry[0];
|
||||
const QString filename = entry.at(0);
|
||||
|
||||
if (!mModulesLoadedOk.contains(moduleName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (backup) {
|
||||
createModuleBackup(filename, savePath + moduleName);
|
||||
QString xmlFilename = filename;
|
||||
if (filename.endsWith(qsl("mpackage"), Qt::CaseInsensitive) || filename.endsWith(qsl("zip"), Qt::CaseInsensitive)) {
|
||||
xmlFilename = mudlet::getMudletPath(enums::profilePackagePathFileName, mHostName, moduleName);
|
||||
// The write below goes into this folder, so it has to exist before the
|
||||
// write and not - as it used to - after it: a module whose unpacked folder
|
||||
// the user has removed would otherwise fail to write, and then have its
|
||||
// now-stale XML dropped from its archive without a replacement going in.
|
||||
const QString packagePath = mudlet::getMudletPath(enums::profilePackagePath, mHostName, moduleName);
|
||||
if (auto packageDir = QDir(packagePath); !packageDir.exists()) {
|
||||
packageDir.mkpath(packagePath);
|
||||
}
|
||||
}
|
||||
writeModule(moduleName, filename);
|
||||
if (entry[1].toInt()) {
|
||||
|
||||
auto writer = std::make_shared<XMLexport>(this);
|
||||
writer->writeModuleXML(moduleName);
|
||||
// The writer stays in `writers` purely as the save-in-progress token that
|
||||
// xmlSaved() retires on the main thread, so the XMLexport - a QObject with
|
||||
// main-thread affinity - is only ever destroyed there.
|
||||
writers.insert(xmlFilename, writer);
|
||||
jobs.append({writer->takeExportDocument(), moduleName, filename, xmlFilename, backup ? backupPath + moduleName : QString()});
|
||||
|
||||
if (entry.at(1).toInt()) {
|
||||
mModulesToSync << moduleName;
|
||||
}
|
||||
}
|
||||
modulesToWrite.clear();
|
||||
if (!backupPath.isEmpty() && !jobs.isEmpty()) {
|
||||
auto backupDir = QDir(backupPath);
|
||||
if (!backupDir.exists()) {
|
||||
backupDir.mkpath(backupPath);
|
||||
}
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
void Host::writeModuleFiles(const QList<ModuleWriteJob>& jobs)
|
||||
{
|
||||
// Pure file I/O over self-contained jobs, usually on a thread pool thread. It is
|
||||
// static because a close that answers "No" to "Save profile?", and one that finds
|
||||
// the main console already gone, wait for nothing: the Host that ordered these
|
||||
// writes can be, and regularly is, destroyed while they are still queued.
|
||||
for (const auto& job : jobs) {
|
||||
if (!job.backupName.isEmpty()) {
|
||||
createModuleBackup(job.filename, job.backupName);
|
||||
}
|
||||
if (!XMLexport::saveXmlDocToFile(job.xmlFilename, *job.document)) {
|
||||
qWarning().noquote().nospace() << "Host::writeModuleFiles() WARNING - failed to write module \"" << job.moduleName << "\" to \"" << job.xmlFilename << "\".";
|
||||
}
|
||||
updateModuleZip(job);
|
||||
}
|
||||
}
|
||||
|
||||
void Host::reloadModules()
|
||||
|
|
@ -716,14 +793,17 @@ void Host::reloadModules()
|
|||
mModulesToSync.clear();
|
||||
}
|
||||
|
||||
void Host::updateModuleZips(const QString& zipName, const QString& moduleName)
|
||||
void Host::updateModuleZip(const ModuleWriteJob& job)
|
||||
{
|
||||
// Static for the same reason writeModuleFiles() is: every path it needs was
|
||||
// resolved, and every folder it needs created, on the main thread beforehand.
|
||||
const QString zipName = job.filename;
|
||||
const QString moduleName = job.moduleName;
|
||||
if (!(zipName.endsWith(qsl("mpackage"), Qt::CaseInsensitive) || zipName.endsWith(qsl("zip"), Qt::CaseInsensitive))) {
|
||||
return;
|
||||
}
|
||||
zip* zipFile = nullptr;
|
||||
const QString packagePathName = mudlet::getMudletPath(enums::profilePackagePath, mHostName, moduleName);
|
||||
const QString filename_xml = mudlet::getMudletPath(enums::profilePackagePathFileName, mHostName, moduleName);
|
||||
const QString filename_xml = job.xmlFilename;
|
||||
int err = 0;
|
||||
zipFile = zip_open(zipName.toStdString().c_str(), ZIP_CREATE, &err);
|
||||
if (!zipFile) {
|
||||
|
|
@ -736,15 +816,11 @@ void Host::updateModuleZips(const QString& zipName, const QString& moduleName)
|
|||
existing file that is to be overwritten may be a source of problems
|
||||
here.
|
||||
*/
|
||||
qWarning().noquote().nospace() << "Host::updateModuleZips(\"" << zipName << "\", \"" << moduleName << "\") WARNING - failed to open module to update it, error: \""
|
||||
qWarning().noquote().nospace() << "Host::updateModuleZip(\"" << zipName << "\", \"" << moduleName << "\") WARNING - failed to open module to update it, error: \""
|
||||
<< zip_error_strerror(&zipError) << "\"";
|
||||
zip_error_fini(&zipError);
|
||||
return;
|
||||
}
|
||||
const QDir packageDir = QDir(packagePathName);
|
||||
if (!packageDir.exists()) {
|
||||
packageDir.mkpath(packagePathName);
|
||||
}
|
||||
const int xmlIndex = zip_name_locate(zipFile, qsl("%1.xml").arg(moduleName).toUtf8().constData(), ZIP_FL_ENC_GUESS);
|
||||
zip_delete(zipFile, xmlIndex);
|
||||
struct zip_source* s = zip_source_file(zipFile, filename_xml.toUtf8().constData(), 0, -1);
|
||||
|
|
@ -850,6 +926,13 @@ bool Host::resetProfile_phase1()
|
|||
return false;
|
||||
}
|
||||
|
||||
// Phase 2 lua_close()s the very state the pump is running Lua code on, so
|
||||
// refuse rather than reset into a use-after-free.
|
||||
if (mLuaInterpreter.pumpingEvents()) {
|
||||
qWarning() << "Host::resetProfile_phase1() called while the test-mode event pump is running, ignoring";
|
||||
return false;
|
||||
}
|
||||
|
||||
mAliasUnit.stopAllTriggers();
|
||||
mTriggerUnit.stopAllTriggers();
|
||||
mTimerUnit.stopAllTriggers();
|
||||
|
|
@ -875,6 +958,7 @@ void Host::resetProfile_phase2()
|
|||
mTimerUnit.doCleanup();
|
||||
mTriggerUnit.doCleanup();
|
||||
mKeyUnit.doCleanup();
|
||||
mActionUnit.doCleanup();
|
||||
mpConsole->resetMainConsole();
|
||||
// Drain queued DeferredDelete events so old TLabel destructors run their
|
||||
// luaL_unref against the still-live Lua state. Without this, those unrefs
|
||||
|
|
@ -990,6 +1074,15 @@ std::tuple<bool, QString, QString> Host::saveProfile(const QString& saveFolder,
|
|||
writers.remove(qsl("profile"));
|
||||
return {false, filename_xml, tr("the profile is no longer available")};
|
||||
}
|
||||
|
||||
// Build the module XML documents and register their writers here, on the main
|
||||
// thread, while the live profile data is quiescent: the background task below then
|
||||
// only serializes the prepared documents and never touches `writers`,
|
||||
// `modulesToWrite` or `mModulesToSync` (doing so from a pool thread was a
|
||||
// heap-corrupting data race).
|
||||
const bool backupModules = saveName != qsl("autosave");
|
||||
const QList<ModuleWriteJob> moduleJobs = prepareModuleSaves(backupModules);
|
||||
|
||||
mWritingHostAndModules = true;
|
||||
|
||||
// emit signal to notify the UI that the save button should get disabled momentarily
|
||||
|
|
@ -1003,20 +1096,49 @@ std::tuple<bool, QString, QString> Host::saveProfile(const QString& saveFolder,
|
|||
qApp->processEvents();
|
||||
}
|
||||
|
||||
auto watcher = new QFutureWatcher<void>;
|
||||
mModuleFuture = QtConcurrent::run([=, this]() {
|
||||
// wait for the host xml to be ready before starting to sync modules
|
||||
waitForAsyncXmlSave();
|
||||
saveModules(saveName != qsl("autosave"));
|
||||
// Snapshot the pending profile-save futures on the main thread - see
|
||||
// pendingXmlSaveFutures(): the background task must not read `writers`/`saveFutures`
|
||||
// itself, as the main thread mutates them whenever a save starts or finishes.
|
||||
const QList<QFuture<bool>> xmlSaveFutures = pendingXmlSaveFutures();
|
||||
// Parented so the profile owns the watcher outright. The deleteLater() below only
|
||||
// arrives if the event loop lives long enough to deliver `finished` and then the
|
||||
// deferred delete, which on the way out it does not - and a Host destroyed while
|
||||
// the write is still queued would leave the watcher with no owner at all.
|
||||
auto watcher = new QFutureWatcher<void>(this);
|
||||
// Captures values only, never `this`: no wait for this task is guaranteed, so the
|
||||
// Host can be destroyed while it is still queued or running.
|
||||
mModuleFuture = QtConcurrent::run([xmlSaveFutures, moduleJobs]() {
|
||||
// wait for the host xml to be ready before writing the modules out
|
||||
for (auto future : xmlSaveFutures) {
|
||||
future.waitForFinished();
|
||||
}
|
||||
Host::writeModuleFiles(moduleJobs);
|
||||
});
|
||||
connect(watcher, &QFutureWatcher<void>::finished, this, [=, this]() {
|
||||
// reload, or queue module reload for when xml is ready
|
||||
// Only the names: holding the whole jobs would keep every module's document in
|
||||
// memory until this runs, and all it needs is what to retire from `writers`.
|
||||
QStringList savedModuleXmlNames;
|
||||
savedModuleXmlNames.reserve(moduleJobs.size());
|
||||
for (const auto& job : moduleJobs) {
|
||||
savedModuleXmlNames << job.xmlFilename;
|
||||
}
|
||||
connect(watcher, &QFutureWatcher<void>::finished, this, [this, savedModuleXmlNames, syncModules]() {
|
||||
// Finish on the main thread: the module documents are now on disk. Consume
|
||||
// mModulesToSync via reloadModules() *before* the xmlSaved() loop below empties
|
||||
// `writers` and emits profileSaveFinished(): that signal fires synchronously,
|
||||
// and a deferred handler (e.g. a queued package install) could start another
|
||||
// save that clears/replaces mModulesToSync, making this save skip the module
|
||||
// sync it owes to other profiles.
|
||||
if (syncModules) {
|
||||
reloadModules();
|
||||
}
|
||||
mWritingHostAndModules = false;
|
||||
watcher->deleteLater();
|
||||
// Drop each module writer from `writers`; the last removal emits
|
||||
// profileSaveFinished() once the profile writer is gone too.
|
||||
for (const auto& xmlFilename : savedModuleXmlNames) {
|
||||
xmlSaved(xmlFilename);
|
||||
}
|
||||
});
|
||||
connect(watcher, &QFutureWatcher<void>::finished, watcher, &QObject::deleteLater);
|
||||
watcher->setFuture(mModuleFuture);
|
||||
return {true, filename_xml, QString()};
|
||||
}
|
||||
|
|
@ -1316,26 +1438,7 @@ bool Host::checkForMappingScript()
|
|||
void Host::check_for_mappingscript()
|
||||
{
|
||||
if (!checkForMappingScript()) {
|
||||
QUiLoader loader;
|
||||
|
||||
QFile file(":/ui/lacking_mapper_script.ui");
|
||||
if (!file.open(QFile::ReadOnly)) {
|
||||
qWarning() << "Host: failed to open lacking_mapper_script.ui for reading:" << file.errorString();
|
||||
return;
|
||||
}
|
||||
|
||||
auto dialog = dynamic_cast<QDialog*>(loader.load(&file, mudlet::self()));
|
||||
file.close();
|
||||
if (!dialog) {
|
||||
// could not load / not a QDialog
|
||||
return;
|
||||
}
|
||||
|
||||
connect(dialog, &QDialog::accepted, mudlet::self(), &mudlet::slot_openMappingScriptsPage);
|
||||
|
||||
dialog->show();
|
||||
dialog->raise();
|
||||
dialog->activateWindow();
|
||||
emit signal_showMapperScriptReminder();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1788,6 +1891,10 @@ void Host::incomingStreamProcessor(const QString& data, int line)
|
|||
mTimerUnit.doCleanup();
|
||||
mTriggerUnit.doCleanup();
|
||||
mKeyUnit.doCleanup();
|
||||
mActionUnit.doCleanup();
|
||||
// ScriptUnit defers deletes too (a package script uninstalling its own package
|
||||
// mid-compile or mid-event-dispatch), so flush it here alongside the others:
|
||||
mScriptUnit.doCleanup();
|
||||
}
|
||||
|
||||
// When Mudlet is running in online mode, deleted temp* objects are cleaned up in bulk
|
||||
|
|
@ -1799,6 +1906,32 @@ void Host::slot_purgeTemps()
|
|||
mTimerUnit.doCleanup();
|
||||
mTriggerUnit.doCleanup();
|
||||
mKeyUnit.doCleanup();
|
||||
mActionUnit.doCleanup();
|
||||
mScriptUnit.doCleanup();
|
||||
}
|
||||
|
||||
// The profile save that installPackage()/uninstallPackage() put off to the next
|
||||
// event loop pass - see mDeferredSaveTimer.
|
||||
void Host::slot_saveProfileAfterPackageChange()
|
||||
{
|
||||
if (currentlySavingProfile()) {
|
||||
// saveProfile() would refuse outright, and this is the only save the
|
||||
// package change has coming: ask again once the one in flight is out of
|
||||
// the way rather than leaving the change unwritten until something else
|
||||
// happens to save. The profile close stops this timer, so the retries
|
||||
// cannot outlive the profile.
|
||||
mDeferredSaveTimer.start(100ms);
|
||||
return;
|
||||
}
|
||||
// If a package's own script uninstalled it mid-compile (from a script
|
||||
// reached outside the compileAll()/editor/raiseEvent flush points, e.g. a
|
||||
// permScript() run from an alias or key), the script deletes were deferred
|
||||
// and are still registered. Flush them now, at depth 0, before saving so
|
||||
// the save below does not serialize the just-uninstalled scripts back in:
|
||||
mScriptUnit.doCleanup();
|
||||
if (auto [ok, filename, error] = saveProfile(); !ok) {
|
||||
qWarning() << qsl("Host::slot_saveProfileAfterPackageChange() WARNING - couldn't save '%1' to '%2' because: %3").arg(getName(), filename, error);
|
||||
}
|
||||
}
|
||||
|
||||
void Host::registerEventHandler(const QString& name, TScript* pScript)
|
||||
|
|
@ -1833,7 +1966,9 @@ void Host::unregisterEventHandler(const QString& name, TScript* pScript)
|
|||
}
|
||||
}
|
||||
|
||||
// If a handler matches the event, the Lua stack will be cleared after this function
|
||||
// Handlers run on this profile's shared lua_State, but each unwinds it back to
|
||||
// the level it found, so a C function raising an event mid-flight keeps its own
|
||||
// arguments and any return values it has already pushed
|
||||
void Host::raiseEvent(const TEvent& pE)
|
||||
{
|
||||
if (Q_UNLIKELY(mEmergencyStop)) {
|
||||
|
|
@ -1846,6 +1981,18 @@ void Host::raiseEvent(const TEvent& pE)
|
|||
|
||||
static const QString star = qsl("*");
|
||||
|
||||
// A handler can uninstall its own package mid-dispatch (a common package
|
||||
// auto-updater pattern): whilst this frame is on the stack
|
||||
// ScriptUnit::uninstall() defers its deletes so neither the executing
|
||||
// handler nor the other TScript pointers in the lists copied below get
|
||||
// freed under us; the deferred deletes are flushed once the outermost
|
||||
// dispatch finishes:
|
||||
mScriptUnit.beginProcessing();
|
||||
const auto processingGuard = qScopeGuard([this] {
|
||||
mScriptUnit.endProcessing();
|
||||
mScriptUnit.doCleanup();
|
||||
});
|
||||
|
||||
if (mEventHandlerMap.contains(pE.mArgumentList.at(0))) {
|
||||
QList<TScript*> scriptList = mEventHandlerMap.value(pE.mArgumentList.at(0));
|
||||
for (auto& script : scriptList) {
|
||||
|
|
@ -1872,6 +2019,10 @@ void Host::raiseEvent(const TEvent& pE)
|
|||
}
|
||||
}
|
||||
|
||||
// Let any test-mode waitForEvent() call blocked on this event capture its
|
||||
// arguments and unblock. Cheap (an empty-list check) when nothing is waiting.
|
||||
mLuaInterpreter.captureEventForWaits(pE);
|
||||
|
||||
// After the event has been raised but before 'event' goes out of scope,
|
||||
// we need to safely dereference the members of 'event' that point to
|
||||
// values in the Lua registry
|
||||
|
|
@ -1949,10 +2100,7 @@ std::pair<bool, QString> Host::installPackage(const QString& fileName, enums::Pa
|
|||
return {true, QString()};
|
||||
}
|
||||
|
||||
// As the pointer to dialog is only used now WITHIN this method and this
|
||||
// method can be re-entered, it is best to use a local rather than a class
|
||||
// pointer just in case we accidentally re-enter this method in the future.
|
||||
QDialog* pUnzipDialog = nullptr;
|
||||
bool showedUnpackingDialog = false;
|
||||
|
||||
QString actualFileName = fileName;
|
||||
std::unique_ptr<QTemporaryFile> tempFile;
|
||||
|
|
@ -2035,51 +2183,33 @@ std::pair<bool, QString> Host::installPackage(const QString& fileName, enums::Pa
|
|||
// home directory for the PROFILE
|
||||
const QDir _tmpDir(_home);
|
||||
// directory to store the expanded archive file contents
|
||||
// Noted before it is made: the only folder this install may ever delete
|
||||
// again is one it made itself. The package name is the archive's own file
|
||||
// name, and then whatever its config.lua says, so it can just as well name
|
||||
// a folder of the profile's that was already here ("map", "log",
|
||||
// "current") - see the refusal further down.
|
||||
const bool destinationAlreadyExisted = QDir(_dest).exists();
|
||||
const bool mkpathSuccessful = _tmpDir.mkpath(_dest);
|
||||
if (!mkpathSuccessful) {
|
||||
return {false, qsl("could not create destination folder")};
|
||||
}
|
||||
QString folderThisInstallMade = destinationAlreadyExisted ? QString() : QDir(_dest).absolutePath();
|
||||
|
||||
// Skip the unpacking dialog for modules created from UI, and for
|
||||
// script-initiated installs (passed via quiet) to avoid stealing
|
||||
// window-manager focus from the user's other applications - see
|
||||
// issue #9170.
|
||||
if (thing != enums::PackageModuleType::ModuleFromUI && !quiet) {
|
||||
QUiLoader loader(this);
|
||||
QFile uiFile(qsl(":/ui/package_manager_unpack.ui"));
|
||||
if (!uiFile.open(QFile::ReadOnly)) {
|
||||
qWarning() << "Host: failed to open package_manager_unpack.ui for reading:" << uiFile.errorString();
|
||||
return {false, qsl("could not open unpacking progress dialog UI file")};
|
||||
}
|
||||
pUnzipDialog = dynamic_cast<QDialog*>(loader.load(&uiFile, nullptr));
|
||||
uiFile.close();
|
||||
if (!pUnzipDialog) {
|
||||
return {false, qsl("could not load unpacking progress dialog")};
|
||||
}
|
||||
|
||||
auto* pLabel = pUnzipDialog->findChild<QLabel*>(qsl("label"));
|
||||
if (pLabel) {
|
||||
if (thing != enums::PackageModuleType::Package) {
|
||||
pLabel->setText(tr("Unpacking module:\n\"%1\"\nplease wait...").arg(packageName));
|
||||
} else {
|
||||
pLabel->setText(tr("Unpacking package:\n\"%1\"\nplease wait...").arg(packageName));
|
||||
}
|
||||
}
|
||||
pUnzipDialog->hide(); // Must hide to change WindowModality
|
||||
pUnzipDialog->setWindowTitle(tr("Unpacking"));
|
||||
pUnzipDialog->setWindowModality(Qt::ApplicationModal);
|
||||
pUnzipDialog->show();
|
||||
qApp->processEvents();
|
||||
pUnzipDialog->raise();
|
||||
pUnzipDialog->repaint(); // Force a redraw
|
||||
qApp->processEvents(); // Try to ensure we are on top of any other dialogs and freshly drawn
|
||||
const QString message =
|
||||
(thing != enums::PackageModuleType::Package) ? tr("Unpacking module:\n\"%1\"\nplease wait...").arg(packageName) : tr("Unpacking package:\n\"%1\"\nplease wait...").arg(packageName);
|
||||
emit signal_showUnpackingProgress(message, tr("Unpacking"));
|
||||
showedUnpackingDialog = true;
|
||||
}
|
||||
|
||||
auto unzipSuccessful = mudlet::unzip(actualFileName, _dest, _tmpDir);
|
||||
|
||||
if (pUnzipDialog) {
|
||||
pUnzipDialog->deleteLater();
|
||||
pUnzipDialog = nullptr;
|
||||
if (showedUnpackingDialog) {
|
||||
emit signal_hideUnpackingProgress();
|
||||
}
|
||||
if (!unzipSuccessful) {
|
||||
return {false, qsl("could not unzip package")};
|
||||
|
|
@ -2111,18 +2241,26 @@ std::pair<bool, QString> Host::installPackage(const QString& fileName, enums::Pa
|
|||
}
|
||||
// continuing, so update the folder name on disk
|
||||
const QString newpath(qsl("%1/%2").arg(_home, packageName));
|
||||
_dir.rename(_dir.absolutePath(), newpath);
|
||||
// A rename onto a folder that is already there fails, and then the
|
||||
// folder this install made is still at its old name while _dir goes
|
||||
// on to the folder that was already here - which is not ours to
|
||||
// delete, whatever the archive would like.
|
||||
if (_dir.rename(_dir.absolutePath(), newpath) && !folderThisInstallMade.isEmpty()) {
|
||||
folderThisInstallMade = QDir(newpath).absolutePath();
|
||||
}
|
||||
_dir = QDir(newpath);
|
||||
}
|
||||
QStringList _filterList;
|
||||
_filterList << qsl("*.xml") << qsl("*.trigger");
|
||||
const QFileInfoList entries = _dir.entryInfoList(_filterList, QDir::Files);
|
||||
bool registeredFromArchive = false;
|
||||
for (auto& entry : entries) {
|
||||
file2.setFileName(entry.absoluteFilePath());
|
||||
if (!file2.open(QFile::ReadOnly | QFile::Text)) {
|
||||
qWarning() << "Host: failed to open file for reading:" << entry.absoluteFilePath() << file2.errorString();
|
||||
continue;
|
||||
}
|
||||
registeredFromArchive = true;
|
||||
XMLimport reader(this);
|
||||
if (thing != enums::PackageModuleType::Package) {
|
||||
QStringList moduleEntry;
|
||||
|
|
@ -2144,6 +2282,31 @@ std::pair<bool, QString> Host::installPackage(const QString& fileName, enums::Pa
|
|||
}
|
||||
file2.close();
|
||||
}
|
||||
|
||||
// Registering the package is this loop's job, so an archive that holds no
|
||||
// package XML that could be read is not installed anywhere: it would be
|
||||
// missing from getPackages()/getModules(), uninstallPackage() would refuse
|
||||
// it, and the folder it was just unpacked into would stay in the profile
|
||||
// for good (#9654). Take that folder away again and say so. Asking the
|
||||
// loop whether it registered anything, rather than asking
|
||||
// mInstalledPackages/mInstalledModules afterwards, is what makes this hold
|
||||
// for a module whose name is already in mInstalledModules on the way in
|
||||
// (profile loading, and installModule() over a stale entry, both do that).
|
||||
if (!registeredFromArchive) {
|
||||
// Only ever remove the folder this install made, and only if it is
|
||||
// inside the profile: the package name can come out empty (a file
|
||||
// called ".mpackage"), name a folder of the user's ("map"), or be
|
||||
// whatever an untrusted archive's config.lua says (".." - the folder
|
||||
// holding every profile), and removeDir() takes everything below what
|
||||
// it is given.
|
||||
const QString profileHome = QDir(mudlet::getMudletPath(enums::profileHomePath, getName())).absolutePath();
|
||||
if (!folderThisInstallMade.isEmpty() && folderThisInstallMade.startsWith(profileHome + QLatin1Char('/'))) {
|
||||
removeDir(folderThisInstallMade, folderThisInstallMade);
|
||||
} else {
|
||||
qWarning() << "Host::installPackage() WARNING - refused" << fileName << "as package" << packageName << "but leaving" << _dir.absolutePath() << "alone: this install did not make it";
|
||||
}
|
||||
return {false, qsl("no package found in %1 - no Mudlet package file in it could be read").arg(fileName)};
|
||||
}
|
||||
} else {
|
||||
file2.setFileName(fileName);
|
||||
if (!file2.open(QFile::ReadOnly | QFile::Text)) {
|
||||
|
|
@ -2188,7 +2351,15 @@ std::pair<bool, QString> Host::installPackage(const QString& fileName, enums::Pa
|
|||
// Defer raising install events until the next event loop iteration
|
||||
// This ensures all package installation is complete (including variable loading)
|
||||
// before event handlers execute, preventing Lua state corruption
|
||||
QTimer::singleShot(0ms, this, [this, thing, packageName, fileName]() {
|
||||
QTimer::singleShot(0ms, this, [this, guard = QPointer<Host>(this), thing, packageName, fileName]() {
|
||||
// The queued call can still be delivered once this Host has been
|
||||
// destroyed - the profile save queued the same way was #9653 - and the
|
||||
// isClosingDown() check below would then be read off freed memory. The
|
||||
// guard lives in the queued call rather than in the Host, so it is safe
|
||||
// to ask and is null by then:
|
||||
if (!guard) {
|
||||
return;
|
||||
}
|
||||
// Don't raise events if Host is shutting down to avoid handlers executing during teardown
|
||||
if (isClosingDown()) {
|
||||
return;
|
||||
|
|
@ -2242,9 +2413,7 @@ std::pair<bool, QString> Host::installPackage(const QString& fileName, enums::Pa
|
|||
// Save profile to ensure modules persist and appear in module manager
|
||||
if (thing != enums::PackageModuleType::Package) {
|
||||
// Use a timer to save profile after module installation completes
|
||||
QTimer::singleShot(100ms, this, [this]() {
|
||||
saveProfile();
|
||||
});
|
||||
mDeferredSaveTimer.start(100ms);
|
||||
}
|
||||
|
||||
return {true, QString()};
|
||||
|
|
@ -2407,18 +2576,9 @@ bool Host::uninstallPackage(const QString& packageName, enums::PackageModuleType
|
|||
const QString dest = mudlet::getMudletPath(enums::profilePackagePath, getName(), packageName);
|
||||
removeDir(dest, dest);
|
||||
|
||||
// ensure only one timer is running in case multiple modules are uninstalled at once
|
||||
if (!mSaveTimer.has_value() || !mSaveTimer.value()) {
|
||||
mSaveTimer = true;
|
||||
// save the profile on the next Qt main loop cycle in order for the asyncronous save mechanism
|
||||
// not to try to write to disk a package/module that just got uninstalled and removed from memory
|
||||
QTimer::singleShot(0ms, this, [this]() {
|
||||
mSaveTimer = false;
|
||||
if (auto [ok, filename, error] = saveProfile(); !ok) {
|
||||
qDebug() << qsl("Host::uninstallPackage: Couldn't save '%1' to '%2' because: %3").arg(getName(), filename, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
// save the profile on the next Qt main loop cycle in order for the asyncronous save mechanism
|
||||
// not to try to write to disk a package/module that just got uninstalled and removed from memory
|
||||
mDeferredSaveTimer.start(0ms);
|
||||
|
||||
//NOW we reset if we're uninstalling a module
|
||||
if (mpEditorDialog && thing == enums::PackageModuleType::ModuleFromScript) {
|
||||
|
|
@ -3430,21 +3590,51 @@ void Host::setBufferSearchOptions(const TConsole::SearchOptions optionsState)
|
|||
mBufferSearchOptions = optionsState;
|
||||
}
|
||||
|
||||
// The single answer to "does this profile have a map widget on screen right
|
||||
// now" - null both for a profile that has never opened one and for one that put
|
||||
// it away again, which a script cannot tell apart and does not need to.
|
||||
//
|
||||
// isHidden() rather than a flag of our own, because the dock gets hidden by
|
||||
// paths that would never think to update one: its own title bar close button,
|
||||
// mudlet::slot_showMapperDialog() handing the map over to a main window dock,
|
||||
// and QMainWindow::restoreState() replaying a saved layout. It is also not
|
||||
// !isVisible(), which would additionally answer "no map widget" whenever the
|
||||
// main window itself is hidden, e.g. minimised to the system tray.
|
||||
QDockWidget* Host::mapWidget() const
|
||||
{
|
||||
if (!mpConsole || !mpConsole->mpDockableMapWidget || mpConsole->mpDockableMapWidget->isHidden()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return mpConsole->mpDockableMapWidget;
|
||||
}
|
||||
|
||||
std::pair<bool, QString> Host::setMapperTitle(const QString& title)
|
||||
{
|
||||
if (!mpDockableMapWidget) {
|
||||
return {false, "no floating/dockable type map window found"};
|
||||
auto pM = mapWidget();
|
||||
if (!pM) {
|
||||
return {false, qsl("no floating/dockable type map window found")};
|
||||
}
|
||||
|
||||
if (title.isEmpty()) {
|
||||
mpDockableMapWidget->setWindowTitle(tr("Map - %1").arg(mHostName));
|
||||
pM->setWindowTitle(tr("Map - %1").arg(mHostName));
|
||||
} else {
|
||||
mpDockableMapWidget->setWindowTitle(title);
|
||||
pM->setWindowTitle(title);
|
||||
}
|
||||
|
||||
return {true, QString()};
|
||||
}
|
||||
|
||||
std::optional<QString> Host::getMapperTitle() const
|
||||
{
|
||||
auto pM = mapWidget();
|
||||
if (!pM) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {pM->windowTitle()};
|
||||
}
|
||||
|
||||
std::pair<int, QString> Host::createMapView(int areaId)
|
||||
{
|
||||
if (!mpMap) {
|
||||
|
|
@ -3587,7 +3777,6 @@ std::pair<bool, QString> Host::openWindow(const QString& name, bool loadLayout,
|
|||
dockwidget = new TDockWidget(this, name);
|
||||
dockwidget->setObjectName(qsl("dockWindow_%1_%2").arg(hostName, name));
|
||||
dockwidget->setContentsMargins(0, 0, 0, 0);
|
||||
dockwidget->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable);
|
||||
dockwidget->setWindowTitle(name);
|
||||
mpConsole->mDockWidgetMap.insert(name, dockwidget);
|
||||
// It wasn't obvious but the parent passed to the TConsole constructor
|
||||
|
|
@ -3597,7 +3786,7 @@ std::pair<bool, QString> Host::openWindow(const QString& name, bool loadLayout,
|
|||
console->setContentsMargins(0, 0, 0, 0);
|
||||
dockwidget->setTConsole(console);
|
||||
console->layerCommandLine->hide();
|
||||
console->mpScrollBar->hide();
|
||||
console->setScrollBarVisible(false);
|
||||
mpConsole->mSubConsoleMap.insert(name, console);
|
||||
dockwidget->setStyleSheet(mProfileStyleSheet);
|
||||
mudlet::self()->addDockWidget(Qt::RightDockWidgetArea, dockwidget);
|
||||
|
|
@ -4054,7 +4243,7 @@ std::pair<bool, QString> Host::setWindow(const QString& windowname, const QStrin
|
|||
if (pDCheck) {
|
||||
return {false, qsl("element '%1' is the base of a floating/dockable user window and may not be moved").arg(name)};
|
||||
}
|
||||
if (mpDockableMapWidget) {
|
||||
if (mpConsole->mpDockableMapWidget) {
|
||||
if (!name.compare(QLatin1String("mapper"), Qt::CaseInsensitive)) {
|
||||
return {false, qsl("element '%1' is the map in a floating/dockable window and may not be moved").arg(name)};
|
||||
}
|
||||
|
|
@ -4151,14 +4340,14 @@ std::pair<bool, QString> Host::setWindow(const QString& windowname, const QStrin
|
|||
std::pair<bool, QString> Host::openMapWidget(const QString& area, int x, int y, int width, int height)
|
||||
{
|
||||
if (!mpConsole) {
|
||||
return {false, QString()};
|
||||
return {false, qsl("no console for this profile - it may be closing")};
|
||||
}
|
||||
|
||||
auto pM = mpDockableMapWidget;
|
||||
auto pM = mpConsole->mpDockableMapWidget;
|
||||
auto pMapper = mpMap.data()->mpMapper;
|
||||
if (!pM && !pMapper) {
|
||||
showHideOrCreateMapper(true);
|
||||
pM = mpDockableMapWidget;
|
||||
pM = mpConsole->mpDockableMapWidget;
|
||||
}
|
||||
if (!pM) {
|
||||
return {false, qsl("cannot create map widget. Do you already use an embedded mapper?")};
|
||||
|
|
@ -4210,20 +4399,35 @@ std::pair<bool, QString> Host::openMapWidget(const QString& area, int x, int y,
|
|||
return {false, qsl(R"("docking option "%1" not available. available docking options are "t" top, "b" bottom, "r" right, "l" left and "f" floating")").arg(area)};
|
||||
}
|
||||
|
||||
// The inverse of moveMapWidget()/resizeMapWidget(), which reach the dock widget
|
||||
// through openMapWidget(). pos()/size() rather than geometry() for the same
|
||||
// reason as Host::windowGeometry(): they are what move()/resize() were given,
|
||||
// while a floating dock's geometry() reports the client area instead.
|
||||
std::optional<QRect> Host::mapWidgetGeometry() const
|
||||
{
|
||||
auto pM = mapWidget();
|
||||
if (!pM) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {QRect(pM->pos(), pM->size())};
|
||||
}
|
||||
|
||||
std::pair<bool, QString> Host::closeMapWidget()
|
||||
{
|
||||
if (!mpConsole) {
|
||||
return {false, QString()};
|
||||
return {false, qsl("no console for this profile - it may be closing")};
|
||||
}
|
||||
|
||||
auto pM = mpDockableMapWidget;
|
||||
if (!pM) {
|
||||
// Test the raw pointer first so that a profile which never made a map widget
|
||||
// is told apart from one that has put its widget away.
|
||||
if (!mpConsole->mpDockableMapWidget) {
|
||||
return {false, qsl("no map widget found to close")};
|
||||
}
|
||||
if (!pM->isVisible()) {
|
||||
if (!mapWidget()) {
|
||||
return {false, qsl("map widget already closed")};
|
||||
}
|
||||
pM->hide();
|
||||
mpConsole->mpDockableMapWidget->hide();
|
||||
return {true, QString()};
|
||||
}
|
||||
|
||||
|
|
@ -4475,8 +4679,8 @@ bool Host::setProfileStyleSheet(const QString& styleSheet)
|
|||
mpNotePad->setStyleSheet(styleSheet);
|
||||
mpNotePad->setTabsStyleSheet(styleSheet);
|
||||
}
|
||||
if (mpDockableMapWidget) {
|
||||
mpDockableMapWidget->setStyleSheet(styleSheet);
|
||||
if (mpConsole->mpDockableMapWidget) {
|
||||
mpConsole->mpDockableMapWidget->setStyleSheet(styleSheet);
|
||||
}
|
||||
|
||||
for (auto& dockWidget : mpConsole->mDockWidgetMap) {
|
||||
|
|
@ -4660,7 +4864,7 @@ void Host::toggleMapperVisibility()
|
|||
if (pMap->mpMapper->isFloatAndDockable()) {
|
||||
// If we are using a floating/dockable widget we must show/hide that
|
||||
// only and not the mapper widget (otherwise it messes up {shrinks
|
||||
// to a minimal size} the mapper inside the container QDockWidget). This
|
||||
// to a minimal size} the mapper inside the container dock widget). This
|
||||
// is the same as the case for a TConsole inside a TDockWidget in
|
||||
// (void) TDockWidget::setVisible(bool).
|
||||
// When in a dock widget, check the parent's visibility, not the child's,
|
||||
|
|
@ -4681,17 +4885,21 @@ void Host::toggleMapperVisibility()
|
|||
|
||||
void Host::createMapper(const bool loadDefaultMap)
|
||||
{
|
||||
// The console owns the map dock; bail if the profile has no console yet or is
|
||||
// already being torn down.
|
||||
if (!mpConsole) {
|
||||
return;
|
||||
}
|
||||
auto pMap = mpMap.data();
|
||||
auto hostName(getName());
|
||||
mpDockableMapWidget = new QDockWidget(tr("Map - %1").arg(hostName));
|
||||
mpDockableMapWidget->setObjectName(qsl("dockMap_%1").arg(hostName));
|
||||
mpConsole->createMapperDock(tr("Map - %1").arg(hostName), qsl("dockMap_%1").arg(hostName));
|
||||
// Arrange for TMap member values to be copied from the Host masters so they
|
||||
// are in place when the 2D mapper is created:
|
||||
getPlayerRoomStyleDetails(pMap->mPlayerRoomStyle, pMap->mPlayerRoomOuterDiameterPercentage, pMap->mPlayerRoomInnerDiameterPercentage, pMap->mPlayerRoomOuterColor, pMap->mPlayerRoomInnerColor);
|
||||
|
||||
pMap->mpMapper = new dlgMapper(mpDockableMapWidget, this, pMap); //FIXME: mpHost definieren
|
||||
pMap->mpMapper = new dlgMapper(mpConsole->mpDockableMapWidget, this, pMap); //FIXME: mpHost definieren
|
||||
pMap->mpMapper->setStyleSheet(mProfileStyleSheet);
|
||||
mpDockableMapWidget->setWidget(pMap->mpMapper);
|
||||
mpConsole->mpDockableMapWidget->setWidget(pMap->mpMapper);
|
||||
|
||||
if (loadDefaultMap && pMap->mpRoomDB->isEmpty()) {
|
||||
qDebug() << "Host::create_mapper() - restore map case 3.";
|
||||
|
|
@ -4716,7 +4924,7 @@ void Host::createMapper(const bool loadDefaultMap)
|
|||
pMap->mpMapper->show();
|
||||
}
|
||||
}
|
||||
mudlet::self()->addDockWidget(Qt::RightDockWidgetArea, mpDockableMapWidget);
|
||||
mudlet::self()->addDockWidget(Qt::RightDockWidgetArea, mpConsole->mpDockableMapWidget);
|
||||
|
||||
// XXX: should this be called multiple times?
|
||||
mudlet::self()->loadWindowLayout();
|
||||
|
|
@ -4725,7 +4933,7 @@ void Host::createMapper(const bool loadDefaultMap)
|
|||
// restored a previous hidden state, but when first creating the mapper, we
|
||||
// always want it to be visible.
|
||||
pMap->mpMapper->show();
|
||||
mpDockableMapWidget->show();
|
||||
mpConsole->mpDockableMapWidget->show();
|
||||
pMap->mpMapper->updateEmptyStateOverlay();
|
||||
|
||||
check_for_mappingscript();
|
||||
|
|
@ -4849,6 +5057,10 @@ std::optional<QString> Host::windowType(const QString& name) const
|
|||
}
|
||||
}
|
||||
|
||||
if (mpConsole->mScrollBoxMap.contains(name)) {
|
||||
return {qsl("scrollbox")};
|
||||
}
|
||||
|
||||
if (mpConsole->mSubCommandLineMap.contains(name)) {
|
||||
return {qsl("commandline")};
|
||||
}
|
||||
|
|
@ -4860,6 +5072,83 @@ std::optional<QString> Host::windowType(const QString& name) const
|
|||
return {};
|
||||
}
|
||||
|
||||
// Returns the position and size of a window element, matching what
|
||||
// moveWindow()/resizeWindow() set and mirroring their widget dispatch, so user
|
||||
// windows are read from their dock widget rather than their console.
|
||||
// pos()/size() rather than geometry(): for a floating dock move() targets the
|
||||
// frame origin while geometry() would report the client area.
|
||||
std::optional<QRect> Host::windowGeometry(const QString& name) const
|
||||
{
|
||||
if (!mpConsole) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (name.isEmpty() || name == QLatin1String("main")) {
|
||||
// 0,0 rather than the console's pos(), which under multi-view is an
|
||||
// offset within the split; the size is getMainWindowSize()'s so the two
|
||||
// functions cannot disagree.
|
||||
return {QRect(QPoint(0, 0), mpConsole->getMainWindowSize())};
|
||||
}
|
||||
if (auto pL = mpConsole->mLabelMap.value(name)) {
|
||||
return {QRect(pL->pos(), pL->size())};
|
||||
}
|
||||
if (auto pC = mpConsole->mSubConsoleMap.value(name)) {
|
||||
if (auto pD = mpConsole->mDockWidgetMap.value(name)) {
|
||||
return {QRect(pD->pos(), pD->size())};
|
||||
}
|
||||
return {QRect(pC->pos(), pC->size())};
|
||||
}
|
||||
if (auto pS = mpConsole->mScrollBoxMap.value(name)) {
|
||||
return {QRect(pS->pos(), pS->size())};
|
||||
}
|
||||
if (auto pN = mpConsole->mSubCommandLineMap.value(name)) {
|
||||
return {QRect(pN->pos(), pN->size())};
|
||||
}
|
||||
if (auto pT = mpConsole->mTextBoxMap.value(name)) {
|
||||
return {QRect(pT->pos(), pT->size())};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
// Returns whether a window element is currently visible, mirroring the widget
|
||||
// dispatch of hideWindow()/showWindow() - user windows report their dock's
|
||||
// visibility, which is what those toggle. Answered relative to the profile's
|
||||
// own console: a child of a hidden user window still reads hidden, but a
|
||||
// profile that is merely not the front tab does not.
|
||||
std::optional<bool> Host::windowVisible(const QString& name) const
|
||||
{
|
||||
if (!mpConsole) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (name.isEmpty() || name == QLatin1String("main")) {
|
||||
// only the tab machinery hides the main console, and that is the hiding
|
||||
// this function looks past
|
||||
return {true};
|
||||
}
|
||||
if (auto pL = mpConsole->mLabelMap.value(name)) {
|
||||
return {pL->isVisibleTo(mpConsole)};
|
||||
}
|
||||
if (auto pC = mpConsole->mSubConsoleMap.value(name)) {
|
||||
if (auto pD = mpConsole->mDockWidgetMap.value(name)) {
|
||||
return {pD->isVisibleTo(mpConsole)};
|
||||
}
|
||||
return {pC->isVisibleTo(mpConsole)};
|
||||
}
|
||||
if (auto pS = mpConsole->mScrollBoxMap.value(name)) {
|
||||
return {pS->isVisibleTo(mpConsole)};
|
||||
}
|
||||
if (auto pN = mpConsole->mSubCommandLineMap.value(name)) {
|
||||
return {pN->isVisibleTo(mpConsole)};
|
||||
}
|
||||
if (auto pT = mpConsole->mTextBoxMap.value(name)) {
|
||||
return {pT->isVisibleTo(mpConsole)};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
void Host::setLargeAreaExitArrows(const bool state)
|
||||
{
|
||||
if (mLargeAreaExitArrows != state) {
|
||||
|
|
@ -5030,7 +5319,7 @@ void Host::setBorders(QMargins borders)
|
|||
auto y = mpConsole->height();
|
||||
const QSize s = QSize(x, y);
|
||||
QResizeEvent event(s, s);
|
||||
QApplication::sendEvent(mpConsole, &event);
|
||||
QCoreApplication::sendEvent(mpConsole, &event);
|
||||
mpConsole->raiseMudletSysWindowResizeEvent(x, y);
|
||||
}
|
||||
|
||||
|
|
|
|||
78
src/Host.h
78
src/Host.h
|
|
@ -47,6 +47,7 @@
|
|||
#include <QList>
|
||||
#include <QMargins>
|
||||
#include <QPointer>
|
||||
#include <QRect>
|
||||
#include <QStack>
|
||||
#include <QTextStream>
|
||||
|
||||
|
|
@ -56,12 +57,13 @@
|
|||
#include "TMxpProcessor.h"
|
||||
#include "TMxpFrameManager.h"
|
||||
|
||||
class QDialog;
|
||||
namespace pugi {
|
||||
class xml_document;
|
||||
}
|
||||
|
||||
class QDockWidget;
|
||||
class QJsonObject;
|
||||
class QKeyEvent;
|
||||
class QPushButton;
|
||||
class QListWidget;
|
||||
|
||||
class TEvent;
|
||||
class TArea;
|
||||
|
|
@ -195,9 +197,11 @@ public:
|
|||
void setPass(const QString& password) { mPass = password; }
|
||||
bool hasAutoLoginCredentials() const { return !mLogin.isEmpty() && !mPass.isEmpty(); }
|
||||
// True once the user has sent any command to the game on the current connection. It gates whether
|
||||
// an unsolicited GMCP Char.Login.URL may auto-open the browser: a URL that arrives only after the
|
||||
// an unsolicited GMCP sign-in address may auto-open the browser: one that arrives only after the
|
||||
// player acted (e.g. chose a provider on the game's own sign-in screen) is a consequence of their
|
||||
// input, whereas one at an untouched connection is not and must not silently launch a browser.
|
||||
// GMCPAuthenticator clears it again when it auto-opens, so one player action can launch at most
|
||||
// one browser hand-off however many sign-in addresses the game pushes.
|
||||
bool userSentInputThisConnection() const { return mUserSentInputThisConnection; }
|
||||
void setUserSentInputThisConnection(const bool b) { mUserSentInputThisConnection = b; }
|
||||
int getRetries() { return mRetries; }
|
||||
|
|
@ -243,6 +247,7 @@ public:
|
|||
QStringList getValidExperiments() const;
|
||||
|
||||
void forceClose();
|
||||
bool profileResetInProgress() const { return mResetProfile; }
|
||||
bool isClosingDown() const { return mIsClosingDown; }
|
||||
bool isClosingForced() const { return mForcedClose; }
|
||||
bool requestClose();
|
||||
|
|
@ -353,6 +358,9 @@ public:
|
|||
QString readProfileIniData(const QString& item);
|
||||
void xmlSaved(const QString& xmlName);
|
||||
bool currentlySavingProfile();
|
||||
// Whether a package install or uninstall still owes the profile a save - see
|
||||
// mDeferredSaveTimer.
|
||||
bool hasPendingProfileSave() const { return mDeferredSaveTimer.isActive(); }
|
||||
void processDiscordGMCP(const QString& packageMessage, const QString& data);
|
||||
void waitForProfileSave();
|
||||
void clearDiscordData();
|
||||
|
|
@ -397,6 +405,8 @@ public:
|
|||
void setSearchOptions(const dlgTriggerEditor::SearchOptions);
|
||||
void setBufferSearchOptions(const TConsole::SearchOptions);
|
||||
std::pair<bool, QString> setMapperTitle(const QString&);
|
||||
std::optional<QString> getMapperTitle() const;
|
||||
QDockWidget* mapWidget() const;
|
||||
|
||||
// Multiple map views support
|
||||
std::pair<int, QString> createMapView(int areaId = 0);
|
||||
|
|
@ -431,6 +441,7 @@ public:
|
|||
std::pair<bool, QString> setWindow(const QString& windowname, const QString& name, int x1, int y1, bool show);
|
||||
std::pair<bool, QString> openMapWidget(const QString& area, int x, int y, int width, int height);
|
||||
std::pair<bool, QString> closeMapWidget();
|
||||
std::optional<QRect> mapWidgetGeometry() const;
|
||||
bool closeWindow(const QString&);
|
||||
bool echoWindow(const QString&, const QString&);
|
||||
bool pasteWindow(const QString& name);
|
||||
|
|
@ -465,6 +476,8 @@ public:
|
|||
mScreenHeight = height;
|
||||
}
|
||||
std::optional<QString> windowType(const QString& name) const;
|
||||
std::optional<QRect> windowGeometry(const QString& name) const;
|
||||
std::optional<bool> windowVisible(const QString& name) const;
|
||||
bool getEditorShowBidi() const { return mEditorShowBidi; }
|
||||
void setEditorShowBidi(const bool);
|
||||
bool caretEnabled() const;
|
||||
|
|
@ -814,7 +827,6 @@ public:
|
|||
bool mMapperCenterSmallAreas = false;
|
||||
bool mVersionInTTYPE = false;
|
||||
QSet<QChar> mDoubleClickIgnore;
|
||||
QPointer<QDockWidget> mpDockableMapWidget;
|
||||
bool mEnableTextAnalyzer = false;
|
||||
bool mWritingHostAndModules = false;
|
||||
// Set from profile preferences, if the timer interval is less
|
||||
|
|
@ -845,6 +857,12 @@ public:
|
|||
bool mAdvertiseScreenReader = false;
|
||||
bool mEnableClosedCaption = false;
|
||||
|
||||
// Turning this off both ignores incoming OSC 8 sequences and advertises 0
|
||||
// for them, so a server can fall back to MXP or plain text rather than
|
||||
// sending links Mudlet will not render. It is checked as sequences are
|
||||
// decoded, so links already drawn in the buffer stay clickable.
|
||||
bool mEnableOSC8Hyperlinks = true;
|
||||
|
||||
enum class BlankLineBehaviour { Show, Hide, ReplaceWithSpace };
|
||||
Q_ENUM(BlankLineBehaviour)
|
||||
BlankLineBehaviour mBlankLineBehaviour = BlankLineBehaviour::Show;
|
||||
|
|
@ -879,9 +897,15 @@ signals:
|
|||
void signal_editorThemeChanged();
|
||||
void signal_remoteEchoChanged(bool enabled);
|
||||
void signal_forceMXPProcessorOnChanged(bool enabled);
|
||||
// The frontend (TMainConsole) owns the dialogs these drive; the strings are
|
||||
// built here so they stay in Host's translation context.
|
||||
void signal_showMapperScriptReminder();
|
||||
void signal_showUnpackingProgress(const QString& message, const QString& title);
|
||||
void signal_hideUnpackingProgress();
|
||||
|
||||
private slots:
|
||||
void slot_purgeTemps();
|
||||
void slot_saveProfileAfterPackageChange();
|
||||
|
||||
private:
|
||||
void setBorders(const QMargins);
|
||||
|
|
@ -896,10 +920,37 @@ private:
|
|||
void createMapper(const bool);
|
||||
void removePackageInfo(const QString& packageName, const bool);
|
||||
static void createModuleBackup(const QString& filename, const QString& saveName);
|
||||
void writeModule(const QString& moduleName, const QString& filename);
|
||||
// A single module queued to be written out during a profile save. Its XML document
|
||||
// is built on the main thread (XMLexport::writeModuleXML()); serializing it to disk
|
||||
// is deferred to a background task. The job is a complete, self-contained order:
|
||||
// its own copy of the document plus every path the write needs, so it holds nothing
|
||||
// whose lifetime the Host controls. It has to: a close that answers "No" to "Save
|
||||
// profile?", and one that finds the main console already gone, wait for nothing, so
|
||||
// the Host can be destroyed while the write is still queued.
|
||||
struct ModuleWriteJob
|
||||
{
|
||||
std::shared_ptr<pugi::xml_document> document;
|
||||
QString moduleName;
|
||||
QString filename;
|
||||
QString xmlFilename;
|
||||
// Empty when this save is not to be backed up first.
|
||||
QString backupName;
|
||||
};
|
||||
// Main thread only: builds every to-be-synced module's XML document, registers its
|
||||
// writer in `writers`, resolves every path and creates the directories the write
|
||||
// needs, returning the jobs a background task should serialize.
|
||||
QList<ModuleWriteJob> prepareModuleSaves(bool backup);
|
||||
// Writes the prepared module documents (and updates their zips) to disk. Static on
|
||||
// purpose: it must keep working after the Host that ordered it has been destroyed,
|
||||
// so it may not reach for any member. Usually a thread pool task, but a waiter in
|
||||
// waitForProfileSave() can steal it onto the main thread, so it must suit either.
|
||||
static void writeModuleFiles(const QList<ModuleWriteJob>& jobs);
|
||||
static void updateModuleZip(const ModuleWriteJob& job);
|
||||
// Main thread only: snapshot of the still-pending profile-save futures, so a
|
||||
// background task never reads `writers`/`saveFutures` while the main thread mutates
|
||||
// them (that concurrent access is a heap-corrupting data race).
|
||||
QList<QFuture<bool>> pendingXmlSaveFutures() const;
|
||||
void waitForAsyncXmlSave();
|
||||
void saveModules(bool backup = true);
|
||||
void updateModuleZips(const QString& zipName, const QString& moduleName);
|
||||
void reloadModules();
|
||||
void startMapAutosave(const int interval);
|
||||
void timerEvent(QTimerEvent* event) override;
|
||||
|
|
@ -925,8 +976,15 @@ private:
|
|||
ActionUnit mActionUnit;
|
||||
KeyUnit mKeyUnit;
|
||||
GifTracker mGifTracker;
|
||||
// ensures that only one saveProfile call is active when multiple modules are being uninstalled in one go
|
||||
std::optional<bool> mSaveTimer;
|
||||
// The profile save that a package/module install or uninstall owes is put off
|
||||
// to the next event loop pass, so that the asynchronous save mechanism is not
|
||||
// asked to write out something that was just taken out of memory. Restarting
|
||||
// this timer also folds a batch of installs/uninstalls into a single save.
|
||||
// It has to be a member timer rather than a QTimer::singleShot(): a call
|
||||
// queued on the Host is still delivered after the Host has been destroyed,
|
||||
// and the save then reads freed members - closeChildren() and ~Host() stop
|
||||
// this one instead (#9653).
|
||||
QTimer mDeferredSaveTimer;
|
||||
|
||||
QFile mErrorLogFile;
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@
|
|||
#include "Host.h"
|
||||
#include "TKey.h"
|
||||
|
||||
#include <QScopeGuard>
|
||||
|
||||
#include <functional>
|
||||
|
||||
KeyUnit::KeyUnit(Host* pHost)
|
||||
|
|
@ -98,6 +100,9 @@ void KeyUnit::uninstall(const QString& packageName)
|
|||
return;
|
||||
}
|
||||
for (auto& key : uninstallList) {
|
||||
// in case the key was also queued for the markCleanup()/doCleanup()
|
||||
// path - deleting it here would otherwise leave a dangling pointer there:
|
||||
mCleanupSet.remove(key);
|
||||
delete key;
|
||||
}
|
||||
uninstallList.clear();
|
||||
|
|
@ -108,6 +113,13 @@ bool KeyUnit::processDataStream(const Qt::Key key, const Qt::KeyboardModifiers m
|
|||
bool isMatchFound = false;
|
||||
|
||||
mProcessingDepth++;
|
||||
const auto processingGuard = qScopeGuard([this] {
|
||||
mProcessingDepth--;
|
||||
Q_ASSERT(mProcessingDepth >= 0);
|
||||
if (mProcessingDepth == 0) {
|
||||
doCleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (auto keyObject : mKeyRootNodeList) {
|
||||
// Skip null or invalid key objects during profile closing/destruction
|
||||
|
|
@ -117,24 +129,28 @@ bool KeyUnit::processDataStream(const Qt::Key key, const Qt::KeyboardModifiers m
|
|||
|
||||
if (keyObject->match(key, modifiers, mRunAllKeyMatches)) {
|
||||
if (!mRunAllKeyMatches) {
|
||||
mProcessingDepth--;
|
||||
Q_ASSERT(mProcessingDepth >= 0);
|
||||
if (mProcessingDepth == 0) {
|
||||
doCleanup();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
isMatchFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
mProcessingDepth--;
|
||||
Q_ASSERT(mProcessingDepth >= 0);
|
||||
if (mProcessingDepth == 0) {
|
||||
doCleanup();
|
||||
return isMatchFound;
|
||||
}
|
||||
|
||||
bool KeyUnit::wouldMatch(const Qt::Key key, const Qt::KeyboardModifiers modifiers) const
|
||||
{
|
||||
for (auto keyObject : mKeyRootNodeList) {
|
||||
if (!keyObject || !keyObject->isActive() || (keyObject->mpHost && keyObject->mpHost->isClosingDown())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (keyObject->wouldMatch(key, modifiers)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return isMatchFound;
|
||||
return false;
|
||||
}
|
||||
|
||||
void KeyUnit::compileAll()
|
||||
|
|
@ -228,15 +244,27 @@ bool KeyUnit::disableKey(const QString& name)
|
|||
bool KeyUnit::killKey(QString& name)
|
||||
{
|
||||
for (auto pChild : mKeyRootNodeList) {
|
||||
if (pChild->getName() == name) {
|
||||
// only temporary Keys can be killed
|
||||
if (!pChild->isTemporary()) {
|
||||
return false;
|
||||
}
|
||||
pChild->setIsActive(false);
|
||||
markCleanup(pChild);
|
||||
return true;
|
||||
if (pChild->getName() != name) {
|
||||
continue;
|
||||
}
|
||||
// Names are not unique, so keep looking rather than give up on the first
|
||||
// same-named key that cannot be killed - a permanent key loaded from the
|
||||
// profile precedes this session's temporaries in this list, and reporting
|
||||
// a failure over it would strand a killable key
|
||||
if (!pChild->isTemporary()) {
|
||||
// only temporary Keys can be killed
|
||||
continue;
|
||||
}
|
||||
// An already killed key is only unlinked from this list once doCleanup()
|
||||
// gets to free it, which cannot happen while a key script is on the call
|
||||
// stack - so until then it is still findable by name. Killing it a second
|
||||
// time achieves nothing:
|
||||
if (mCleanupSet.contains(pChild)) {
|
||||
continue;
|
||||
}
|
||||
pChild->setIsActive(false);
|
||||
markCleanup(pChild);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -316,11 +344,12 @@ void KeyUnit::removeKeyRootNode(TKey* pT)
|
|||
if (!pT) {
|
||||
return;
|
||||
}
|
||||
if (!pT->isTemporary()) {
|
||||
mLookupTable.remove(pT->getName(), pT);
|
||||
} else {
|
||||
mLookupTable.remove(pT->getName());
|
||||
}
|
||||
// Names are not unique - the lookup table is a QMultiMap - so drop this one
|
||||
// key's entry rather than every entry filed under the name. The
|
||||
// single-argument remove() used to be taken for temporary keys, which evicted
|
||||
// live same-named keys and left them unreachable by name for the rest of the
|
||||
// session
|
||||
mLookupTable.remove(pT->getName(), pT);
|
||||
mKeyMap.remove(pT->getID());
|
||||
mKeyRootNodeList.remove(pT);
|
||||
}
|
||||
|
|
@ -382,11 +411,8 @@ void KeyUnit::removeKey(TKey* pT)
|
|||
if (!pT) {
|
||||
return;
|
||||
}
|
||||
if (!pT->isTemporary()) {
|
||||
mLookupTable.remove(pT->getName(), pT);
|
||||
} else {
|
||||
mLookupTable.remove(pT->getName());
|
||||
}
|
||||
// see removeKeyRootNode(): one entry, not every same-named one
|
||||
mLookupTable.remove(pT->getName(), pT);
|
||||
mKeyMap.remove(pT->getID());
|
||||
}
|
||||
|
||||
|
|
@ -469,17 +495,22 @@ void KeyUnit::doCleanup()
|
|||
return;
|
||||
}
|
||||
|
||||
QSet<TKey*> deletedKeys;
|
||||
QMutableSetIterator<TKey*> itKey(mCleanupSet);
|
||||
while (itKey.hasNext()) {
|
||||
auto pKey = itKey.next();
|
||||
itKey.remove();
|
||||
deletedKeys.insert(pKey);
|
||||
delete pKey;
|
||||
}
|
||||
// Flush the deletes uninstall() deferred (#9337). uninstallList is ordered
|
||||
// children-before-parents and each ~Tree unlinks from its parent, so deleting
|
||||
// children first empties the parent's child list (no double free); the seen
|
||||
// set guards a node queued twice by re-entrant uninstalls.
|
||||
QSet<TKey*> deletedKeys;
|
||||
// set guards a node queued twice by re-entrant uninstalls and is shared with
|
||||
// the mCleanupSet loop above so an object that ended up in both containers is
|
||||
// freed once. It matches on pointer identity only: a node freed indirectly, as
|
||||
// a child of a queued parent, is not in the set (not reachable today - only
|
||||
// temporary root nodes are ever queued, and those have no children).
|
||||
for (auto key : uninstallList) {
|
||||
if (!deletedKeys.contains(key)) {
|
||||
deletedKeys.insert(key);
|
||||
|
|
|
|||
|
|
@ -69,8 +69,11 @@ public:
|
|||
void uninstall(const QString&);
|
||||
void _uninstall(TKey* pChild, const QString& packageName);
|
||||
bool processDataStream(const Qt::Key, const Qt::KeyboardModifiers);
|
||||
// Query-only counterpart to processDataStream(), which executes what it matches
|
||||
bool wouldMatch(const Qt::Key, const Qt::KeyboardModifiers) const;
|
||||
void markCleanup(TKey* pT);
|
||||
void doCleanup();
|
||||
int processingDepth() const { return mProcessingDepth; }
|
||||
void stopAllTriggers();
|
||||
void reenableAllTriggers();
|
||||
|
||||
|
|
|
|||
40
src/LsanHooks.cpp
Normal file
40
src/LsanHooks.cpp
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/***************************************************************************
|
||||
* Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org *
|
||||
* *
|
||||
* This program is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation; either version 2 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* This program is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program; if not, write to the *
|
||||
* Free Software Foundation, Inc., *
|
||||
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
||||
***************************************************************************/
|
||||
|
||||
#include "LsanSuppressions.h"
|
||||
|
||||
// Inert unless the LeakSanitizer runtime is linked in. They cannot be guarded
|
||||
// with an "is ASAN on" macro check: Mudlet only applies -fsanitize=address at
|
||||
// link time, so no compiler macro is set, and Qt's qcompilerdetection.h shims
|
||||
// __has_feature to 0 on GCC anyway. Built as their own OBJECT library, see
|
||||
// src/CMakeLists.txt.
|
||||
|
||||
// Keeps third-party noise (GPU drivers, font stack) out of the leak reports
|
||||
// that testing/PTB builds show users, with no LSAN_OPTIONS set at runtime
|
||||
extern "C" const char* __lsan_default_suppressions()
|
||||
{
|
||||
return mudletLsanSuppressions;
|
||||
}
|
||||
|
||||
// Without this, LeakSanitizer appends a "Suppressions used" summary to every
|
||||
// clean exit, which reads like an error to users:
|
||||
extern "C" const char* __lsan_default_options()
|
||||
{
|
||||
return "print_suppressions=0";
|
||||
}
|
||||
|
|
@ -49,6 +49,8 @@ LuaInterface::LuaInterface(lua_State* L)
|
|||
lua_atpanic(L, &onPanic);
|
||||
}
|
||||
|
||||
// Does not release lrefs: a profile reset closes the lua_State before this
|
||||
// object is replaced, so unref'ing here would write into a freed state.
|
||||
LuaInterface::~LuaInterface() = default;
|
||||
|
||||
int LuaInterface::onPanic(lua_State* L)
|
||||
|
|
@ -68,6 +70,19 @@ VarUnit* LuaInterface::getVarUnit()
|
|||
return varUnit.data();
|
||||
}
|
||||
|
||||
lua_State* LuaInterface::getState() const
|
||||
{
|
||||
return mL;
|
||||
}
|
||||
|
||||
void LuaInterface::releaseVariableReferences()
|
||||
{
|
||||
for (const int ref : std::as_const(lrefs)) {
|
||||
luaL_unref(mL, LUA_REGISTRYINDEX, ref);
|
||||
}
|
||||
lrefs.clear();
|
||||
}
|
||||
|
||||
QStringList LuaInterface::varName(TVar* var)
|
||||
{
|
||||
QStringList names;
|
||||
|
|
@ -825,17 +840,19 @@ void LuaInterface::getVars(bool hide)
|
|||
//returns the base item
|
||||
// QElapsedTimer t;
|
||||
// t.start();
|
||||
// onPanic() longjmp()s to the shared buf, so without a setjmp of our own
|
||||
// that jump lands in whichever frame set it last - usually one that has
|
||||
// already returned, taking the caller's scope down with it.
|
||||
if (setjmp(buf) != 0) {
|
||||
qWarning() << "LuaInterface::getVars() WARNING - Lua panicked while reading the variables in; the variable tree is incomplete.";
|
||||
return;
|
||||
}
|
||||
lua_pushnil(mL);
|
||||
depth = 0;
|
||||
auto global = new TVar();
|
||||
global->setName("_G", LUA_TSTRING);
|
||||
global->setValue("{}", LUA_TTABLE);
|
||||
QListIterator<int> it(lrefs);
|
||||
while (it.hasNext()) {
|
||||
const int ref = it.next();
|
||||
luaL_unref(mL, LUA_REGISTRYINDEX, ref);
|
||||
}
|
||||
lrefs.clear();
|
||||
releaseVariableReferences();
|
||||
varUnit->clear();
|
||||
varUnit->setBase(global);
|
||||
varUnit->addVariable(global);
|
||||
|
|
|
|||
|
|
@ -65,12 +65,15 @@ public:
|
|||
void renameVar(TVar*);
|
||||
void createVar(TVar*);
|
||||
VarUnit* getVarUnit();
|
||||
// Anything that builds a variable tree and throws it away owes this call:
|
||||
// ~LuaInterface cannot make it, see there.
|
||||
void releaseVariableReferences();
|
||||
bool loadVar(TVar* var);
|
||||
bool reparentCVariable(TVar* from, TVar* to, TVar* curVar);
|
||||
bool reparentVariable(QTreeWidgetItem*, QTreeWidgetItem*, QTreeWidgetItem*);
|
||||
std::pair<bool, QString> validMove(QTreeWidgetItem*);
|
||||
void getAllChildren(TVar* var, QList<TVar*>* list);
|
||||
lua_State* getState();
|
||||
lua_State* getState() const;
|
||||
static int onPanic(lua_State*);
|
||||
|
||||
private:
|
||||
|
|
|
|||
43
src/LuaLiteral.cpp
Normal file
43
src/LuaLiteral.cpp
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/***************************************************************************
|
||||
* Copyright (C) 2026 by Mike Conley - mike.conley@stickmud.com *
|
||||
* *
|
||||
* This program is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation; either version 2 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* This program is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program; if not, write to the *
|
||||
* Free Software Foundation, Inc., *
|
||||
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
||||
***************************************************************************/
|
||||
|
||||
#include "LuaLiteral.h"
|
||||
|
||||
#include "utils.h"
|
||||
|
||||
QString LuaLiteral::quote(const QString& text)
|
||||
{
|
||||
// Escalate the bracket level until the text can do none of three things:
|
||||
// close the literal outright; reopen it, which Lua 5.1 rejects under its
|
||||
// deprecated-nesting rule; or merge with the closing bracket appended after
|
||||
// it. That last one is why endsWith is here - text ending in ']' followed
|
||||
// by this level's '=' run is completed into a closing bracket by the first
|
||||
// character of the closer, shutting the literal one character early.
|
||||
// Terminates because none of the three patterns fits in text shorter than
|
||||
// the '=' run it requires.
|
||||
QString equals;
|
||||
while (text.contains(qsl("]%1]").arg(equals)) || text.contains(qsl("[%1[").arg(equals)) || text.endsWith(qsl("]%1").arg(equals))) {
|
||||
equals += QLatin1Char('=');
|
||||
}
|
||||
|
||||
// Lua discards a newline immediately after the opening bracket, so the
|
||||
// added one costs nothing and lets text that itself starts with a newline
|
||||
// survive the round trip.
|
||||
return qsl("[%1[\n%2]%1]").arg(equals, text);
|
||||
}
|
||||
34
src/LuaLiteral.h
Normal file
34
src/LuaLiteral.h
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#ifndef MUDLET_LUALITERAL_H
|
||||
#define MUDLET_LUALITERAL_H
|
||||
|
||||
/***************************************************************************
|
||||
* Copyright (C) 2026 by Mike Conley - mike.conley@stickmud.com *
|
||||
* *
|
||||
* This program is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation; either version 2 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* This program is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program; if not, write to the *
|
||||
* Free Software Foundation, Inc., *
|
||||
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
||||
***************************************************************************/
|
||||
|
||||
#include <QString>
|
||||
|
||||
class LuaLiteral
|
||||
{
|
||||
public:
|
||||
// Quotes arbitrary, possibly hostile text as a Lua long-bracket string
|
||||
// literal. Callers embedding remote input in generated Lua source must use
|
||||
// this rather than formatting into "[[%1]]" themselves.
|
||||
static QString quote(const QString& text);
|
||||
};
|
||||
|
||||
#endif // MUDLET_LUALITERAL_H
|
||||
|
|
@ -23,7 +23,6 @@
|
|||
#include "utils.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QDesktopServices>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkReply>
|
||||
|
|
@ -192,14 +191,7 @@ void OAuthClientFlow::handleDiscoveryReply(QNetworkReply* reply)
|
|||
|
||||
mCodeVerifier = generateCodeVerifier();
|
||||
mState = randomUrlSafeToken(16);
|
||||
const QUrl authorizationUrl = buildAuthorizationUrl(authorizationEndpoint, mClientId, mScopes, mRedirectUri, mState, codeChallengeS256(mCodeVerifier), mNonce);
|
||||
|
||||
if (QDesktopServices::openUrl(authorizationUrl)) {
|
||||
emit browserOpened(authorizationUrl.toString());
|
||||
} else {
|
||||
// Keep the listener up: the user can still open the link by hand and complete the sign-in.
|
||||
emit browserOpenFailed(authorizationUrl.toString());
|
||||
}
|
||||
emit authorizationUrlReady(buildAuthorizationUrl(authorizationEndpoint, mClientId, mScopes, mRedirectUri, mState, codeChallengeS256(mCodeVerifier), mNonce));
|
||||
}
|
||||
|
||||
void OAuthClientFlow::handleRedirectConnection()
|
||||
|
|
@ -278,11 +270,15 @@ void OAuthClientFlow::readRedirectRequest(QTcpSocket* socket)
|
|||
respond(socket, "200 OK", tr("You are signed in. You can close this tab and return to Mudlet."));
|
||||
|
||||
mCompleted = true;
|
||||
// Move rather than copy: cleanup() scrubs mNonce next, and clearing a shared QString detaches,
|
||||
// zeroing a fresh copy while the real value stays in the buffer this one still points at.
|
||||
QString nonce = std::move(mNonce);
|
||||
cleanup();
|
||||
emit authorizationCaptured(code, mCodeVerifier, mRedirectUri);
|
||||
emit authorizationCaptured(code, mCodeVerifier, mRedirectUri, nonce);
|
||||
// The authorization code and verifier are single-use secrets; the receiver has taken its own copies
|
||||
// (and is responsible for scrubbing them), so drop ours now.
|
||||
SecureStringUtils::secureStringClear(code);
|
||||
SecureStringUtils::secureStringClear(nonce);
|
||||
SecureStringUtils::secureStringClear(mCodeVerifier);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,10 +32,12 @@ class QNetworkReply;
|
|||
class QTcpSocket;
|
||||
|
||||
// Runs the client-driven GMCP Char.Login v2 OAuth flow: fetches the server's OpenID Connect
|
||||
// discovery document, opens the provider's authorization URL in the system browser with a PKCE
|
||||
// (S256) challenge, and captures the authorization code on a loopback (RFC 8252) redirect
|
||||
// listener. The token exchange itself stays on the game server - this class only produces the
|
||||
// {code, code_verifier, redirect_uri} triple that GMCPAuthenticator sends as Char.Login.AuthCode.
|
||||
// discovery document, builds the provider's authorization URL with a PKCE (S256) challenge, and
|
||||
// captures the authorization code on a loopback (RFC 8252) redirect listener. The token exchange
|
||||
// itself stays on the game server - this class only produces the {code, code_verifier,
|
||||
// redirect_uri, nonce} set that GMCPAuthenticator sends as Char.Login.AuthCode. Handing the
|
||||
// authorization URL to the system browser is the caller's job, so that the decision to launch a
|
||||
// browser at the player is made in one place for both this and the server-driven flow.
|
||||
class OAuthClientFlow : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
|
@ -58,9 +60,8 @@ public:
|
|||
const QString& nonce);
|
||||
|
||||
signals:
|
||||
void authorizationCaptured(const QString& code, const QString& codeVerifier, const QString& redirectUri);
|
||||
void browserOpened(const QString& url);
|
||||
void browserOpenFailed(const QString& url);
|
||||
void authorizationCaptured(const QString& code, const QString& codeVerifier, const QString& redirectUri, const QString& nonce);
|
||||
void authorizationUrlReady(const QUrl& authorizationUrl);
|
||||
void flowFailed(const QString& logDetail);
|
||||
|
||||
private:
|
||||
|
|
|
|||
|
|
@ -79,8 +79,44 @@ void ScriptUnit::uninstall(const QString& packageName)
|
|||
uninstallList.append(rootScript);
|
||||
}
|
||||
}
|
||||
for (auto& script : uninstallList) {
|
||||
delete script;
|
||||
// Re-entrant uninstall (#9337): a package's own script (e.g. a package
|
||||
// auto-updater calling uninstallPackage()) is removing its package while one
|
||||
// of that package's scripts is still on the call stack - either an event
|
||||
// handler Host::raiseEvent() is dispatching to, or a top-level body
|
||||
// TScript::compileScript() is compiling. Deleting now would be a use-after-free
|
||||
// (of the script still executing, and of the other TScript pointers raiseEvent()
|
||||
// or ScriptUnit::compileAll() is still iterating), so defer to doCleanup() at
|
||||
// depth 0. Deactivating is enough to stop the handlers firing for the rest of
|
||||
// the dispatch: TScript::callEventHandler() checks isActive().
|
||||
if (mProcessingDepth > 0) {
|
||||
for (auto script : uninstallList) {
|
||||
script->setIsActive(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// At depth 0 delete straight away, but go through doCleanup() rather than a bare
|
||||
// loop: uninstallList is a member that a prior deferred uninstall may have left
|
||||
// populated, so a second uninstall of the same still-registered package can queue
|
||||
// the same pointers twice - doCleanup()'s seen set stops that double-freeing.
|
||||
doCleanup();
|
||||
}
|
||||
|
||||
// Flush the deletes uninstall() deferred (#9337). uninstallList is ordered
|
||||
// children-before-parents and each ~Tree unlinks from its parent, so deleting
|
||||
// children first empties the parent's child list (no double free); the seen
|
||||
// set guards a node queued twice by re-entrant uninstalls.
|
||||
void ScriptUnit::doCleanup()
|
||||
{
|
||||
if (mProcessingDepth > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
QSet<TScript*> deletedScripts;
|
||||
for (auto script : uninstallList) {
|
||||
if (!deletedScripts.contains(script)) {
|
||||
deletedScripts.insert(script);
|
||||
delete script;
|
||||
}
|
||||
}
|
||||
uninstallList.clear();
|
||||
}
|
||||
|
|
@ -237,11 +273,22 @@ int ScriptUnit::getNewID()
|
|||
|
||||
void ScriptUnit::compileAll(bool saveLoadingError)
|
||||
{
|
||||
for (auto script : mScriptRootNodeList) {
|
||||
// Iterate a snapshot of the root list: a script's top-level body, run by
|
||||
// compile() below, can uninstall its own package (a package auto-updater
|
||||
// pattern). uninstall() defers the actual delete whilst compileScript() is on
|
||||
// the stack, so no node is unlinked mid-loop, but taking a copy keeps the
|
||||
// iteration safe even against a body that adds or removes root scripts:
|
||||
const std::vector<TScript*> rootNodes(mScriptRootNodeList.begin(), mScriptRootNodeList.end());
|
||||
for (auto script : rootNodes) {
|
||||
if (script->isActive()) {
|
||||
script->compileAll(saveLoadingError);
|
||||
}
|
||||
}
|
||||
// The loop is now done with the (possibly self-uninstalled) scripts, so flush
|
||||
// the deletes uninstall() deferred - before the editor tree is rebuilt below and
|
||||
// before returning to the event loop, where the 0ms save Host::uninstallPackage()
|
||||
// queues would otherwise serialize the still-live "uninstalled" scripts back in:
|
||||
doCleanup();
|
||||
if (mpHost->mpEditorDialog) {
|
||||
mpHost->mpEditorDialog->doCleanReset();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,15 +44,9 @@ public:
|
|||
explicit ScriptUnit(Host*);
|
||||
~ScriptUnit();
|
||||
|
||||
std::list<TScript*> getScriptRootNodeList()
|
||||
{
|
||||
return mScriptRootNodeList;
|
||||
}
|
||||
std::list<TScript*> getScriptRootNodeList() { return mScriptRootNodeList; }
|
||||
|
||||
QMap<int, TScript*> getScriptList()
|
||||
{
|
||||
return mScriptMap;
|
||||
}
|
||||
QMap<int, TScript*> getScriptList() { return mScriptMap; }
|
||||
|
||||
TScript* getScript(int id);
|
||||
void compileAll(bool saveLoadingError = false);
|
||||
|
|
@ -63,6 +57,17 @@ public:
|
|||
void stopAllTriggers();
|
||||
void uninstall(const QString&);
|
||||
void _uninstall(TScript* pChild, const QString& packageName);
|
||||
// Tracks Host::raiseEvent() dispatch nesting so that uninstall() can defer
|
||||
// deleting a package's scripts while one of their event handlers is still on
|
||||
// the call stack (e.g. a handler calling uninstallPackage() on its own
|
||||
// package) - deferred items are flushed by doCleanup() at depth 0:
|
||||
void beginProcessing() { ++mProcessingDepth; }
|
||||
void endProcessing()
|
||||
{
|
||||
--mProcessingDepth;
|
||||
Q_ASSERT(mProcessingDepth >= 0);
|
||||
}
|
||||
void doCleanup();
|
||||
int getNewID();
|
||||
std::vector<int> findItems(const QString& name, const bool exactMatch = true, const bool caseSensitive = true);
|
||||
void resetStats();
|
||||
|
|
@ -84,6 +89,9 @@ private:
|
|||
QPointer<Host> mpHost;
|
||||
QMap<int, TScript*> mScriptMap;
|
||||
std::list<TScript*> mScriptRootNodeList;
|
||||
// > 0 whilst Host::raiseEvent() is dispatching to event handlers; uninstall()
|
||||
// and doCleanup() must not delete scripts then:
|
||||
int mProcessingDepth = 0;
|
||||
int mMaxID = 0;
|
||||
int statsItemsTotal = 0;
|
||||
int statsTempItems = 0;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@
|
|||
#include "TToolBar.h"
|
||||
#include "mudlet.h"
|
||||
|
||||
#include <QScopeGuard>
|
||||
|
||||
TAction::TAction(TAction* parent, Host* pHost)
|
||||
: Tree<TAction>(parent)
|
||||
, mpHost(pHost)
|
||||
|
|
@ -161,6 +163,18 @@ void TAction::execute()
|
|||
}
|
||||
}
|
||||
|
||||
// Whilst this frame is on the stack ActionUnit::uninstall() must defer
|
||||
// deleting this profile's actions: the script run below can uninstall its
|
||||
// own package (e.g. a "reload package" button calling uninstallPackage())
|
||||
// and freeing this TAction mid-execute() is a use-after-free - the members
|
||||
// read after the call would be dangling. The guard defers that delete past
|
||||
// the last member access here; see ActionUnit::mProcessingDepth.
|
||||
ActionUnit* pUnit = mpHost->getActionUnit();
|
||||
pUnit->beginProcessing();
|
||||
const auto processingGuard = qScopeGuard([pUnit] {
|
||||
pUnit->endProcessing();
|
||||
});
|
||||
|
||||
mpHost->mLuaInterpreter.call(mFuncName, mName);
|
||||
// move focus back to the active console / command line:
|
||||
mpHost->setFocusOnHostActiveCommandLine();
|
||||
|
|
@ -168,6 +182,8 @@ void TAction::execute()
|
|||
|
||||
void TAction::expandToolbar(TToolBar* pT)
|
||||
{
|
||||
// The -1 is needed to compensate for the initial pre-increment to TToolBar::mItemCount
|
||||
pT->resetItemCount(mButtonFillerOffset - 1);
|
||||
for (auto pTAction : *mpMyChildrenList) {
|
||||
if (!pTAction->isActive()) {
|
||||
// This test and conditional loop abort was missing from this method
|
||||
|
|
@ -253,6 +269,8 @@ void TAction::insertActions(TToolBar* pT, QMenu* pMenu)
|
|||
|
||||
void TAction::expandToolbar(TEasyButtonBar* pT)
|
||||
{
|
||||
// The -1 is needed to compensate for the initial pre-increment to TEasyButtonBar::mItemCount
|
||||
pT->resetItemCount(mButtonFillerOffset - 1);
|
||||
for (auto pTAction : *mpMyChildrenList) {
|
||||
if (!pTAction->isActive()) {
|
||||
continue;
|
||||
|
|
|
|||
162
src/TAction.h
162
src/TAction.h
|
|
@ -55,42 +55,111 @@ public:
|
|||
void compileAll();
|
||||
QString getName() const { return mName; }
|
||||
void setName(const QString& name);
|
||||
void setButtonRotation(int rotation) { if (rotation != mButtonRotation) { setDataChanged(); mButtonRotation = rotation; } }
|
||||
void setButtonRotation(int rotation) {
|
||||
if (rotation != mButtonRotation) {
|
||||
setDataChanged();
|
||||
mButtonRotation = rotation;
|
||||
}
|
||||
}
|
||||
int getButtonRotation() const { return mButtonRotation; }
|
||||
void setButtonColumns(int columns) { if (columns != mButtonColumns) { setDataChanged(); mButtonColumns = columns; } }
|
||||
void setButtonColumns(int columns) {
|
||||
if (columns != mButtonColumns) {
|
||||
setDataChanged();
|
||||
mButtonColumns = columns;
|
||||
}
|
||||
}
|
||||
int getButtonColumns() const { return mButtonColumns; }
|
||||
bool getButtonFlat() const { return mButtonFlat; }
|
||||
void setButtonFlat(bool flat) { if (flat != mButtonFlat) { setDataChanged(); mButtonFlat = flat; } }
|
||||
|
||||
void setSizeX(int size) { if (size != mSizeX) { setDataChanged(); mSizeX = size; } }
|
||||
void setButtonFlat(bool flat) {
|
||||
if (flat != mButtonFlat) {
|
||||
setDataChanged();
|
||||
mButtonFlat = flat;
|
||||
}
|
||||
}
|
||||
// This should always be called AFTER setButtonColumns!
|
||||
void setButtonFillerOffset(const int value)
|
||||
{
|
||||
const auto newValue = std::max(0, std::min(value, mButtonColumns - 1));
|
||||
if (newValue != mButtonFillerOffset) {
|
||||
setDataChanged();
|
||||
mButtonFillerOffset = newValue;
|
||||
}
|
||||
}
|
||||
int getButtonFillerOffset() const { return mButtonFillerOffset; }
|
||||
void setSizeX(int size) {
|
||||
if (size != mSizeX) {
|
||||
setDataChanged();
|
||||
mSizeX = size;
|
||||
}
|
||||
}
|
||||
int getSizeX() const { return mSizeX; }
|
||||
void setSizeY(int size) { if (size != mSizeY) { setDataChanged(); mSizeY = size; } }
|
||||
void setSizeY(int size) {
|
||||
if (size != mSizeY) {
|
||||
setDataChanged();
|
||||
mSizeY = size;
|
||||
}
|
||||
}
|
||||
int getSizeY() const { return mSizeY; }
|
||||
QSize getSize() const { return {mSizeX, mSizeY}; }
|
||||
|
||||
void fillMenu(TEasyButtonBar* pT, QMenu* menu);
|
||||
void compile();
|
||||
bool compileScript();
|
||||
void execute();
|
||||
QString getIcon() const { return mIcon; }
|
||||
void setIcon(const QString& icon) { if (icon != mIcon) { mIcon = icon; } }
|
||||
void setIcon(const QString& icon) {
|
||||
if (icon != mIcon) {
|
||||
mIcon = icon;
|
||||
}
|
||||
}
|
||||
QString getScript() const { return mScript; }
|
||||
bool setScript(const QString& script);
|
||||
QString getCommandButtonUp() const { return mCommandButtonUp; }
|
||||
void setCommandButtonUp(const QString& cmd) { if (cmd != mCommandButtonUp) { setDataChanged(); mCommandButtonUp = cmd; } }
|
||||
void setCommandButtonDown(const QString& cmd) { if (cmd != mCommandButtonDown) { setDataChanged(); mCommandButtonDown = cmd; } }
|
||||
void setCommandButtonUp(const QString& cmd) {
|
||||
if (cmd != mCommandButtonUp) {
|
||||
setDataChanged();
|
||||
mCommandButtonUp = cmd;
|
||||
}
|
||||
}
|
||||
void setCommandButtonDown(const QString& cmd) {
|
||||
if (cmd != mCommandButtonDown) {
|
||||
setDataChanged();
|
||||
mCommandButtonDown = cmd;
|
||||
}
|
||||
}
|
||||
QString getCommandButtonDown() const { return mCommandButtonDown; }
|
||||
bool isPushDownButton() { return mIsPushDownButton; }
|
||||
void setIsPushDownButton(bool b) { if (b != mIsPushDownButton) { setDataChanged(); mIsPushDownButton = b; } }
|
||||
bool isPushDownButton() const { return mIsPushDownButton; }
|
||||
void setIsPushDownButton(const bool b) {
|
||||
if (b != mIsPushDownButton) {
|
||||
setDataChanged();
|
||||
mIsPushDownButton = b;
|
||||
}
|
||||
}
|
||||
|
||||
void setIsFolder(bool b) { if (b != isFolder()) { setDataChanged(); this->Tree::setIsFolder(b);} }
|
||||
void setIsFolder(bool b) {
|
||||
if (b != isFolder()) {
|
||||
setDataChanged();
|
||||
this->Tree::setIsFolder(b);
|
||||
}
|
||||
}
|
||||
|
||||
bool registerAction();
|
||||
void insertActions(TToolBar* pT, QMenu* menu);
|
||||
void expandToolbar(TToolBar* pT);
|
||||
void insertActions(TEasyButtonBar* pT, QMenu* menu);
|
||||
void expandToolbar(TEasyButtonBar* pT);
|
||||
void setDataSaved() { if (mpParent) { mpParent->setDataSaved(); } mDataChanged = false; }
|
||||
void setDataChanged() { if (mpParent) { mpParent->setDataChanged(); } mDataChanged = true; }
|
||||
void setDataSaved() {
|
||||
if (mpParent) {
|
||||
mpParent->setDataSaved();
|
||||
}
|
||||
mDataChanged = false;
|
||||
}
|
||||
void setDataChanged() {
|
||||
if (mpParent) {
|
||||
mpParent->setDataChanged();
|
||||
}
|
||||
mDataChanged = true;
|
||||
}
|
||||
bool isDataChanged() { return mDataChanged; }
|
||||
QString packageName(TAction* pAction) const;
|
||||
QString moduleName(TAction* pAction) const;
|
||||
|
|
@ -100,42 +169,33 @@ public:
|
|||
QPointer<TEasyButtonBar> mpEasyButtonBar;
|
||||
QPointer<EAction> mpEAction;
|
||||
QPointer<TFlipButton> mpFButton;
|
||||
// The following was an int but there was confusion over:
|
||||
// EITHER: "1" = released/unclicked/up & "2" = pressed/clicked/down
|
||||
// OR: "1" = pressed/clicked/down & "0" = released/unclicked/up
|
||||
// The Wiki says it should be "1" and "2" but the code sort of did "0"/"1"
|
||||
// in some places.
|
||||
// Now uses a boolean:
|
||||
// "true" = pressed/clicked/down & "false" = released/unclicked/up
|
||||
/* The following was an int but there was confusion over:
|
||||
* EITHER: "1" = released/unclicked/up & "2" = pressed/clicked/down,
|
||||
* OR: "1" = pressed/clicked/down & "0" = released/unclicked/up.
|
||||
* The Wiki says it should be "1" and "2" but the code sort of did "0"/"1"
|
||||
* in some places.
|
||||
* Now uses a boolean:
|
||||
* "true" = pressed/clicked/down & "false" = released/unclicked/up.
|
||||
* Only relevant for "push-down" buttons*/
|
||||
bool mButtonState = false;
|
||||
int mPosX = 0;
|
||||
int mPosY = 0;
|
||||
// THIS class uses 0 = horizontal, 1 = vertical; c.f. TFlipButton class
|
||||
// which uses Qt::Orientation enum for the same thing:
|
||||
/* THIS class uses 0 = horizontal, 1 = vertical.
|
||||
* c.f. TFlipButton class which uses Qt::Orientation enum
|
||||
* (1 = Qt::Horizontal, 2 = Qt::Vertical).*/
|
||||
int mOrientation = 0;
|
||||
// 0 to 3 are only applicable to the Easy Button Bar buttons/menus (around
|
||||
// edge of main console:
|
||||
// 0 = Top "Toolbar" (Easy Button Bar)
|
||||
// 2 = Left "Toolbar" (Easy Button Bar)
|
||||
// 3 = Left "Toolbar" (Easy Button Bar)
|
||||
// 4 = Dockable/floating Toolbar
|
||||
/* 0, 2, 3 are only applicable to the Easy Button Bar buttons/menus (around
|
||||
* edge of main console):
|
||||
* 0 = Top "Toolbar" (Easy Button Bar).
|
||||
* 1 = Not used since 2009 in commit: c5f404729d46976c6b2c7cf89fd098f5806440c8.
|
||||
* 2 = Left "Toolbar" (Easy Button Bar).
|
||||
* 3 = Right "Toolbar" (Easy Button Bar).
|
||||
* 4 = Dockable/floating Toolbar.*/
|
||||
int mLocation = 0;
|
||||
bool mIsPushDownButton = false;
|
||||
|
||||
bool mNeedsToBeCompiled = true;
|
||||
QString mIcon;
|
||||
QIcon mIconPix;
|
||||
|
||||
// 0 = Horizontal
|
||||
// 1 = Vertical
|
||||
// 2 = Vertical + Mirrored
|
||||
int mButtonRotation = 0;
|
||||
int mButtonColumns = 1;
|
||||
// Not currently user accessible but was previously and maintained in game
|
||||
// saves - and applied to buttons when drawn:
|
||||
bool mButtonFlat = false;
|
||||
int mSizeX = 0;
|
||||
int mSizeY = 0;
|
||||
// Not currently user accessible but was previously and maintained in game
|
||||
// saves - and applied to buttons when drawn:
|
||||
bool mUseCustomLayout = false;
|
||||
|
|
@ -156,6 +216,26 @@ private:
|
|||
QString mFuncName;
|
||||
bool mModuleMember = false;
|
||||
bool mDataChanged = true;
|
||||
bool mIsPushDownButton = false; // Make private
|
||||
|
||||
QString mIcon;
|
||||
// 0 = Horizontal
|
||||
// 1 = Vertical
|
||||
// 2 = Vertical + Mirrored
|
||||
int mButtonRotation = 0;
|
||||
int mButtonColumns = 1;
|
||||
/* Maximum is one less than the above, and is the number of columns/rows
|
||||
* the first button/menu in a toolbar must be offset. This replaces the
|
||||
* prior arrangement that incremented this by one (modulus the
|
||||
* mButtonColums) each time the toolbar was saved. Since that now happens
|
||||
* a lot with the undo/redo and auto-save features that is no longer
|
||||
* sustainable. */
|
||||
int mButtonFillerOffset = 0;
|
||||
/* Not currently user accessible but was previously and maintained in game
|
||||
* saves - and applied to buttons when drawn: */
|
||||
bool mButtonFlat = false; // Make private
|
||||
int mSizeX = 0; // Make private
|
||||
int mSizeY = 0; // Make private
|
||||
};
|
||||
|
||||
#ifndef QT_NO_DEBUG_STREAM
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ void TAlias::compileRegex()
|
|||
TDebug(Qt::white, Qt::red) << "REGEX ERROR: failed to compile, reason:\n" << error << "\n" >> mpHost;
|
||||
TDebug(Qt::red, Qt::gray) << TDebug::csmContinue << R"(in: ")" << mRegexCode << "\"\n" >> mpHost;
|
||||
}
|
||||
setError(qsl("<b><font color='blue'>%1</font></b>").arg(tr(R"(Error: in "Pattern:", faulty regular expression, reason: "%1".)").arg(error)));
|
||||
setError(qsl("<b>%1</b>").arg(tr(R"(Error: in "Pattern:", faulty regular expression, reason: "%1".)").arg(error)));
|
||||
} else {
|
||||
pcre2_jit_compile(re.data(), PCRE2_JIT_COMPLETE);
|
||||
mOK_init = true;
|
||||
|
|
@ -332,6 +332,17 @@ void TAlias::compile()
|
|||
|
||||
bool TAlias::setScript(const QString& script)
|
||||
{
|
||||
// Switching from a registered anonymous Lua function (set up by tempAlias with a
|
||||
// function argument) to a script string: release the old function from the Lua
|
||||
// registry and leave callback mode. Otherwise execute() keeps calling the stale
|
||||
// function so the new script never runs, and the registry entry leaks - the
|
||||
// destructor would take its mScript-based branch and delete the compiled function.
|
||||
if (mRegisteredAnonymousLuaFunction) {
|
||||
if (mpHost) {
|
||||
mpHost->mLuaInterpreter.delete_luafunction(this);
|
||||
}
|
||||
mRegisteredAnonymousLuaFunction = false;
|
||||
}
|
||||
mScript = script;
|
||||
mNeedsToBeCompiled = true;
|
||||
mOK_code = compileScript();
|
||||
|
|
|
|||
210
src/TBuffer.cpp
210
src/TBuffer.cpp
|
|
@ -24,6 +24,7 @@
|
|||
#include "TBuffer.h"
|
||||
|
||||
#include "Host.h"
|
||||
#include "LuaLiteral.h"
|
||||
#include "mudlet.h"
|
||||
#include "TConsole.h"
|
||||
#include "TEvent.h"
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
#include "THyperlinkSelectionManager.h"
|
||||
#include "TStringUtils.h"
|
||||
#include "TTextEdit.h"
|
||||
#include "UntrustedText.h"
|
||||
#include "TTextProperties.h"
|
||||
#include "widechar_width.h"
|
||||
#include "TEncodingHelper.h"
|
||||
|
|
@ -226,6 +228,7 @@ TBuffer::TBuffer(const TBuffer& other)
|
|||
, mEchoingText(other.mEchoingText)
|
||||
, mpConsole(other.mpConsole)
|
||||
, mGotESC(other.mGotESC)
|
||||
, mGotEscCharset(other.mGotEscCharset)
|
||||
, mGotCSI(other.mGotCSI)
|
||||
, mGotOSC(other.mGotOSC)
|
||||
, mGotString(other.mGotString)
|
||||
|
|
@ -271,6 +274,7 @@ TBuffer::TBuffer(const TBuffer& other)
|
|||
, mWrapDetectSamples(other.mWrapDetectSamples)
|
||||
, mIncompleteSequenceBytes(other.mIncompleteSequenceBytes)
|
||||
, mLocalGotESC(other.mLocalGotESC)
|
||||
, mLocalGotEscCharset(other.mLocalGotEscCharset)
|
||||
, mLocalGotCSI(other.mLocalGotCSI)
|
||||
, mLocalGotOSC(other.mLocalGotOSC)
|
||||
, mLocalGotString(other.mLocalGotString)
|
||||
|
|
@ -321,6 +325,7 @@ TBuffer& TBuffer::operator=(const TBuffer& other)
|
|||
mEchoingText = other.mEchoingText;
|
||||
mpConsole = other.mpConsole;
|
||||
mGotESC = other.mGotESC;
|
||||
mGotEscCharset = other.mGotEscCharset;
|
||||
mGotCSI = other.mGotCSI;
|
||||
mGotOSC = other.mGotOSC;
|
||||
mGotString = other.mGotString;
|
||||
|
|
@ -366,6 +371,7 @@ TBuffer& TBuffer::operator=(const TBuffer& other)
|
|||
mWrapDetectSamples = other.mWrapDetectSamples;
|
||||
mIncompleteSequenceBytes = other.mIncompleteSequenceBytes;
|
||||
mLocalGotESC = other.mLocalGotESC;
|
||||
mLocalGotEscCharset = other.mLocalGotEscCharset;
|
||||
mLocalGotCSI = other.mLocalGotCSI;
|
||||
mLocalGotOSC = other.mLocalGotOSC;
|
||||
mLocalGotString = other.mLocalGotString;
|
||||
|
|
@ -580,6 +586,7 @@ void TBuffer::addLink(bool trigMode, const QString& text, QStringList& command,
|
|||
void TBuffer::swapParserSequenceState()
|
||||
{
|
||||
std::swap(mGotESC, mLocalGotESC);
|
||||
std::swap(mGotEscCharset, mLocalGotEscCharset);
|
||||
std::swap(mGotCSI, mLocalGotCSI);
|
||||
std::swap(mGotOSC, mLocalGotOSC);
|
||||
std::swap(mGotString, mLocalGotString);
|
||||
|
|
@ -588,9 +595,8 @@ void TBuffer::swapParserSequenceState()
|
|||
|
||||
void TBuffer::translateToPlainText(std::string& incoming, const bool isFromServer)
|
||||
{
|
||||
// mGotESC/mGotCSI/mGotOSC/mGotString and mIncompleteSequenceBytes persist
|
||||
// between calls so that a sequence split across Game Server packets still
|
||||
// parses.
|
||||
// The mGot... latches and mIncompleteSequenceBytes persist between calls so
|
||||
// that a sequence split across Game Server packets still parses.
|
||||
// Locally generated text (feedTriggers(), MMCP chat messages, MXP
|
||||
// insertions) runs through the same parser, so swap in a separate set of
|
||||
// that state for the duration of such a feed - otherwise local text
|
||||
|
|
@ -623,6 +629,11 @@ void TBuffer::translateToPlainTextInner(std::string& incoming, const bool isFrom
|
|||
// What can appear in a CSI final byte position - (includes a backslash
|
||||
// which has to be doubled to include it in here):
|
||||
const QByteArray cFinal = QByteArrayLiteral("@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~");
|
||||
// The complete two byte escape sequences (DECSC, DECRC, RIS and a stray
|
||||
// ST) that games do send and that Mudlet has to swallow. Only these: any
|
||||
// other byte after an ESC is text, and printing it is no worse than what
|
||||
// Mudlet has always done, whereas eating it loses real output:
|
||||
const QByteArray cShortEscape = QByteArrayLiteral("78c\\");
|
||||
|
||||
// As well as enabling the prepending of left-over bytes from last packet
|
||||
// from the MUD server this may help in high frequency interactions to
|
||||
|
|
@ -749,11 +760,22 @@ void TBuffer::translateToPlainTextInner(std::string& incoming, const bool isFrom
|
|||
}
|
||||
|
||||
mGotESC = true;
|
||||
mGotEscCharset = false;
|
||||
++localBufferPosition;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (mGotEscCharset) {
|
||||
mGotEscCharset = false;
|
||||
if (static_cast<unsigned char>(ch) >= 0x30 && static_cast<unsigned char>(ch) <= 0x7E) {
|
||||
++localBufferPosition;
|
||||
continue;
|
||||
}
|
||||
// Only a final byte can name a character set, so this was a stray
|
||||
// ESC after all and the byte is text.
|
||||
}
|
||||
|
||||
if (mGotESC) {
|
||||
mGotESC = false;
|
||||
if (ch == '[' || ch == ']') {
|
||||
|
|
@ -771,16 +793,20 @@ void TBuffer::translateToPlainTextInner(std::string& incoming, const bool isFrom
|
|||
++localBufferPosition;
|
||||
continue;
|
||||
}
|
||||
if (static_cast<unsigned char>(ch) >= 0x20) {
|
||||
// The final byte of some other escape sequence (e.g. ESC 7
|
||||
// or ESC M) that Mudlet does not handle - consume it silently
|
||||
// as a real terminal would instead of showing it as text:
|
||||
if (ch == '(' || ch == ')' || ch == '*' || ch == '+') {
|
||||
// An ISO 2022 character set designation such as ESC ( B; the
|
||||
// byte after this one names the set:
|
||||
mGotEscCharset = true;
|
||||
++localBufferPosition;
|
||||
continue;
|
||||
}
|
||||
// A control character straight after the ESC means the escape
|
||||
// sequence is malformed - abandon it (the latch was already
|
||||
// cleared above) and process the character normally:
|
||||
if (cShortEscape.indexOf(ch) >= 0) {
|
||||
++localBufferPosition;
|
||||
continue;
|
||||
}
|
||||
// Any other byte is text: a stray ESC in the game's output must
|
||||
// not swallow it, and consuming a multibyte character's lead byte
|
||||
// would orphan its continuation bytes.
|
||||
}
|
||||
|
||||
if (mGotCSI) {
|
||||
|
|
@ -1599,7 +1625,18 @@ void TBuffer::commitLineData(QString line, std::deque<TChar> chars, const char c
|
|||
const int lineIndex = lineBuffer.size() - 1;
|
||||
mCommitLineIndices.append(lineIndex);
|
||||
if (!mSkipTriggerProcessing) {
|
||||
// Keep the just-committed formats around so that color triggers
|
||||
// can match against the colors as received from the game even
|
||||
// after earlier triggers in this pass have recolored the line;
|
||||
// save/restore gives nested feedTriggers() passes (which re-enter
|
||||
// this function) their own snapshot:
|
||||
std::deque<TChar> savedPassLine = std::move(mPreTriggerPassLine);
|
||||
const int savedPassLineNumber = mPreTriggerPassLineNumber;
|
||||
mPreTriggerPassLine = std::move(chars);
|
||||
mPreTriggerPassLineNumber = lineIndex;
|
||||
mpHost->mpConsole->runTriggers(lineIndex);
|
||||
mPreTriggerPassLine = std::move(savedPassLine);
|
||||
mPreTriggerPassLineNumber = savedPassLineNumber;
|
||||
}
|
||||
|
||||
// Only use of TBuffer::wrap(), breaks up new text
|
||||
|
|
@ -1813,6 +1850,23 @@ void TBuffer::recordLineLengthForWrapDetection(const qsizetype length)
|
|||
});
|
||||
}
|
||||
|
||||
const std::deque<TChar>* TBuffer::preTriggerPassLine(int lineNumber) const
|
||||
{
|
||||
if (lineNumber >= 0 && lineNumber == mPreTriggerPassLineNumber) {
|
||||
return &mPreTriggerPassLine;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// A structural edit to the trigger-pass line makes the edited text the new
|
||||
// baseline for color matching, as it was before the snapshot existed:
|
||||
void TBuffer::syncPreTriggerPassLine(int y)
|
||||
{
|
||||
if (y >= 0 && y == mPreTriggerPassLineNumber && y < static_cast<int>(buffer.size())) {
|
||||
mPreTriggerPassLine = buffer[y];
|
||||
}
|
||||
}
|
||||
|
||||
void TBuffer::processMxpWatchdogCallback()
|
||||
{
|
||||
if (!mpHost) {
|
||||
|
|
@ -2401,6 +2455,10 @@ void TBuffer::decodeSGR(const QString& sequence)
|
|||
qDebug().noquote().nospace() << "TBuffer::decodeSGR(\"" << sequence
|
||||
<< "\") ERROR - failed to detect underline parameter element (the second part) in a SGR...;4:?;..m sequence assuming it is a zero!";
|
||||
}
|
||||
// Sub-parameter values follow the widely-adopted kitty/VTE
|
||||
// convention: 0 none, 1 single, 2 double, 3 curly, 4 dotted,
|
||||
// 5 dashed. Mudlet has no distinct double-underline style so
|
||||
// 2 is shown as a plain single underline.
|
||||
switch (value) {
|
||||
case 0: // Underline off
|
||||
mUnderline = false;
|
||||
|
|
@ -2408,29 +2466,35 @@ void TBuffer::decodeSGR(const QString& sequence)
|
|||
mUnderlineDotted = false;
|
||||
mUnderlineDashed = false;
|
||||
break;
|
||||
case 1: // Underline on (solid)
|
||||
case 1: // Single (straight) underline
|
||||
mUnderline = true;
|
||||
mUnderlineWavy = false;
|
||||
mUnderlineDotted = false;
|
||||
mUnderlineDashed = false;
|
||||
break;
|
||||
case 2: // Dashed underline
|
||||
case 2: // Double underline - unsupported, show as single
|
||||
mUnderline = true;
|
||||
mUnderlineWavy = false;
|
||||
mUnderlineDotted = false;
|
||||
mUnderlineDashed = true;
|
||||
mUnderlineDashed = false;
|
||||
break;
|
||||
case 3: // Dotted underline
|
||||
case 3: // Curly (wavy) underline
|
||||
mUnderline = true;
|
||||
mUnderlineWavy = true;
|
||||
mUnderlineDotted = false;
|
||||
mUnderlineDashed = false;
|
||||
break;
|
||||
case 4: // Dotted underline
|
||||
mUnderline = true;
|
||||
mUnderlineWavy = false;
|
||||
mUnderlineDotted = true;
|
||||
mUnderlineDashed = false;
|
||||
break;
|
||||
case 4: // Wavy underline
|
||||
case 5: // Dashed underline
|
||||
mUnderline = true;
|
||||
mUnderlineWavy = true;
|
||||
mUnderlineWavy = false;
|
||||
mUnderlineDotted = false;
|
||||
mUnderlineDashed = false;
|
||||
mUnderlineDashed = true;
|
||||
break;
|
||||
default: // Something unexpected
|
||||
qDebug().noquote().nospace() << "TBuffer::decodeSGR(\"" << sequence
|
||||
|
|
@ -3225,8 +3289,16 @@ void TBuffer::decodeOSC(const QString& sequence)
|
|||
break;
|
||||
}
|
||||
|
||||
// Deliberately below the terminator branch above: the close is the only
|
||||
// thing that clears mHyperlinkActive, so refusing it would leave a link
|
||||
// open forever and mark the rest of the session clickable. Turning the
|
||||
// preference off mid-link must still let that link finish.
|
||||
if (!mpHost->mEnableOSC8Hyperlinks) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rawUrl.isEmpty()) {
|
||||
if (rawUrl.length() > 8192) {
|
||||
if (rawUrl.length() > static_cast<int>(MAX_OSC_SEQUENCE_LENGTH)) {
|
||||
qWarning() << "TBuffer::decodeOSC(...) - Rejected hyperlink: URL too long:" << rawUrl;
|
||||
return;
|
||||
}
|
||||
|
|
@ -3311,7 +3383,7 @@ void TBuffer::decodeOSC(const QString& sequence)
|
|||
QString customTooltip;
|
||||
|
||||
if (queryParams.contains(qsl("tooltip"))) {
|
||||
customTooltip = queryParams.value(qsl("tooltip"));
|
||||
customTooltip = UntrustedText::forAuthoredText(queryParams.value(qsl("tooltip")));
|
||||
}
|
||||
|
||||
// Note: title is now parsed directly into mCurrentHyperlinkStyling by parseJsonHyperlinkConfig
|
||||
|
|
@ -3367,19 +3439,25 @@ void TBuffer::decodeOSC(const QString& sequence)
|
|||
|
||||
if (baseUrl.startsWith(qsl("send:"))) {
|
||||
QString innerCommand = QUrl::fromPercentEncoding(baseUrl.mid(5).toUtf8());
|
||||
command = {qsl("send([[%1]], false)").arg(innerCommand)};
|
||||
hint = {qsl("%1: %2").arg(QObject::tr("Send"), innerCommand)};
|
||||
mCurrentHyperlinkStyling.actionScheme = Mudlet::HyperlinkStyling::ActionSend;
|
||||
mCurrentHyperlinkStyling.baseCommand = innerCommand;
|
||||
command = {qsl("send(%1, false)").arg(LuaLiteral::quote(innerCommand))};
|
||||
hint = {qsl("%1: %2").arg(QObject::tr("Send"), UntrustedText::forTarget(innerCommand))};
|
||||
} else if (baseUrl.startsWith(qsl("prompt:"))) {
|
||||
QString innerCommand = QUrl::fromPercentEncoding(baseUrl.mid(7).toUtf8());
|
||||
command = {qsl("sendCmdLine([[%1]])").arg(innerCommand)};
|
||||
hint = {qsl("%1: %2").arg(QObject::tr("Prompt"), innerCommand)};
|
||||
mCurrentHyperlinkStyling.actionScheme = Mudlet::HyperlinkStyling::ActionPrompt;
|
||||
mCurrentHyperlinkStyling.baseCommand = innerCommand;
|
||||
command = {qsl("sendCmdLine(%1)").arg(LuaLiteral::quote(innerCommand))};
|
||||
hint = {qsl("%1: %2").arg(QObject::tr("Prompt"), UntrustedText::forTarget(innerCommand))};
|
||||
} else {
|
||||
QUrl qurl(baseUrl);
|
||||
QString scheme = qurl.scheme().toLower();
|
||||
|
||||
if (scheme == qsl("http") || scheme == qsl("https") || scheme == qsl("ftp")) {
|
||||
command = {qsl("openUrl([[%1]])").arg(baseUrl)};
|
||||
hint = {qsl("%1: %2").arg(QObject::tr("Open browser to"), baseUrl)};
|
||||
mCurrentHyperlinkStyling.actionScheme = Mudlet::HyperlinkStyling::ActionOpenUrl;
|
||||
mCurrentHyperlinkStyling.baseCommand = baseUrl;
|
||||
command = {qsl("openUrl(%1)").arg(LuaLiteral::quote(baseUrl))};
|
||||
hint = {qsl("%1: %2").arg(QObject::tr("Open browser to"), UntrustedText::forTarget(baseUrl))};
|
||||
} else {
|
||||
qWarning().noquote().nospace() << "TBuffer::decodeOSC(...) - Ignored untrusted or unsupported URI scheme: \"" << scheme << "\"";
|
||||
return;
|
||||
|
|
@ -3413,20 +3491,20 @@ void TBuffer::decodeOSC(const QString& sequence)
|
|||
// Determine command type based on prefix
|
||||
if (menuCommand.startsWith(qsl("send:"))) {
|
||||
QString innerCommand = QUrl::fromPercentEncoding(menuCommand.mid(5).toUtf8());
|
||||
menuCommands.append(qsl("send([[%1]], false)").arg(innerCommand));
|
||||
menuHints.append(menuLabel);
|
||||
menuCommands.append(qsl("send(%1, false)").arg(LuaLiteral::quote(innerCommand)));
|
||||
menuHints.append(UntrustedText::forAuthoredText(menuLabel));
|
||||
} else if (menuCommand.startsWith(qsl("prompt:"))) {
|
||||
QString innerCommand = QUrl::fromPercentEncoding(menuCommand.mid(7).toUtf8());
|
||||
menuCommands.append(qsl("sendCmdLine([[%1]])").arg(innerCommand));
|
||||
menuHints.append(menuLabel);
|
||||
menuCommands.append(qsl("sendCmdLine(%1)").arg(LuaLiteral::quote(innerCommand)));
|
||||
menuHints.append(UntrustedText::forAuthoredText(menuLabel));
|
||||
} else if (menuCommand == qsl("-")) {
|
||||
// Special case: "-" creates a menu separator
|
||||
menuCommands.append(QString());
|
||||
menuHints.append(QString());
|
||||
} else {
|
||||
// Treat as direct command
|
||||
menuCommands.append(qsl("send([[%1]], false)").arg(menuCommand));
|
||||
menuHints.append(menuLabel);
|
||||
menuCommands.append(qsl("send(%1, false)").arg(LuaLiteral::quote(menuCommand)));
|
||||
menuHints.append(UntrustedText::forAuthoredText(menuLabel));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3822,14 +3900,14 @@ bool TBuffer::parseJsonHyperlinkConfig(const QString& jsonString, QMap<QString,
|
|||
if (root.contains(qsl("title"))) {
|
||||
QJsonValue titleValue = root[qsl("title")];
|
||||
if (titleValue.isString()) {
|
||||
styling.menuTitle = titleValue.toString();
|
||||
styling.menuTitle = UntrustedText::forAuthoredText(titleValue.toString());
|
||||
#if defined(DEBUG_OSC_PROCESSING)
|
||||
qDebug() << "[OSC] Title parameter added:" << titleValue.toString();
|
||||
#endif
|
||||
} else if (titleValue.isObject()) {
|
||||
QJsonObject titleObj = titleValue.toObject();
|
||||
if (titleObj.contains(qsl("text")) && titleObj[qsl("text")].isString()) {
|
||||
styling.menuTitle = titleObj[qsl("text")].toString();
|
||||
styling.menuTitle = UntrustedText::forAuthoredText(titleObj[qsl("text")].toString());
|
||||
}
|
||||
if (titleObj.contains(qsl("style")) && titleObj[qsl("style")].isObject()) {
|
||||
parseJsonStateStyle(titleObj[qsl("style")].toObject(), styling.menuTitleStyle);
|
||||
|
|
@ -4723,6 +4801,10 @@ bool TBuffer::insertInLine(QPoint& P, const QString& text, const TChar& format)
|
|||
if (text.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// Bound a single insert to the same limit the echo/append path uses
|
||||
// (see appendLine()), so an oversized insertText() cannot consume unbounded
|
||||
// memory or processing time.
|
||||
const QString insertedText = (text.size() > MAX_CHARACTERS_PER_ECHO) ? text.left(MAX_CHARACTERS_PER_ECHO) : text;
|
||||
const int x = P.x();
|
||||
const int y = P.y();
|
||||
if ((y >= 0) && (y < static_cast<int>(buffer.size()))) {
|
||||
|
|
@ -4733,14 +4815,14 @@ bool TBuffer::insertInLine(QPoint& P, const QString& text, const TChar& format)
|
|||
TChar c(mpConsole);
|
||||
expandLine(y, x - buffer.at(y).size(), c);
|
||||
}
|
||||
for (int i = 0, total = text.size(); i < total; ++i) {
|
||||
lineBuffer[y].insert(x + i, text.at(i));
|
||||
const TChar c = format;
|
||||
auto it = buffer[y].begin();
|
||||
buffer[y].insert(it + x + i, c);
|
||||
}
|
||||
// Insert the whole run in one operation. Inserting one character at a
|
||||
// time into the middle of the QString/std::deque is O(n) per character,
|
||||
// which is quadratic for large inserts.
|
||||
lineBuffer[y].insert(x, insertedText);
|
||||
buffer[y].insert(buffer[y].begin() + x, static_cast<std::size_t>(insertedText.size()), format);
|
||||
syncPreTriggerPassLine(y);
|
||||
} else {
|
||||
appendLine(text, 0, text.size(), format.mFgColor, format.mBgColor, format.mFlags);
|
||||
appendLine(insertedText, 0, insertedText.size(), format.mFgColor, format.mBgColor, format.mFlags);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -4945,7 +5027,7 @@ inline QList<WrapInfo> TBuffer::getWrapInfo(const QString& lineText, bool isNewl
|
|||
xPos = 0;
|
||||
continue;
|
||||
}
|
||||
int nextBoundary = boundaryFinder.toNextBoundary();
|
||||
const int nextBoundary = boundaryFinder.toNextBoundary();
|
||||
const QString grapheme = lineText.mid(indexOfChar, nextBoundary - indexOfChar);
|
||||
const uint unicode = graphemeInfo::getBaseCharacter(grapheme);
|
||||
// Safety check: during destruction, mpHost might be null
|
||||
|
|
@ -4963,13 +5045,21 @@ inline QList<WrapInfo> TBuffer::getWrapInfo(const QString& lineText, bool isNewl
|
|||
const int firstNonIndentChar = firstChar + (needsIndent ? 0 : indentationHere);
|
||||
if (c == QChar::Space or lineBreakFinder.isAtBoundary() or lineBreakFinder.toPreviousBoundary() <= firstNonIndentChar) {
|
||||
boundaryFinder.setPosition(indexOfChar);
|
||||
output.append(WrapInfo(isNewline, needsIndent, firstChar, indexOfChar));
|
||||
} else {
|
||||
indexOfChar = lineBreakFinder.position();
|
||||
nextBoundary = lineBreakFinder.position();
|
||||
boundaryFinder.setPosition(nextBoundary);
|
||||
output.append(WrapInfo(isNewline, needsIndent, firstChar, indexOfChar));
|
||||
boundaryFinder.setPosition(indexOfChar);
|
||||
}
|
||||
if (indexOfChar <= firstChar) {
|
||||
// no room for even one grapheme - either the wrap width is too
|
||||
// narrow (or zero) or the indentation eats all of it. Breaking
|
||||
// here would produce an empty segment and leave indexOfChar
|
||||
// where it was, looping forever, so keep one grapheme on the
|
||||
// line to guarantee the scan moves on
|
||||
indexOfChar = (nextBoundary > firstChar) ? nextBoundary : firstChar + 1;
|
||||
boundaryFinder.setPosition(indexOfChar);
|
||||
totalWidth += charWidth;
|
||||
}
|
||||
output.append(WrapInfo(isNewline, needsIndent, firstChar, indexOfChar));
|
||||
isNewline = false;
|
||||
needsIndent = true;
|
||||
xPos = 0;
|
||||
|
|
@ -5294,6 +5384,7 @@ bool TBuffer::replaceInLine(QPoint& P_begin, QPoint& P_end, const QString& with,
|
|||
auto it1 = buffer[y].begin() + x;
|
||||
auto it2 = buffer[y].begin() + x_end;
|
||||
buffer[y].erase(it1, it2);
|
||||
syncPreTriggerPassLine(y);
|
||||
}
|
||||
|
||||
// insert replacement
|
||||
|
|
@ -5303,6 +5394,29 @@ bool TBuffer::replaceInLine(QPoint& P_begin, QPoint& P_end, const QString& with,
|
|||
|
||||
void TBuffer::clear()
|
||||
{
|
||||
// Clearing the display is not gagging: flush any deferred log text before
|
||||
// the deleteLines() calls below would discard it, so a received line that
|
||||
// was still pending for logging is not lost from the log file
|
||||
if (!mpHost.isNull() && mpHost->mpConsole && this == &mpHost->mpConsole->buffer && mpHost->mpConsole->mLogToLogFile) {
|
||||
logRemainingOutput();
|
||||
|
||||
// A line whose own trigger calls clearWindow() has been committed and
|
||||
// displayed, but commitLine() defers its log() call until runTriggers()
|
||||
// returns - and the deleteLines() below will reset its commit index to
|
||||
// -1, suppressing that call. Log those still-pending lines now so they
|
||||
// are not lost. mCommitLineIndices holds them in display order.
|
||||
for (const int commitLineIndex : mCommitLineIndices) {
|
||||
if (commitLineIndex >= 0 && commitLineIndex < static_cast<int>(lineBuffer.size())) {
|
||||
mpHost->mpConsole->mLogStream << assembleLog(commitLineIndex, commitLineIndex);
|
||||
}
|
||||
}
|
||||
mpHost->mpConsole->mLogStream.flush();
|
||||
|
||||
lastTextToLog.clear();
|
||||
lastLoggedFromLine = -1;
|
||||
lastloggedToLine = -1;
|
||||
}
|
||||
|
||||
mCurrentHyperlinkCommand.clear();
|
||||
mCurrentHyperlinkHint.clear();
|
||||
mCurrentHyperlinkLinkId = 0;
|
||||
|
|
@ -5426,6 +5540,7 @@ void TBuffer::shrinkBuffer()
|
|||
// We need to adjust the search result line as some lines have now gone
|
||||
// away:
|
||||
mpConsole->mCurrentSearchResult = qMax(0, mpConsole->mCurrentSearchResult - mBatchDeleteSize);
|
||||
mPreTriggerPassLineNumber = -1;
|
||||
|
||||
// The removed leading lines shift every remaining index down; keep the
|
||||
// deferred logging state pointing at the same lines
|
||||
|
|
@ -5470,6 +5585,9 @@ bool TBuffer::deleteLines(int from, int to)
|
|||
}
|
||||
|
||||
buffer.erase(buffer.begin() + from, buffer.begin() + to + 1);
|
||||
if (mPreTriggerPassLineNumber >= from) {
|
||||
mPreTriggerPassLineNumber = -1;
|
||||
}
|
||||
|
||||
// Keep the deferred logging state in step with the removed lines so
|
||||
// pending text is only dropped when the lines it holds were deleted
|
||||
|
|
@ -5727,7 +5845,7 @@ QString TBuffer::bufferToHtml(const bool showTimeStamp /*= false*/, const int ro
|
|||
// we will NOT need a closing "</span>"
|
||||
if (showTimeStamp && !timeBuffer.at(row).isEmpty()) {
|
||||
// Use the console's background so the timestamp blends in with the
|
||||
// rest of the text, as done in TTextEdit::drawLine(...).
|
||||
// rest of the text, as done in TTextEdit::layoutLine(...).
|
||||
const QColor timeStampBgColor{mpConsole ? mpConsole->getConsoleBgColor() : QColor(Qt::black)};
|
||||
s.append(qsl("<span style=\"color: rgb(200,150,0); background: %1; \">%2").arg(timeStampBgColor.name(), timeBuffer.at(row).left(mudlet::smTimeStampFormat.length())));
|
||||
// Set the current idea of what the formatting is so we can spot if it
|
||||
|
|
|
|||
|
|
@ -292,10 +292,10 @@ class TBuffer
|
|||
|
||||
static inline const int TCHAR_IN_BYTES = sizeof(TChar);
|
||||
|
||||
public:
|
||||
// limit on how many characters a single echo can accept for performance reasons
|
||||
static inline const int MAX_CHARACTERS_PER_ECHO = 1000000;
|
||||
|
||||
public:
|
||||
explicit TBuffer(Host* pH, TConsole* pConsole = nullptr);
|
||||
~TBuffer();
|
||||
TBuffer(const TBuffer& other);
|
||||
|
|
@ -312,6 +312,9 @@ public:
|
|||
int size() { return static_cast<int>(buffer.size()); }
|
||||
bool isEmpty() const { return buffer.size() == 0; }
|
||||
QString& line(int lineNumber);
|
||||
// Colors of the current trigger-pass line as committed, before any
|
||||
// trigger ran; nullptr when lineNumber is not the line being processed:
|
||||
const std::deque<TChar>* preTriggerPassLine(int lineNumber) const;
|
||||
int find(int line, const QString& what, int pos);
|
||||
QStringList split(int line, const QString& splitter);
|
||||
QStringList split(int line, const QRegularExpression& splitter);
|
||||
|
|
@ -391,6 +394,7 @@ public:
|
|||
private:
|
||||
inline QList<WrapInfo> getWrapInfo(const QString& lineText, bool isNewline, const int maxWidth, const int indent, const int hangingIndent);
|
||||
void shrinkBuffer();
|
||||
void syncPreTriggerPassLine(int y);
|
||||
int calculateWrapPosition(int lineNumber, int begin, int end);
|
||||
void handleNewLine();
|
||||
void translateToPlainTextInner(std::string& incoming, bool isFromServer);
|
||||
|
|
@ -444,6 +448,9 @@ private:
|
|||
// First stage in decoding SGR/OCS sequences - set true when we see the
|
||||
// ASCII ESC character:
|
||||
bool mGotESC = false;
|
||||
// Set between the ESC '(', ')', '*' or '+' of an ISO 2022 character set
|
||||
// designation and the byte that names the set:
|
||||
bool mGotEscCharset = false;
|
||||
// Second stage in decoding SGR sequences - set true when we see the ASCII
|
||||
// ESC character followed by the '[' one:
|
||||
bool mGotCSI = false;
|
||||
|
|
@ -503,6 +510,8 @@ private:
|
|||
|
||||
QString mMudLine;
|
||||
std::deque<TChar> mMudBuffer;
|
||||
std::deque<TChar> mPreTriggerPassLine;
|
||||
int mPreTriggerPassLineNumber = -1;
|
||||
// A line that ended at the game's own wrap column (Host::mUndoServerWrap)
|
||||
// is held here instead of being committed, so its continuation can be
|
||||
// joined back on and triggers run once over the whole logical line:
|
||||
|
|
@ -523,13 +532,14 @@ private:
|
|||
// translateToPlainText()}:
|
||||
std::string mIncompleteSequenceBytes;
|
||||
|
||||
// The parser sequence state (mGotESC, mGotCSI, mGotOSC, mGotString and
|
||||
// The parser sequence state (the mGot... latches and
|
||||
// mIncompleteSequenceBytes) for whichever of the two data channels - Game
|
||||
// Server stream or locally generated text - is not currently being
|
||||
// processed; translateToPlainText() swaps it in around a local feed so
|
||||
// that such text cannot consume or clear a latch belonging to a sequence
|
||||
// split across Game Server packets (and vice versa):
|
||||
bool mLocalGotESC = false;
|
||||
bool mLocalGotEscCharset = false;
|
||||
bool mLocalGotCSI = false;
|
||||
bool mLocalGotOSC = false;
|
||||
bool mLocalGotString = false;
|
||||
|
|
|
|||
|
|
@ -163,6 +163,20 @@ bool TCommandLine::keybindingMatched(QKeyEvent* keyEvent)
|
|||
return false;
|
||||
}
|
||||
|
||||
bool TCommandLine::keybindingWouldMatchProfileSwitchShortcut(const QKeyEvent* keyEvent) const
|
||||
{
|
||||
if (!mpKeyUnit || (mpHost && mpHost->isClosingDown())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto* pMudlet = mudlet::self();
|
||||
if (!pMudlet || !pMudlet->profileSwitchShortcutMatches(keyEvent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return mpKeyUnit->wouldMatch(static_cast<Qt::Key>(keyEvent->key()), keyEvent->modifiers());
|
||||
}
|
||||
|
||||
// This function overrides the QWidget::event() and should return true if the
|
||||
// event was recognized, otherwise it should return false. If the recognized
|
||||
// event was accepted (see QEvent::accepted), any further processing such as
|
||||
|
|
@ -186,6 +200,15 @@ bool TCommandLine::event(QEvent* event)
|
|||
ke->accept();
|
||||
return true;
|
||||
}
|
||||
|
||||
// QShortcutMap consumes a key matching one of the profile switching
|
||||
// shortcuts before the KeyPress ever reaches here, so a user binding on
|
||||
// one has to be spotted now - and only spotted, since the binding runs
|
||||
// off the KeyPress this claim lets through:
|
||||
if (keybindingWouldMatchProfileSwitchShortcut(ke)) {
|
||||
ke->accept();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const Qt::KeyboardModifiers allModifiers = Qt::ShiftModifier | Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier | Qt::KeypadModifier | Qt::GroupSwitchModifier;
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ private:
|
|||
void enterCommand(QKeyEvent*);
|
||||
void processNormalKey(QEvent*);
|
||||
bool keybindingMatched(QKeyEvent*);
|
||||
bool keybindingWouldMatchProfileSwitchShortcut(const QKeyEvent*) const;
|
||||
void spellCheckWord(QTextCursor& c);
|
||||
bool handleCtrlTabChange(QKeyEvent* key, int tabNumber);
|
||||
void restoreHistory();
|
||||
|
|
|
|||
|
|
@ -589,6 +589,10 @@ TConsole::TConsole(Host* pH, const QString& name, const ConsoleType type, QWidge
|
|||
mHScrollBarEnabled = true;
|
||||
}
|
||||
|
||||
// a Buffer is never displayed and the three types below start with their
|
||||
// scroll bar hidden, so only the main and debug consoles begin with one
|
||||
mScrollBarEnabled = !(mType & (ErrorConsole | SubConsole | UserWindow | Buffer));
|
||||
|
||||
if (mType & (ErrorConsole | SubConsole | UserWindow)) {
|
||||
mpScrollBar->hide();
|
||||
mLowerPane->hide();
|
||||
|
|
@ -743,6 +747,12 @@ void TConsole::resizeEvent(QResizeEvent* event)
|
|||
layerCommandLine->move(0, mpBaseVFrame->height() - layerCommandLine->height());
|
||||
}
|
||||
|
||||
// MXP frames are positioned by hand against the space the borders leave, so
|
||||
// they have to be moved whenever the window or those borders change
|
||||
if ((mType & MainConsole) && !mpHost.isNull()) {
|
||||
mpHost->mMxpFrameManager.scheduleRelayout();
|
||||
}
|
||||
|
||||
// Sync Host dimensions on resize so wraps and NAWS reflect the current pane width.
|
||||
if ((mType & MainConsole) && !mpHost.isNull() && mUpperPane && !mUpperPane->visibleRegion().isEmpty()) {
|
||||
const int paneWidthPx = mUpperPane->visibleRegion().boundingRect().width();
|
||||
|
|
@ -870,6 +880,9 @@ void TConsole::refresh()
|
|||
void TConsole::clear()
|
||||
{
|
||||
mUpperPane->resetHScrollbar();
|
||||
// before the buffer goes, or the selection is left pointing at lines that
|
||||
// no longer exist and the copy actions work on out of range indices
|
||||
clearSelection();
|
||||
buffer.clear();
|
||||
clearSplit();
|
||||
mUpperPane->update();
|
||||
|
|
@ -1584,11 +1597,26 @@ bool TConsole::setWindowBackgroundImage(const QString& imgPath, int mode)
|
|||
if (mode == 5) {
|
||||
QPixmap pixmap(imgPath);
|
||||
if (pixmap.isNull()) {
|
||||
qWarning().nospace().noquote() << "TConsole::setWindowBackgroundImage() ERROR - could not load \"" << imgPath << "\" as an image.";
|
||||
return false;
|
||||
}
|
||||
const QPixmap previousSource = mWindowBgSourcePixmap;
|
||||
const QString previousPath = mWindowBgImagePath;
|
||||
const QString previousStyleSheet = mpWindowBackground->styleSheet();
|
||||
mWindowBgSourcePixmap = pixmap;
|
||||
mWindowBgImagePath = imgPath;
|
||||
// clearing a stylesheet repolishes the widget and drops the palette brush,
|
||||
// so it has to happen before the brush is installed
|
||||
mpWindowBackground->setStyleSheet(QString());
|
||||
updateWindowBackgroundCoverPixmap();
|
||||
if (!updateWindowBackgroundCoverPixmap()) {
|
||||
mWindowBgSourcePixmap = previousSource;
|
||||
mWindowBgImagePath = previousPath;
|
||||
mpWindowBackground->setStyleSheet(previousStyleSheet);
|
||||
// the failed attempt dropped the brush, so rebuild the one the previous
|
||||
// source was showing rather than waiting for the next resize
|
||||
updateWindowBackgroundCoverPixmap();
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const QColor bgColor = mpHost ? mpHost->mBgColor : QColorConstants::Black;
|
||||
const QString styleSheet = buildBackgroundImageStyleSheet(qsl("WindowBackground"), bgColor, mode, imgPath);
|
||||
|
|
@ -1632,30 +1660,69 @@ void TConsole::updateMainFrameTransparency()
|
|||
QPalette framePalette;
|
||||
framePalette.setColor(QPalette::Text, QColor(Qt::black));
|
||||
framePalette.setColor(QPalette::Highlight, QColor(55, 55, 255));
|
||||
framePalette.setColor(QPalette::Window, mWindowBgImageMode ? QColor(0, 0, 0, 0) : QColor(0, 0, 0, 255));
|
||||
framePalette.setColor(QPalette::Window, mWindowBgImageMode ? QColor(0, 0, 0, 0) : mBorderColor);
|
||||
mpMainFrame->setPalette(framePalette);
|
||||
mpMainFrame->setAutoFillBackground(true);
|
||||
}
|
||||
|
||||
// Simulates CSS "cover" since QT stylesheets do not support it
|
||||
void TConsole::updateWindowBackgroundCoverPixmap()
|
||||
void TConsole::setBorderColor(const QColor& color)
|
||||
{
|
||||
mBorderColor = color;
|
||||
updateMainFrameTransparency();
|
||||
}
|
||||
|
||||
void TConsole::lowerMainDisplay()
|
||||
{
|
||||
mpMainDisplay->lower();
|
||||
if (mpWindowBackground) {
|
||||
mpWindowBackground->lower();
|
||||
}
|
||||
}
|
||||
|
||||
// The largest centred rectangle of the source that has the target's aspect ratio.
|
||||
QRect TConsole::coverSourceRect(const QSize& sourceSize, const QSize& targetSize)
|
||||
{
|
||||
QSize cropSize = targetSize;
|
||||
cropSize.scale(sourceSize, Qt::KeepAspectRatio);
|
||||
cropSize = cropSize.boundedTo(sourceSize).expandedTo(QSize(1, 1));
|
||||
return QRect(QPoint((sourceSize.width() - cropSize.width()) / 2, (sourceSize.height() - cropSize.height()) / 2), cropSize);
|
||||
}
|
||||
|
||||
// Simulates CSS "cover" since QT stylesheets do not support it. Crop first: the
|
||||
// other order multiplies the intermediate by the aspect mismatch, so a 3000x100
|
||||
// image in a 1920x1080 window builds a 32400x1080 (~140MB) one on every resize.
|
||||
bool TConsole::updateWindowBackgroundCoverPixmap()
|
||||
{
|
||||
if (!mpWindowBackground || mWindowBgSourcePixmap.isNull()) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const QSize targetSize = mpWindowBackground->size();
|
||||
if (targetSize.isEmpty()) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const QPixmap scaled = mWindowBgSourcePixmap.scaled(targetSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation);
|
||||
const QRect cropRect(qMax(0, (scaled.width() - targetSize.width()) / 2), qMax(0, (scaled.height() - targetSize.height()) / 2), targetSize.width(), targetSize.height());
|
||||
const QRect sourceRect = coverSourceRect(mWindowBgSourcePixmap.size(), targetSize);
|
||||
const QPixmap cropped = (sourceRect == mWindowBgSourcePixmap.rect()) ? mWindowBgSourcePixmap : mWindowBgSourcePixmap.copy(sourceRect);
|
||||
const QPixmap scaled = cropped.scaled(targetSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
|
||||
if (scaled.isNull()) {
|
||||
if (!mWindowBgCoverScaleFailed) {
|
||||
mWindowBgCoverScaleFailed = true;
|
||||
qWarning().nospace().noquote() << "TConsole::updateWindowBackgroundCoverPixmap() ERROR - could not scale \"" << mWindowBgImagePath << "\" (source area " << sourceRect << ") to "
|
||||
<< targetSize << '.';
|
||||
}
|
||||
// a brush smaller than the widget tiles, so drop the stale one
|
||||
mpWindowBackground->setAutoFillBackground(false);
|
||||
mpWindowBackground->setPalette(QPalette());
|
||||
return false;
|
||||
}
|
||||
mWindowBgCoverScaleFailed = false;
|
||||
|
||||
QPalette palette;
|
||||
palette.setBrush(QPalette::Window, QBrush(scaled.copy(cropRect)));
|
||||
palette.setBrush(QPalette::Window, QBrush(scaled));
|
||||
mpWindowBackground->setPalette(palette);
|
||||
mpWindowBackground->setAutoFillBackground(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
void TConsole::setCmdVisible(bool isVisible)
|
||||
|
|
@ -1675,7 +1742,7 @@ void TConsole::setCmdVisible(bool isVisible)
|
|||
mpCommandLine->setFont(font());
|
||||
// put this CommandLine in the mainConsoles SubCommandLineMap
|
||||
// name is the console name
|
||||
mpHost->mpConsole->mSubCommandLineMap[mConsoleName] = mpCommandLine;
|
||||
mpHost->mpConsole->registerSubCommandLine(mConsoleName, mpCommandLine);
|
||||
layoutLayer2->addWidget(mpCommandLine);
|
||||
}
|
||||
if (mType == MainConsole) {
|
||||
|
|
@ -1948,10 +2015,20 @@ void TConsole::setCommandFgColor(const QColor& newColor)
|
|||
void TConsole::setScrollBarVisible(bool isVisible)
|
||||
{
|
||||
if (mpScrollBar) {
|
||||
mScrollBarEnabled = isVisible;
|
||||
mpScrollBar->setVisible(isVisible);
|
||||
}
|
||||
}
|
||||
|
||||
// Reports what enableScrollBar()/disableScrollBar() last asked for rather than
|
||||
// QWidget::isVisible(): a profile that is not the front tab has its whole
|
||||
// console hidden, which would otherwise make every background profile report
|
||||
// its scroll bar as gone.
|
||||
bool TConsole::getScrollBarVisible() const
|
||||
{
|
||||
return mScrollBarEnabled;
|
||||
}
|
||||
|
||||
void TConsole::setHorizontalScrollBar(bool isEnabled)
|
||||
{
|
||||
if (mpHScrollBar) {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ struct TFontAttributes
|
|||
bool operator==(const TFontAttributes& other) const = default;
|
||||
bool operator!=(const TFontAttributes& other) const = default;
|
||||
|
||||
TFontAttributes(const TFontAttributes& other) = default;
|
||||
TFontAttributes& operator=(const TFontAttributes& other) = default;
|
||||
|
||||
QFont makeFont() const
|
||||
|
|
@ -251,6 +252,7 @@ public:
|
|||
void setCommandFgColor(const QColor&);
|
||||
void setCommandFgColor(int, int, int, int);
|
||||
void setScrollBarVisible(bool);
|
||||
bool getScrollBarVisible() const;
|
||||
void setHorizontalScrollBar(bool);
|
||||
void setScrolling(const bool state);
|
||||
bool getScrolling() const { return mScrollingEnabled; }
|
||||
|
|
@ -294,7 +296,12 @@ public:
|
|||
bool setWindowBackgroundImage(const QString&, int);
|
||||
bool resetWindowBackgroundImage();
|
||||
void updateMainFrameTransparency();
|
||||
void updateWindowBackgroundCoverPixmap();
|
||||
// False only when a scale failed; no source or an unsized widget defers to the next resize
|
||||
bool updateWindowBackgroundCoverPixmap();
|
||||
static QRect coverSourceRect(const QSize& sourceSize, const QSize& targetSize);
|
||||
void setBorderColor(const QColor&);
|
||||
QColor borderColor() const { return mBorderColor; }
|
||||
void lowerMainDisplay();
|
||||
void setLink(const QStringList& linkFunction, const QStringList& linkHint, const QVector<int> linkReference = QVector<int>());
|
||||
// Cannot be called setAttributes as that would mask an inherited method
|
||||
void setDisplayAttributes(const TChar::AttributeFlags, const bool);
|
||||
|
|
@ -431,6 +438,7 @@ public:
|
|||
QString mWindowBgImagePath;
|
||||
QPixmap mWindowBgSourcePixmap;
|
||||
bool mHScrollBarEnabled = false;
|
||||
bool mScrollBarEnabled = true;
|
||||
ControlCharacterMode mControlCharacter = ControlCharacterMode::AsIs;
|
||||
QVideoWidget* mpVideoWidget = nullptr;
|
||||
QSplitter* commandSplitter = nullptr;
|
||||
|
|
@ -495,6 +503,10 @@ private:
|
|||
// Whether to show (a 13 character by default) timestamp to the left of
|
||||
// each line of text:
|
||||
bool mShowTimeStamps = false;
|
||||
// mpMainFrame's palette cannot hold this - it is rebuilt from scratch on every colour change
|
||||
QColor mBorderColor = Qt::black;
|
||||
// latches the 'cover' scale failure so a resize drag does not repeat the warning
|
||||
bool mWindowBgCoverScaleFailed = false;
|
||||
};
|
||||
|
||||
Q_DECLARE_OPERATORS_FOR_FLAGS(TConsole::ConsoleType)
|
||||
|
|
|
|||
|
|
@ -126,9 +126,9 @@ TDetachedWindow::~TDetachedWindow()
|
|||
if (auto pHost = mudletInstance->getHostManager().getHost(profileName)) {
|
||||
auto pMap = pHost->mpMap.data();
|
||||
|
||||
if (pMap && pHost->mpDockableMapWidget) {
|
||||
if (pMap && pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) {
|
||||
// Find the main window's mapper
|
||||
auto mainMapWidget = pHost->mpDockableMapWidget->widget();
|
||||
auto mainMapWidget = pHost->mpConsole->mpDockableMapWidget->widget();
|
||||
|
||||
if (auto mainMapper = qobject_cast<dlgMapper*>(mainMapWidget)) {
|
||||
pMap->mpMapper = mainMapper;
|
||||
|
|
@ -151,9 +151,9 @@ TDetachedWindow::~TDetachedWindow()
|
|||
if (auto pHost = mudletInstance->getHostManager().getHost(mCurrentProfileName)) {
|
||||
auto pMap = pHost->mpMap.data();
|
||||
|
||||
if (pMap && pHost->mpDockableMapWidget) {
|
||||
if (pMap && pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) {
|
||||
// Find the main window's mapper
|
||||
auto mainMapWidget = pHost->mpDockableMapWidget->widget();
|
||||
auto mainMapWidget = pHost->mpConsole->mpDockableMapWidget->widget();
|
||||
|
||||
if (auto mainMapper = qobject_cast<dlgMapper*>(mainMapWidget)) {
|
||||
pMap->mpMapper = mainMapper;
|
||||
|
|
@ -1561,8 +1561,8 @@ void TDetachedWindow::updateDockWidgetVisibilityForProfile(const QString& profil
|
|||
if (auto pMudlet = mudlet::self()) {
|
||||
if (auto pHost = pMudlet->getHostManager().getHost(dockProfileName)) {
|
||||
if (auto pMap = pHost->mpMap.data()) {
|
||||
if (pHost->mpDockableMapWidget) {
|
||||
auto mainMapWidget = pHost->mpDockableMapWidget->widget();
|
||||
if (pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) {
|
||||
auto mainMapWidget = pHost->mpConsole->mpDockableMapWidget->widget();
|
||||
|
||||
if (auto mainMapper = qobject_cast<dlgMapper*>(mainMapWidget)) {
|
||||
pMap->mpMapper = mainMapper;
|
||||
|
|
@ -2054,8 +2054,8 @@ bool TDetachedWindow::removeProfile(const QString& profileName)
|
|||
if (auto pMudlet = mudlet::self()) {
|
||||
if (auto pHost = pMudlet->getHostManager().getHost(profileName)) {
|
||||
if (auto pMap = pHost->mpMap.data()) {
|
||||
if (pHost->mpDockableMapWidget) {
|
||||
auto mainMapWidget = pHost->mpDockableMapWidget->widget();
|
||||
if (pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) {
|
||||
auto mainMapWidget = pHost->mpConsole->mpDockableMapWidget->widget();
|
||||
|
||||
if (auto mainMapper = qobject_cast<dlgMapper*>(mainMapWidget)) {
|
||||
pMap->mpMapper = mainMapper;
|
||||
|
|
@ -2785,8 +2785,8 @@ void TDetachedWindow::slot_showMapperDialog()
|
|||
mpMapDockWidget = nullptr;
|
||||
|
||||
// Restore the main window's mapper as the active one
|
||||
if (pHost->mpDockableMapWidget) {
|
||||
auto mainMapWidget = pHost->mpDockableMapWidget->widget();
|
||||
if (pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) {
|
||||
auto mainMapWidget = pHost->mpConsole->mpDockableMapWidget->widget();
|
||||
if (auto mainMapper = qobject_cast<dlgMapper*>(mainMapWidget)) {
|
||||
pMap->mpMapper = mainMapper;
|
||||
}
|
||||
|
|
@ -2802,7 +2802,7 @@ void TDetachedWindow::slot_showMapperDialog()
|
|||
|
||||
// Store the main window's mapper temporarily so we can restore it later
|
||||
QPointer<dlgMapper> mainMapper = pMap->mpMapper;
|
||||
QPointer<QDockWidget> mainDockWidget = pHost->mpDockableMapWidget;
|
||||
QPointer<QDockWidget> mainDockWidget = (pHost->mpConsole ? pHost->mpConsole->mpDockableMapWidget : nullptr);
|
||||
|
||||
// Create a new mapper instance for the detached window
|
||||
// We need to copy player room style details first
|
||||
|
|
@ -2881,8 +2881,8 @@ void TDetachedWindow::slot_showMapperDialog()
|
|||
}
|
||||
|
||||
// Restore the main window's mapper as the active one when hiding
|
||||
if (pHost->mpDockableMapWidget) {
|
||||
auto mainMapWidget = pHost->mpDockableMapWidget->widget();
|
||||
if (pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) {
|
||||
auto mainMapWidget = pHost->mpConsole->mpDockableMapWidget->widget();
|
||||
if (auto mainMapper = qobject_cast<dlgMapper*>(mainMapWidget)) {
|
||||
pMap->mpMapper = mainMapper;
|
||||
}
|
||||
|
|
@ -3251,8 +3251,8 @@ void TDetachedWindow::addTransferredDockWidget(const QString& mapKey, QDockWidge
|
|||
}
|
||||
|
||||
// Restore the main window's mapper as the active one when hiding
|
||||
if (pHost->mpDockableMapWidget) {
|
||||
auto mainMapWidget = pHost->mpDockableMapWidget->widget();
|
||||
if (pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) {
|
||||
auto mainMapWidget = pHost->mpConsole->mpDockableMapWidget->widget();
|
||||
|
||||
if (auto mainMapper = qobject_cast<dlgMapper*>(mainMapWidget)) {
|
||||
pMap->mpMapper = mainMapper;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ TDockWidget::TDockWidget(Host* pH, const QString& consoleName)
|
|||
, mWidgetConsoleName(consoleName)
|
||||
, mpHost(pH)
|
||||
{
|
||||
setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable);
|
||||
}
|
||||
|
||||
// This sets the mutual pointers that the TConsole and the TDockWidget now
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
#include "TFlipButton.h"
|
||||
|
||||
#include <QGridLayout>
|
||||
#include <QScopeGuard>
|
||||
|
||||
|
||||
TEasyButtonBar::TEasyButtonBar(TAction* pA, QString name, QWidget* pW)
|
||||
|
|
@ -50,11 +51,9 @@ TEasyButtonBar::TEasyButtonBar(TAction* pA, QString name, QWidget* pW)
|
|||
const QSizePolicy sizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
|
||||
mpWidget->setSizePolicy(sizePolicy);
|
||||
} else {
|
||||
mpWidget->setMinimumHeight(mpTAction->mSizeY);
|
||||
mpWidget->setMaximumHeight(mpTAction->mSizeY);
|
||||
mpWidget->setMinimumWidth(mpTAction->mSizeX);
|
||||
mpWidget->setMaximumWidth(mpTAction->mSizeX);
|
||||
mpWidget->setGeometry(mpTAction->mPosX, mpTAction->mPosY, mpTAction->mSizeX, mpTAction->mSizeY);
|
||||
mpWidget->setMaximumSize(mpTAction->getSize());
|
||||
mpWidget->setMinimumSize(mpTAction->getSize());
|
||||
mpWidget->setGeometry(mpTAction->mPosX, mpTAction->mPosY, mpTAction->getSizeX(), mpTAction->getSizeY());
|
||||
}
|
||||
setStyleSheet(mpTAction->css);
|
||||
mpWidget->setStyleSheet(mpTAction->css);
|
||||
|
|
@ -74,11 +73,11 @@ void TEasyButtonBar::addButton(TFlipButton* pB)
|
|||
}
|
||||
} else {
|
||||
qDebug() << "setting up custom sizes";
|
||||
const QSize size = QSize(pB->mpTAction->mSizeX, pB->mpTAction->mSizeY);
|
||||
const QSize size = pB->mpTAction->getSize();
|
||||
pB->setMaximumSize(size);
|
||||
pB->setMinimumSize(size);
|
||||
pB->setParent(mpWidget);
|
||||
pB->setGeometry(pB->mpTAction->mPosX, pB->mpTAction->mPosY, pB->mpTAction->mSizeX, pB->mpTAction->mSizeY);
|
||||
pB->setGeometry(pB->mpTAction->mPosX, pB->mpTAction->mPosY, pB->mpTAction->getSizeX(), pB->mpTAction->getSizeY());
|
||||
}
|
||||
|
||||
pB->setStyleSheet(pB->mpTAction->css);
|
||||
|
|
@ -100,12 +99,8 @@ void TEasyButtonBar::addButton(TFlipButton* pB)
|
|||
if (!mpTAction->mUseCustomLayout) {
|
||||
// tool bar mButtonColumns > 0 -> autolayout
|
||||
// case == 0: use individual button placement for user defined layouts
|
||||
int columns = mpTAction->getButtonColumns();
|
||||
if (columns <= 0) {
|
||||
columns = 1;
|
||||
}
|
||||
mItemCount++;
|
||||
const int row = mItemCount / columns;
|
||||
int columns = std::max(1, mpTAction->getButtonColumns());
|
||||
const int row = ++mItemCount / columns;
|
||||
const int col = mItemCount % columns;
|
||||
if (mVerticalOrientation) {
|
||||
mpLayout->addWidget(pB, row, col);
|
||||
|
|
@ -127,21 +122,23 @@ void TEasyButtonBar::addButton(TFlipButton* pB)
|
|||
|
||||
void TEasyButtonBar::finalize()
|
||||
{
|
||||
if (mpTAction->mUseCustomLayout) {
|
||||
if (mpTAction->mUseCustomLayout || !mpTAction->getButtonFillerOffset()) {
|
||||
return;
|
||||
}
|
||||
auto fillerWidget = new QWidget;
|
||||
|
||||
const QSizePolicy sizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
fillerWidget->setSizePolicy(sizePolicy);
|
||||
int columns = mpTAction->getButtonColumns();
|
||||
if (columns <= 0) {
|
||||
columns = 1;
|
||||
}
|
||||
const int row = (++mItemCount) / columns;
|
||||
const int column = mItemCount % columns;
|
||||
auto fillerWidget = new QWidget(this);
|
||||
QPushButton dummy;
|
||||
fillerWidget->setMinimumSize(dummy.minimumSizeHint());
|
||||
fillerWidget->setMaximumSize(dummy.minimumSizeHint());
|
||||
if (mpLayout) {
|
||||
mpLayout->addWidget(fillerWidget, row, column);
|
||||
if (mpTAction->mOrientation == 1) {
|
||||
// The toolbar is to be filled with rows of mpTAction->getButtonColumns() wide
|
||||
// The filler widget is to be one or more columns wide
|
||||
mpLayout->addWidget(fillerWidget, 0, 0, mpTAction->getButtonFillerOffset(), 1);
|
||||
} else {
|
||||
// The toolbar is to be filled with columns of mpTAction->getButtonColumns() tall
|
||||
// The filler widget is to be one or more rows tall
|
||||
mpLayout->addWidget(fillerWidget, 0, 0, 1, mpTAction->getButtonFillerOffset());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +153,19 @@ void TEasyButtonBar::slot_pressed(const bool isChecked)
|
|||
|
||||
TAction* pA = pB->mpTAction;
|
||||
|
||||
// Hold off ActionUnit deletes for this whole slot: showMenu() below blocks in
|
||||
// a modal event loop in which a menu item's script (or inbound game data) can
|
||||
// uninstall pA's own package. beginProcessing() keeps that delete deferred -
|
||||
// even against a Host catch-all doCleanup() firing at depth 0 mid-loop - so pA
|
||||
// survives every dereference here; the scope guard then flushes once, after pA
|
||||
// is no longer touched (see ActionUnit::uninstall()):
|
||||
ActionUnit* pActionUnit = pA->mpHost->getActionUnit();
|
||||
pActionUnit->beginProcessing();
|
||||
const auto processingGuard = qScopeGuard([pActionUnit] {
|
||||
pActionUnit->endProcessing();
|
||||
pActionUnit->doCleanup();
|
||||
});
|
||||
|
||||
// NOTE: This function blocks until an item is selected from the menu, and,
|
||||
// as the action to "pop-up" the menu is the same as "buttons" use to
|
||||
// perform their command/scripts is why "commands" are (no longer) permitted
|
||||
|
|
@ -164,7 +174,7 @@ void TEasyButtonBar::slot_pressed(const bool isChecked)
|
|||
// entries...
|
||||
pB->showMenu();
|
||||
|
||||
if (pA->mIsPushDownButton) {
|
||||
if (pA->isPushDownButton()) {
|
||||
// DO NOT MANIPULATE THE BUTTON STATE OURSELF NOW
|
||||
pA->mButtonState = isChecked;
|
||||
pA->mpHost->mpConsole->mButtonState = (pA->mButtonState ? 2 : 1);
|
||||
|
|
@ -202,11 +212,9 @@ void TEasyButtonBar::clear()
|
|||
mpWidget->setContentsMargins(0, 0, 0, 0);
|
||||
} else {
|
||||
mpLayout = nullptr;
|
||||
mpWidget->setMinimumHeight(mpTAction->mSizeY);
|
||||
mpWidget->setMaximumHeight(mpTAction->mSizeY);
|
||||
mpWidget->setMinimumWidth(mpTAction->mSizeX);
|
||||
mpWidget->setMaximumWidth(mpTAction->mSizeX);
|
||||
mpWidget->setGeometry(mpTAction->mPosX, mpTAction->mPosY, mpTAction->mSizeX, mpTAction->mSizeY);
|
||||
mpWidget->setMinimumSize(mpTAction->getSize());
|
||||
mpWidget->setMaximumSize(mpTAction->getSize());
|
||||
mpWidget->setGeometry(mpTAction->mPosX, mpTAction->mPosY, mpTAction->getSizeX(), mpTAction->getSizeY());
|
||||
}
|
||||
layout()->addWidget(pW);
|
||||
setStyleSheet(mpTAction->css);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ public:
|
|||
Q_DISABLE_COPY(TEasyButtonBar)
|
||||
TEasyButtonBar(TAction*, QString, QWidget* pW = nullptr);
|
||||
void addButton(TFlipButton* pW);
|
||||
void resetItemCount(const int initialOffset) { mItemCount = initialOffset; }
|
||||
void setVerticalOrientation() { mVerticalOrientation = true; }
|
||||
void setHorizontalOrientation() { mVerticalOrientation = false; }
|
||||
void clear();
|
||||
|
|
|
|||
|
|
@ -53,7 +53,11 @@ protected:
|
|||
private:
|
||||
int mID = 0;
|
||||
QPointer<Host> mpHost;
|
||||
// This and mMirrored are derived from TAction::mButtonRotation, NOT
|
||||
// TAction::mOrientation!
|
||||
Qt::Orientation mOrientation = Qt::Horizontal;
|
||||
// This and mOrientation are derived from TAction::mButtonRotation, NOT
|
||||
// TAction::mOrientation!
|
||||
bool mMirrored = false;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -37,31 +37,14 @@ TForkedProcess::~TForkedProcess()
|
|||
}
|
||||
|
||||
|
||||
TForkedProcess::TForkedProcess(TLuaInterpreter* pInterpreter, lua_State* L)
|
||||
// Raises nothing: a lua_error() here would longjmp out of the constructor and
|
||||
// strand both this QProcess and every argument the caller still holds, so
|
||||
// checking the arguments and reporting a failed start are startProcess()'s job
|
||||
TForkedProcess::TForkedProcess(TLuaInterpreter* pInterpreter, const QString& program, const QStringList& arguments, const int callBackReference)
|
||||
: QProcess()
|
||||
, callBackFunctionRef(callBackReference)
|
||||
, mpInterpreter(pInterpreter)
|
||||
{
|
||||
int n = lua_gettop(L);
|
||||
if (n < 2) {
|
||||
lua_pushstring(L, "Need read function and process name as parameters.");
|
||||
lua_error(L);
|
||||
}
|
||||
|
||||
if (!lua_isfunction(L, 1)) {
|
||||
lua_pushstring(L, "Need read function as first parameter.");
|
||||
lua_error(L);
|
||||
}
|
||||
|
||||
lua_pushvalue(L, 1);
|
||||
callBackFunctionRef = luaL_ref(L, LUA_REGISTRYINDEX);
|
||||
|
||||
|
||||
QString prog{luaL_checkstring(L, 2)};
|
||||
QStringList args;
|
||||
for (int i = 3; i <= n; i++) {
|
||||
args << luaL_checkstring(L, i);
|
||||
}
|
||||
|
||||
// QProcess::finished is overloaded so we have to say which form we are
|
||||
// connecting here
|
||||
connect(this, qOverload<int, QProcess::ExitStatus>(&QProcess::finished), mpInterpreter, &TLuaInterpreter::slot_deleteSender);
|
||||
|
|
@ -69,14 +52,8 @@ TForkedProcess::TForkedProcess(TLuaInterpreter* pInterpreter, lua_State* L)
|
|||
connect(this, &QProcess::readyReadStandardOutput, this, &TForkedProcess::slot_receivedData);
|
||||
|
||||
setProcessChannelMode(QProcess::MergedChannels);
|
||||
start(prog, args, QIODevice::ReadWrite);
|
||||
if (!waitForStarted()) {
|
||||
const QString errorMessage = qsl("Failed to start process '%1': %2. Working directory: '%3'. PATH: '%4'").arg(prog, errorString(), QDir::currentPath(), qEnvironmentVariable("PATH"));
|
||||
lua_pushstring(L, errorMessage.toUtf8().constData());
|
||||
lua_error(L);
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
start(program, arguments, QIODevice::ReadWrite);
|
||||
running = waitForStarted();
|
||||
}
|
||||
|
||||
void TForkedProcess::slot_finished(int exitCode, QProcess::ExitStatus exitStatus)
|
||||
|
|
@ -156,7 +133,47 @@ static int qPointerGC(lua_State* L)
|
|||
|
||||
int TForkedProcess::startProcess(TLuaInterpreter* pInterpreter, lua_State* L)
|
||||
{
|
||||
auto process = new TForkedProcess(pInterpreter, L);
|
||||
const int n = lua_gettop(L);
|
||||
if (n < 2) {
|
||||
lua_pushstring(L, "Need read function and process name as parameters.");
|
||||
return lua_error(L);
|
||||
}
|
||||
if (!lua_isfunction(L, 1)) {
|
||||
lua_pushstring(L, "Need read function as first parameter.");
|
||||
return lua_error(L);
|
||||
}
|
||||
for (int i = 2; i <= n; ++i) {
|
||||
// the same raise these used to make from inside the constructor, but
|
||||
// while nothing of ours is alive for the longjmp to strand
|
||||
static_cast<void>(luaL_checkstring(L, i));
|
||||
}
|
||||
|
||||
TForkedProcess* process = nullptr;
|
||||
{
|
||||
const QString program{lua_tostring(L, 2)};
|
||||
QStringList arguments;
|
||||
for (int i = 3; i <= n; ++i) {
|
||||
arguments << lua_tostring(L, i);
|
||||
}
|
||||
|
||||
lua_pushvalue(L, 1);
|
||||
const int callBackReference = luaL_ref(L, LUA_REGISTRYINDEX);
|
||||
process = new TForkedProcess(pInterpreter, program, arguments, callBackReference);
|
||||
if (!process->running) {
|
||||
lua_pushstring(L,
|
||||
qsl("Failed to start process '%1': %2. Working directory: '%3'. PATH: '%4'")
|
||||
.arg(program, process->errorString(), QDir::currentPath(), qEnvironmentVariable("PATH"))
|
||||
.toUtf8()
|
||||
.constData());
|
||||
// the destructor releases the callback reference
|
||||
delete process;
|
||||
process = nullptr;
|
||||
}
|
||||
}
|
||||
if (!process) {
|
||||
// raised out here so program, arguments and the message are all gone
|
||||
return lua_error(L);
|
||||
}
|
||||
|
||||
// The userdata for the closures.
|
||||
auto** luaMemory = (QPointer<TForkedProcess>**)lua_newuserdata(L, sizeof(QPointer<TForkedProcess>*));
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ private:
|
|||
static int isProcessRunning(lua_State* L);
|
||||
static int sendMessage(lua_State* L);
|
||||
|
||||
TForkedProcess(TLuaInterpreter*, lua_State*);
|
||||
TForkedProcess(TLuaInterpreter*, const QString& program, const QStringList& arguments, const int callBackReference);
|
||||
|
||||
int callBackFunctionRef = -1;
|
||||
TLuaInterpreter* mpInterpreter = nullptr;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
***************************************************************************/
|
||||
|
||||
#include "THyperlinkSelectionManager.h"
|
||||
#include "LuaLiteral.h"
|
||||
#include "TConsole.h"
|
||||
|
||||
#include <QUrl>
|
||||
|
|
@ -83,61 +84,41 @@ void THyperlinkSelectionManager::clearAllSelections()
|
|||
|
||||
QString THyperlinkSelectionManager::addSelectedParameter(const QString& command, bool isSelected) const
|
||||
{
|
||||
QUrl url(command);
|
||||
QUrlQuery query(url);
|
||||
// Split on '?' by hand rather than parsing the whole thing as a QUrl. This
|
||||
// is a game command, not a URL: QUrl::path() would drop everything after a
|
||||
// '#' (an ordinary character in a MUD command) and would percent-decode a
|
||||
// second time, since the payload was already decoded once when the URI was
|
||||
// parsed. Only the query portion is ours to rewrite.
|
||||
const int queryStart = command.indexOf(QLatin1Char('?'));
|
||||
const QString base = queryStart >= 0 ? command.left(queryStart) : command;
|
||||
|
||||
QUrlQuery query(queryStart >= 0 ? command.mid(queryStart + 1) : QString());
|
||||
query.removeQueryItem(qsl("selected"));
|
||||
query.addQueryItem(qsl("selected"), isSelected ? qsl("true") : qsl("false"));
|
||||
|
||||
QString cleanCommand = url.path();
|
||||
if (!query.isEmpty()) {
|
||||
cleanCommand += qsl("?") + query.query(QUrl::FullyEncoded);
|
||||
}
|
||||
return cleanCommand;
|
||||
return base + QLatin1Char('?') + query.query(QUrl::FullyEncoded);
|
||||
}
|
||||
|
||||
QString THyperlinkSelectionManager::modifyUriForSelection(const QString& baseUri, const QString& group, const QString& value) const
|
||||
QString THyperlinkSelectionManager::modifyUriForSelection(Mudlet::HyperlinkStyling::ActionScheme scheme, const QString& baseCommand, const QString& group, const QString& value) const
|
||||
{
|
||||
// Query the current selection state from our internal state
|
||||
bool isSelected = this->isSelected(group, value);
|
||||
const bool isSelected = this->isSelected(group, value);
|
||||
const QString command = addSelectedParameter(baseCommand, isSelected);
|
||||
|
||||
#if defined(DEBUG_OSC_PROCESSING)
|
||||
qDebug() << "modifyUriForSelection called with baseUri:" << baseUri << "group:" << group << "value:" << value << "isSelected:" << isSelected;
|
||||
qDebug() << "modifyUriForSelection called with scheme:" << scheme << "baseCommand:" << baseCommand << "group:" << group << "value:" << value << "isSelected:" << isSelected;
|
||||
#endif
|
||||
|
||||
// Check if it's a send() or sendCmdLine() call
|
||||
const QString sendPrefix = qsl("send([[");
|
||||
const QString sendSuffix = qsl("]])");
|
||||
const QString sendCmdLinePrefix = qsl("sendCmdLine([[");
|
||||
const QString sendCmdLineSuffix = qsl("]])");
|
||||
|
||||
if (baseUri.startsWith(sendPrefix) && baseUri.endsWith(sendSuffix)) {
|
||||
const int prefixLength = sendPrefix.length();
|
||||
const int suffixLength = sendSuffix.length();
|
||||
QString command = baseUri.mid(prefixLength, baseUri.length() - prefixLength - suffixLength);
|
||||
QString cleanCommand = addSelectedParameter(command, isSelected);
|
||||
QString result = qsl("send([[%1]], false)").arg(cleanCommand);
|
||||
#if defined(DEBUG_OSC_PROCESSING)
|
||||
qDebug() << "Modified to:" << result;
|
||||
#endif
|
||||
return result;
|
||||
}
|
||||
if (baseUri.startsWith(sendCmdLinePrefix) && baseUri.endsWith(sendCmdLineSuffix)) {
|
||||
const int prefixLength = sendCmdLinePrefix.length();
|
||||
const int suffixLength = sendCmdLineSuffix.length();
|
||||
QString command = baseUri.mid(prefixLength, baseUri.length() - prefixLength - suffixLength);
|
||||
QString cleanCommand = addSelectedParameter(command, isSelected);
|
||||
QString result = qsl("sendCmdLine([[%1]])").arg(cleanCommand);
|
||||
#if defined(DEBUG_OSC_PROCESSING)
|
||||
qDebug() << "Modified to:" << result;
|
||||
#endif
|
||||
return result;
|
||||
switch (scheme) {
|
||||
case Mudlet::HyperlinkStyling::ActionSend:
|
||||
return qsl("send(%1, false)").arg(LuaLiteral::quote(command));
|
||||
case Mudlet::HyperlinkStyling::ActionPrompt:
|
||||
return qsl("sendCmdLine(%1)").arg(LuaLiteral::quote(command));
|
||||
case Mudlet::HyperlinkStyling::ActionOpenUrl:
|
||||
case Mudlet::HyperlinkStyling::ActionNone:
|
||||
break;
|
||||
}
|
||||
|
||||
// For other URI formats (like openUrl), return as-is
|
||||
#if defined(DEBUG_OSC_PROCESSING)
|
||||
qDebug() << "No modification - returning as-is";
|
||||
#endif
|
||||
return baseUri;
|
||||
return QString();
|
||||
}
|
||||
|
||||
void THyperlinkSelectionManager::registerGroupMember(const QString& group, const QString& value)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@
|
|||
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
||||
***************************************************************************/
|
||||
|
||||
#include "THyperlinkStyling.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QSet>
|
||||
|
|
@ -50,8 +52,11 @@ public:
|
|||
void setGroupExclusive(const QString& group, bool exclusive);
|
||||
bool isGroupExclusive(const QString& group) const;
|
||||
|
||||
// Modifies hyperlink URI to include current selection state
|
||||
QString modifyUriForSelection(const QString& baseUri, const QString& group, const QString& value) const;
|
||||
// Builds the Lua call for a link whose selection state just changed, with
|
||||
// the group's current state appended to the command as &selected=. Returns
|
||||
// an empty string for schemes that have no selection-aware form (openUrl,
|
||||
// and links with no parsed action) - callers keep the command they have.
|
||||
QString modifyUriForSelection(Mudlet::HyperlinkStyling::ActionScheme scheme, const QString& baseCommand, const QString& group, const QString& value) const;
|
||||
|
||||
signals:
|
||||
void selectionChanged(const QString& group, const QString& value, bool selected);
|
||||
|
|
|
|||
|
|
@ -71,6 +71,13 @@ struct HyperlinkStyling {
|
|||
StateDisabled // Disabled state (from selection object)
|
||||
};
|
||||
|
||||
// The link's primary action as parsed from the URI, kept alongside the
|
||||
// generated Lua so selection callbacks can rebuild the call without
|
||||
// parsing Lua source back apart.
|
||||
enum ActionScheme { ActionNone, ActionSend, ActionPrompt, ActionOpenUrl };
|
||||
ActionScheme actionScheme = ActionNone;
|
||||
QString baseCommand;
|
||||
|
||||
// State-specific styling containers
|
||||
struct StateStyle {
|
||||
QColor foregroundColor;
|
||||
|
|
|
|||
32
src/TKey.cpp
32
src/TKey.cpp
|
|
@ -103,6 +103,27 @@ bool TKey::match(const Qt::Key key, const Qt::KeyboardModifiers modifier, const
|
|||
}
|
||||
|
||||
|
||||
bool TKey::wouldMatch(const Qt::Key key, const Qt::KeyboardModifiers modifier) const
|
||||
{
|
||||
// Also covers the dereference below - isActive() is false once mpMyChildrenList is gone
|
||||
if (!isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isFolder() && (mKeyCode == key) && (mKeyModifier == modifier)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (auto childKey : *mpMyChildrenList) {
|
||||
if (childKey->wouldMatch(key, modifier)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
bool TKey::registerKey()
|
||||
{
|
||||
if (!mpHost) {
|
||||
|
|
@ -164,6 +185,17 @@ void TKey::compile()
|
|||
|
||||
bool TKey::setScript(const QString& script)
|
||||
{
|
||||
// Switching from a registered anonymous Lua function (set up by tempKey with a
|
||||
// function argument) to a script string: release the old function from the Lua
|
||||
// registry and leave callback mode. Otherwise execute() keeps calling the stale
|
||||
// function so the new script never runs, and the registry entry leaks - the
|
||||
// destructor would take its mScript-based branch and delete the compiled function.
|
||||
if (mRegisteredAnonymousLuaFunction) {
|
||||
if (mpHost) {
|
||||
mpHost->mLuaInterpreter.delete_luafunction(this);
|
||||
}
|
||||
mRegisteredAnonymousLuaFunction = false;
|
||||
}
|
||||
mScript = script;
|
||||
mNeedsToBeCompiled = true;
|
||||
mOK_code = compileScript();
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ public:
|
|||
|
||||
|
||||
bool match(const Qt::Key, const Qt::KeyboardModifiers, const bool);
|
||||
// Query-only counterpart to match(), which executes what it matches
|
||||
bool wouldMatch(const Qt::Key, const Qt::KeyboardModifiers) const;
|
||||
bool registerKey();
|
||||
void validateKeyBinding();
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -370,6 +370,7 @@ public:
|
|||
static int getFontSize(lua_State*);
|
||||
static int openUserWindow(lua_State*);
|
||||
static int setUserWindowTitle(lua_State*);
|
||||
static int getUserWindowTitle(lua_State*);
|
||||
static int echoUserWindow(lua_State*);
|
||||
static int clearUserWindow(lua_State*);
|
||||
static int enableTimer(lua_State*);
|
||||
|
|
@ -395,6 +396,8 @@ public:
|
|||
static int selectCaptureGroup(lua_State*);
|
||||
static int tempLineTrigger(lua_State*);
|
||||
static int raiseEvent(lua_State*);
|
||||
static int waitForEvent(lua_State*);
|
||||
static int pumpEvents(lua_State*);
|
||||
static int deleteLine(lua_State*);
|
||||
static int copy(lua_State*);
|
||||
static int cut(lua_State*);
|
||||
|
|
@ -444,8 +447,8 @@ public:
|
|||
static int createMiniConsole(lua_State*);
|
||||
static int createScrollBox(lua_State*);
|
||||
static int createLabel(lua_State*);
|
||||
static int createLabelMainWindow(lua_State*, const QString& labelName);
|
||||
static int createLabelUserWindow(lua_State*, const QString& windowName, const QString& labelName);
|
||||
static int createLabelMainWindow(lua_State*, const char* labelName);
|
||||
static int createLabelUserWindow(lua_State*, const char* windowName, const char* labelName);
|
||||
static int deleteLabel(lua_State*);
|
||||
static int deleteMiniConsole(lua_State*);
|
||||
static int deleteCommandLine(lua_State*);
|
||||
|
|
@ -462,12 +465,14 @@ public:
|
|||
static int setTextEditTabMovesFocus(lua_State*);
|
||||
static int deleteScrollBox(lua_State*);
|
||||
static int setLabelToolTip(lua_State*);
|
||||
static int getLabelToolTip(lua_State*);
|
||||
static int setLabelCursor(lua_State*);
|
||||
static int setLabelCustomCursor(lua_State*);
|
||||
static int moveWindow(lua_State*);
|
||||
static int setWindow(lua_State*);
|
||||
static int openMapWidget(lua_State*);
|
||||
static int closeMapWidget(lua_State*);
|
||||
static int getMapWidgetGeometry(lua_State*);
|
||||
static int setTextFormat(lua_State*);
|
||||
static int setBackgroundImage(lua_State*);
|
||||
static int resetBackgroundImage(lua_State*);
|
||||
|
|
@ -484,6 +489,7 @@ public:
|
|||
static int setCmdLineAction(lua_State*);
|
||||
static int resetCmdLineAction(lua_State*);
|
||||
static int setCmdLineStyleSheet(lua_State*);
|
||||
static int getCmdLineStyleSheet(lua_State*);
|
||||
static int getImageSize(lua_State*);
|
||||
static int setLabelDoubleClickCallback(lua_State*);
|
||||
static int setLabelReleaseCallback(lua_State*);
|
||||
|
|
@ -493,6 +499,9 @@ public:
|
|||
static int setLabelOnLeave(lua_State*);
|
||||
static int getMainWindowSize(lua_State*);
|
||||
static int getUserWindowSize(lua_State*);
|
||||
static int getWindowGeometry(lua_State*);
|
||||
static int windowVisible(lua_State*);
|
||||
static int getLabelText(lua_State*);
|
||||
static int getMousePosition(lua_State*);
|
||||
static int setProfileIcon(lua_State*);
|
||||
static int resetProfileIcon(lua_State*);
|
||||
|
|
@ -555,6 +564,7 @@ public:
|
|||
static int getConsoleBufferSize(lua_State*);
|
||||
static int setConsoleBufferSize(lua_State*);
|
||||
static int enableScrollBar(lua_State*);
|
||||
static int getScrollBarVisible(lua_State*);
|
||||
static int disableScrollBar(lua_State*);
|
||||
static int disableHorizontalScrollBar(lua_State*);
|
||||
static int enableHorizontalScrollBar(lua_State*);
|
||||
|
|
@ -594,6 +604,7 @@ public:
|
|||
static int killAlias(lua_State*);
|
||||
static int permBeginOfLineStringTrigger(lua_State*);
|
||||
static int setUserWindowStyleSheet(lua_State*);
|
||||
static int getUserWindowStyleSheet(lua_State*);
|
||||
static int getTime(lua_State*);
|
||||
static int getEpoch(lua_State*);
|
||||
static int invokeFileDialog(lua_State*);
|
||||
|
|
@ -722,6 +733,7 @@ public:
|
|||
static int getConnectionInfo(lua_State*);
|
||||
static int unzipAsync(lua_State*);
|
||||
static int setMapWindowTitle(lua_State*);
|
||||
static int getMapWindowTitle(lua_State*);
|
||||
static int getMudletInfo(lua_State*);
|
||||
static int getMapBackgroundColor(lua_State*);
|
||||
static int setMapBackgroundColor(lua_State*);
|
||||
|
|
@ -780,6 +792,12 @@ public:
|
|||
void freeLuaRegistryIndex(int index);
|
||||
void freeAllInLuaRegistry(TEvent);
|
||||
|
||||
// Called from Host::raiseEvent(), to unblock a waitForEvent() on that event.
|
||||
void captureEventForWaits(const TEvent&);
|
||||
// Lets callers refuse anything that would lua_close() the state the pump is
|
||||
// running Lua on. Always false outside MUDLET_TEST_MODE.
|
||||
bool pumpingEvents() const { return !mPendingEventWaits.isEmpty() || mEventPumpDepth > 0; }
|
||||
|
||||
inline static const QMap<Qt::MouseButton, QString> csmMouseButtons = {
|
||||
{Qt::NoButton, qsl("NoButton")}, {Qt::LeftButton, qsl("LeftButton")}, {Qt::RightButton, qsl("RightButton")}, {Qt::MiddleButton, qsl("MidButton")},
|
||||
{Qt::BackButton, qsl("BackButton")}, {Qt::ForwardButton, qsl("ForwardButton")}, {Qt::TaskButton, qsl("TaskButton")}, {Qt::ExtraButton4, qsl("ExtraButton4")},
|
||||
|
|
@ -809,6 +827,14 @@ public slots:
|
|||
private:
|
||||
static bool getVerifiedBool(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false);
|
||||
static QString getVerifiedString(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false);
|
||||
static bool checkStringArg(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false);
|
||||
static bool checkIntArg(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false);
|
||||
static bool checkBoolArg(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false);
|
||||
static bool checkNumberArg(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false);
|
||||
static bool checkStringOrIntegerArg(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false);
|
||||
static bool checkCommandOrFunctionArg(lua_State*, const char* functionName, const int pos);
|
||||
static bool checkCommandsOrFunctionsTable(lua_State*, const char* functionName, const int index);
|
||||
static bool checkHintsTable(lua_State*, const char* functionName, const int index);
|
||||
static int getVerifiedInt(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false);
|
||||
static float getVerifiedFloat(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false);
|
||||
static double getVerifiedDouble(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false);
|
||||
|
|
@ -816,11 +842,13 @@ private:
|
|||
static void errorArgumentType(lua_State*, const char* functionName, const int pos, const char* publicName, const char* publicType, const bool isOptional = false);
|
||||
static int warnArgumentValue(lua_State*, const char* functionName, const QString& message, const bool useFalseInsteadofNil = false);
|
||||
static int warnArgumentValue(lua_State*, const char* functionName, const char* message, const bool useFalseInsteadofNil = false);
|
||||
static int setLabelCallback(lua_State*, const QString& funcName);
|
||||
static int movieFunc(lua_State*, const QString& funcName);
|
||||
static int setLabelCallback(lua_State*, const char* funcName);
|
||||
static int movieFunc(lua_State*, const char* funcName);
|
||||
static std::pair<bool, QString> discordApiEnabled(lua_State*, bool writeAccess = false);
|
||||
static void setRequestDefaults(const QUrl& url, QNetworkRequest& request);
|
||||
static int performHttpRequest(lua_State*, const char* functionName, const int pos, QNetworkAccessManager::Operation operation, const QString& verb);
|
||||
static int performHttpRequest(lua_State*, const char* functionName, const int pos, QNetworkAccessManager::Operation operation, const char* verb);
|
||||
static void validateHttpHeaders(lua_State*, const int index, const char* functionName);
|
||||
static void applyHttpHeaders(lua_State*, const int index, QNetworkRequest& request);
|
||||
// The last argument is only needed if the third one is true:
|
||||
static void generateElapsedTimeTable(lua_State*, const QStringList&, const bool, const qint64 elapsedTimeMilliSeconds = 0);
|
||||
static std::tuple<bool, int> getWatchId(lua_State*, Host&);
|
||||
|
|
@ -893,6 +921,21 @@ private:
|
|||
QMap<QString, QPair<int, int>> mCapturedNameGroupsPosList;
|
||||
QVector<QVector<QPair<QString, QString>>> mMultiCaptureNameGroups;
|
||||
QMap<QNetworkReply*, QString> downloadMap;
|
||||
|
||||
// A waitForEvent() call in progress. mArgsRef is a Lua registry reference,
|
||||
// so it has to be unref'd once the waiter has read it.
|
||||
struct TEventWait
|
||||
{
|
||||
QString mName;
|
||||
int mArgsRef = LUA_NOREF;
|
||||
bool mCaptured = false;
|
||||
};
|
||||
QList<TEventWait*> mPendingEventWaits;
|
||||
// pumpEvents() registers no TEventWait of its own, so it needs its own
|
||||
// counter to be visible to pumpingEvents().
|
||||
int mEventPumpDepth = 0;
|
||||
int createEventArgsTableRef(const TEvent&);
|
||||
|
||||
lua_State* pGlobalLua = nullptr;
|
||||
std::unique_ptr<lua_State, lua_state_deleter> pIndenterState;
|
||||
QPointer<Host> mpHost;
|
||||
|
|
|
|||
|
|
@ -118,7 +118,10 @@ int TLuaInterpreter::getDiscordDetail(lua_State* L)
|
|||
return warnArgumentValue(L, __func__, result.second);
|
||||
}
|
||||
|
||||
lua_pushfstring(L, pMudlet->mDiscord.getDetailText(&host).toUtf8().constData());
|
||||
// Pushed as data, never as a format string: presence text can come from the
|
||||
// game server, and a '%' in it would otherwise be taken as a printf
|
||||
// specifier. The same holds for the five other Discord text getters below.
|
||||
lua_pushstring(L, pMudlet->mDiscord.getDetailText(&host).toUtf8().constData());
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +136,7 @@ int TLuaInterpreter::getDiscordLargeIcon(lua_State* L)
|
|||
return warnArgumentValue(L, __func__, result.second);
|
||||
}
|
||||
|
||||
lua_pushfstring(L, pMudlet->mDiscord.getLargeImage(&host).toUtf8().constData());
|
||||
lua_pushstring(L, pMudlet->mDiscord.getLargeImage(&host).toUtf8().constData());
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -148,7 +151,7 @@ int TLuaInterpreter::getDiscordLargeIconText(lua_State* L)
|
|||
return warnArgumentValue(L, __func__, result.second);
|
||||
}
|
||||
|
||||
lua_pushfstring(L, pMudlet->mDiscord.getLargeImageText(&host).toUtf8().constData());
|
||||
lua_pushstring(L, pMudlet->mDiscord.getLargeImageText(&host).toUtf8().constData());
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -180,7 +183,7 @@ int TLuaInterpreter::getDiscordSmallIcon(lua_State* L)
|
|||
return warnArgumentValue(L, __func__, result.second);
|
||||
}
|
||||
|
||||
lua_pushfstring(L, pMudlet->mDiscord.getSmallImage(&host).toUtf8().constData());
|
||||
lua_pushstring(L, pMudlet->mDiscord.getSmallImage(&host).toUtf8().constData());
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +198,7 @@ int TLuaInterpreter::getDiscordSmallIconText(lua_State* L)
|
|||
return warnArgumentValue(L, __func__, result.second);
|
||||
}
|
||||
|
||||
lua_pushfstring(L, pMudlet->mDiscord.getSmallImageText(&host).toUtf8().constData());
|
||||
lua_pushstring(L, pMudlet->mDiscord.getSmallImageText(&host).toUtf8().constData());
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -210,7 +213,7 @@ int TLuaInterpreter::getDiscordState(lua_State* L)
|
|||
return warnArgumentValue(L, __func__, result.second);
|
||||
}
|
||||
|
||||
lua_pushfstring(L, pMudlet->mDiscord.getStateText(&host).toUtf8().constData());
|
||||
lua_pushstring(L, pMudlet->mDiscord.getStateText(&host).toUtf8().constData());
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -371,11 +374,21 @@ int TLuaInterpreter::setDiscordGameUrl(lua_State* L)
|
|||
lua_pushboolean(L, true);
|
||||
return 1;
|
||||
}
|
||||
QString inputText = getVerifiedString(L, __func__, 1, "url").trimmed();
|
||||
host.setDiscordInviteURL(inputText.isEmpty() ? QString() : inputText);
|
||||
// argument 1 is applied before argument 2 is checked, as it was before:
|
||||
// setDiscordInviteURL() persists to the profile, and hoisting the second
|
||||
// check above it would stop a bad game name from saving the URL
|
||||
if (!checkStringArg(L, __func__, 1, "url")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
{
|
||||
const QString inviteUrl = QString{lua_tostring(L, 1)}.trimmed();
|
||||
host.setDiscordInviteURL(inviteUrl.isEmpty() ? QString() : inviteUrl);
|
||||
}
|
||||
if (args > 1) {
|
||||
inputText = getVerifiedString(L, __func__, 2, "game name").trimmed();
|
||||
host.setDiscordGameName(inputText);
|
||||
if (!checkStringArg(L, __func__, 2, "game name")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
host.setDiscordGameName(QString{lua_tostring(L, 2)}.trimmed());
|
||||
} else {
|
||||
host.setDiscordGameName(QString());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,11 @@
|
|||
int TLuaInterpreter::mmcpChatTo(lua_State* L)
|
||||
{
|
||||
const char* sFunc = "mmcp.chatTo";
|
||||
const QString target = getVerifiedString(L, sFunc, 1, "target");
|
||||
const QString msg = getVerifiedString(L, sFunc, 2, "message");
|
||||
if (!checkStringArg(L, sFunc, 1, "target") || !checkStringArg(L, sFunc, 2, "message")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
const QString target{lua_tostring(L, 1)};
|
||||
const QString msg{lua_tostring(L, 2)};
|
||||
|
||||
Host* pHost = &getHostFromLua(L);
|
||||
if (!pHost->mMMCPServer) {
|
||||
|
|
@ -107,17 +110,23 @@ int TLuaInterpreter::mmcpAllowSnoop(lua_State* L)
|
|||
int TLuaInterpreter::mmcpCall(lua_State* L)
|
||||
{
|
||||
const char* sFunc = "mmcp.call";
|
||||
const QString host = getVerifiedString(L, sFunc, 1, "host");
|
||||
if (!checkStringArg(L, sFunc, 1, "host")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
int port = csDefaultMMCPHostPort;
|
||||
|
||||
const int n = lua_gettop(L);
|
||||
if (n > 1) {
|
||||
port = getVerifiedInt(L, sFunc, 2, qsl("port number {default = %1}").arg(csDefaultMMCPHostPort).toUtf8().constData(), true);
|
||||
// static: a temporary here would be alive inside getVerifiedInt() when
|
||||
// it raises - see checkStringArg()
|
||||
static const QByteArray portName = qsl("port number {default = %1}").arg(csDefaultMMCPHostPort).toUtf8();
|
||||
port = getVerifiedInt(L, sFunc, 2, portName.constData(), true);
|
||||
if (port > 65535 || port < 1) {
|
||||
return warnArgumentValue(L, sFunc, qsl("invalid port number %1 given, if supplied it must be in range 1 to 65535").arg(port));
|
||||
}
|
||||
}
|
||||
|
||||
const QString host{lua_tostring(L, 1)};
|
||||
Host* pHost = &getHostFromLua(L);
|
||||
if (!pHost->mMMCPServer) {
|
||||
pHost->initMMCPServer();
|
||||
|
|
@ -186,8 +195,11 @@ int TLuaInterpreter::mmcpEmoteAll(lua_State* L)
|
|||
int TLuaInterpreter::mmcpChatGroup(lua_State* L)
|
||||
{
|
||||
const char* sFunc = "mmcp.chatGroup";
|
||||
const QString group = getVerifiedString(L, sFunc, 1, "group");
|
||||
const QString msg = getVerifiedString(L, sFunc, 2, "message");
|
||||
if (!checkStringArg(L, sFunc, 1, "group") || !checkStringArg(L, sFunc, 2, "message")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
const QString group{lua_tostring(L, 1)};
|
||||
const QString msg{lua_tostring(L, 2)};
|
||||
|
||||
Host* pHost = &getHostFromLua(L);
|
||||
if (!pHost->mMMCPServer) {
|
||||
|
|
@ -363,8 +375,11 @@ int TLuaInterpreter::mmcpServe(lua_State* L)
|
|||
int TLuaInterpreter::mmcpSetGroup(lua_State* L)
|
||||
{
|
||||
const char* sFunc = "mmcp.setGroup";
|
||||
const QString target = getVerifiedString(L, sFunc, 1, "target");
|
||||
const QString group = getVerifiedString(L, sFunc, 2, "group");
|
||||
if (!checkStringArg(L, sFunc, 1, "target") || !checkStringArg(L, sFunc, 2, "group")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
const QString target{lua_tostring(L, 1)};
|
||||
const QString group{lua_tostring(L, 2)};
|
||||
|
||||
Host* pHost = &getHostFromLua(L);
|
||||
if (!pHost->mMMCPServer) {
|
||||
|
|
@ -383,8 +398,11 @@ int TLuaInterpreter::mmcpSetGroup(lua_State* L)
|
|||
int TLuaInterpreter::mmcpSendSideChannel(lua_State* L)
|
||||
{
|
||||
const char* sFunc = "mmcp.sendSideChannel";
|
||||
const QString channel = getVerifiedString(L, sFunc, 1, "channel");
|
||||
const QString message = getVerifiedString(L, sFunc, 2, "message");
|
||||
if (!checkStringArg(L, sFunc, 1, "channel") || !checkStringArg(L, sFunc, 2, "message")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
const QString channel{lua_tostring(L, 1)};
|
||||
const QString message{lua_tostring(L, 2)};
|
||||
|
||||
Host* pHost = &getHostFromLua(L);
|
||||
if (!pHost->mMMCPServer) {
|
||||
|
|
@ -453,8 +471,8 @@ int TLuaInterpreter::mmcpStopServer(lua_State* L)
|
|||
if (!result.first) {
|
||||
return warnArgumentValue(L, sFunc, result.second.toUtf8().constData());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
lua_pushboolean(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
|
@ -478,7 +496,8 @@ int TLuaInterpreter::mmcpDisconnect(lua_State* L)
|
|||
return 1;
|
||||
}
|
||||
|
||||
int TLuaInterpreter::mmcpGetClientList(lua_State* L) {
|
||||
int TLuaInterpreter::mmcpGetClientList(lua_State* L)
|
||||
{
|
||||
Host* pHost = &getHostFromLua(L);
|
||||
|
||||
if (!pHost->mMMCPServer) {
|
||||
|
|
@ -524,10 +543,10 @@ int TLuaInterpreter::mmcpGetClientList(lua_State* L) {
|
|||
lua_pushstring(L, pClient->getVersion().toUtf8().constData());
|
||||
lua_settable(L, -3);
|
||||
|
||||
|
||||
|
||||
lua_pushnumber(L, ++i); // Push outer table key (index)
|
||||
lua_insert(L, -2); // Swap the inner table and key so that the table is on top
|
||||
lua_settable(L, -3); // Set the inner table in the outer table.
|
||||
lua_insert(L, -2); // Swap the inner table and key so that the table is on top
|
||||
lua_settable(L, -3); // Set the inner table in the outer table.
|
||||
}
|
||||
|
||||
return 1;
|
||||
|
|
|
|||
|
|
@ -306,10 +306,7 @@ int TLuaInterpreter::addCustomLine(lua_State* L)
|
|||
int g = 0;
|
||||
int b = 0;
|
||||
Qt::PenStyle line_style(Qt::SolidLine);
|
||||
QString direction;
|
||||
QList<qreal> x;
|
||||
QList<qreal> y;
|
||||
QList<int> z;
|
||||
TRoom* pR_to = nullptr;
|
||||
const int id_from = getVerifiedInt(L, __func__, 1, "roomID");
|
||||
TRoom* pR = host.mpMap->mpRoomDB->getRoom(id_from);
|
||||
if (!pR) {
|
||||
|
|
@ -322,7 +319,7 @@ int TLuaInterpreter::addCustomLine(lua_State* L)
|
|||
}
|
||||
if (lua_isnumber(L, 2)) {
|
||||
id_to = static_cast<int>(lua_tointeger(L, 2));
|
||||
TRoom* pR_to = host.mpMap->mpRoomDB->getRoom(id_to);
|
||||
pR_to = host.mpMap->mpRoomDB->getRoom(id_to);
|
||||
if (!pR_to) {
|
||||
return warnArgumentValue(L, __func__, qsl("number %1 is not a valid target roomID").arg(id_to));
|
||||
}
|
||||
|
|
@ -335,13 +332,12 @@ int TLuaInterpreter::addCustomLine(lua_State* L)
|
|||
qsl("target room is in area '%1' (ID: %2) which is not the one '%3' (ID: %4) in which this custom line is to be drawn")
|
||||
.arg((host.mpMap->mpRoomDB->getAreaNamesMap()).value(area_to), QString::number(area_to), (host.mpMap->mpRoomDB->getAreaNamesMap()).value(area), QString::number(area)));
|
||||
}
|
||||
|
||||
x.append(static_cast<qreal>(pR_to->x()));
|
||||
y.append(static_cast<qreal>(pR_to->y()));
|
||||
z.append(pR->z());
|
||||
} else if (lua_istable(L, 2)) {
|
||||
lua_pushnil(L);
|
||||
int i = 0; // Indexes groups of coordinates in the table
|
||||
int xCount = 0;
|
||||
int yCount = 0;
|
||||
int zCount = 0;
|
||||
while (lua_next(L, 2) != 0) {
|
||||
++i;
|
||||
if (lua_type(L, -1) != LUA_TTABLE) {
|
||||
|
|
@ -382,13 +378,13 @@ int TLuaInterpreter::addCustomLine(lua_State* L)
|
|||
}
|
||||
switch (j) {
|
||||
case 1:
|
||||
x.append(lua_tonumber(L, -1));
|
||||
++xCount;
|
||||
break;
|
||||
case 2:
|
||||
y.append(lua_tonumber(L, -1));
|
||||
++yCount;
|
||||
break;
|
||||
case 3:
|
||||
z.append(static_cast<int>(lua_tonumber(L, -1)));
|
||||
++zCount;
|
||||
break;
|
||||
default:; // No-op
|
||||
}
|
||||
|
|
@ -397,14 +393,14 @@ int TLuaInterpreter::addCustomLine(lua_State* L)
|
|||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
if (!i || x.isEmpty()) {
|
||||
if (!i || !xCount) {
|
||||
// If there is only an empty sub-table inside the table then i is
|
||||
// one but there is nothing in any of the QLists and things will
|
||||
// still blow up as per Issue #5272 - so also check for at least one
|
||||
// one but there is no coordinate at all and things will still blow
|
||||
// up as per Issue #5272 - so also check for at least one
|
||||
// x-coordinate value:
|
||||
return warnArgumentValue(L, __func__, "missing coordinates to create the line to");
|
||||
}
|
||||
if (x.count() != y.count() || x.count() != z.count()) {
|
||||
if (xCount != yCount || xCount != zCount) {
|
||||
return warnArgumentValue(L,
|
||||
__func__,
|
||||
"mismatch in numbers of coordinates for the points for the custom line given in table as second argument; each must contain three coordinates, i.e. x, y AND z "
|
||||
|
|
@ -412,28 +408,35 @@ int TLuaInterpreter::addCustomLine(lua_State* L)
|
|||
}
|
||||
}
|
||||
|
||||
direction = dirToString(L, 3);
|
||||
if (direction.isEmpty()) {
|
||||
lua_pushfstring(L, "addCustomLine: bad argument #3 type (direction as string or number (between 1 and 12 inclusive) expected, got %s!)", luaL_typename(L, 3));
|
||||
return lua_error(L);
|
||||
}
|
||||
if (!pR->hasExitOrSpecialExit(direction)) {
|
||||
return warnArgumentValue(L, __func__, qsl("roomID %1 does not have an exit in a direction that can be identified from '%2'").arg(QString::number(id_from), lua_tostring(L, 3)));
|
||||
{
|
||||
const QString direction = dirToString(L, 3);
|
||||
if (direction.isEmpty()) {
|
||||
lua_pushfstring(L, "addCustomLine: bad argument #3 type (direction as string or number (between 1 and 12 inclusive) expected, got %s!)", luaL_typename(L, 3));
|
||||
return lua_error(L);
|
||||
}
|
||||
if (!pR->hasExitOrSpecialExit(direction)) {
|
||||
return warnArgumentValue(L, __func__, qsl("roomID %1 does not have an exit in a direction that can be identified from '%2'").arg(QString::number(id_from), lua_tostring(L, 3)));
|
||||
}
|
||||
}
|
||||
|
||||
const QString lineStyleString = getVerifiedString(L, __func__, 4, "line style");
|
||||
if (!lineStyleString.compare(QLatin1String("solid line"))) {
|
||||
line_style = Qt::SolidLine;
|
||||
} else if (!lineStyleString.compare(QLatin1String("dot line"))) {
|
||||
line_style = Qt::DotLine;
|
||||
} else if (!lineStyleString.compare(QLatin1String("dash line"))) {
|
||||
line_style = Qt::DashLine;
|
||||
} else if (!lineStyleString.compare(QLatin1String("dash dot line"))) {
|
||||
line_style = Qt::DashDotLine;
|
||||
} else if (!lineStyleString.compare(QLatin1String("dash dot dot line"))) {
|
||||
line_style = Qt::DashDotDotLine;
|
||||
} else {
|
||||
return warnArgumentValue(L, __func__, qsl("invalid line style '%1', only use one of: 'solid line', 'dot line', 'dash line', 'dash dot line' or 'dash dot dot line'").arg(lineStyleString));
|
||||
if (!checkStringArg(L, __func__, 4, "line style")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
{
|
||||
const QString lineStyleString{lua_tostring(L, 4)};
|
||||
if (!lineStyleString.compare(QLatin1String("solid line"))) {
|
||||
line_style = Qt::SolidLine;
|
||||
} else if (!lineStyleString.compare(QLatin1String("dot line"))) {
|
||||
line_style = Qt::DotLine;
|
||||
} else if (!lineStyleString.compare(QLatin1String("dash line"))) {
|
||||
line_style = Qt::DashLine;
|
||||
} else if (!lineStyleString.compare(QLatin1String("dash dot line"))) {
|
||||
line_style = Qt::DashDotLine;
|
||||
} else if (!lineStyleString.compare(QLatin1String("dash dot dot line"))) {
|
||||
line_style = Qt::DashDotDotLine;
|
||||
} else {
|
||||
return warnArgumentValue(L, __func__, qsl("invalid line style '%1', only use one of: 'solid line', 'dot line', 'dash line', 'dash dot line' or 'dash dot dot line'").arg(lineStyleString));
|
||||
}
|
||||
}
|
||||
|
||||
if (!lua_istable(L, 5)) {
|
||||
|
|
@ -478,6 +481,38 @@ int TLuaInterpreter::addCustomLine(lua_State* L)
|
|||
}
|
||||
|
||||
const bool arrow = getVerifiedBool(L, __func__, 6, "end with arrow");
|
||||
|
||||
QList<qreal> x;
|
||||
QList<qreal> y;
|
||||
QList<int> z;
|
||||
if (pR_to) {
|
||||
x.append(static_cast<qreal>(pR_to->x()));
|
||||
y.append(static_cast<qreal>(pR_to->y()));
|
||||
z.append(pR->z());
|
||||
} else {
|
||||
lua_pushnil(L);
|
||||
while (lua_next(L, 2) != 0) {
|
||||
lua_pushnil(L);
|
||||
int j = 0;
|
||||
while (lua_next(L, -2) != 0) {
|
||||
switch (++j) {
|
||||
case 1:
|
||||
x.append(lua_tonumber(L, -1));
|
||||
break;
|
||||
case 2:
|
||||
y.append(lua_tonumber(L, -1));
|
||||
break;
|
||||
case 3:
|
||||
z.append(static_cast<int>(lua_tonumber(L, -1)));
|
||||
break;
|
||||
default:; // No-op
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const int lz = z.at(0);
|
||||
QList<QPointF> points;
|
||||
points.append(QPointF(x.at(0), y.at(0)));
|
||||
|
|
@ -488,6 +523,7 @@ int TLuaInterpreter::addCustomLine(lua_State* L)
|
|||
points.append(QPointF(x.at(i), y.at(i)));
|
||||
}
|
||||
|
||||
const QString direction = dirToString(L, 3);
|
||||
//Heiko: direction/line relationship must be unique
|
||||
pR->customLines[direction] = points;
|
||||
pR->customLinesArrow[direction] = arrow;
|
||||
|
|
@ -509,9 +545,12 @@ int TLuaInterpreter::addCustomLine(lua_State* L)
|
|||
// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#addMapEvent
|
||||
int TLuaInterpreter::addMapEvent(lua_State* L)
|
||||
{
|
||||
if (!checkStringArg(L, __func__, 1, "uniquename")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
QStringList actionInfo;
|
||||
const QString uniqueName = getVerifiedString(L, __func__, 1, "uniquename");
|
||||
actionInfo << getVerifiedString(L, __func__, 2, "event name");
|
||||
const QString uniqueName{lua_tostring(L, 1)};
|
||||
|
||||
if (!lua_isstring(L, 3)) {
|
||||
actionInfo << QString();
|
||||
|
|
@ -912,6 +951,22 @@ int TLuaInterpreter::closeMapWidget(lua_State* L)
|
|||
return 1;
|
||||
}
|
||||
|
||||
// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getMapWidgetGeometry
|
||||
int TLuaInterpreter::getMapWidgetGeometry(lua_State* L)
|
||||
{
|
||||
const Host& host = getHostFromLua(L);
|
||||
|
||||
if (auto geometry = host.mapWidgetGeometry()) {
|
||||
lua_pushnumber(L, geometry->x());
|
||||
lua_pushnumber(L, geometry->y());
|
||||
lua_pushnumber(L, geometry->width());
|
||||
lua_pushnumber(L, geometry->height());
|
||||
return 4;
|
||||
}
|
||||
|
||||
return warnArgumentValue(L, __func__, "no floating/dockable type map window found");
|
||||
}
|
||||
|
||||
// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#connectExitStub
|
||||
int TLuaInterpreter::connectExitStub(lua_State* L)
|
||||
{
|
||||
|
|
@ -1029,13 +1084,14 @@ int TLuaInterpreter::createMapLabel(lua_State* L)
|
|||
bool showOnTop = true;
|
||||
bool noScaling = true;
|
||||
bool temporary = false;
|
||||
QString fontName;
|
||||
int foregroundTransparency = 255;
|
||||
int backgroundTransparency = 50;
|
||||
|
||||
const int args = lua_gettop(L);
|
||||
const int area = getVerifiedInt(L, __func__, 1, "areaID");
|
||||
const QString text = getVerifiedString(L, __func__, 2, "text");
|
||||
if (!checkStringArg(L, __func__, 2, "text")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
const float posx = getVerifiedFloat(L, __func__, 3, "posX");
|
||||
const float posy = getVerifiedFloat(L, __func__, 4, "posY");
|
||||
const float posz = getVerifiedFloat(L, __func__, 5, "posZ");
|
||||
|
|
@ -1059,8 +1115,8 @@ int TLuaInterpreter::createMapLabel(lua_State* L)
|
|||
}
|
||||
}
|
||||
}
|
||||
if (args > 15) {
|
||||
fontName = getVerifiedString(L, __func__, 16, "fontName", true);
|
||||
if (args > 15 && !checkStringArg(L, __func__, 16, "fontName", true)) {
|
||||
return lua_error(L);
|
||||
}
|
||||
if (args > 16) {
|
||||
foregroundTransparency = getVerifiedInt(L, __func__, 17, "foregroundTransparency", true);
|
||||
|
|
@ -1078,6 +1134,11 @@ int TLuaInterpreter::createMapLabel(lua_State* L)
|
|||
}
|
||||
|
||||
const Host& host = getHostFromLua(L);
|
||||
const QString text{lua_tostring(L, 2)};
|
||||
QString fontName;
|
||||
if (args > 15) {
|
||||
fontName = lua_tostring(L, 16);
|
||||
}
|
||||
lua_pushinteger(L,
|
||||
host.mpMap->createMapLabel(area,
|
||||
text,
|
||||
|
|
@ -1102,7 +1163,9 @@ int TLuaInterpreter::createMapImageLabel(lua_State* L)
|
|||
{
|
||||
const int args = lua_gettop(L);
|
||||
const int area = getVerifiedInt(L, __func__, 1, "areaID");
|
||||
const QString imagePathFileName = getVerifiedString(L, __func__, 2, "imagePathFileName");
|
||||
if (!checkStringArg(L, __func__, 2, "imagePathFileName")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
const float posx = getVerifiedFloat(L, __func__, 3, "posX");
|
||||
const float posy = getVerifiedFloat(L, __func__, 4, "posY");
|
||||
const float posz = getVerifiedFloat(L, __func__, 5, "posZ");
|
||||
|
|
@ -1116,6 +1179,7 @@ int TLuaInterpreter::createMapImageLabel(lua_State* L)
|
|||
}
|
||||
|
||||
const Host& host = getHostFromLua(L);
|
||||
const QString imagePathFileName{lua_tostring(L, 2)};
|
||||
lua_pushinteger(L, host.mpMap->createMapImageLabel(area, imagePathFileName, posx, posy, posz, width, height, zoom, showOnTop, temporary));
|
||||
host.mpMap->updateArea(area);
|
||||
return 1;
|
||||
|
|
@ -1125,20 +1189,15 @@ int TLuaInterpreter::createMapImageLabel(lua_State* L)
|
|||
int TLuaInterpreter::createMapper(lua_State* L)
|
||||
{
|
||||
const int n = lua_gettop(L);
|
||||
QString windowName = "";
|
||||
const bool hasParentWindow = (n > 4);
|
||||
int counter = 1;
|
||||
|
||||
if (n > 4) {
|
||||
if (hasParentWindow) {
|
||||
if (lua_type(L, 1) != LUA_TSTRING) {
|
||||
lua_pushfstring(L, "createMapper: bad argument #1 type (parent window name as string expected, got %s!)", luaL_typename(L, 1));
|
||||
return lua_error(L);
|
||||
}
|
||||
windowName = lua_tostring(L, 1);
|
||||
counter++;
|
||||
if (isMain(windowName)) {
|
||||
// createMapper only accepts the empty name as the main window
|
||||
windowName.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const int x = getVerifiedInt(L, __func__, counter, "mapper x-coordinate");
|
||||
|
|
@ -1149,6 +1208,15 @@ int TLuaInterpreter::createMapper(lua_State* L)
|
|||
counter++;
|
||||
const int height = getVerifiedInt(L, __func__, counter, "mapper height");
|
||||
|
||||
QString windowName = "";
|
||||
if (hasParentWindow) {
|
||||
windowName = lua_tostring(L, 1);
|
||||
if (isMain(windowName)) {
|
||||
// createMapper only accepts the empty name as the main window
|
||||
windowName.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const Host& host = getHostFromLua(L);
|
||||
if (auto [success, message] = host.mpConsole->createMapper(windowName, x, y, width, height); !success) {
|
||||
return warnArgumentValue(L, __func__, message);
|
||||
|
|
@ -1694,8 +1762,6 @@ int TLuaInterpreter::getExitStubs1(lua_State* L)
|
|||
// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getExitStubsNames
|
||||
int TLuaInterpreter::getExitStubsNames(lua_State* L)
|
||||
{
|
||||
const QStringList stubmap = {"north", "northeast", "northwest", "east", "west", "south", "southeast", "southwest", "up", "down", "in", "out", "other"};
|
||||
|
||||
const Host& host = getHostFromLua(L);
|
||||
if (!host.mpMap || !host.mpMap->mpRoomDB) {
|
||||
return warnArgumentValue(L, __func__, "no map present or loaded");
|
||||
|
|
@ -1707,6 +1773,7 @@ int TLuaInterpreter::getExitStubsNames(lua_State* L)
|
|||
if (!pR) {
|
||||
return warnArgumentValue(L, __func__, csmInvalidRoomID.arg(roomId));
|
||||
}
|
||||
const QStringList stubmap = {"north", "northeast", "northwest", "east", "west", "south", "southeast", "southwest", "up", "down", "in", "out", "other"};
|
||||
QList<int> const stubs = pR->exitStubs;
|
||||
lua_newtable(L);
|
||||
for (int i = 0, total = stubs.size(); i < total; ++i) {
|
||||
|
|
@ -2338,11 +2405,14 @@ int TLuaInterpreter::getRoomUserData(lua_State* L)
|
|||
}
|
||||
|
||||
const int roomId = getVerifiedInt(L, __func__, 1, "roomID");
|
||||
const QString key = getVerifiedString(L, __func__, 2, "key");
|
||||
if (!checkStringArg(L, __func__, 2, "key")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
bool isBackwardCompatibilityRequired = true;
|
||||
if (lua_gettop(L) > 2) {
|
||||
isBackwardCompatibilityRequired = !getVerifiedBool(L, __func__, 3, "enableFullErrorReporting {default = false}", true);
|
||||
}
|
||||
const QString key{lua_tostring(L, 2)};
|
||||
|
||||
TRoom* pR = host.mpMap->mpRoomDB->getRoom(roomId);
|
||||
if (!pR) {
|
||||
|
|
@ -2739,11 +2809,15 @@ int TLuaInterpreter::lockSpecialExit(lua_State* L)
|
|||
{
|
||||
const int fromRoomID = getVerifiedInt(L, __func__, 1, "exit roomID");
|
||||
// The second argument (was the toRoomID) is now ignored as it is not required/considered in any way
|
||||
const QString dir = getVerifiedString(L, __func__, 3, "special exit name/command");
|
||||
if (dir.isEmpty()) {
|
||||
if (!checkStringArg(L, __func__, 3, "special exit name/command")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
const char* const exitName = lua_tostring(L, 3);
|
||||
if (!exitName[0]) {
|
||||
return warnArgumentValue(L, __func__, "the special exit name/command cannot be empty");
|
||||
}
|
||||
const bool b = getVerifiedBool(L, __func__, 4, "special exit lock state");
|
||||
const QString dir{exitName};
|
||||
|
||||
const Host& host = getHostFromLua(L);
|
||||
TRoom* pR = host.mpMap->mpRoomDB->getRoom(fromRoomID);
|
||||
|
|
@ -2796,12 +2870,15 @@ int TLuaInterpreter::openMapWidget(lua_State* L)
|
|||
// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#registerMapInfo
|
||||
int TLuaInterpreter::registerMapInfo(lua_State* L)
|
||||
{
|
||||
auto name = getVerifiedString(L, __func__, 1, "label");
|
||||
|
||||
if (!checkStringArg(L, __func__, 1, "label")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
if (!lua_isfunction(L, 2)) {
|
||||
lua_pushfstring(L, "registerMapInfo: bad argument #2 type (callback as function expected, got %s!)", luaL_typename(L, 2));
|
||||
return lua_error(L);
|
||||
}
|
||||
|
||||
auto name = QString{lua_tostring(L, 1)};
|
||||
const int callback = luaL_ref(L, LUA_REGISTRYINDEX);
|
||||
|
||||
auto& host = getHostFromLua(L);
|
||||
|
|
@ -2809,6 +2886,7 @@ int TLuaInterpreter::registerMapInfo(lua_State* L)
|
|||
name,
|
||||
[=](int roomID, int selectionSize, int areaId, int displayAreaId, QColor& infoColor) {
|
||||
Q_UNUSED(infoColor)
|
||||
const int callerStackTop = lua_gettop(L);
|
||||
lua_rawgeti(L, LUA_REGISTRYINDEX, callback);
|
||||
if (roomID > 0) {
|
||||
lua_pushinteger(L, roomID);
|
||||
|
|
@ -2821,21 +2899,15 @@ int TLuaInterpreter::registerMapInfo(lua_State* L)
|
|||
|
||||
const int error = lua_pcall(L, 4, 6, 0);
|
||||
if (error) {
|
||||
const int errorCount = lua_gettop(L);
|
||||
if (mudlet::smDebugMode) {
|
||||
for (int i = 1; i <= errorCount; i++) {
|
||||
if (lua_isstring(L, i)) {
|
||||
auto errorMessage = lua_tostring(L, i);
|
||||
TDebug(QColor(Qt::white), QColor(Qt::red)) << "LUA ERROR: when running map info callback for '" << name << "\nreason: " << errorMessage << "\n" >> 0;
|
||||
}
|
||||
}
|
||||
if (mudlet::smDebugMode && lua_isstring(L, -1)) {
|
||||
auto errorMessage = lua_tostring(L, -1);
|
||||
TDebug(QColor(Qt::white), QColor(Qt::red)) << "LUA ERROR: when running map info callback for '" << name << "\nreason: " << errorMessage << "\n" >> 0;
|
||||
}
|
||||
lua_pop(L, errorCount);
|
||||
lua_settop(L, callerStackTop);
|
||||
return MapInfoProperties{};
|
||||
}
|
||||
|
||||
auto nResult = lua_gettop(L);
|
||||
auto index = -nResult;
|
||||
auto index = -6; // the lua_pcall() above always leaves exactly this many results
|
||||
const QString text = lua_tostring(L, index);
|
||||
const bool isBold = lua_toboolean(L, ++index);
|
||||
const bool isItalic = lua_toboolean(L, ++index);
|
||||
|
|
@ -2855,7 +2927,7 @@ int TLuaInterpreter::registerMapInfo(lua_State* L)
|
|||
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
|
||||
color = QColor(r, g, b);
|
||||
}
|
||||
lua_pop(L, nResult);
|
||||
lua_settop(L, callerStackTop);
|
||||
return MapInfoProperties{isBold, isItalic, text, color};
|
||||
},
|
||||
L,
|
||||
|
|
@ -3056,16 +3128,23 @@ int TLuaInterpreter::saveJsonMap(lua_State* L)
|
|||
// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#saveMap
|
||||
int TLuaInterpreter::saveMap(lua_State* L)
|
||||
{
|
||||
QString location;
|
||||
const int args = lua_gettop(L);
|
||||
int saveVersion = 0;
|
||||
|
||||
if (lua_gettop(L) > 0) {
|
||||
location = getVerifiedString(L, __func__, 1, "save location path and file name", true);
|
||||
if (lua_gettop(L) > 1) {
|
||||
if (args > 0) {
|
||||
if (!checkStringArg(L, __func__, 1, "save location path and file name", true)) {
|
||||
return lua_error(L);
|
||||
}
|
||||
if (args > 1) {
|
||||
saveVersion = getVerifiedInt(L, __func__, 2, "map format version", true);
|
||||
}
|
||||
}
|
||||
|
||||
QString location;
|
||||
if (args > 0) {
|
||||
location = lua_tostring(L, 1);
|
||||
}
|
||||
|
||||
const Host& host = getHostFromLua(L);
|
||||
const bool error = host.mpConsole->saveMap(location, saveVersion);
|
||||
lua_pushboolean(L, error);
|
||||
|
|
@ -3080,13 +3159,23 @@ int TLuaInterpreter::searchAreaUserData(lua_State* L)
|
|||
return warnArgumentValue(L, __func__, "no map present or loaded");
|
||||
}
|
||||
|
||||
const int args = lua_gettop(L);
|
||||
if (args) {
|
||||
if (!checkStringArg(L, __func__, 1, "key", true)) {
|
||||
return lua_error(L);
|
||||
}
|
||||
if (args > 1 && !checkStringArg(L, __func__, 2, "value", true)) {
|
||||
return lua_error(L);
|
||||
}
|
||||
}
|
||||
|
||||
QString key = QString();
|
||||
QString value = QString(); //both of these assigns a null value which is detectably different from the empty value
|
||||
|
||||
if (lua_gettop(L)) {
|
||||
key = getVerifiedString(L, __func__, 1, "key", true);
|
||||
if (lua_gettop(L) > 1) {
|
||||
value = getVerifiedString(L, __func__, 2, "value", true);
|
||||
if (args) {
|
||||
key = lua_tostring(L, 1);
|
||||
if (args > 1) {
|
||||
value = lua_tostring(L, 2);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3255,13 +3344,23 @@ int TLuaInterpreter::searchRoomUserData(lua_State* L)
|
|||
return warnArgumentValue(L, __func__, "no map present or loaded");
|
||||
}
|
||||
|
||||
const int args = lua_gettop(L);
|
||||
if (args) {
|
||||
if (!checkStringArg(L, __func__, 1, "key", true)) {
|
||||
return lua_error(L);
|
||||
}
|
||||
if (args > 1 && !checkStringArg(L, __func__, 2, "value", true)) {
|
||||
return lua_error(L);
|
||||
}
|
||||
}
|
||||
|
||||
QString key = QString();
|
||||
QString value = QString(); //both of these assigns a null value which is detectably different from the empty value
|
||||
|
||||
if (lua_gettop(L)) {
|
||||
key = getVerifiedString(L, __func__, 1, "key", true);
|
||||
if (lua_gettop(L) > 1) {
|
||||
value = getVerifiedString(L, __func__, 2, "value", true);
|
||||
if (args) {
|
||||
key = lua_tostring(L, 1);
|
||||
if (args > 1) {
|
||||
value = lua_tostring(L, 2);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3347,7 +3446,6 @@ int TLuaInterpreter::searchRoomUserData(lua_State* L)
|
|||
int TLuaInterpreter::setAreaName(lua_State* L)
|
||||
{
|
||||
int id = -1;
|
||||
QString existingName;
|
||||
const Host& host = getHostFromLua(L);
|
||||
if (!host.mpMap || !host.mpMap->mpRoomDB) {
|
||||
return warnArgumentValue(L, __func__, "no map present or loaded");
|
||||
|
|
@ -3366,7 +3464,7 @@ int TLuaInterpreter::setAreaName(lua_State* L)
|
|||
// return warnArgumentValue(L, __func__, csmInvalidAreaID.arg(id));
|
||||
// }
|
||||
} else if (lua_isstring(L, 1)) {
|
||||
existingName = lua_tostring(L, 1);
|
||||
const QString existingName{lua_tostring(L, 1)};
|
||||
id = host.mpMap->mpRoomDB->getAreaNamesMap().key(existingName, 0);
|
||||
if (existingName.isEmpty()) {
|
||||
return warnArgumentValue(L, __func__, "area name cannot be empty");
|
||||
|
|
@ -3432,11 +3530,15 @@ int TLuaInterpreter::setAreaName(lua_State* L)
|
|||
int TLuaInterpreter::setAreaUserData(lua_State* L)
|
||||
{
|
||||
const int areaId = getVerifiedInt(L, __func__, 1, "areaID");
|
||||
const QString key = getVerifiedString(L, __func__, 2, "key");
|
||||
if (key.isEmpty()) {
|
||||
if (!checkStringArg(L, __func__, 2, "key")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
const char* const keyName = lua_tostring(L, 2);
|
||||
if (!keyName[0]) {
|
||||
return warnArgumentValue(L, __func__, "key is not allowed to be an empty string");
|
||||
}
|
||||
const QString value = getVerifiedString(L, __func__, 3, "value");
|
||||
const QString key{keyName};
|
||||
|
||||
const Host& host = getHostFromLua(L);
|
||||
if (!host.mpMap || !host.mpMap->mpRoomDB) {
|
||||
|
|
@ -3561,36 +3663,40 @@ int TLuaInterpreter::setDoor(lua_State* L)
|
|||
if (!pR) {
|
||||
return warnArgumentValue(L, __func__, csmInvalidRoomID.arg(roomId));
|
||||
}
|
||||
const QString exitCmd = getVerifiedString(L, __func__, 2, "door command");
|
||||
|
||||
if (exitCmd.compare(qsl("n")) && exitCmd.compare(qsl("e")) && exitCmd.compare(qsl("s")) && exitCmd.compare(qsl("w")) && exitCmd.compare(qsl("ne")) && exitCmd.compare(qsl("se"))
|
||||
&& exitCmd.compare(qsl("sw")) && exitCmd.compare(qsl("nw")) && exitCmd.compare(qsl("up")) && exitCmd.compare(qsl("down")) && exitCmd.compare(qsl("in")) && exitCmd.compare(qsl("out"))) {
|
||||
// One of the above WILL BE ZERO if the exitCmd is ONE of the above qsls
|
||||
// So the above will be TRUE if NONE of above strings match - which
|
||||
// means we must treat the exitCmd as a SPECIAL exit
|
||||
if (!(pR->getSpecialExits().contains(exitCmd))) {
|
||||
// And NOT a special one either
|
||||
return warnArgumentValue(L, __func__, qsl("roomID %1 does not have a special exit in direction '%2'").arg(QString::number(roomId), exitCmd));
|
||||
if (!checkStringArg(L, __func__, 2, "door command")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
{
|
||||
const QString exitCmd{lua_tostring(L, 2)};
|
||||
if (exitCmd.compare(qsl("n")) && exitCmd.compare(qsl("e")) && exitCmd.compare(qsl("s")) && exitCmd.compare(qsl("w")) && exitCmd.compare(qsl("ne")) && exitCmd.compare(qsl("se"))
|
||||
&& exitCmd.compare(qsl("sw")) && exitCmd.compare(qsl("nw")) && exitCmd.compare(qsl("up")) && exitCmd.compare(qsl("down")) && exitCmd.compare(qsl("in")) && exitCmd.compare(qsl("out"))) {
|
||||
// One of the above WILL BE ZERO if the exitCmd is ONE of the above qsls
|
||||
// So the above will be TRUE if NONE of above strings match - which
|
||||
// means we must treat the exitCmd as a SPECIAL exit
|
||||
if (!(pR->getSpecialExits().contains(exitCmd))) {
|
||||
// And NOT a special one either
|
||||
return warnArgumentValue(L, __func__, qsl("roomID %1 does not have a special exit in direction '%2'").arg(QString::number(roomId), exitCmd));
|
||||
}
|
||||
// else IS a valid special exit - so fall out of if and continue
|
||||
} else {
|
||||
// Is a normal exit so see if it is valid
|
||||
if (!(((!exitCmd.compare(qsl("n"))) && (pR->getExit(DIR_NORTH) > 0 || pR->exitStubs.contains(DIR_NORTH)))
|
||||
|| ((!exitCmd.compare(qsl("e"))) && (pR->getExit(DIR_EAST) > 0 || pR->exitStubs.contains(DIR_EAST)))
|
||||
|| ((!exitCmd.compare(qsl("s"))) && (pR->getExit(DIR_SOUTH) > 0 || pR->exitStubs.contains(DIR_SOUTH)))
|
||||
|| ((!exitCmd.compare(qsl("w"))) && (pR->getExit(DIR_WEST) > 0 || pR->exitStubs.contains(DIR_WEST)))
|
||||
|| ((!exitCmd.compare(qsl("ne"))) && (pR->getExit(DIR_NORTHEAST) > 0 || pR->exitStubs.contains(DIR_NORTHEAST)))
|
||||
|| ((!exitCmd.compare(qsl("se"))) && (pR->getExit(DIR_SOUTHEAST) > 0 || pR->exitStubs.contains(DIR_SOUTHEAST)))
|
||||
|| ((!exitCmd.compare(qsl("sw"))) && (pR->getExit(DIR_SOUTHWEST) > 0 || pR->exitStubs.contains(DIR_SOUTHWEST)))
|
||||
|| ((!exitCmd.compare(qsl("nw"))) && (pR->getExit(DIR_NORTHWEST) > 0 || pR->exitStubs.contains(DIR_NORTHWEST)))
|
||||
|| ((!exitCmd.compare(qsl("up"))) && (pR->getExit(DIR_UP) > 0 || pR->exitStubs.contains(DIR_UP)))
|
||||
|| ((!exitCmd.compare(qsl("down"))) && (pR->getExit(DIR_DOWN) > 0 || pR->exitStubs.contains(DIR_DOWN)))
|
||||
|| ((!exitCmd.compare(qsl("in"))) && (pR->getExit(DIR_IN) > 0 || pR->exitStubs.contains(DIR_IN)))
|
||||
|| ((!exitCmd.compare(qsl("out"))) && (pR->getExit(DIR_OUT) > 0 || pR->exitStubs.contains(DIR_OUT))))) {
|
||||
// No there IS NOT a stub or real exit in the exitCmd direction
|
||||
return warnArgumentValue(L, __func__, qsl("roomID %1 does not have a normal exit or a stub exit in direction '%2'").arg(QString::number(roomId), exitCmd));
|
||||
}
|
||||
// else IS a valid stub or real normal exit -fall through to continue
|
||||
}
|
||||
// else IS a valid special exit - so fall out of if and continue
|
||||
} else {
|
||||
// Is a normal exit so see if it is valid
|
||||
if (!(((!exitCmd.compare(qsl("n"))) && (pR->getExit(DIR_NORTH) > 0 || pR->exitStubs.contains(DIR_NORTH)))
|
||||
|| ((!exitCmd.compare(qsl("e"))) && (pR->getExit(DIR_EAST) > 0 || pR->exitStubs.contains(DIR_EAST)))
|
||||
|| ((!exitCmd.compare(qsl("s"))) && (pR->getExit(DIR_SOUTH) > 0 || pR->exitStubs.contains(DIR_SOUTH)))
|
||||
|| ((!exitCmd.compare(qsl("w"))) && (pR->getExit(DIR_WEST) > 0 || pR->exitStubs.contains(DIR_WEST)))
|
||||
|| ((!exitCmd.compare(qsl("ne"))) && (pR->getExit(DIR_NORTHEAST) > 0 || pR->exitStubs.contains(DIR_NORTHEAST)))
|
||||
|| ((!exitCmd.compare(qsl("se"))) && (pR->getExit(DIR_SOUTHEAST) > 0 || pR->exitStubs.contains(DIR_SOUTHEAST)))
|
||||
|| ((!exitCmd.compare(qsl("sw"))) && (pR->getExit(DIR_SOUTHWEST) > 0 || pR->exitStubs.contains(DIR_SOUTHWEST)))
|
||||
|| ((!exitCmd.compare(qsl("nw"))) && (pR->getExit(DIR_NORTHWEST) > 0 || pR->exitStubs.contains(DIR_NORTHWEST)))
|
||||
|| ((!exitCmd.compare(qsl("up"))) && (pR->getExit(DIR_UP) > 0 || pR->exitStubs.contains(DIR_UP)))
|
||||
|| ((!exitCmd.compare(qsl("down"))) && (pR->getExit(DIR_DOWN) > 0 || pR->exitStubs.contains(DIR_DOWN)))
|
||||
|| ((!exitCmd.compare(qsl("in"))) && (pR->getExit(DIR_IN) > 0 || pR->exitStubs.contains(DIR_IN)))
|
||||
|| ((!exitCmd.compare(qsl("out"))) && (pR->getExit(DIR_OUT) > 0 || pR->exitStubs.contains(DIR_OUT))))) {
|
||||
// No there IS NOT a stub or real exit in the exitCmd direction
|
||||
return warnArgumentValue(L, __func__, qsl("roomID %1 does not have a normal exit or a stub exit in direction '%2'").arg(QString::number(roomId), exitCmd));
|
||||
}
|
||||
// else IS a valid stub or real normal exit -fall through to continue
|
||||
}
|
||||
|
||||
const int doorStatus = getVerifiedInt(L, __func__, 3, "door type {0='none', 1='open', 2='closed' or 3='locked'}");
|
||||
|
|
@ -3598,6 +3704,7 @@ int TLuaInterpreter::setDoor(lua_State* L)
|
|||
return warnArgumentValue(L, __func__, qsl("door type %1 is not one of 0='none', 1='open', 2='closed' or 3='locked'").arg(doorStatus));
|
||||
}
|
||||
|
||||
const QString exitCmd{lua_tostring(L, 2)};
|
||||
const bool result = pR->setDoor(exitCmd, doorStatus);
|
||||
if (result) {
|
||||
host.mpMap->setUnsaved(__func__);
|
||||
|
|
@ -3789,13 +3896,15 @@ int TLuaInterpreter::setExitWeight(lua_State* L)
|
|||
return warnArgumentValue(L, __func__, csmInvalidRoomID.arg(roomID));
|
||||
}
|
||||
|
||||
const QString direction(dirToString(L, 2));
|
||||
if (direction.isEmpty()) {
|
||||
lua_pushfstring(L, "setExitWeight: bad argument #2 type (direction as string or number {between 1 and 12 inclusive} expected, got %s!)", luaL_typename(L, 2));
|
||||
return lua_error(L);
|
||||
}
|
||||
if (!pR->hasExitOrSpecialExit(direction)) {
|
||||
return warnArgumentValue(L, __func__, qsl("roomID %1 does not have an exit that can be identified from '%2'").arg(QString::number(roomID), lua_tostring(L, 2)));
|
||||
{
|
||||
const QString direction(dirToString(L, 2));
|
||||
if (direction.isEmpty()) {
|
||||
lua_pushfstring(L, "setExitWeight: bad argument #2 type (direction as string or number {between 1 and 12 inclusive} expected, got %s!)", luaL_typename(L, 2));
|
||||
return lua_error(L);
|
||||
}
|
||||
if (!pR->hasExitOrSpecialExit(direction)) {
|
||||
return warnArgumentValue(L, __func__, qsl("roomID %1 does not have an exit that can be identified from '%2'").arg(QString::number(roomID), lua_tostring(L, 2)));
|
||||
}
|
||||
}
|
||||
|
||||
const int weight = getVerifiedInt(L, __func__, 3, "exit weight");
|
||||
|
|
@ -3806,7 +3915,7 @@ int TLuaInterpreter::setExitWeight(lua_State* L)
|
|||
.arg(QString::number(weight), QString::number(std::numeric_limits<int>::max())));
|
||||
}
|
||||
|
||||
pR->setExitWeight(direction, weight);
|
||||
pR->setExitWeight(dirToString(L, 2), weight);
|
||||
lua_pushboolean(L, true);
|
||||
host.mpMap->updateArea(pR->getArea());
|
||||
return 1;
|
||||
|
|
@ -3839,11 +3948,15 @@ int TLuaInterpreter::setMapUserData(lua_State* L)
|
|||
return warnArgumentValue(L, __func__, "no map present or loaded");
|
||||
}
|
||||
|
||||
const QString key = getVerifiedString(L, __func__, 1, "key");
|
||||
if (key.isEmpty()) {
|
||||
if (!checkStringArg(L, __func__, 1, "key")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
const char* const keyName = lua_tostring(L, 1);
|
||||
if (!keyName[0]) {
|
||||
return warnArgumentValue(L, __func__, "key is not allowed to be an empty string");
|
||||
}
|
||||
const QString value = getVerifiedString(L, __func__, 2, "value");
|
||||
const QString key{keyName};
|
||||
|
||||
host.mpMap->mUserData[key] = value;
|
||||
host.mpMap->setUnsaved(__func__);
|
||||
|
|
@ -3914,13 +4027,11 @@ int TLuaInterpreter::setRoomArea(lua_State* L)
|
|||
return warnArgumentValue(L, __func__, "no map present or loaded");
|
||||
}
|
||||
|
||||
QVector<int> roomIds;
|
||||
if (lua_isnumber(L, 1)) {
|
||||
const int id = getVerifiedInt(L, __func__, 1, "roomID");
|
||||
if (!host.mpMap->mpRoomDB->getRoomIDList().contains(id)) {
|
||||
return warnArgumentValue(L, __func__, csmInvalidRoomID.arg(id));
|
||||
}
|
||||
roomIds.append(id);
|
||||
} else if (lua_istable(L, 1)) {
|
||||
lua_pushnil(L);
|
||||
while (lua_next(L, 1) != 0) {
|
||||
|
|
@ -3928,7 +4039,6 @@ int TLuaInterpreter::setRoomArea(lua_State* L)
|
|||
if (!host.mpMap->mpRoomDB->getRoomIDList().contains(id)) {
|
||||
return warnArgumentValue(L, __func__, csmInvalidRoomID.arg(id));
|
||||
}
|
||||
roomIds.append(id);
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -3940,7 +4050,6 @@ int TLuaInterpreter::setRoomArea(lua_State* L)
|
|||
}
|
||||
|
||||
int areaId = -1;
|
||||
QString areaName;
|
||||
if (lua_isnumber(L, 2)) {
|
||||
areaId = static_cast<int>(lua_tonumber(L, 2));
|
||||
if (areaId < 1) {
|
||||
|
|
@ -3954,7 +4063,7 @@ int TLuaInterpreter::setRoomArea(lua_State* L)
|
|||
return warnArgumentValue(L, __func__, csmInvalidAreaID.arg(areaId));
|
||||
}
|
||||
} else if (lua_isstring(L, 2)) {
|
||||
areaName = lua_tostring(L, 2);
|
||||
const QString areaName{lua_tostring(L, 2)};
|
||||
// areaId will be zero if not found!
|
||||
if (areaName.isEmpty()) {
|
||||
return warnArgumentValue(L, __func__, "area name cannot be empty");
|
||||
|
|
@ -3971,6 +4080,17 @@ int TLuaInterpreter::setRoomArea(lua_State* L)
|
|||
return lua_error(L);
|
||||
}
|
||||
|
||||
QVector<int> roomIds;
|
||||
if (lua_isnumber(L, 1)) {
|
||||
roomIds.append(static_cast<int>(lua_tointeger(L, 1)));
|
||||
} else {
|
||||
lua_pushnil(L);
|
||||
while (lua_next(L, 1) != 0) {
|
||||
roomIds.append(static_cast<int>(lua_tointeger(L, -1)));
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const bool result = std::all_of(roomIds.begin(), roomIds.end(), [&](int id) {
|
||||
// defer area recalculation on all rooms until the last room (.back())
|
||||
return host.mpMap->setRoomArea(id, areaId, id != roomIds.back());
|
||||
|
|
@ -4122,9 +4242,12 @@ int TLuaInterpreter::setRoomUserData(lua_State* L)
|
|||
}
|
||||
|
||||
const int roomId = getVerifiedInt(L, __func__, 1, "roomID");
|
||||
const QString key = getVerifiedString(L, __func__, 2, "key");
|
||||
// Ideally should reject empty keys but this could break existing scripts so we can't
|
||||
if (!checkStringArg(L, __func__, 2, "key")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
const QString value = getVerifiedString(L, __func__, 3, "value");
|
||||
// Ideally should reject empty keys but this could break existing scripts so we can't
|
||||
const QString key{lua_tostring(L, 2)};
|
||||
|
||||
TRoom* pR = host.mpMap->mpRoomDB->getRoom(roomId);
|
||||
if (!pR) {
|
||||
|
|
@ -4485,7 +4608,9 @@ int TLuaInterpreter::exportAreaImage(lua_State* L)
|
|||
}
|
||||
|
||||
// filePath parameter is required
|
||||
const QString filePath = getVerifiedString(L, __func__, 2, "file path");
|
||||
if (!checkStringArg(L, __func__, 2, "file path")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
|
||||
std::optional<int> zLevel = std::nullopt;
|
||||
bool exportAllZLevels = false;
|
||||
|
|
@ -4503,6 +4628,8 @@ int TLuaInterpreter::exportAreaImage(lua_State* L)
|
|||
}
|
||||
}
|
||||
|
||||
const QString filePath{lua_tostring(L, 2)};
|
||||
|
||||
// NOTE: Zoom parameter temporarily disabled due to blurry room symbol rendering at zoom > 2.0
|
||||
qreal zoom = 2.0;
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -76,7 +76,9 @@ int TLuaInterpreter::connectToServer(lua_State* L)
|
|||
bool isToSaveToProfile = false;
|
||||
|
||||
Host& host = getHostFromLua(L);
|
||||
const QString url = getVerifiedString(L, __func__, 1, "url");
|
||||
if (!checkStringArg(L, __func__, 1, "url")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
|
||||
if (!lua_isnoneornil(L, 2)) {
|
||||
port = getVerifiedInt(L, __func__, 2, "port number {default = 23}", true);
|
||||
|
|
@ -90,6 +92,8 @@ int TLuaInterpreter::connectToServer(lua_State* L)
|
|||
isToSaveToProfile = getVerifiedBool(L, __func__, 3, "save host name and port number", true);
|
||||
}
|
||||
|
||||
const QString url{lua_tostring(L, 1)};
|
||||
|
||||
if (isToSaveToProfile) {
|
||||
QPair<bool, QString> result = host.writeProfileData(QLatin1String("url"), url);
|
||||
if (!result.first) {
|
||||
|
|
@ -120,8 +124,12 @@ int TLuaInterpreter::disconnect(lua_State* L)
|
|||
int TLuaInterpreter::downloadFile(lua_State* L)
|
||||
{
|
||||
Host& host = getHostFromLua(L);
|
||||
const QString localFile = getVerifiedString(L, __func__, 1, "local filename");
|
||||
const QString urlString = getVerifiedString(L, __func__, 2, "remote url");
|
||||
if (!checkStringArg(L, __func__, 1, "local filename") || !checkStringArg(L, __func__, 2, "remote url")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
|
||||
const QString localFile{lua_tostring(L, 1)};
|
||||
const QString urlString{lua_tostring(L, 2)};
|
||||
const QUrl url = QUrl::fromUserInput(urlString);
|
||||
if (!url.isValid()) {
|
||||
return warnArgumentValue(L, __func__, qsl("url is invalid, reason: %1").arg(url.errorString()));
|
||||
|
|
@ -297,17 +305,19 @@ int TLuaInterpreter::sendATCP(lua_State* L)
|
|||
{
|
||||
Host& host = getHostFromLua(L);
|
||||
if (!lua_isstring(L, 1)) {
|
||||
lua_pushfstring(L, "sendATCP: bad argument #1 type (message as string expected, got %1!)", luaL_typename(L, 1));
|
||||
lua_pushfstring(L, "sendATCP: bad argument #1 type (message as string expected, got %s!)", luaL_typename(L, 1));
|
||||
return lua_error(L);
|
||||
}
|
||||
const bool hasWhat = lua_gettop(L) > 1;
|
||||
if (hasWhat && !lua_isstring(L, 2)) {
|
||||
lua_pushfstring(L, "sendATCP: bad argument #2 type (what as string is optional, got %s!)", luaL_typename(L, 2));
|
||||
return lua_error(L);
|
||||
}
|
||||
|
||||
const std::string msg = host.mTelnet.encodeAndCookBytes(lua_tostring(L, 1));
|
||||
|
||||
std::string what;
|
||||
if (lua_gettop(L) > 1) {
|
||||
if (!lua_isstring(L, 2)) {
|
||||
lua_pushfstring(L, "sendATCP: bad argument #2 type (what as string is optional, got %1!)", luaL_typename(L, 2));
|
||||
return lua_error(L);
|
||||
}
|
||||
if (hasWhat) {
|
||||
what = host.mTelnet.encodeAndCookBytes(lua_tostring(L, 2));
|
||||
}
|
||||
|
||||
|
|
@ -346,17 +356,19 @@ int TLuaInterpreter::sendGMCP(lua_State* L)
|
|||
{
|
||||
Host& host = getHostFromLua(L);
|
||||
if (!lua_isstring(L, 1)) {
|
||||
lua_pushfstring(L, "sendGMCP: bad argument #1 type (message as string expected, got %1!)", luaL_typename(L, 1));
|
||||
lua_pushfstring(L, "sendGMCP: bad argument #1 type (message as string expected, got %s!)", luaL_typename(L, 1));
|
||||
return lua_error(L);
|
||||
}
|
||||
const bool hasWhat = lua_gettop(L) > 1;
|
||||
if (hasWhat && !lua_isstring(L, 2)) {
|
||||
lua_pushfstring(L, "sendGMCP: bad argument #2 type (what as string is optional, got %s!)", luaL_typename(L, 2));
|
||||
return lua_error(L);
|
||||
}
|
||||
|
||||
const std::string msg = host.mTelnet.encodeAndCookBytes(lua_tostring(L, 1));
|
||||
|
||||
std::string what;
|
||||
if (lua_gettop(L) > 1) {
|
||||
if (!lua_isstring(L, 2)) {
|
||||
lua_pushfstring(L, "sendGMCP: bad argument #2 type (what as string is optional, got %1!)", luaL_typename(L, 2));
|
||||
return lua_error(L);
|
||||
}
|
||||
if (hasWhat) {
|
||||
what = host.mTelnet.encodeAndCookBytes(lua_tostring(L, 2));
|
||||
}
|
||||
|
||||
|
|
@ -393,8 +405,12 @@ int TLuaInterpreter::sendGMCP(lua_State* L)
|
|||
// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#sendIrc
|
||||
int TLuaInterpreter::sendIrc(lua_State* L)
|
||||
{
|
||||
const QString target = getVerifiedString(L, __func__, 1, "target");
|
||||
const QString msg = getVerifiedString(L, __func__, 2, "message");
|
||||
if (!checkStringArg(L, __func__, 1, "target") || !checkStringArg(L, __func__, 2, "message")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
|
||||
const QString target{lua_tostring(L, 1)};
|
||||
const QString msg{lua_tostring(L, 2)};
|
||||
|
||||
Host* pHost = &getHostFromLua(L);
|
||||
if (!pHost->mpDlgIRC) {
|
||||
|
|
@ -585,9 +601,11 @@ int TLuaInterpreter::setIrcServer(lua_State* L)
|
|||
const int args = lua_gettop(L);
|
||||
int secure = false;
|
||||
int port = 6667;
|
||||
QString password;
|
||||
const std::string addr = getVerifiedString(L, __func__, 1, "hostname").toStdString();
|
||||
if (addr.empty()) {
|
||||
if (!checkStringArg(L, __func__, 1, "hostname")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
const char* hostName = lua_tostring(L, 1);
|
||||
if (*hostName == '\0') {
|
||||
return warnArgumentValue(L, __func__, "hostname must not be empty");
|
||||
}
|
||||
if (!lua_isnoneornil(L, 2)) {
|
||||
|
|
@ -599,12 +617,17 @@ int TLuaInterpreter::setIrcServer(lua_State* L)
|
|||
if (args > 2) {
|
||||
secure = getVerifiedBool(L, __func__, 3, "secure {default = false}", true);
|
||||
}
|
||||
if (args > 3 && !checkStringArg(L, __func__, 4, "server password", true)) {
|
||||
return lua_error(L);
|
||||
}
|
||||
|
||||
QString password;
|
||||
if (args > 3) {
|
||||
password = getVerifiedString(L, __func__, 4, "server password", true);
|
||||
password = lua_tostring(L, 4);
|
||||
}
|
||||
|
||||
Host* pHost = &getHostFromLua(L);
|
||||
QPair<bool, QString> result = dlgIRC::writeIrcHostName(pHost, QString::fromStdString(addr));
|
||||
QPair<bool, QString> result = dlgIRC::writeIrcHostName(pHost, QString::fromUtf8(hostName));
|
||||
if (!result.first) {
|
||||
return warnArgumentValue(L, __func__, qsl("unable to save hostname, reason: %1").arg(result.second));
|
||||
}
|
||||
|
|
@ -629,40 +652,69 @@ int TLuaInterpreter::setIrcServer(lua_State* L)
|
|||
return 2;
|
||||
}
|
||||
|
||||
// Validates the optional headers table at Lua stack index `index`: it must be
|
||||
// absent/nil, or a table whose keys and values are all strings, otherwise a Lua
|
||||
// error is raised. This has to run before any QUrl or QNetworkRequest is
|
||||
// constructed, because lua_error() longjmps past C++ destructors and would
|
||||
// otherwise leak those heap-owning Qt objects.
|
||||
/*static*/ void TLuaInterpreter::validateHttpHeaders(lua_State* L, const int index, const char* functionName)
|
||||
{
|
||||
if (!lua_istable(L, index)) {
|
||||
if (!lua_isnoneornil(L, index)) {
|
||||
lua_pushfstring(L, "%s: bad argument #%d type (headers as a table expected, got %s!)", functionName, index, luaL_typename(L, index));
|
||||
lua_error(L);
|
||||
}
|
||||
return;
|
||||
}
|
||||
lua_pushnil(L);
|
||||
while (lua_next(L, index) != 0) {
|
||||
// key at index -2 and value at index -1
|
||||
if (lua_type(L, -1) != LUA_TSTRING || lua_type(L, -2) != LUA_TSTRING) {
|
||||
lua_pushfstring(L,
|
||||
"%s: bad argument #%d type (custom headers must be strings, got header: %s (should be string) and value: %s (should be string))",
|
||||
functionName,
|
||||
index,
|
||||
luaL_typename(L, -2),
|
||||
luaL_typename(L, -1));
|
||||
lua_error(L);
|
||||
}
|
||||
// removes value, but keeps key for next iteration
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Applies the already-validated headers table at `index` to `request`. Call
|
||||
// validateHttpHeaders() first: this assumes every key/value is a string and
|
||||
// never raises a Lua error, so it is safe to run with a live QNetworkRequest.
|
||||
/*static*/ void TLuaInterpreter::applyHttpHeaders(lua_State* L, const int index, QNetworkRequest& request)
|
||||
{
|
||||
if (!lua_istable(L, index)) {
|
||||
return;
|
||||
}
|
||||
lua_pushnil(L);
|
||||
while (lua_next(L, index) != 0) {
|
||||
request.setRawHeader(QByteArray(lua_tostring(L, -2)), QByteArray(lua_tostring(L, -1)));
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getHTTP
|
||||
int TLuaInterpreter::getHTTP(lua_State* L)
|
||||
{
|
||||
auto& host = getHostFromLua(L);
|
||||
const QString urlString = getVerifiedString(L, __func__, 1, "remote url");
|
||||
const QUrl url = QUrl::fromUserInput(urlString);
|
||||
if (!checkStringArg(L, __func__, 1, "remote url")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
validateHttpHeaders(L, 2, __func__);
|
||||
|
||||
const QUrl url = QUrl::fromUserInput(QString{lua_tostring(L, 1)});
|
||||
if (!url.isValid()) {
|
||||
return warnArgumentValue(L, __func__, qsl("url is invalid, reason: %1").arg(url.errorString()));
|
||||
}
|
||||
|
||||
QNetworkRequest request = QNetworkRequest(url);
|
||||
mudlet::self()->setNetworkRequestDefaults(url, request);
|
||||
|
||||
if (!lua_istable(L, 2) && !lua_isnoneornil(L, 2)) {
|
||||
lua_pushfstring(L, "getHTTP: bad argument #2 type (headers as a table expected, got %s!)", luaL_typename(L, 2));
|
||||
return lua_error(L);
|
||||
}
|
||||
if (lua_istable(L, 2)) {
|
||||
lua_pushnil(L);
|
||||
while (lua_next(L, 2) != 0) {
|
||||
// key at index -2 and value at index -1
|
||||
if (lua_type(L, -1) == LUA_TSTRING && lua_type(L, -2) == LUA_TSTRING) {
|
||||
request.setRawHeader(QByteArray(lua_tostring(L, -2)), QByteArray(lua_tostring(L, -1)));
|
||||
} else {
|
||||
lua_pushfstring(L,
|
||||
"getHTTP: bad argument #2 type (custom headers must be strings, got header: %s (should be string) and value: %s (should be string))",
|
||||
luaL_typename(L, -2),
|
||||
luaL_typename(L, -1));
|
||||
return lua_error(L);
|
||||
}
|
||||
// removes value, but keeps key for next iteration
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
applyHttpHeaders(L, 2, request);
|
||||
|
||||
host.updateProxySettings(host.mLuaInterpreter.mpFileDownloader);
|
||||
QNetworkReply* reply = host.mLuaInterpreter.mpFileDownloader->get(request);
|
||||
|
|
@ -679,49 +731,32 @@ int TLuaInterpreter::getHTTP(lua_State* L)
|
|||
// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#postHTTP
|
||||
int TLuaInterpreter::postHTTP(lua_State* L)
|
||||
{
|
||||
return performHttpRequest(L, __func__, 0, QNetworkAccessManager::PostOperation, qsl("post"));
|
||||
return performHttpRequest(L, __func__, 0, QNetworkAccessManager::PostOperation, "post");
|
||||
}
|
||||
|
||||
// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#putHTTP
|
||||
int TLuaInterpreter::putHTTP(lua_State* L)
|
||||
{
|
||||
return performHttpRequest(L, __func__, 0, QNetworkAccessManager::PutOperation, qsl("put"));
|
||||
return performHttpRequest(L, __func__, 0, QNetworkAccessManager::PutOperation, "put");
|
||||
}
|
||||
|
||||
// Documentation: https://wiki.mudlet.org/w/Manual:Networking_Functions#deleteHTTP
|
||||
int TLuaInterpreter::deleteHTTP(lua_State* L)
|
||||
{
|
||||
auto& host = getHostFromLua(L);
|
||||
const QString urlString = getVerifiedString(L, __func__, 1, "remote url");
|
||||
const QUrl url = QUrl::fromUserInput(urlString);
|
||||
if (!checkStringArg(L, __func__, 1, "remote url")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
validateHttpHeaders(L, 2, __func__);
|
||||
|
||||
const QUrl url = QUrl::fromUserInput(QString{lua_tostring(L, 1)});
|
||||
if (!url.isValid()) {
|
||||
return warnArgumentValue(L, __func__, qsl("url is invalid, reason: %1").arg(url.errorString()));
|
||||
}
|
||||
|
||||
QNetworkRequest request = QNetworkRequest(url);
|
||||
mudlet::self()->setNetworkRequestDefaults(url, request);
|
||||
|
||||
if (!lua_istable(L, 2) && !lua_isnoneornil(L, 2)) {
|
||||
lua_pushfstring(L, "deleteHTTP: bad argument #2 type (headers as a table expected, got %s!)", luaL_typename(L, 2));
|
||||
return lua_error(L);
|
||||
}
|
||||
if (lua_istable(L, 2)) {
|
||||
lua_pushnil(L);
|
||||
while (lua_next(L, 2) != 0) {
|
||||
// key at index -2 and value at index -1
|
||||
if (lua_type(L, -1) == LUA_TSTRING && lua_type(L, -2) == LUA_TSTRING) {
|
||||
request.setRawHeader(QByteArray(lua_tostring(L, -2)), QByteArray(lua_tostring(L, -1)));
|
||||
} else {
|
||||
lua_pushfstring(L,
|
||||
"deleteHTTP: bad argument #2 type (custom headers must be strings, got header: %s (should be string) and value: %s (should be string))",
|
||||
luaL_typename(L, -2),
|
||||
luaL_typename(L, -1));
|
||||
return lua_error(L);
|
||||
}
|
||||
// removes value, but keeps key for next iteration
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
applyHttpHeaders(L, 2, request);
|
||||
|
||||
host.updateProxySettings(host.mLuaInterpreter.mpFileDownloader);
|
||||
QNetworkReply* reply = host.mLuaInterpreter.mpFileDownloader->deleteResource(request);
|
||||
|
|
@ -738,6 +773,9 @@ int TLuaInterpreter::deleteHTTP(lua_State* L)
|
|||
// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#customHTTP
|
||||
int TLuaInterpreter::customHTTP(lua_State* L)
|
||||
{
|
||||
auto customMethod = getVerifiedString(L, __func__, 1, "http method");
|
||||
return performHttpRequest(L, __func__, 1, QNetworkAccessManager::CustomOperation, customMethod);
|
||||
if (!checkStringArg(L, __func__, 1, "http method")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
|
||||
return performHttpRequest(L, __func__, 1, QNetworkAccessManager::CustomOperation, lua_tostring(L, 1));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
#include "glwidget_integration.h"
|
||||
#endif
|
||||
|
||||
#include <chrono>
|
||||
#include <limits>
|
||||
#include <math.h>
|
||||
|
||||
|
|
@ -67,6 +68,7 @@
|
|||
#include <QDesktopServices>
|
||||
#include <QFileInfo>
|
||||
#include <QMovie>
|
||||
#include <QTimer>
|
||||
#include <QVector>
|
||||
#ifdef QT_TEXTTOSPEECH_LIB
|
||||
#include <QTextToSpeech>
|
||||
|
|
@ -79,9 +81,34 @@ bool bSpeechBuilt;
|
|||
bool bSpeechQueueing;
|
||||
int speechState = QTextToSpeech::State::Ready;
|
||||
QString speechCurrent;
|
||||
// Whether a ttsSpeechStarted has been raised for what speechCurrent holds. The
|
||||
// events are raised off the engine's state edges, and an engine that is already
|
||||
// speaking has no edge to report when it is given something else to say.
|
||||
static bool speechStartAnnounced = false;
|
||||
// Set while the utterance ttsSpeak() last asked for was started over one that
|
||||
// was still being spoken. Every engine stops the running utterance inside say(),
|
||||
// and the Ready that reports that has to be told apart from the engine going
|
||||
// idle - see ttsSpeak().
|
||||
static bool speechInterrupting = false;
|
||||
// How long an engine is given to start the utterance that interrupted another
|
||||
// before a Ready held back on its account is taken at face value after all.
|
||||
static constexpr std::chrono::milliseconds scmInterruptedSpeechGrace{250};
|
||||
|
||||
static const QTextToSpeech::State TEXT_TO_SPEECH_ERROR_STATE = QTextToSpeech::State::Error;
|
||||
|
||||
// ttsStateChanged() raises this same event whenever the engine reports a state
|
||||
// edge into Speaking; ttsSpeak() raises it through here for the utterance an
|
||||
// already-speaking engine starts without any such edge.
|
||||
static void raiseSpeechStartedEvent(const QString& text)
|
||||
{
|
||||
TEvent event{};
|
||||
event.mArgumentList.append(QLatin1String("ttsSpeechStarted"));
|
||||
event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
event.mArgumentList.append(text);
|
||||
event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mudlet::self()->getHostManager().postInterHostEvent(nullptr, event, true);
|
||||
}
|
||||
|
||||
// No documentation available in wiki - internal function
|
||||
void TLuaInterpreter::ttsBuild()
|
||||
{
|
||||
|
|
@ -89,7 +116,18 @@ void TLuaInterpreter::ttsBuild()
|
|||
return;
|
||||
}
|
||||
|
||||
speechUnit = new QTextToSpeech();
|
||||
// Under automated tests always request Qt's deterministic "mock" engine and
|
||||
// never fall back to a real backend, which would speak aloud and make specs
|
||||
// host-dependent. When the mock plugin is absent Qt leaves the engine in the
|
||||
// Error state with no voices, so the TTS specs skip (or fail where the mock
|
||||
// is mandatory) instead of exercising a developer's real speech engine. This
|
||||
// also makes a non-empty voice list a reliable proof that the mock was
|
||||
// selected. Outside test mode the default engine is built exactly as before.
|
||||
if (qEnvironmentVariableIsSet("MUDLET_TEST_MODE")) {
|
||||
speechUnit = new QTextToSpeech(qsl("mock"));
|
||||
} else {
|
||||
speechUnit = new QTextToSpeech();
|
||||
}
|
||||
bSpeechBuilt = true;
|
||||
bSpeechQueueing = false;
|
||||
|
||||
|
|
@ -103,6 +141,10 @@ int TLuaInterpreter::ttsSkip(lua_State* L)
|
|||
Q_UNUSED(L)
|
||||
TLuaInterpreter::ttsBuild();
|
||||
|
||||
// An explicit stop ends whatever is being spoken outright, so the Ready it
|
||||
// produces is the engine going idle and must drain the queue as it always
|
||||
// has - it is not the interruption ttsSpeak() has to defend against.
|
||||
speechInterrupting = false;
|
||||
speechUnit->stop();
|
||||
|
||||
return 0;
|
||||
|
|
@ -111,12 +153,22 @@ int TLuaInterpreter::ttsSkip(lua_State* L)
|
|||
// No documentation available in wiki - internal function
|
||||
void TLuaInterpreter::ttsStateChanged(QTextToSpeech::State state)
|
||||
{
|
||||
// ttsSpeak() announces an utterance itself when the engine was already
|
||||
// speaking, because there is then no state edge to announce it. An engine
|
||||
// that gets round to reporting the interruption afterwards ends up here
|
||||
// with an edge back into Speaking for that same utterance, which would tell
|
||||
// a script it started twice.
|
||||
const bool alreadyAnnounced = (state == QTextToSpeech::State::Speaking && speechStartAnnounced);
|
||||
|
||||
if (state != speechState) {
|
||||
speechState = state;
|
||||
TEvent event{};
|
||||
switch (state) {
|
||||
case QTextToSpeech::State::Paused:
|
||||
event.mArgumentList.append(QLatin1String("ttsSpeechPaused"));
|
||||
// Resuming has always announced the utterance again, so let it:
|
||||
// being paused is the end of what was announced before.
|
||||
speechStartAnnounced = false;
|
||||
break;
|
||||
case QTextToSpeech::State::Speaking:
|
||||
event.mArgumentList.append(QLatin1String("ttsSpeechStarted"));
|
||||
|
|
@ -140,9 +192,37 @@ void TLuaInterpreter::ttsStateChanged(QTextToSpeech::State state)
|
|||
if (state == QTextToSpeech::Speaking) {
|
||||
event.mArgumentList.append(speechCurrent);
|
||||
event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
// The engine has taken up what it was last given, so ttsSpeak() has
|
||||
// nothing left to announce and any interruption is over.
|
||||
speechStartAnnounced = true;
|
||||
speechInterrupting = false;
|
||||
}
|
||||
|
||||
mudlet::self()->getHostManager().postInterHostEvent(NULL, event, true);
|
||||
if (!alreadyAnnounced) {
|
||||
mudlet::self()->getHostManager().postInterHostEvent(NULL, event, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (state == QTextToSpeech::State::Ready && speechInterrupting) {
|
||||
// Not the engine falling idle but the utterance ttsSpeak() spoke over
|
||||
// ending inside say(). Draining here would speak a queued line on top of
|
||||
// the one the script has just asked for, and that one is then never
|
||||
// heard at all (#9659). The engine reports Speaking for the requested
|
||||
// utterance next, which clears this above.
|
||||
speechInterrupting = false;
|
||||
bSpeechQueueing = false;
|
||||
// Unless it does not: an engine that rejects an utterance outright, or
|
||||
// that replaces one without ever reporting a state change, leaves this
|
||||
// as the last word on the matter, and a queue waiting on a Ready that
|
||||
// is never coming waits forever. Look again once the engine has had its
|
||||
// chance to start speaking - if it is still idle, that Ready did mean
|
||||
// idle and the queue is free to go.
|
||||
QTimer::singleShot(scmInterruptedSpeechGrace, qApp, []() {
|
||||
if (!speechUnit.isNull() && speechUnit->state() == QTextToSpeech::State::Ready && !speechQueue.isEmpty()) {
|
||||
TLuaInterpreter::ttsStateChanged(QTextToSpeech::State::Ready);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (state != QTextToSpeech::State::Ready || speechQueue.empty()) {
|
||||
|
|
@ -150,11 +230,13 @@ void TLuaInterpreter::ttsStateChanged(QTextToSpeech::State state)
|
|||
return;
|
||||
}
|
||||
|
||||
QString textToSay;
|
||||
textToSay = speechQueue.takeFirst();
|
||||
const QString textToSay = speechQueue.takeFirst();
|
||||
|
||||
speechUnit->say(textToSay);
|
||||
// recorded before say() because the engine can switch to Speaking inside
|
||||
// that call, and this function reports speechCurrent with the event
|
||||
speechCurrent = textToSay;
|
||||
speechStartAnnounced = false;
|
||||
speechUnit->say(textToSay);
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -168,7 +250,7 @@ int TLuaInterpreter::ttsClearQueue(lua_State* L)
|
|||
int index = getVerifiedInt(L, __func__, 1, "index");
|
||||
index--;
|
||||
if (index < 0 || index >= speechQueue.size()) {
|
||||
return warnArgumentValue(L, __func__, qsl("index %1 out of bounds for queue size %2").arg(index + 1, speechQueue.size()));
|
||||
return warnArgumentValue(L, __func__, qsl("index %1 out of bounds for queue size %2").arg(index + 1).arg(speechQueue.size()));
|
||||
}
|
||||
|
||||
speechQueue.remove(index);
|
||||
|
|
@ -220,7 +302,7 @@ int TLuaInterpreter::ttsGetQueue(lua_State* L)
|
|||
if (lua_gettop(L) > 0) {
|
||||
int index = getVerifiedInt(L, __func__, 1, "index");
|
||||
index--;
|
||||
if (index < 0 || index > speechQueue.size()) {
|
||||
if (index < 0 || index >= speechQueue.size()) {
|
||||
lua_pushboolean(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
|
@ -311,10 +393,22 @@ int TLuaInterpreter::ttsPause(lua_State* L)
|
|||
int TLuaInterpreter::ttsQueue(lua_State* L)
|
||||
{
|
||||
TLuaInterpreter::ttsBuild();
|
||||
QString inputText = getVerifiedString(L, __func__, 1, "input").trimmed();
|
||||
if (inputText.isEmpty()) { // there's nothing more to say. discussion: https://github.com/Mudlet/Mudlet/issues/4688
|
||||
return warnArgumentValue(L, __func__, qsl("skipped empty text to speak (TTS)"));
|
||||
if (!checkStringArg(L, __func__, 1, "input")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
// the empty-input refusal has to stay ahead of the argument #2 check, as it
|
||||
// did before, or ttsQueue("", <bad index>) would raise instead of returning
|
||||
// nil and a message
|
||||
{
|
||||
const QString trimmedText = QString{lua_tostring(L, 1)}.trimmed();
|
||||
if (trimmedText.isEmpty()) { // there's nothing more to say. discussion: https://github.com/Mudlet/Mudlet/issues/4688
|
||||
return warnArgumentValue(L, __func__, qsl("skipped empty text to speak (TTS)"));
|
||||
}
|
||||
}
|
||||
if (lua_gettop(L) > 1 && !checkIntArg(L, __func__, 2, "index")) {
|
||||
return lua_error(L);
|
||||
}
|
||||
QString inputText = QString{lua_tostring(L, 1)}.trimmed();
|
||||
|
||||
std::vector<QString> const dontSpeak = {"<", ">", "<", ">"}; // discussion: https://github.com/Mudlet/Mudlet/issues/4689
|
||||
for (const QString& dropThis : dontSpeak) {
|
||||
|
|
@ -329,7 +423,7 @@ int TLuaInterpreter::ttsQueue(lua_State* L)
|
|||
|
||||
int index;
|
||||
if (lua_gettop(L) > 1) {
|
||||
index = getVerifiedInt(L, __func__, 2, "index");
|
||||
index = static_cast<int>(lua_tointeger(L, 2));
|
||||
index--;
|
||||
if (index < 0) {
|
||||
index = 0;
|
||||
|
|
@ -355,6 +449,9 @@ int TLuaInterpreter::ttsQueue(lua_State* L)
|
|||
|
||||
if (speechQueue.size() == 1 && speechUnit->state() == QTextToSpeech::State::Ready && !bSpeechQueueing) {
|
||||
bSpeechQueueing = true;
|
||||
// The engine says it is idle, so nothing is left of any utterance an
|
||||
// earlier ttsSpeak() interrupted and this queued line is free to start.
|
||||
speechInterrupting = false;
|
||||
TLuaInterpreter::ttsStateChanged(speechUnit->state());
|
||||
}
|
||||
|
||||
|
|
@ -392,8 +489,24 @@ int TLuaInterpreter::ttsSpeak(lua_State* L)
|
|||
}
|
||||
}
|
||||
|
||||
speechUnit->say(textToSay);
|
||||
// recorded before say() because the engine can switch to Speaking inside
|
||||
// that call, and ttsStateChanged() reports speechCurrent with the event
|
||||
speechCurrent = textToSay;
|
||||
speechStartAnnounced = false;
|
||||
// Every engine stops what it is saying inside say() and reports Ready for
|
||||
// it, some during the call and some shortly after. That Ready says the
|
||||
// interrupted utterance ended, not that the engine has nothing to do, so
|
||||
// ttsStateChanged() must not drain the queue over the top of this one.
|
||||
speechInterrupting = (speechUnit->state() == QTextToSpeech::State::Speaking);
|
||||
speechUnit->say(textToSay);
|
||||
|
||||
// An engine that was already speaking stays in the Speaking state through
|
||||
// all of that, and the events are raised off state edges - so without this
|
||||
// nothing tells a script that what is being spoken has changed (#9659).
|
||||
if (!speechStartAnnounced && speechUnit->state() == QTextToSpeech::State::Speaking) {
|
||||
speechStartAnnounced = true;
|
||||
raiseSpeechStartedEvent(textToSay);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
@ -511,7 +624,6 @@ int TLuaInterpreter::ttsSetVoiceByName(lua_State* L)
|
|||
for (const auto& voice : speechVoices) {
|
||||
if (voice.name() == nextVoice) {
|
||||
speechUnit->setVoice(voice);
|
||||
lua_pushboolean(L, true);
|
||||
|
||||
TEvent event{};
|
||||
event.mArgumentList.append(QLatin1String("ttsVoiceChanged"));
|
||||
|
|
@ -520,6 +632,7 @@ int TLuaInterpreter::ttsSetVoiceByName(lua_State* L)
|
|||
event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mudlet::self()->getHostManager().postInterHostEvent(NULL, event, true);
|
||||
|
||||
lua_pushboolean(L, true);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -40,10 +40,15 @@
|
|||
#include "mudlet.h"
|
||||
#include "GifTracker.h"
|
||||
|
||||
#include <QDialog>
|
||||
#include <QDockWidget>
|
||||
#include <QIcon>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QMimeData>
|
||||
#include <QProgressDialog>
|
||||
#include <QUiLoader>
|
||||
#include <QScrollBar>
|
||||
#include <QShortcut>
|
||||
#include <QSizePolicy>
|
||||
|
|
@ -82,6 +87,30 @@ TMainConsole::TMainConsole(Host* pH, QWidget* parent)
|
|||
|
||||
TMainConsole::~TMainConsole()
|
||||
{
|
||||
// There is one window in which a command line's destroyed() handler is unsafe:
|
||||
// after this console's members - mSubCommandLineMap among them - have been
|
||||
// destroyed, but before ~QObject severs incoming connections. The only command
|
||||
// lines that can be destroyed inside it are the ones QWidget::~QWidget deletes,
|
||||
// i.e. this console's own children, so sweeping those is enough. Command lines
|
||||
// created into a user window belong to a TDockWidget reparented onto the main
|
||||
// window instead, and can only die after ~QObject has already dropped the
|
||||
// connection. Children rather than map entries, because deleteCommandLine() and
|
||||
// resetMainConsole() drop the entry while the widget lives on until its
|
||||
// deferred delete is delivered.
|
||||
for (auto commandLine : findChildren<TCommandLine*>()) {
|
||||
disconnect(commandLine, &QObject::destroyed, this, nullptr);
|
||||
}
|
||||
mSubCommandLineMap.clear();
|
||||
|
||||
// Neither is a child of this console: the map dock is reparented onto the main
|
||||
// window by addDockWidget(), and the unpacking dialog is parentless. So neither
|
||||
// dies with the console automatically.
|
||||
if (mpDockableMapWidget) {
|
||||
mpDockableMapWidget->deleteLater();
|
||||
}
|
||||
if (mpUnpackingDialog) {
|
||||
mpUnpackingDialog->deleteLater();
|
||||
}
|
||||
if (mpHunspell_system) {
|
||||
Hunspell_destroy(mpHunspell_system);
|
||||
mpHunspell_system = nullptr;
|
||||
|
|
@ -131,6 +160,16 @@ std::optional<QSize> TMainConsole::getLabelSizeHint(const QString& name) const
|
|||
return {};
|
||||
}
|
||||
|
||||
std::optional<QString> TMainConsole::getLabelToolTip(const QString& name) const
|
||||
{
|
||||
auto pL = mLabelMap.value(name);
|
||||
if (!pL) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {pL->toolTip()};
|
||||
}
|
||||
|
||||
// NOLINTNEXTLINE(readability-make-member-function-const)
|
||||
std::pair<bool, QString> TMainConsole::setUserWindowStyleSheet(const QString& name, const QString& userWindowStyleSheet)
|
||||
{
|
||||
|
|
@ -146,6 +185,16 @@ std::pair<bool, QString> TMainConsole::setUserWindowStyleSheet(const QString& na
|
|||
return {false, qsl("userwindow name '%1' not found").arg(name)};
|
||||
}
|
||||
|
||||
std::optional<QString> TMainConsole::getUserWindowStyleSheet(const QString& name) const
|
||||
{
|
||||
auto pW = mDockWidgetMap.value(name);
|
||||
if (!pW) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {pW->styleSheet()};
|
||||
}
|
||||
|
||||
std::pair<bool, QString> TMainConsole::setCmdLineStyleSheet(const QString& name, const QString& styleSheet)
|
||||
{
|
||||
if (name.isEmpty() || !name.compare(qsl("main"))) {
|
||||
|
|
@ -161,6 +210,23 @@ std::pair<bool, QString> TMainConsole::setCmdLineStyleSheet(const QString& name,
|
|||
return {false, qsl("command-line name '%1' not found").arg(name)};
|
||||
}
|
||||
|
||||
std::optional<QString> TMainConsole::getCmdLineStyleSheet(const QString& name) const
|
||||
{
|
||||
if (name.isEmpty() || !name.compare(qsl("main"))) {
|
||||
if (auto pMain = mpHost->mpConsole->mpCommandLine) {
|
||||
return {pMain->styleSheet()};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
auto pN = mSubCommandLineMap.value(name);
|
||||
if (!pN) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {pN->styleSheet()};
|
||||
}
|
||||
|
||||
void TMainConsole::toggleLogging(bool isMessageEnabled)
|
||||
{
|
||||
const auto loggingPath = mudlet::getMudletPath(enums::profileDataItemPath, mpHost->getName(), qsl("autolog"));
|
||||
|
|
@ -469,11 +535,10 @@ void TMainConsole::resetMainConsole()
|
|||
itDockWidget.remove();
|
||||
}
|
||||
|
||||
QMutableMapIterator<QString, TCommandLine*> itCommandLine(mSubCommandLineMap);
|
||||
while (itCommandLine.hasNext()) {
|
||||
itCommandLine.next();
|
||||
itCommandLine.value()->deleteLater();
|
||||
itCommandLine.remove();
|
||||
const QList<TCommandLine*> commandLines = mSubCommandLineMap.values();
|
||||
for (auto commandLine : commandLines) {
|
||||
deregisterSubCommandLine(commandLine);
|
||||
commandLine->deleteLater();
|
||||
}
|
||||
|
||||
// Remaining SubConsole/Buffer entries (UserWindow ones were already removed above)
|
||||
|
|
@ -688,8 +753,12 @@ std::pair<bool, QString> TMainConsole::deleteCommandLine(const QString& name)
|
|||
return {false, QLatin1String("a command line cannot have an empty string as its name")};
|
||||
}
|
||||
|
||||
auto pCmdLine = mSubCommandLineMap.take(name);
|
||||
auto pCmdLine = mSubCommandLineMap.value(name);
|
||||
if (pCmdLine) {
|
||||
// Deregister rather than just take() the entry: the widget outlives this
|
||||
// call until its deferred delete is delivered, and its destroyed() handler
|
||||
// must not be left armed for a console that may be gone by then.
|
||||
deregisterSubCommandLine(pCmdLine);
|
||||
// Using deleteLater() rather than delete as it seems a safer option
|
||||
// given that this item is likely to be linked to some events and
|
||||
// suchlike:
|
||||
|
|
@ -828,7 +897,7 @@ std::pair<bool, QString> TMainConsole::setLabelCustomCursor(const QString& name,
|
|||
std::pair<bool, QString> TMainConsole::createMapper(const QString& windowname, int x, int y, int width, int height)
|
||||
{
|
||||
auto pW = mDockWidgetMap.value(windowname);
|
||||
auto pM = mpHost->mpDockableMapWidget;
|
||||
auto pM = mpDockableMapWidget;
|
||||
if (pM) {
|
||||
return {false, qsl("cannot create mapper. Do you already use a map window?")};
|
||||
}
|
||||
|
|
@ -863,12 +932,15 @@ std::pair<bool, QString> TMainConsole::createMapper(const QString& windowname, i
|
|||
}
|
||||
|
||||
mpHost->mpMap->pushErrorMessagesToFile(tr("Loading map(2) at %1 report").arg(now.toString(Qt::ISODate)), true);
|
||||
|
||||
TEvent mapOpenEvent{};
|
||||
mapOpenEvent.mArgumentList.append(QLatin1String("mapOpenEvent"));
|
||||
mapOpenEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mpHost->raiseEvent(mapOpenEvent);
|
||||
} else {
|
||||
mpMapper->updateAreaComboBox();
|
||||
mpMapper->resetAreaComboBoxToPlayerRoomArea();
|
||||
}
|
||||
|
||||
TEvent mapOpenEvent{};
|
||||
mapOpenEvent.mArgumentList.append(QLatin1String("mapOpenEvent"));
|
||||
mapOpenEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mpHost->raiseEvent(mapOpenEvent);
|
||||
}
|
||||
mpMapper->resize(width, height);
|
||||
mpMapper->move(x, y);
|
||||
|
|
@ -909,7 +981,7 @@ std::pair<bool, QString> TMainConsole::createCommandLine(const QString& windowna
|
|||
} else {
|
||||
pN = new TCommandLine(mpHost, name, TCommandLine::SubCommandLine, this, mpMainFrame);
|
||||
}
|
||||
mSubCommandLineMap[name] = pN;
|
||||
registerSubCommandLine(name, pN);
|
||||
pN->resize(width, height);
|
||||
pN->move(x, y);
|
||||
pN->show();
|
||||
|
|
@ -918,6 +990,37 @@ std::pair<bool, QString> TMainConsole::createCommandLine(const QString& windowna
|
|||
return {false, QLatin1String("couldn't create commandLine")};
|
||||
}
|
||||
|
||||
void TMainConsole::registerSubCommandLine(const QString& name, TCommandLine* pCommandLine)
|
||||
{
|
||||
if (auto pDisplaced = mSubCommandLineMap.value(name); pDisplaced && pDisplaced != pCommandLine) {
|
||||
// Would otherwise be left connected but unreachable by name
|
||||
deregisterSubCommandLine(pDisplaced);
|
||||
}
|
||||
mSubCommandLineMap[name] = pCommandLine;
|
||||
|
||||
// A TCommandLine is always a child widget of something else - the miniconsole
|
||||
// it is embedded in, or the user window / scroll box it was created into - so
|
||||
// it can be destroyed without deleteCommandLine() ever being called, and this
|
||||
// map does not hold QPointers. Without this the entry outlives the widget and
|
||||
// every later lookup of the name reads freed memory; TConsole::setFont() walks
|
||||
// the whole map, so even changing the display font in Preferences hits it.
|
||||
connect(pCommandLine, &QObject::destroyed, this, [this, pCommandLine]() {
|
||||
deregisterSubCommandLine(pCommandLine);
|
||||
});
|
||||
}
|
||||
|
||||
void TMainConsole::deregisterSubCommandLine(TCommandLine* pCommandLine)
|
||||
{
|
||||
// This is the only destroyed() connection made from a command line to this
|
||||
// console, so severing all of them is severing just that one.
|
||||
disconnect(pCommandLine, &QObject::destroyed, this, nullptr);
|
||||
// Erase by value rather than by name: a replacement command line may have been
|
||||
// registered under the same name in the meantime and must be left in place.
|
||||
mSubCommandLineMap.removeIf([pCommandLine](const auto& it) {
|
||||
return it.value() == pCommandLine;
|
||||
});
|
||||
}
|
||||
|
||||
std::pair<bool, QString> TMainConsole::createTextBox(const QString& windowname, const QString& name, int x, int y, int width, int height)
|
||||
{
|
||||
if (name.isEmpty()) {
|
||||
|
|
@ -1034,32 +1137,32 @@ bool TMainConsole::lowerWindow(const QString& name)
|
|||
|
||||
if (pC) {
|
||||
pC->lower();
|
||||
mpMainDisplay->lower();
|
||||
lowerMainDisplay();
|
||||
return true;
|
||||
}
|
||||
if (pL) {
|
||||
pL->lower();
|
||||
mpMainDisplay->lower();
|
||||
lowerMainDisplay();
|
||||
return true;
|
||||
}
|
||||
if (pM && !name.compare(QLatin1String("mapper"), Qt::CaseInsensitive)) {
|
||||
pM->lower();
|
||||
mpMainDisplay->lower();
|
||||
lowerMainDisplay();
|
||||
return true;
|
||||
}
|
||||
if (pS) {
|
||||
pS->lower();
|
||||
mpMainDisplay->lower();
|
||||
lowerMainDisplay();
|
||||
return true;
|
||||
}
|
||||
if (pN) {
|
||||
pN->lower();
|
||||
mpMainDisplay->lower();
|
||||
lowerMainDisplay();
|
||||
return true;
|
||||
}
|
||||
if (pT) {
|
||||
pT->lower();
|
||||
mpMainDisplay->lower();
|
||||
lowerMainDisplay();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -1361,6 +1464,32 @@ std::pair<bool, QString> TMainConsole::setUserWindowTitle(const QString& name, c
|
|||
return {false, qsl("internal error: TConsole \"%1\" is marked as a user window but does not have a TDockWidget to contain it").arg(name)};
|
||||
}
|
||||
|
||||
// The title is in .second when .first is true, otherwise .second is why there
|
||||
// is none. Mirrors setUserWindowTitle's checks in the same order and words, so
|
||||
// that a miniconsole sharing the name is not reported as a missing window.
|
||||
std::pair<bool, QString> TMainConsole::getUserWindowTitle(const QString& name) const
|
||||
{
|
||||
if (name.isEmpty()) {
|
||||
return {false, qsl("a user window cannot have an empty string as its name")};
|
||||
}
|
||||
|
||||
auto pC = mSubConsoleMap.value(name);
|
||||
if (!pC) {
|
||||
return {false, qsl("user window name '%1' not found").arg(name)};
|
||||
}
|
||||
|
||||
if (pC->getType() != UserWindow) {
|
||||
return {false, qsl("\"%1\" is not a user window").arg(name)};
|
||||
}
|
||||
|
||||
auto pD = mDockWidgetMap.value(name);
|
||||
if (!pD) {
|
||||
return {false, qsl("internal error: TConsole \"%1\" is marked as a user window but does not have a TDockWidget to contain it").arg(name)};
|
||||
}
|
||||
|
||||
return {true, pD->windowTitle()};
|
||||
}
|
||||
|
||||
bool TMainConsole::setTextFormat(const QString& name, const QColor& fgColor, const QColor& bgColor, const TChar::AttributeFlags& flags)
|
||||
{
|
||||
if (name.isEmpty() || name.compare(qsl("main"), Qt::CaseSensitive) == 0) {
|
||||
|
|
@ -1696,9 +1825,13 @@ void TMainConsole::showPackageDownloadProgress(const QString& title, const QStri
|
|||
qWarning() << "TMainConsole::showPackageDownloadProgress() WARNING - called with no host; ignoring the download-progress request.";
|
||||
return;
|
||||
}
|
||||
// a second server-triggered download can arrive mid-download; without the
|
||||
// close, the first dialog leaks frozen and its Cancel aborts the wrong download
|
||||
// A second server-triggered download can arrive mid-download (e.g. a
|
||||
// reconnect re-sends Client.GUI). QProgressDialog::close() emits canceled(),
|
||||
// so closing the superseded dialog while it is still wired to
|
||||
// slot_cancelPackageDownload() would abort the download this new dialog is
|
||||
// about to track; detach it before closing.
|
||||
if (mpPackageDownloadProgressDialog) {
|
||||
mpPackageDownloadProgressDialog->disconnect();
|
||||
mpPackageDownloadProgressDialog->close();
|
||||
}
|
||||
// placeholder range; reset by the first download-progress update
|
||||
|
|
@ -1725,6 +1858,178 @@ void TMainConsole::closePackageDownloadProgress()
|
|||
}
|
||||
}
|
||||
|
||||
void TMainConsole::createMapProgressDialog(const QString& title, const QString& label, const QString& cancelButtonText, int minimum, int maximum)
|
||||
{
|
||||
if (mpMapProgressDialog) {
|
||||
mpMapProgressDialog->hide();
|
||||
mpMapProgressDialog->deleteLater();
|
||||
}
|
||||
auto pHost = getHost();
|
||||
// If canceled() cannot be wired to the map, omit the cancel button rather
|
||||
// than show one that does nothing.
|
||||
const bool cancelWirable = pHost && !pHost->mpMap.isNull();
|
||||
// Deliberately not WA_DeleteOnClose: the JSON import keeps updating this
|
||||
// dialog from a processEvents loop, so it must outlive a mid-operation
|
||||
// dismissal; we delete it explicitly instead.
|
||||
mpMapProgressDialog = new QProgressDialog(label, cancelWirable ? cancelButtonText : QString(), minimum, maximum, this);
|
||||
mpMapProgressDialog->setWindowTitle(title);
|
||||
mpMapProgressDialog->setWindowIcon(QIcon(qsl(":/icons/mudlet_map_download.png")));
|
||||
mpMapProgressDialog->setAutoClose(false);
|
||||
mpMapProgressDialog->setAutoReset(false);
|
||||
// QProgressDialog still emits canceled() on Escape or window-close even with
|
||||
// no cancel button, so only connect it when the operation is cancelable;
|
||||
// otherwise a non-cancelable import could be aborted by a spurious cancel.
|
||||
if (cancelWirable && !cancelButtonText.isEmpty()) {
|
||||
connect(mpMapProgressDialog, &QProgressDialog::canceled, pHost->mpMap.data(), &TMap::slot_mapProgressDialogCancelled);
|
||||
}
|
||||
}
|
||||
|
||||
void TMainConsole::showMapTransferProgress(const QString& title, const QString& label, const QString& cancelButtonText)
|
||||
{
|
||||
createMapProgressDialog(title, label, cancelButtonText, 0, 0);
|
||||
mpMapProgressDialog->setMinimumWidth(300);
|
||||
mpMapProgressDialog->setMinimumDuration(0);
|
||||
mpMapProgressDialog->show();
|
||||
}
|
||||
|
||||
void TMainConsole::showMapJsonProgress(const QString& title, const QString& label, const QString& cancelButtonText, int maximum)
|
||||
{
|
||||
createMapProgressDialog(title, label, cancelButtonText, 0, maximum);
|
||||
mpMapProgressDialog->setWindowModality(Qt::NonModal);
|
||||
mpMapProgressDialog->setMinimumWidth(500);
|
||||
mpMapProgressDialog->setMinimumDuration(1);
|
||||
}
|
||||
|
||||
void TMainConsole::setMapProgressDialogLabel(const QString& text)
|
||||
{
|
||||
if (mpMapProgressDialog) {
|
||||
mpMapProgressDialog->setLabelText(text);
|
||||
}
|
||||
}
|
||||
|
||||
void TMainConsole::setMapProgressDialogRange(int minimum, int maximum)
|
||||
{
|
||||
if (mpMapProgressDialog) {
|
||||
mpMapProgressDialog->setRange(minimum, maximum);
|
||||
}
|
||||
}
|
||||
|
||||
void TMainConsole::setMapProgressDialogValue(int value)
|
||||
{
|
||||
if (mpMapProgressDialog) {
|
||||
mpMapProgressDialog->setValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
void TMainConsole::disableMapProgressDialogCancel()
|
||||
{
|
||||
if (mpMapProgressDialog) {
|
||||
// Taking the button away does not stop a window-close from emitting
|
||||
// canceled(), so drop the connection as well - by this point the
|
||||
// operation can no longer be stopped. Only ours goes, leaving
|
||||
// QProgressDialog's own canceled() -> cancel() wiring intact.
|
||||
if (auto pHost = getHost(); pHost && !pHost->mpMap.isNull()) {
|
||||
disconnect(mpMapProgressDialog, &QProgressDialog::canceled, pHost->mpMap.data(), &TMap::slot_mapProgressDialogCancelled);
|
||||
}
|
||||
mpMapProgressDialog->setCancelButton(nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
void TMainConsole::closeMapProgressDialog()
|
||||
{
|
||||
if (mpMapProgressDialog) {
|
||||
// hide() rather than close() so we don't re-enter QProgressDialog's
|
||||
// closeEvent -> cancel() while a cancel is already being handled.
|
||||
mpMapProgressDialog->hide();
|
||||
mpMapProgressDialog->deleteLater();
|
||||
// deleteLater() leaves the QPointer set until the event loop gets to
|
||||
// run, which a synchronous JSON operation will not let it do, so forget
|
||||
// the dialog now and make any late writes to it no-ops.
|
||||
mpMapProgressDialog = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void TMainConsole::createMapperDock(const QString& title, const QString& objectName)
|
||||
{
|
||||
mpDockableMapWidget = new QDockWidget(title);
|
||||
mpDockableMapWidget->setObjectName(objectName);
|
||||
}
|
||||
|
||||
void TMainConsole::showMapperScriptReminder()
|
||||
{
|
||||
QUiLoader loader;
|
||||
QFile file(qsl(":/ui/lacking_mapper_script.ui"));
|
||||
if (!file.open(QFile::ReadOnly)) {
|
||||
qWarning() << "TMainConsole::showMapperScriptReminder() WARNING - failed to open lacking_mapper_script.ui for reading:" << file.errorString();
|
||||
return;
|
||||
}
|
||||
|
||||
auto dialog = qobject_cast<QDialog*>(loader.load(&file, mudlet::self()));
|
||||
file.close();
|
||||
if (!dialog) {
|
||||
qWarning() << "TMainConsole::showMapperScriptReminder() WARNING - could not load the mapping-script reminder dialog.";
|
||||
return;
|
||||
}
|
||||
|
||||
connect(dialog, &QDialog::accepted, mudlet::self(), &mudlet::slot_openMappingScriptsPage);
|
||||
|
||||
dialog->show();
|
||||
dialog->raise();
|
||||
dialog->activateWindow();
|
||||
}
|
||||
|
||||
void TMainConsole::showUnpackingProgress(const QString& message, const QString& title)
|
||||
{
|
||||
// deleteLater() not close(): the dialog is parentless with no WA_DeleteOnClose,
|
||||
// so closing it would leak it once we overwrite the pointer below.
|
||||
if (mpUnpackingDialog) {
|
||||
mpUnpackingDialog->deleteLater();
|
||||
}
|
||||
|
||||
QUiLoader loader;
|
||||
QFile uiFile(qsl(":/ui/package_manager_unpack.ui"));
|
||||
if (!uiFile.open(QFile::ReadOnly)) {
|
||||
qWarning() << "TMainConsole::showUnpackingProgress() WARNING - failed to open package_manager_unpack.ui for reading:" << uiFile.errorString();
|
||||
return;
|
||||
}
|
||||
auto* pDialog = qobject_cast<QDialog*>(loader.load(&uiFile, nullptr));
|
||||
uiFile.close();
|
||||
if (!pDialog) {
|
||||
qWarning() << "TMainConsole::showUnpackingProgress() WARNING - could not load the unpacking progress dialog.";
|
||||
return;
|
||||
}
|
||||
mpUnpackingDialog = pDialog;
|
||||
|
||||
// Trap: processEvents() below can deliver a re-entrant install (or its
|
||||
// matching hide) that replaces or clears mpUnpackingDialog and disposes of
|
||||
// this frame's dialog. Drive a local pointer, never the member, and bail if
|
||||
// our dialog is taken out from under us.
|
||||
QPointer<QDialog> dialog = pDialog;
|
||||
|
||||
if (auto* pLabel = dialog->findChild<QLabel*>(qsl("label"))) {
|
||||
pLabel->setText(message);
|
||||
}
|
||||
dialog->hide(); // Must hide to change WindowModality
|
||||
dialog->setWindowTitle(title);
|
||||
dialog->setWindowModality(Qt::ApplicationModal);
|
||||
dialog->show();
|
||||
QCoreApplication::processEvents();
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
dialog->raise();
|
||||
dialog->repaint(); // Force a redraw
|
||||
QCoreApplication::processEvents(); // Try to ensure we are on top of any other dialogs and freshly drawn
|
||||
}
|
||||
|
||||
void TMainConsole::closeUnpackingProgress()
|
||||
{
|
||||
if (mpUnpackingDialog) {
|
||||
mpUnpackingDialog->deleteLater();
|
||||
mpUnpackingDialog = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void TMainConsole::setupVideoOutput(TMediaPlayer* player, bool& setupSucceeded)
|
||||
{
|
||||
setupSucceeded = false;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@
|
|||
|
||||
class TMediaPlayer;
|
||||
class TTextBox;
|
||||
class QDialog;
|
||||
class QDockWidget;
|
||||
class QProgressDialog;
|
||||
|
||||
class TMainConsole : public TConsole
|
||||
|
|
@ -68,14 +70,19 @@ public:
|
|||
QString getCurrentLine(const std::string&);
|
||||
TConsole* createBuffer(const QString& name);
|
||||
std::pair<bool, QString> setUserWindowStyleSheet(const QString& name, const QString& userWindowStyleSheet);
|
||||
std::optional<QString> getUserWindowStyleSheet(const QString& name) const;
|
||||
std::pair<bool, QString> setUserWindowTitle(const QString& name, const QString& text);
|
||||
std::pair<bool, QString> getUserWindowTitle(const QString& name) const;
|
||||
bool setTextFormat(const QString& name, const QColor& fgColor, const QColor& bgColor, const TChar::AttributeFlags& flags);
|
||||
TLabel* createLabel(const QString& windowname, const QString& name, int x, int y, int width, int height, bool fillBackground, bool clickThrough = false);
|
||||
std::pair<bool, QString> createMapper(const QString& windowname, int, int, int, int);
|
||||
std::pair<bool, QString> createCommandLine(const QString& windowname, const QString& name, int, int, int, int);
|
||||
void registerSubCommandLine(const QString& name, TCommandLine* pCommandLine);
|
||||
void deregisterSubCommandLine(TCommandLine* pCommandLine);
|
||||
std::pair<bool, QString> createTextBox(const QString& windowname, const QString& name, int, int, int, int);
|
||||
QSize getUserWindowSize(const QString& windowname) const;
|
||||
std::pair<bool, QString> setCmdLineStyleSheet(const QString& name, const QString& styleSheet);
|
||||
std::optional<QString> getCmdLineStyleSheet(const QString& name) const;
|
||||
std::pair<bool, QString> setLabelStyleSheet(const QString& name, const QString& stylesheet);
|
||||
std::optional<QString> getLabelStyleSheet(const QString& name) const;
|
||||
std::optional<QSize> getLabelSizeHint(const QString& name) const;
|
||||
|
|
@ -85,6 +92,7 @@ public:
|
|||
std::pair<bool, QString> deleteTextBox(const QString&);
|
||||
std::pair<bool, QString> deleteScrollBox(const QString&);
|
||||
std::pair<bool, QString> setLabelToolTip(const QString& name, const QString& text, double duration);
|
||||
std::optional<QString> getLabelToolTip(const QString& name) const;
|
||||
std::pair<bool, QString> setLabelCursor(const QString& name, int shape);
|
||||
std::pair<bool, QString> setLabelCustomCursor(const QString& name, const QString& pixMapLocation, int hotX, int hotY);
|
||||
bool setBackgroundImage(const QString& name, const QString& path);
|
||||
|
|
@ -95,6 +103,17 @@ public:
|
|||
void showPackageDownloadProgress(const QString& title, const QString& cancelText);
|
||||
void updatePackageDownloadProgress(qint64 got, qint64 total);
|
||||
void closePackageDownloadProgress();
|
||||
void showMapTransferProgress(const QString& title, const QString& label, const QString& cancelButtonText);
|
||||
void showMapJsonProgress(const QString& title, const QString& label, const QString& cancelButtonText, int maximum);
|
||||
void setMapProgressDialogLabel(const QString& text);
|
||||
void setMapProgressDialogRange(int minimum, int maximum);
|
||||
void setMapProgressDialogValue(int value);
|
||||
void disableMapProgressDialogCancel();
|
||||
void closeMapProgressDialog();
|
||||
void createMapperDock(const QString& title, const QString& objectName);
|
||||
void showMapperScriptReminder();
|
||||
void showUnpackingProgress(const QString& message, const QString& title);
|
||||
void closeUnpackingProgress();
|
||||
void setupVideoOutput(TMediaPlayer* player, bool& setupSucceeded);
|
||||
void hideVideoOutput(TMediaPlayer* player);
|
||||
const QString& getSystemSpellDictionary() const { return mSpellDic; }
|
||||
|
|
@ -131,6 +150,12 @@ public:
|
|||
QTextStream mLogStream;
|
||||
bool mLogToLogFile = false;
|
||||
QPointer<QProgressDialog> mpPackageDownloadProgressDialog;
|
||||
QPointer<QProgressDialog> mpMapProgressDialog;
|
||||
// Outlives Host::closeMapWidget(), which only hides it, so this being
|
||||
// non-null says the profile has made a map widget at some point, not that it
|
||||
// has one on screen - see Host::mapWidget() for the latter.
|
||||
QPointer<QDockWidget> mpDockableMapWidget;
|
||||
QPointer<QDialog> mpUnpackingDialog;
|
||||
|
||||
|
||||
public slots:
|
||||
|
|
@ -148,6 +173,8 @@ signals:
|
|||
|
||||
|
||||
private:
|
||||
void createMapProgressDialog(const QString& title, const QString& label, const QString& cancelButtonText, int minimum, int maximum);
|
||||
|
||||
// Was public in Host class but made private there and cloned to here
|
||||
// (for main TConsole) to prevent it being changed without going through the
|
||||
// process to load in the changed dictionary:
|
||||
|
|
|
|||
343
src/TMap.cpp
343
src/TMap.cpp
|
|
@ -39,15 +39,14 @@
|
|||
#include <QBuffer>
|
||||
#include <QDataStream>
|
||||
#include <QElapsedTimer>
|
||||
#include <QFileDialog>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QJsonValue>
|
||||
#include <QMetaMethod>
|
||||
#include <QPainter>
|
||||
#include <QPixmap>
|
||||
#include <QProgressDialog>
|
||||
#include <QSaveFile>
|
||||
#include <QSizeF>
|
||||
#include <chrono>
|
||||
|
|
@ -1010,7 +1009,7 @@ bool TMap::findPath(int from, int to)
|
|||
std::vector<cost> d(vertexCount);
|
||||
try {
|
||||
astar_search(g, start, distance_heuristic<mygraph_t, cost, std::vector<location>>(locations, goal), predecessor_map(&p[0]).distance_map(&d[0]).visitor(astar_goal_visitor<vertex>(goal)));
|
||||
} catch (found_goal) {
|
||||
} catch (const found_goal&) {
|
||||
qDebug() << "TMap::findPath(" << from << "," << to << ") INFO: time elapsed in A*:" << t.nsecsElapsed() * 1.0e-6 << "ms.";
|
||||
t.restart();
|
||||
if (!roomidToIndex.contains(to)) {
|
||||
|
|
@ -1158,13 +1157,15 @@ bool TMap::serialize(QDataStream& ofs, int saveVersion)
|
|||
ofs << mCustomEnvColors;
|
||||
ofs << mpRoomDB->hashToRoomID;
|
||||
if (mSaveVersion < 19) {
|
||||
// Save the data in the map user data for older versions
|
||||
mUserData.insert(qsl("system.fallback_mapSymbolFont"), mMapSymbolFont.toString());
|
||||
mUserData.insert(qsl("system.fallback_mapSymbolFontFudgeFactor"), QString::number(mMapSymbolFontFudgeFactor));
|
||||
mUserData.insert(qsl("system.fallback_onlyUseMapSymbolFont"), mIsOnlyMapSymbolFontToBeUsed ? qsl("true") : qsl("false"));
|
||||
}
|
||||
ofs << mUserData;
|
||||
if (mSaveVersion >= 19) {
|
||||
// Save the data in the map user data for older versions - use a local
|
||||
// copy so that saving does not modify the live map's user data:
|
||||
QMap<QString, QString> userData{mUserData};
|
||||
userData.insert(qsl("system.fallback_mapSymbolFont"), mMapSymbolFont.toString());
|
||||
userData.insert(qsl("system.fallback_mapSymbolFontFudgeFactor"), QString::number(mMapSymbolFontFudgeFactor));
|
||||
userData.insert(qsl("system.fallback_onlyUseMapSymbolFont"), mIsOnlyMapSymbolFontToBeUsed ? qsl("true") : qsl("false"));
|
||||
ofs << userData;
|
||||
} else {
|
||||
ofs << mUserData;
|
||||
// Save the data directly in supported format versions (19 and above)
|
||||
ofs << mMapSymbolFont;
|
||||
ofs << mMapSymbolFontFudgeFactor;
|
||||
|
|
@ -1316,16 +1317,6 @@ bool TMap::serialize(QDataStream& ofs, int saveVersion)
|
|||
}
|
||||
|
||||
ofs << pR->getId();
|
||||
if (mSaveVersion <= 19) {
|
||||
if (!pR->mSymbol.isEmpty()) {
|
||||
pR->userData.insert(QLatin1String("system.fallback_symbol"), pR->mSymbol);
|
||||
}
|
||||
}
|
||||
if (mSaveVersion < 21) {
|
||||
if (pR->hidden) {
|
||||
pR->userData.insert(QLatin1String("system.fallback_hidden"), QLatin1String("true"));
|
||||
}
|
||||
}
|
||||
ofs << pR->getArea();
|
||||
ofs << pR->x();
|
||||
ofs << pR->y();
|
||||
|
|
@ -1380,10 +1371,6 @@ bool TMap::serialize(QDataStream& ofs, int saveVersion)
|
|||
|
||||
if (mSaveVersion >= 21) {
|
||||
ofs << pR->mSymbolColor;
|
||||
} else {
|
||||
if (pR->mSymbolColor.isValid()) {
|
||||
pR->userData.insert(QLatin1String("system.fallback_symbol_color"), pR->mSymbolColor.name());
|
||||
}
|
||||
}
|
||||
|
||||
// Border properties are stored in userData (not binary stream) to avoid map bloat
|
||||
|
|
@ -1398,7 +1385,25 @@ bool TMap::serialize(QDataStream& ofs, int saveVersion)
|
|||
pR->userData.remove(ROOM_UI_BORDERTHICKNESS);
|
||||
}
|
||||
|
||||
ofs << pR->userData;
|
||||
// Formats before 21 carry the hidden flag and symbol color - and
|
||||
// formats before 19 the symbol - as user data fallbacks; use a local
|
||||
// copy so that saving does not modify the live room's user data.
|
||||
// TRoom::restore() strips each key again when loading a format that
|
||||
// carries it, so none may appear in formats which store the value
|
||||
// directly in the stream:
|
||||
QMap<QString, QString> userData{pR->userData};
|
||||
if (mSaveVersion < 21) {
|
||||
if (pR->hidden) {
|
||||
userData.insert(QLatin1String("system.fallback_hidden"), QLatin1String("true"));
|
||||
}
|
||||
if (pR->mSymbolColor.isValid()) {
|
||||
userData.insert(QLatin1String("system.fallback_symbol_color"), pR->mSymbolColor.name());
|
||||
}
|
||||
}
|
||||
if (mSaveVersion < 19 && !pR->mSymbol.isEmpty()) {
|
||||
userData.insert(QLatin1String("system.fallback_symbol"), pR->mSymbol);
|
||||
}
|
||||
ofs << userData;
|
||||
if (mSaveVersion >= 20) {
|
||||
// Before version 20 stored the style as an Latin1 string, the color
|
||||
// as a QList<int> for the RGB components and used UPPER case for
|
||||
|
|
@ -1591,6 +1596,7 @@ bool TMap::validatePotentialMapFile(QFile& file, QDataStream& ifs)
|
|||
|
||||
bool TMap::restore(QString location)
|
||||
{
|
||||
const MapOperationScope operationScope(this);
|
||||
qDebug().noquote().nospace() << "TMap::restore(\"" << location << "\") INFO: restoring map of Profile: \"" << mProfileName << "\" URL: " << mpHost->getUrl();
|
||||
|
||||
QElapsedTimer _time;
|
||||
|
|
@ -1696,6 +1702,12 @@ bool TMap::restore(QString location)
|
|||
}
|
||||
ifs >> mMapSymbolFontFudgeFactor;
|
||||
ifs >> mIsOnlyMapSymbolFontToBeUsed;
|
||||
// Clean up stale fallback keys that past versions could leave
|
||||
// behind in the live map's user data (and thus in files saved
|
||||
// from it) after saving in a format before 19:
|
||||
mUserData.remove(qsl("system.fallback_mapSymbolFont"));
|
||||
mUserData.remove(qsl("system.fallback_mapSymbolFontFudgeFactor"));
|
||||
mUserData.remove(qsl("system.fallback_onlyUseMapSymbolFont"));
|
||||
} else {
|
||||
// Fallback to reading the data from the map user data - and
|
||||
// remove it from the data the user will see:
|
||||
|
|
@ -2487,6 +2499,7 @@ void TMap::pushErrorMessagesToFile(const QString title, const bool isACleanup)
|
|||
|
||||
void TMap::downloadMap(const QString& remoteUrl, const QString& localFileName)
|
||||
{
|
||||
const MapOperationScope operationScope(this);
|
||||
Host* pHost = mpHost;
|
||||
if (!pHost) {
|
||||
return;
|
||||
|
|
@ -2500,6 +2513,15 @@ void TMap::downloadMap(const QString& remoteUrl, const QString& localFileName)
|
|||
postMessage(warnMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mMapProgressStandalone) {
|
||||
//: Shown in the main console when a map download is refused
|
||||
const QString warnMsg = tr("[ WARN ] - Attempt made to download an XML map while a map import or\n"
|
||||
"export is already in progress - wait for that operation to complete\n"
|
||||
"before retrying!");
|
||||
postMessage(warnMsg);
|
||||
return;
|
||||
}
|
||||
mImportRunning = true;
|
||||
// MUST clear this flag when done under ALL circumstances
|
||||
|
||||
|
|
@ -2591,6 +2613,23 @@ bool TMap::importMap(QFile& file, QString* errMsg)
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mMapProgressStandalone) {
|
||||
// readXmlMapFile() would see the JSON operation's progress dialog as its
|
||||
// own, skip creating one, and then mapClear() the map out from under it:
|
||||
if (errMsg) {
|
||||
//: Error returned by the loadMap() Lua function
|
||||
*errMsg = tr("loadMap: unable to perform request, a map import or export is\n"
|
||||
"already in progress.");
|
||||
} else {
|
||||
//: Shown in the main console when a map import is refused
|
||||
const QString warnMsg = tr("[ WARN ] - Attempt made to import an XML map while a map import or\n"
|
||||
"export is already in progress - wait for that operation to complete\n"
|
||||
"before retrying!");
|
||||
postMessage(warnMsg);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
mImportRunning = true;
|
||||
// MUST clear this flag when done under ALL circumstances
|
||||
|
||||
|
|
@ -2602,6 +2641,7 @@ bool TMap::importMap(QFile& file, QString* errMsg)
|
|||
|
||||
bool TMap::readXmlMapFile(QFile& file, QString* errMsg)
|
||||
{
|
||||
const MapOperationScope operationScope(this);
|
||||
Host* pHost = mpHost;
|
||||
bool isLocalImport = false;
|
||||
if (!pHost) {
|
||||
|
|
@ -2828,23 +2868,18 @@ void TMap::createTransferProgress(const QString& title, const QString& label, bo
|
|||
return;
|
||||
}
|
||||
|
||||
mpProgressDialog = new QProgressDialog(label, cancelable ? tr("Abort") : QString(), 0, 0);
|
||||
mpProgressDialog->setWindowTitle(title);
|
||||
mpProgressDialog->setWindowIcon(QIcon(qsl(":/icons/mudlet_map_download.png")));
|
||||
mpProgressDialog->setMinimumWidth(300);
|
||||
mpProgressDialog->setAutoClose(false);
|
||||
mpProgressDialog->setAutoReset(false);
|
||||
mpProgressDialog->setMinimumDuration(0); // Normally waits for 4 seconds before showing
|
||||
if (cancelable) {
|
||||
connect(mpProgressDialog, &QProgressDialog::canceled, this, &TMap::slot_downloadCancel);
|
||||
}
|
||||
mpProgressDialog->show();
|
||||
mMapProgressStandalone = true;
|
||||
mMapProgressIsTransfer = true;
|
||||
mMapProgressCancelRequested = false;
|
||||
mMapProgressStandaloneMaximum = 0;
|
||||
warnIfMapProgressUnwired(__func__, true);
|
||||
emit signal_mapTransferProgressStart(title, label, cancelable ? tr("Abort") : QString());
|
||||
}
|
||||
|
||||
void TMap::updateTransferProgressLabel(const QString& text)
|
||||
{
|
||||
if (mpProgressDialog) {
|
||||
mpProgressDialog->setLabelText(text);
|
||||
if (mMapProgressStandalone) {
|
||||
emit signal_mapProgressSetLabel(text);
|
||||
} else if (mpMapper) {
|
||||
mpMapper->setMapProgressLabel(text);
|
||||
}
|
||||
|
|
@ -2852,8 +2887,9 @@ void TMap::updateTransferProgressLabel(const QString& text)
|
|||
|
||||
void TMap::updateTransferProgressRange(int minimum, int maximum)
|
||||
{
|
||||
if (mpProgressDialog) {
|
||||
mpProgressDialog->setRange(minimum, maximum);
|
||||
if (mMapProgressStandalone) {
|
||||
mMapProgressStandaloneMaximum = maximum;
|
||||
emit signal_mapProgressSetRange(minimum, maximum);
|
||||
} else if (mpMapper) {
|
||||
mpMapper->setMapProgressRange(minimum, maximum);
|
||||
}
|
||||
|
|
@ -2861,8 +2897,8 @@ void TMap::updateTransferProgressRange(int minimum, int maximum)
|
|||
|
||||
void TMap::updateTransferProgressValue(int value)
|
||||
{
|
||||
if (mpProgressDialog) {
|
||||
mpProgressDialog->setValue(value);
|
||||
if (mMapProgressStandalone) {
|
||||
emit signal_mapProgressSetValue(value);
|
||||
} else if (mpMapper) {
|
||||
mpMapper->setMapProgressValue(value);
|
||||
}
|
||||
|
|
@ -2870,8 +2906,8 @@ void TMap::updateTransferProgressValue(int value)
|
|||
|
||||
int TMap::transferProgressMaximum() const
|
||||
{
|
||||
if (mpProgressDialog) {
|
||||
return mpProgressDialog->maximum();
|
||||
if (mMapProgressStandalone) {
|
||||
return mMapProgressStandaloneMaximum;
|
||||
}
|
||||
if (mpMapper) {
|
||||
return mpMapper->mapProgressMaximum();
|
||||
|
|
@ -2881,13 +2917,13 @@ int TMap::transferProgressMaximum() const
|
|||
|
||||
bool TMap::hasActiveTransferProgress() const
|
||||
{
|
||||
return mpProgressDialog != nullptr || (mpMapper && mpMapper->isMapProgressVisible());
|
||||
return mMapProgressStandalone || (mpMapper && mpMapper->isMapProgressVisible());
|
||||
}
|
||||
|
||||
void TMap::disableTransferProgressCancel()
|
||||
{
|
||||
if (mpProgressDialog) {
|
||||
mpProgressDialog->setCancelButton(nullptr);
|
||||
if (mMapProgressStandalone) {
|
||||
emit signal_mapProgressDisableCancel();
|
||||
} else if (mpMapper) {
|
||||
mpMapper->setMapProgressCancelable(false);
|
||||
}
|
||||
|
|
@ -2895,9 +2931,12 @@ void TMap::disableTransferProgressCancel()
|
|||
|
||||
void TMap::clearTransferProgress()
|
||||
{
|
||||
if (mpProgressDialog) {
|
||||
mpProgressDialog->deleteLater();
|
||||
mpProgressDialog = nullptr;
|
||||
// Only close a transfer-owned standalone dialog: a concurrent JSON
|
||||
// import/export owns the standalone progress state and must keep it.
|
||||
if (mMapProgressStandalone && mMapProgressIsTransfer) {
|
||||
mMapProgressStandalone = false;
|
||||
mMapProgressIsTransfer = false;
|
||||
emit signal_mapProgressClose();
|
||||
return;
|
||||
}
|
||||
if (mpMapper) {
|
||||
|
|
@ -2906,6 +2945,45 @@ void TMap::clearTransferProgress()
|
|||
}
|
||||
}
|
||||
|
||||
void TMap::requestMapOperationAbort()
|
||||
{
|
||||
if (!mMapOperationDepth || mMapOperationAbortRequested) {
|
||||
return;
|
||||
}
|
||||
mMapOperationAbortRequested = true;
|
||||
// Deliberately not slot_mapProgressDialogCancelled(): that is the user
|
||||
// pressing Abort and says so in the console, whereas this is the profile
|
||||
// going away and has no console left to say it to. What it does share is
|
||||
// the flag the JSON import and export poll at their next progress step, and
|
||||
// dropping a download that would otherwise hold the close up on the network.
|
||||
mMapProgressCancelRequested = true;
|
||||
if (mMapProgressIsTransfer && mpNetworkReply) {
|
||||
mpNetworkReply->abort();
|
||||
}
|
||||
}
|
||||
|
||||
void TMap::slot_mapProgressDialogCancelled()
|
||||
{
|
||||
// The JSON path polls mMapProgressCancelRequested in its increment loop; the
|
||||
// transfer path needs its network reply aborted here.
|
||||
mMapProgressCancelRequested = true;
|
||||
if (mMapProgressIsTransfer) {
|
||||
slot_downloadCancel();
|
||||
}
|
||||
}
|
||||
|
||||
void TMap::warnIfMapProgressUnwired(const char* context, const bool transferPath)
|
||||
{
|
||||
static const QMetaMethod transferStart = QMetaMethod::fromSignal(&TMap::signal_mapTransferProgressStart);
|
||||
static const QMetaMethod jsonStart = QMetaMethod::fromSignal(&TMap::signal_mapJsonProgressStart);
|
||||
static const QMetaMethod progressClose = QMetaMethod::fromSignal(&TMap::signal_mapProgressClose);
|
||||
if (isSignalConnected(transferPath ? transferStart : jsonStart) && isSignalConnected(progressClose)) {
|
||||
return;
|
||||
}
|
||||
qWarning().nospace() << "TMap::" << context
|
||||
<< "() WARNING - no frontend is connected to show the map progress dialog; the operation will run without a visible progress dialog and cannot be canceled from one.";
|
||||
}
|
||||
|
||||
QHash<QString, QSet<int>> TMap::roomSymbolsHash()
|
||||
{
|
||||
QHash<QString, QSet<int>> results;
|
||||
|
|
@ -2974,6 +3052,7 @@ void TMap::setRoomNamesShown(bool shown)
|
|||
*/
|
||||
std::pair<bool, QString> TMap::writeJsonMapFile(const QString& dest)
|
||||
{
|
||||
const MapOperationScope operationScope(this);
|
||||
QString destination{dest};
|
||||
|
||||
if (destination.isEmpty()) {
|
||||
|
|
@ -2989,7 +3068,7 @@ std::pair<bool, QString> TMap::writeJsonMapFile(const QString& dest)
|
|||
destination.append(QLatin1String(".json"));
|
||||
}
|
||||
|
||||
if (mpProgressDialog) {
|
||||
if (mMapProgressStandalone) {
|
||||
return {false, qsl("import or export already in progress")};
|
||||
}
|
||||
|
||||
|
|
@ -3002,35 +3081,31 @@ std::pair<bool, QString> TMap::writeJsonMapFile(const QString& dest)
|
|||
}
|
||||
}
|
||||
|
||||
mpProgressDialog = new QProgressDialog(tr("Exporting JSON map data from %1\n"
|
||||
"Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...")
|
||||
.arg(mProfileName,
|
||||
QLatin1String("0"),
|
||||
QString::number(mProgressDialogAreasTotal),
|
||||
QLatin1String("0"),
|
||||
QString::number(mProgressDialogRoomsTotal),
|
||||
QLatin1String("0"),
|
||||
QString::number(mProgressDialogLabelsTotal)),
|
||||
tr("Abort"),
|
||||
0,
|
||||
mProgressDialogRoomsTotal,
|
||||
mpHost->mpConsole);
|
||||
mpProgressDialog->setValue(0);
|
||||
mpProgressDialog->setWindowModality(Qt::NonModal);
|
||||
mMapProgressStandalone = true;
|
||||
mMapProgressIsTransfer = false;
|
||||
mMapProgressCancelRequested = false;
|
||||
mMapProgressStandaloneMaximum = static_cast<int>(mProgressDialogRoomsTotal);
|
||||
warnIfMapProgressUnwired(__func__, false);
|
||||
//: This is a title of a progress window.
|
||||
mpProgressDialog->setWindowTitle(tr("Map JSON export"));
|
||||
mpProgressDialog->setWindowIcon(QIcon(qsl(":/icons/mudlet_map_download.png")));
|
||||
mpProgressDialog->setMinimumWidth(500);
|
||||
mpProgressDialog->setAutoClose(false);
|
||||
mpProgressDialog->setAutoReset(false);
|
||||
mpProgressDialog->setMinimumDuration(1); // Normally waits for 4 seconds before showing
|
||||
emit signal_mapJsonProgressStart(tr("Map JSON export"),
|
||||
tr("Exporting JSON map data from %1\n"
|
||||
"Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...")
|
||||
.arg(mProfileName,
|
||||
QLatin1String("0"),
|
||||
QString::number(mProgressDialogAreasTotal),
|
||||
QLatin1String("0"),
|
||||
QString::number(mProgressDialogRoomsTotal),
|
||||
QLatin1String("0"),
|
||||
QString::number(mProgressDialogLabelsTotal)),
|
||||
tr("Abort"),
|
||||
static_cast<int>(mProgressDialogRoomsTotal));
|
||||
emit signal_mapProgressSetValue(0);
|
||||
qApp->processEvents();
|
||||
QSaveFile file(destination);
|
||||
if (!file.open(QFile::OpenMode(QFile::Text | QFile::WriteOnly))) {
|
||||
qWarning().noquote().nospace() << "TMap::writeJsonMapFile(...) WARNING - Could not open save file \"" << destination << "\".";
|
||||
mpProgressDialog->setAttribute(Qt::WA_DeleteOnClose, true);
|
||||
mpProgressDialog->close();
|
||||
mpProgressDialog = nullptr;
|
||||
emit signal_mapProgressClose();
|
||||
mMapProgressStandalone = false;
|
||||
return {false, qsl("could not open save file \"%1\", reason: %2").arg(destination.toHtmlEscaped(), file.errorString())};
|
||||
}
|
||||
|
||||
|
|
@ -3066,9 +3141,8 @@ std::pair<bool, QString> TMap::writeJsonMapFile(const QString& dest)
|
|||
}
|
||||
if (abort) {
|
||||
file.cancelWriting();
|
||||
mpProgressDialog->setAttribute(Qt::WA_DeleteOnClose, true);
|
||||
mpProgressDialog->close();
|
||||
mpProgressDialog = nullptr;
|
||||
emit signal_mapProgressClose();
|
||||
mMapProgressStandalone = false;
|
||||
return {false, qsl("aborted by user")};
|
||||
}
|
||||
|
||||
|
|
@ -3147,20 +3221,19 @@ std::pair<bool, QString> TMap::writeJsonMapFile(const QString& dest)
|
|||
mapObj.insert(QLatin1String("playerRoomOuterDiameterPercentage"), static_cast<double>(mPlayerRoomOuterDiameterPercentage));
|
||||
mapObj.insert(QLatin1String("playerRoomInnerDiameterPercentage"), static_cast<double>(mPlayerRoomInnerDiameterPercentage));
|
||||
|
||||
mpProgressDialog->setLabelText(tr("Exporting JSON map file from %1 - writing data to file:\n"
|
||||
"%2 ...")
|
||||
.arg(mProfileName, destination));
|
||||
mpProgressDialog->setValue(0);
|
||||
emit signal_mapProgressSetLabel(tr("Exporting JSON map file from %1 - writing data to file:\n"
|
||||
"%2 ...")
|
||||
.arg(mProfileName, destination));
|
||||
emit signal_mapProgressSetValue(0);
|
||||
// Hide the cancel button as we can't stop now:
|
||||
mpProgressDialog->setCancelButton(nullptr);
|
||||
emit signal_mapProgressDisableCancel();
|
||||
file.write(QJsonDocument(mapObj).toJson(QJsonDocument::Indented));
|
||||
if (!file.commit()) {
|
||||
qDebug() << "TMap::writeJsonMapFile: error saving JSON map: " << file.errorString();
|
||||
}
|
||||
|
||||
mpProgressDialog->setAttribute(Qt::WA_DeleteOnClose, true);
|
||||
mpProgressDialog->close();
|
||||
mpProgressDialog = nullptr;
|
||||
emit signal_mapProgressClose();
|
||||
mMapProgressStandalone = false;
|
||||
|
||||
return {file.error() == QFileDevice::NoError, ((file.error() == QFileDevice::NoError) ? QString() : qsl("could not export file, reason: %1").arg(file.errorString()))};
|
||||
}
|
||||
|
|
@ -3168,12 +3241,13 @@ std::pair<bool, QString> TMap::writeJsonMapFile(const QString& dest)
|
|||
// The translatable messages are used within this file and do not need to
|
||||
// mention the file concerned whereas the untranslated messages are used by the
|
||||
// Lua sub-system and do need to report the file:
|
||||
std::pair<bool, QString> TMap::readJsonMapFile(const QString& source, const bool translatableTexts, const bool allowUserCancellation)
|
||||
std::pair<bool, QString> TMap::readJsonMapFile(const QString& source, const bool translatableTexts)
|
||||
{
|
||||
const MapOperationScope operationScope(this);
|
||||
const QString oldDefaultAreaName{mDefaultAreaName};
|
||||
const QString oldUnnamedName{mUnnamedAreaName};
|
||||
|
||||
if (mpProgressDialog) {
|
||||
if (mMapProgressStandalone) {
|
||||
return {false, (translatableTexts ? tr("import or export already in progress") : qsl("import or export already in progress"))};
|
||||
}
|
||||
|
||||
|
|
@ -3227,28 +3301,25 @@ std::pair<bool, QString> TMap::readJsonMapFile(const QString& source, const bool
|
|||
mProgressDialogRoomsCount = 0;
|
||||
mProgressDialogLabelsTotal = qRound(mapObj[QLatin1String("labelCount")].toDouble());
|
||||
mProgressDialogLabelsCount = 0;
|
||||
mpProgressDialog = new QProgressDialog(tr("Importing JSON map data to %1\n"
|
||||
"Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...")
|
||||
.arg(mProfileName,
|
||||
QLatin1String("0"),
|
||||
QString::number(mProgressDialogAreasTotal),
|
||||
QLatin1String("0"),
|
||||
QString::number(mProgressDialogRoomsTotal),
|
||||
QLatin1String("0"),
|
||||
QString::number(mProgressDialogLabelsTotal)),
|
||||
(allowUserCancellation ? tr("Abort") : QString()),
|
||||
0,
|
||||
mProgressDialogRoomsTotal,
|
||||
mpHost->mpConsole);
|
||||
mpProgressDialog->setValue(0);
|
||||
mpProgressDialog->setWindowModality(Qt::NonModal);
|
||||
mMapProgressStandalone = true;
|
||||
mMapProgressIsTransfer = false;
|
||||
mMapProgressCancelRequested = false;
|
||||
mMapProgressStandaloneMaximum = static_cast<int>(mProgressDialogRoomsTotal);
|
||||
warnIfMapProgressUnwired(__func__, false);
|
||||
//: This is a title of a progress window.
|
||||
mpProgressDialog->setWindowTitle(tr("Map JSON import"));
|
||||
mpProgressDialog->setWindowIcon(QIcon(qsl(":/icons/mudlet_map_download.png")));
|
||||
mpProgressDialog->setMinimumWidth(500);
|
||||
mpProgressDialog->setAutoClose(false);
|
||||
mpProgressDialog->setAutoReset(false);
|
||||
mpProgressDialog->setMinimumDuration(1); // Normally waits for 4 seconds before showing
|
||||
emit signal_mapJsonProgressStart(tr("Map JSON import"),
|
||||
tr("Importing JSON map data to %1\n"
|
||||
"Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...")
|
||||
.arg(mProfileName,
|
||||
QLatin1String("0"),
|
||||
QString::number(mProgressDialogAreasTotal),
|
||||
QLatin1String("0"),
|
||||
QString::number(mProgressDialogRoomsTotal),
|
||||
QLatin1String("0"),
|
||||
QString::number(mProgressDialogLabelsTotal)),
|
||||
tr("Abort"),
|
||||
static_cast<int>(mProgressDialogRoomsTotal));
|
||||
emit signal_mapProgressSetValue(0);
|
||||
qApp->processEvents();
|
||||
|
||||
mDefaultAreaName = mapObj[QLatin1String("defaultAreaName")].toString();
|
||||
|
|
@ -3325,18 +3396,15 @@ std::pair<bool, QString> TMap::readJsonMapFile(const QString& source, const bool
|
|||
auto [id, name] = pArea->readJsonArea(mapObj.value(QLatin1String("areas")).toArray(), i);
|
||||
++mProgressDialogAreasCount;
|
||||
if (incrementJsonProgressDialog(false, true, 0)) {
|
||||
if (allowUserCancellation) {
|
||||
abort = true;
|
||||
}
|
||||
abort = true;
|
||||
break;
|
||||
}
|
||||
// This will populate the TRoomDB::areas and TRoomDB::areaNameMap:
|
||||
pNewRoomDB->addArea(pArea.release(), id, name);
|
||||
}
|
||||
if (abort) {
|
||||
mpProgressDialog->setAttribute(Qt::WA_DeleteOnClose, true);
|
||||
mpProgressDialog->close();
|
||||
mpProgressDialog = nullptr;
|
||||
emit signal_mapProgressClose();
|
||||
mMapProgressStandalone = false;
|
||||
mDefaultAreaName = oldDefaultAreaName;
|
||||
mUnnamedAreaName = oldUnnamedName;
|
||||
return {false, (translatableTexts ? tr("aborted by user") : qsl("aborted by user"))};
|
||||
|
|
@ -3368,9 +3436,8 @@ std::pair<bool, QString> TMap::readJsonMapFile(const QString& source, const bool
|
|||
if (mpMapper && mpMapper->mp2dMap) {
|
||||
mpMapper->mp2dMap->setPlayerRoomStyle(mPlayerRoomStyle);
|
||||
}
|
||||
mpProgressDialog->setAttribute(Qt::WA_DeleteOnClose, true);
|
||||
mpProgressDialog->close();
|
||||
mpProgressDialog = nullptr;
|
||||
emit signal_mapProgressClose();
|
||||
mMapProgressStandalone = false;
|
||||
return {true, QString()};
|
||||
}
|
||||
|
||||
|
|
@ -3469,30 +3536,30 @@ bool TMap::incrementJsonProgressDialog(const bool isExportNotImport, const bool
|
|||
mProgressDialogLabelsCount += increment;
|
||||
}
|
||||
|
||||
mpProgressDialog->setValue(mProgressDialogRoomsCount);
|
||||
emit signal_mapProgressSetValue(static_cast<int>(mProgressDialogRoomsCount));
|
||||
if (isExportNotImport) {
|
||||
mpProgressDialog->setLabelText(tr("Exporting JSON map data from %1\n"
|
||||
"Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...")
|
||||
.arg(mProfileName,
|
||||
QString::number(mProgressDialogAreasCount),
|
||||
QString::number(mProgressDialogAreasTotal),
|
||||
QString::number(mProgressDialogRoomsCount),
|
||||
QString::number(mProgressDialogRoomsTotal),
|
||||
QString::number(mProgressDialogLabelsCount),
|
||||
QString::number(mProgressDialogLabelsTotal)));
|
||||
emit signal_mapProgressSetLabel(tr("Exporting JSON map data from %1\n"
|
||||
"Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...")
|
||||
.arg(mProfileName,
|
||||
QString::number(mProgressDialogAreasCount),
|
||||
QString::number(mProgressDialogAreasTotal),
|
||||
QString::number(mProgressDialogRoomsCount),
|
||||
QString::number(mProgressDialogRoomsTotal),
|
||||
QString::number(mProgressDialogLabelsCount),
|
||||
QString::number(mProgressDialogLabelsTotal)));
|
||||
} else {
|
||||
mpProgressDialog->setLabelText(tr("Importing JSON map data to %1\n"
|
||||
"Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...")
|
||||
.arg(mProfileName,
|
||||
QString::number(mProgressDialogAreasCount),
|
||||
QString::number(mProgressDialogAreasTotal),
|
||||
QString::number(mProgressDialogRoomsCount),
|
||||
QString::number(mProgressDialogRoomsTotal),
|
||||
QString::number(mProgressDialogLabelsCount),
|
||||
QString::number(mProgressDialogLabelsTotal)));
|
||||
emit signal_mapProgressSetLabel(tr("Importing JSON map data to %1\n"
|
||||
"Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...")
|
||||
.arg(mProfileName,
|
||||
QString::number(mProgressDialogAreasCount),
|
||||
QString::number(mProgressDialogAreasTotal),
|
||||
QString::number(mProgressDialogRoomsCount),
|
||||
QString::number(mProgressDialogRoomsTotal),
|
||||
QString::number(mProgressDialogLabelsCount),
|
||||
QString::number(mProgressDialogLabelsTotal)));
|
||||
}
|
||||
qApp->processEvents();
|
||||
return mpProgressDialog->wasCanceled();
|
||||
return mMapProgressCancelRequested;
|
||||
}
|
||||
|
||||
void TMap::updateArea(int areaId)
|
||||
|
|
|
|||
76
src/TMap.h
76
src/TMap.h
|
|
@ -67,7 +67,6 @@ class TRoom;
|
|||
class TRoomDB;
|
||||
class QFile;
|
||||
class QNetworkAccessManager;
|
||||
class QProgressDialog;
|
||||
class MapInfoContributorManager;
|
||||
|
||||
class TMap : public QObject
|
||||
|
|
@ -79,6 +78,18 @@ signals:
|
|||
void signal_areaChanged(int areaId);
|
||||
void signal_mmpMapLocationChanged();
|
||||
|
||||
// Map-progress seam for the libmudlet split (#8681, #9011): the map engine
|
||||
// must stay free of Qt Widgets, so it emits these pre-translated payloads for
|
||||
// the frontend (TMainConsole) to render as a QProgressDialog. Cancellation
|
||||
// returns through slot_mapProgressDialogCancelled().
|
||||
void signal_mapTransferProgressStart(const QString& title, const QString& label, const QString& cancelButtonText);
|
||||
void signal_mapJsonProgressStart(const QString& title, const QString& label, const QString& cancelButtonText, int maximum);
|
||||
void signal_mapProgressSetLabel(const QString& text);
|
||||
void signal_mapProgressSetRange(int minimum, int maximum);
|
||||
void signal_mapProgressSetValue(int value);
|
||||
void signal_mapProgressDisableCancel();
|
||||
void signal_mapProgressClose();
|
||||
|
||||
private:
|
||||
QString mDefaultAreaName;
|
||||
QString mUnnamedAreaName;
|
||||
|
|
@ -160,9 +171,10 @@ public:
|
|||
void reportProgressToProgressDialog(int, int);
|
||||
|
||||
// Download/import progress helpers. Use the inline progress widget in the
|
||||
// mapper when it is visible, otherwise fall back to a modal QProgressDialog.
|
||||
// Do NOT use these from the JSON export/import paths - those keep their own
|
||||
// dedicated QProgressDialog.
|
||||
// mapper when it is visible, otherwise ask the frontend for a standalone
|
||||
// progress dialog via signal_mapTransferProgressStart(). Do NOT use these
|
||||
// from the JSON export/import paths - those drive their own frontend dialog
|
||||
// through signal_mapJsonProgressStart().
|
||||
void createTransferProgress(const QString& title, const QString& label, bool cancelable);
|
||||
void updateTransferProgressLabel(const QString& text);
|
||||
void updateTransferProgressRange(int minimum, int maximum);
|
||||
|
|
@ -172,6 +184,20 @@ public:
|
|||
void disableTransferProgressCancel();
|
||||
void clearTransferProgress();
|
||||
|
||||
// True while a map import, export or download is on the stack. Those pump
|
||||
// qApp->processEvents() to keep their progress display alive, so anything
|
||||
// delivered from an event loop can find itself running nested inside one -
|
||||
// and destroying this map's Host from there would free the operation's own
|
||||
// "this" (#9520). Whoever would do that has to wait for this to go false.
|
||||
bool mapOperationInProgress() const { return mMapOperationDepth > 0; }
|
||||
// Ask an operation that is in progress to stop at its next opportunity, so
|
||||
// that a caller waiting on the above does not wait for a whole map. Only the
|
||||
// JSON import and export poll this; an XML import or a download runs to its
|
||||
// own end. Asking twice does nothing, which mapOperationAbortRequested()
|
||||
// also lets a caller polling in a loop see.
|
||||
void requestMapOperationAbort();
|
||||
bool mapOperationAbortRequested() const { return mMapOperationAbortRequested; }
|
||||
|
||||
// Show which rooms have which symbols:
|
||||
QHash<QString, QSet<int>> roomSymbolsHash();
|
||||
|
||||
|
|
@ -185,7 +211,7 @@ public:
|
|||
void setRoomNamesShown(bool shown);
|
||||
|
||||
std::pair<bool, QString> writeJsonMapFile(const QString&);
|
||||
std::pair<bool, QString> readJsonMapFile(const QString&, const bool translatableTexts = false, const bool allowUserCancellation = true);
|
||||
std::pair<bool, QString> readJsonMapFile(const QString&, const bool translatableTexts = false);
|
||||
qsizetype getCurrentProgressRoomCount() const { return mProgressDialogRoomsCount; }
|
||||
bool incrementJsonProgressDialog(const bool isExportNotImport, const bool isRoomNotLabel, const int increment = 1);
|
||||
QString getDefaultAreaName() const { return mDefaultAreaName; }
|
||||
|
|
@ -363,9 +389,40 @@ public slots:
|
|||
void slot_downloadCancel();
|
||||
void slot_downloadError(QNetworkReply::NetworkError);
|
||||
void slot_replyFinished(QNetworkReply*);
|
||||
// Called by the frontend when the user cancels the standalone map-progress
|
||||
// dialog it owns on our behalf.
|
||||
void slot_mapProgressDialogCancelled();
|
||||
|
||||
|
||||
private:
|
||||
// Held for the whole of a map operation that pumps the event loop, so that
|
||||
// mapOperationInProgress() can tell anything re-entered from that pump that
|
||||
// this map is on the stack. Nested operations are counted, not flagged: an
|
||||
// XML import can start from inside a download's pump.
|
||||
class MapOperationScope
|
||||
{
|
||||
public:
|
||||
explicit MapOperationScope(TMap* pMap)
|
||||
: mpMap(pMap)
|
||||
{
|
||||
if (!mpMap->mMapOperationDepth) {
|
||||
mpMap->mMapOperationAbortRequested = false;
|
||||
}
|
||||
++mpMap->mMapOperationDepth;
|
||||
}
|
||||
~MapOperationScope() { --mpMap->mMapOperationDepth; }
|
||||
MapOperationScope(const MapOperationScope&) = delete;
|
||||
MapOperationScope& operator=(const MapOperationScope&) = delete;
|
||||
|
||||
private:
|
||||
TMap* mpMap = nullptr;
|
||||
};
|
||||
|
||||
int mMapOperationDepth = 0;
|
||||
// requestMapOperationAbort() is asked again on every retry of a deferred
|
||||
// profile close, and asking twice would abort a network reply twice over.
|
||||
bool mMapOperationAbortRequested = false;
|
||||
|
||||
void addDirectionalRoute(QHash<unsigned int, route>& bestRoutes,
|
||||
const QMap<QString, int>& exitWeights,
|
||||
unsigned int source,
|
||||
|
|
@ -375,6 +432,7 @@ private:
|
|||
const QString& exitKey,
|
||||
const QSet<unsigned int>& unUsableRoomSet);
|
||||
const QString createFileHeaderLine(QString, QChar);
|
||||
void warnIfMapProgressUnwired(const char* context, bool transferPath);
|
||||
void writeJsonUserData(QJsonObject&) const;
|
||||
void readJsonUserData(const QJsonObject&);
|
||||
bool validatePotentialMapFile(QFile&, QDataStream&);
|
||||
|
|
@ -401,7 +459,13 @@ private:
|
|||
int mExpectedFileSize = 0;
|
||||
bool mImportRunning = false;
|
||||
|
||||
QProgressDialog* mpProgressDialog = nullptr;
|
||||
// Engine-side mirror of the frontend-owned dialog, which the engine can't
|
||||
// read back. mMapProgressStandalone also serves as the "import/export already
|
||||
// running" guard (see writeJsonMapFile()/readJsonMapFile()).
|
||||
bool mMapProgressStandalone = false;
|
||||
bool mMapProgressIsTransfer = false;
|
||||
bool mMapProgressCancelRequested = false;
|
||||
int mMapProgressStandaloneMaximum = 0;
|
||||
// Using during updates of text in progress dialog partially from other
|
||||
// classes:
|
||||
qsizetype mProgressDialogAreasTotal = 0;
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ public:
|
|||
{
|
||||
}
|
||||
|
||||
// Copy constructor:
|
||||
// Copy constructor - deliberately does not carry over the capture
|
||||
// containers, so a copied state starts with empty captures:
|
||||
TMatchState(const TMatchState& ms)
|
||||
: mNumberOfConditions(ms.mNumberOfConditions)
|
||||
, mNextCondition(ms.mNextCondition)
|
||||
|
|
@ -51,6 +52,13 @@ public:
|
|||
{
|
||||
}
|
||||
|
||||
// Pair the user-defined copy constructor with an explicit copy assignment
|
||||
// (Rule of Two). Note the two are deliberately asymmetric: unlike the
|
||||
// constructor above, this defaulted assignment copies every member,
|
||||
// capture containers included. That reproduces the previously implicit
|
||||
// assignment exactly, so behaviour is unchanged:
|
||||
TMatchState& operator=(const TMatchState& ms) = default;
|
||||
|
||||
int nextCondition() { return mNextCondition; }
|
||||
void conditionMatched() { mNextCondition++; }
|
||||
bool isComplete() { return (mNextCondition >= mNumberOfConditions); }
|
||||
|
|
|
|||
465
src/TMedia.cpp
465
src/TMedia.cpp
|
|
@ -34,6 +34,38 @@
|
|||
#include <QRandomGenerator>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
#include <QTimer>
|
||||
|
||||
namespace {
|
||||
// Holds TMediaPlayer::reservedForPlay() for as long as a play() call is setting that player up,
|
||||
// however that call returns.
|
||||
class MediaPlayerReservation
|
||||
{
|
||||
public:
|
||||
MediaPlayerReservation() = default;
|
||||
~MediaPlayerReservation()
|
||||
{
|
||||
if (mPlayer) {
|
||||
mPlayer->setReservedForPlay(false);
|
||||
}
|
||||
}
|
||||
Q_DISABLE_COPY(MediaPlayerReservation)
|
||||
|
||||
void reserve(const std::shared_ptr<TMediaPlayer>& player)
|
||||
{
|
||||
if (mPlayer) {
|
||||
mPlayer->setReservedForPlay(false);
|
||||
}
|
||||
mPlayer = player;
|
||||
if (mPlayer) {
|
||||
mPlayer->setReservedForPlay(true);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<TMediaPlayer> mPlayer;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
// Public
|
||||
TMedia::TMedia(Host* pHost, const QString& profileName)
|
||||
|
|
@ -316,6 +348,24 @@ void TMedia::stopMedia(TMediaData& mediaData)
|
|||
continue;
|
||||
}
|
||||
|
||||
// A pooled player between tracks holds no source and has nothing to stop. Criteria this
|
||||
// broad are the common case - a bare stopMusic() or Client.Media.Stop {} matches every
|
||||
// player there is - so without this each idle one would be ended all over again, and
|
||||
// told about with an empty file name and the key and tag of its last track.
|
||||
if (!pPlayer->mediaPlayer() || pPlayer->mediaPlayer()->source().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Whichever way this track is being ended below, it is not to start again. A looping
|
||||
// or multi-entry track restarts itself from the EndOfMedia handler in
|
||||
// connectMediaPlayer(), which would undo the stop that was just asked for - on a
|
||||
// StoppedState-first backend that signal can still be on its way when the stop
|
||||
// arrives. An emptied playlist is what that handler checks, and play() builds a fresh
|
||||
// one whenever this player is picked up again.
|
||||
if (pPlayer->playlist()) {
|
||||
pPlayer->playlist()->clear();
|
||||
}
|
||||
|
||||
if ((mediaData.mediaFadeAway() == TMediaData::MediaFadeAwayEnabled || mediaData.mediaFadeOut() != TMediaData::MediaFadeNotSet)
|
||||
&& pPlayer->mediaData().mediaEnd() == TMediaData::MediaEndNotSet) {
|
||||
const int finishPosition = pPlayer->mediaData().mediaFinish();
|
||||
|
|
@ -339,7 +389,22 @@ void TMedia::stopMedia(TMediaData& mediaData)
|
|||
}
|
||||
|
||||
// **Stop the player but keep it for reuse**
|
||||
// Only a player that had started reports a change back to StoppedState, and that
|
||||
// signal is what ends the playback and releases the source. One that is still loading
|
||||
// - where a stop issued soon after a play lands on an asynchronous backend - is
|
||||
// already stopped as far as Qt is concerned, so it reports nothing and its source
|
||||
// would be held for good.
|
||||
const bool willReportItsOwnStop = pPlayer->getPlaybackState() != QMediaPlayer::StoppedState;
|
||||
|
||||
pPlayer->mediaPlayer()->stop();
|
||||
|
||||
if (!willReportItsOwnStop) {
|
||||
releaseMediaSourceAfterEvents(pPlayer, pPlayer->mediaData(), PlaybackEnd::Stopped);
|
||||
// Announced at most once per playback, so a handler that stops the media it has
|
||||
// just been told about does not arrive back here for the same track: the source it
|
||||
// reads as live stays set until the deferred release above runs.
|
||||
raiseMediaFinishedEvent(pPlayer, pPlayer->mediaPlayer()->source(), pPlayer->mediaData());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -382,19 +447,24 @@ void TMedia::parseGMCP(QString& packageMessage, QString& gmcp)
|
|||
}
|
||||
|
||||
// Documentation: https://wiki.mudlet.org/w/Manual:Miscellaneous_Functions#purgeMediaCache
|
||||
bool TMedia::purgeMediaCache()
|
||||
std::pair<bool, QString> TMedia::purgeMediaCache()
|
||||
{
|
||||
const QString mediaPath = mudlet::getMudletPath(enums::profileMediaPath, mpHost->getName());
|
||||
QDir mediaDir(mediaPath);
|
||||
|
||||
if (!mediaDir.mkpath(mediaPath)) {
|
||||
qWarning() << qsl("TMedia::purgeMediaCache() WARNING - not able to reference directory: %1").arg(mudlet::getMudletPath(enums::profileMediaPath, mpHost->getName()));
|
||||
return false;
|
||||
qWarning() << qsl("TMedia::purgeMediaCache() WARNING - not able to reference directory: %1").arg(mediaPath);
|
||||
return {false, qsl("could not access the media directory \"%1\"").arg(mediaPath)};
|
||||
}
|
||||
|
||||
stopAllMediaPlayers();
|
||||
mediaDir.removeRecursively();
|
||||
return true;
|
||||
|
||||
if (!mediaDir.removeRecursively()) {
|
||||
qWarning() << qsl("TMedia::purgeMediaCache() WARNING - not able to remove all of directory: %1").arg(mediaPath);
|
||||
return {false, qsl("removed what could be removed, but not all of the media directory \"%1\" - some files may be in use or write protected").arg(mediaPath)};
|
||||
}
|
||||
|
||||
return {true, QString()};
|
||||
}
|
||||
|
||||
void TMedia::refreshAudioDevices()
|
||||
|
|
@ -563,14 +633,93 @@ void TMedia::stopAllMediaPlayers()
|
|||
QList<std::shared_ptr<TMediaPlayer>> mediaPlayerList = findMediaPlayersByCriteria(mediaData);
|
||||
|
||||
for (const auto& pPlayer : std::as_const(mediaPlayerList)) {
|
||||
if (!pPlayer) {
|
||||
continue;
|
||||
if (!pPlayer || !pPlayer->mediaPlayer() || pPlayer->mediaPlayer()->source().isEmpty()) {
|
||||
continue; // A pooled player between tracks has nothing playing to stop
|
||||
}
|
||||
|
||||
// Everything the ending is described by has to be read before the source goes, because
|
||||
// releasing is what makes it unreadable.
|
||||
const TMediaData endedData = pPlayer->mediaData();
|
||||
const QUrl endedUrl = pPlayer->mediaPlayer()->source();
|
||||
const bool hadVideoOutput = pPlayer->mediaPlayer()->videoOutput() != nullptr;
|
||||
const quint64 claimedAt = pPlayer->claimGeneration();
|
||||
|
||||
// No loop is to survive this: the EndOfMedia handler restarts one from the playlist,
|
||||
// and a StoppedState-first backend can still have that signal on its way.
|
||||
if (pPlayer->playlist()) {
|
||||
pPlayer->playlist()->clear();
|
||||
}
|
||||
|
||||
pPlayer->mediaPlayer()->stop();
|
||||
|
||||
// stop() can deliver StoppedState synchronously, whose handler raises sysMediaFinished
|
||||
// and so lets a script hand this player straight to another track. The release below is
|
||||
// direct - it carries no generation of its own for releaseMediaSourceAfterEvents()'
|
||||
// checks to catch - so this is the one thing standing between that new track and having
|
||||
// its source cleared out from under it.
|
||||
if (pPlayer->claimGeneration() != claimedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Released here rather than left to releaseMediaSourceAfterEvents(): this is a
|
||||
// teardown, so there is no loop left to restart and no reason to wait a turn, and a
|
||||
// caller may need the files free straight away - purgeMediaCache() deletes them. The
|
||||
// empty source left behind is also what tells any release already scheduled for this
|
||||
// player to stay quiet when its turn comes, so nothing is said twice.
|
||||
pPlayer->releaseSource();
|
||||
|
||||
if (endedData.mediaWidget() == TMediaData::MediaWidgetLabel && endedData.mediaClose() == TMediaData::MediaCloseEnabled && hadVideoOutput) {
|
||||
emit signal_hideVideoOutput(pPlayer.get());
|
||||
}
|
||||
|
||||
// Announced from here because releasing synchronously means no deferred turn will do
|
||||
// it: on a backend that reports StoppedState asynchronously nothing else ever would,
|
||||
// and a script waiting on sysMediaFinished would sit through the teardown none the
|
||||
// wiser. Skipped when stop() above already announced it - see endAnnounced().
|
||||
raiseMediaFinishedEvent(pPlayer, endedUrl, endedData);
|
||||
|
||||
//: This word is part of a sentence like "Music stops" when the music is about to stop.
|
||||
printClosedCaption(endedData, tr("stops"));
|
||||
}
|
||||
}
|
||||
|
||||
int TMedia::playersHoldingSource() const
|
||||
{
|
||||
const auto countHeld = [](const QList<std::shared_ptr<TMediaPlayer>>& list) {
|
||||
int held = 0;
|
||||
for (const auto& player : list) {
|
||||
if (player && player->mediaPlayer() && !player->mediaPlayer()->source().isEmpty()) {
|
||||
++held;
|
||||
}
|
||||
}
|
||||
return held;
|
||||
};
|
||||
|
||||
return countHeld(mMSPSoundList) + countHeld(mMSPMusicList) + countHeld(mGMCPSoundList) + countHeld(mGMCPMusicList) + countHeld(mGMCPVideoList) + countHeld(mAPISoundList) + countHeld(mAPIMusicList)
|
||||
+ countHeld(mAPIVideoList);
|
||||
}
|
||||
|
||||
int TMedia::playersInPlayingState() const
|
||||
{
|
||||
const auto countPlaying = [](const QList<std::shared_ptr<TMediaPlayer>>& list) {
|
||||
int playing = 0;
|
||||
for (const auto& player : list) {
|
||||
if (player && player->getPlaybackState() == QMediaPlayer::PlayingState) {
|
||||
++playing;
|
||||
}
|
||||
}
|
||||
return playing;
|
||||
};
|
||||
|
||||
return countPlaying(mMSPSoundList) + countPlaying(mMSPMusicList) + countPlaying(mGMCPSoundList) + countPlaying(mGMCPMusicList) + countPlaying(mGMCPVideoList) + countPlaying(mAPISoundList)
|
||||
+ countPlaying(mAPIMusicList) + countPlaying(mAPIVideoList);
|
||||
}
|
||||
|
||||
int TMedia::mediaPlayerCount() const
|
||||
{
|
||||
return mMSPSoundList.size() + mMSPMusicList.size() + mGMCPSoundList.size() + mGMCPMusicList.size() + mGMCPVideoList.size() + mAPISoundList.size() + mAPIMusicList.size() + mAPIVideoList.size();
|
||||
}
|
||||
|
||||
void TMedia::setMediaPlayersMuted(const TMediaData::MediaProtocol mediaProtocol, const bool state)
|
||||
{
|
||||
TMediaData mediaData{};
|
||||
|
|
@ -1012,6 +1161,17 @@ void TMedia::downloadFile(TMediaData& mediaData)
|
|||
const QString scheme = fileUrl.scheme();
|
||||
if (scheme != qsl("http") && scheme != qsl("https")) {
|
||||
qWarning() << qsl("TMedia::downloadFile() WARNING - refused to download media from a non-HTTP(S) URL: %1").arg(fileUrl.toString());
|
||||
|
||||
// Told the same way a download that fails is, so a script waiting on this media learns
|
||||
// the request is over rather than waiting on it forever.
|
||||
TEvent event{};
|
||||
event.mArgumentList << qsl("sysDownloadError");
|
||||
event.mArgumentTypeList << ARGUMENT_TYPE_STRING;
|
||||
event.mArgumentList << qsl("Media can only be downloaded from an http:// or https:// URL, not \"%1\"").arg(fileUrl.toString());
|
||||
event.mArgumentTypeList << ARGUMENT_TYPE_STRING;
|
||||
event.mArgumentList << mediaData.mediaAbsolutePathFileName();
|
||||
event.mArgumentTypeList << ARGUMENT_TYPE_STRING;
|
||||
mpHost->raiseEvent(event);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1050,7 +1210,7 @@ QString TMedia::setupMediaAbsolutePathFileName(TMediaData& mediaData)
|
|||
|
||||
void TMedia::connectMediaPlayer(std::shared_ptr<TMediaPlayer>& player)
|
||||
{
|
||||
if (!player) {
|
||||
if (!player || !player->mediaPlayer()) {
|
||||
qWarning() << qsl("TMedia::connectMediaPlayer() WARNING - Attempted to connect a null TMediaPlayer.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -1076,18 +1236,79 @@ void TMedia::connectMediaPlayer(std::shared_ptr<TMediaPlayer>& player)
|
|||
QUrl nextMedia = lockedPlayer->playlist()->next();
|
||||
|
||||
if (!nextMedia.isEmpty()) {
|
||||
lockedPlayer->mediaPlayer()->setSource(nextMedia);
|
||||
lockedPlayer->mediaPlayer()->play();
|
||||
lockedPlayer->continuePlaying(nextMedia);
|
||||
} else if (lockedPlayer->playlist()->playbackMode() == TMediaPlaylist::Loop) {
|
||||
lockedPlayer->playlist()->setCurrentIndex(0);
|
||||
lockedPlayer->mediaPlayer()->setSource(lockedPlayer->playlist()->currentMedia());
|
||||
lockedPlayer->mediaPlayer()->play();
|
||||
lockedPlayer->continuePlaying(lockedPlayer->playlist()->currentMedia());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
disconnect(player->mediaPlayer(), &QMediaPlayer::errorOccurred, nullptr, nullptr);
|
||||
connect(player->mediaPlayer(), &QMediaPlayer::errorOccurred, this, [this, weakPlayer](QMediaPlayer::Error error, const QString& errorString) {
|
||||
const auto lockedPlayer = weakPlayer.lock();
|
||||
|
||||
if (!lockedPlayer || !lockedPlayer->mediaPlayer() || error == QMediaPlayer::NoError) {
|
||||
return;
|
||||
}
|
||||
|
||||
qWarning().noquote() << qsl("TMedia::connectMediaPlayer() WARNING - media player error %1 on \"%2\": %3")
|
||||
.arg(QString::number(static_cast<int>(error)), lockedPlayer->mediaPlayer()->source().toString(), errorString);
|
||||
|
||||
if (mudlet::smDebugMode && mpHost && mpHost->mpConsole) {
|
||||
//: %1 is the media backend's own description of what went wrong, e.g. "Failed to load media".
|
||||
mpHost->mpConsole->printSystemMessage(qsl("%1\n").arg(tr("Media error: %1").arg(errorString)));
|
||||
}
|
||||
|
||||
// Only a failure nothing else will report is ended from here. A track that was playing
|
||||
// reports StoppedState when the error takes it down, and the playback state handler
|
||||
// ends it from there. That leaves two cases: a player already stopped, which is where a
|
||||
// load failure lands because claimSource() leaves it stopped and there is no state to
|
||||
// change from; and Qt's darwin backend, which reports PlayingState for media it has
|
||||
// just failed to load and then never moves off it. InvalidMedia catches that second
|
||||
// case and only that one - it cannot be asked to carry the first, because Qt's FFmpeg
|
||||
// backend raises this signal before it sets the status.
|
||||
if (lockedPlayer->mediaPlayer()->mediaStatus() != QMediaPlayer::InvalidMedia && lockedPlayer->getPlaybackState() != QMediaPlayer::StoppedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing else will end it: a source set on a player that was already stopped - which
|
||||
// is what every claimSource() on a new or finished player does, and what a loop restart
|
||||
// or playlist advance does from the EndOfMedia handler - has no state to change from.
|
||||
// Left alone the track falls silent still holding a source nothing will ever release,
|
||||
// and a script waiting on sysMediaFinished to start the next one waits forever.
|
||||
//
|
||||
// Ended a turn from now rather than here, because setSource() can deliver this error
|
||||
// synchronously from inside claimSource(): sysMediaFinished would then reach a script
|
||||
// in the middle of the playMusic() call that asked for the track, and a handler that
|
||||
// responds by playing the same undecodable file again would recurse until the stack
|
||||
// gave out. The claim generation says whether this failure is still anyone's to report
|
||||
// by the time the turn comes: a track that took the player over in between owns it now.
|
||||
const quint64 claimedAt = lockedPlayer->claimGeneration();
|
||||
|
||||
QTimer::singleShot(0, this, [this, weakPlayer, claimedAt] {
|
||||
const auto endingPlayer = weakPlayer.lock();
|
||||
|
||||
if (!endingPlayer || !endingPlayer->mediaPlayer() || endingPlayer->claimGeneration() != claimedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The release armed below ignores playback state by design, since darwin claims to
|
||||
// be playing media it has just failed to load. That makes this the only place an
|
||||
// error the backend recovered from can be told apart from one it did not: a turn
|
||||
// on, media it has condemned says so with InvalidMedia, and media that is playing
|
||||
// without having been condemned is fine after all and must be left alone.
|
||||
if (endingPlayer->mediaPlayer()->mediaStatus() != QMediaPlayer::InvalidMedia && endingPlayer->getPlaybackState() == QMediaPlayer::PlayingState) {
|
||||
return;
|
||||
}
|
||||
|
||||
releaseMediaSourceAfterEvents(endingPlayer, endingPlayer->mediaData(), PlaybackEnd::Failed);
|
||||
raiseMediaFinishedEvent(endingPlayer, endingPlayer->mediaPlayer()->source(), endingPlayer->mediaData());
|
||||
});
|
||||
});
|
||||
|
||||
// Playback state changed connection
|
||||
disconnect(player->mediaPlayer(), &QMediaPlayer::playbackStateChanged, nullptr, nullptr);
|
||||
connect(player->mediaPlayer(), &QMediaPlayer::playbackStateChanged, this, [this, weakPlayer](QMediaPlayerPlaybackState playbackState) {
|
||||
|
|
@ -1250,8 +1471,11 @@ std::shared_ptr<TMediaPlayer> TMedia::getMediaPlayer(TMediaData& mediaData)
|
|||
continue;
|
||||
}
|
||||
|
||||
if (existingPlayer->reservedForPlay()) {
|
||||
continue; // Another play() call is setting this one up
|
||||
}
|
||||
|
||||
if (existingPlayer->getPlaybackState() != QMediaPlayer::PlayingState && existingPlayer->mediaPlayer()->mediaStatus() != QMediaPlayer::LoadingMedia) {
|
||||
existingPlayer->setMediaData(mediaData);
|
||||
return existingPlayer; // Reuse existing player
|
||||
}
|
||||
}
|
||||
|
|
@ -1290,7 +1514,6 @@ std::shared_ptr<TMediaPlayer> TMedia::getMediaPlayer(TMediaData& mediaData)
|
|||
return nullptr;
|
||||
}
|
||||
|
||||
newPlayer->setMediaData(mediaData);
|
||||
connectMediaPlayer(newPlayer);
|
||||
mediaPlayerList.append(newPlayer);
|
||||
|
||||
|
|
@ -1343,6 +1566,162 @@ void TMedia::getMediaPlayerCounts(int& soundPlayers, int& musicPlayers, int& sto
|
|||
}
|
||||
#endif // MUDLET_MEMORY_TRACKING
|
||||
|
||||
// Tells scripts a playback is over. Raised for a failed load as well as for a stop, because a
|
||||
// script that starts its next track from sysMediaFinished otherwise waits forever on the first
|
||||
// file the backend cannot decode.
|
||||
void TMedia::raiseMediaFinishedEvent(const std::shared_ptr<TMediaPlayer>& player, const QUrl& endedUrl, const TMediaData& endedData)
|
||||
{
|
||||
if (!mpHost || !player) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (endedUrl.isEmpty()) {
|
||||
// A pooled player between tracks. There is no playback to report, and the event would
|
||||
// carry an empty file name and path with the key and tag of whatever it last played.
|
||||
return;
|
||||
}
|
||||
|
||||
if (player->endAnnounced()) {
|
||||
// Already reported by whichever of the stop, the error and the StoppedState got here
|
||||
// first - see TMediaPlayer::endAnnounced().
|
||||
return;
|
||||
}
|
||||
|
||||
// Set before the handlers run, not after: raiseEvent() dispatches synchronously, and a
|
||||
// handler that stops this player would otherwise arrive back here and announce again.
|
||||
player->noteEndAnnounced();
|
||||
|
||||
TEvent mediaFinished{};
|
||||
mediaFinished.mArgumentList.append(qsl("sysMediaFinished"));
|
||||
|
||||
mediaFinished.mArgumentList.append(endedUrl.fileName());
|
||||
mediaFinished.mArgumentList.append(endedUrl.path());
|
||||
mediaFinished.mArgumentList.append(mediaTypeToString(endedData.mediaType()));
|
||||
mediaFinished.mArgumentList.append(endedData.mediaKey());
|
||||
mediaFinished.mArgumentList.append(endedData.mediaTag());
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
|
||||
mpHost->raiseEvent(mediaFinished);
|
||||
}
|
||||
|
||||
// Ends a playback: releases the media source and prints the closing caption, one event-loop
|
||||
// turn from now. Deferred so a StoppedState-first backend can still emit the EndOfMedia that
|
||||
// restarts a loop - clearing the source immediately destroys the playback engine and that
|
||||
// signal never arrives (#9566). See TMediaPlayer for the generation counters this compares.
|
||||
//
|
||||
// endedBy decides whether the player's own state gets a say in the deferred turn. A stop needs
|
||||
// it: on an EndOfMedia-first backend the restart happened before the snapshot, so the
|
||||
// continuation counter cannot see it and a player still reporting PlayingState is the only sign
|
||||
// the track carried on. A failure must not have it, because a backend can report PlayingState
|
||||
// for media it has just failed to load (Qt 6.9's darwin backend does), and believing that would
|
||||
// leave the dead source held forever - no state change follows to schedule another release.
|
||||
void TMedia::releaseMediaSourceAfterEvents(const std::shared_ptr<TMediaPlayer>& player, const TMediaData& endedData, const PlaybackEnd endedBy)
|
||||
{
|
||||
const bool playbackStateDecides = (endedBy == PlaybackEnd::Stopped);
|
||||
|
||||
if (!player->mediaPlayer() || player->mediaPlayer()->source().isEmpty()) {
|
||||
// Nothing left to end, so no caption for it either
|
||||
qDebug() << "TMedia::releaseMediaSourceAfterEvents() - asked to end a playback that is already holding no source; nothing to do.";
|
||||
return;
|
||||
}
|
||||
|
||||
const std::weak_ptr<TMediaPlayer> weakPlayer = player;
|
||||
const quint64 claimedAt = player->claimGeneration();
|
||||
const quint64 continuedAt = player->continuationGeneration();
|
||||
|
||||
QTimer::singleShot(0, this, [this, weakPlayer, endedData, claimedAt, continuedAt, playbackStateDecides] {
|
||||
const auto lockedPlayer = weakPlayer.lock();
|
||||
const bool stillOurs = lockedPlayer && lockedPlayer->claimGeneration() == claimedAt;
|
||||
// Two ways the same playback can have carried on during the deferred turn. On a
|
||||
// StoppedState-first backend the loop restarts from the EndOfMedia handler after the
|
||||
// snapshot above, so the counter is what sees it; on an EndOfMedia-first backend the
|
||||
// restart already happened before the snapshot, so the counter cannot see it and the
|
||||
// player still reporting PlayingState is.
|
||||
const bool sameMediaContinues =
|
||||
lockedPlayer && (lockedPlayer->continuationGeneration() != continuedAt || (playbackStateDecides && stillOurs && lockedPlayer->getPlaybackState() == QMediaPlayer::PlayingState));
|
||||
|
||||
if (sameMediaContinues) {
|
||||
// No caption either: nothing ended, so "stops" between the passes of a looping
|
||||
// track would be wrong. Logged because this is the one outcome that keeps a source
|
||||
// on purpose, which makes it the first thing to rule out when one is held too long.
|
||||
qDebug() << "TMedia::releaseMediaSourceAfterEvents() - the same playback carried on into another pass; keeping its source.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Releasing bumps no generation, so any path that clears the source itself leaves a
|
||||
// pending turn still looking entitled to end this playback - an error and the stop
|
||||
// that follows it, stopAllMediaPlayers(), the setupVideo() failure in play(). Each of
|
||||
// those announces its own ending, so this one has nothing left to do or to say.
|
||||
if (stillOurs && lockedPlayer->mediaPlayer() && lockedPlayer->mediaPlayer()->source().isEmpty()) {
|
||||
qDebug() << "TMedia::releaseMediaSourceAfterEvents() - this playback was already ended by whoever released the source; nothing left to do.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lockedPlayer) {
|
||||
qDebug() << "TMedia::releaseMediaSourceAfterEvents() - player was destroyed before its deferred release ran; its destructor released the source.";
|
||||
} else if (!stillOurs) {
|
||||
// A claimed player is already loading the source of the track that took it over,
|
||||
// which on an asynchronous backend still reads as stopped.
|
||||
qDebug() << "TMedia::releaseMediaSourceAfterEvents() - another track claimed this player before its deferred release ran; keeping the new source.";
|
||||
} else if (!lockedPlayer->mediaPlayer()) {
|
||||
qWarning() << "TMedia::releaseMediaSourceAfterEvents() WARNING - mediaPlayer() is null, cannot release the media source.";
|
||||
} else if (playbackStateDecides && lockedPlayer->getPlaybackState() != QMediaPlayer::StoppedState) {
|
||||
qDebug() << "TMedia::releaseMediaSourceAfterEvents() - player is no longer stopped, keeping its source.";
|
||||
} else {
|
||||
qDebug() << "TMedia::releaseMediaSourceAfterEvents() - releasing the media source of the playback that ended.";
|
||||
lockedPlayer->releaseSource();
|
||||
|
||||
if (endedData.mediaWidget() == TMediaData::MediaWidgetLabel && endedData.mediaClose() == TMediaData::MediaCloseEnabled && lockedPlayer->mediaPlayer()->videoOutput() != nullptr) {
|
||||
emit signal_hideVideoOutput(lockedPlayer.get());
|
||||
}
|
||||
}
|
||||
|
||||
// Printed on every path that got past the continuation check: the track this release
|
||||
// was scheduled for is over regardless of what has become of the player since.
|
||||
//: This word is part of a sentence like "Music stops" when the music is about to stop.
|
||||
printClosedCaption(endedData, tr("stops"));
|
||||
});
|
||||
}
|
||||
|
||||
// Hands a player over to a track that is about to start on it. Call only once the request is
|
||||
// certain to go ahead - a request that is then refused must not have ended anything.
|
||||
void TMedia::claimPlayerFor(const std::shared_ptr<TMediaPlayer>& player, TMediaData& mediaData, const QUrl& mediaSource)
|
||||
{
|
||||
// In this order: the ending is reported under the key and tag the player still carries for
|
||||
// the track that ended, and its source is released before claimSource() bumps the generation
|
||||
// that any release still pending on this player is judged against.
|
||||
endDisplacedPlayback(player);
|
||||
player->setMediaData(mediaData);
|
||||
player->claimSource(mediaSource);
|
||||
}
|
||||
|
||||
// Ends whatever playback the player is still holding, so the track taking it over starts from a
|
||||
// player with nothing loaded. A paused one is what makes this necessary: it keeps its source, and
|
||||
// handing setSource() a player that is not stopped stops it - which would otherwise be reported
|
||||
// as the new track ending, under the new track's key and tag, and could clear its source a turn
|
||||
// later.
|
||||
void TMedia::endDisplacedPlayback(const std::shared_ptr<TMediaPlayer>& player)
|
||||
{
|
||||
if (!player || !player->mediaPlayer() || player->mediaPlayer()->source().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read before the source goes, because releasing is what makes it unreadable.
|
||||
const TMediaData endedData = player->mediaData();
|
||||
const QUrl endedUrl = player->mediaPlayer()->source();
|
||||
|
||||
player->mediaPlayer()->stop();
|
||||
player->releaseSource();
|
||||
|
||||
// Skipped when the stop above already announced it - see endAnnounced().
|
||||
raiseMediaFinishedEvent(player, endedUrl, endedData);
|
||||
}
|
||||
|
||||
void TMedia::handlePlayerPlaybackStateChanged(QMediaPlayerPlaybackState playbackState, const std::shared_ptr<TMediaPlayer>& player)
|
||||
{
|
||||
if (!player) {
|
||||
|
|
@ -1350,34 +1729,25 @@ void TMedia::handlePlayerPlaybackStateChanged(QMediaPlayerPlaybackState playback
|
|||
}
|
||||
|
||||
if (playbackState == QMediaPlayer::StoppedState) {
|
||||
TEvent mediaFinished{};
|
||||
mediaFinished.mArgumentList.append(qsl("sysMediaFinished"));
|
||||
|
||||
const QUrl mediaUrl = player->mediaPlayer()->source();
|
||||
mediaFinished.mArgumentList.append(mediaUrl.fileName());
|
||||
mediaFinished.mArgumentList.append(mediaUrl.path());
|
||||
mediaFinished.mArgumentList.append(mediaTypeToString(player->mediaData().mediaType()));
|
||||
mediaFinished.mArgumentList.append(player->mediaData().mediaKey());
|
||||
mediaFinished.mArgumentList.append(player->mediaData().mediaTag());
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
|
||||
if (mpHost) {
|
||||
mpHost->raiseEvent(mediaFinished);
|
||||
if (player->claimingSource()) {
|
||||
qDebug() << "TMedia::handlePlayerPlaybackStateChanged() - stopped a player that is being handed a new source; the playback it stopped was ended by whoever handed it over.";
|
||||
return;
|
||||
}
|
||||
|
||||
player->mediaPlayer()->setSource(QUrl());
|
||||
|
||||
if (player->mediaData().mediaWidget() == TMediaData::MediaWidgetLabel && player->mediaData().mediaClose() == TMediaData::MediaCloseEnabled && player->mediaPlayer()->videoOutput() != nullptr) {
|
||||
emit signal_hideVideoOutput(player.get());
|
||||
if (!player->mediaPlayer() || player->mediaPlayer()->source().isEmpty()) {
|
||||
// Whoever released the source already ended this playback and raised its event. A
|
||||
// second one from here would carry an empty file name and path, because the URL
|
||||
// they describe is exactly what was just cleared.
|
||||
qDebug() << "TMedia::handlePlayerPlaybackStateChanged() - stopped a player that is already holding no source; its playback was ended elsewhere.";
|
||||
return;
|
||||
}
|
||||
|
||||
//: This word is part of a sentence like "Music stops" when the music is about to stop.
|
||||
printClosedCaption(player->mediaData(), tr("stops"));
|
||||
// Scheduled before the event below, because a sysMediaFinished handler runs
|
||||
// synchronously and may hand this player to the next track - which would change both
|
||||
// the media data and the generations the release has to be judged against.
|
||||
releaseMediaSourceAfterEvents(player, player->mediaData(), PlaybackEnd::Stopped);
|
||||
raiseMediaFinishedEvent(player, player->mediaPlayer()->source(), player->mediaData());
|
||||
|
||||
return;
|
||||
} else if (playbackState == QMediaPlayer::PlayingState && player->mediaData().mediaVolume() != TMediaData::MediaVolumePreload) { // NOLINT(readability-else-after-return)
|
||||
TEvent mediaStarted{};
|
||||
|
|
@ -1548,6 +1918,7 @@ void TMedia::play(TMediaData& mediaData)
|
|||
}
|
||||
|
||||
std::shared_ptr<TMediaPlayer> pPlayer;
|
||||
MediaPlayerReservation reservation;
|
||||
|
||||
// Only match an existing media player for music and video
|
||||
if (mediaData.mediaType() == TMediaData::MediaTypeMusic || mediaData.mediaType() == TMediaData::MediaTypeVideo) {
|
||||
|
|
@ -1571,6 +1942,10 @@ void TMedia::play(TMediaData& mediaData)
|
|||
return;
|
||||
}
|
||||
|
||||
// The stops below raise sysMediaFinished into script handlers synchronously - see
|
||||
// TMediaPlayer::reservedForPlay().
|
||||
reservation.reserve(pPlayer);
|
||||
|
||||
// Ensure the player has a valid playlist
|
||||
TMediaPlaylist* playlist = pPlayer->playlist();
|
||||
|
||||
|
|
@ -1613,7 +1988,7 @@ void TMedia::play(TMediaData& mediaData)
|
|||
}
|
||||
|
||||
const QUrl mediaSource = mediaData.mediaInput() == TMediaData::MediaInputFile ? QUrl::fromLocalFile(absolutePathFileName) : QUrl(absolutePathFileName);
|
||||
pPlayer->mediaPlayer()->setSource(mediaSource);
|
||||
claimPlayerFor(pPlayer, mediaData, mediaSource);
|
||||
} else {
|
||||
if (mediaData.mediaLoops() == TMediaData::MediaLoopsRepeat) { // Repeat indefinitely
|
||||
playlist->setPlaybackMode(TMediaPlaylist::Loop);
|
||||
|
|
@ -1679,7 +2054,7 @@ void TMedia::play(TMediaData& mediaData)
|
|||
|
||||
playlist->setCurrentIndex(0);
|
||||
pPlayer->setPlaylist(playlist);
|
||||
pPlayer->mediaPlayer()->setSource(playlist->currentMedia());
|
||||
claimPlayerFor(pPlayer, mediaData, playlist->currentMedia());
|
||||
}
|
||||
|
||||
// Set volume and start position
|
||||
|
|
@ -1704,6 +2079,16 @@ void TMedia::play(TMediaData& mediaData)
|
|||
|
||||
// Handle video setup if applicable
|
||||
if (mediaData.mediaType() == TMediaData::MediaTypeVideo && !setupVideo(pPlayer)) {
|
||||
// Claiming the player disarmed any release still pending on it, so drop the source it
|
||||
// is now never going to play rather than leave it held indefinitely.
|
||||
pPlayer->releaseSource();
|
||||
|
||||
// Same guards as the deferred release: a reused player can still be showing the widget
|
||||
// of an earlier clip, and hiding that is only wanted when this request asked for it.
|
||||
if (mediaData.mediaWidget() == TMediaData::MediaWidgetLabel && mediaData.mediaClose() == TMediaData::MediaCloseEnabled && pPlayer->mediaPlayer()->videoOutput() != nullptr) {
|
||||
emit signal_hideVideoOutput(pPlayer.get());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
115
src/TMedia.h
115
src/TMedia.h
|
|
@ -32,8 +32,10 @@
|
|||
#include "TMediaPlaylist.h"
|
||||
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
#include <QAudioOutput>
|
||||
#include <QMediaPlayer>
|
||||
#include <QUrl>
|
||||
|
||||
class QJsonObject;
|
||||
|
||||
|
|
@ -55,6 +57,14 @@ public:
|
|||
~TMediaPlayer()
|
||||
{
|
||||
if (mMediaPlayer) {
|
||||
// The unload below releases the media file, and announces itself wherever it
|
||||
// changes something - sourceChanged from clearing the source, playbackStateChanged
|
||||
// from a stop that had something to stop - synchronously, into handlers reading a
|
||||
// TMediaPlayer whose members are about to go. The handlers TMedia installs decline
|
||||
// by way of a weak_ptr that has already expired by now; blocking makes that
|
||||
// structural rather than something each new handler has to remember. The unload
|
||||
// still happens, and Qt unblocks in ~QObject so destroyed() still arrives.
|
||||
mMediaPlayer->blockSignals(true);
|
||||
mMediaPlayer->stop();
|
||||
mMediaPlayer->setSource(QUrl());
|
||||
}
|
||||
|
|
@ -62,6 +72,83 @@ public:
|
|||
|
||||
TMediaData mediaData() const { return mMediaData; }
|
||||
void setMediaData(TMediaData& mediaData) { mMediaData = mediaData; }
|
||||
|
||||
// TMedia::releaseMediaSourceAfterEvents() ends a playback one event-loop turn late, by which
|
||||
// time a stopped player is indistinguishable from one asynchronously loading a source set
|
||||
// since. These two counters record what happened in between: a claim is this player being
|
||||
// given a new track to play, a continuation is its own playlist advancing or looping.
|
||||
// Outside this class, install a source only through claimSource() or continuePlaying() -
|
||||
// never through mediaPlayer()->setSource() directly, since a missed bump lets that pending
|
||||
// release clear the new source again. As of Qt 6.9 that reproduces only on backends that
|
||||
// load asynchronously, so it will not show up on a macOS-only test run.
|
||||
void claimSource(const QUrl& media)
|
||||
{
|
||||
// Bumped before the source is touched because setSource() can raise errorOccurred
|
||||
// synchronously, and that handler snapshots these counters to arm its own release.
|
||||
++mClaimGeneration;
|
||||
mEndAnnounced = false;
|
||||
if (mMediaPlayer) {
|
||||
// A stopped player still holding anything has that media loaded, so handing it a
|
||||
// source now starts playback synchronously and raises sysMediaStarted inside the
|
||||
// script call that asked for it. Unloading first restores the usual asynchronous
|
||||
// start. The reported symptom was replaying the same file (#9611).
|
||||
if (mMediaPlayer->playbackState() == QMediaPlayer::StoppedState && !mMediaPlayer->source().isEmpty()) {
|
||||
releaseSource();
|
||||
}
|
||||
mClaimingSource = true;
|
||||
mMediaPlayer->setSource(media);
|
||||
mClaimingSource = false;
|
||||
}
|
||||
}
|
||||
void continuePlaying(const QUrl& media)
|
||||
{
|
||||
++mContinuationGeneration;
|
||||
if (mMediaPlayer) {
|
||||
mMediaPlayer->setSource(media);
|
||||
// Cleared after the source is installed, not before. On a backend that delivers
|
||||
// EndOfMedia while the player is still playing this setSource() is a real
|
||||
// playing-to-stopped transition, and its handler announces the pass that just ended.
|
||||
// Clearing first leaves that announcement standing for the pass about to start, and
|
||||
// the last pass has no continuation left to clear it again.
|
||||
mEndAnnounced = false;
|
||||
mMediaPlayer->play();
|
||||
}
|
||||
}
|
||||
// No bump: an empty source cannot be mistaken for a track that needs protecting from a
|
||||
// pending release. A release already scheduled therefore still fires, and recognises that
|
||||
// it has nothing left to do by the source being empty - see releaseMediaSourceAfterEvents().
|
||||
void releaseSource()
|
||||
{
|
||||
if (mMediaPlayer) {
|
||||
mMediaPlayer->setSource(QUrl());
|
||||
}
|
||||
}
|
||||
quint64 claimGeneration() const { return mClaimGeneration; }
|
||||
quint64 continuationGeneration() const { return mContinuationGeneration; }
|
||||
|
||||
// One ended playback can be reported from three places - a stop, a load error and the
|
||||
// StoppedState that follows either - and the source stays set until the deferred release
|
||||
// runs, so each of them still finds a playback that looks live. Only the first may tell
|
||||
// scripts about it: a second sysMediaFinished for the same track is at best a duplicate,
|
||||
// and at worst unbounded recursion when the handler stops the media it was told about.
|
||||
// Cleared by the two ways this player is given something new to play, above.
|
||||
bool endAnnounced() const { return mEndAnnounced; }
|
||||
void noteEndAnnounced() { mEndAnnounced = true; }
|
||||
|
||||
// True only while claimSource() is installing a new source. A stop delivered during that is
|
||||
// the previous track being displaced rather than this one ending, and whoever displaced it
|
||||
// has already said so - with the metadata the player no longer holds.
|
||||
bool claimingSource() const { return mClaimingSource; }
|
||||
|
||||
// A play() call owns the player it is setting up until it returns, because the events it
|
||||
// raises run script handlers synchronously. A handler that starts media of its own must be
|
||||
// given a different player: two play() calls sharing one overwrite each other's playlist and
|
||||
// media data.
|
||||
bool reservedForPlay() const { return mReservedForPlay; }
|
||||
void setReservedForPlay(const bool reserved) { mReservedForPlay = reserved; }
|
||||
|
||||
// Read-only uses and playback control are fine; do not setSource() on it, for the reason
|
||||
// given above claimSource().
|
||||
QMediaPlayer* mediaPlayer() const { return mMediaPlayer.get(); }
|
||||
bool isInitialized() const { return initialized; }
|
||||
QMediaPlayer::PlaybackState getPlaybackState() const
|
||||
|
|
@ -117,6 +204,11 @@ private:
|
|||
std::unique_ptr<QMediaPlayer> mMediaPlayer;
|
||||
std::unique_ptr<TMediaPlaylist> mPlaylist;
|
||||
bool initialized = false;
|
||||
quint64 mClaimGeneration = 0;
|
||||
quint64 mContinuationGeneration = 0;
|
||||
bool mEndAnnounced = false;
|
||||
bool mClaimingSource = false;
|
||||
bool mReservedForPlay = false;
|
||||
};
|
||||
|
||||
class TMedia : public QObject
|
||||
|
|
@ -142,13 +234,25 @@ public:
|
|||
void pauseMedia(TMediaData& mediaData);
|
||||
void stopMedia(TMediaData& mediaData);
|
||||
void parseGMCP(QString& packageMessage, QString& gmcp);
|
||||
bool purgeMediaCache();
|
||||
std::pair<bool, QString> purgeMediaCache();
|
||||
void refreshAudioDevices();
|
||||
void muteMedia(const TMediaData::MediaProtocol mediaProtocol);
|
||||
void unmuteMedia(const TMediaData::MediaProtocol mediaProtocol);
|
||||
void printClosedCaption(const TMediaData& mediaData, const QString& action) const;
|
||||
void stopAllMediaPlayers();
|
||||
|
||||
// Read-only diagnostics for the media tests. A deferred release is otherwise hard to
|
||||
// observe: playingMedia() has already dropped the player, the closed caption needs captions
|
||||
// enabled and signal_hideVideoOutput needs a video widget.
|
||||
int playersHoldingSource() const;
|
||||
// Players that have actually started. playingMedia() deliberately counts one that is still
|
||||
// loading as playing, which is not enough for a test that needs playback truly under way.
|
||||
int playersInPlayingState() const;
|
||||
// Players registered in the protocol lists, so a reuse test can tell a claimed player from
|
||||
// a second one allocated alongside it. A player play() abandons before it finishes is never
|
||||
// registered and so is never counted.
|
||||
int mediaPlayerCount() const;
|
||||
|
||||
// Returns true if mediaFileName would resolve to a location outside mediaRoot, either
|
||||
// lexically (e.g. via "../" traversal) or through a symlink component that already exists
|
||||
// under mediaRoot but points elsewhere. Static so it can be unit-tested without a Host.
|
||||
|
|
@ -188,6 +292,15 @@ private:
|
|||
std::shared_ptr<TMediaPlayer> matchMediaPlayer(TMediaData& mediaData);
|
||||
bool doesMediaHavePriorityToPlay(TMediaData& mediaData, const QString& absolutePathFileName);
|
||||
void matchMediaKeyAndStopMediaVariants(TMediaData& mediaData, const QString& absolutePathFileName);
|
||||
// Why a playback ended, which decides whether the player's own state is worth consulting
|
||||
// when the deferred release comes around. See releaseMediaSourceAfterEvents().
|
||||
enum class PlaybackEnd { Stopped, Failed };
|
||||
// endedUrl and endedData are passed in rather than read off the player, so a caller that has
|
||||
// already released the source can still say what it was that ended.
|
||||
void raiseMediaFinishedEvent(const std::shared_ptr<TMediaPlayer>& player, const QUrl& endedUrl, const TMediaData& endedData);
|
||||
void claimPlayerFor(const std::shared_ptr<TMediaPlayer>& player, TMediaData& mediaData, const QUrl& mediaSource);
|
||||
void endDisplacedPlayback(const std::shared_ptr<TMediaPlayer>& player);
|
||||
void releaseMediaSourceAfterEvents(const std::shared_ptr<TMediaPlayer>& player, const TMediaData& endedData, const PlaybackEnd endedBy);
|
||||
void handlePlayerPlaybackStateChanged(QMediaPlayerPlaybackState playbackState, const std::shared_ptr<TMediaPlayer>& player);
|
||||
bool setupVideo(const std::shared_ptr<TMediaPlayer>& player);
|
||||
static QString mediaTypeToString(int mediaType);
|
||||
|
|
|
|||
|
|
@ -124,6 +124,11 @@ bool TMxpFrameManager::createFrame(const QString& name, const QMap<QString, QStr
|
|||
qDebug() << "TMxpFrameManager::createFrame:" << name << "TITLE attr:" << attributes.value(qsl("TITLE")) << "title:" << frame->title << "floating:" << frame->floating;
|
||||
#endif
|
||||
|
||||
// relayoutFrames() works off mFrameOrder, so nothing may lay a frame out
|
||||
// before it is in there
|
||||
mFrames[name] = frame;
|
||||
mFrameOrder.append(frame);
|
||||
|
||||
// Create the appropriate UI layout
|
||||
if (frame->isInternal) {
|
||||
if (!frame->dockFrame.isEmpty() && frame->align == qsl("client")) {
|
||||
|
|
@ -135,9 +140,6 @@ bool TMxpFrameManager::createFrame(const QString& name, const QMap<QString, QStr
|
|||
layoutExternalFrame(frame);
|
||||
}
|
||||
|
||||
// Store the frame
|
||||
mFrames[name] = frame;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -176,6 +178,7 @@ bool TMxpFrameManager::closeFrame(const QString& name)
|
|||
|
||||
// Remove from frames map and delete
|
||||
mFrames.remove(name);
|
||||
mFrameOrder.removeOne(frame);
|
||||
delete frame;
|
||||
|
||||
// No need to recalculate borders for tab frames since they don't affect main window borders
|
||||
|
|
@ -200,10 +203,11 @@ bool TMxpFrameManager::closeFrame(const QString& name)
|
|||
removeFrameFromHierarchy(frame);
|
||||
|
||||
mFrames.remove(name);
|
||||
mFrameOrder.removeOne(frame);
|
||||
delete frame;
|
||||
|
||||
// Recalculate borders after frame removal to reclaim space
|
||||
recalculateBorders();
|
||||
// Reposition what is left so it reclaims the space the frame gave up
|
||||
relayoutFrames();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -257,6 +261,7 @@ void TMxpFrameManager::resetAllFrames()
|
|||
closeFrame(name);
|
||||
}
|
||||
|
||||
mFrameOrder.clear();
|
||||
mMxpBorders = QMargins();
|
||||
|
||||
if (mpHost) {
|
||||
|
|
@ -350,29 +355,38 @@ QStringList TMxpFrameManager::getFrameNames() const
|
|||
return mFrames.keys();
|
||||
}
|
||||
|
||||
void TMxpFrameManager::layoutInternalFrame(TMxpFrame* frame)
|
||||
QRect TMxpFrameManager::availableFrameArea() const
|
||||
{
|
||||
if (!mpHost || !mpHost->mpConsole) {
|
||||
qWarning() << "TMxpFrameManager::layoutInternalFrame: No console available";
|
||||
return;
|
||||
if (!mpHost || mpHost->mpConsole.isNull()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
TMainConsole* mainConsole = mpHost->mpConsole.data();
|
||||
|
||||
// Note: DOCK tabbing is handled in createFrame() when ALIGN=CLIENT is set.
|
||||
// Per CMUD, ALIGN=CLIENT + DOCK creates tabbed frames.
|
||||
// This is a CMUD extension, not part of the official MXP 1.0 specification.
|
||||
|
||||
// Check if we're inside a DEST - if so, nest this frame inside the destination
|
||||
TMxpFrame* parentFrame = nullptr;
|
||||
if (!mCurrentDestination.isEmpty()) {
|
||||
parentFrame = getFrame(mCurrentDestination);
|
||||
// getMainWindowSize() rather than mpMainFrame's own geometry, which
|
||||
// TConsole::resizeEvent() sets to the full console size until the layout
|
||||
// corrects it. It is also the size Lua scripts lay themselves out against,
|
||||
// so taking the user borders off it keeps frames out of the space a package
|
||||
// such as the base UI has reserved with setBorderRight() and friends.
|
||||
QRect area = QRect(QPoint(0, 0), mpHost->mpConsole->getMainWindowSize()).marginsRemoved(mpHost->userBorders());
|
||||
if (area.width() < 0) {
|
||||
area.setWidth(0);
|
||||
}
|
||||
if (area.height() < 0) {
|
||||
area.setHeight(0);
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
// Works out where a frame goes. An edge aligned top level frame consumes the
|
||||
// space it takes from mMxpBorders and a nested one advances its parent's
|
||||
// usedHeight; an absolutely positioned frame consumes neither. Callers are
|
||||
// responsible for pushing the updated borders to the Host.
|
||||
QRect TMxpFrameManager::calculateFrameGeometry(TMxpFrame* frame, TMxpFrame* parentFrame)
|
||||
{
|
||||
// Determine the container for this frame
|
||||
QSize containerSize;
|
||||
int containerX = 0;
|
||||
int containerY = 0;
|
||||
QRect area;
|
||||
|
||||
if (parentFrame && parentFrame->widget) {
|
||||
// Nested frame - position relative to parent
|
||||
|
|
@ -384,12 +398,17 @@ void TMxpFrameManager::layoutInternalFrame(TMxpFrame* frame)
|
|||
containerY += parentFrame->usedHeight;
|
||||
containerSize.setHeight(containerSize.height() - parentFrame->usedHeight);
|
||||
} else {
|
||||
// Top-level frame - use MXP-specific borders (not Host borders which are for Lua)
|
||||
containerSize = mainConsole->size();
|
||||
containerX = mMxpBorders.left();
|
||||
containerY = mMxpBorders.top();
|
||||
containerSize.setWidth(containerSize.width() - mMxpBorders.left() - mMxpBorders.right());
|
||||
containerSize.setHeight(containerSize.height() - mMxpBorders.top() - mMxpBorders.bottom());
|
||||
// A parent with no widget of its own gives nothing to place against, so
|
||||
// such a frame is placed as a top level one for the rest of this
|
||||
// calculation - frame->parentFrame still records the hierarchy
|
||||
parentFrame = nullptr;
|
||||
// MXP borders stack inwards from the area the user borders leave.
|
||||
// userBorders() and not borders(), which already carries the MXP borders
|
||||
// this function is in the middle of recomputing.
|
||||
area = availableFrameArea();
|
||||
containerX = area.x() + mMxpBorders.left();
|
||||
containerY = area.y() + mMxpBorders.top();
|
||||
containerSize = QSize(area.width() - mMxpBorders.left() - mMxpBorders.right(), area.height() - mMxpBorders.top() - mMxpBorders.bottom());
|
||||
}
|
||||
|
||||
// Calculate frame dimensions relative to container
|
||||
|
|
@ -404,13 +423,14 @@ void TMxpFrameManager::layoutInternalFrame(TMxpFrame* frame)
|
|||
}
|
||||
|
||||
// Ensure minimum size for visibility
|
||||
if (frameWidth < 50)
|
||||
if (frameWidth < 50) {
|
||||
frameWidth = 100;
|
||||
}
|
||||
|
||||
// For character-based height specs, handle minimum size more carefully
|
||||
bool isCharacterHeight = frame->height.trimmed().endsWith('c', Qt::CaseInsensitive);
|
||||
bool isCharacterWidth = frame->width.trimmed().endsWith('c', Qt::CaseInsensitive);
|
||||
bool willHaveTitle = !frame->floating && frame->hasExplicitTitle;
|
||||
const bool isCharacterHeight = frame->height.trimmed().endsWith('c', Qt::CaseInsensitive);
|
||||
const bool isCharacterWidth = frame->width.trimmed().endsWith('c', Qt::CaseInsensitive);
|
||||
const bool willHaveTitle = !frame->floating && frame->hasExplicitTitle;
|
||||
|
||||
// Apply minimum size for visibility to non-character-based frames
|
||||
if (frameHeight < 20 && !isCharacterHeight) {
|
||||
|
|
@ -455,7 +475,7 @@ void TMxpFrameManager::layoutInternalFrame(TMxpFrame* frame)
|
|||
// Calculate position based on alignment
|
||||
int x = containerX;
|
||||
int y = containerY;
|
||||
QString align = frame->align.toLower();
|
||||
const QString align = frame->align.toLower();
|
||||
|
||||
if (parentFrame) {
|
||||
// Nested frame - position within parent's bounds using VBox/HBox logic
|
||||
|
|
@ -473,62 +493,92 @@ void TMxpFrameManager::layoutInternalFrame(TMxpFrame* frame)
|
|||
y = containerY + containerSize.height() - frameHeight;
|
||||
frameWidth = containerSize.width();
|
||||
}
|
||||
} else {
|
||||
// Top-level frame - position at window edges and update MXP borders
|
||||
QSize windowSize = mainConsole->size();
|
||||
|
||||
// Check for LEFT/TOP absolute positioning first - these take precedence over alignment
|
||||
bool hasAbsolutePosition = !frame->left.isEmpty() || !frame->top.isEmpty();
|
||||
|
||||
if (hasAbsolutePosition) {
|
||||
// Absolute positioning via LEFT/TOP attributes
|
||||
if (!frame->left.isEmpty()) {
|
||||
QSize leftSize = calculateFrameSize(frame->left, windowSize, false);
|
||||
if (leftSize.width() > 0) {
|
||||
x = leftSize.width();
|
||||
}
|
||||
}
|
||||
if (!frame->top.isEmpty()) {
|
||||
QSize topSize = calculateFrameSize(frame->top, windowSize, true);
|
||||
if (topSize.height() > 0) {
|
||||
y = topSize.height();
|
||||
}
|
||||
}
|
||||
// Absolute positioned frames don't modify MXP borders
|
||||
} else if (align == qsl("left")) {
|
||||
// Left-aligned: position at actual left edge (after existing left MXP frames)
|
||||
x = mMxpBorders.left();
|
||||
y = 0;
|
||||
frameHeight = windowSize.height();
|
||||
// Update MXP left border
|
||||
mMxpBorders.setLeft(mMxpBorders.left() + frameWidth);
|
||||
} else if (align == qsl("right")) {
|
||||
// Right-aligned: position at right edge
|
||||
x = windowSize.width() - mMxpBorders.right() - frameWidth;
|
||||
y = 0;
|
||||
frameHeight = windowSize.height();
|
||||
mMxpBorders.setRight(mMxpBorders.right() + frameWidth);
|
||||
} else if (align == qsl("top")) {
|
||||
// Top-aligned: position at top edge
|
||||
x = mMxpBorders.left();
|
||||
y = mMxpBorders.top();
|
||||
frameWidth = windowSize.width() - mMxpBorders.left() - mMxpBorders.right();
|
||||
mMxpBorders.setTop(mMxpBorders.top() + frameHeight);
|
||||
} else if (align == qsl("bottom")) {
|
||||
// Bottom-aligned: position at bottom edge
|
||||
x = mMxpBorders.left();
|
||||
y = windowSize.height() - mMxpBorders.bottom() - frameHeight;
|
||||
frameWidth = windowSize.width() - mMxpBorders.left() - mMxpBorders.right();
|
||||
mMxpBorders.setBottom(mMxpBorders.bottom() + frameHeight);
|
||||
} else {
|
||||
// No alignment and no absolute positioning - use container defaults
|
||||
x = containerX;
|
||||
y = containerY;
|
||||
}
|
||||
|
||||
mpHost->setMxpBorders(mMxpBorders);
|
||||
return {x, y, frameWidth, frameHeight};
|
||||
}
|
||||
|
||||
// Top-level frame - position at the edges of the available area and update MXP borders
|
||||
|
||||
// Check for LEFT/TOP absolute positioning first - these take precedence over alignment
|
||||
const bool hasAbsolutePosition = !frame->left.isEmpty() || !frame->top.isEmpty();
|
||||
|
||||
if (hasAbsolutePosition) {
|
||||
// Absolute positioning via LEFT/TOP attributes
|
||||
if (!frame->left.isEmpty()) {
|
||||
QSize leftSize = calculateFrameSize(frame->left, area.size(), false);
|
||||
if (leftSize.width() > 0) {
|
||||
x = area.x() + leftSize.width();
|
||||
}
|
||||
}
|
||||
if (!frame->top.isEmpty()) {
|
||||
QSize topSize = calculateFrameSize(frame->top, area.size(), true);
|
||||
if (topSize.height() > 0) {
|
||||
y = area.y() + topSize.height();
|
||||
}
|
||||
}
|
||||
// Absolute positioned frames don't modify MXP borders
|
||||
} else if (align == qsl("left")) {
|
||||
// Left-aligned: position at actual left edge (after existing left MXP frames)
|
||||
x = area.x() + mMxpBorders.left();
|
||||
y = area.y();
|
||||
frameHeight = area.height();
|
||||
// Update MXP left border
|
||||
mMxpBorders.setLeft(mMxpBorders.left() + frameWidth);
|
||||
} else if (align == qsl("right")) {
|
||||
// Right-aligned: position at right edge
|
||||
x = area.x() + area.width() - mMxpBorders.right() - frameWidth;
|
||||
y = area.y();
|
||||
frameHeight = area.height();
|
||||
mMxpBorders.setRight(mMxpBorders.right() + frameWidth);
|
||||
} else if (align == qsl("top")) {
|
||||
// Top-aligned: position at top edge
|
||||
x = area.x() + mMxpBorders.left();
|
||||
y = area.y() + mMxpBorders.top();
|
||||
frameWidth = area.width() - mMxpBorders.left() - mMxpBorders.right();
|
||||
mMxpBorders.setTop(mMxpBorders.top() + frameHeight);
|
||||
} else if (align == qsl("bottom")) {
|
||||
// Bottom-aligned: position at bottom edge
|
||||
x = area.x() + mMxpBorders.left();
|
||||
y = area.y() + area.height() - mMxpBorders.bottom() - frameHeight;
|
||||
frameWidth = area.width() - mMxpBorders.left() - mMxpBorders.right();
|
||||
mMxpBorders.setBottom(mMxpBorders.bottom() + frameHeight);
|
||||
}
|
||||
|
||||
return {x, y, frameWidth, frameHeight};
|
||||
}
|
||||
|
||||
void TMxpFrameManager::layoutInternalFrame(TMxpFrame* frame)
|
||||
{
|
||||
if (!mpHost || !mpHost->mpConsole) {
|
||||
qWarning() << "TMxpFrameManager::layoutInternalFrame: No console available";
|
||||
return;
|
||||
}
|
||||
|
||||
TMainConsole* mainConsole = mpHost->mpConsole.data();
|
||||
|
||||
// Note: DOCK tabbing is handled in createFrame() when ALIGN=CLIENT is set.
|
||||
// Per CMUD, ALIGN=CLIENT + DOCK creates tabbed frames.
|
||||
// This is a CMUD extension, not part of the official MXP 1.0 specification.
|
||||
|
||||
// Check if we're inside a DEST - if so, nest this frame inside the destination
|
||||
TMxpFrame* parentFrame = nullptr;
|
||||
if (!mCurrentDestination.isEmpty()) {
|
||||
parentFrame = getFrame(mCurrentDestination);
|
||||
}
|
||||
|
||||
const QRect geometry = calculateFrameGeometry(frame, parentFrame);
|
||||
const int x = geometry.x();
|
||||
const int y = geometry.y();
|
||||
const int frameWidth = geometry.width();
|
||||
const int frameHeight = geometry.height();
|
||||
|
||||
// A nested frame never touches mMxpBorders, so this hands Host the margins
|
||||
// it already has and setBorders() drops it
|
||||
mpHost->setMxpBorders(mMxpBorders);
|
||||
|
||||
const bool isCharacterHeight = frame->height.trimmed().endsWith('c', Qt::CaseInsensitive);
|
||||
const bool willHaveTitle = !frame->floating && frame->hasExplicitTitle;
|
||||
|
||||
// FLOATING attribute, no explicit title, or very small height = borderless frame without header
|
||||
// Exception: character-based frames with explicit titles always show headers
|
||||
bool showHeader = !frame->floating && frame->hasExplicitTitle && (frameHeight >= 50 || (isCharacterHeight && willHaveTitle));
|
||||
|
|
@ -1002,51 +1052,58 @@ void TMxpFrameManager::layoutTabIntoExistingFrame(TMxpFrame* frame, TMxpFrame* t
|
|||
console->show();
|
||||
}
|
||||
|
||||
void TMxpFrameManager::recalculateBorders()
|
||||
void TMxpFrameManager::scheduleRelayout()
|
||||
{
|
||||
if (mRelayoutPending || mFrames.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deferred so that the layout of the widgets frames are placed against has
|
||||
// settled, and so that pushing new borders from here cannot re-enter the
|
||||
// resize handling that asked for the relayout. The push at the end of a
|
||||
// relayout does schedule one more pass, which then finds the same borders
|
||||
// and stops there because Host::setBorders() ignores an unchanged value.
|
||||
mRelayoutPending = true;
|
||||
QTimer::singleShot(0, mpHost, [this]() {
|
||||
mRelayoutPending = false;
|
||||
relayoutFrames();
|
||||
});
|
||||
}
|
||||
|
||||
void TMxpFrameManager::relayoutFrames()
|
||||
{
|
||||
if (!mpHost) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset borders and recalculate based on remaining frames
|
||||
// calculateFrameGeometry() accumulates into these, so they have to start
|
||||
// empty or every pass would count the same frames again
|
||||
mMxpBorders = QMargins();
|
||||
|
||||
if (!mpHost->mpConsole) {
|
||||
if (mpHost->mpConsole.isNull() || !mpHost->mpConsole->mpMainFrame) {
|
||||
mpHost->setMxpBorders(mMxpBorders);
|
||||
return;
|
||||
}
|
||||
|
||||
QSize windowSize = mpHost->mpConsole->size();
|
||||
const QWidget* mainFrame = mpHost->mpConsole->mpMainFrame;
|
||||
|
||||
// Recalculate borders by examining all remaining top-level frames
|
||||
// (frames without parents that affect the main window borders)
|
||||
for (auto* frame : mFrames.values()) {
|
||||
if (!frame || frame->parentFrame) {
|
||||
continue; // Skip child frames, only process top-level frames
|
||||
}
|
||||
// calculateFrameGeometry() also accumulates into a parent's usedHeight, so
|
||||
// without this nested frames would march further down on every pass
|
||||
for (auto* frame : std::as_const(mFrameOrder)) {
|
||||
frame->usedHeight = 0;
|
||||
}
|
||||
|
||||
// Only frames that were positioned using alignment (not absolute positioning)
|
||||
// contribute to MXP borders
|
||||
bool hasAbsolutePosition = !frame->left.isEmpty() || !frame->top.isEmpty();
|
||||
if (hasAbsolutePosition) {
|
||||
for (auto* frame : std::as_const(mFrameOrder)) {
|
||||
// Skip a frame whose layout never produced a widget, one docked as a tab
|
||||
// (the QTabWidget places it), and one in a window of its own. An external
|
||||
// frame keeps mpMainFrame as its parent even after Qt::Window is set on
|
||||
// it, so isWindow() rather than the parent is what tells them apart.
|
||||
if (!frame->widget || frame->widget->isWindow() || frame->widget->parentWidget() != mainFrame) {
|
||||
continue;
|
||||
}
|
||||
|
||||
QString align = frame->align.toLower();
|
||||
QSize widthSize = calculateFrameSize(frame->width, windowSize, false);
|
||||
QSize heightSize = calculateFrameSize(frame->height, windowSize, true);
|
||||
|
||||
if (align == qsl("left")) {
|
||||
mMxpBorders.setLeft(mMxpBorders.left() + widthSize.width());
|
||||
} else if (align == qsl("right")) {
|
||||
mMxpBorders.setRight(mMxpBorders.right() + widthSize.width());
|
||||
} else if (align == qsl("top")) {
|
||||
mMxpBorders.setTop(mMxpBorders.top() + heightSize.height());
|
||||
} else if (align == qsl("bottom")) {
|
||||
mMxpBorders.setBottom(mMxpBorders.bottom() + heightSize.height());
|
||||
}
|
||||
frame->widget->setGeometry(calculateFrameGeometry(frame, frame->parentFrame));
|
||||
}
|
||||
|
||||
// Apply the recalculated borders
|
||||
mpHost->setMxpBorders(mMxpBorders);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
#include <QMap>
|
||||
#include <QMargins>
|
||||
#include <QPointer>
|
||||
#include <QRect>
|
||||
#include <QSize>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
|
@ -111,31 +112,41 @@ public:
|
|||
QStringList getFrameNames() const;
|
||||
bool frameExists(const QString& name) const { return mFrames.contains(name); }
|
||||
int frameCount() const { return mFrames.size(); }
|
||||
|
||||
|
||||
// Reposition every frame on the next event loop turn, once the space they
|
||||
// are laid out in has changed. Does nothing while no frames are open.
|
||||
void scheduleRelayout();
|
||||
|
||||
// Configuration
|
||||
static constexpr int MAX_FRAMES = 20;
|
||||
|
||||
private:
|
||||
Host* mpHost;
|
||||
QMap<QString, TMxpFrame*> mFrames;
|
||||
// Frames in creation order: borders accumulate inwards, so the order frames
|
||||
// were opened in decides where each one sits
|
||||
QList<TMxpFrame*> mFrameOrder;
|
||||
QString mCurrentDestination; // Current output target (empty = main console)
|
||||
QMargins mMxpBorders; // MXP-specific borders, separate from Host::mBorders
|
||||
|
||||
bool mRelayoutPending = false;
|
||||
|
||||
// Layout and sizing helpers
|
||||
void layoutInternalFrame(TMxpFrame* frame);
|
||||
void layoutExternalFrame(TMxpFrame* frame);
|
||||
void layoutTabFrame(TMxpFrame* frame);
|
||||
void layoutTabIntoExistingFrame(TMxpFrame* frame, TMxpFrame* targetFrame);
|
||||
QRect availableFrameArea() const;
|
||||
QRect calculateFrameGeometry(TMxpFrame* frame, TMxpFrame* parentFrame);
|
||||
QSize calculateFrameSize(const QString& spec, const QSize& containerSize, bool isHeight);
|
||||
void relayoutFrames();
|
||||
Qt::DockWidgetArea alignmentToDockArea(const QString& align);
|
||||
|
||||
|
||||
// Validation
|
||||
bool validateFrameName(const QString& name) const;
|
||||
bool canCreateFrame() const;
|
||||
|
||||
|
||||
// Cleanup
|
||||
void removeFrameFromHierarchy(TMxpFrame* frame);
|
||||
void recalculateBorders();
|
||||
};
|
||||
|
||||
#endif // MUDLET_TMXPFRAMEMANAGER_H
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@
|
|||
***************************************************************************/
|
||||
|
||||
#include "TMxpLinkTagHandler.h"
|
||||
#include "LuaLiteral.h"
|
||||
#include "TMxpClient.h"
|
||||
#include "UntrustedText.h"
|
||||
|
||||
// <A href=URL [hint=text] [expire=name]>
|
||||
TMxpTagHandlerResult TMxpLinkTagHandler::handleStartTag(TMxpContext& ctx, TMxpClient& client, MxpStartTag* tag)
|
||||
|
|
@ -37,9 +39,12 @@ TMxpTagHandlerResult TMxpLinkTagHandler::handleStartTag(TMxpContext& ctx, TMxpCl
|
|||
return MXP_TAG_NOT_HANDLED;
|
||||
}
|
||||
|
||||
const QString hint = tag->hasAttribute(qsl("hint")) ? tag->getAttributeValue(qsl("hint")) : href;
|
||||
// Server-supplied, and lands in the same tooltip as an OSC 8 hint. An
|
||||
// explicit hint is prose written to be read; falling back to the href makes
|
||||
// this a link target the user is being asked to trust.
|
||||
const QString hint = tag->hasAttribute(qsl("hint")) ? UntrustedText::forAuthoredText(tag->getAttributeValue(qsl("hint"))) : UntrustedText::forTarget(href);
|
||||
|
||||
href = qsl("openUrl([[%1]])").arg(href);
|
||||
href = qsl("openUrl(%1)").arg(LuaLiteral::quote(href));
|
||||
|
||||
// Use the version of setLink that supports expire names
|
||||
if (!expireName.isEmpty()) {
|
||||
|
|
|
|||
|
|
@ -390,7 +390,6 @@ TMxpProcessingResult TMxpProcessor::processMxpInput(char& ch, bool resolveCustom
|
|||
return HANDLER_INSERT_ENTITY_LIT;
|
||||
}
|
||||
}
|
||||
// ask for the next char
|
||||
return HANDLER_NEXT_CHAR;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -892,8 +892,17 @@ void TRoom::restore(QDataStream& ifs, int roomID, int version)
|
|||
if (!hiddenString.compare(QLatin1String("true"), Qt::CaseInsensitive)) {
|
||||
hidden = true;
|
||||
}
|
||||
} else {
|
||||
// The stream carries the authoritative value so any copy of the
|
||||
// fallback key in the user data is stale:
|
||||
userData.remove(QLatin1String("system.fallback_hidden"));
|
||||
}
|
||||
if (version < 19) {
|
||||
if (version >= 19) {
|
||||
// Clean up a stale fallback key that past versions could leave
|
||||
// behind in the live room's user data (and thus in files saved
|
||||
// from it) after saving in a format before 19:
|
||||
userData.remove(QLatin1String("system.fallback_symbol"));
|
||||
} else {
|
||||
const QString symbolString = userData.take(QLatin1String("system.fallback_symbol"));
|
||||
if (!symbolString.isEmpty()) {
|
||||
// There is a fallback in the user data
|
||||
|
|
@ -915,6 +924,10 @@ void TRoom::restore(QDataStream& ifs, int roomID, int version)
|
|||
if (userData.contains(symbolColorFallbackKey)) {
|
||||
mSymbolColor = QColor(userData.take(symbolColorFallbackKey));
|
||||
}
|
||||
} else {
|
||||
// The stream carries the authoritative value so any copy of the
|
||||
// fallback key in the user data is stale:
|
||||
userData.remove(QLatin1String("system.fallback_symbol_color"));
|
||||
}
|
||||
|
||||
// Border properties are stored in userData (not binary stream) to avoid map bloat
|
||||
|
|
|
|||
|
|
@ -25,9 +25,12 @@
|
|||
|
||||
|
||||
#include "Host.h"
|
||||
#include "ScriptUnit.h"
|
||||
#include "TDebug.h"
|
||||
#include "mudlet.h"
|
||||
|
||||
#include <QScopeGuard>
|
||||
|
||||
TScript::TScript(TScript* parent, Host* pHost)
|
||||
: Tree<TScript>(parent)
|
||||
, mpHost(pHost)
|
||||
|
|
@ -120,6 +123,26 @@ bool TScript::setScript(const QString& script)
|
|||
|
||||
bool TScript::compileScript(bool saveLoadingError)
|
||||
{
|
||||
// Whilst this frame is on the stack ScriptUnit::uninstall() must defer deleting
|
||||
// this profile's scripts: the top-level Lua body run below (the lua_pcall inside
|
||||
// TLuaInterpreter::compile()) can uninstall its own package - a common package
|
||||
// auto-updater pattern - and freeing this script mid-compile, or writing to it
|
||||
// after compile() returns (see mNeedsToBeCompiled/mOK_code below and in
|
||||
// setScript()), is a use-after-free. See ScriptUnit::mProcessingDepth.
|
||||
ScriptUnit* pUnit = mpHost->getScriptUnit();
|
||||
pUnit->beginProcessing();
|
||||
// NB: deliberately decrement-only - do NOT add a doCleanup() call here. setScript()
|
||||
// writes mOK_code AFTER this returns and ScriptUnit::compileAll()'s loop is still
|
||||
// iterating the root list, so deleting `this` now would be a use-after-free. The
|
||||
// deferred deletes are flushed at a safe point once the pointer is no longer in
|
||||
// use: after ScriptUnit::compileAll()'s loop, at the end of the editor's
|
||||
// saveScript(), in Host::raiseEvent()'s scope guard, and by the catch-all
|
||||
// doCleanup() in Host::incomingStreamProcessor()/slot_purgeTemps() and the queued
|
||||
// save in Host::uninstallPackage().
|
||||
const auto processingGuard = qScopeGuard([pUnit] {
|
||||
pUnit->endProcessing();
|
||||
});
|
||||
|
||||
QString error;
|
||||
if (mpHost->mLuaInterpreter.compile(mScript, error, QString("Script: ") + getName())) {
|
||||
mNeedsToBeCompiled = false;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue