diff --git a/.CI/chatterino-nightly.flatpakref b/.CI/chatterino-nightly.flatpakref index a1484546b..61cf409e3 100644 --- a/.CI/chatterino-nightly.flatpakref +++ b/.CI/chatterino-nightly.flatpakref @@ -1,9 +1,9 @@ [Flatpak Ref] Name=com.chatterino.chatterino -Branch=nightly +Branch=beta Title=com.chatterino.chatterino from flathub IsRuntime=false -Url=https://dl.flathub.org/repo/ -SuggestRemoteName=flathub +Url=https://dl.flathub.org/beta-repo/ +SuggestRemoteName=flathub-beta GPGKey=mQINBFlD2sABEADsiUZUOYBg1UdDaWkEdJYkTSZD68214m8Q1fbrP5AptaUfCl8KYKFMNoAJRBXn9FbE6q6VBzghHXj/rSnA8WPnkbaEWR7xltOqzB1yHpCQ1l8xSfH5N02DMUBSRtD/rOYsBKbaJcOgW0K21sX+BecMY/AI2yADvCJEjhVKrjR9yfRX+NQEhDcbXUFRGt9ZT+TI5yT4xcwbvvTu7aFUR/dH7+wjrQ7lzoGlZGFFrQXSs2WI0WaYHWDeCwymtohXryF8lcWQkhH8UhfNJVBJFgCY8Q6UHkZG0FxMu8xnIDBMjBmSZKwKQn0nwzwM2afskZEnmNPYDI8nuNsSZBZSAw+ThhkdCZHZZRwzmjzyRuLLVFpOj3XryXwZcSefNMPDkZAuWWzPYjxS80cm2hG1WfqrG0Gl8+iX69cbQchb7gbEb0RtqNskTo9DDmO0bNKNnMbzmIJ3/rTbSahKSwtewklqSP/01o0WKZiy+n/RAkUKOFBprjJtWOZkc8SPXV/rnoS2dWsJWQZhuPPtv3tefdDiEyp7ePrfgfKxuHpZES0IZRiFI4J/nAUP5bix+srcIxOVqAam68CbAlPvWTivRUMRVbKjJiGXIOJ78wAMjqPg3QIC0GQ0EPAWwAOzzpdgbnG7TCQetaVV8rSYCuirlPYN+bJIwBtkOC9SWLoPMVZTwQARAQABtC5GbGF0aHViIFJlcG8gU2lnbmluZyBLZXkgPGZsYXRodWJAZmxhdGh1Yi5vcmc+iQJUBBMBCAA+FiEEblwF2XnHba+TwIE1QYTdTZB6fK4FAllD2sACGwMFCRLMAwAFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQQYTdTZB6fK5RJQ/+Ptd4sWxaiAW91FFk7+wmYOkEe1NY2UDNJjEEz34PNP/1RoxveHDt43kYJQ23OWaPJuZAbu+fWtjRYcMBzOsMCaFcRSHFiDIC9aTp4ux/mo+IEeyarYt/oyKb5t5lta6xaAqg7rwt65jW5/aQjnS4h7eFZ+dAKta7Y/fljNrOznUp81/SMcx4QA5G2Pw0hs4Xrxg59oONOTFGBgA6FF8WQghrpR7SnEe0FSEOVsAjwQ13Cfkfa7b70omXSWp7GWfUzgBKyoWxKTqzMN3RQHjjhPJcsQnrqH5enUu4Pcb2LcMFpzimHnUgb9ft72DP5wxfzHGAWOUiUXHbAekfq5iFks8cha/RST6wkxG3Rf44Zn09aOxh1btMcGL+5xb1G0BuCQnA0fP/kDYIPwh9z22EqwRQOspIcvGeLVkFeIfubxpcMdOfQqQnZtHMCabV5Q/Rk9K1ZGc8M2hlg8gHbXMFch2xJ0Wu72eXbA/UY5MskEeBgawTQnQOK/vNm7t0AJMpWK26Qg6178UmRghmeZDj9uNRc3EI1nSbgvmGlpDmCxaAGqaGL1zW4KPW5yN25/qeqXcgCvUjZLI9PNq3Kvizp1lUrbx7heRiSoazCucvHQ1VHUzcPVLUKKTkoTP8okThnRRRsBcZ1+jI4yMWIDLOCT7IW3FePr+3xyuy5eEo9a25Ag0EWUPa7AEQALT/CmSyZ8LWlRYQZKYw417p7Z2hxqd6TjwkwM3IQ1irumkWcTZBZIbBgrSOg6CcXD2oWydCQHWi9qaxhuhEl2bJL5LskmBcMxVdQeD0LLHd8QUnbnnIby8ocvWN1alPfvJFjCUTrmD22U1ycOzRw2lIe4kiQONbOZtdWrVImQQSndjFlisitbmlWHvHm2lOOYy8+GJB7YffVV193hmnBSJffCy4bvkuLxsI+n1DhOzc7MPV3z6HGk4HiEcF0yyt9tCYhpsxHFdBoq2h771HfAcS0s98EVAqYMFnf9em+4cnYpdI6mhIfS1FQiKl6DBAYA8tT3ggla00DurPo0JwX/zN+PaO5h/6O9aCZwV7G6rbkgMuqMergXaf8oP38gr0z+MqWnkfM63Bodq68GP4l4hd02BoFBbDf38TMuGQB14+twJMdfbAxo2MbgluvQgfwHfZ2ca6gyEY+9s/YD1gugLjV+S6CB51WkFNe1z4tAPgJZNxUcKCbeaHNbthl8Hks/pY9RCEseX/EdfzF18epbSjJMPh4DPQXbUoFwmyuYcoBOPmvZHNl9hK7B/1RP8w1ZrXk8qdupC0SNbafX7270B7lMMVImzZetGsM9ypXJ6llhp3FwW09iseNyGJGPsr/dvTMGDXqOPfU/9SAS1LSTY4K9PbRtdrBE318YX8mIk5ABEBAAGJBHIEGAEIACYWIQRuXAXZecdtr5PAgTVBhN1NkHp8rgUCWUPa7AIbAgUJEswDAAJACRBBhN1NkHp8rsF0IAQZAQgAHRYhBFSmzd2JGfsgQgDYrFYnAunj7X7oBQJZQ9rsAAoJEFYnAunj7X7oR6AP/0KYmiAFeqx14Z43/6s2gt3VhxlSd8bmcVV7oJFbMhdHBIeWBp2BvsUf00I0Zl14ZkwCKfLwbbORC2eIxvzJ+QWjGfPhDmS4XUSmhlXxWnYEveSek5Tde+fmu6lqKM8CHg5BNx4GWIX/vdLi1wWJZyhrUwwICAxkuhKxuP2Z1An48930eslTD2GGcjByc27+9cIZjHKa07I/aLffo04V+oMT9/tgzoquzgpVV4jwekADo2MJjhkkPveSNI420bgT+Q7Fi1l0X1aFUniBvQMsaBa27PngWm6xE2ZYvh7nWCdd5g0c0eLIHxWwzV1lZ4Ryx4ITO/VL25ItECcjhTRdYa64sA62MYSaB0x3eR+SihpgP3wSNPFu3MJo6FKTFdi4CBAEmpWHFW7FcRmd+cQXeFrHLN3iNVWryy0HK/CUEJmiZEmpNiXecl4vPIIuyF0zgSCztQtKoMr+injpmQGC/rF/ELBVZTUSLNB350S0Ztvw0FKWDAJSxFmoxt3xycqvvt47rxTrhi78nkk6jATKGyvP55sO+K7Q7Wh0DXA69hvPrYW2eu8jGCdVGxi6HX7L1qcfEd0378S71dZ3g9o6KKl1OsDWWQ6MJ6FGBZedl/ibRfs8p5+sbCX3lQSjEFy3rx6n0rUrXx8U2qb+RCLzJlmC5MNBOTDJwHPcX6gKsUcXZrEQALmRHoo3SrewO41RCr+5nUlqiqV3AohBMhnQbGzyHf2+drutIaoh7Rj80XRh2bkkuPLwlNPf+bTXwNVGse4bej7B3oV6Ae1N7lTNVF4Qh+1OowtGjmfJPWo0z1s6HFJVxoIof9z58Msvgao0zrKGqaMWaNQ6LUeC9g9Aj/9Uqjbo8X54aLiYs8Z1WNc06jKP+gv8AWLtv6CR+l2kLez1YMDucjm7v6iuCMVAmZdmxhg5I/X2+OM3vBsqPDdQpr2TPDLX3rCrSBiS0gOQ6DwN5N5QeTkxmY/7QO8bgLo/Wzu1iilH4vMKW6LBKCaRx5UEJxKpL4wkgITsYKneIt3NTHo5EOuaYk+y2+Dvt6EQFiuMsdbfUjs3seIHsghX/cbPJa4YUqZAL8C4OtVHaijwGo0ymt9MWvS9yNKMyT0JhN2/BdeOVWrHk7wXXJn/ZjpXilicXKPx4udCF76meE+6N2u/T+RYZ7fP1QMEtNZNmYDOfA6sViuPDfQSHLNbauJBo/n1sRYAsL5mcG22UDchJrlKvmK3EOADCQg+myrm8006LltubNB4wWNzHDJ0Ls2JGzQZCd/xGyVmUiidCBUrD537WdknOYE4FD7P0cHaM9brKJ/M8LkEH0zUlo73bY4XagbnCqve6PvQb5G2Z55qhWphd6f4B6DGed86zJEa/RhS RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo diff --git a/benchmarks/.clang-format b/.clang-format similarity index 100% rename from benchmarks/.clang-format rename to .clang-format diff --git a/.clang-tidy b/.clang-tidy index 658f66139..32bd6b420 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -29,6 +29,7 @@ Checks: "-*, -readability-function-cognitive-complexity, -bugprone-easily-swappable-parameters, -cert-err58-cpp, + -modernize-avoid-c-arrays " CheckOptions: - key: readability-identifier-naming.ClassCase diff --git a/.docker/README.md b/.docker/README.md index 6d8b1f0c3..08fc9261b 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -36,7 +36,7 @@ NOTE: The AppImage from Ubuntu 22.04 is broken. Approach with caution #### Testing -1. Build a docker image builds the chatterino tests +1. Build a docker image builds the Chatterino tests `docker buildx build -t chatterino-ubuntu-22.04-test -f .docker/Dockerfile-ubuntu-22.04-test .` 1. Run the tests `docker run --rm --network=host chatterino-ubuntu-22.04-test` diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9880f2ce1..2c20ec100 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,5 @@ -# Description - - + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 70a952134..7f9cdd097 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -230,9 +230,9 @@ jobs: run: | cd build set cl=/MP - nmake /S /NOLOGO crashpad_handler + nmake /S /NOLOGO chatterino-crash-handler mkdir Chatterino2/crashpad - cp bin/crashpad/crashpad_handler.exe Chatterino2/crashpad/crashpad_handler.exe + cp bin/crashpad/crashpad-handler.exe Chatterino2/crashpad/crashpad-handler.exe 7z a bin/chatterino-Qt-${{ matrix.qt-version }}.pdb.7z bin/chatterino.pdb - name: Prepare build dir (windows) @@ -257,14 +257,14 @@ jobs: - name: Upload artifact (Windows - binary) if: startsWith(matrix.os, 'windows') && !matrix.skip-artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}.zip path: build/chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}.zip - name: Upload artifact (Windows - symbols) if: startsWith(matrix.os, 'windows') && !matrix.skip-artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}-symbols.pdb.7z path: build/bin/chatterino-Qt-${{ matrix.qt-version }}.pdb.7z @@ -339,14 +339,14 @@ jobs: - name: Upload artifact - AppImage (Ubuntu) if: startsWith(matrix.os, 'ubuntu-20.04') && !matrix.skip-artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Chatterino-x86_64-${{ matrix.qt-version }}.AppImage path: build/Chatterino-x86_64.AppImage - name: Upload artifact - .deb (Ubuntu) if: startsWith(matrix.os, 'ubuntu') && !matrix.skip-artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Chatterino-${{ matrix.os }}-Qt-${{ matrix.qt-version }}.deb path: build/Chatterino-${{ matrix.os }}-x86_64.deb @@ -390,7 +390,7 @@ jobs: - name: Upload artifact (MacOS) if: startsWith(matrix.os, 'macos') - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: chatterino-macos-Qt-${{ matrix.qt-version }}.dmg path: build/chatterino-macos-Qt-${{ matrix.qt-version }}.dmg @@ -404,49 +404,49 @@ jobs: with: fetch-depth: 0 # allows for tags access - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 name: Ubuntu 22.04 Qt6.2.4 deb with: name: Chatterino-ubuntu-22.04-Qt-6.2.4.deb path: release-artifacts/ - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 name: Windows Qt6.5.0 with: name: chatterino-windows-x86-64-Qt-6.5.0.zip path: release-artifacts/ - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 name: Windows Qt6.5.0 symbols with: name: chatterino-windows-x86-64-Qt-6.5.0-symbols.pdb.7z path: release-artifacts/ - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 name: Windows Qt5.15.2 with: name: chatterino-windows-x86-64-Qt-5.15.2.zip path: release-artifacts/ - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 name: Linux Qt5.12.12 AppImage with: name: Chatterino-x86_64-5.12.12.AppImage path: release-artifacts/ - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 name: Ubuntu 20.04 Qt5.12.12 deb with: name: Chatterino-ubuntu-20.04-Qt-5.12.12.deb path: release-artifacts/ - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 name: Ubuntu 22.04 Qt5.15.2 deb with: name: Chatterino-ubuntu-22.04-Qt-5.15.2.deb path: release-artifacts/ - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 name: macOS x86_64 Qt5.15.2 dmg with: name: chatterino-macos-Qt-5.15.2.dmg diff --git a/.github/workflows/check-formatting.yml b/.github/workflows/check-formatting.yml index ca8789627..9a3a36685 100644 --- a/.github/workflows/check-formatting.yml +++ b/.github/workflows/check-formatting.yml @@ -33,4 +33,4 @@ jobs: clangFormatVersion: 16 - name: Check line-endings - run: ./tools/check-line-endings.sh + run: ./scripts/check-line-endings.sh diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml index 96468c137..95a832a7f 100644 --- a/.github/workflows/clang-tidy.yml +++ b/.github/workflows/clang-tidy.yml @@ -124,7 +124,7 @@ jobs: build_dir: build-clang-tidy config_file: ".clang-tidy" split_workflow: true - exclude: "lib/*" + exclude: "lib/*,tools/crash-handler/*" cmake_command: >- cmake -S. -Bbuild-clang-tidy -DCMAKE_BUILD_TYPE=Release diff --git a/.github/workflows/create-installer.yml b/.github/workflows/create-installer.yml index 588099375..c6087f4f1 100644 --- a/.github/workflows/create-installer.yml +++ b/.github/workflows/create-installer.yml @@ -25,7 +25,7 @@ jobs: fetch-depth: 0 # allows for tags access - name: Download artifact - uses: dawidd6/action-download-artifact@v2 + uses: dawidd6/action-download-artifact@v3 with: workflow: build.yml name: chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}.zip @@ -52,7 +52,7 @@ jobs: shell: powershell - name: Upload installer - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: path: build/${{ steps.build-installer.outputs.C2_INSTALLER_BASE_NAME }}.exe name: ${{ steps.build-installer.outputs.C2_INSTALLER_BASE_NAME }}.exe diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml index 4d9b8c55a..530f1169b 100644 --- a/.github/workflows/test-macos.yml +++ b/.github/workflows/test-macos.yml @@ -7,7 +7,7 @@ on: merge_group: env: - TWITCH_PUBSUB_SERVER_IMAGE: ghcr.io/chatterino/twitch-pubsub-server-test:v1.0.6 + TWITCH_PUBSUB_SERVER_TAG: v1.0.7 QT_QPA_PLATFORM: minimal concurrency: @@ -54,11 +54,7 @@ jobs: - name: Install dependencies run: | - brew install boost openssl rapidjson p7zip create-dmg cmake tree docker colima - - - name: Setup Colima - run: | - colima start + brew install boost openssl rapidjson p7zip create-dmg cmake - name: Build run: | @@ -74,18 +70,27 @@ jobs: .. make -j"$(sysctl -n hw.logicalcpu)" + - name: Download and extract Twitch PubSub Server Test + run: | + mkdir pubsub-server-test + curl -L -o pubsub-server.tar.gz "https://github.com/Chatterino/twitch-pubsub-server-test/releases/download/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/server-${{ env.TWITCH_PUBSUB_SERVER_TAG }}-darwin-amd64.tar.gz" + tar -xzf pubsub-server.tar.gz -C pubsub-server-test + rm pubsub-server.tar.gz + cd pubsub-server-test + curl -L -o server.crt "https://github.com/Chatterino/twitch-pubsub-server-test/raw/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/cmd/server/server.crt" + curl -L -o server.key "https://github.com/Chatterino/twitch-pubsub-server-test/raw/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/cmd/server/server.key" + cd .. + + - name: Cargo Install httpbox + run: | + cargo install --git https://github.com/kevinastone/httpbox --rev 89b971f + - name: Test timeout-minutes: 30 run: | - docker pull kennethreitz/httpbin - docker pull ${{ env.TWITCH_PUBSUB_SERVER_IMAGE }} - docker run --network=host --detach ${{ env.TWITCH_PUBSUB_SERVER_IMAGE }} - docker run -p 9051:80 --detach kennethreitz/httpbin + httpbox --port 9051 & + cd ../pubsub-server-test + ./server 127.0.0.1:9050 & + cd ../build-test ctest --repeat until-pass:4 --output-on-failure --exclude-regex ClassicEmoteNameFiltering working-directory: build-test - - - name: Post Setup Colima - if: always() - run: | - colima stop - working-directory: build-test diff --git a/.gitmodules b/.gitmodules index 571cc0f44..cb1235a85 100644 --- a/.gitmodules +++ b/.gitmodules @@ -38,6 +38,6 @@ [submodule "lib/lua/src"] path = lib/lua/src url = https://github.com/lua/lua -[submodule "lib/crashpad"] - path = lib/crashpad - url = https://github.com/getsentry/crashpad +[submodule "tools/crash-handler"] + path = tools/crash-handler + url = https://github.com/Chatterino/crash-handler diff --git a/BUILDING_ON_LINUX.md b/BUILDING_ON_LINUX.md index 7c0625d40..67ae8fe79 100644 --- a/BUILDING_ON_LINUX.md +++ b/BUILDING_ON_LINUX.md @@ -6,13 +6,13 @@ Note on Qt version compatibility: If you are installing Qt from a package manage ### Ubuntu 20.04 -_Most likely works the same for other Debian-like distros_ +_Most likely works the same for other Debian-like distros._ -Install all of the dependencies using `sudo apt install qttools5-dev qt5-image-formats-plugins libqt5svg5-dev libboost-dev libssl-dev libboost-system-dev libboost-filesystem-dev cmake g++ libsecret-1-dev` +Install all the dependencies using `sudo apt install qttools5-dev qt5-image-formats-plugins libqt5svg5-dev libboost-dev libssl-dev libboost-system-dev libboost-filesystem-dev cmake g++ libsecret-1-dev` ### Arch Linux -Install all of the dependencies using `sudo pacman -S --needed qt5-base qt5-imageformats qt5-svg qt5-tools boost rapidjson pkgconf openssl cmake` +Install all the dependencies using `sudo pacman -S --needed qt5-base qt5-imageformats qt5-svg qt5-tools boost rapidjson pkgconf openssl cmake` Alternatively you can use the [chatterino2-git](https://aur.archlinux.org/packages/chatterino2-git/) package to build and install Chatterino for you. @@ -20,11 +20,11 @@ Alternatively you can use the [chatterino2-git](https://aur.archlinux.org/packag _Most likely works the same for other Red Hat-like distros. Substitute `dnf` with `yum`._ -Install all of the dependencies using `sudo dnf install qt5-qtbase-devel qt5-qtimageformats qt5-qtsvg-devel qt5-linguist libsecret-devel openssl-devel boost-devel cmake` +Install all the dependencies using `sudo dnf install qt5-qtbase-devel qt5-qtimageformats qt5-qtsvg-devel qt5-linguist libsecret-devel openssl-devel boost-devel cmake` ### NixOS 18.09+ -Enter the development environment with all of the dependencies: `nix-shell -p openssl boost qt5.full pkg-config cmake` +Enter the development environment with all the dependencies: `nix-shell -p openssl boost qt5.full pkg-config cmake` ## Compile diff --git a/BUILDING_ON_MAC.md b/BUILDING_ON_MAC.md index 53e29cac6..78f94c7e9 100644 --- a/BUILDING_ON_MAC.md +++ b/BUILDING_ON_MAC.md @@ -20,7 +20,7 @@ Local dev machines for testing are available on Apple Silicon on macOS 13. 1. Go to the project directory where you cloned Chatterino2 & its submodules 1. Create a build directory and go into it: `mkdir build && cd build` -1. Run cmake: +1. Run CMake: `cmake -DCMAKE_PREFIX_PATH=/opt/homebrew/opt/qt@5 -DOPENSSL_ROOT_DIR=/opt/homebrew/opt/openssl@1.1 ..` 1. Build: `make` diff --git a/BUILDING_ON_WINDOWS.md b/BUILDING_ON_WINDOWS.md index 94c9a226d..1ddbfbd54 100644 --- a/BUILDING_ON_WINDOWS.md +++ b/BUILDING_ON_WINDOWS.md @@ -1,18 +1,18 @@ # Building on Windows -**Note that installing all of the development prerequisites and libraries will require about 40 GB of free disk space. Please ensure this space is available on your `C:` drive before proceeding.** +**Note that installing all the development prerequisites and libraries will require about 12 GB of free disk space. Please ensure this space is available on your `C:` drive before proceeding.** This guide assumes you are on a 64-bit system. You might need to manually search out alternate download links should you desire to build Chatterino on a 32-bit system. -## Installing prerequisites +## Prerequisites ### Visual Studio -Download and install [Visual Studio 2022 Community](https://visualstudio.microsoft.com/downloads/). In the installer, select "Desktop development with C++" and "Universal Windows Platform development". +Download and install [Visual Studio 2022 Community](https://visualstudio.microsoft.com/downloads/). In the installer, select "Desktop development with C++". Notes: -- This installation will take about 21 GB of disk space +- This installation will take about 8 GB of disk space - You do not need to sign in with a Microsoft account after setup completes. You may simply exit the login dialog. ### Qt @@ -26,7 +26,9 @@ Notes: - Installing the latest **stable** Qt version is advised for new installations, but if you want to use your existing installation please ensure you are running **Qt 5.12 or later**. -#### When prompted which components to install: +#### Components + +When prompted which components to install, do the following: 1. Unfold the tree element that says "Qt" 2. Unfold the top most tree element (latest stable Qt version, e.g. `Qt 6.5.3`) @@ -43,7 +45,7 @@ Once Qt is done installing, make sure you add its bin directory to your `PATH` (
How to add Qt to PATH - + 1. Type "path" in the Windows start menu and click `Edit the system environment variables`. 2. Click the `Environment Variables...` button bottom right. 3. In the `User variables` (scoped to the current user) or `System variables` (system-wide) section, scroll down until you find `Path` and double click it. @@ -78,7 +80,7 @@ Note: This installation will take about 2.1 GB of disk space.
OpenSSL -### For our websocket library, we need OpenSSL 1.1 +For our websocket library, we need OpenSSL 1.1. 1. Download OpenSSL for windows, version `1.1.1s`: **[Download](https://web.archive.org/web/20221101204129/https://slproweb.com/download/Win64OpenSSL-1_1_1s.exe)** 2. When prompted, install OpenSSL to `C:\local\openssl` @@ -120,57 +122,69 @@ Then in a terminal, configure conan to use `NMake Makefiles` as its generator: Open up your terminal with the Visual Studio environment variables (e.g. `x64 Native Tools Command Prompt for VS 2022`), cd to the cloned chatterino2 directory and run the following commands: -1. `mkdir build` -1. `cd build` -1. `conan install .. -s build_type=Release -c tools.cmake.cmaketoolchain:generator="NMake Makefiles" --build=missing --output-folder=.` -1. `cmake -G"NMake Makefiles" -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE="conan_toolchain.cmake" -DCMAKE_PREFIX_PATH="C:\Qt\6.5.3\msvc2019_64" ..` -1. `nmake` +```cmd +mkdir build +cd build +conan install .. -s build_type=Release -c tools.cmake.cmaketoolchain:generator="NMake Makefiles" --build=missing --output-folder=. +cmake -G"NMake Makefiles" -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE="conan_toolchain.cmake" -DCMAKE_PREFIX_PATH="C:\Qt\6.5.3\msvc2019_64" .. +nmake +``` To build a debug build, you'll also need to add the `-s compiler.runtime_type=Debug` flag to the `conan install` invocation. See [this StackOverflow post](https://stackoverflow.com/questions/59828611/windeployqt-doesnt-deploy-qwindowsd-dll-for-a-debug-application/75607313#75607313) -#### Ensure DLLs are available +#### Deploying Qt libraries Once Chatterino has finished building, to ensure all .dll's are available you can run this from the build directory: `windeployqt bin/chatterino.exe --release --no-compiler-runtime --no-translations --no-opengl-sw --dir bin/` Can't find windeployqt? You forgot to add your Qt bin directory (e.g. `C:\Qt\6.5.3\msvc2019_64\bin`) to your `PATH` -### Run the build in Qt Creator +### Developing in Qt Creator 1. Open the `CMakeLists.txt` file by double-clicking it, or by opening it via Qt Creator. 2. You will be presented with a screen that is titled "Configure Project". In this screen, you should have at least one option present ready to be configured, like this: ![Qt Create Configure Project screenshot](https://user-images.githubusercontent.com/69117321/169887645-2ae0871a-fe8a-4eb9-98db-7b996dea3a54.png) 3. Select the profile(s) you want to build with and click "Configure Project". -#### How to run and produce builds +#### Building and running - In the main screen, click the green "play symbol" on the bottom left to run the project directly. - Click the hammer on the bottom left to generate a build (does not run the build though). Build results will be placed in a folder at the same level as the "chatterino2" project folder (e.g. if your sources are at `C:\Users\example\src\chatterino2`, then the build will be placed in an automatically generated folder under `C:\Users\example\src`, e.g. `C:\Users\example\src\build-chatterino-Desktop_Qt_6.5.3_MSVC2019_64bit-Release`.) -- Note that if you are building chatterino purely for usage, not for development, it is recommended that you click the "PC" icon above the play icon and select "Release" instead of "Debug". +- Note that if you are building Chatterino purely for usage, not for development, it is recommended that you click the "PC" icon above the play icon and select "Release" instead of "Debug". - Output and error messages produced by the compiler can be seen under the "4 Compile Output" tab in Qt Creator. #### Producing standalone builds -If you build chatterino, the result directories will contain a `chatterino.exe` file in the `$OUTPUTDIR\release\` directory. This `.exe` file will not directly run on any given target system, because it will be lacking various Qt runtimes. +If you build Chatterino, the result directories will contain a `chatterino.exe` file in the `$OUTPUTDIR\release\` directory. This `.exe` file will not directly run on any given target system, because it will be lacking various Qt runtimes. To produce a standalone package, you need to generate all required files using the tool `windeployqt`. This tool can be found in the `bin` directory of your Qt installation, e.g. at `C:\Qt\6.5.3\msvc2019_64\bin\windeployqt.exe`. To produce all supplement files for a standalone build, follow these steps (adjust paths as required): -1. Navigate to your build output directory with Windows Explorer, e.g. `C:\Users\example\src\build-chatterino-Desktop_Qt_6.5.3_MSVC2019_64bit-Release` -2. Enter the `release` directory -3. Delete all files except the `chatterino.exe` file. You should be left with a directory only containing `chatterino.exe`. -4. Open a command prompt and execute: +1. Navigate to your build output directory with Windows Explorer, e.g. `C:\Users\example\src\build-chatterino-Desktop_Qt_6.5.3_MSVC2019_64bit-Release` +2. Enter the `release` directory +3. Delete all files except the `chatterino.exe` file. You should be left with a directory only containing `chatterino.exe`. +4. Open a command prompt and execute: + ```cmd + cd C:\Users\example\src\build-chatterino-Desktop_Qt_6.5.3_MSVC2019_64bit-Release\release + windeployqt bin/chatterino.exe --release --no-compiler-runtime --no-translations --no-opengl-sw --dir bin/ + ``` +5. The `releases` directory will now be populated with all the required files to make the Chatterino build standalone. - cd C:\Users\example\src\build-chatterino-Desktop_Qt_6.5.3_MSVC2019_64bit-Release\release - C:\Qt\6.5.3\msvc2019_64\bin\windeployqt.exe chatterino.exe +You can now create a zip archive of all the contents in `releases` and distribute the program as is, without requiring any development tools to be present on the target system. (However, the CRT must be present, as usual - see the [README](README.md)). -5. The `releases` directory will now be populated with all the required files to make the chatterino build standalone. +#### Formatting -You can now create a zip archive of all the contents in `releases` and distribute the program as is, without requiring any development tools to be present on the target system. (However, the vcredist package must be present, as usual - see the [README](README.md)). +To automatically format your code, do the following: + +1. Download [LLVM 16.0.6](https://github.com/llvm/llvm-project/releases/download/llvmorg-16.0.6/LLVM-16.0.6-win64.exe) +2. During the installation, make sure to add it to your path +3. In Qt Creator, Select `Tools` > `Options` > `Beautifier` +4. Under `General` select `Tool: ClangFormat` and enable `Automatic Formatting on File Save` +5. Under `Clang Format` select `Use predefined style: File` and `Fallback style: None` ### Building on MSVC with AddressSanitizer @@ -183,7 +197,7 @@ copy the file found in `\VC\Tools\MSVC\ To learn more about AddressSanitizer and MSVC, visit the [Microsoft Docs](https://learn.microsoft.com/en-us/cpp/sanitizers/asan). -### Building/Running in CLion +### Developing in CLion _Note:_ We're using `build` instead of the CLion default `cmake-build-debug` folder. @@ -196,7 +210,7 @@ Clone the repository as described in the readme. Open a terminal in the cloned f Now open the project in CLion. You will be greeted with the _Open Project Wizard_. Set the _CMake Options_ to -``` +```text -DCMAKE_PREFIX_PATH=C:\Qt\6.5.3\msvc2019_64\lib\cmake\Qt6 -DCMAKE_TOOLCHAIN_FILE="conan_toolchain.cmake" ``` @@ -227,9 +241,9 @@ Select the `CMake Applications > chatterino` configuration and add a new _Run Ex
-Screenshot of chatterino configuration +Screenshot of Chatterino configuration -![Screenshot of chatterino configuration](https://user-images.githubusercontent.com/41973452/160240843-dc0c603c-227f-4f56-98ca-57f03989dfb4.png) +![Screenshot of Chatterino configuration](https://user-images.githubusercontent.com/41973452/160240843-dc0c603c-227f-4f56-98ca-57f03989dfb4.png)
@@ -240,26 +254,24 @@ write `portable` into it. #### Debugging -To visualize QT types like `QString`, you need to inform CLion and LLDB +To visualize Qt types like `QString`, you need to inform CLion and LLDB about these types. 1. Set `Enable NatVis renderers for LLDB option` in `Settings | Build, Execution, Deployment | Debugger | Data Views | C/C++` (should be enabled by default). -2. Use the official NatVis file for QT from [`qt-labs/vstools`](https://github.com/qt-labs/vstools) by saving them to +2. Use the official NatVis file for Qt from [`qt-labs/vstools`](https://github.com/qt-labs/vstools) by saving them to the project root using PowerShell: ```powershell -(irm "https://github.com/qt-labs/vstools/raw/dev/QtVsTools.Package/qt5.natvis.xml").Replace('##NAMESPACE##::', '') | Out-File qt5.natvis +(iwr "https://github.com/qt-labs/vstools/raw/dev/QtVsTools.Package/qt6.natvis.xml").Content.Replace('##NAMESPACE##::', '') | Out-File qt6.natvis # [OR] using the permalink -(irm "https://github.com/qt-labs/vstools/raw/0769d945f8d0040917d654d9731e6b65951e102c/QtVsTools.Package/qt5.natvis.xml").Replace('##NAMESPACE##::', '') | Out-File qt5.natvis +(iwr "https://github.com/qt-labs/vstools/raw/1c8ba533bd88d935be3724667e0087fd0796102c/QtVsTools.Package/qt6.natvis.xml").Content.Replace('##NAMESPACE##::', '') | Out-File qt6.natvis ``` -Now you can debug the application and see QT types rendered correctly. +Now you can debug the application and see Qt types rendered correctly. If this didn't work for you, try following the [tutorial from JetBrains](https://www.jetbrains.com/help/clion/qt-tutorial.html#debug-renderers). diff --git a/BUILDING_ON_WINDOWS_WITH_VCPKG.md b/BUILDING_ON_WINDOWS_WITH_VCPKG.md index eea127ac3..b99809431 100644 --- a/BUILDING_ON_WINDOWS_WITH_VCPKG.md +++ b/BUILDING_ON_WINDOWS_WITH_VCPKG.md @@ -8,12 +8,16 @@ This will require more than 30GB of free space on your hard drive. 1. Install [CMake](https://cmake.org/) 1. Install [git](https://git-scm.com/) 1. Install [vcpkg](https://vcpkg.io/) - - `git clone https://github.com/Microsoft/vcpkg.git` - - `cd .\vcpkg\` - - `.\bootstrap-vcpkg.bat` - - `.\vcpkg integrate install` - - `.\vcpkg integrate powershell` - - `cd ..` + + ```shell + git clone https://github.com/Microsoft/vcpkg.git + cd vcpkg + .\bootstrap-vcpkg.bat + .\vcpkg integrate install + .\vcpkg integrate powershell + cd .. + ``` + 1. Configure the environment variables for vcpkg. Check [this document](https://gist.github.com/mitchmindtree/92c8e37fa80c8dddee5b94fc88d1288b#setting-an-environment-variable-on-windows) for more information for how to set environment variables on Windows. - Ensure your dependencies are built as 64-bit @@ -31,15 +35,19 @@ This will require more than 30GB of free space on your hard drive. ## Building 1. Clone - - `git clone --recurse-submodules https://github.com/Chatterino/chatterino2.git` + ```shell + git clone --recurse-submodules https://github.com/Chatterino/chatterino2.git + ``` 1. Install dependencies - - `cd .\chatterino2\` - - `vcpkg install` + ```powershell + cd .\chatterino2\ + vcpkg install + ``` 1. Build - - `mkdir .\build\` - - `cd .\build\` - - (cmd) `cmake .. -DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%/scripts/buildsystems/vcpkg.cmake` - - (ps1) `cmake .. -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake"` - - `cmake --build . --parallel --config Release` -1. Run - - `.\bin\chatterino2.exe` + ```powershell + cmake -B build -DCMAKE_TOOLCHAIN_FILE="$Env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" + cd build + cmake --build . --parallel --config Release + ``` + When using CMD, use `-DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%/scripts/buildsystems/vcpkg.cmake` to specify the toolchain. +1. Run `.\bin\chatterino2.exe` diff --git a/CHANGELOG.md b/CHANGELOG.md index e27945cb9..b82d0779f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unversioned - Major: Allow use of Twitch follower emotes in other channels if subscribed. (#4922) +- Major: Add `/automod` split to track automod caught messages across all open channels the user moderates. (#4986, #5026) +- Major: Show restricted chat messages and suspicious treatment updates. (#5056) - Minor: Migrate to the new Get Channel Followers Helix endpoint, fixing follower count not showing up in usercards. (#4809) - Minor: The account switcher is now styled to match your theme. (#4817) - Minor: Add an invisible resize handle to the bottom of frameless user info popups and reply thread popups. (#4795) @@ -13,6 +15,11 @@ - Minor: The `/reply` command now replies to the latest message of the user. (#4919) - Minor: All sound capabilities can now be disabled by setting your "Sound backend" setting to "Null" and restarting Chatterino. (#4978) - Minor: Add an option to use new experimental smarter emote completion. (#4987) +- Minor: Add `--safe-mode` command line option that can be used for troubleshooting when Chatterino is misbehaving or is misconfigured. It disables hiding the settings button & prevents plugins from loading. (#4985) +- Minor: Updated the flatpakref link included with nightly builds to point to up-to-date flathub-beta builds. (#5008) +- Minor: Add a new completion API for experimental plugins feature. (#5000, #5047) +- Minor: Re-enabled _Restart on crash_ option on Windows. (#5012) +- Minor: The whisper highlight color can now be configured through the settings. (#5053) - Bugfix: Fixed an issue where certain emojis did not send to Twitch chat correctly. (#4840) - Bugfix: Fixed capitalized channel names in log inclusion list not being logged. (#4848) - Bugfix: Trimmed custom streamlink paths on all platforms making sure you don't accidentally add spaces at the beginning or end of its path. (#4834) @@ -43,13 +50,24 @@ - Bugfix: Fixed lookahead/-behind not working in _Ignores_. (#4965) - Bugfix: Fixed Image Uploader accidentally deleting images with some hosts when link resolver was enabled. (#4971) - Bugfix: Fixed rare crash with Image Uploader when closing a split right after starting an upload. (#4971) +- Bugfix: Fixed an issue on macOS where the image uploader would keep prompting the user even after they clicked "Yes, don't ask again". (#5011) +- Bugfix: Hide the Usercard button in the User Info Popup when in special channels. (#4972) +- Bugfix: Fixed support for Windows 11 Snap layouts. (#4994) - Bugfix: Fixed some windows appearing between screens. (#4797) +- Bugfix: Fixed a crash that could occur when using certain features in a Usercard after closing the split from which it was created. (#5034, #5051) +- Bugfix: Fixed a crash that could occur when using certain features in a Reply popup after closing the split from which it was created. (#5036, #5051) +- Bugfix: Fixed a bug on Wayland where tooltips would spawn as separate windows instead of behaving like tooltips. (#4998, #5040) +- Bugfix: Fixes to section deletion in text input fields. (#5013) +- Bugfix: Show user text input within watch streak notices. (#5029) +- Bugfix: Fixed avatar in usercard and moderation button triggering when releasing the mouse outside their area. (#5052) +- Bugfix: Fixed moderator-only topics being subscribed to for non-moderators. (#5056) - Dev: Run miniaudio in a separate thread, and simplify it to not manage the device ourselves. There's a chance the simplification is a bad idea. (#4978) - Dev: Change clang-format from v14 to v16. (#4929) - Dev: Fixed UTF16 encoding of `modes` file for the installer. (#4791) - Dev: Temporarily disable High DPI scaling on Qt6 builds on Windows. (#4767) - Dev: Tests now run on Ubuntu 22.04 instead of 20.04 to loosen C++ restrictions in tests. (#4774) - Dev: Do a pretty major refactor of the Settings classes. List settings (e.g. highlights) are most heavily modified, and should have an extra eye kept on them. (#4775) +- Dev: conan: Update Boost to 1.83 & OpenSSL to 3.2.0. (#5007) - Dev: Remove `boost::noncopyable` use & `boost_random` dependency. (#4776) - Dev: Fix clang-tidy `cppcoreguidelines-pro-type-member-init` warnings. (#4426) - Dev: Immediate layout for invisible `ChannelView`s is skipped. (#4811) @@ -69,16 +87,27 @@ - Dev: Refactor `Emoji`'s EmojiMap into a vector. (#4980) - Dev: Refactor `DebugCount` and add copy button to debug popup. (#4921) - Dev: Refactor `common/Credentials`. (#4979) +- Dev: Refactor chat logger. (#5058) - Dev: Changed lifetime of context menus. (#4924) +- Dev: Renamed `tools` directory to `scripts`. (#5035) - Dev: Refactor `ChannelView`, removing a bunch of clang-tidy warnings. (#4926) - Dev: Refactor `IrcMessageHandler`, removing a bunch of clang-tidy warnings & changing its public API. (#4927) - Dev: `Details` file properties tab is now populated on Windows. (#4912) - Dev: Removed `Outcome` from network requests. (#4959) -- Dev: Added Tests for Windows and MacOS in CI. (#4970) +- Dev: Added Tests for Windows and MacOS in CI. (#4970, #5032) - Dev: Move `clang-tidy` checker to its own CI job. (#4996) - Dev: Refactored the Image Uploader feature. (#4971) - Dev: Fixed deadlock and use-after-free in tests. (#4981) - Dev: Cleanly exit Chatterino instead of force exiting. (#4993) +- Dev: Moved all `.clang-format` files to the root directory. (#5037) +- Dev: Load less message history upon reconnects. (#5001, #5018) +- Dev: Load less message history upon reconnects. (#5001) +- Dev: BREAKING: Replace custom `import()` with normal Lua `require()`. (#5014) +- Dev: Fixed most compiler warnings. (#5028) +- Dev: Added the ability to show `ChannelView`s without a `Split`. (#4747) +- Dev: Refactor Args to be less of a singleton. (#5041) +- Dev: Channels without any animated elements on screen will skip updates from the GIF timer. (#5042, #5043, #5045) +- Dev: Autogenerate docs/plugin-meta.lua. (#5055) ## 2.4.6 diff --git a/CMakeLists.txt b/CMakeLists.txt index 7e74581b6..f6b8281e1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -210,7 +210,7 @@ if (CHATTERINO_PLUGINS) endif() if (BUILD_WITH_CRASHPAD) - add_subdirectory("${CMAKE_SOURCE_DIR}/lib/crashpad" EXCLUDE_FROM_ALL) + add_subdirectory("${CMAKE_SOURCE_DIR}/tools/crash-handler") endif() # Used to provide a date of build in the About page (for nightly builds). Getting the actual time of diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18ba8dd93..52bdd8f49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ int compare(const QString &a, const QString &b); ```cpp /* - * Matches a link and returns boost::none if it failed and a + * Matches a link and returns std::nullopt if it failed and a * QRegularExpressionMatch on success. * ^^^ This comment just repeats the function signature!!! * diff --git a/README.md b/README.md index 61c28ae72..0a046198d 100644 --- a/README.md +++ b/README.md @@ -22,34 +22,29 @@ If you still receive an error about `MSVCR120.dll missing`, then you should inst To get source code with required submodules run: -``` +```shell git clone --recurse-submodules https://github.com/Chatterino/chatterino2.git ``` or -``` +```shell git clone https://github.com/Chatterino/chatterino2.git cd chatterino2 git submodule update --init --recursive ``` -[Building on Windows](../master/BUILDING_ON_WINDOWS.md) - -[Building on Windows with vcpkg](../master/BUILDING_ON_WINDOWS_WITH_VCPKG.md) - -[Building on Linux](../master/BUILDING_ON_LINUX.md) - -[Building on Mac](../master/BUILDING_ON_MAC.md) - -[Building on FreeBSD](../master/BUILDING_ON_FREEBSD.md) +- [Building on Windows](../master/BUILDING_ON_WINDOWS.md) +- [Building on Windows with vcpkg](../master/BUILDING_ON_WINDOWS_WITH_VCPKG.md) +- [Building on Linux](../master/BUILDING_ON_LINUX.md) +- [Building on macOS](../master/BUILDING_ON_MAC.md) +- [Building on FreeBSD](../master/BUILDING_ON_FREEBSD.md) ## Git blame -This project has big commits in the history which for example update all line -endings. To improve the output of git-blame, consider setting: +This project has big commits in the history which touch most files while only doing stylistic changes. To improve the output of git-blame, consider setting: -``` +```shell git config blame.ignoreRevsFile .git-blame-ignore-revs ``` @@ -58,19 +53,9 @@ file](./.git-blame-ignore-revs). GitHub does this by default. ## Code style -The code is formatted using clang format in Qt Creator. [.clang-format](src/.clang-format) contains the style file for clang format. +The code is formatted using [clang-format](https://clang.llvm.org/docs/ClangFormat.html). Our configuration is found in the [.clang-format](.clang-format) file in the repository root directory. -### Get it automated with QT Creator + Beautifier + Clang Format - -1. Download LLVM: https://github.com/llvm/llvm-project/releases/download/llvmorg-16.0.6/LLVM-16.0.6-win64.exe -2. During the installation, make sure to add it to your path -3. In QT Creator, select `Help` > `About Plugins` > `C++` > `Beautifier` to enable the plugin -4. Restart QT Creator -5. Select `Tools` > `Options` > `Beautifier` -6. Under `General` select `Tool: ClangFormat` and enable `Automatic Formatting on File Save` -7. Under `Clang Format` select `Use predefined style: File` and `Fallback style: None` - -Qt creator should now format the documents when saving it. +For more contribution guidelines, take a look at [the wiki](https://wiki.chatterino.com/Contributing%20for%20Developers/). ## Doxygen diff --git a/conanfile.py b/conanfile.py index 7db329b8b..0dd32c730 100644 --- a/conanfile.py +++ b/conanfile.py @@ -5,7 +5,7 @@ from os import path class Chatterino(ConanFile): name = "Chatterino" - requires = "boost/1.81.0" + requires = "boost/1.83.0" settings = "os", "compiler", "build_type", "arch" default_options = { "with_benchmark": False, @@ -24,7 +24,7 @@ class Chatterino(ConanFile): self.requires("benchmark/1.7.1") if self.options.get_safe("with_openssl3", False): - self.requires("openssl/3.1.0") + self.requires("openssl/3.2.0") else: self.requires("openssl/1.1.1t") diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts index c2efdb1ba..23cbdc2bb 100644 --- a/docs/chatterino.d.ts +++ b/docs/chatterino.d.ts @@ -19,4 +19,25 @@ declare module c2 { ): boolean; function send_msg(channel: String, text: String): boolean; function system_msg(channel: String, text: String): boolean; + + class CompletionList { + values: String[]; + hide_others: boolean; + } + + enum EventType { + CompletionRequested = "CompletionRequested", + } + + type CbFuncCompletionsRequested = ( + query: string, + full_text_content: string, + cursor_position: number, + is_first_word: boolean + ) => CompletionList; + type CbFunc = T extends EventType.CompletionRequested + ? CbFuncCompletionsRequested + : never; + + function register_callback(type: T, func: CbFunc): void; } diff --git a/docs/make-release.md b/docs/make-release.md index 4fa0993eb..c28dead6b 100644 --- a/docs/make-release.md +++ b/docs/make-release.md @@ -5,7 +5,7 @@ - [ ] Updated version code in `src/common/Version.hpp` - [ ] Updated version code in `CMakeLists.txt` This can only be "whole versions", so if you're releasing `2.4.0-beta` you'll need to condense it to `2.4.0` -- [ ] Add a new release at the top of of the `releases` key in `resources/com.chatterino.chatterino.appdata.xml` +- [ ] Add a new release at the top of the `releases` key in `resources/com.chatterino.chatterino.appdata.xml` This cannot use dash to denote a pre-release identifier, you have to use a tilde instead. - [ ] Updated version code in `.CI/chatterino-installer.iss` diff --git a/docs/plugin-info.schema.json b/docs/plugin-info.schema.json index da4750c1a..2b5203ef6 100644 --- a/docs/plugin-info.schema.json +++ b/docs/plugin-info.schema.json @@ -43,7 +43,8 @@ "type": "string", "description": "A small description of your license.", "examples": ["MIT", "GPL-2.0-or-later"] - } + }, + "$schema": { "type": "string" } }, "required": ["name", "description", "authors", "version", "license"] } diff --git a/docs/plugin-meta.lua b/docs/plugin-meta.lua new file mode 100644 index 000000000..78bdf759b --- /dev/null +++ b/docs/plugin-meta.lua @@ -0,0 +1,58 @@ +---@meta Chatterino2 + +-- This file is automatically generated from src/controllers/plugins/LuaAPI.hpp by the scripts/make_luals_meta.py script +-- This file is intended to be used with LuaLS (https://luals.github.io/). +-- Add the folder this file is in to "Lua.workspace.library". + +c2 = {} + +---@alias LogLevel integer +---@type { Debug: LogLevel, Info: LogLevel, Warning: LogLevel, Critical: LogLevel } +c2.LogLevel = {} + +---@alias EventType integer +---@type { CompletionRequested: EventType } +c2.EventType = {} +---@class CommandContext +---@field words string[] The words typed when executing the command. For example `/foo bar baz` will result in `{"/foo", "bar", "baz"}`. +---@field channel_name string The name of the channel the command was executed in. + +---@class CompletionList +---@field values string[] The completions +---@field hide_others boolean Whether other completions from Chatterino should be hidden/ignored. + +--- Registers a new command called `name` which when executed will call `handler`. +--- +---@param name string The name of the command. +---@param handler fun(ctx: CommandContext) The handler to be invoked when the command gets executed. +---@return boolean ok Returns `true` if everything went ok, `false` if a command with this name exists. +function c2.register_command(name, handler) end + +--- Registers a callback to be invoked when completions for a term are requested. +--- +---@param type "CompletionRequested" +---@param func fun(query: string, full_text_content: string, cursor_position: integer, is_first_word: boolean): CompletionList The callback to be invoked. +function c2.register_callback(type, func) end + +--- Sends a message to `channel` with the specified text. Also executes commands. +--- +--- **Warning**: It is possible to trigger your own Lua command with this causing a potentially infinite loop. +--- +---@param channel string The name of the Twitch channel +---@param text string The text to be sent +---@return boolean ok +function c2.send_msg(channel, text) end + +--- Creates a system message (gray message) and adds it to the Twitch channel specified by `channel`. +--- +---@param channel string +---@param text string +---@return boolean ok +function c2.system_msg(channel, text) end + +--- Writes a message to the Chatterino log. +--- +---@param level LogLevel The desired level. +---@param ... any Values to log. Should be convertible to a string with `tostring()`. +function c2.log(level, ...) end + diff --git a/docs/test-and-benchmark.md b/docs/test-and-benchmark.md index e881db6be..7e42ab56b 100644 --- a/docs/test-and-benchmark.md +++ b/docs/test-and-benchmark.md @@ -1,6 +1,6 @@ # Test and Benchmark -Chatterino includes a set of unit tests and benchmarks. These can be built using cmake by adding the `-DBUILD_TESTS=On` and `-DBUILD_BENCHMARKS=On` flags respectively. +Chatterino includes a set of unit tests and benchmarks. These can be built using CMake by adding the `-DBUILD_TESTS=On` and `-DBUILD_BENCHMARKS=On` flags respectively. ## Adding your own test diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md index 8bb0cf61c..2e3284350 100644 --- a/docs/wip-plugins.md +++ b/docs/wip-plugins.md @@ -45,7 +45,7 @@ An example plugin is available at [https://github.com/Mm2PL/Chatterino-test-plug If you prefer, you may use [TypescriptToLua](https://typescripttolua.github.io) to typecheck your plugins. There is a `chatterino.d.ts` file describing the API -in this directory. However this has several drawbacks like harder debugging at +in this directory. However, this has several drawbacks like harder debugging at runtime. ## API @@ -113,6 +113,42 @@ Limitations/known issues: rebuilding the window content caused by reloading another plugin will solve this. - Spaces in command names aren't handled very well (https://github.com/Chatterino/chatterino2/issues/1517). +#### `register_callback("CompletionRequested", handler)` + +Registers a callback (`handler`) to process completions. The callback gets the following parameters: + +- `query`: The queried word. +- `full_text_content`: The whole input. +- `cursor_position`: The position of the cursor in the input. +- `is_first_word`: Flag whether `query` is the first word in the input. + +Example: + +| Input | `query` | `full_text_content` | `cursor_position` | `is_first_word` | +| ---------- | ------- | ------------------- | ----------------- | --------------- | +| `foo│` | `foo` | `foo` | 3 | `true` | +| `fo│o` | `fo` | `foo` | 2 | `true` | +| `foo bar│` | `bar` | `foo bar` | 7 | `false` | +| `foo │bar` | `foo` | `foo bar` | 4 | `false` | + +```lua +function string.startswith(s, other) + return string.sub(s, 1, string.len(other)) == other +end + +c2.register_callback( + "CompletionRequested", + function(query, full_text_content, cursor_position, is_first_word) + if ("!join"):startswith(query) then + ---@type CompletionList + return { hide_others = true, values = { "!join" } } + end + ---@type CompletionList + return { hide_others = false, values = {} } + end +) +``` + #### `send_msg(channel, text)` Sends a message to `channel` with the specified text. Also executes commands. @@ -158,18 +194,25 @@ It achieves this by forcing all inputs to be encoded with `UTF-8`. See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-load) -#### `import(filename)` +#### `require(modname)` -This function mimics Lua's `dofile` however relative paths are relative to your plugin's directory. -You are restricted to loading files in your plugin's directory. You cannot load files with bytecode inside. +This is Lua's [`require()`](https://www.lua.org/manual/5.3/manual.html#pdf-require) function. +However, the searcher and load configuration is notably different from the default: + +- Lua's built-in dynamic library searcher is removed, +- `package.path` is not used, in its place are two searchers, +- when `require()` is used, first a file relative to the currently executing + file will be checked, then a file relative to the plugin directory, +- binary chunks are never loaded + +As in normal Lua, dots are converted to the path separators (`'/'` on Linux and Mac, `'\'` on Windows). Example: ```lua -import("stuff.lua") -- executes Plugins/name/stuff.lua -import("./stuff.lua") -- executes Plugins/name/stuff.lua -import("../stuff.lua") -- tries to load Plugins/stuff.lua and errors -import("luac.out") -- tried to load Plugins/name/luac.out and errors because it contains non-utf8 data +require("stuff") -- executes Plugins/name/stuff.lua or $(dirname $CURR_FILE)/stuff.lua +require("dir.name") -- executes Plugins/name/dir/name.lua or $(dirname $CURR_FILE)/dir/name.lua +require("binary") -- tried to load Plugins/name/binary.lua and errors because binary is not a text file ``` #### `print(Args...)` diff --git a/lib/README.md b/lib/README.md index 40d2acf17..ea37d0b63 100644 --- a/lib/README.md +++ b/lib/README.md @@ -1,3 +1,3 @@ -Third party libraries are stored here +Third party libraries are stored here. Fetched via `git submodule update --init --recursive` diff --git a/lib/crashpad b/lib/crashpad deleted file mode 160000 index 3182e3be2..000000000 --- a/lib/crashpad +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3182e3be21a8a753f9f269f0a590370d49c8f3cf diff --git a/mocks/.clang-format b/mocks/.clang-format deleted file mode 100644 index 0feaad9dc..000000000 --- a/mocks/.clang-format +++ /dev/null @@ -1,52 +0,0 @@ -Language: Cpp - -AccessModifierOffset: -4 -AlignEscapedNewlinesLeft: true -AllowShortFunctionsOnASingleLine: false -AllowShortIfStatementsOnASingleLine: false -AllowShortLambdasOnASingleLine: Empty -AllowShortLoopsOnASingleLine: false -AlwaysBreakAfterDefinitionReturnType: false -AlwaysBreakBeforeMultilineStrings: false -BasedOnStyle: Google -BraceWrapping: - AfterClass: "true" - AfterControlStatement: "true" - AfterFunction: "true" - AfterNamespace: "false" - BeforeCatch: "true" - BeforeElse: "true" -BreakBeforeBraces: Custom -BreakConstructorInitializersBeforeComma: true -ColumnLimit: 80 -ConstructorInitializerAllOnOneLineOrOnePerLine: false -DerivePointerBinding: false -FixNamespaceComments: true -IndentCaseLabels: true -IndentWidth: 4 -IndentWrappedFunctionNames: true -IndentPPDirectives: AfterHash -SortIncludes: CaseInsensitive -IncludeBlocks: Regroup -IncludeCategories: - # Project includes - - Regex: '^"[a-zA-Z\._-]+(/[a-zA-Z0-9\._-]+)*"$' - Priority: 1 - # Qt includes - - Regex: '^$' - Priority: 3 - CaseSensitive: true - # LibCommuni includes - - Regex: "^$" - Priority: 3 - # Standard library includes - - Regex: "^<[a-zA-Z_]+>$" - Priority: 4 - # Third party library includes - - Regex: "^<([a-zA-Z_0-9-]+/)*[a-zA-Z_0-9-]+.h(pp)?>$" - Priority: 3 -NamespaceIndentation: Inner -PointerBindsToType: false -SpacesBeforeTrailingComments: 2 -Standard: Auto -ReflowComments: false diff --git a/mocks/include/mocks/EmptyApplication.hpp b/mocks/include/mocks/EmptyApplication.hpp index 87deafa8a..b1957898d 100644 --- a/mocks/include/mocks/EmptyApplication.hpp +++ b/mocks/include/mocks/EmptyApplication.hpp @@ -1,6 +1,7 @@ #pragma once #include "Application.hpp" +#include "common/Args.hpp" namespace chatterino::mock { @@ -9,6 +10,11 @@ class EmptyApplication : public IApplication public: virtual ~EmptyApplication() = default; + const Args &getArgs() override + { + return this->args_; + } + Theme *getThemes() override { return nullptr; @@ -44,6 +50,11 @@ public: return nullptr; } + CrashHandler *getCrashHandler() override + { + return nullptr; + } + CommandController *getCommands() override { return nullptr; @@ -64,6 +75,12 @@ public: return nullptr; } + Logging *getChatLogger() override + { + assert(!"getChatLogger was called without being initialized"); + return nullptr; + } + ChatterinoBadges *getChatterinoBadges() override { return nullptr; @@ -105,6 +122,9 @@ public: { return nullptr; } + +private: + Args args_; }; } // namespace chatterino::mock diff --git a/resources/avatars/crazysmc.png b/resources/avatars/crazysmc.png new file mode 100644 index 000000000..433f4e3e9 Binary files /dev/null and b/resources/avatars/crazysmc.png differ diff --git a/resources/contributors.txt b/resources/contributors.txt index b2167359d..258380b75 100644 --- a/resources/contributors.txt +++ b/resources/contributors.txt @@ -65,6 +65,8 @@ ZonianMidian | https://github.com/ZonianMidian | :/avatars/zonianmidian.png | Co olafyang | https://github.com/olafyang | | Contributor chrrs | https://github.com/chrrs | | Contributor 4rneee | https://github.com/4rneee | | Contributor +crazysmc | https://github.com/crazysmc | :/avatars/crazysmc.png | Contributor +SputNikPlop | https://github.com/SputNikPlop | | Contributor # If you are a contributor add yourself above this line diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 000000000..92192ef35 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,3 @@ +# scripts + +This directory contains scripts that may be useful for a contributor to run while working on Chatterino diff --git a/tools/build-docker-images.sh b/scripts/build-docker-images.sh similarity index 100% rename from tools/build-docker-images.sh rename to scripts/build-docker-images.sh diff --git a/tools/check-format.sh b/scripts/check-format.sh similarity index 78% rename from tools/check-format.sh rename to scripts/check-format.sh index e7722ed6f..1d786901c 100755 --- a/tools/check-format.sh +++ b/scripts/check-format.sh @@ -11,7 +11,7 @@ while read -r file; do echo "$file differs!!!!!!!" fail="1" fi -done < <(find src/ -type f \( -iname "*.hpp" -o -iname "*.cpp" \)) +done < <(find src/ tests/src benchmarks/src mocks/include -type f \( -iname "*.hpp" -o -iname "*.cpp" \)) if [ "$fail" = "1" ]; then echo "At least one file is poorly formatted - check the output above" diff --git a/tools/check-line-endings.sh b/scripts/check-line-endings.sh similarity index 100% rename from tools/check-line-endings.sh rename to scripts/check-line-endings.sh diff --git a/tools/clang-format-all.sh b/scripts/clang-format-all.sh similarity index 100% rename from tools/clang-format-all.sh rename to scripts/clang-format-all.sh diff --git a/tools/get-tlds-update.sh b/scripts/get-tlds-update.sh old mode 100644 new mode 100755 similarity index 100% rename from tools/get-tlds-update.sh rename to scripts/get-tlds-update.sh diff --git a/scripts/make_luals_meta.py b/scripts/make_luals_meta.py new file mode 100644 index 000000000..6afa3c5e3 --- /dev/null +++ b/scripts/make_luals_meta.py @@ -0,0 +1,142 @@ +""" +This script generates docs/plugin-meta.lua. It accepts no arguments + +It assumes comments look like: +/** + * Thing + * + * @lua@param thing boolean + * @lua@returns boolean + * @exposed name + */ +- Do not have any useful info on '/**' and '*/' lines. +- Class members are not allowed to have non-@command lines and commands different from @lua@field + +Valid commands are: +1. @exposeenum [dotted.name.in_lua.last_part] + Define a table with keys of the enum. Values behind those keys aren't + written on purpose. + This generates three lines: + - An type alias of [last_part] to integer, + - A type description that describes available values of the enum, + - A global table definition for the num +2. @lua[@command] + Writes [@command] to the file as a comment, usually this is @class, @param, @return, ... + @lua@class and @lua@field have special treatment when it comes to generation of spacing new lines +3. @exposed [c2.name] + Generates a function definition line from the last `@lua@param`s. + +Non-command lines of comments are written with a space after '---' +""" +from pathlib import Path + +BOILERPLATE = """ +---@meta Chatterino2 + +-- This file is automatically generated from src/controllers/plugins/LuaAPI.hpp by the scripts/make_luals_meta.py script +-- This file is intended to be used with LuaLS (https://luals.github.io/). +-- Add the folder this file is in to "Lua.workspace.library". + +c2 = {} +""" + +repo_root = Path(__file__).parent.parent +lua_api_file = repo_root / "src" / "controllers" / "plugins" / "LuaAPI.hpp" +lua_meta = repo_root / "docs" / "plugin-meta.lua" + +print("Reading from", lua_api_file.relative_to(repo_root)) +print("Writing to", lua_meta.relative_to(repo_root)) +with lua_api_file.open("r") as f: + lines = f.read().splitlines() + +# Are we in a doc comment? +comment: bool = False + +# Last `@lua@param`s seen - for @exposed generation +last_params_names: list[str] = [] +# Are we in a `@lua@class` definition? - makes newlines around @lua@class and @lua@field prettier +is_class = False + +# The name of the next enum in lua world +expose_next_enum_as: str | None = None +# Name of the current enum in c++ world, used to generate internal typenames for +current_enum_name: str | None = None + +with lua_meta.open("w") as out: + out.write(BOILERPLATE[1:]) # skip the newline after triple quote + + for line in lines: + line = line.strip() + if line.startswith("enum class "): + line = line.removeprefix("enum class ") + temp = line.split(" ", 2) + current_enum_name = temp[0] + if not expose_next_enum_as: + print( + f"Skipping enum {current_enum_name}, there wasn't a @exposeenum command" + ) + current_enum_name = None + continue + current_enum_name = expose_next_enum_as.split(".", 1)[-1] + out.write("---@alias " + current_enum_name + " integer\n") + out.write("---@type { ") + # temp[1] is '{' + if len(temp) == 2: # no values on this line + continue + line = temp[2] + + if current_enum_name is not None: + for i, tok in enumerate(line.split(" ")): + if tok == "};": + break + entry = tok.removesuffix(",") + if i != 0: + out.write(", ") + out.write(entry + ": " + current_enum_name) + out.write(" }\n" f"{expose_next_enum_as} = {{}}\n") + print(f"Wrote enum {expose_next_enum_as} => {current_enum_name}") + current_enum_name = None + expose_next_enum_as = None + continue + + if line.startswith("/**"): + comment = True + continue + elif "*/" in line: + comment = False + if not is_class: + out.write("\n") + continue + if not comment: + continue + line = line.replace("*", "", 1).lstrip() + if line == "": + out.write("---\n") + elif line.startswith("@exposeenum "): + expose_next_enum_as = line.split(" ", 1)[1] + elif line.startswith("@exposed "): + exp = line.replace("@exposed ", "", 1) + params = ", ".join(last_params_names) + out.write(f"function {exp}({params}) end\n") + print(f"Wrote function {exp}(...)") + last_params_names = [] + elif line.startswith("@lua"): + command = line.replace("@lua", "", 1) + if command.startswith("@param"): + last_params_names.append(command.split(" ", 2)[1]) + elif command.startswith("@class"): + print(f"Writing {command}") + if is_class: + out.write("\n") + is_class = True + elif not command.startswith("@field"): + is_class = False + + out.write("---" + command + "\n") + else: + if is_class: + is_class = False + out.write("\n") + + # note the space difference from the branch above + out.write("--- " + line + "\n") diff --git a/tools/update-emoji-data.sh b/scripts/update-emoji-data.sh similarity index 100% rename from tools/update-emoji-data.sh rename to scripts/update-emoji-data.sh diff --git a/tools/windows-fix-directory-case-sensitivity.sh b/scripts/windows-fix-directory-case-sensitivity.sh old mode 100644 new mode 100755 similarity index 100% rename from tools/windows-fix-directory-case-sensitivity.sh rename to scripts/windows-fix-directory-case-sensitivity.sh diff --git a/src/.clang-format b/src/.clang-format deleted file mode 100644 index 0feaad9dc..000000000 --- a/src/.clang-format +++ /dev/null @@ -1,52 +0,0 @@ -Language: Cpp - -AccessModifierOffset: -4 -AlignEscapedNewlinesLeft: true -AllowShortFunctionsOnASingleLine: false -AllowShortIfStatementsOnASingleLine: false -AllowShortLambdasOnASingleLine: Empty -AllowShortLoopsOnASingleLine: false -AlwaysBreakAfterDefinitionReturnType: false -AlwaysBreakBeforeMultilineStrings: false -BasedOnStyle: Google -BraceWrapping: - AfterClass: "true" - AfterControlStatement: "true" - AfterFunction: "true" - AfterNamespace: "false" - BeforeCatch: "true" - BeforeElse: "true" -BreakBeforeBraces: Custom -BreakConstructorInitializersBeforeComma: true -ColumnLimit: 80 -ConstructorInitializerAllOnOneLineOrOnePerLine: false -DerivePointerBinding: false -FixNamespaceComments: true -IndentCaseLabels: true -IndentWidth: 4 -IndentWrappedFunctionNames: true -IndentPPDirectives: AfterHash -SortIncludes: CaseInsensitive -IncludeBlocks: Regroup -IncludeCategories: - # Project includes - - Regex: '^"[a-zA-Z\._-]+(/[a-zA-Z0-9\._-]+)*"$' - Priority: 1 - # Qt includes - - Regex: '^$' - Priority: 3 - CaseSensitive: true - # LibCommuni includes - - Regex: "^$" - Priority: 3 - # Standard library includes - - Regex: "^<[a-zA-Z_]+>$" - Priority: 4 - # Third party library includes - - Regex: "^<([a-zA-Z_0-9-]+/)*[a-zA-Z_0-9-]+.h(pp)?>$" - Priority: 3 -NamespaceIndentation: Inner -PointerBindsToType: false -SpacesBeforeTrailingComments: 2 -Standard: Auto -ReflowComments: false diff --git a/src/Application.cpp b/src/Application.cpp index c1d87af0c..6c530bc81 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -35,9 +35,11 @@ #include "providers/twitch/PubSubActions.hpp" #include "providers/twitch/PubSubManager.hpp" #include "providers/twitch/PubSubMessages.hpp" +#include "providers/twitch/pubsubmessages/LowTrustUsers.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" +#include "singletons/CrashHandler.hpp" #include "singletons/Emotes.hpp" #include "singletons/Fonts.hpp" #include "singletons/helper/LoggingChannel.hpp" @@ -103,8 +105,9 @@ IApplication::IApplication() // It will create the instances of the major classes, and connect their signals // to each other -Application::Application(Settings &_settings, Paths &_paths) - : themes(&this->emplace()) +Application::Application(Settings &_settings, Paths &_paths, const Args &_args) + : args_(_args) + , themes(&this->emplace()) , fonts(&this->emplace()) , emotes(&this->emplace()) , accounts(&this->emplace()) @@ -114,6 +117,7 @@ Application::Application(Settings &_settings, Paths &_paths) , toasts(&this->emplace()) , imageUploader(&this->emplace()) , seventvAPI(&this->emplace()) + , crashHandler(&this->emplace()) , commands(&this->emplace()) , notifications(&this->emplace()) @@ -124,12 +128,12 @@ Application::Application(Settings &_settings, Paths &_paths) , userData(&this->emplace()) , sound(&this->emplace(makeSoundController(_settings))) , twitchLiveController(&this->emplace()) + , logging(new Logging(_settings)) #ifdef CHATTERINO_HAVE_PLUGINS , plugins(&this->emplace()) #endif - , logging(&this->emplace()) { - this->instance = this; + Application::instance = this; // We can safely ignore this signal's connection since the Application will always // be destroyed after fonts @@ -138,13 +142,15 @@ Application::Application(Settings &_settings, Paths &_paths) }); } +Application::~Application() = default; + void Application::initialize(Settings &settings, Paths &paths) { assert(isAppInitialized == false); isAppInitialized = true; // Show changelog - if (!getArgs().isFramelessEmbed && + if (!this->args_.isFramelessEmbed && getSettings()->currentVersion.getValue() != "" && getSettings()->currentVersion.getValue() != CHATTERINO_VERSION) { @@ -159,7 +165,7 @@ void Application::initialize(Settings &settings, Paths &paths) } } - if (!getArgs().isFramelessEmbed) + if (!this->args_.isFramelessEmbed) { getSettings()->currentVersion.setValue(CHATTERINO_VERSION); @@ -174,8 +180,10 @@ void Application::initialize(Settings &settings, Paths &paths) singleton->initialize(settings, paths); } - // add crash message - if (!getArgs().isFramelessEmbed && getArgs().crashRecovery) + // Show crash message. + // On Windows, the crash message was already shown. +#ifndef Q_OS_WIN + if (!this->args_.isFramelessEmbed && this->args_.crashRecovery) { if (auto selected = this->windows->getMainWindow().getNotebook().getSelectedPage()) @@ -195,10 +203,11 @@ void Application::initialize(Settings &settings, Paths &paths) } } } +#endif this->windows->updateWordTypeMask(); - if (!getArgs().isFramelessEmbed) + if (!this->args_.isFramelessEmbed) { this->initNm(paths); } @@ -214,7 +223,7 @@ int Application::run(QApplication &qtApp) this->twitch->connect(); - if (!getArgs().isFramelessEmbed) + if (!this->args_.isFramelessEmbed) { this->windows->getMainWindow().show(); } @@ -305,6 +314,11 @@ ITwitchIrcServer *Application::getTwitch() return this->twitch; } +Logging *Application::getChatLogger() +{ + return this->logging.get(); +} + void Application::save() { for (auto &singleton : this->singletons_) @@ -467,6 +481,87 @@ void Application::initPubSub() }); }); + std::ignore = + this->twitch->pubsub->signals_.moderation.suspiciousMessageReceived + .connect([&](const auto &action) { + if (action.treatment == + PubSubLowTrustUsersMessage::Treatment::INVALID) + { + qCWarning(chatterinoTwitch) + << "Received suspicious message with unknown " + "treatment:" + << action.treatmentString; + return; + } + + // monitored chats are received over irc; in the future, we will use pubsub instead + if (action.treatment != + PubSubLowTrustUsersMessage::Treatment::Restricted) + { + return; + } + + if (getSettings()->streamerModeHideModActions && + isInStreamerMode()) + { + return; + } + + auto chan = + this->twitch->getChannelOrEmptyByID(action.channelID); + + if (chan->isEmpty()) + { + return; + } + + postToThread([chan, action] { + const auto p = + TwitchMessageBuilder::makeLowTrustUserMessage( + action, chan->getName()); + chan->addMessage(p.first); + chan->addMessage(p.second); + }); + }); + + std::ignore = + this->twitch->pubsub->signals_.moderation.suspiciousTreatmentUpdated + .connect([&](const auto &action) { + if (action.treatment == + PubSubLowTrustUsersMessage::Treatment::INVALID) + { + qCWarning(chatterinoTwitch) + << "Received suspicious user update with unknown " + "treatment:" + << action.treatmentString; + return; + } + + if (action.updatedByUserLogin.isEmpty()) + { + return; + } + + if (getSettings()->streamerModeHideModActions && + isInStreamerMode()) + { + return; + } + + auto chan = + this->twitch->getChannelOrEmptyByID(action.channelID); + if (chan->isEmpty()) + { + return; + } + + postToThread([chan, action] { + auto msg = + TwitchMessageBuilder::makeLowTrustUpdateMessage(action); + chan->addMessage(msg); + }); + }); + std::ignore = this->twitch->pubsub->signals_.moderation.autoModMessageCaught.connect( [&](const auto &msg, const QString &channelID) { @@ -546,9 +641,16 @@ void Application::initPubSub() msg.senderUserID, msg.senderUserLogin, senderDisplayName, senderColor}; postToThread([chan, action] { - const auto p = makeAutomodMessage(action); + const auto p = + TwitchMessageBuilder::makeAutomodMessage( + action, chan->getName()); chan->addMessage(p.first); chan->addMessage(p.second); + + getApp()->twitch->automodChannel->addMessage( + p.first); + getApp()->twitch->automodChannel->addMessage( + p.second); }); } // "ALLOWED" and "DENIED" statuses remain unimplemented @@ -573,7 +675,8 @@ void Application::initPubSub() } postToThread([chan, action] { - const auto p = makeAutomodMessage(action); + const auto p = TwitchMessageBuilder::makeAutomodMessage( + action, chan->getName()); chan->addMessage(p.first); chan->addMessage(p.second); }); @@ -615,7 +718,8 @@ void Application::initPubSub() } postToThread([chan, action] { - const auto p = makeAutomodInfoMessage(action); + const auto p = + TwitchMessageBuilder::makeAutomodInfoMessage(action); chan->addMessage(p); }); }); @@ -657,6 +761,7 @@ void Application::initPubSub() [this] { this->twitch->pubsub->unlistenAllModerationActions(); this->twitch->pubsub->unlistenAutomod(); + this->twitch->pubsub->unlistenLowTrustUsers(); this->twitch->pubsub->unlistenWhispers(); }, boost::signals2::at_front); diff --git a/src/Application.hpp b/src/Application.hpp index 063043618..6e1fe066d 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -11,6 +11,7 @@ namespace chatterino { +class Args; class TwitchIrcServer; class ITwitchIrcServer; class PubSub; @@ -44,6 +45,7 @@ class FfzBadges; class SeventvBadges; class ImageUploader; class SeventvAPI; +class CrashHandler; class IApplication { @@ -53,6 +55,7 @@ public: static IApplication *instance; + virtual const Args &getArgs() = 0; virtual Theme *getThemes() = 0; virtual Fonts *getFonts() = 0; virtual IEmotes *getEmotes() = 0; @@ -60,10 +63,12 @@ public: virtual HotkeyController *getHotkeys() = 0; virtual WindowManager *getWindows() = 0; virtual Toasts *getToasts() = 0; + virtual CrashHandler *getCrashHandler() = 0; virtual CommandController *getCommands() = 0; virtual HighlightController *getHighlights() = 0; virtual NotificationController *getNotifications() = 0; virtual ITwitchIrcServer *getTwitch() = 0; + virtual Logging *getChatLogger() = 0; virtual ChatterinoBadges *getChatterinoBadges() = 0; virtual FfzBadges *getFfzBadges() = 0; virtual SeventvBadges *getSeventvBadges() = 0; @@ -76,6 +81,7 @@ public: class Application : public IApplication { + const Args &args_; std::vector> singletons_; int argc_{}; char **argv_{}; @@ -83,7 +89,13 @@ class Application : public IApplication public: static Application *instance; - Application(Settings &settings, Paths &paths); + Application(Settings &_settings, Paths &_paths, const Args &_args); + ~Application() override; + + Application(const Application &) = delete; + Application(Application &&) = delete; + Application &operator=(const Application &) = delete; + Application &operator=(Application &&) = delete; void initialize(Settings &settings, Paths &paths); void load(); @@ -103,6 +115,7 @@ public: Toasts *const toasts{}; ImageUploader *const imageUploader{}; SeventvAPI *const seventvAPI{}; + CrashHandler *const crashHandler{}; CommandController *const commands{}; NotificationController *const notifications{}; @@ -115,14 +128,17 @@ public: private: TwitchLiveController *const twitchLiveController{}; + const std::unique_ptr logging; public: #ifdef CHATTERINO_HAVE_PLUGINS PluginController *const plugins{}; #endif - /*[[deprecated]]*/ Logging *const logging{}; - + const Args &getArgs() override + { + return this->args_; + } Theme *getThemes() override { return this->themes; @@ -148,6 +164,10 @@ public: { return this->toasts; } + CrashHandler *getCrashHandler() override + { + return this->crashHandler; + } CommandController *getCommands() override { return this->commands; @@ -161,6 +181,7 @@ public: return this->highlights; } ITwitchIrcServer *getTwitch() override; + Logging *getChatLogger() override; ChatterinoBadges *getChatterinoBadges() override { return this->chatterinoBadges; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7f9c9146f..90616709c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -289,8 +289,6 @@ set(SOURCE_FILES messages/search/SubtierPredicate.cpp messages/search/SubtierPredicate.hpp - providers/Crashpad.cpp - providers/Crashpad.hpp providers/IvrApi.cpp providers/IvrApi.hpp providers/LinkResolver.cpp @@ -414,6 +412,8 @@ set(SOURCE_FILES providers/twitch/pubsubmessages/ChatModeratorAction.hpp providers/twitch/pubsubmessages/Listen.cpp providers/twitch/pubsubmessages/Listen.hpp + providers/twitch/pubsubmessages/LowTrustUsers.cpp + providers/twitch/pubsubmessages/LowTrustUsers.hpp providers/twitch/pubsubmessages/Message.hpp providers/twitch/pubsubmessages/Unlisten.cpp providers/twitch/pubsubmessages/Unlisten.hpp @@ -425,6 +425,8 @@ set(SOURCE_FILES singletons/Badges.cpp singletons/Badges.hpp + singletons/CrashHandler.cpp + singletons/CrashHandler.hpp singletons/Emotes.cpp singletons/Emotes.hpp singletons/Fonts.cpp @@ -627,6 +629,8 @@ set(SOURCE_FILES widgets/helper/SignalLabel.hpp widgets/helper/TitlebarButton.cpp widgets/helper/TitlebarButton.hpp + widgets/helper/TitlebarButtons.cpp + widgets/helper/TitlebarButtons.hpp widgets/listview/GenericItemDelegate.cpp widgets/listview/GenericItemDelegate.hpp @@ -1006,7 +1010,6 @@ endif () if (BUILD_WITH_CRASHPAD) target_compile_definitions(${LIBRARY_PROJECT} PUBLIC CHATTERINO_WITH_CRASHPAD) target_link_libraries(${LIBRARY_PROJECT} PUBLIC crashpad::client) - set_target_directory_hierarchy(crashpad_handler crashpad) endif() # Configure compiler warnings @@ -1066,7 +1069,6 @@ else () -Wno-switch -Wno-deprecated-declarations -Wno-sign-compare - -Wno-unused-variable # Disabling strict-aliasing warnings for now, although we probably want to re-enable this in the future -Wno-strict-aliasing diff --git a/src/RunGui.cpp b/src/RunGui.cpp index d533b6102..67293d76d 100644 --- a/src/RunGui.cpp +++ b/src/RunGui.cpp @@ -5,6 +5,7 @@ #include "common/Modes.hpp" #include "common/NetworkManager.hpp" #include "common/QLogging.hpp" +#include "singletons/CrashHandler.hpp" #include "singletons/Paths.hpp" #include "singletons/Resources.hpp" #include "singletons/Settings.hpp" @@ -78,7 +79,7 @@ namespace { { // set up the QApplication flags QApplication::setAttribute(Qt::AA_Use96Dpi, true); -#ifdef Q_OS_WIN32 +#if defined(Q_OS_WIN32) && QT_VERSION < QT_VERSION_CHECK(6, 0, 0) QApplication::setAttribute(Qt::AA_DisableHighDpiScaling, true); #endif @@ -98,23 +99,12 @@ namespace { installCustomPalette(); } - void showLastCrashDialog() + void showLastCrashDialog(const Args &args) { - //#ifndef C_DISABLE_CRASH_DIALOG - // LastRunCrashDialog dialog; - - // switch (dialog.exec()) - // { - // case QDialog::Accepted: - // { - // }; - // break; - // default: - // { - // _exit(0); - // } - // } - //#endif + auto *dialog = new LastRunCrashDialog(args); + // Use exec() over open() to block the app from being loaded + // and to be able to set the safe mode. + dialog->exec(); } void createRunningFile(const QString &path) @@ -132,14 +122,13 @@ namespace { } std::chrono::steady_clock::time_point signalsInitTime; - bool restartOnSignal = false; [[noreturn]] void handleSignal(int signum) { using namespace std::chrono_literals; - if (restartOnSignal && - std::chrono::steady_clock::now() - signalsInitTime > 30s) + if (std::chrono::steady_clock::now() - signalsInitTime > 30s && + getIApp()->getCrashHandler()->shouldRecover()) { QProcess proc; @@ -235,15 +224,18 @@ namespace { } } // namespace -void runGui(QApplication &a, Paths &paths, Settings &settings) +void runGui(QApplication &a, Paths &paths, Settings &settings, const Args &args) { initQt(); initResources(); initSignalHandler(); - settings.restartOnCrash.connect([](const bool &value) { - restartOnSignal = value; - }); +#ifdef Q_OS_WIN + if (args.crashRecovery) + { + showLastCrashDialog(args); + } +#endif auto thread = std::thread([dir = paths.miscDirectory] { #ifdef Q_OS_WIN32 @@ -282,31 +274,12 @@ void runGui(QApplication &a, Paths &paths, Settings &settings) chatterino::NetworkManager::init(); chatterino::Updates::instance().checkForUpdates(); -#ifdef C_USE_BREAKPAD - QBreakpadInstance.setDumpPath(getPaths()->settingsFolderPath + "/Crashes"); -#endif - - // Running file - auto runningPath = - paths.miscDirectory + "/running_" + paths.applicationFilePathHash; - - if (QFile::exists(runningPath)) - { - showLastCrashDialog(); - } - else - { - createRunningFile(runningPath); - } - - Application app(settings, paths); + Application app(settings, paths, args); app.initialize(settings, paths); app.run(a); app.save(); - removeRunningFile(runningPath); - - if (!getArgs().dontSaveSettings) + if (!args.dontSaveSettings) { pajlada::Settings::SettingManager::gSave(); } diff --git a/src/RunGui.hpp b/src/RunGui.hpp index 338164404..61bd41f0b 100644 --- a/src/RunGui.hpp +++ b/src/RunGui.hpp @@ -3,8 +3,12 @@ class QApplication; namespace chatterino { + +class Args; class Paths; class Settings; -void runGui(QApplication &a, Paths &paths, Settings &settings); +void runGui(QApplication &a, Paths &paths, Settings &settings, + const Args &args); + } // namespace chatterino diff --git a/src/common/Args.cpp b/src/common/Args.cpp index 2894c3f7b..c480f71b8 100644 --- a/src/common/Args.cpp +++ b/src/common/Args.cpp @@ -1,6 +1,7 @@ -#include "Args.hpp" +#include "common/Args.hpp" #include "common/QLogging.hpp" +#include "debug/AssertInGuiThread.hpp" #include "singletons/Paths.hpp" #include "singletons/WindowManager.hpp" #include "util/AttachToConsole.hpp" @@ -14,6 +15,55 @@ #include #include +namespace { + +template +QCommandLineOption hiddenOption(Args... args) +{ + QCommandLineOption opt(args...); + opt.setFlags(QCommandLineOption::HiddenFromHelp); + return opt; +} + +QStringList extractCommandLine( + const QCommandLineParser &parser, + std::initializer_list options) +{ + QStringList args; + for (const auto &option : options) + { + if (parser.isSet(option)) + { + auto optionName = option.names().first(); + if (optionName.length() == 1) + { + optionName.prepend(u'-'); + } + else + { + optionName.prepend("--"); + } + + auto values = parser.values(option); + if (values.empty()) + { + args += optionName; + } + else + { + for (const auto &value : values) + { + args += optionName; + args += value; + } + } + } + } + return args; +} + +} // namespace + namespace chatterino { Args::Args(const QApplication &app) @@ -23,35 +73,44 @@ Args::Args(const QApplication &app) parser.addHelpOption(); // Used internally by app to restart after unexpected crashes - QCommandLineOption crashRecoveryOption("crash-recovery"); - crashRecoveryOption.setFlags(QCommandLineOption::HiddenFromHelp); + auto crashRecoveryOption = hiddenOption("crash-recovery"); + auto exceptionCodeOption = hiddenOption("cr-exception-code", "", "code"); + auto exceptionMessageOption = + hiddenOption("cr-exception-message", "", "message"); // Added to ignore the parent-window option passed during native messaging - QCommandLineOption parentWindowOption("parent-window"); - parentWindowOption.setFlags(QCommandLineOption::HiddenFromHelp); - QCommandLineOption parentWindowIdOption("x-attach-split-to-window", "", - "window-id"); - parentWindowIdOption.setFlags(QCommandLineOption::HiddenFromHelp); + auto parentWindowOption = hiddenOption("parent-window"); + auto parentWindowIdOption = + hiddenOption("x-attach-split-to-window", "", "window-id"); // Verbose - QCommandLineOption verboseOption({{"v", "verbose"}, - "Attaches to the Console on windows, " - "allowing you to see debug output."}); - crashRecoveryOption.setFlags(QCommandLineOption::HiddenFromHelp); + auto verboseOption = QCommandLineOption( + QStringList{"v", "verbose"}, "Attaches to the Console on windows, " + "allowing you to see debug output."); + // Safe mode + QCommandLineOption safeModeOption( + "safe-mode", "Starts Chatterino without loading Plugins and always " + "show the settings button."); - parser.addOptions({ - {{"V", "version"}, "Displays version information."}, - crashRecoveryOption, - parentWindowOption, - parentWindowIdOption, - verboseOption, - }); - parser.addOption(QCommandLineOption( + // Channel layout + auto channelLayout = QCommandLineOption( {"c", "channels"}, "Joins only supplied channels on startup. Use letters with colons to " "specify platform. Only Twitch channels are supported at the moment.\n" "If platform isn't specified, default is Twitch.", - "t:channel1;t:channel2;...")); + "t:channel1;t:channel2;..."); + + parser.addOptions({ + {{"V", "version"}, "Displays version information."}, + crashRecoveryOption, + exceptionCodeOption, + exceptionMessageOption, + parentWindowOption, + parentWindowIdOption, + verboseOption, + safeModeOption, + channelLayout, + }); if (!parser.parse(app.arguments())) { @@ -71,15 +130,25 @@ Args::Args(const QApplication &app) (args.size() > 0 && (args[0].startsWith("chrome-extension://") || args[0].endsWith(".json"))); - if (parser.isSet("c")) + if (parser.isSet(channelLayout)) { - this->applyCustomChannelLayout(parser.value("c")); + this->applyCustomChannelLayout(parser.value(channelLayout)); } this->verbose = parser.isSet(verboseOption); this->printVersion = parser.isSet("V"); - this->crashRecovery = parser.isSet("crash-recovery"); + + this->crashRecovery = parser.isSet(crashRecoveryOption); + if (parser.isSet(exceptionCodeOption)) + { + this->exceptionCode = + static_cast(parser.value(exceptionCodeOption).toULong()); + } + if (parser.isSet(exceptionMessageOption)) + { + this->exceptionMessage = parser.value(exceptionMessageOption); + } if (parser.isSet(parentWindowIdOption)) { @@ -89,6 +158,21 @@ Args::Args(const QApplication &app) this->parentWindowId = parser.value(parentWindowIdOption).toULongLong(); } + if (parser.isSet(safeModeOption)) + { + this->safeMode = true; + } + + this->currentArguments_ = extractCommandLine(parser, { + verboseOption, + safeModeOption, + channelLayout, + }); +} + +QStringList Args::currentArguments() const +{ + return this->currentArguments_; } void Args::applyCustomChannelLayout(const QString &argValue) @@ -164,18 +248,4 @@ void Args::applyCustomChannelLayout(const QString &argValue) } } -static Args *instance = nullptr; - -void initArgs(const QApplication &app) -{ - instance = new Args(app); -} - -const Args &getArgs() -{ - assert(instance); - - return *instance; -} - } // namespace chatterino diff --git a/src/common/Args.hpp b/src/common/Args.hpp index 9fba4bdaf..a60e988fc 100644 --- a/src/common/Args.hpp +++ b/src/common/Args.hpp @@ -9,13 +9,38 @@ namespace chatterino { /// Command line arguments passed to Chatterino. +/// +/// All accepted arguments: +/// +/// Crash recovery: +/// --crash-recovery +/// --cr-exception-code code +/// --cr-exception-message message +/// +/// Native messaging: +/// --parent-window +/// --x-attach-split-to-window=window-id +/// +/// -v, --verbose +/// -V, --version +/// -c, --channels=t:channel1;t:channel2;... +/// --safe-mode +/// +/// See documentation on `QGuiApplication` for documentation on Qt arguments like -platform. class Args { public: + Args() = default; Args(const QApplication &app); bool printVersion{}; + bool crashRecovery{}; + /// Native, platform-specific exception code from crashpad + std::optional exceptionCode{}; + /// Text version of the exception code. Potentially contains more context. + std::optional exceptionMessage{}; + bool shouldRunBrowserExtensionHost{}; // Shows a single chat. Used on windows to embed in another application. bool isFramelessEmbed{}; @@ -26,12 +51,14 @@ public: bool dontLoadMainWindow{}; std::optional customChannelLayout; bool verbose{}; + bool safeMode{}; + + QStringList currentArguments() const; private: void applyCustomChannelLayout(const QString &argValue); + + QStringList currentArguments_; }; -void initArgs(const QApplication &app); -const Args &getArgs(); - } // namespace chatterino diff --git a/src/common/Channel.cpp b/src/common/Channel.cpp index f0dc5d185..ff5dd2834 100644 --- a/src/common/Channel.cpp +++ b/src/common/Channel.cpp @@ -101,7 +101,8 @@ void Channel::addMessage(MessagePtr message, { channelPlatform = "twitch"; } - app->logging->addMessage(this->name_, message, channelPlatform); + getIApp()->getChatLogger()->addMessage(this->name_, message, + channelPlatform); } if (this->messages_.pushBack(message, deleted)) @@ -295,7 +296,8 @@ bool Channel::isWritable() const { using Type = Channel::Type; auto type = this->getType(); - return type != Type::TwitchMentions && type != Type::TwitchLive; + return type != Type::TwitchMentions && type != Type::TwitchLive && + type != Type::TwitchAutomod; } void Channel::sendMessage(const QString &message) @@ -314,7 +316,6 @@ bool Channel::isBroadcaster() const bool Channel::hasModRights() const { - // fourtf: check if staff return this->isMod() || this->isBroadcaster(); } @@ -330,7 +331,8 @@ bool Channel::isLive() const bool Channel::shouldIgnoreHighlights() const { - return this->type_ == Type::TwitchMentions || + return this->type_ == Type::TwitchAutomod || + this->type_ == Type::TwitchMentions || this->type_ == Type::TwitchWhispers; } diff --git a/src/common/Channel.hpp b/src/common/Channel.hpp index 7fcb5636c..de134b121 100644 --- a/src/common/Channel.hpp +++ b/src/common/Channel.hpp @@ -38,6 +38,7 @@ public: TwitchWatching, TwitchMentions, TwitchLive, + TwitchAutomod, TwitchEnd, Irc, Misc @@ -60,8 +61,6 @@ public: pajlada::Signals::Signal &> filledInMessages; pajlada::Signals::NoArgSignal destroyed; pajlada::Signals::NoArgSignal displayNameChanged; - /// Invoked when AbstractIrcServer::onReadConnected occurs - pajlada::Signals::NoArgSignal connected; Type getType() const; const QString &getName() const; diff --git a/src/common/Env.cpp b/src/common/Env.cpp index 174cae67b..6a7f5dac3 100644 --- a/src/common/Env.cpp +++ b/src/common/Env.cpp @@ -3,6 +3,7 @@ #include "common/QLogging.hpp" #include "util/TypeName.hpp" +#include #include namespace chatterino { @@ -10,16 +11,8 @@ namespace chatterino { namespace { template - void warn(const char *envName, T defaultValue) + void warn(const char *envName, const QString &envString, T defaultValue) { - auto *envString = std::getenv(envName); - if (!envString) - { - // This function is not supposed to be used for non-existant - // environment variables. - return; - } - const auto typeName = QString::fromStdString( std::string(type_name())); @@ -33,23 +26,12 @@ namespace { .arg(defaultValue); } - QString readStringEnv(const char *envName, QString defaultValue) - { - auto envString = std::getenv(envName); - if (envString != nullptr) - { - return QString(envString); - } - - return defaultValue; - } - std::optional readOptionalStringEnv(const char *envName) { - auto envString = std::getenv(envName); - if (envString != nullptr) + auto envString = qEnvironmentVariable(envName); + if (!envString.isEmpty()) { - return QString(envString); + return envString; } return std::nullopt; @@ -57,30 +39,28 @@ namespace { uint16_t readPortEnv(const char *envName, uint16_t defaultValue) { - auto envString = std::getenv(envName); - if (envString != nullptr) + auto envString = qEnvironmentVariable(envName); + if (!envString.isEmpty()) { - bool ok; - auto val = QString(envString).toUShort(&ok); + bool ok = false; + auto val = envString.toUShort(&ok); if (ok) { return val; } - else - { - warn(envName, defaultValue); - } + + warn(envName, envString, defaultValue); } return defaultValue; } - uint16_t readBoolEnv(const char *envName, bool defaultValue) + bool readBoolEnv(const char *envName, bool defaultValue) { - auto envString = std::getenv(envName); - if (envString != nullptr) + auto envString = qEnvironmentVariable(envName); + if (!envString.isEmpty()) { - return QVariant(QString(envString)).toBool(); + return QVariant(envString).toBool(); } return defaultValue; @@ -90,14 +70,14 @@ namespace { Env::Env() : recentMessagesApiUrl( - readStringEnv("CHATTERINO2_RECENT_MESSAGES_URL", - "https://recent-messages.robotty.de/api/v2/" - "recent-messages/%1")) - , linkResolverUrl(readStringEnv( + qEnvironmentVariable("CHATTERINO2_RECENT_MESSAGES_URL", + "https://recent-messages.robotty.de/api/v2/" + "recent-messages/%1")) + , linkResolverUrl(qEnvironmentVariable( "CHATTERINO2_LINK_RESOLVER_URL", "https://braize.pajlada.com/chatterino/link_resolver/%1")) - , twitchServerHost( - readStringEnv("CHATTERINO2_TWITCH_SERVER_HOST", "irc.chat.twitch.tv")) + , twitchServerHost(qEnvironmentVariable("CHATTERINO2_TWITCH_SERVER_HOST", + "irc.chat.twitch.tv")) , twitchServerPort(readPortEnv("CHATTERINO2_TWITCH_SERVER_PORT", 443)) , twitchServerSecure(readBoolEnv("CHATTERINO2_TWITCH_SERVER_SECURE", true)) , proxyUrl(readOptionalStringEnv("CHATTERINO2_PROXY_URL")) diff --git a/src/common/FlagsEnum.hpp b/src/common/FlagsEnum.hpp index 07d672751..f83b820a1 100644 --- a/src/common/FlagsEnum.hpp +++ b/src/common/FlagsEnum.hpp @@ -97,6 +97,11 @@ public: return !this->hasAny(flags); } + T value() const + { + return this->value_; + } + private: T value_{}; }; diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index 31c035d85..de4ef056c 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -12,6 +12,8 @@ Q_LOGGING_CATEGORY(chatterinoBenchmark, "chatterino.benchmark", logThreshold); Q_LOGGING_CATEGORY(chatterinoBttv, "chatterino.bttv", logThreshold); Q_LOGGING_CATEGORY(chatterinoCache, "chatterino.cache", logThreshold); Q_LOGGING_CATEGORY(chatterinoCommon, "chatterino.common", logThreshold); +Q_LOGGING_CATEGORY(chatterinoCrashhandler, "chatterino.crashhandler", + logThreshold); Q_LOGGING_CATEGORY(chatterinoEmoji, "chatterino.emoji", logThreshold); Q_LOGGING_CATEGORY(chatterinoEnv, "chatterino.env", logThreshold); Q_LOGGING_CATEGORY(chatterinoFfzemotes, "chatterino.ffzemotes", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index 01500f1da..36daa0e1e 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -8,6 +8,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoBenchmark); Q_DECLARE_LOGGING_CATEGORY(chatterinoBttv); Q_DECLARE_LOGGING_CATEGORY(chatterinoCache); Q_DECLARE_LOGGING_CATEGORY(chatterinoCommon); +Q_DECLARE_LOGGING_CATEGORY(chatterinoCrashhandler); Q_DECLARE_LOGGING_CATEGORY(chatterinoEmoji); Q_DECLARE_LOGGING_CATEGORY(chatterinoEnv); Q_DECLARE_LOGGING_CATEGORY(chatterinoFfzemotes); diff --git a/src/common/WindowDescriptors.hpp b/src/common/WindowDescriptors.hpp index 820916c36..0a7a72d16 100644 --- a/src/common/WindowDescriptors.hpp +++ b/src/common/WindowDescriptors.hpp @@ -30,7 +30,7 @@ namespace chatterino { enum class WindowType; struct SplitDescriptor { - // Twitch or mentions or watching or whispers or IRC + // Twitch or mentions or watching or live or automod or whispers or IRC QString type_; // Twitch Channel name or IRC channel name diff --git a/src/controllers/commands/builtin/Misc.cpp b/src/controllers/commands/builtin/Misc.cpp index 7100e7776..870e5b342 100644 --- a/src/controllers/commands/builtin/Misc.cpp +++ b/src/controllers/commands/builtin/Misc.cpp @@ -616,10 +616,8 @@ QString openUsercard(const CommandContext &ctx) "should be open."); } - auto *userPopup = new UserInfoPopup( - getSettings()->autoCloseUserPopup, - static_cast(&(getApp()->windows->getMainWindow())), - currentSplit); + auto *userPopup = + new UserInfoPopup(getSettings()->autoCloseUserPopup, currentSplit); userPopup->setData(userName, channel); userPopup->moveTo(QCursor::pos(), widgets::BoundsChecking::CursorPosition); userPopup->show(); diff --git a/src/controllers/commands/builtin/twitch/Shoutout.cpp b/src/controllers/commands/builtin/twitch/Shoutout.cpp index b5077daec..af012cd34 100644 --- a/src/controllers/commands/builtin/twitch/Shoutout.cpp +++ b/src/controllers/commands/builtin/twitch/Shoutout.cpp @@ -47,7 +47,7 @@ QString sendShoutout(const CommandContext &ctx) getHelix()->getUserByName( target, - [twitchChannel, channel, currentUser, &target](const auto targetUser) { + [twitchChannel, channel, currentUser](const auto targetUser) { getHelix()->sendShoutout( twitchChannel->roomId(), targetUser.id, currentUser->getUserId(), diff --git a/src/controllers/completion/TabCompletionModel.cpp b/src/controllers/completion/TabCompletionModel.cpp index 74e826529..f38fa62ac 100644 --- a/src/controllers/completion/TabCompletionModel.cpp +++ b/src/controllers/completion/TabCompletionModel.cpp @@ -1,5 +1,6 @@ #include "controllers/completion/TabCompletionModel.hpp" +#include "Application.hpp" #include "common/Channel.hpp" #include "controllers/completion/sources/CommandSource.hpp" #include "controllers/completion/sources/EmoteSource.hpp" @@ -9,6 +10,9 @@ #include "controllers/completion/strategies/ClassicUserStrategy.hpp" #include "controllers/completion/strategies/CommandStrategy.hpp" #include "controllers/completion/strategies/SmartEmoteStrategy.hpp" +#include "controllers/plugins/LuaUtilities.hpp" +#include "controllers/plugins/Plugin.hpp" +#include "controllers/plugins/PluginController.hpp" #include "singletons/Settings.hpp" namespace chatterino { @@ -19,7 +23,9 @@ TabCompletionModel::TabCompletionModel(Channel &channel, QObject *parent) { } -void TabCompletionModel::updateResults(const QString &query, bool isFirstWord) +void TabCompletionModel::updateResults(const QString &query, + const QString &fullTextContent, + int cursorPosition, bool isFirstWord) { this->updateSourceFromQuery(query); @@ -29,6 +35,17 @@ void TabCompletionModel::updateResults(const QString &query, bool isFirstWord) // Copy results to this model QStringList results; +#ifdef CHATTERINO_HAVE_PLUGINS + // Try plugins first + bool done{}; + std::tie(done, results) = getApp()->plugins->updateCustomCompletions( + query, fullTextContent, cursorPosition, isFirstWord); + if (done) + { + this->setStringList(results); + return; + } +#endif this->source_->addToStringList(results, 0, isFirstWord); this->setStringList(results); } diff --git a/src/controllers/completion/TabCompletionModel.hpp b/src/controllers/completion/TabCompletionModel.hpp index 21e06e3c9..56cf313ed 100644 --- a/src/controllers/completion/TabCompletionModel.hpp +++ b/src/controllers/completion/TabCompletionModel.hpp @@ -26,8 +26,12 @@ public: /// @brief Updates the model based on the completion query /// @param query Completion query + /// @param fullTextContent Full text of the input, used by plugins for contextual completion + /// @param cursorPosition Number of characters behind the cursor from the + /// beginning of fullTextContent, also used by plugins /// @param isFirstWord Whether the completion is the first word in the input - void updateResults(const QString &query, bool isFirstWord = false); + void updateResults(const QString &query, const QString &fullTextContent, + int cursorPosition, bool isFirstWord = false); private: enum class SourceKind { diff --git a/src/controllers/highlights/HighlightController.cpp b/src/controllers/highlights/HighlightController.cpp index 6e91f706b..9dee55047 100644 --- a/src/controllers/highlights/HighlightController.cpp +++ b/src/controllers/highlights/HighlightController.cpp @@ -204,6 +204,41 @@ void rebuildMessageHighlights(Settings &settings, { checks.emplace_back(highlightPhraseCheck(highlight)); } + + if (settings.enableAutomodHighlight) + { + const auto highlightSound = + settings.enableAutomodHighlightSound.getValue(); + const auto highlightAlert = + settings.enableAutomodHighlightTaskbar.getValue(); + const auto highlightSoundUrlValue = + settings.automodHighlightSoundUrl.getValue(); + + checks.emplace_back(HighlightCheck{ + [=](const auto & /*args*/, const auto & /*badges*/, + const auto & /*senderName*/, const auto & /*originalMessage*/, + const auto &flags, + const auto /*self*/) -> std::optional { + if (!flags.has(MessageFlag::AutoModOffendingMessage)) + { + return std::nullopt; + } + + std::optional highlightSoundUrl; + if (!highlightSoundUrlValue.isEmpty()) + { + highlightSoundUrl = highlightSoundUrlValue; + } + + return HighlightResult{ + highlightAlert, // alert + highlightSound, // playSound + highlightSoundUrl, // customSoundUrl + nullptr, // color + false, // showInMentions + }; + }}); + } } void rebuildUserHighlights(Settings &settings, @@ -434,6 +469,11 @@ void HighlightController::initialize(Settings &settings, Paths & /*paths*/) this->rebuildListener_.addSetting(settings.threadHighlightSoundUrl); this->rebuildListener_.addSetting(settings.showThreadHighlightInMentions); + this->rebuildListener_.addSetting(settings.enableAutomodHighlight); + this->rebuildListener_.addSetting(settings.enableAutomodHighlightSound); + this->rebuildListener_.addSetting(settings.enableAutomodHighlightTaskbar); + this->rebuildListener_.addSetting(settings.automodHighlightSoundUrl); + this->rebuildListener_.setCB([this, &settings] { qCDebug(chatterinoHighlights) << "Rebuild checks because a setting changed"; diff --git a/src/controllers/highlights/HighlightModel.cpp b/src/controllers/highlights/HighlightModel.cpp index 7c7b08e9b..73c046b37 100644 --- a/src/controllers/highlights/HighlightModel.cpp +++ b/src/controllers/highlights/HighlightModel.cpp @@ -98,9 +98,8 @@ void HighlightModel::afterInit() QUrl(getSettings()->whisperHighlightSoundUrl.getValue()); setFilePathItem(whisperRow[Column::SoundPath], whisperSound, false); - // auto whisperColor = ColorProvider::instance().color(ColorType::Whisper); - // setColorItem(whisperRow[Column::Color], *whisperColor, false); - whisperRow[Column::Color]->setFlags(Qt::ItemFlag::NoItemFlags); + auto whisperColor = ColorProvider::instance().color(ColorType::Whisper); + setColorItem(whisperRow[Column::Color], *whisperColor, false); this->insertCustomRow(whisperRow, HighlightRowIndexes::WhisperRow); @@ -234,6 +233,30 @@ void HighlightModel::afterInit() this->insertCustomRow(threadMessageRow, HighlightRowIndexes::ThreadMessageRow); + + // Highlight settings for automod caught messages + const std::vector automodRow = this->createRow(); + setBoolItem(automodRow[Column::Pattern], + getSettings()->enableAutomodHighlight.getValue(), true, false); + automodRow[Column::Pattern]->setData("AutoMod Caught Messages", + Qt::DisplayRole); + automodRow[Column::ShowInMentions]->setFlags({}); + setBoolItem(automodRow[Column::FlashTaskbar], + getSettings()->enableAutomodHighlightTaskbar.getValue(), true, + false); + setBoolItem(automodRow[Column::PlaySound], + getSettings()->enableAutomodHighlightSound.getValue(), true, + false); + automodRow[Column::UseRegex]->setFlags({}); + automodRow[Column::CaseSensitive]->setFlags({}); + + const auto automodSound = + QUrl(getSettings()->automodHighlightSoundUrl.getValue()); + setFilePathItem(automodRow[Column::SoundPath], automodSound, false); + + automodRow[Column::Color]->setFlags(Qt::ItemFlag::NoItemFlags); + + this->insertCustomRow(automodRow, HighlightRowIndexes::AutomodRow); } void HighlightModel::customRowSetData(const std::vector &row, @@ -278,6 +301,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->enableThreadHighlight.setValue( value.toBool()); } + else if (rowIndex == HighlightRowIndexes::AutomodRow) + { + getSettings()->enableAutomodHighlight.setValue( + value.toBool()); + } } } break; @@ -336,6 +364,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->enableThreadHighlightTaskbar.setValue( value.toBool()); } + else if (rowIndex == HighlightRowIndexes::AutomodRow) + { + getSettings()->enableAutomodHighlightTaskbar.setValue( + value.toBool()); + } } } break; @@ -377,6 +410,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->enableThreadHighlightSound.setValue( value.toBool()); } + else if (rowIndex == HighlightRowIndexes::AutomodRow) + { + getSettings()->enableAutomodHighlightSound.setValue( + value.toBool()); + } } } break; @@ -412,6 +450,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->threadHighlightSoundUrl.setValue( value.toString()); } + else if (rowIndex == HighlightRowIndexes::AutomodRow) + { + getSettings()->automodHighlightSoundUrl.setValue( + value.toString()); + } } } break; @@ -419,48 +462,47 @@ void HighlightModel::customRowSetData(const std::vector &row, // Custom color if (role == Qt::DecorationRole) { - auto colorName = value.value().name(QColor::HexArgb); + const auto setColor = [&](auto &setting, ColorType ty) { + auto color = value.value(); + setting.setValue(color.name(QColor::HexArgb)); + const_cast(ColorProvider::instance()) + .updateColor(ty, color); + }; + if (rowIndex == HighlightRowIndexes::SelfHighlightRow) { - getSettings()->selfHighlightColor.setValue(colorName); + setColor(getSettings()->selfHighlightColor, + ColorType::SelfHighlight); + } + else if (rowIndex == HighlightRowIndexes::WhisperRow) + { + setColor(getSettings()->whisperHighlightColor, + ColorType::Whisper); } - // else if (rowIndex == HighlightRowIndexes::WhisperRow) - // { - // getSettings()->whisperHighlightColor.setValue(colorName); - // } else if (rowIndex == HighlightRowIndexes::SubRow) { - getSettings()->subHighlightColor.setValue(colorName); + setColor(getSettings()->subHighlightColor, + ColorType::Subscription); } else if (rowIndex == HighlightRowIndexes::RedeemedRow) { - getSettings()->redeemedHighlightColor.setValue(colorName); - const_cast(ColorProvider::instance()) - .updateColor(ColorType::RedeemedHighlight, - QColor(colorName)); + setColor(getSettings()->redeemedHighlightColor, + ColorType::RedeemedHighlight); } else if (rowIndex == HighlightRowIndexes::FirstMessageRow) { - getSettings()->firstMessageHighlightColor.setValue( - colorName); - const_cast(ColorProvider::instance()) - .updateColor(ColorType::FirstMessageHighlight, - QColor(colorName)); + setColor(getSettings()->firstMessageHighlightColor, + ColorType::FirstMessageHighlight); } else if (rowIndex == HighlightRowIndexes::ElevatedMessageRow) { - getSettings()->elevatedMessageHighlightColor.setValue( - colorName); - const_cast(ColorProvider::instance()) - .updateColor(ColorType::ElevatedMessageHighlight, - QColor(colorName)); + setColor(getSettings()->elevatedMessageHighlightColor, + ColorType::ElevatedMessageHighlight); } else if (rowIndex == HighlightRowIndexes::ThreadMessageRow) { - getSettings()->threadHighlightColor.setValue(colorName); - const_cast(ColorProvider::instance()) - .updateColor(ColorType::ThreadMessageHighlight, - QColor(colorName)); + setColor(getSettings()->threadHighlightColor, + ColorType::ThreadMessageHighlight); } } } diff --git a/src/controllers/highlights/HighlightModel.hpp b/src/controllers/highlights/HighlightModel.hpp index 0ee5192cf..be807d33e 100644 --- a/src/controllers/highlights/HighlightModel.hpp +++ b/src/controllers/highlights/HighlightModel.hpp @@ -34,6 +34,7 @@ public: FirstMessageRow = 4, ElevatedMessageRow = 5, ThreadMessageRow = 6, + AutomodRow = 7, }; enum UserHighlightRowIndexes { diff --git a/src/controllers/notifications/NotificationController.cpp b/src/controllers/notifications/NotificationController.cpp index 4866bf987..80d13a540 100644 --- a/src/controllers/notifications/NotificationController.cpp +++ b/src/controllers/notifications/NotificationController.cpp @@ -208,11 +208,11 @@ void NotificationController::checkStream(bool live, QString channelName) void NotificationController::removeFakeChannel(const QString channelName) { - auto i = std::find(fakeTwitchChannels.begin(), fakeTwitchChannels.end(), - channelName); - if (i != fakeTwitchChannels.end()) + auto it = std::find(fakeTwitchChannels.begin(), fakeTwitchChannels.end(), + channelName); + if (it != fakeTwitchChannels.end()) { - fakeTwitchChannels.erase(i); + fakeTwitchChannels.erase(it); // "delete" old 'CHANNEL is live' message LimitedQueueSnapshot snapshot = getApp()->twitch->liveChannel->getMessageSnapshot(); diff --git a/src/controllers/plugins/LuaAPI.cpp b/src/controllers/plugins/LuaAPI.cpp index 6ffaf4982..d8c1b676c 100644 --- a/src/controllers/plugins/LuaAPI.cpp +++ b/src/controllers/plugins/LuaAPI.cpp @@ -15,6 +15,7 @@ # include # include # include +# include namespace { using namespace chatterino; @@ -94,6 +95,37 @@ int c2_register_command(lua_State *L) return 1; } +int c2_register_callback(lua_State *L) +{ + auto *pl = getApp()->plugins->getPluginByStatePtr(L); + if (pl == nullptr) + { + luaL_error(L, "internal error: no plugin"); + return 0; + } + EventType evtType{}; + if (!lua::peek(L, &evtType, 1)) + { + luaL_error(L, "cannot get event name (1st arg of register_callback, " + "expected a string)"); + return 0; + } + if (lua_isnoneornil(L, 2)) + { + luaL_error(L, "missing argument for register_callback: function " + "\"pointer\""); + return 0; + } + + auto callbackSavedName = QString("c2cb-%1").arg( + magic_enum::enum_name(evtType).data()); + lua_setfield(L, LUA_REGISTRYINDEX, callbackSavedName.toStdString().c_str()); + + lua_pop(L, 2); + + return 0; +} + int c2_send_msg(lua_State *L) { QString text; @@ -167,6 +199,7 @@ int c2_system_msg(lua_State *L) lua::push(L, false); return 1; } + const auto chn = getApp()->twitch->getChannelOrEmpty(channel); if (chn->isEmpty()) { @@ -250,69 +283,87 @@ int g_load(lua_State *L) # endif } -int g_import(lua_State *L) +int loadfile(lua_State *L, const QString &str) { - auto countArgs = lua_gettop(L); - // Lua allows dofile() which loads from stdin, but this is very useless in our case - if (countArgs == 0) - { - lua_pushnil(L); - luaL_error(L, "it is not allowed to call import() without arguments"); - return 1; - } - auto *pl = getApp()->plugins->getPluginByStatePtr(L); - QString fname; - if (!lua::pop(L, &fname)) + if (pl == nullptr) { - lua_pushnil(L); - luaL_error(L, "chatterino g_import: expected a string for a filename"); - return 1; + return luaL_error(L, "loadfile: internal error: no plugin?"); } auto dir = QUrl(pl->loadDirectory().canonicalPath() + "/"); - auto file = dir.resolved(fname); - qCDebug(chatterinoLua) << "plugin" << pl->id << "is trying to load" << file - << "(its dir is" << dir << ")"; - if (!dir.isParentOf(file)) + if (!dir.isParentOf(str)) { - lua_pushnil(L); - luaL_error(L, "chatterino g_import: filename must be inside of the " - "plugin directory"); + // XXX: This intentionally hides the resolved path to not leak it + lua::push( + L, QString("requested module is outside of the plugin directory")); + return 1; + } + QFileInfo info(str); + if (!info.exists()) + { + lua::push(L, QString("no file '%1'").arg(str)); return 1; } - auto path = file.path(QUrl::FullyDecoded); - QFile qf(path); - qf.open(QIODevice::ReadOnly); - if (qf.size() > 10'000'000) + auto temp = str.toStdString(); + const auto *filename = temp.c_str(); + + auto res = luaL_loadfilex(L, filename, "t"); + // Yoinked from checkload lib/lua/src/loadlib.c + if (res == LUA_OK) { - lua_pushnil(L); - luaL_error(L, "chatterino g_import: size limit of 10MB exceeded, what " - "the hell are you doing"); + lua_pushstring(L, filename); + return 2; + } + + return luaL_error(L, "error loading module '%s' from file '%s':\n\t%s", + lua_tostring(L, 1), filename, lua_tostring(L, -1)); +} + +int searcherAbsolute(lua_State *L) +{ + auto name = QString::fromUtf8(luaL_checkstring(L, 1)); + name = name.replace('.', QDir::separator()); + + QString filename; + auto *pl = getApp()->plugins->getPluginByStatePtr(L); + if (pl == nullptr) + { + return luaL_error(L, "searcherAbsolute: internal error: no plugin?"); + } + + QFileInfo file(pl->loadDirectory().filePath(name + ".lua")); + return loadfile(L, file.canonicalFilePath()); +} + +int searcherRelative(lua_State *L) +{ + lua_Debug dbg; + lua_getstack(L, 1, &dbg); + lua_getinfo(L, "S", &dbg); + auto currentFile = QString::fromUtf8(dbg.source, dbg.srclen); + if (currentFile.startsWith("@")) + { + currentFile = currentFile.mid(1); + } + if (currentFile == "=[C]" || currentFile == "") + { + lua::push( + L, + QString( + "Unable to load relative to file:caller has no source file")); return 1; } - // validate utf-8 to block bytecode exploits - auto data = qf.readAll(); - auto *utf8 = QTextCodec::codecForName("UTF-8"); - QTextCodec::ConverterState state; - utf8->toUnicode(data.constData(), data.size(), &state); - if (state.invalidChars != 0) - { - lua_pushnil(L); - luaL_error(L, "invalid utf-8 in import() target (%s) is not allowed", - fname.toStdString().c_str()); - return 1; - } + auto parent = QFileInfo(currentFile).dir(); - // fetch dofile and call it - lua_getfield(L, LUA_REGISTRYINDEX, "real_dofile"); - // maybe data race here if symlink was swapped? - lua::push(L, path); - lua_call(L, 1, LUA_MULTRET); + auto name = QString::fromUtf8(luaL_checkstring(L, 1)); + name = name.replace('.', QDir::separator()); + QString filename = + parent.canonicalPath() + QDir::separator() + name + ".lua"; - return lua_gettop(L); + return loadfile(L, filename); } int g_print(lua_State *L) diff --git a/src/controllers/plugins/LuaAPI.hpp b/src/controllers/plugins/LuaAPI.hpp index dfa95447e..95a998f79 100644 --- a/src/controllers/plugins/LuaAPI.hpp +++ b/src/controllers/plugins/LuaAPI.hpp @@ -1,27 +1,111 @@ #pragma once #ifdef CHATTERINO_HAVE_PLUGINS +# include + +# include struct lua_State; namespace chatterino::lua::api { -// names in this namespace reflect what's visible inside Lua and follow the lua naming scheme +// function names in this namespace reflect what's visible inside Lua and follow the lua naming scheme // NOLINTBEGIN(readability-identifier-naming) // Following functions are exposed in c2 table. + +// Comments in this file are special, the docs/plugin-meta.lua file is generated from them +// All multiline comments will be added into that file. See scripts/make_luals_meta.py script for more info. + +/** + * @exposeenum c2.LogLevel + */ +// Represents "calls" to qCDebug, qCInfo ... +enum class LogLevel { Debug, Info, Warning, Critical }; + +/** + * @exposeenum c2.EventType + */ +enum class EventType { + CompletionRequested, +}; + +/** + * @lua@class CommandContext + * @lua@field words string[] The words typed when executing the command. For example `/foo bar baz` will result in `{"/foo", "bar", "baz"}`. + * @lua@field channel_name string The name of the channel the command was executed in. + */ + +/** + * @lua@class CompletionList + */ +struct CompletionList { + /** + * @lua@field values string[] The completions + */ + std::vector values{}; + + /** + * @lua@field hide_others boolean Whether other completions from Chatterino should be hidden/ignored. + */ + bool hideOthers{}; +}; + +/** + * Registers a new command called `name` which when executed will call `handler`. + * + * @lua@param name string The name of the command. + * @lua@param handler fun(ctx: CommandContext) The handler to be invoked when the command gets executed. + * @lua@return boolean ok Returns `true` if everything went ok, `false` if a command with this name exists. + * @exposed c2.register_command + */ int c2_register_command(lua_State *L); + +/** + * Registers a callback to be invoked when completions for a term are requested. + * + * @lua@param type "CompletionRequested" + * @lua@param func fun(query: string, full_text_content: string, cursor_position: integer, is_first_word: boolean): CompletionList The callback to be invoked. + * @exposed c2.register_callback + */ +int c2_register_callback(lua_State *L); + +/** + * Sends a message to `channel` with the specified text. Also executes commands. + * + * **Warning**: It is possible to trigger your own Lua command with this causing a potentially infinite loop. + * + * @lua@param channel string The name of the Twitch channel + * @lua@param text string The text to be sent + * @lua@return boolean ok + * @exposed c2.send_msg + */ int c2_send_msg(lua_State *L); +/** + * Creates a system message (gray message) and adds it to the Twitch channel specified by `channel`. + * + * @lua@param channel string + * @lua@param text string + * @lua@return boolean ok + * @exposed c2.system_msg + */ int c2_system_msg(lua_State *L); + +/** + * Writes a message to the Chatterino log. + * + * @lua@param level LogLevel The desired level. + * @lua@param ... any Values to log. Should be convertible to a string with `tostring()`. + * @exposed c2.log + */ int c2_log(lua_State *L); // These ones are global int g_load(lua_State *L); int g_print(lua_State *L); -int g_import(lua_State *L); // NOLINTEND(readability-identifier-naming) -// Exposed as c2.LogLevel -// Represents "calls" to qCDebug, qCInfo ... -enum class LogLevel { Debug, Info, Warning, Critical }; +// This is for require() exposed as an element of package.searchers +int searcherAbsolute(lua_State *L); +int searcherRelative(lua_State *L); } // namespace chatterino::lua::api diff --git a/src/controllers/plugins/LuaUtilities.cpp b/src/controllers/plugins/LuaUtilities.cpp index 3477e4e38..4807f89f2 100644 --- a/src/controllers/plugins/LuaUtilities.cpp +++ b/src/controllers/plugins/LuaUtilities.cpp @@ -4,6 +4,7 @@ # include "common/Channel.hpp" # include "common/QLogging.hpp" # include "controllers/commands/CommandContext.hpp" +# include "controllers/plugins/LuaAPI.hpp" # include # include @@ -75,6 +76,9 @@ QString humanErrorText(lua_State *L, int errCode) case LUA_ERRFILE: errName = "(file error)"; break; + case ERROR_BAD_PEEK: + errName = "(unable to convert value to c++)"; + break; default: errName = "(unknown error type)"; } @@ -111,6 +115,7 @@ StackIdx push(lua_State *L, const std::string &str) StackIdx push(lua_State *L, const CommandContext &ctx) { + StackGuard guard(L, 1); auto outIdx = pushEmptyTable(L, 2); push(L, ctx.words); @@ -127,8 +132,27 @@ StackIdx push(lua_State *L, const bool &b) return lua_gettop(L); } +StackIdx push(lua_State *L, const int &b) +{ + lua_pushinteger(L, b); + return lua_gettop(L); +} + +bool peek(lua_State *L, bool *out, StackIdx idx) +{ + StackGuard guard(L); + if (!lua_isboolean(L, idx)) + { + return false; + } + + *out = bool(lua_toboolean(L, idx)); + return true; +} + bool peek(lua_State *L, double *out, StackIdx idx) { + StackGuard guard(L); int ok{0}; auto v = lua_tonumberx(L, idx, &ok); if (ok != 0) @@ -140,6 +164,7 @@ bool peek(lua_State *L, double *out, StackIdx idx) bool peek(lua_State *L, QString *out, StackIdx idx) { + StackGuard guard(L); size_t len{0}; const char *str = lua_tolstring(L, idx, &len); if (str == nullptr) @@ -156,6 +181,7 @@ bool peek(lua_State *L, QString *out, StackIdx idx) bool peek(lua_State *L, QByteArray *out, StackIdx idx) { + StackGuard guard(L); size_t len{0}; const char *str = lua_tolstring(L, idx, &len); if (str == nullptr) @@ -172,6 +198,7 @@ bool peek(lua_State *L, QByteArray *out, StackIdx idx) bool peek(lua_State *L, std::string *out, StackIdx idx) { + StackGuard guard(L); size_t len{0}; const char *str = lua_tolstring(L, idx, &len); if (str == nullptr) @@ -186,6 +213,23 @@ bool peek(lua_State *L, std::string *out, StackIdx idx) return true; } +bool peek(lua_State *L, api::CompletionList *out, StackIdx idx) +{ + StackGuard guard(L); + int typ = lua_getfield(L, idx, "values"); + if (typ != LUA_TTABLE) + { + lua_pop(L, 1); + return false; + } + if (!lua::pop(L, &out->values, -1)) + { + return false; + } + lua_getfield(L, idx, "hide_others"); + return lua::pop(L, &out->hideOthers); +} + QString toString(lua_State *L, StackIdx idx) { size_t len{}; diff --git a/src/controllers/plugins/LuaUtilities.hpp b/src/controllers/plugins/LuaUtilities.hpp index f88f2a928..c7bd0270e 100644 --- a/src/controllers/plugins/LuaUtilities.hpp +++ b/src/controllers/plugins/LuaUtilities.hpp @@ -2,14 +2,19 @@ #ifdef CHATTERINO_HAVE_PLUGINS +# include "common/QLogging.hpp" + # include # include # include # include +# include +# include # include # include # include +# include # include struct lua_State; class QJsonObject; @@ -19,6 +24,12 @@ struct CommandContext; namespace chatterino::lua { +namespace api { + struct CompletionList; +} // namespace api + +constexpr int ERROR_BAD_PEEK = LUA_OK - 1; + /** * @brief Dumps the Lua stack into qCDebug(chatterinoLua) * @@ -52,20 +63,136 @@ StackIdx push(lua_State *L, const CommandContext &ctx); StackIdx push(lua_State *L, const QString &str); StackIdx push(lua_State *L, const std::string &str); StackIdx push(lua_State *L, const bool &b); +StackIdx push(lua_State *L, const int &b); // returns OK? +bool peek(lua_State *L, bool *out, StackIdx idx = -1); bool peek(lua_State *L, double *out, StackIdx idx = -1); bool peek(lua_State *L, QString *out, StackIdx idx = -1); bool peek(lua_State *L, QByteArray *out, StackIdx idx = -1); bool peek(lua_State *L, std::string *out, StackIdx idx = -1); +bool peek(lua_State *L, api::CompletionList *out, StackIdx idx = -1); /** * @brief Converts Lua object at stack index idx to a string. */ QString toString(lua_State *L, StackIdx idx = -1); +// This object ensures that the stack is of expected size when it is destroyed +class StackGuard +{ + int expected; + lua_State *L; + +public: + /** + * Use this constructor if you expect the stack size to be the same on the + * destruction of the object as its creation + */ + StackGuard(lua_State *L) + : expected(lua_gettop(L)) + , L(L) + { + } + + /** + * Use this if you expect the stack size changing, diff is the expected difference + * Ex: diff=3 means three elements added to the stack + */ + StackGuard(lua_State *L, int diff) + : expected(lua_gettop(L) + diff) + , L(L) + { + } + + ~StackGuard() + { + if (expected < 0) + { + return; + } + int after = lua_gettop(this->L); + if (this->expected != after) + { + stackDump(this->L, "StackGuard check tripped"); + // clang-format off + // clang format likes to insert a new line which means that some builds won't show this message fully + assert(false && "internal error: lua stack was not in an expected state"); + // clang-format on + } + } + + // This object isn't meant to be passed around + StackGuard operator=(StackGuard &) = delete; + StackGuard &operator=(StackGuard &&) = delete; + StackGuard(StackGuard &) = delete; + StackGuard(StackGuard &&) = delete; + + // This function tells the StackGuard that the stack isn't in an expected state but it was handled + void handled() + { + this->expected = -1; + } +}; + /// TEMPLATES +template +bool peek(lua_State *L, std::optional *out, StackIdx idx = -1) +{ + if (lua_isnil(L, idx)) + { + *out = std::nullopt; + return true; + } + + *out = T(); + return peek(L, out->operator->(), idx); +} + +template +bool peek(lua_State *L, std::vector *vec, StackIdx idx = -1) +{ + StackGuard guard(L); + + if (!lua_istable(L, idx)) + { + lua::stackDump(L, "!table"); + qCDebug(chatterinoLua) + << "value is not a table, type is" << lua_type(L, idx); + return false; + } + auto len = lua_rawlen(L, idx); + if (len == 0) + { + qCDebug(chatterinoLua) << "value has 0 length"; + return true; + } + if (len > 1'000'000) + { + qCDebug(chatterinoLua) << "value is too long"; + return false; + } + // count like lua + for (int i = 1; i <= len; i++) + { + lua_geti(L, idx, i); + std::optional obj; + if (!lua::peek(L, &obj)) + { + //lua_seti(L, LUA_REGISTRYINDEX, 1); // lazy + qCDebug(chatterinoLua) + << "Failed to convert lua object into c++: at array index " << i + << ":"; + stackDump(L, "bad conversion into string"); + return false; + } + lua_pop(L, 1); + vec->push_back(obj.value()); + } + return true; +} + /** * @brief Converts object at stack index idx to enum given by template parameter T */ @@ -150,6 +277,7 @@ StackIdx push(lua_State *L, T inp) template bool pop(lua_State *L, T *out, StackIdx idx = -1) { + StackGuard guard(L, -1); auto ok = peek(L, out, idx); if (ok) { @@ -186,6 +314,58 @@ StackIdx pushEnumTable(lua_State *L) return out; } +// Represents a Lua function on the stack +template +class CallbackFunction +{ + StackIdx stackIdx_; + lua_State *L; + +public: + CallbackFunction(lua_State *L, StackIdx stackIdx) + : stackIdx_(stackIdx) + , L(L) + { + } + + // this type owns the stackidx, it must not be trivially copiable + CallbackFunction operator=(CallbackFunction &) = delete; + CallbackFunction(CallbackFunction &) = delete; + + // Permit only move + CallbackFunction &operator=(CallbackFunction &&) = default; + CallbackFunction(CallbackFunction &&) = default; + + ~CallbackFunction() + { + lua_remove(L, this->stackIdx_); + } + + std::variant operator()(Args... arguments) + { + lua_pushvalue(this->L, this->stackIdx_); + ( // apparently this calls lua::push() for every Arg + [this, &arguments] { + lua::push(this->L, arguments); + }(), + ...); + + int res = lua_pcall(L, sizeof...(Args), 1, 0); + if (res != LUA_OK) + { + qCDebug(chatterinoLua) << "error is: " << res; + return {res}; + } + + ReturnType val; + if (!lua::pop(L, &val)) + { + return {ERROR_BAD_PEEK}; + } + return {val}; + } +}; + } // namespace chatterino::lua #endif diff --git a/src/controllers/plugins/Plugin.hpp b/src/controllers/plugins/Plugin.hpp index 456ac4ff1..7d81609e1 100644 --- a/src/controllers/plugins/Plugin.hpp +++ b/src/controllers/plugins/Plugin.hpp @@ -2,6 +2,8 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "Application.hpp" +# include "controllers/plugins/LuaAPI.hpp" +# include "controllers/plugins/LuaUtilities.hpp" # include # include @@ -85,10 +87,51 @@ public: return this->loadDirectory_; } + // Note: The CallbackFunction object's destructor will remove the function from the lua stack + using LuaCompletionCallback = + lua::CallbackFunction; + std::optional getCompletionCallback() + { + if (this->state_ == nullptr || !this->error_.isNull()) + { + return {}; + } + // this uses magic enum to help automatic tooling find usages + auto typ = + lua_getfield(this->state_, LUA_REGISTRYINDEX, + QString("c2cb-%1") + .arg(magic_enum::enum_name( + lua::api::EventType::CompletionRequested) + .data()) + .toStdString() + .c_str()); + if (typ != LUA_TFUNCTION) + { + lua_pop(this->state_, 1); + return {}; + } + + // move + return std::make_optional>( + this->state_, lua_gettop(this->state_)); + } + + /** + * If the plugin crashes while evaluating the main file, this function will return the error + */ + QString error() + { + return this->error_; + } + private: QDir loadDirectory_; lua_State *state_; + QString error_; + // maps command name -> function name std::unordered_map ownedCommands; diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp index e98a30720..8566c9e24 100644 --- a/src/controllers/plugins/PluginController.cpp +++ b/src/controllers/plugins/PluginController.cpp @@ -2,6 +2,7 @@ # include "controllers/plugins/PluginController.hpp" # include "Application.hpp" +# include "common/Args.hpp" # include "common/QLogging.hpp" # include "controllers/commands/CommandContext.hpp" # include "controllers/commands/CommandController.hpp" @@ -18,6 +19,7 @@ # include # include +# include namespace chatterino { @@ -45,15 +47,13 @@ void PluginController::loadPlugins() auto dir = QDir(getPaths()->pluginsDirectory); qCDebug(chatterinoLua) << "Loading plugins in" << dir.path(); for (const auto &info : - dir.entryInfoList(QDir::NoFilter | QDir::NoDotAndDotDot)) + dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot)) { - if (info.isDir()) - { - auto pluginDir = QDir(info.absoluteFilePath()); - this->tryLoadFromDir(pluginDir); - } + auto pluginDir = QDir(info.absoluteFilePath()); + this->tryLoadFromDir(pluginDir); } } + bool PluginController::tryLoadFromDir(const QDir &pluginDir) { // look for init.lua @@ -103,9 +103,10 @@ bool PluginController::tryLoadFromDir(const QDir &pluginDir) return true; } -void PluginController::openLibrariesFor(lua_State *L, - const PluginMeta & /*meta*/) +void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta, + const QDir &pluginDir) { + lua::StackGuard guard(L); // Stuff to change, remove or hide behind a permission system: static const std::vector loadedlibs = { luaL_Reg{LUA_GNAME, luaopen_base}, @@ -123,6 +124,7 @@ void PluginController::openLibrariesFor(lua_State *L, luaL_Reg{LUA_STRLIBNAME, luaopen_string}, luaL_Reg{LUA_MATHLIBNAME, luaopen_math}, luaL_Reg{LUA_UTF8LIBNAME, luaopen_utf8}, + luaL_Reg{LUA_LOADLIBNAME, luaopen_package}, }; // Warning: Do not add debug library to this, it would make the security of // this a living nightmare due to stuff like registry access @@ -138,29 +140,30 @@ void PluginController::openLibrariesFor(lua_State *L, static const luaL_Reg c2Lib[] = { {"system_msg", lua::api::c2_system_msg}, {"register_command", lua::api::c2_register_command}, + {"register_callback", lua::api::c2_register_callback}, {"send_msg", lua::api::c2_send_msg}, {"log", lua::api::c2_log}, {nullptr, nullptr}, }; lua_pushglobaltable(L); - auto global = lua_gettop(L); + auto gtable = lua_gettop(L); - // count of elements in C2LIB + LogLevel - auto c2libIdx = lua::pushEmptyTable(L, 5); + // count of elements in C2LIB + LogLevel + EventType + auto c2libIdx = lua::pushEmptyTable(L, 8); luaL_setfuncs(L, c2Lib, 0); lua::pushEnumTable(L); lua_setfield(L, c2libIdx, "LogLevel"); - lua_setfield(L, global, "c2"); + lua::pushEnumTable(L); + lua_setfield(L, c2libIdx, "EventType"); + + lua_setfield(L, gtable, "c2"); // ban functions // Note: this might not be fully secure? some kind of metatable fuckery might come up? - lua_pushglobaltable(L); - auto gtable = lua_gettop(L); - // possibly randomize this name at runtime to prevent some attacks? # ifndef NDEBUG @@ -168,16 +171,10 @@ void PluginController::openLibrariesFor(lua_State *L, lua_setfield(L, LUA_REGISTRYINDEX, "real_load"); # endif - lua_getfield(L, gtable, "dofile"); - lua_setfield(L, LUA_REGISTRYINDEX, "real_dofile"); - // NOLINTNEXTLINE(*-avoid-c-arrays) static const luaL_Reg replacementFuncs[] = { {"load", lua::api::g_load}, {"print", lua::api::g_print}, - - // This function replaces both `dofile` and `require`, see docs/wip-plugins.md for more info - {"import", lua::api::g_import}, {nullptr, nullptr}, }; luaL_setfuncs(L, replacementFuncs, 0); @@ -188,18 +185,63 @@ void PluginController::openLibrariesFor(lua_State *L, lua_pushnil(L); lua_setfield(L, gtable, "dofile"); - lua_pop(L, 1); + // set up package lib + lua_getfield(L, gtable, "package"); + + auto package = lua_gettop(L); + lua_pushstring(L, ""); + lua_setfield(L, package, "cpath"); + + // we don't use path + lua_pushstring(L, ""); + lua_setfield(L, package, "path"); + + { + lua_getfield(L, gtable, "table"); + auto table = lua_gettop(L); + lua_getfield(L, -1, "remove"); + lua_remove(L, table); + } + auto remove = lua_gettop(L); + + // remove searcher_Croot, searcher_C and searcher_Lua leaving only searcher_preload + for (int i = 0; i < 3; i++) + { + lua_pushvalue(L, remove); + lua_getfield(L, package, "searchers"); + lua_pcall(L, 1, 0, 0); + } + lua_pop(L, 1); // get rid of remove + + lua_getfield(L, package, "searchers"); + lua_pushcclosure(L, lua::api::searcherRelative, 0); + lua_seti(L, -2, 2); + + lua::push(L, QString(pluginDir.absolutePath())); + lua_pushcclosure(L, lua::api::searcherAbsolute, 1); + lua_seti(L, -2, 3); + + lua_pop(L, 3); // remove gtable, package, package.searchers } void PluginController::load(const QFileInfo &index, const QDir &pluginDir, const PluginMeta &meta) { - lua_State *l = luaL_newstate(); - PluginController::openLibrariesFor(l, meta); - auto pluginName = pluginDir.dirName(); + lua_State *l = luaL_newstate(); auto plugin = std::make_unique(pluginName, l, meta, pluginDir); + auto *temp = plugin.get(); this->plugins_.insert({pluginName, std::move(plugin)}); + + if (getApp()->getArgs().safeMode) + { + // This isn't done earlier to ensure the user can disable a misbehaving plugin + qCWarning(chatterinoLua) << "Skipping loading plugin " << meta.name + << " because safe mode is enabled."; + return; + } + PluginController::openLibrariesFor(l, meta, pluginDir); + if (!PluginController::isPluginEnabled(pluginName) || !getSettings()->pluginsEnabled) { @@ -211,9 +253,10 @@ void PluginController::load(const QFileInfo &index, const QDir &pluginDir, int err = luaL_dofile(l, index.absoluteFilePath().toStdString().c_str()); if (err != 0) { + temp->error_ = lua::humanErrorText(l, err); qCWarning(chatterinoLua) << "Failed to load" << pluginName << "plugin from" << index << ": " - << lua::humanErrorText(l, err); + << temp->error_; return; } qCInfo(chatterinoLua) << "Loaded" << pluginName << "plugin from" << index; @@ -298,5 +341,52 @@ const std::map> &PluginController::plugins() return this->plugins_; } -}; // namespace chatterino +std::pair PluginController::updateCustomCompletions( + const QString &query, const QString &fullTextContent, int cursorPosition, + bool isFirstWord) const +{ + QStringList results; + + for (const auto &[name, pl] : this->plugins()) + { + if (!pl->error().isNull()) + { + continue; + } + + lua::StackGuard guard(pl->state_); + + auto opt = pl->getCompletionCallback(); + if (opt) + { + qCDebug(chatterinoLua) + << "Processing custom completions from plugin" << name; + auto &cb = *opt; + auto errOrList = + cb(query, fullTextContent, cursorPosition, isFirstWord); + if (std::holds_alternative(errOrList)) + { + guard.handled(); + int err = std::get(errOrList); + qCDebug(chatterinoLua) + << "Got error from plugin " << pl->meta.name + << " while refreshing tab completion: " + << lua::humanErrorText(pl->state_, err); + continue; + } + + auto list = std::get(errOrList); + if (list.hideOthers) + { + results = QStringList(list.values.begin(), list.values.end()); + return {true, results}; + } + results += QStringList(list.values.begin(), list.values.end()); + } + } + + return {false, results}; +} + +} // namespace chatterino #endif diff --git a/src/controllers/plugins/PluginController.hpp b/src/controllers/plugins/PluginController.hpp index 9630e889b..8dc698b40 100644 --- a/src/controllers/plugins/PluginController.hpp +++ b/src/controllers/plugins/PluginController.hpp @@ -36,6 +36,7 @@ public: // This is required to be public because of c functions Plugin *getPluginByStatePtr(lua_State *L); + // TODO: make a function that iterates plugins that aren't errored/enabled const std::map> &plugins() const; /** @@ -52,17 +53,22 @@ public: */ static bool isPluginEnabled(const QString &id); + std::pair updateCustomCompletions( + const QString &query, const QString &fullTextContent, + int cursorPosition, bool isFirstWord) const; + private: void loadPlugins(); void load(const QFileInfo &index, const QDir &pluginDir, const PluginMeta &meta); // This function adds lua standard libraries into the state - static void openLibrariesFor(lua_State *L, const PluginMeta & /*meta*/); + static void openLibrariesFor(lua_State *L, const PluginMeta & /*meta*/, + const QDir &pluginDir); static void loadChatterinoLib(lua_State *l); bool tryLoadFromDir(const QDir &pluginDir); std::map> plugins_; }; -}; // namespace chatterino +} // namespace chatterino #endif diff --git a/src/controllers/sound/MiniaudioBackend.cpp b/src/controllers/sound/MiniaudioBackend.cpp index 288dd5085..d9afa6c6c 100644 --- a/src/controllers/sound/MiniaudioBackend.cpp +++ b/src/controllers/sound/MiniaudioBackend.cpp @@ -256,8 +256,8 @@ void MiniaudioBackend::play(const QUrl &sound) if (sound.isLocalFile()) { auto soundPath = sound.toLocalFile(); - auto result = ma_engine_play_sound(this->engine.get(), - qPrintable(soundPath), nullptr); + result = ma_engine_play_sound(this->engine.get(), + qPrintable(soundPath), nullptr); if (result != MA_SUCCESS) { qCWarning(chatterinoSound) << "Failed to play sound" << sound diff --git a/src/main.cpp b/src/main.cpp index 9dce310a0..bbd955814 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,11 +4,11 @@ #include "common/Modes.hpp" #include "common/QLogging.hpp" #include "common/Version.hpp" -#include "providers/Crashpad.hpp" #include "providers/IvrApi.hpp" #include "providers/NetworkConfigurationProvider.hpp" #include "providers/twitch/api/Helix.hpp" #include "RunGui.hpp" +#include "singletons/CrashHandler.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" #include "util/AttachToConsole.hpp" @@ -62,18 +62,18 @@ int main(int argc, char **argv) return 1; } - initArgs(a); + const Args args(a); #ifdef CHATTERINO_WITH_CRASHPAD - const auto crashpadHandler = installCrashHandler(); + const auto crashpadHandler = installCrashHandler(args); #endif // run in gui mode or browser extension host mode - if (getArgs().shouldRunBrowserExtensionHost) + if (args.shouldRunBrowserExtensionHost) { runBrowserExtensionHost(); } - else if (getArgs().printVersion) + else if (args.printVersion) { attachToConsole(); @@ -87,7 +87,7 @@ int main(int argc, char **argv) } else { - if (getArgs().verbose) + if (args.verbose) { attachToConsole(); } @@ -99,7 +99,7 @@ int main(int argc, char **argv) Settings settings(paths->settingsDirectory); - runGui(a, *paths, settings); + runGui(a, *paths, settings, args); } return 0; } diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index a503ea275..30c1308a4 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -50,6 +50,9 @@ enum class MessageFlag : int64_t { LiveUpdatesAdd = (1LL << 28), LiveUpdatesRemove = (1LL << 29), LiveUpdatesUpdate = (1LL << 30), + /// The message caught by AutoMod containing the user who sent the message & its contents + AutoModOffendingMessage = (1LL << 31), + LowTrustUsers = (1LL << 32), }; using MessageFlags = FlagsEnum; diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index c34fe74e5..d5ba0ff79 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -78,152 +78,6 @@ MessagePtr makeSystemMessage(const QString &text, const QTime &time) return MessageBuilder(systemMessage, text, time).release(); } -EmotePtr makeAutoModBadge() -{ - return std::make_shared(Emote{ - EmoteName{}, - ImageSet{Image::fromResourcePixmap(getResources().twitch.automod)}, - Tooltip{"AutoMod"}, - Url{"https://dashboard.twitch.tv/settings/moderation/automod"}}); -} - -MessagePtr makeAutomodInfoMessage(const AutomodInfoAction &action) -{ - auto builder = MessageBuilder(); - QString text("AutoMod: "); - - builder.emplace(); - builder.message().flags.set(MessageFlag::PubSub); - - // AutoMod shield badge - builder.emplace(makeAutoModBadge(), - MessageElementFlag::BadgeChannelAuthority); - // AutoMod "username" - builder.emplace("AutoMod:", MessageElementFlag::BoldUsername, - MessageColor(QColor("blue")), - FontStyle::ChatMediumBold); - builder.emplace( - "AutoMod:", MessageElementFlag::NonBoldUsername, - MessageColor(QColor("blue"))); - switch (action.type) - { - case AutomodInfoAction::OnHold: { - QString info("Hey! Your message is being checked " - "by mods and has not been sent."); - text += info; - builder.emplace(info, MessageElementFlag::Text, - MessageColor::Text); - } - break; - case AutomodInfoAction::Denied: { - QString info("Mods have removed your message."); - text += info; - builder.emplace(info, MessageElementFlag::Text, - MessageColor::Text); - } - break; - case AutomodInfoAction::Approved: { - QString info("Mods have accepted your message."); - text += info; - builder.emplace(info, MessageElementFlag::Text, - MessageColor::Text); - } - break; - } - - builder.message().flags.set(MessageFlag::AutoMod); - builder.message().messageText = text; - builder.message().searchText = text; - - auto message = builder.release(); - - return message; -} - -std::pair makeAutomodMessage( - const AutomodAction &action) -{ - MessageBuilder builder, builder2; - - // - // Builder for AutoMod message with explanation - builder.message().loginName = "automod"; - builder.message().flags.set(MessageFlag::PubSub); - builder.message().flags.set(MessageFlag::Timeout); - builder.message().flags.set(MessageFlag::AutoMod); - - // AutoMod shield badge - builder.emplace(makeAutoModBadge(), - MessageElementFlag::BadgeChannelAuthority); - // AutoMod "username" - builder.emplace("AutoMod:", MessageElementFlag::BoldUsername, - MessageColor(QColor("blue")), - FontStyle::ChatMediumBold); - builder.emplace( - "AutoMod:", MessageElementFlag::NonBoldUsername, - MessageColor(QColor("blue"))); - // AutoMod header message - builder.emplace( - ("Held a message for reason: " + action.reason + - ". Allow will post it in chat. "), - MessageElementFlag::Text, MessageColor::Text); - // Allow link button - builder - .emplace("Allow", MessageElementFlag::Text, - MessageColor(QColor("green")), - FontStyle::ChatMediumBold) - ->setLink({Link::AutoModAllow, action.msgID}); - // Deny link button - builder - .emplace(" Deny", MessageElementFlag::Text, - MessageColor(QColor("red")), - FontStyle::ChatMediumBold) - ->setLink({Link::AutoModDeny, action.msgID}); - // ID of message caught by AutoMod - // builder.emplace(action.msgID, MessageElementFlag::Text, - // MessageColor::Text); - auto text1 = - QString("AutoMod: Held a message for reason: %1. Allow will post " - "it in chat. Allow Deny") - .arg(action.reason); - builder.message().messageText = text1; - builder.message().searchText = text1; - - auto message1 = builder.release(); - - // - // Builder for offender's message - builder2.emplace(); - builder2.emplace(); - builder2.message().loginName = action.target.login; - builder2.message().flags.set(MessageFlag::PubSub); - builder2.message().flags.set(MessageFlag::Timeout); - builder2.message().flags.set(MessageFlag::AutoMod); - - // sender username - builder2 - .emplace( - action.target.displayName + ":", MessageElementFlag::BoldUsername, - MessageColor(action.target.color), FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, action.target.login}); - builder2 - .emplace(action.target.displayName + ":", - MessageElementFlag::NonBoldUsername, - MessageColor(action.target.color)) - ->setLink({Link::UserInfo, action.target.login}); - // sender's message caught by AutoMod - builder2.emplace(action.message, MessageElementFlag::Text, - MessageColor::Text); - auto text2 = - QString("%1: %2").arg(action.target.displayName, action.message); - builder2.message().messageText = text2; - builder2.message().searchText = text2; - - auto message2 = builder2.release(); - - return std::make_pair(message1, message2); -} - MessageBuilder::MessageBuilder() : message_(std::make_shared()) { diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index 28874439b..c7277997a 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -53,9 +53,6 @@ const ImageUploaderResultTag imageUploaderResultMessage{}; MessagePtr makeSystemMessage(const QString &text); MessagePtr makeSystemMessage(const QString &text, const QTime &time); -std::pair makeAutomodMessage( - const AutomodAction &action); -MessagePtr makeAutomodInfoMessage(const AutomodInfoAction &action); struct MessageParseArgs { bool disablePingSounds = false; diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp index 719ec1bed..addeb05b8 100644 --- a/src/messages/SharedMessageBuilder.cpp +++ b/src/messages/SharedMessageBuilder.cpp @@ -18,6 +18,8 @@ #include +#include + namespace { using namespace chatterino; @@ -170,18 +172,10 @@ void SharedMessageBuilder::parseHighlights() this->highlightAlert_ = highlightResult.alert; this->highlightSound_ = highlightResult.playSound; + this->highlightSoundCustomUrl_ = highlightResult.customSoundUrl; this->message().highlightColor = highlightResult.color; - if (highlightResult.customSoundUrl) - { - this->highlightSoundUrl_ = *highlightResult.customSoundUrl; - } - else - { - this->highlightSoundUrl_ = getFallbackHighlightSound(); - } - if (highlightResult.showInMentions) { this->message().flags.set(MessageFlag::ShowInMentions); @@ -199,6 +193,15 @@ void SharedMessageBuilder::appendChannelName() } void SharedMessageBuilder::triggerHighlights() +{ + SharedMessageBuilder::triggerHighlights( + this->channel->getName(), this->highlightSound_, + this->highlightSoundCustomUrl_, this->highlightAlert_); +} + +void SharedMessageBuilder::triggerHighlights( + const QString &channelName, bool playSound, + const std::optional &customSoundUrl, bool windowAlert) { if (isInStreamerMode() && getSettings()->streamerModeMuteMentions) { @@ -206,21 +209,32 @@ void SharedMessageBuilder::triggerHighlights() return; } - if (getSettings()->isMutedChannel(this->channel->getName())) + if (getSettings()->isMutedChannel(channelName)) { // Do nothing. Pings are muted in this channel. return; } - bool hasFocus = (QApplication::focusWidget() != nullptr); - bool resolveFocus = !hasFocus || getSettings()->highlightAlwaysPlaySound; + const bool hasFocus = (QApplication::focusWidget() != nullptr); + const bool resolveFocus = + !hasFocus || getSettings()->highlightAlwaysPlaySound; - if (this->highlightSound_ && resolveFocus) + if (playSound && resolveFocus) { - getIApp()->getSound()->play(this->highlightSoundUrl_); + // TODO(C++23): optional or_else + QUrl soundUrl; + if (customSoundUrl) + { + soundUrl = *customSoundUrl; + } + else + { + soundUrl = getFallbackHighlightSound(); + } + getIApp()->getSound()->play(soundUrl); } - if (this->highlightAlert_) + if (windowAlert) { getApp()->windows->sendAlert(); } diff --git a/src/messages/SharedMessageBuilder.hpp b/src/messages/SharedMessageBuilder.hpp index 5dce1ad9f..b6e99e61b 100644 --- a/src/messages/SharedMessageBuilder.hpp +++ b/src/messages/SharedMessageBuilder.hpp @@ -8,6 +8,8 @@ #include #include +#include + namespace chatterino { class Badge; @@ -57,6 +59,9 @@ protected: // parseHighlights only updates the visual state of the message, but leaves the playing of alerts and sounds to the triggerHighlights function virtual void parseHighlights(); + static void triggerHighlights(const QString &channelName, bool playSound, + const std::optional &customSoundUrl, + bool windowAlert); void appendChannelName(); @@ -72,8 +77,7 @@ protected: bool highlightAlert_ = false; bool highlightSound_ = false; - - QUrl highlightSoundUrl_; + std::optional highlightSoundCustomUrl_{}; }; } // namespace chatterino diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index d80eba7d5..c2e8d1886 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -196,8 +196,10 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) } // Painting -void MessageLayout::paint(const MessagePaintContext &ctx) +MessagePaintResult MessageLayout::paint(const MessagePaintContext &ctx) { + MessagePaintResult result; + QPixmap *pixmap = this->ensureBuffer(ctx.painter, ctx.canvasWidth); if (!this->bufferValid_) @@ -209,7 +211,8 @@ void MessageLayout::paint(const MessagePaintContext &ctx) ctx.painter.drawPixmap(0, ctx.y, *pixmap); // draw gif emotes - this->container_.paintAnimatedElements(ctx.painter, ctx.y); + result.hasAnimatedElements = + this->container_.paintAnimatedElements(ctx.painter, ctx.y); // draw disabled if (this->message_->flags.has(MessageFlag::Disabled)) @@ -270,6 +273,8 @@ void MessageLayout::paint(const MessagePaintContext &ctx) } this->bufferValid_ = true; + + return result; } QPixmap *MessageLayout::ensureBuffer(QPainter &painter, int width) @@ -337,9 +342,13 @@ void MessageLayout::updateBuffer(QPixmap *buffer, this->message_->flags.has(MessageFlag::HighlightedWhisper)) && !this->flags.has(MessageLayoutFlag::IgnoreHighlights)) { - // Blend highlight color with usual background color - backgroundColor = - blendColors(backgroundColor, *this->message_->highlightColor); + assert(this->message_->highlightColor); + if (this->message_->highlightColor) + { + // Blend highlight color with usual background color + backgroundColor = + blendColors(backgroundColor, *this->message_->highlightColor); + } } else if (this->message_->flags.has(MessageFlag::Subscription) && ctx.preferences.enableSubHighlight) @@ -358,7 +367,8 @@ void MessageLayout::updateBuffer(QPixmap *buffer, blendColors(backgroundColor, *ctx.colorProvider.color(ColorType::RedeemedHighlight)); } - else if (this->message_->flags.has(MessageFlag::AutoMod)) + else if (this->message_->flags.has(MessageFlag::AutoMod) || + this->message_->flags.has(MessageFlag::LowTrustUsers)) { backgroundColor = QColor("#404040"); } diff --git a/src/messages/layouts/MessageLayout.hpp b/src/messages/layouts/MessageLayout.hpp index d87a526b2..40275ff22 100644 --- a/src/messages/layouts/MessageLayout.hpp +++ b/src/messages/layouts/MessageLayout.hpp @@ -32,6 +32,10 @@ enum class MessageLayoutFlag : uint8_t { }; using MessageLayoutFlags = FlagsEnum; +struct MessagePaintResult { + bool hasAnimatedElements = false; +}; + class MessageLayout { public: @@ -55,7 +59,7 @@ public: bool layout(int width, float scale_, MessageElementFlags flags); // Painting - void paint(const MessagePaintContext &ctx); + MessagePaintResult paint(const MessagePaintContext &ctx); void invalidateBuffer(); void deleteBuffer(); void deleteCache(); diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp index 0b2b729b8..aa3135a00 100644 --- a/src/messages/layouts/MessageLayoutContainer.cpp +++ b/src/messages/layouts/MessageLayoutContainer.cpp @@ -235,13 +235,15 @@ void MessageLayoutContainer::paintElements(QPainter &painter, } } -void MessageLayoutContainer::paintAnimatedElements(QPainter &painter, +bool MessageLayoutContainer::paintAnimatedElements(QPainter &painter, int yOffset) const { + bool anyAnimatedElement = false; for (const auto &element : this->elements_) { - element->paintAnimated(painter, yOffset); + anyAnimatedElement |= element->paintAnimated(painter, yOffset); } + return anyAnimatedElement; } void MessageLayoutContainer::paintSelection(QPainter &painter, diff --git a/src/messages/layouts/MessageLayoutContainer.hpp b/src/messages/layouts/MessageLayoutContainer.hpp index 5887295fb..be765da85 100644 --- a/src/messages/layouts/MessageLayoutContainer.hpp +++ b/src/messages/layouts/MessageLayoutContainer.hpp @@ -64,8 +64,9 @@ struct MessageLayoutContainer { /** * Paint the animated elements in this message + * @returns true if this container contains at least one animated element */ - void paintAnimatedElements(QPainter &painter, int yOffset) const; + bool paintAnimatedElements(QPainter &painter, int yOffset) const; /** * Paint the selection for this container diff --git a/src/messages/layouts/MessageLayoutContext.cpp b/src/messages/layouts/MessageLayoutContext.cpp index 82b158485..98c963919 100644 --- a/src/messages/layouts/MessageLayoutContext.cpp +++ b/src/messages/layouts/MessageLayoutContext.cpp @@ -47,6 +47,12 @@ void MessagePreferences::connectSettings(Settings *settings, }, holder); + settings->enableAutomodHighlight.connect( + [this](const auto &newValue) { + this->enableAutomodHighlight = newValue; + }, + holder); + settings->alternateMessages.connect( [this](const auto &newValue) { this->alternateMessages = newValue; diff --git a/src/messages/layouts/MessageLayoutContext.hpp b/src/messages/layouts/MessageLayoutContext.hpp index a64d98bb4..d8f08ab3a 100644 --- a/src/messages/layouts/MessageLayoutContext.hpp +++ b/src/messages/layouts/MessageLayoutContext.hpp @@ -39,6 +39,7 @@ struct MessagePreferences { bool enableElevatedMessageHighlight{}; bool enableFirstMessageHighlight{}; bool enableSubHighlight{}; + bool enableAutomodHighlight{}; bool alternateMessages{}; bool separateMessages{}; diff --git a/src/messages/layouts/MessageLayoutElement.cpp b/src/messages/layouts/MessageLayoutElement.cpp index 523a6b145..11139ea39 100644 --- a/src/messages/layouts/MessageLayoutElement.cpp +++ b/src/messages/layouts/MessageLayoutElement.cpp @@ -153,11 +153,11 @@ void ImageLayoutElement::paint(QPainter &painter, } } -void ImageLayoutElement::paintAnimated(QPainter &painter, int yOffset) +bool ImageLayoutElement::paintAnimated(QPainter &painter, int yOffset) { if (this->image_ == nullptr) { - return; + return false; } if (this->image_->animated()) @@ -167,8 +167,10 @@ void ImageLayoutElement::paintAnimated(QPainter &painter, int yOffset) auto rect = this->getRect(); rect.moveTop(rect.y() + yOffset); painter.drawPixmap(QRectF(rect), *pixmap, QRectF()); + return true; } } + return false; } int ImageLayoutElement::getMouseOverIndex(const QPoint &abs) const @@ -265,7 +267,7 @@ void LayeredImageLayoutElement::paint(QPainter &painter, } } -void LayeredImageLayoutElement::paintAnimated(QPainter &painter, int yOffset) +bool LayeredImageLayoutElement::paintAnimated(QPainter &painter, int yOffset) { auto fullRect = QRectF(this->getRect()); fullRect.moveTop(fullRect.y() + yOffset); @@ -297,6 +299,7 @@ void LayeredImageLayoutElement::paintAnimated(QPainter &painter, int yOffset) } } } + return animatedFlag; } int LayeredImageLayoutElement::getMouseOverIndex(const QPoint &abs) const @@ -446,8 +449,9 @@ void TextLayoutElement::paint(QPainter &painter, QTextOption(Qt::AlignLeft | Qt::AlignTop)); } -void TextLayoutElement::paintAnimated(QPainter &, int) +bool TextLayoutElement::paintAnimated(QPainter & /*painter*/, int /*yOffset*/) { + return false; } int TextLayoutElement::getMouseOverIndex(const QPoint &abs) const @@ -567,8 +571,10 @@ void TextIconLayoutElement::paint(QPainter &painter, } } -void TextIconLayoutElement::paintAnimated(QPainter &painter, int yOffset) +bool TextIconLayoutElement::paintAnimated(QPainter & /*painter*/, + int /*yOffset*/) { + return false; } int TextIconLayoutElement::getMouseOverIndex(const QPoint &abs) const @@ -640,8 +646,10 @@ void ReplyCurveLayoutElement::paint(QPainter &painter, painter.drawPath(path); } -void ReplyCurveLayoutElement::paintAnimated(QPainter &painter, int yOffset) +bool ReplyCurveLayoutElement::paintAnimated(QPainter & /*painter*/, + int /*yOffset*/) { + return false; } int ReplyCurveLayoutElement::getMouseOverIndex(const QPoint &abs) const diff --git a/src/messages/layouts/MessageLayoutElement.hpp b/src/messages/layouts/MessageLayoutElement.hpp index df42d1c9c..894a5829d 100644 --- a/src/messages/layouts/MessageLayoutElement.hpp +++ b/src/messages/layouts/MessageLayoutElement.hpp @@ -52,7 +52,8 @@ public: virtual size_t getSelectionIndexCount() const = 0; virtual void paint(QPainter &painter, const MessageColors &messageColors) = 0; - virtual void paintAnimated(QPainter &painter, int yOffset) = 0; + /// @returns true if anything was painted + virtual bool paintAnimated(QPainter &painter, int yOffset) = 0; virtual int getMouseOverIndex(const QPoint &abs) const = 0; virtual int getXFromIndex(size_t index) = 0; @@ -86,7 +87,7 @@ protected: uint32_t to = UINT32_MAX) const override; size_t getSelectionIndexCount() const override; void paint(QPainter &painter, const MessageColors &messageColors) override; - void paintAnimated(QPainter &painter, int yOffset) override; + bool paintAnimated(QPainter &painter, int yOffset) override; int getMouseOverIndex(const QPoint &abs) const override; int getXFromIndex(size_t index) override; @@ -105,7 +106,7 @@ protected: uint32_t to = UINT32_MAX) const override; size_t getSelectionIndexCount() const override; void paint(QPainter &painter, const MessageColors &messageColors) override; - void paintAnimated(QPainter &painter, int yOffset) override; + bool paintAnimated(QPainter &painter, int yOffset) override; int getMouseOverIndex(const QPoint &abs) const override; int getXFromIndex(size_t index) override; @@ -158,7 +159,7 @@ protected: uint32_t to = UINT32_MAX) const override; size_t getSelectionIndexCount() const override; void paint(QPainter &painter, const MessageColors &messageColors) override; - void paintAnimated(QPainter &painter, int yOffset) override; + bool paintAnimated(QPainter &painter, int yOffset) override; int getMouseOverIndex(const QPoint &abs) const override; int getXFromIndex(size_t index) override; @@ -182,7 +183,7 @@ protected: uint32_t to = UINT32_MAX) const override; size_t getSelectionIndexCount() const override; void paint(QPainter &painter, const MessageColors &messageColors) override; - void paintAnimated(QPainter &painter, int yOffset) override; + bool paintAnimated(QPainter &painter, int yOffset) override; int getMouseOverIndex(const QPoint &abs) const override; int getXFromIndex(size_t index) override; @@ -200,7 +201,7 @@ public: protected: void paint(QPainter &painter, const MessageColors &messageColors) override; - void paintAnimated(QPainter &painter, int yOffset) override; + bool paintAnimated(QPainter &painter, int yOffset) override; int getMouseOverIndex(const QPoint &abs) const override; int getXFromIndex(size_t index) override; void addCopyTextToString(QString &str, uint32_t from = 0, diff --git a/src/providers/Crashpad.cpp b/src/providers/Crashpad.cpp deleted file mode 100644 index f81cbe071..000000000 --- a/src/providers/Crashpad.cpp +++ /dev/null @@ -1,95 +0,0 @@ -#ifdef CHATTERINO_WITH_CRASHPAD -# include "providers/Crashpad.hpp" - -# include "common/QLogging.hpp" -# include "singletons/Paths.hpp" - -# include -# include -# include - -# include -# include - -namespace { - -/// The name of the crashpad handler executable. -/// This varies across platforms -# if defined(Q_OS_UNIX) -const QString CRASHPAD_EXECUTABLE_NAME = QStringLiteral("crashpad_handler"); -# elif defined(Q_OS_WINDOWS) -const QString CRASHPAD_EXECUTABLE_NAME = QStringLiteral("crashpad_handler.exe"); -# else -# error Unsupported platform -# endif - -/// Converts a QString into the platform string representation. -# if defined(Q_OS_UNIX) -std::string nativeString(const QString &s) -{ - return s.toStdString(); -} -# elif defined(Q_OS_WINDOWS) -std::wstring nativeString(const QString &s) -{ - return s.toStdWString(); -} -# else -# error Unsupported platform -# endif - -} // namespace - -namespace chatterino { - -std::unique_ptr installCrashHandler() -{ - // Currently, the following directory layout is assumed: - // [applicationDirPath] - // │ - // ├─chatterino - // │ - // ╰─[crashpad] - // │ - // ╰─crashpad_handler - // TODO: The location of the binary might vary across platforms - auto crashpadBinDir = QDir(QApplication::applicationDirPath()); - - if (!crashpadBinDir.cd("crashpad")) - { - qCDebug(chatterinoApp) << "Cannot find crashpad directory"; - return nullptr; - } - if (!crashpadBinDir.exists(CRASHPAD_EXECUTABLE_NAME)) - { - qCDebug(chatterinoApp) << "Cannot find crashpad handler executable"; - return nullptr; - } - - const auto handlerPath = base::FilePath(nativeString( - crashpadBinDir.absoluteFilePath(CRASHPAD_EXECUTABLE_NAME))); - - // Argument passed in --database - // > Crash reports are written to this database, and if uploads are enabled, - // uploaded from this database to a crash report collection server. - const auto databaseDir = - base::FilePath(nativeString(getPaths()->crashdumpDirectory)); - - auto client = std::make_unique(); - - // See https://chromium.googlesource.com/crashpad/crashpad/+/HEAD/handler/crashpad_handler.md - // for documentation on available options. - if (!client->StartHandler(handlerPath, databaseDir, {}, {}, {}, {}, {}, - true, false)) - { - qCDebug(chatterinoApp) << "Failed to start crashpad handler"; - return nullptr; - } - - qCDebug(chatterinoApp) << "Started crashpad handler"; - return client; -} - -} // namespace chatterino - -#endif diff --git a/src/providers/Crashpad.hpp b/src/providers/Crashpad.hpp deleted file mode 100644 index d15f3fcb7..000000000 --- a/src/providers/Crashpad.hpp +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#ifdef CHATTERINO_WITH_CRASHPAD -# include - -# include - -namespace chatterino { - -std::unique_ptr installCrashHandler(); - -} // namespace chatterino - -#endif diff --git a/src/providers/irc/AbstractIrcServer.cpp b/src/providers/irc/AbstractIrcServer.cpp index 58fda00fd..f1b069cc4 100644 --- a/src/providers/irc/AbstractIrcServer.cpp +++ b/src/providers/irc/AbstractIrcServer.cpp @@ -5,15 +5,12 @@ #include "messages/LimitedQueueSnapshot.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" +#include "providers/twitch/TwitchChannel.hpp" #include namespace chatterino { -const int RECONNECT_BASE_INTERVAL = 2000; -// 60 falloff counter means it will try to reconnect at most every 60*2 seconds -const int MAX_FALLOFF_COUNTER = 60; - // Ratelimits for joinBucket_ const int JOIN_RATELIMIT_BUDGET = 18; const int JOIN_RATELIMIT_COOLDOWN = 12500; @@ -88,6 +85,9 @@ AbstractIrcServer::AbstractIrcServer() } this->readConnection_->smartReconnect(); }); + this->connections_.managedConnect(this->readConnection_->heartbeat, [this] { + this->markChannelsConnected(); + }); } void AbstractIrcServer::initializeIrc() @@ -331,8 +331,6 @@ void AbstractIrcServer::onReadConnected(IrcConnection *connection) { chan->addMessage(connectedMsg); } - - chan->connected.invoke(); } this->falloffCounter_ = 1; @@ -360,9 +358,24 @@ void AbstractIrcServer::onDisconnected() } chan->addMessage(disconnectedMsg); + + if (auto *channel = dynamic_cast(chan.get())) + { + channel->markDisconnected(); + } } } +void AbstractIrcServer::markChannelsConnected() +{ + this->forEachChannel([](const ChannelPtr &chan) { + if (auto *channel = dynamic_cast(chan.get())) + { + channel->markConnected(); + } + }); +} + std::shared_ptr AbstractIrcServer::getCustomChannel( const QString &channelName) { diff --git a/src/providers/irc/AbstractIrcServer.hpp b/src/providers/irc/AbstractIrcServer.hpp index 2b29ae43d..10bba1860 100644 --- a/src/providers/irc/AbstractIrcServer.hpp +++ b/src/providers/irc/AbstractIrcServer.hpp @@ -73,6 +73,7 @@ protected: virtual void onReadConnected(IrcConnection *connection); virtual void onWriteConnected(IrcConnection *connection); virtual void onDisconnected(); + void markChannelsConnected(); virtual std::shared_ptr getCustomChannel( const QString &channelName); diff --git a/src/providers/irc/IrcConnection2.cpp b/src/providers/irc/IrcConnection2.cpp index b06343f84..9f97e6db6 100644 --- a/src/providers/irc/IrcConnection2.cpp +++ b/src/providers/irc/IrcConnection2.cpp @@ -16,7 +16,7 @@ IrcConnection::IrcConnection(QObject *parent) { // Log connection errors for ease-of-debugging QObject::connect(this, &Communi::IrcConnection::socketError, this, - [this](QAbstractSocket::SocketError error) { + [](QAbstractSocket::SocketError error) { qCDebug(chatterinoIrc) << "Connection error:" << error; }); @@ -64,6 +64,7 @@ IrcConnection::IrcConnection(QObject *parent) // If we're still receiving messages, all is well this->recentlyReceivedMessage_ = false; this->waitingForPong_ = false; + this->heartbeat.invoke(); return; } diff --git a/src/providers/irc/IrcConnection2.hpp b/src/providers/irc/IrcConnection2.hpp index 4c76161b8..150793ec8 100644 --- a/src/providers/irc/IrcConnection2.hpp +++ b/src/providers/irc/IrcConnection2.hpp @@ -19,6 +19,9 @@ public: // receiver to trigger a reconnect, if desired pajlada::Signals::Signal connectionLost; + // Signal to indicate the connection is still healthy + pajlada::Signals::NoArgSignal heartbeat; + // Request a reconnect with a minimum interval between attempts. // This won't violate RECONNECT_MIN_INTERVAL void smartReconnect(); diff --git a/src/providers/recentmessages/Api.cpp b/src/providers/recentmessages/Api.cpp index aad37234e..efce6d333 100644 --- a/src/providers/recentmessages/Api.cpp +++ b/src/providers/recentmessages/Api.cpp @@ -18,72 +18,84 @@ namespace chatterino::recentmessages { using namespace recentmessages::detail; -void load(const QString &channelName, std::weak_ptr channelPtr, - ResultCallback onLoaded, ErrorCallback onError) +void load( + const QString &channelName, std::weak_ptr channelPtr, + ResultCallback onLoaded, ErrorCallback onError, const int limit, + const std::optional> + after, + const std::optional> + before, + const bool jitter) { qCDebug(LOG) << "Loading recent messages for" << channelName; - const auto url = constructRecentMessagesUrl(channelName); + const auto url = + constructRecentMessagesUrl(channelName, limit, after, before); - NetworkRequest(url) - .onSuccess([channelPtr, onLoaded](const auto &result) { - auto shared = channelPtr.lock(); - if (!shared) - { - return; - } - - qCDebug(LOG) << "Successfully loaded recent messages for" - << shared->getName(); - - auto root = result.parseJson(); - auto parsedMessages = parseRecentMessages(root); - - // build the Communi messages into chatterino messages - auto builtMessages = - buildRecentMessages(parsedMessages, shared.get()); - - postToThread([shared = std::move(shared), root = std::move(root), - messages = std::move(builtMessages), - onLoaded]() mutable { - // Notify user about a possible gap in logs if it returned some messages - // but isn't currently joined to a channel - const auto errorCode = root.value("error_code").toString(); - if (!errorCode.isEmpty()) + const long delayMs = jitter ? std::rand() % 100 : 0; + QTimer::singleShot(delayMs, [=] { + NetworkRequest(url) + .onSuccess([channelPtr, onLoaded](const auto &result) { + auto shared = channelPtr.lock(); + if (!shared) { - qCDebug(LOG) - << QString("Got error from API: error_code=%1, " - "channel=%2") - .arg(errorCode, shared->getName()); - if (errorCode == "channel_not_joined" && !messages.empty()) - { - shared->addMessage(makeSystemMessage( - "Message history service recovering, there may " - "be gaps in the message history.")); - } + return; } - onLoaded(messages); - }); - }) - .onError([channelPtr, onError](const NetworkResult &result) { - auto shared = channelPtr.lock(); - if (!shared) - { - return; - } + qCDebug(LOG) << "Successfully loaded recent messages for" + << shared->getName(); - qCDebug(LOG) << "Failed to load recent messages for" - << shared->getName(); + auto root = result.parseJson(); + auto parsedMessages = parseRecentMessages(root); - shared->addMessage(makeSystemMessage( - QStringLiteral( - "Message history service unavailable (Error: %1)") - .arg(result.formatError()))); + // build the Communi messages into chatterino messages + auto builtMessages = + buildRecentMessages(parsedMessages, shared.get()); - onError(); - }) - .execute(); + postToThread([shared = std::move(shared), + root = std::move(root), + messages = std::move(builtMessages), + onLoaded]() mutable { + // Notify user about a possible gap in logs if it returned some messages + // but isn't currently joined to a channel + const auto errorCode = root.value("error_code").toString(); + if (!errorCode.isEmpty()) + { + qCDebug(LOG) + << QString("Got error from API: error_code=%1, " + "channel=%2") + .arg(errorCode, shared->getName()); + if (errorCode == "channel_not_joined" && + !messages.empty()) + { + shared->addMessage(makeSystemMessage( + "Message history service recovering, there may " + "be gaps in the message history.")); + } + } + + onLoaded(messages); + }); + }) + .onError([channelPtr, onError](const NetworkResult &result) { + auto shared = channelPtr.lock(); + if (!shared) + { + return; + } + + qCDebug(LOG) << "Failed to load recent messages for" + << shared->getName(); + + shared->addMessage(makeSystemMessage( + QStringLiteral( + "Message history service unavailable (Error: %1)") + .arg(result.formatError()))); + + onError(); + }) + .execute(); + }); } } // namespace chatterino::recentmessages diff --git a/src/providers/recentmessages/Api.hpp b/src/providers/recentmessages/Api.hpp index 2a558010e..57193be1f 100644 --- a/src/providers/recentmessages/Api.hpp +++ b/src/providers/recentmessages/Api.hpp @@ -2,8 +2,10 @@ #include +#include #include #include +#include #include namespace chatterino { @@ -28,8 +30,16 @@ using ErrorCallback = std::function; * @param channelPtr Weak pointer to Channel to use to build messages * @param onLoaded Callback taking the built messages as a const std::vector & * @param onError Callback called when the network request fails + * @param limit Maximum number of messages to query + * @param after Only return messages that were received after this timestamp; ignored if `std::nullopt` + * @param before Only return messages that were received before this timestamp; ignored if `std::nullopt` + * @param jitter Whether to delay the request by a small random duration */ -void load(const QString &channelName, std::weak_ptr channelPtr, - ResultCallback onLoaded, ErrorCallback onError); +void load( + const QString &channelName, std::weak_ptr channelPtr, + ResultCallback onLoaded, ErrorCallback onError, int limit, + std::optional> after, + std::optional> before, + bool jitter); } // namespace chatterino::recentmessages diff --git a/src/providers/recentmessages/Impl.cpp b/src/providers/recentmessages/Impl.cpp index 36a4b4c99..2f784ef35 100644 --- a/src/providers/recentmessages/Impl.cpp +++ b/src/providers/recentmessages/Impl.cpp @@ -5,7 +5,6 @@ #include "providers/twitch/IrcMessageHandler.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" -#include "singletons/Settings.hpp" #include "util/FormatTime.hpp" #include @@ -94,14 +93,34 @@ std::vector buildRecentMessages( // Returns the URL to be used for querying the Recent Messages API for the // given channel. -QUrl constructRecentMessagesUrl(const QString &name) +QUrl constructRecentMessagesUrl( + const QString &name, const int limit, + const std::optional> + after, + const std::optional> + before) { QUrl url(Env::get().recentMessagesApiUrl.arg(name)); QUrlQuery urlQuery(url); if (!urlQuery.hasQueryItem("limit")) + { + urlQuery.addQueryItem("limit", QString::number(limit)); + } + if (after.has_value()) { urlQuery.addQueryItem( - "limit", QString::number(getSettings()->twitchMessageHistoryLimit)); + "after", QString::number( + std::chrono::duration_cast( + after->time_since_epoch()) + .count())); + } + if (before.has_value()) + { + urlQuery.addQueryItem( + "before", QString::number( + std::chrono::duration_cast( + before->time_since_epoch()) + .count())); } url.setQuery(urlQuery); return url; diff --git a/src/providers/recentmessages/Impl.hpp b/src/providers/recentmessages/Impl.hpp index 000d379c8..3c60b6c3d 100644 --- a/src/providers/recentmessages/Impl.hpp +++ b/src/providers/recentmessages/Impl.hpp @@ -8,7 +8,9 @@ #include #include +#include #include +#include #include namespace chatterino::recentmessages::detail { @@ -24,6 +26,9 @@ std::vector buildRecentMessages( // Returns the URL to be used for querying the Recent Messages API for the // given channel. -QUrl constructRecentMessagesUrl(const QString &name); +QUrl constructRecentMessagesUrl( + const QString &name, int limit, + std::optional> after, + std::optional> before); } // namespace chatterino::recentmessages::detail diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 0bfef74af..373e4f72c 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -44,12 +44,13 @@ using namespace chatterino; // Message types below are the ones that might contain special user's message on USERNOTICE const QSet SPECIAL_MESSAGE_TYPES{ - "sub", // - "subgift", // - "resub", // resub messages - "bitsbadgetier", // bits badge upgrade - "ritual", // new viewer ritual - "announcement", // new mod announcement thing + "sub", // + "subgift", // + "resub", // resub messages + "bitsbadgetier", // bits badge upgrade + "ritual", // new viewer ritual + "announcement", // new mod announcement thing + "viewermilestone", // watch streak, but other categories possible in future }; MessagePtr generateBannedMessage(bool confirmedBan) @@ -1136,6 +1137,7 @@ void IrcMessageHandler::handleJoinMessage(Communi::IrcMessage *message) getApp()->accounts->twitch.getCurrent()->getUserName()) { twitchChannel->addMessage(makeSystemMessage("joined channel")); + twitchChannel->joined.invoke(); } else if (getSettings()->showJoins.getValue()) { diff --git a/src/providers/twitch/PubSubManager.cpp b/src/providers/twitch/PubSubManager.cpp index cc240d01d..1dbe41392 100644 --- a/src/providers/twitch/PubSubManager.cpp +++ b/src/providers/twitch/PubSubManager.cpp @@ -7,6 +7,7 @@ #include "providers/twitch/PubSubHelpers.hpp" #include "providers/twitch/PubSubMessages.hpp" #include "providers/twitch/TwitchAccount.hpp" +#include "pubsubmessages/LowTrustUsers.hpp" #include "util/DebugCount.hpp" #include "util/Helpers.hpp" #include "util/RapidjsonHelpers.hpp" @@ -210,7 +211,6 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) } action.target.login = args[0].toString(); - bool ok; action.messageText = args[1].toString(); action.messageId = args[2].toString(); @@ -586,6 +586,25 @@ void PubSub::unlistenAutomod() } } +void PubSub::unlistenLowTrustUsers() +{ + for (const auto &p : this->clients) + { + const auto &client = p.second; + if (const auto &[topics, nonce] = + client->unlistenPrefix("low-trust-users."); + !topics.empty()) + { + this->registerNonce(nonce, { + client, + "UNLISTEN", + topics, + topics.size(), + }); + } + } +} + void PubSub::unlistenWhispers() { for (const auto &p : this->clients) @@ -671,6 +690,30 @@ void PubSub::listenToAutomod(const QString &channelID) this->listenToTopic(topic); } +void PubSub::listenToLowTrustUsers(const QString &channelID) +{ + if (this->userID_.isEmpty()) + { + qCDebug(chatterinoPubSub) + << "Unable to listen to low trust users topic, no user logged in"; + return; + } + + static const QString topicFormat("low-trust-users.%1.%2"); + assert(!channelID.isEmpty()); + + auto topic = topicFormat.arg(this->userID_, channelID); + + if (this->isListeningToTopic(topic)) + { + return; + } + + qCDebug(chatterinoPubSub) << "Listen to topic" << topic; + + this->listenToTopic(topic); +} + void PubSub::listenToChannelPointRewards(const QString &channelID) { static const QString topicFormat("community-points-channel-v1.%1"); @@ -1170,6 +1213,38 @@ void PubSub::handleMessageResponse(const PubSubMessageMessage &message) this->signals_.moderation.autoModMessageCaught.invoke(innerMessage, channelID); } + else if (topic.startsWith("low-trust-users.")) + { + auto oInnerMessage = message.toInner(); + if (!oInnerMessage) + { + return; + } + + auto innerMessage = *oInnerMessage; + + switch (innerMessage.type) + { + case PubSubLowTrustUsersMessage::Type::UserMessage: { + this->signals_.moderation.suspiciousMessageReceived.invoke( + innerMessage); + } + break; + + case PubSubLowTrustUsersMessage::Type::TreatmentUpdate: { + this->signals_.moderation.suspiciousTreatmentUpdated.invoke( + innerMessage); + } + break; + + case PubSubLowTrustUsersMessage::Type::INVALID: { + qCWarning(chatterinoPubSub) + << "Invalid low trust users event type:" + << innerMessage.typeString; + } + break; + } + } else { qCDebug(chatterinoPubSub) << "Unknown topic:" << topic; diff --git a/src/providers/twitch/PubSubManager.hpp b/src/providers/twitch/PubSubManager.hpp index f300bb5d3..f0701101f 100644 --- a/src/providers/twitch/PubSubManager.hpp +++ b/src/providers/twitch/PubSubManager.hpp @@ -34,6 +34,7 @@ struct PubSubAutoModQueueMessage; struct AutomodAction; struct AutomodUserAction; struct AutomodInfoAction; +struct PubSubLowTrustUsersMessage; struct PubSubWhisperMessage; struct PubSubListenMessage; @@ -67,9 +68,6 @@ class PubSub QString userID_; public: - // The max amount of connections we may open - static constexpr int maxConnections = 10; - PubSub(const QString &host, std::chrono::seconds pingInterval = std::chrono::seconds(15)); @@ -100,6 +98,9 @@ public: Signal userBanned; Signal userUnbanned; + Signal suspiciousMessageReceived; + Signal suspiciousTreatmentUpdated; + // Message caught by automod // channelID pajlada::Signals::Signal @@ -126,12 +127,56 @@ public: void unlistenAllModerationActions(); void unlistenAutomod(); + void unlistenLowTrustUsers(); void unlistenWhispers(); + /** + * Listen to incoming whispers for the currently logged in user. + * This topic is relevant for everyone. + * + * PubSub topic: whispers.{currentUserID} + */ bool listenToWhispers(); + + /** + * Listen to moderation actions in the given channel. + * This topic is relevant for everyone. + * For moderators, this topic includes blocked/permitted terms updates, + * roomstate changes, general mod/vip updates, all bans/timeouts/deletions. + * For normal users, this topic includes moderation actions that are targetted at the local user: + * automod catching a user's sent message, a moderator approving or denying their caught messages, + * the user gaining/losing mod/vip, the user receiving a ban/timeout/deletion. + * + * PubSub topic: chat_moderator_actions.{currentUserID}.{channelID} + */ void listenToChannelModerationActions(const QString &channelID); + + /** + * Listen to Automod events in the given channel. + * This topic is only relevant for moderators. + * This will send events about incoming messages that + * are caught by Automod. + * + * PubSub topic: automod-queue.{currentUserID}.{channelID} + */ void listenToAutomod(const QString &channelID); + /** + * Listen to Low Trust events in the given channel. + * This topic is only relevant for moderators. + * This will fire events about suspicious treatment updates + * and messages sent by restricted/monitored users. + * + * PubSub topic: low-trust-users.{currentUserID}.{channelID} + */ + void listenToLowTrustUsers(const QString &channelID); + + /** + * Listen to incoming channel point redemptions in the given channel. + * This topic is relevant for everyone. + * + * PubSub topic: community-points-channel-v1.{channelID} + */ void listenToChannelPointRewards(const QString &channelID); std::vector requests; diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 146707694..8ee4ebbaf 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -56,7 +56,6 @@ namespace { #else const QString MAGIC_MESSAGE_SUFFIX = QString::fromUtf8(u8" \U000E0000"); #endif - constexpr int TITLE_REFRESH_PERIOD = 10000; constexpr int CLIP_CREATION_COOLDOWN = 5000; const QString CLIPS_LINK("https://clips.twitch.tv/%1"); const QString CLIPS_FAILURE_CLIPS_DISABLED_TEXT( @@ -103,17 +102,13 @@ TwitchChannel::TwitchChannel(const QString &name) // We can safely ignore this signal connection this has no external dependencies - once the signal // is destroyed, it will no longer be able to fire - std::ignore = this->connected.connect([this]() { - if (this->roomId().isEmpty()) + std::ignore = this->joined.connect([this]() { + if (this->disconnected_) { - // If we get a reconnected event when the room id is not set, we - // just connected for the first time. After receiving the first - // message from a channel, setRoomId is called and further - // invocations of this event will load recent messages. - return; + this->loadRecentMessagesReconnect(); + this->lastConnectedAt_ = std::chrono::system_clock::now(); + this->disconnected_ = false; } - - this->loadRecentMessagesReconnect(); }); // timers @@ -737,6 +732,8 @@ void TwitchChannel::setRoomId(const QString &id) *this->roomID_.access() = id; this->roomIdChanged(); this->loadRecentMessages(); + this->disconnected_ = false; + this->lastConnectedAt_ = std::chrono::system_clock::now(); } } @@ -1111,6 +1108,25 @@ bool TwitchChannel::setLive(bool newLiveStatus) return true; } +void TwitchChannel::markConnected() +{ + if (this->lastConnectedAt_.has_value() && !this->disconnected_) + { + this->lastConnectedAt_ = std::chrono::system_clock::now(); + } +} + +void TwitchChannel::markDisconnected() +{ + if (this->roomId().isEmpty()) + { + // we were never joined in the first place + return; + } + + this->disconnected_ = true; +} + void TwitchChannel::loadRecentMessages() { if (!getSettings()->loadTwitchMessageHistoryOnConnect) @@ -1163,7 +1179,9 @@ void TwitchChannel::loadRecentMessages() return; tc->loadingRecentMessages_.clear(); - }); + }, + getSettings()->twitchMessageHistoryLimit.getValue(), std::nullopt, + std::nullopt, false); } void TwitchChannel::loadRecentMessagesReconnect() @@ -1178,6 +1196,21 @@ void TwitchChannel::loadRecentMessagesReconnect() return; // already loading } + const auto now = std::chrono::system_clock::now(); + int limit = getSettings()->twitchMessageHistoryLimit.getValue(); + if (this->lastConnectedAt_.has_value()) + { + // calculate how many messages could have occured + // while we were not connected to the channel + // assuming a maximum of 10 messages per second + const auto secondsSinceDisconnect = + std::chrono::duration_cast( + now - this->lastConnectedAt_.value()) + .count(); + limit = + std::min(static_cast(secondsSinceDisconnect + 1) * 10, limit); + } + auto weak = weakOf(this); recentmessages::load( this->getName(), weak, @@ -1203,7 +1236,8 @@ void TwitchChannel::loadRecentMessagesReconnect() return; tc->loadingRecentMessages_.clear(); - }); + }, + limit, this->lastConnectedAt_, now, true); } void TwitchChannel::refreshPubSub() @@ -1219,7 +1253,11 @@ void TwitchChannel::refreshPubSub() getApp()->twitch->pubsub->setAccount(currentAccount); getApp()->twitch->pubsub->listenToChannelModerationActions(roomId); - getApp()->twitch->pubsub->listenToAutomod(roomId); + if (this->hasModRights()) + { + getApp()->twitch->pubsub->listenToAutomod(roomId); + getApp()->twitch->pubsub->listenToLowTrustUsers(roomId); + } getApp()->twitch->pubsub->listenToChannelPointRewards(roomId); } diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index d9e5d3e41..772ec5f62 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -136,6 +136,16 @@ public: SharedAccessGuard accessRoomModes() const; SharedAccessGuard accessStreamStatus() const; + /** + * Records that the channel is no longer joined. + */ + void markDisconnected(); + + /** + * Records that the channel's read connection is healthy. + */ + void markConnected(); + // Emotes std::optional bttvEmote(const EmoteName &name) const; std::optional ffzEmote(const EmoteName &name) const; @@ -200,6 +210,11 @@ public: */ std::shared_ptr getOrCreateThread(const MessagePtr &message); + /** + * This signal fires when the local user has joined the channel + **/ + pajlada::Signals::NoArgSignal joined; + // Only TwitchChannel may invoke this signal pajlada::Signals::NoArgSignal userStateChanged; @@ -353,6 +368,9 @@ private: int chatterCount_{}; UniqueAccess streamStatus_; UniqueAccess roomModes_; + bool disconnected_{}; + std::optional> + lastConnectedAt_{}; std::atomic_flag loadingRecentMessages_ = ATOMIC_FLAG_INIT; std::unordered_map> threads_; diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index 3039f7358..9602ce976 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -25,13 +25,11 @@ #include -// using namespace Communi; using namespace std::chrono_literals; -#define TWITCH_PUBSUB_URL "wss://pubsub-edge.twitch.tv" - namespace { +const QString TWITCH_PUBSUB_URL = "wss://pubsub-edge.twitch.tv"; const QString BTTV_LIVE_UPDATES_URL = "wss://sockets.betterttv.net/ws"; const QString SEVENTV_EVENTAPI_URL = "wss://events.7tv.io/v3"; @@ -43,12 +41,12 @@ TwitchIrcServer::TwitchIrcServer() : whispersChannel(new Channel("/whispers", Channel::Type::TwitchWhispers)) , mentionsChannel(new Channel("/mentions", Channel::Type::TwitchMentions)) , liveChannel(new Channel("/live", Channel::Type::TwitchLive)) + , automodChannel(new Channel("/automod", Channel::Type::TwitchAutomod)) , watchingChannel(Channel::getEmpty(), Channel::Type::TwitchWatching) + , pubsub(new PubSub(TWITCH_PUBSUB_URL)) { this->initializeIrc(); - this->pubsub = new PubSub(TWITCH_PUBSUB_URL); - if (getSettings()->enableBTTVLiveUpdates && getSettings()->enableBTTVChannelEmotes) { @@ -219,6 +217,7 @@ void TwitchIrcServer::readConnectionMessageReceived( { this->addGlobalSystemMessage( "Twitch Servers requested us to reconnect, reconnecting"); + this->markChannelsConnected(); this->connect(); } else if (command == "GLOBALUSERSTATE") @@ -272,6 +271,11 @@ std::shared_ptr TwitchIrcServer::getCustomChannel( return this->liveChannel; } + if (channelName == "/automod") + { + return this->automodChannel; + } + static auto getTimer = [](ChannelPtr channel, int msBetweenMessages, bool addInitialMessages) { if (addInitialMessages) @@ -383,6 +387,7 @@ void TwitchIrcServer::forEachChannelAndSpecialChannels( func(this->whispersChannel); func(this->mentionsChannel); func(this->liveChannel); + func(this->automodChannel); } std::shared_ptr TwitchIrcServer::getChannelOrEmptyByID( diff --git a/src/providers/twitch/TwitchIrcServer.hpp b/src/providers/twitch/TwitchIrcServer.hpp index c3ff2436f..f7f047de7 100644 --- a/src/providers/twitch/TwitchIrcServer.hpp +++ b/src/providers/twitch/TwitchIrcServer.hpp @@ -77,8 +77,10 @@ public: const ChannelPtr whispersChannel; const ChannelPtr mentionsChannel; const ChannelPtr liveChannel; + const ChannelPtr automodChannel; IndirectChannel watchingChannel; + // NOTE: We currently leak this PubSub *pubsub; std::unique_ptr bttvLiveUpdates; std::unique_ptr seventvEventAPI; diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 00f9bf688..0035f52c3 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -5,6 +5,7 @@ #include "common/Literals.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/highlights/HighlightController.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/ignores/IgnorePhrase.hpp" #include "controllers/userdata/UserDataController.hpp" @@ -1729,7 +1730,7 @@ void TwitchMessageBuilder::listOfUsersSystemMessage( MessagePtr TwitchMessageBuilder::buildHypeChatMessage( Communi::IrcPrivateMessage *message) { - auto level = message->tag(u"pinned-chat-paid-level"_s).toString(); + auto levelID = message->tag(u"pinned-chat-paid-level"_s).toString(); auto currency = message->tag(u"pinned-chat-paid-currency"_s).toString(); bool okAmount = false; auto amount = message->tag(u"pinned-chat-paid-amount"_s).toInt(&okAmount); @@ -1743,7 +1744,7 @@ MessagePtr TwitchMessageBuilder::buildHypeChatMessage( // additionally, there's `pinned-chat-paid-is-system-message` which isn't used by Chatterino. QString subtitle; - auto levelIt = HYPE_CHAT_PAID_LEVEL.find(level); + auto levelIt = HYPE_CHAT_PAID_LEVEL.find(levelID); if (levelIt != HYPE_CHAT_PAID_LEVEL.end()) { const auto &level = levelIt->second; @@ -1765,6 +1766,355 @@ MessagePtr TwitchMessageBuilder::buildHypeChatMessage( return builder.release(); } +EmotePtr makeAutoModBadge() +{ + return std::make_shared(Emote{ + EmoteName{}, + ImageSet{Image::fromResourcePixmap(getResources().twitch.automod)}, + Tooltip{"AutoMod"}, + Url{"https://dashboard.twitch.tv/settings/moderation/automod"}}); +} + +MessagePtr TwitchMessageBuilder::makeAutomodInfoMessage( + const AutomodInfoAction &action) +{ + auto builder = MessageBuilder(); + QString text("AutoMod: "); + + builder.emplace(); + builder.message().flags.set(MessageFlag::PubSub); + + // AutoMod shield badge + builder.emplace(makeAutoModBadge(), + MessageElementFlag::BadgeChannelAuthority); + // AutoMod "username" + builder.emplace("AutoMod:", MessageElementFlag::BoldUsername, + MessageColor(QColor("blue")), + FontStyle::ChatMediumBold); + builder.emplace( + "AutoMod:", MessageElementFlag::NonBoldUsername, + MessageColor(QColor("blue"))); + switch (action.type) + { + case AutomodInfoAction::OnHold: { + QString info("Hey! Your message is being checked " + "by mods and has not been sent."); + text += info; + builder.emplace(info, MessageElementFlag::Text, + MessageColor::Text); + } + break; + case AutomodInfoAction::Denied: { + QString info("Mods have removed your message."); + text += info; + builder.emplace(info, MessageElementFlag::Text, + MessageColor::Text); + } + break; + case AutomodInfoAction::Approved: { + QString info("Mods have accepted your message."); + text += info; + builder.emplace(info, MessageElementFlag::Text, + MessageColor::Text); + } + break; + } + + builder.message().flags.set(MessageFlag::AutoMod); + builder.message().messageText = text; + builder.message().searchText = text; + + auto message = builder.release(); + + return message; +} + +std::pair TwitchMessageBuilder::makeAutomodMessage( + const AutomodAction &action, const QString &channelName) +{ + MessageBuilder builder, builder2; + + // + // Builder for AutoMod message with explanation + builder.message().loginName = "automod"; + builder.message().channelName = channelName; + builder.message().flags.set(MessageFlag::PubSub); + builder.message().flags.set(MessageFlag::Timeout); + builder.message().flags.set(MessageFlag::AutoMod); + + // AutoMod shield badge + builder.emplace(makeAutoModBadge(), + MessageElementFlag::BadgeChannelAuthority); + // AutoMod "username" + builder.emplace("AutoMod:", MessageElementFlag::BoldUsername, + MessageColor(QColor("blue")), + FontStyle::ChatMediumBold); + builder.emplace( + "AutoMod:", MessageElementFlag::NonBoldUsername, + MessageColor(QColor("blue"))); + // AutoMod header message + builder.emplace( + ("Held a message for reason: " + action.reason + + ". Allow will post it in chat. "), + MessageElementFlag::Text, MessageColor::Text); + // Allow link button + builder + .emplace("Allow", MessageElementFlag::Text, + MessageColor(QColor("green")), + FontStyle::ChatMediumBold) + ->setLink({Link::AutoModAllow, action.msgID}); + // Deny link button + builder + .emplace(" Deny", MessageElementFlag::Text, + MessageColor(QColor("red")), + FontStyle::ChatMediumBold) + ->setLink({Link::AutoModDeny, action.msgID}); + // ID of message caught by AutoMod + // builder.emplace(action.msgID, MessageElementFlag::Text, + // MessageColor::Text); + auto text1 = + QString("AutoMod: Held a message for reason: %1. Allow will post " + "it in chat. Allow Deny") + .arg(action.reason); + builder.message().messageText = text1; + builder.message().searchText = text1; + + auto message1 = builder.release(); + + // + // Builder for offender's message + builder2.message().channelName = channelName; + builder2 + .emplace("#" + channelName, + MessageElementFlag::ChannelName, + MessageColor::System) + ->setLink({Link::JumpToChannel, channelName}); + builder2.emplace(); + builder2.emplace(); + builder2.message().loginName = action.target.login; + builder2.message().flags.set(MessageFlag::PubSub); + builder2.message().flags.set(MessageFlag::Timeout); + builder2.message().flags.set(MessageFlag::AutoMod); + builder2.message().flags.set(MessageFlag::AutoModOffendingMessage); + + // sender username + builder2 + .emplace( + action.target.displayName + ":", MessageElementFlag::BoldUsername, + MessageColor(action.target.color), FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.target.login}); + builder2 + .emplace(action.target.displayName + ":", + MessageElementFlag::NonBoldUsername, + MessageColor(action.target.color)) + ->setLink({Link::UserInfo, action.target.login}); + // sender's message caught by AutoMod + builder2.emplace(action.message, MessageElementFlag::Text, + MessageColor::Text); + auto text2 = + QString("%1: %2").arg(action.target.displayName, action.message); + builder2.message().messageText = text2; + builder2.message().searchText = text2; + + auto message2 = builder2.release(); + + // Normally highlights would be checked & triggered during the builder parse steps + // and when the message is added to the channel + // We do this a bit weird since the message comes in from PubSub and not the normal message route + auto [highlighted, highlightResult] = getIApp()->getHighlights()->check( + {}, {}, action.target.login, action.message, message2->flags); + if (highlighted) + { + SharedMessageBuilder::triggerHighlights( + channelName, highlightResult.playSound, + highlightResult.customSoundUrl, highlightResult.alert); + } + + return std::make_pair(message1, message2); +} + +MessagePtr TwitchMessageBuilder::makeLowTrustUpdateMessage( + const PubSubLowTrustUsersMessage &action) +{ + MessageBuilder builder; + builder.emplace(); + builder.message().flags.set(MessageFlag::System); + builder.message().flags.set(MessageFlag::PubSub); + builder.message().flags.set(MessageFlag::DoNotTriggerNotification); + + builder + .emplace(action.updatedByUserDisplayName, + MessageElementFlag::Username, + MessageColor::System, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.updatedByUserLogin}); + + assert(action.treatment != PubSubLowTrustUsersMessage::Treatment::INVALID); + switch (action.treatment) + { + case PubSubLowTrustUsersMessage::Treatment::NoTreatment: { + builder.emplace("removed", MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(action.suspiciousUserDisplayName, + MessageElementFlag::Username, + MessageColor::System, + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.suspiciousUserLogin}); + builder.emplace("from the suspicious user list.", + MessageElementFlag::Text, + MessageColor::System); + } + break; + + case PubSubLowTrustUsersMessage::Treatment::ActiveMonitoring: { + builder.emplace("added", MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(action.suspiciousUserDisplayName, + MessageElementFlag::Username, + MessageColor::System, + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.suspiciousUserLogin}); + builder.emplace("as a monitored suspicious chatter.", + MessageElementFlag::Text, + MessageColor::System); + } + break; + + case PubSubLowTrustUsersMessage::Treatment::Restricted: { + builder.emplace("added", MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(action.suspiciousUserDisplayName, + MessageElementFlag::Username, + MessageColor::System, + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.suspiciousUserLogin}); + builder.emplace("as a restricted suspicious chatter.", + MessageElementFlag::Text, + MessageColor::System); + } + break; + + default: + qCDebug(chatterinoTwitch) << "Unexpected suspicious treatment: " + << action.treatmentString; + break; + } + + return builder.release(); +} + +std::pair TwitchMessageBuilder::makeLowTrustUserMessage( + const PubSubLowTrustUsersMessage &action, const QString &channelName) +{ + MessageBuilder builder, builder2; + + // Builder for low trust user message with explanation + builder.message().channelName = channelName; + builder.message().flags.set(MessageFlag::PubSub); + builder.message().flags.set(MessageFlag::LowTrustUsers); + + // AutoMod shield badge + builder.emplace(makeAutoModBadge(), + MessageElementFlag::BadgeChannelAuthority); + + // Suspicious user header message + QString prefix = "Suspicious User:"; + builder.emplace(prefix, MessageElementFlag::Text, + MessageColor(QColor("blue")), + FontStyle::ChatMediumBold); + + QString headerMessage; + if (action.treatment == PubSubLowTrustUsersMessage::Treatment::Restricted) + { + headerMessage = "Restricted"; + } + else + { + headerMessage = "Monitored"; + } + + if (action.restrictionTypes.has( + PubSubLowTrustUsersMessage::RestrictionType::ManuallyAdded)) + { + headerMessage += " by " + action.updatedByUserLogin; + } + + headerMessage += " at " + action.updatedAt; + + if (action.restrictionTypes.has( + PubSubLowTrustUsersMessage::RestrictionType::DetectedBanEvader)) + { + QString evader; + if (action.evasionEvaluation == + PubSubLowTrustUsersMessage::EvasionEvaluation::LikelyEvader) + { + evader = "likely"; + } + else + { + evader = "possible"; + } + + headerMessage += ". Detected as " + evader + " ban evader"; + } + + if (action.restrictionTypes.has( + PubSubLowTrustUsersMessage::RestrictionType::BannedInSharedChannel)) + { + headerMessage += ". Banned in " + + QString::number(action.sharedBanChannelIDs.size()) + + " shared channels"; + } + + builder.emplace(headerMessage, MessageElementFlag::Text, + MessageColor::Text); + builder.message().messageText = prefix + " " + headerMessage; + builder.message().searchText = prefix + " " + headerMessage; + + auto message1 = builder.release(); + + // + // Builder for offender's message + builder2.message().channelName = channelName; + builder2 + .emplace("#" + channelName, + MessageElementFlag::ChannelName, + MessageColor::System) + ->setLink({Link::JumpToChannel, channelName}); + builder2.emplace(); + builder2.emplace(); + builder2.message().loginName = action.suspiciousUserLogin; + builder2.message().flags.set(MessageFlag::PubSub); + builder2.message().flags.set(MessageFlag::LowTrustUsers); + + // sender username + builder2 + .emplace(action.suspiciousUserDisplayName + ":", + MessageElementFlag::BoldUsername, + MessageColor(action.suspiciousUserColor), + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.suspiciousUserLogin}); + builder2 + .emplace(action.suspiciousUserDisplayName + ":", + MessageElementFlag::NonBoldUsername, + MessageColor(action.suspiciousUserColor)) + ->setLink({Link::UserInfo, action.suspiciousUserLogin}); + + // sender's message caught by AutoMod + builder2.emplace(action.text, MessageElementFlag::Text, + MessageColor::Text); + auto text = + QString("%1: %2").arg(action.suspiciousUserDisplayName, action.text); + builder2.message().messageText = text; + builder2.message().searchText = text; + + auto message2 = builder2.release(); + + return std::make_pair(message1, message2); +} + void TwitchMessageBuilder::setThread(std::shared_ptr thread) { this->thread_ = std::move(thread); diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index cc1681acb..9bffd8f7a 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -3,6 +3,7 @@ #include "common/Aliases.hpp" #include "common/Outcome.hpp" #include "messages/SharedMessageBuilder.hpp" +#include "pubsubmessages/LowTrustUsers.hpp" #include #include @@ -89,6 +90,15 @@ public: static MessagePtr buildHypeChatMessage(Communi::IrcPrivateMessage *message); + static std::pair makeAutomodMessage( + const AutomodAction &action, const QString &channelName); + static MessagePtr makeAutomodInfoMessage(const AutomodInfoAction &action); + + static std::pair makeLowTrustUserMessage( + const PubSubLowTrustUsersMessage &action, const QString &channelName); + static MessagePtr makeLowTrustUpdateMessage( + const PubSubLowTrustUsersMessage &action); + // Shares some common logic from SharedMessageBuilder::parseBadgeTag static std::unordered_map parseBadgeInfoTag( const QVariantMap &tags); diff --git a/src/providers/twitch/pubsubmessages/LowTrustUsers.cpp b/src/providers/twitch/pubsubmessages/LowTrustUsers.cpp new file mode 100644 index 000000000..630558944 --- /dev/null +++ b/src/providers/twitch/pubsubmessages/LowTrustUsers.cpp @@ -0,0 +1,104 @@ +#include "providers/twitch/pubsubmessages/LowTrustUsers.hpp" + +#include +#include + +namespace chatterino { + +PubSubLowTrustUsersMessage::PubSubLowTrustUsersMessage(const QJsonObject &root) + : typeString(root.value("type").toString()) +{ + if (const auto oType = + magic_enum::enum_cast(this->typeString.toStdString()); + oType.has_value()) + { + this->type = oType.value(); + } + + auto data = root.value("data").toObject(); + + if (this->type == Type::UserMessage) + { + this->msgID = data.value("message_id").toString(); + this->sentAt = data.value("sent_at").toString(); + this->text = + data.value("message_content").toObject().value("text").toString(); + + // the rest of the data is within a nested object + data = data.value("low_trust_user").toObject(); + + const auto sender = data.value("sender").toObject(); + this->suspiciousUserID = sender.value("user_id").toString(); + this->suspiciousUserLogin = sender.value("login").toString(); + this->suspiciousUserDisplayName = + sender.value("display_name").toString(); + this->suspiciousUserColor = + QColor(sender.value("chat_color").toString()); + + std::vector badges; + for (const auto &badge : sender.value("badges").toArray()) + { + badges.emplace_back(badge.toObject()); + } + this->senderBadges = badges; + + const auto sharedValue = data.value("shared_ban_channel_ids"); + std::vector sharedIDs; + if (!sharedValue.isNull()) + { + for (const auto &id : sharedValue.toArray()) + { + sharedIDs.emplace_back(id.toString()); + } + } + this->sharedBanChannelIDs = sharedIDs; + } + else + { + this->suspiciousUserID = data.value("target_user_id").toString(); + this->suspiciousUserLogin = data.value("target_user").toString(); + this->suspiciousUserDisplayName = this->suspiciousUserLogin; + } + + this->channelID = data.value("channel_id").toString(); + this->updatedAtString = data.value("updated_at").toString(); + this->updatedAt = QDateTime::fromString(this->updatedAtString, Qt::ISODate) + .toLocalTime() + .toString("MMM d yyyy, h:mm ap"); + + const auto updatedBy = data.value("updated_by").toObject(); + this->updatedByUserID = updatedBy.value("id").toString(); + this->updatedByUserLogin = updatedBy.value("login").toString(); + this->updatedByUserDisplayName = updatedBy.value("display_name").toString(); + + this->treatmentString = data.value("treatment").toString(); + if (const auto oTreatment = magic_enum::enum_cast( + this->treatmentString.toStdString()); + oTreatment.has_value()) + { + this->treatment = oTreatment.value(); + } + + this->evasionEvaluationString = + data.value("ban_evasion_evaluation").toString(); + if (const auto oEvaluation = magic_enum::enum_cast( + this->evasionEvaluationString.toStdString()); + oEvaluation.has_value()) + { + this->evasionEvaluation = oEvaluation.value(); + } + + FlagsEnum restrictions; + for (const auto &rType : data.value("types").toArray()) + { + if (const auto oRestriction = magic_enum::enum_cast( + rType.toString().toStdString()); + oRestriction.has_value()) + { + restrictions.set(oRestriction.value()); + } + } + this->restrictionTypes = restrictions; +} + +} // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/LowTrustUsers.hpp b/src/providers/twitch/pubsubmessages/LowTrustUsers.hpp new file mode 100644 index 000000000..84ca577d7 --- /dev/null +++ b/src/providers/twitch/pubsubmessages/LowTrustUsers.hpp @@ -0,0 +1,255 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace chatterino { + +struct LowTrustUserChatBadge { + QString id; + QString version; + + explicit LowTrustUserChatBadge(const QJsonObject &obj) + : id(obj.value("id").toString()) + , version(obj.value("version").toString()) + { + } +}; + +struct PubSubLowTrustUsersMessage { + /** + * The type of low trust message update + */ + enum class Type { + /** + * An incoming message from someone marked as low trust + */ + UserMessage, + + /** + * An incoming update about a user's low trust status + */ + TreatmentUpdate, + + INVALID, + }; + + /** + * The treatment set for the suspicious user + */ + enum class Treatment { + NoTreatment, + ActiveMonitoring, + Restricted, + + INVALID, + }; + + /** + * A ban evasion likelihood value (if any) that has been applied to the user + * automatically by Twitch + */ + enum class EvasionEvaluation { + UnknownEvader, + UnlikelyEvader, + LikelyEvader, + PossibleEvader, + + INVALID, + }; + + /** + * Restriction type (if any) that apply to the suspicious user + */ + enum class RestrictionType : uint8_t { + UnknownType = 1 << 0, + ManuallyAdded = 1 << 1, + DetectedBanEvader = 1 << 2, + BannedInSharedChannel = 1 << 3, + + INVALID = 1 << 4, + }; + + Type type = Type::INVALID; + + Treatment treatment = Treatment::INVALID; + + EvasionEvaluation evasionEvaluation = EvasionEvaluation::INVALID; + + FlagsEnum restrictionTypes; + + QString channelID; + + QString suspiciousUserID; + QString suspiciousUserLogin; + QString suspiciousUserDisplayName; + + QString updatedByUserID; + QString updatedByUserLogin; + QString updatedByUserDisplayName; + + /** + * Formatted timestamp of when the treatment was last updated for the suspicious user + */ + QString updatedAt; + + /** + * Plain text of the message sent. + * Only used for the UserMessage type. + */ + QString text; + + /** + * ID of the message. + * Only used for the UserMessage type. + */ + QString msgID; + + /** + * RFC3339 timestamp of when the message was sent. + * Only used for the UserMessage type. + */ + QString sentAt; + + /** + * Color of the user who sent the message. + * Only used for the UserMessage type. + */ + QColor suspiciousUserColor; + + /** + * A list of channel IDs where the suspicious user is also banned. + * Only used for the UserMessage type. + */ + std::vector sharedBanChannelIDs; + + /** + * A list of badges of the user who sent the message. + * Only used for the UserMessage type. + */ + std::vector senderBadges; + + /** + * Stores the string value of `type` + * Useful in case type shows up as invalid after being parsed + */ + QString typeString; + + /** + * Stores the string value of `treatment` + * Useful in case treatment shows up as invalid after being parsed + */ + QString treatmentString; + + /** + * Stores the string value of `ban_evasion_evaluation` + * Useful in case evasionEvaluation shows up as invalid after being parsed + */ + QString evasionEvaluationString; + + /** + * Stores the string value of `updated_at` + * Useful in case formattedUpdatedAt doesn't parse correctly + */ + QString updatedAtString; + + PubSubLowTrustUsersMessage() = default; + explicit PubSubLowTrustUsersMessage(const QJsonObject &root); +}; + +} // namespace chatterino + +template <> +constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< + chatterino::PubSubLowTrustUsersMessage::Type>( + chatterino::PubSubLowTrustUsersMessage::Type value) noexcept +{ + switch (value) + { + case chatterino::PubSubLowTrustUsersMessage::Type::UserMessage: + return "low_trust_user_new_message"; + + case chatterino::PubSubLowTrustUsersMessage::Type::TreatmentUpdate: + return "low_trust_user_treatment_update"; + + default: + return default_tag; + } +} + +template <> +constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< + chatterino::PubSubLowTrustUsersMessage::Treatment>( + chatterino::PubSubLowTrustUsersMessage::Treatment value) noexcept +{ + using Treatment = chatterino::PubSubLowTrustUsersMessage::Treatment; + switch (value) + { + case Treatment::NoTreatment: + return "NO_TREATMENT"; + + case Treatment::ActiveMonitoring: + return "ACTIVE_MONITORING"; + + case Treatment::Restricted: + return "RESTRICTED"; + + default: + return default_tag; + } +} + +template <> +constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< + chatterino::PubSubLowTrustUsersMessage::EvasionEvaluation>( + chatterino::PubSubLowTrustUsersMessage::EvasionEvaluation value) noexcept +{ + using EvasionEvaluation = + chatterino::PubSubLowTrustUsersMessage::EvasionEvaluation; + switch (value) + { + case EvasionEvaluation::UnknownEvader: + return "UNKNOWN_EVADER"; + + case EvasionEvaluation::UnlikelyEvader: + return "UNLIKELY_EVADER"; + + case EvasionEvaluation::LikelyEvader: + return "LIKELY_EVADER"; + + case EvasionEvaluation::PossibleEvader: + return "POSSIBLE_EVADER"; + + default: + return default_tag; + } +} + +template <> +constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< + chatterino::PubSubLowTrustUsersMessage::RestrictionType>( + chatterino::PubSubLowTrustUsersMessage::RestrictionType value) noexcept +{ + using RestrictionType = + chatterino::PubSubLowTrustUsersMessage::RestrictionType; + switch (value) + { + case RestrictionType::UnknownType: + return "UNKNOWN_TYPE"; + + case RestrictionType::ManuallyAdded: + return "MANUALLY_ADDED"; + + case RestrictionType::DetectedBanEvader: + return "DETECTED_BAN_EVADER"; + + case RestrictionType::BannedInSharedChannel: + return "BANNED_IN_SHARED_CHANNEL"; + + default: + return default_tag; + } +} diff --git a/src/singletons/CrashHandler.cpp b/src/singletons/CrashHandler.cpp new file mode 100644 index 000000000..ea77b09a1 --- /dev/null +++ b/src/singletons/CrashHandler.cpp @@ -0,0 +1,229 @@ +#include "singletons/CrashHandler.hpp" + +#include "common/Args.hpp" +#include "common/Literals.hpp" +#include "common/QLogging.hpp" +#include "singletons/Paths.hpp" + +#include +#include +#include +#include +#include +#include + +#ifdef CHATTERINO_WITH_CRASHPAD +# include + +# include +# include +#endif + +namespace { + +using namespace chatterino; +using namespace literals; + +/// The name of the crashpad handler executable. +/// This varies across platforms +#if defined(Q_OS_UNIX) +const QString CRASHPAD_EXECUTABLE_NAME = QStringLiteral("crashpad-handler"); +#elif defined(Q_OS_WINDOWS) +const QString CRASHPAD_EXECUTABLE_NAME = QStringLiteral("crashpad-handler.exe"); +#else +# error Unsupported platform +#endif + +/// Converts a QString into the platform string representation. +#if defined(Q_OS_UNIX) +std::string nativeString(const QString &s) +{ + return s.toStdString(); +} +#elif defined(Q_OS_WINDOWS) +std::wstring nativeString(const QString &s) +{ + return s.toStdWString(); +} +#else +# error Unsupported platform +#endif + +const QString RECOVERY_FILE = u"chatterino-recovery.json"_s; + +/// The recovery options are saved outside the settings +/// to be able to read them without loading the settings. +/// +/// The flags are saved in the `RECOVERY_FILE` as JSON. +std::optional readRecoverySettings(const Paths &paths) +{ + QFile file(QDir(paths.crashdumpDirectory).filePath(RECOVERY_FILE)); + if (!file.open(QFile::ReadOnly)) + { + return std::nullopt; + } + + QJsonParseError error{}; + auto doc = QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError) + { + qCWarning(chatterinoCrashhandler) + << "Failed to parse recovery settings" << error.errorString(); + return std::nullopt; + } + + const auto obj = doc.object(); + auto shouldRecover = obj["shouldRecover"_L1]; + if (!shouldRecover.isBool()) + { + return std::nullopt; + } + + return shouldRecover.toBool(); +} + +bool canRestart(const Paths &paths, const Args &args) +{ +#ifdef NDEBUG + if (args.isFramelessEmbed || args.shouldRunBrowserExtensionHost) + { + return false; + } + + auto settings = readRecoverySettings(paths); + if (!settings) + { + return false; // default, no settings found + } + return *settings; +#else + (void)paths; + return false; +#endif +} + +/// This encodes the arguments into a single string. +/// +/// The command line arguments are joined by '+'. A plus is escaped by an +/// additional plus ('++' -> '+'). +/// +/// The decoding happens in crash-handler/src/CommandLine.cpp +std::string encodeArguments(const Args &appArgs) +{ + std::string args; + for (auto arg : appArgs.currentArguments()) + { + if (!args.empty()) + { + args.push_back('+'); + } + args += arg.replace(u'+', u"++"_s).toStdString(); + } + return args; +} + +} // namespace + +namespace chatterino { + +using namespace std::string_literals; + +void CrashHandler::initialize(Settings & /*settings*/, Paths &paths) +{ + auto optSettings = readRecoverySettings(paths); + if (optSettings) + { + this->shouldRecover_ = *optSettings; + } + else + { + // By default, we don't restart after a crash. + this->saveShouldRecover(false); + } +} + +void CrashHandler::saveShouldRecover(bool value) +{ + this->shouldRecover_ = value; + + QFile file(QDir(getPaths()->crashdumpDirectory).filePath(RECOVERY_FILE)); + if (!file.open(QFile::WriteOnly | QFile::Truncate)) + { + qCWarning(chatterinoCrashhandler) + << "Failed to open" << file.fileName(); + return; + } + file.write(QJsonDocument(QJsonObject{ + {"shouldRecover"_L1, value}, + }) + .toJson(QJsonDocument::Compact)); +} + +#ifdef CHATTERINO_WITH_CRASHPAD +std::unique_ptr installCrashHandler(const Args &args) +{ + // Currently, the following directory layout is assumed: + // [applicationDirPath] + // ├─chatterino(.exe) + // ╰─[crashpad] + // ╰─crashpad-handler(.exe) + // TODO: The location of the binary might vary across platforms + auto crashpadBinDir = QDir(QApplication::applicationDirPath()); + + if (!crashpadBinDir.cd("crashpad")) + { + qCDebug(chatterinoCrashhandler) << "Cannot find crashpad directory"; + return nullptr; + } + if (!crashpadBinDir.exists(CRASHPAD_EXECUTABLE_NAME)) + { + qCDebug(chatterinoCrashhandler) + << "Cannot find crashpad handler executable"; + return nullptr; + } + + auto handlerPath = base::FilePath(nativeString( + crashpadBinDir.absoluteFilePath(CRASHPAD_EXECUTABLE_NAME))); + + // Argument passed in --database + // > Crash reports are written to this database, and if uploads are enabled, + // uploaded from this database to a crash report collection server. + auto databaseDir = + base::FilePath(nativeString(getPaths()->crashdumpDirectory)); + + auto client = std::make_unique(); + + std::map annotations{ + { + "canRestart"s, + canRestart(*getPaths(), args) ? "true"s : "false"s, + }, + { + "exePath"s, + QApplication::applicationFilePath().toStdString(), + }, + { + "startedAt"s, + QDateTime::currentDateTimeUtc().toString(Qt::ISODate).toStdString(), + }, + { + "exeArguments"s, + encodeArguments(args), + }, + }; + + // See https://chromium.googlesource.com/crashpad/crashpad/+/HEAD/handler/crashpad_handler.md + // for documentation on available options. + if (!client->StartHandler(handlerPath, databaseDir, {}, {}, {}, annotations, + {}, true, false)) + { + qCDebug(chatterinoCrashhandler) << "Failed to start crashpad handler"; + return nullptr; + } + + qCDebug(chatterinoCrashhandler) << "Started crashpad handler"; + return client; +} +#endif + +} // namespace chatterino diff --git a/src/singletons/CrashHandler.hpp b/src/singletons/CrashHandler.hpp new file mode 100644 index 000000000..058e46aed --- /dev/null +++ b/src/singletons/CrashHandler.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "common/Singleton.hpp" + +#include + +#ifdef CHATTERINO_WITH_CRASHPAD +# include + +# include +#endif + +namespace chatterino { + +class Args; + +class CrashHandler : public Singleton +{ +public: + bool shouldRecover() const + { + return this->shouldRecover_; + } + + /// Sets and saves whether Chatterino should restart on a crash + void saveShouldRecover(bool value); + + void initialize(Settings &settings, Paths &paths) override; + +private: + bool shouldRecover_ = false; +}; + +#ifdef CHATTERINO_WITH_CRASHPAD +std::unique_ptr installCrashHandler(const Args &args); +#endif + +} // namespace chatterino diff --git a/src/singletons/Logging.cpp b/src/singletons/Logging.cpp index a83ecbd17..323a9d9da 100644 --- a/src/singletons/Logging.cpp +++ b/src/singletons/Logging.cpp @@ -12,7 +12,7 @@ namespace chatterino { -void Logging::initialize(Settings &settings, Paths & /*paths*/) +Logging::Logging(Settings &settings) { // We can safely ignore this signal connection since settings are only-ever destroyed // on application exit diff --git a/src/singletons/Logging.hpp b/src/singletons/Logging.hpp index 16c3cd81a..edd1ac07f 100644 --- a/src/singletons/Logging.hpp +++ b/src/singletons/Logging.hpp @@ -1,6 +1,5 @@ #pragma once -#include "common/Singleton.hpp" #include "util/QStringHash.hpp" #include "util/ThreadGuard.hpp" @@ -12,19 +11,15 @@ namespace chatterino { -class Paths; +class Settings; struct Message; using MessagePtr = std::shared_ptr; class LoggingChannel; -class Logging : public Singleton +class Logging { - Paths *pathManager = nullptr; - public: - Logging() = default; - - void initialize(Settings &settings, Paths &paths) override; + Logging(Settings &settings); void addMessage(const QString &channelName, MessagePtr message, const QString &platformName); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 8e650de87..c66fcb218 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -375,6 +375,23 @@ public: ""}; QStringSetting subHighlightColor = {"/highlighting/subHighlightColor", ""}; + BoolSetting enableAutomodHighlight = { + "/highlighting/automod/enabled", + true, + }; + BoolSetting enableAutomodHighlightSound = { + "/highlighting/automod/enableSound", + false, + }; + BoolSetting enableAutomodHighlightTaskbar = { + "/highlighting/automod/enableTaskbarFlashing", + false, + }; + QStringSetting automodHighlightSoundUrl = { + "/highlighting/automod/soundUrl", + "", + }; + BoolSetting enableThreadHighlight = { "/highlighting/thread/nameIsHighlightKeyword", true}; BoolSetting showThreadHighlightInMentions = { @@ -510,7 +527,6 @@ public: ThumbnailPreviewMode::AlwaysShow, }; QStringSetting cachePath = {"/cache/path", ""}; - BoolSetting restartOnCrash = {"/misc/restartOnCrash", false}; BoolSetting attachExtensionToAnyProcess = { "/misc/attachExtensionToAnyProcess", false}; BoolSetting askOnImageUpload = {"/misc/askOnImageUpload", true}; diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index 47d8502ff..21277bcfd 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -55,7 +55,7 @@ using SplitDirection = SplitContainer::Direction; void WindowManager::showSettingsDialog(QWidget *parent, SettingsDialogPreference preference) { - if (getArgs().dontSaveSettings) + if (getApp()->getArgs().dontSaveSettings) { QMessageBox::critical(parent, "Chatterino - Editing Settings Forbidden", "Settings cannot be edited when running with\n" @@ -365,9 +365,9 @@ void WindowManager::initialize(Settings &settings, Paths &paths) { WindowLayout windowLayout; - if (getArgs().customChannelLayout) + if (getApp()->getArgs().customChannelLayout) { - windowLayout = getArgs().customChannelLayout.value(); + windowLayout = getApp()->getArgs().customChannelLayout.value(); } else { @@ -379,7 +379,7 @@ void WindowManager::initialize(Settings &settings, Paths &paths) this->applyWindowLayout(windowLayout); } - if (getArgs().isFramelessEmbed) + if (getApp()->getArgs().isFramelessEmbed) { this->framelessEmbedWindow_.reset(new FramelessEmbedWindow); this->framelessEmbedWindow_->show(); @@ -392,7 +392,7 @@ void WindowManager::initialize(Settings &settings, Paths &paths) this->mainWindow_->getNotebook().addPage(true); // TODO: don't create main window if it's a frameless embed - if (getArgs().isFramelessEmbed) + if (getApp()->getArgs().isFramelessEmbed) { this->mainWindow_->hide(); } @@ -427,7 +427,7 @@ void WindowManager::initialize(Settings &settings, Paths &paths) void WindowManager::save() { - if (getArgs().dontSaveSettings) + if (getApp()->getArgs().dontSaveSettings) { return; } @@ -612,6 +612,10 @@ void WindowManager::encodeChannel(IndirectChannel channel, QJsonObject &obj) obj.insert("name", channel.get()->getName()); } break; + case Channel::Type::TwitchAutomod: { + obj.insert("type", "automod"); + } + break; case Channel::Type::TwitchMentions: { obj.insert("type", "mentions"); } @@ -685,6 +689,10 @@ IndirectChannel WindowManager::decodeChannel(const SplitDescriptor &descriptor) { return app->twitch->liveChannel; } + else if (descriptor.type_ == "automod") + { + return app->twitch->automodChannel; + } else if (descriptor.type_ == "irc") { return Irc::instance().getOrAddChannel(descriptor.server_, @@ -725,7 +733,7 @@ WindowLayout WindowManager::loadWindowLayoutFromFile() const void WindowManager::applyWindowLayout(const WindowLayout &layout) { - if (getArgs().dontLoadMainWindow) + if (getApp()->getArgs().dontLoadMainWindow) { return; } @@ -751,7 +759,7 @@ void WindowManager::applyWindowLayout(const WindowLayout &layout) // out of bounds windows auto screens = qApp->screens(); bool outOfBounds = - !getenv("I3SOCK") && + !qEnvironmentVariableIsSet("I3SOCK") && std::none_of(screens.begin(), screens.end(), [&](QScreen *screen) { return screen->availableGeometry().intersects( diff --git a/src/singletons/helper/LoggingChannel.cpp b/src/singletons/helper/LoggingChannel.cpp index d73ec79e5..c6b36d11e 100644 --- a/src/singletons/helper/LoggingChannel.cpp +++ b/src/singletons/helper/LoggingChannel.cpp @@ -29,6 +29,10 @@ LoggingChannel::LoggingChannel(const QString &_channelName, { this->subDirectory = "Live"; } + else if (channelName.startsWith("/automod")) + { + this->subDirectory = "AutoMod"; + } else { this->subDirectory = @@ -96,7 +100,8 @@ void LoggingChannel::addMessage(MessagePtr message) } QString str; - if (channelName.startsWith("/mentions")) + if (channelName.startsWith("/mentions") || + channelName.startsWith("/automod")) { str.append("#" + message->channelName + " "); } diff --git a/src/util/AttachToConsole.cpp b/src/util/AttachToConsole.cpp index 5e5ca77ff..41689c699 100644 --- a/src/util/AttachToConsole.cpp +++ b/src/util/AttachToConsole.cpp @@ -3,7 +3,7 @@ #ifdef USEWINSDK # include -# include +# include #endif namespace chatterino { @@ -13,8 +13,8 @@ void attachToConsole() #ifdef USEWINSDK if (AttachConsole(ATTACH_PARENT_PROCESS)) { - freopen("CONOUT$", "w", stdout); - freopen("CONOUT$", "w", stderr); + std::ignore = freopen_s(nullptr, "CONOUT$", "w", stdout); + std::ignore = freopen_s(nullptr, "CONOUT$", "w", stderr); } #endif } diff --git a/src/widgets/BaseWindow.cpp b/src/widgets/BaseWindow.cpp index a98ab8da8..d65490cea 100644 --- a/src/widgets/BaseWindow.cpp +++ b/src/widgets/BaseWindow.cpp @@ -8,8 +8,8 @@ #include "util/PostToThread.hpp" #include "util/WindowsHelper.hpp" #include "widgets/helper/EffectLabel.hpp" +#include "widgets/helper/TitlebarButtons.hpp" #include "widgets/Label.hpp" -#include "widgets/TooltipWidget.hpp" #include "widgets/Window.hpp" #include @@ -118,7 +118,7 @@ float BaseWindow::scale() const float BaseWindow::qtFontScale() const { - return this->scale() / std::max(0.01, this->nativeScale_); + return this->scale() / std::max(0.01F, this->nativeScale_); } void BaseWindow::init() @@ -180,9 +180,8 @@ void BaseWindow::init() this->close(); }); - this->ui_.minButton = _minButton; - this->ui_.maxButton = _maxButton; - this->ui_.exitButton = _exitButton; + this->ui_.titlebarButtons = new TitleBarButtons( + this, _minButton, _maxButton, _exitButton); this->ui_.buttons.push_back(_minButton); this->ui_.buttons.push_back(_maxButton); @@ -468,18 +467,10 @@ EffectLabel *BaseWindow::addTitleBarLabel(std::function onClicked) void BaseWindow::changeEvent(QEvent *) { - if (this->isVisible()) - { - TooltipWidget::instance()->hide(); - } - #ifdef USEWINSDK - if (this->ui_.maxButton) + if (this->ui_.titlebarButtons) { - this->ui_.maxButton->setButtonStyle( - this->windowState() & Qt::WindowMaximized - ? TitleBarButtonStyle::Unmaximize - : TitleBarButtonStyle::Maximize); + this->ui_.titlebarButtons->updateMaxButton(); } if (this->isVisible() && this->hasCustomWindowFrame()) @@ -500,7 +491,6 @@ void BaseWindow::changeEvent(QEvent *) void BaseWindow::leaveEvent(QEvent *) { - TooltipWidget::instance()->hide(); } void BaseWindow::moveTo(QPoint point, widgets::BoundsChecking mode) @@ -585,6 +575,11 @@ bool BaseWindow::nativeEvent(const QByteArray &eventType, void *message, bool returnValue = false; + auto isHoveringTitlebarButton = [&]() { + auto ht = msg->wParam; + return ht == HTMAXBUTTON || ht == HTMINBUTTON || ht == HTCLOSE; + }; + switch (msg->message) { case WM_DPICHANGED: @@ -612,6 +607,91 @@ bool BaseWindow::nativeEvent(const QByteArray &eventType, void *message, returnValue = this->handleNCHITTEST(msg, result); break; + case WM_NCMOUSEHOVER: + case WM_NCMOUSEMOVE: { + // WM_NCMOUSEMOVE/WM_NCMOUSEHOVER gets sent when the mouse is + // moving/hovering in the non-client area + // - (mostly) the edges and the titlebar. + // We only need to handle the event for the titlebar buttons, + // as Qt doesn't create mouse events for these events. + if (!this->ui_.titlebarButtons) + { + // we don't consume the event if we don't have custom buttons + break; + } + + if (isHoveringTitlebarButton()) + { + *result = 0; + returnValue = true; + long x = GET_X_LPARAM(msg->lParam); + long y = GET_Y_LPARAM(msg->lParam); + + RECT winrect; + GetWindowRect(HWND(winId()), &winrect); + QPoint globalPos(x, y); + this->ui_.titlebarButtons->hover(msg->wParam, globalPos); + this->lastEventWasNcMouseMove_ = true; + } + else + { + this->ui_.titlebarButtons->leave(); + } + } + break; + + case WM_MOUSEMOVE: { + if (!this->lastEventWasNcMouseMove_) + { + break; + } + this->lastEventWasNcMouseMove_ = false; + // Windows doesn't send WM_NCMOUSELEAVE in some cases, + // so the buttons show as hovered even though they're not hovered. + [[fallthrough]]; + } + case WM_NCMOUSELEAVE: { + // WM_NCMOUSELEAVE gets sent when the mouse leaves any + // non-client area. In case we have titlebar buttons, + // we want to ensure they're deselected. + if (this->ui_.titlebarButtons) + { + this->ui_.titlebarButtons->leave(); + } + } + break; + + case WM_NCLBUTTONDOWN: + case WM_NCLBUTTONUP: { + // WM_NCLBUTTON{DOWN, UP} gets called when the left mouse button + // was pressed in a non-client area. + // We simulate a mouse down/up event for the titlebar buttons + // as Qt doesn't create an event in that case. + if (!this->ui_.titlebarButtons || !isHoveringTitlebarButton()) + { + break; + } + returnValue = true; + *result = 0; + + auto ht = msg->wParam; + long x = GET_X_LPARAM(msg->lParam); + long y = GET_Y_LPARAM(msg->lParam); + + RECT winrect; + GetWindowRect(HWND(winId()), &winrect); + QPoint globalPos(x, y); + if (msg->message == WM_NCLBUTTONDOWN) + { + this->ui_.titlebarButtons->mousePress(ht, globalPos); + } + else + { + this->ui_.titlebarButtons->mouseRelease(ht, globalPos); + } + } + break; + default: return QWidget::nativeEvent(eventType, message, result); } @@ -668,29 +748,21 @@ void BaseWindow::calcButtonsSizes() return; } - if (this->frameless_) + if (this->frameless_ || !this->ui_.titlebarButtons) { return; } - if ((this->width() / this->scale()) < 300) +#ifdef USEWINSDK + if ((static_cast(this->width()) / this->scale()) < 300) { - if (this->ui_.minButton) - this->ui_.minButton->setScaleIndependantSize(30, 30); - if (this->ui_.maxButton) - this->ui_.maxButton->setScaleIndependantSize(30, 30); - if (this->ui_.exitButton) - this->ui_.exitButton->setScaleIndependantSize(30, 30); + this->ui_.titlebarButtons->setSmallSize(); } else { - if (this->ui_.minButton) - this->ui_.minButton->setScaleIndependantSize(46, 30); - if (this->ui_.maxButton) - this->ui_.maxButton->setScaleIndependantSize(46, 30); - if (this->ui_.exitButton) - this->ui_.exitButton->setScaleIndependantSize(46, 30); + this->ui_.titlebarButtons->setRegularSize(); } +#endif } void BaseWindow::drawCustomWindowFrame(QPainter &painter) @@ -943,32 +1015,55 @@ bool BaseWindow::handleNCHITTEST(MSG *msg, long *result) if (*result == 0) { - bool client = false; - // Check the main layout first, as it's the largest area if (this->ui_.layoutBase->geometry().contains(point)) { - client = true; + *result = HTCLIENT; } // Check the titlebar buttons - if (!client && this->ui_.titlebarBox->geometry().contains(point)) + if (*result == 0 && + this->ui_.titlebarBox->geometry().contains(point)) { - for (QWidget *widget : this->ui_.buttons) + for (const auto *widget : this->ui_.buttons) { - if (widget->isVisible() && - widget->geometry().contains(point)) + if (!widget->isVisible() || + !widget->geometry().contains(point)) { - client = true; + continue; } + + if (const auto *btn = + dynamic_cast(widget)) + { + switch (btn->getButtonStyle()) + { + case TitleBarButtonStyle::Minimize: { + *result = HTMINBUTTON; + break; + } + case TitleBarButtonStyle::Unmaximize: + case TitleBarButtonStyle::Maximize: { + *result = HTMAXBUTTON; + break; + } + case TitleBarButtonStyle::Close: { + *result = HTCLOSE; + break; + } + default: { + *result = HTCLIENT; + break; + } + } + break; + } + *result = HTCLIENT; + break; } } - if (client) - { - *result = HTCLIENT; - } - else + if (*result == 0) { *result = HTCAPTION; } @@ -996,6 +1091,11 @@ bool BaseWindow::handleNCHITTEST(MSG *msg, long *result) return true; } + if (widget == this) + { + return false; + } + return recursiveCheckMouseTracking(widget->parentWidget()); }; diff --git a/src/widgets/BaseWindow.hpp b/src/widgets/BaseWindow.hpp index 3b46aea6b..d55b5bd6d 100644 --- a/src/widgets/BaseWindow.hpp +++ b/src/widgets/BaseWindow.hpp @@ -18,6 +18,7 @@ namespace chatterino { class Button; class EffectLabel; class TitleBarButton; +class TitleBarButtons; enum class TitleBarButtonStyle; class BaseWindow : public BaseWidget @@ -135,9 +136,7 @@ private: QLayout *windowLayout = nullptr; QHBoxLayout *titlebarBox = nullptr; QWidget *titleLabel = nullptr; - TitleBarButton *minButton = nullptr; - TitleBarButton *maxButton = nullptr; - TitleBarButton *exitButton = nullptr; + TitleBarButtons *titlebarButtons = nullptr; QWidget *layoutBase = nullptr; std::vector