Merge development

Assisted-by: Claude:claude-opus-5
This commit is contained in:
Vadim Peretokin 2026-08-11 19:16:13 +02:00
commit 679cd094d3
350 changed files with 63629 additions and 7680 deletions

8
.gitattributes vendored Normal file
View 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

View file

@ -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
View 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}"

View file

@ -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: |

View file

@ -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

View file

@ -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

View file

@ -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
View 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) || '' }}

View file

@ -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

View file

@ -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'

View file

@ -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: |

View file

@ -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}"

View file

@ -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
View file

@ -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

@ -1 +1 @@
Subproject commit a1827544e2da7e50517615003288c25380f8d457
Subproject commit a185ce80ba2416b0a0bb04b4ee8f11f1117ae08f

127
CI/assemble-release-checksums.sh Executable file
View 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}"

View file

@ -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
View 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
View 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
View 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
View 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()

View file

@ -0,0 +1 @@
Mudlet self-test HTTP fixture.

387
CI/mmcp-peer.py Normal file
View 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
View 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

View file

@ -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

View file

@ -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

View file

@ -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
View 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"

View file

@ -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

View file

@ -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 |

View file

@ -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) {

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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"

View file

@ -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.
}

View file

@ -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") {

View file

@ -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
View 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
View 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

View file

@ -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

View file

@ -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

View file

@ -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);
}

View file

@ -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;

View file

@ -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);

View file

@ -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
View 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";
}

View file

@ -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);

View file

@ -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
View 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
View 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

View file

@ -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);
}

View file

@ -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:

View file

@ -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();
}

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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();

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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();

View file

@ -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) {

View file

@ -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)

View file

@ -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;

View file

@ -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

View file

@ -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);

View file

@ -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();

View file

@ -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;
};

View file

@ -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>*));

View file

@ -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;

View file

@ -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)

View file

@ -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);

View file

@ -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;

View file

@ -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();

View file

@ -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

View file

@ -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;

View file

@ -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());
}

View file

@ -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;

View file

@ -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

View file

@ -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));
}

View file

@ -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 = {"<", ">", "&lt;", "&gt;"}; // 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

View file

@ -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;

View file

@ -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:

View file

@ -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)

View file

@ -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;

View file

@ -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); }

View file

@ -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;
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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

View file

@ -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()) {

View file

@ -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;
}

View file

@ -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

View file

@ -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