Repository: Zoxc/crusader Branch: master Commit: 5d1e6e76fb9b Files: 64 Total size: 1002.4 KB Directory structure: gitextract_275plfem/ ├── .dockerignore ├── .gitattributes ├── .github/ │ └── workflows/ │ ├── ci.yml │ ├── release.md │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── android/ │ ├── .gitignore │ ├── Cargo.toml │ ├── app/ │ │ ├── build.gradle │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── zoxc/ │ │ │ └── crusader/ │ │ │ └── MainActivity.java │ │ └── res/ │ │ └── values/ │ │ ├── colors.xml │ │ └── themes.xml │ ├── build.gradle │ ├── debugInstall.ps1 │ ├── gradle/ │ │ └── wrapper/ │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties │ ├── gradle.properties │ ├── gradlew │ ├── gradlew.bat │ ├── settings.gradle │ └── src/ │ └── lib.rs ├── data/ │ ├── v0.crr │ ├── v1.crr │ └── v2.crr ├── docker/ │ ├── README.md │ ├── remote-static.Dockerfile │ └── server-static.Dockerfile ├── docs/ │ ├── BUILDING.md │ ├── CLI.md │ ├── LOCAL_TESTS.md │ ├── RESULTS.md │ └── TROUBLESHOOTING.md ├── media/ │ ├── Crusader Screen Shots.md │ └── batch_add_border.sh └── src/ ├── Cargo.toml ├── crusader/ │ ├── Cargo.toml │ └── src/ │ └── main.rs ├── crusader-gui/ │ ├── Cargo.toml │ └── src/ │ └── main.rs ├── crusader-gui-lib/ │ ├── Cargo.toml │ └── src/ │ ├── client.rs │ └── lib.rs └── crusader-lib/ ├── Cargo.toml ├── UFL.txt ├── assets/ │ ├── vue.js │ └── vue.prod.js ├── build.rs └── src/ ├── common.rs ├── discovery.rs ├── file_format.rs ├── latency.rs ├── lib.rs ├── peer.rs ├── plot.rs ├── protocol.rs ├── remote.html ├── remote.rs ├── serve.rs └── test.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ /src/target ================================================ FILE: .gitattributes ================================================ src/crusader-lib/assets/* linguist-vendored ================================================ FILE: .github/workflows/ci.yml ================================================ name: ci on: push: branches: [master] pull_request: env: CARGO_INCREMENTAL: 0 jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions-rs/toolchain@v1 with: toolchain: stable override: true components: rustfmt, clippy - name: Build server-only binary run: cargo build -p crusader --no-default-features working-directory: src - name: Build run: cargo build working-directory: src - name: Lint run: cargo clippy --all -- -D warnings working-directory: src - name: Format run: cargo fmt --all -- --check working-directory: src android: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions-rs/toolchain@v1 with: toolchain: stable override: true components: rustfmt, clippy - name: Install Rust targets run: > rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android - name: Install cargo-ndk run: cargo install cargo-ndk - name: Setup Java uses: actions/setup-java@v3 with: distribution: 'temurin' java-version: '17' - name: Setup Android SDK uses: android-actions/setup-android@v2 - name: Build Android Rust crates working-directory: android run: cargo ndk -t arm64-v8a -o app/src/main/jniLibs/ -- build - name: Build Android APK working-directory: android run: ./gradlew buildDebug # Wait for a new cargo ndk release for better clippy support #- name: Lint # run: cargo ndk -t arm64-v8a -- clippy --all -- -D warnings # working-directory: android - name: Format run: cargo fmt --all -- --check working-directory: android ================================================ FILE: .github/workflows/release.md ================================================ Crusader has pre-built binaries for a number of operating systems. Download the appropriate binary below for your OS. ================================================ FILE: .github/workflows/release.yml ================================================ name: release on: push: tags: - "v*" env: CARGO_INCREMENTAL: 0 jobs: create-release: name: create-release runs-on: ubuntu-latest outputs: upload_url: ${{ steps.release.outputs.upload_url }} permissions: contents: write steps: - uses: actions/checkout@v2 - name: Get the release version from the tag shell: bash run: echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - name: Create GitHub release id: release uses: actions/create-release@v1.1.4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ env.TAG_NAME }} release_name: Automated build of ${{ env.TAG_NAME }} prerelease: true body_path: .github/workflows/release.md release-assets: name: Release assets needs: create-release runs-on: ${{ matrix.build.os }} strategy: fail-fast: false matrix: build: - os: ubuntu-latest target: arm-unknown-linux-musleabihf friendly: Linux-ARM-32-bit exe_postfix: cargo: cross gui: false - os: ubuntu-latest target: aarch64-unknown-linux-musl friendly: Linux-ARM-64-bit exe_postfix: cargo: cross gui: false - os: ubuntu-latest target: x86_64-unknown-linux-musl friendly: Linux-X86-64-bit exe_postfix: cargo: cargo gui: false - os: macos-latest target: aarch64-apple-darwin friendly: macOS-ARM-64-bit exe_postfix: cargo: cargo gui: true - os: macos-latest target: x86_64-apple-darwin friendly: macOS-X86-64-bit exe_postfix: cargo: cargo gui: true - os: windows-latest target: i686-pc-windows-msvc friendly: Windows-X86-32-bit exe_postfix: .exe cargo: cargo gui: true - os: windows-latest target: x86_64-pc-windows-msvc friendly: Windows-X86-64-bit exe_postfix: .exe cargo: cargo gui: true steps: - uses: actions/checkout@v2 - uses: actions-rs/toolchain@v1 with: toolchain: stable override: true target: ${{ matrix.build.target }} - name: Install cross if: matrix.build.cargo == 'cross' run: cargo install cross - name: Install and use musl if: matrix.build.os == 'ubuntu-latest' && matrix.build.cargo != 'cross' run: | sudo apt-get install -y --no-install-recommends musl-tools echo "CC=musl-gcc" >> $GITHUB_ENV echo "AR=ar" >> $GITHUB_ENV - name: Build command line binary if: ${{ !matrix.build.gui }} run: ${{ matrix.build.cargo }} build -p crusader --target ${{ matrix.build.target }} --release working-directory: src env: RUSTFLAGS: "-C target-feature=+crt-static" - name: Build if: matrix.build.gui run: ${{ matrix.build.cargo }} build --target ${{ matrix.build.target }} --release working-directory: src env: RUSTFLAGS: "-C target-feature=+crt-static" - name: Build output shell: bash run: | staging="Crusader-${{ matrix.build.friendly }}" mkdir -p "$staging" cp src/target/${{ matrix.build.target }}/release/crusader${{ matrix.build.exe_postfix }} "$staging/" - name: Copy GUI binary if: matrix.build.gui shell: bash run: | cp src/target/${{ matrix.build.target }}/release/crusader-gui${{ matrix.build.exe_postfix }} "crusader-${{ matrix.build.friendly }}/" - name: Archive output if: matrix.build.os == 'windows-latest' shell: bash run: | staging="Crusader-${{ matrix.build.friendly }}" 7z a "$staging.zip" "$staging" echo "ASSET=$staging.zip" >> $GITHUB_ENV - name: Archive output if: matrix.build.os != 'windows-latest' shell: bash run: | staging="Crusader-${{ matrix.build.friendly }}" tar czf "$staging.tar.gz" "$staging" echo "ASSET=$staging.tar.gz" >> $GITHUB_ENV - name: Upload archive uses: actions/upload-release-asset@v1.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_name: ${{ env.ASSET }} asset_path: ${{ env.ASSET }} asset_content_type: application/octet-stream release-android-assets: name: Android APK needs: create-release runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions-rs/toolchain@v1 with: toolchain: stable override: true - name: Install Rust targets run: > rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android - name: Install cargo-ndk run: cargo install cargo-ndk - name: Setup Java uses: actions/setup-java@v3 with: distribution: 'temurin' java-version: '17' - name: Setup Android SDK uses: android-actions/setup-android@v2 - name: Build Android Rust crates working-directory: android run: > cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 -o app/src/main/jniLibs/ -- build --release - name: Decode Keystore env: ENCODED_STRING: ${{ secrets.KEYSTORE }} run: echo "$ENCODED_STRING" | base64 -di > ../android.keystore - name: Build Android APK working-directory: android run: ./gradlew build env: SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} - name: Upload APK uses: actions/upload-release-asset@v1.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_name: Crusader-Android.apk asset_path: android/app/build/outputs/apk/release/app-release.apk asset_content_type: application/octet-stream ================================================ FILE: .gitignore ================================================ /src/target ================================================ FILE: CHANGELOG.md ================================================ # CHANGELOG The **Crusader Network Tester** measures network rates and latency in the presence of upload and download traffic. It produces plots of the traffic rates, latency and packet loss. This file lists the changes that have occurred since January 2024 in the project. ## Unreleased ## 0.3.2 - 2024-10-03 * Fix saved raw data path printed after a test * Avoid duplicate legends when plotting transferred bytes * Make `--plot-transferred` increase default plot height * Fix unique output path generation ## 0.3.1 - 2024-09-30 * Increase samples used for clock synchronization and idle latency measurement * Clock synchronization now uses the average of the lowest 1/3rd of samples * Adjust for clock drift in tests * Fix connecting to servers on non-standard port with peers * Make discovery more robust by sending multiple packets ## 0.3 - 2024-09-16 * Show throughput, latency, and packet loss summaries in plots and with the `test` command * Rename both option to bidirectional * Rename `--latency-peer-server` to `--latency-peer-address` * Continuous clock synchronization with the latency monitor * Support opening result files in the GUI by drag and drop * Add `--out-name` command line option to specify result filename prefix * Change filename prefix for both raw result and plots to `test` * Add file dialog to save options in GUI * Add buttons to save and load from the `crusader-results` folder in GUI * Add an `export` command line command to convert result files to JSON * Change timeout when connecting a peer to the server to 8 seconds * Hide advanced parameters in GUI * Add a reset parameters button in GUI * Add an option to measure latency-only for the client in the GUI * Don't allow peers to connect with the regular server * Added average lines in GUI ## 0.2 - 2024-08-29 * Added support for local discovery of server and peers using UDP port 35483 * The `test` command line option `--latency-peer` is renamed to `--latency-peer-server`. A new flag `--latency-peer` will instead search for a local peer. * Improved error messages * Fix date/time display in remote web page * Rename the `Latency` tab to `Monitor` * Change default streams from 16 to 8. * Change default throughput sample interval from 20 ms to 60 ms. * Change default load duration from 5 s to 10 s. * Change default grace duration from 5 s to 10 s. * Fix serving from link-local interfaces on Linux * Fix peers on link-local interfaces * Show download and upload plots for aggregate tests in the GUI * Added a shortcut (space) to stop the latency monitor * Change timeout when connecting to servers and peers to 8 seconds * Added average lines to the plot output * Show interface IPs when starting servers ## 0.1 - 2024-08-21 * Added `crusader remote` command to start a web server listening on port 35482. It allows starting tests on a separate machine and displays the resulting charts in the web page. * Use system fonts in GUI * Improved error handling and error messages * Added `--idle` option to the client to test without traffic * Save results in a `crusader-results` folder * Allow building of a server-only binary * Generated files will use a YYYY-MM-DD HH.MM.SS format * Rename bandwidth to throughput * Rename sample rate to sample interval * Rename `Both` to `Aggregate` and `Total` to `Round-trip` in plots ## 0.0.12 - 2024-07-31 * Create UDP server for each server IP (fixes #22) * Improved error handling for log messages * Changed date format to use YYYY-MM-DD in logs ## 0.0.11 - 2024-07-29 * Log file includes timestamps and version number * Added peer latency measurements * Added version to title bar of GUI * Added `plot_max_bandwidth` and `plot_max_latency` command line options ## 0.0.10 - 2024-01-09 * Specify plot title * Ignore ENOBUFS error ================================================ FILE: LICENSE-APACHE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS ================================================ FILE: LICENSE-MIT ================================================ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Crusader Network Tester [![GitHub Release](https://img.shields.io/github/v/release/Zoxc/crusader)](https://github.com/Zoxc/crusader/releases) [![Docker Hub](https://img.shields.io/badge/container-dockerhub-blue)](https://hub.docker.com/r/zoxc/crusader) [![MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Zoxc/crusader/blob/master/LICENSE-MIT) [![Apache](https://img.shields.io/badge/license-Apache-blue.svg)](https://github.com/Zoxc/crusader/blob/master/LICENSE-APACHE) ![Crusader Results Screenshot](./media/Crusader-Result.png) The **Crusader Network Tester** measures network throughput, latency and packet loss in the presence of upload and download traffic. It also incorporates a continuous latency tester for monitoring background responsiveness. Crusader makes throughput measurements using TCP on port 35481 and latency tests using UDP port 35481. The remote web server option uses TCP port 35482. Local server discovery uses UDP port 35483. **Pre-built binaries** for Windows, Mac, Linux, and Android are available on the [Releases](https://github.com/Zoxc/crusader/releases) page. The GUI is not prebuilt for Linux and must be built from source. **Documentation** See the [Documentation](#documentation) section below. **Status:** The latest Crusader release version is shown above. The [pre-built binaries](https://github.com/Zoxc/crusader/releases) always provide the latest version. See the [CHANGELOG.md](./CHANGELOG.md) file for details. ## Crusader GUI A test run requires two separate computers, both running Crusader: a **server** that listens for connections, and a **client** that initiates the test. The Crusader GUI incorporates both the server and the client and allows you to interact with results. To use it, download the proper binary from the [Releases](https://github.com/Zoxc/crusader/releases) page. When you open the `crusader-gui` you see this window. Enter the address of another computer that's running the Crusader server, then click **Start test**. When the test is complete, the **Result** tab shows a chart like the second image below. An easy way to use Crusader is to download the Crusader GUI onto two computers, then start the server on one computer, and the client on the other. ![Crusader Client Screenshot](./media/Crusader-Client.png) The Crusader GUI has five tabs: * **Client tab** Runs the Crusader client program. The options shown above are described in the [Command-line options](./docs/CLI.md) page. * **Server tab** Runs the Crusader server, listening for connections from other clients * **Remote tab** Starts a webserver (default port 35482). A browser that connects to that port can initiate a test to a Crusader server. * **Monitor tab** Continually displays the latency to the selected Crusader server until stopped. * **Result tab** Displays the result of the most recent client run ## The Result Tab ![Crusader Results Screenshot](./media/Crusader-Result.png) A Crusader test creates three bursts of traffic. By default, it generates ten seconds each of download only, upload only, then bi-directional traffic. Each burst is separated by several seconds of idle time. The Crusader Result tab displays the results of the test with three plots (see image above): * The **Throughput** plot shows the bursts of traffic. Green is download (from server to client), blue is upload, and the purple line is the instantaneous sum of the download plus upload. * The **Latency** plot shows the corresponding latency. Green shows the (uni-directional) time from the server to the client. Blue is the (uni-directional) time from the client to the server. Black shows the sum from the client to the server and back (round-trip time). * The **Packet Loss** plot has green and blue marks that indicate times when packets were lost. For more details, see the [Understanding Crusader Results](./docs/RESULTS.md) page. ## Documentation * [This README](./README.md) * [Understanding Crusader Results](./docs/RESULTS.md) * [Local Testing](./docs/LOCAL_TESTS.md) * [Command-line Options](./docs/CLI.md) * [Building Crusader from source](./docs/BUILDING.md) * [Troubleshooting](./docs/TROUBLESHOOTING.md) * [Docker container](https://hub.docker.com/r/zoxc/crusader) for the server is available on [dockerhub](https://hub.docker.com/r/zoxc/crusader). ================================================ FILE: android/.gitignore ================================================ .gradle /target /app/build /app/src/main/jniLibs ================================================ FILE: android/Cargo.toml ================================================ [package] name = "crusader-android" version = "0.1.0" edition = "2021" resolver = "2" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] log = "0.4" eframe = { version = "0.28.1", features = ["wgpu"] } crusader-gui-lib = { path = "../src/crusader-gui-lib" } crusader-lib = { path = "../src/crusader-lib" } winit = "0.29.15" jni = "0.19.0" ndk-context = "0.1" [target.'cfg(target_os = "android")'.dependencies] android_logger = "0.11.0" android-activity = { version = "0.5", features = ["game-activity"] } [patch.crates-io] winit = { git = "https://github.com/Zoxc/winit", branch = "crusader2" } egui = { git = "https://github.com/Zoxc/egui", branch = "crusader2" } epaint = { git = "https://github.com/Zoxc/egui", branch = "crusader2" } emath = { git = "https://github.com/Zoxc/egui", branch = "crusader2" } egui_plot = { git = "https://github.com/Zoxc/egui_plot", branch = "crusader" } [lib] name = "main" crate-type = ["cdylib"] ================================================ FILE: android/app/build.gradle ================================================ plugins { id 'com.android.application' } android { compileSdk 31 defaultConfig { applicationId "zoxc.crusader" minSdk 28 targetSdk 31 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } signingConfigs { release { storeFile = file("../../../android.keystore") storePassword System.getenv("SIGNING_STORE_PASSWORD") keyAlias System.getenv("SIGNING_KEY_ALIAS") keyPassword System.getenv("SIGNING_KEY_PASSWORD") } } buildTypes { release { minifyEnabled false signingConfig signingConfigs.release } debug { minifyEnabled false //packagingOptions { // doNotStrip '**/*.so' //} //debuggable true } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { implementation 'com.google.android.material:material:1.5.0' implementation "androidx.games:games-activity:2.0.2" // To use the Games Controller Library //implementation "androidx.games:games-controller:1.1.0" // To use the Games Text Input Library //implementation "androidx.games:games-text-input:1.1.0" } ================================================ FILE: android/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: android/app/src/main/java/zoxc/crusader/MainActivity.java ================================================ package zoxc.crusader; import androidx.appcompat.app.AppCompatActivity; import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsControllerCompat; import com.google.androidgamesdk.GameActivity; import android.os.Bundle; import android.content.pm.PackageManager; import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; import android.os.Bundle; import android.view.View; import android.view.WindowManager; import android.util.Log; import android.content.Intent; import android.net.Uri; import android.app.Activity; import android.view.inputmethod.InputMethodManager; import android.provider.OpenableColumns; import android.database.Cursor; import android.content.Context; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.OutputStream; public class MainActivity extends GameActivity { static { System.loadLibrary("main"); } public void showKeyboard(boolean show) { InputMethodManager input = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE); if (show) { input.showSoftInput(getWindow().getDecorView().getRootView(), 0); } else { input.hideSoftInputFromWindow(getWindow().getDecorView().getRootView().getWindowToken(), 0); } } public void loadFile() { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); startActivityForResult(intent, ACTIVITY_LOAD_FILE); } public void saveFile(boolean image, String name, byte[] data) { Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); if (image) { intent.setType("image/png"); } else { intent.setType("application/octet-stream"); } intent.putExtra(Intent.EXTRA_TITLE, name); saveFileData = data; saveImage = image; startActivityForResult(intent, ACTIVITY_CREATE_FILE); } private byte[] saveFileData = null; private boolean saveImage; private static final int ACTIVITY_LOAD_FILE = 1; private static final int ACTIVITY_CREATE_FILE = 2; static native void fileLoaded(String name, byte[] data); static native void fileSaved(boolean image, String name); @Override public void onActivityResult(int requestCode, int resultCode, Intent resultData) { super.onActivityResult(requestCode, resultCode, resultData); if (requestCode == ACTIVITY_CREATE_FILE) { if (resultCode == Activity.RESULT_OK && resultData != null) { Uri uri = resultData.getData(); String name = getName(uri); try { OutputStream stream = getContentResolver().openOutputStream(uri); stream.write(saveFileData); stream.close(); fileSaved(saveImage, name); } catch(Exception e) {} } saveFileData = null; } if (requestCode == ACTIVITY_LOAD_FILE && resultCode == Activity.RESULT_OK && resultData != null) { Uri uri = resultData.getData(); String name = getName(uri); try { InputStream stream = getContentResolver().openInputStream(uri); ByteArrayOutputStream buffer = new ByteArrayOutputStream(); int read; byte[] byte_buffer = new byte[0x1000]; while ((read = stream.read(byte_buffer, 0, byte_buffer.length)) != -1) { buffer.write(byte_buffer, 0, read); } stream.close(); byte[] data = buffer.toByteArray(); fileLoaded(name, data); } catch(Exception e) {} } } public String getName(Uri uri) { Cursor cursor = getContentResolver().query(uri, null, null, null, null, null); String name = ""; try { if (cursor != null && cursor.moveToFirst()) { int column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); if (column != -1) { name = cursor.getString(column); } return name; } } finally { cursor.close(); } return name; } } ================================================ FILE: android/app/src/main/res/values/colors.xml ================================================ #FFBB86FC #FF6200EE #FF3700B3 #FF03DAC5 #FF018786 #FF000000 #FFFFFFFF #FFE6E6E6 ================================================ FILE: android/app/src/main/res/values/themes.xml ================================================ ================================================ FILE: android/build.gradle ================================================ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id 'com.android.application' version '7.1.2' apply false id 'com.android.library' version '7.1.2' apply false } task clean(type: Delete) { delete rootProject.buildDir } ================================================ FILE: android/debugInstall.ps1 ================================================ $ErrorActionPreference = "Stop" cargo ndk -t arm64-v8a -o app/src/main/jniLibs/ -- build --release if ($lastexitcode -ne 0) { throw "Error" } ./gradlew.bat buildDebug if ($lastexitcode -ne 0) { throw "Error" } ./gradlew.bat installDebug if ($lastexitcode -ne 0) { throw "Error" } ================================================ FILE: android/gradle/wrapper/gradle-wrapper.properties ================================================ #Mon May 02 15:39:12 BST 2022 distributionBase=GRADLE_USER_HOME distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME ================================================ FILE: android/gradle.properties ================================================ # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true ================================================ FILE: android/gradlew ================================================ #!/usr/bin/env sh # # Copyright 2015 the original author or authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## ## ## Gradle start up script for UN*X ## ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" # Need this for relative symlinks. while [ -h "$PRG" ] ; do ls=`ls -ld "$PRG"` link=`expr "$ls" : '.*-> \(.*\)$'` if expr "$link" : '/.*' > /dev/null; then PRG="$link" else PRG=`dirname "$PRG"`"/$link" fi done SAVED="`pwd`" cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" warn () { echo "$*" } die () { echo echo "$*" echo exit 1 } # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "`uname`" in CYGWIN* ) cygwin=true ;; Darwin* ) darwin=true ;; MINGW* ) msys=true ;; NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD="java" which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD="$MAX_FD_LIMIT" fi ulimit -n $MAX_FD if [ $? -ne 0 ] ; then warn "Could not set maximum file descriptor limit: $MAX_FD" fi else warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" fi fi # For Darwin, add options to specify how the application appears in the dock if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi # For Cygwin or MSYS, switch paths to Windows format before running java if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" for dir in $ROOTDIRSRAW ; do ROOTDIRS="$ROOTDIRS$SEP$dir" SEP="|" done OURCYGPATTERN="(^($ROOTDIRS))" # Add a user-defined pattern to the cygpath arguments if [ "$GRADLE_CYGPATTERN" != "" ] ; then OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" fi # Now convert the arguments - kludge to limit ourselves to /bin/sh i=0 for arg in "$@" ; do CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` else eval `echo args$i`="\"$arg\"" fi i=`expr $i + 1` done case $i in 0) set -- ;; 1) set -- "$args0" ;; 2) set -- "$args0" "$args1" ;; 3) set -- "$args0" "$args1" "$args2" ;; 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } APP_ARGS=`save "$@"` # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" exec "$JAVACMD" "$@" ================================================ FILE: android/gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: android/settings.gradle ================================================ pluginManagement { repositories { gradlePluginPortal() google() mavenCentral() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } } include ':app' ================================================ FILE: android/src/lib.rs ================================================ #![allow( clippy::field_reassign_with_default, clippy::option_map_unit_fn, clippy::missing_safety_doc )] use crusader_gui_lib::Tester; use crusader_lib::file_format::RawResult; use eframe::egui::{self, vec2, Align, FontFamily, Layout}; use jni::{ objects::{JClass, JObject, JString}, sys::{jboolean, jbyteArray}, JNIEnv, }; use std::{ error::Error, io::Cursor, path::Path, sync::{Arc, Mutex}, }; #[cfg(target_os = "android")] use { android_activity::AndroidApp, crusader_lib::test::PlotConfig, eframe::{NativeOptions, Renderer, Theme}, log::Level, std::fs, winit::platform::android::EventLoopBuilderExtAndroid, }; struct App { tester: Tester, keyboard_shown: bool, } impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { use eframe::egui::FontFamily::Proportional; use eframe::egui::FontId; use eframe::egui::TextStyle::*; let mut style = ctx.style(); let style_ = Arc::make_mut(&mut style); style_.spacing.button_padding = vec2(10.0, 0.0); style_.spacing.interact_size.y = 40.0; style_.spacing.item_spacing = vec2(10.0, 10.0); style_.text_styles = [ (Heading, FontId::new(26.0, Proportional)), (Body, FontId::new(16.0, Proportional)), (Monospace, FontId::new(16.0, FontFamily::Monospace)), (Button, FontId::new(16.0, Proportional)), (Small, FontId::new(16.0, Proportional)), ] .into(); ctx.set_style(style); egui::CentralPanel::default().show(ctx, |ui| { let mut rect = ui.max_rect(); rect.set_top(rect.top() + 40.0); rect.set_height(rect.height() - 60.0); let mut ui = ui.child_ui(rect, Layout::left_to_right(Align::Center), None); ui.vertical(|ui| { ui.heading("Crusader Network Benchmark"); ui.separator(); SAVED_FILE.lock().unwrap().take().map(|(image, name)| { if !image { self.tester.save_raw(Path::new(&name).to_owned()); } }); LOADED_FILE.lock().unwrap().take().map(|(name, data)| { RawResult::load_from_reader(Cursor::new(data)) .map(|data| self.tester.load_file(Path::new(&name).to_owned(), data)); }); self.tester.show(ctx, ui); }); }); if ctx.wants_keyboard_input() != self.keyboard_shown { show_keyboard(ctx.wants_keyboard_input()).unwrap(); self.keyboard_shown = ctx.wants_keyboard_input(); } } } fn show_keyboard(show: bool) -> Result<(), Box> { let context = ndk_context::android_context(); let vm = unsafe { jni::JavaVM::from_raw(context.vm().cast())? }; let activity: JObject = (context.context() as jni::sys::jobject).into(); let env = vm.attach_current_thread()?; env.call_method(activity, "showKeyboard", "(Z)V", &[show.into()])? .v()?; Ok(()) } fn save_file(image: bool, name: String, data: Vec) -> Result<(), Box> { let context = ndk_context::android_context(); let vm = unsafe { jni::JavaVM::from_raw(context.vm().cast())? }; let activity: JObject = (context.context() as jni::sys::jobject).into(); let env = vm.attach_current_thread()?; env.call_method( activity, "saveFile", "(ZLjava/lang/String;[B)V", &[ image.into(), env.new_string(name).unwrap().into(), env.byte_array_from_slice(&data).unwrap().into(), ], )? .v()?; Ok(()) } static SAVED_FILE: Mutex> = Mutex::new(None); #[no_mangle] pub unsafe extern "C" fn Java_zoxc_crusader_MainActivity_fileSaved( env: JNIEnv, _: JClass, image: jboolean, name: JString, ) { let name: String = env.get_string(name).unwrap().into(); *SAVED_FILE.lock().unwrap() = Some((image != 0, name)); } fn load_file() -> Result<(), Box> { let context = ndk_context::android_context(); let vm = unsafe { jni::JavaVM::from_raw(context.vm().cast())? }; let activity: JObject = (context.context() as jni::sys::jobject).into(); let env = vm.attach_current_thread()?; env.call_method(activity, "loadFile", "()V", &[])?.v()?; Ok(()) } static LOADED_FILE: Mutex)>> = Mutex::new(None); #[no_mangle] pub unsafe extern "C" fn Java_zoxc_crusader_MainActivity_fileLoaded( env: JNIEnv, _: JClass, name: JString, data: jbyteArray, ) { let name: String = env.get_string(name).unwrap().into(); let data = env.convert_byte_array(data).unwrap(); *LOADED_FILE.lock().unwrap() = Some((name, data)); } #[cfg(target_os = "android")] #[no_mangle] fn android_main(app: AndroidApp) { android_logger::init_once(android_logger::Config::default().with_min_level(Level::Trace)); crusader_lib::plot::register_fonts(); let settings = app .internal_data_path() .map(|path| path.join("settings.toml")); let temp_plot = app.internal_data_path().map(|path| path.join("plot.png")); let mut options = NativeOptions::default(); options.follow_system_theme = false; options.default_theme = Theme::Light; options.renderer = Renderer::Wgpu; options.event_loop_builder = Some(Box::new(move |builder| { builder.with_android_app(app.clone()); })); let mut tester = Tester::new(settings); tester.file_loader = Some(Box::new(|_| load_file().unwrap())); tester.plot_saver = Some(Box::new(move |result| { let path = temp_plot.as_deref().unwrap(); crusader_lib::plot::save_graph_to_path(path, &PlotConfig::default(), result).unwrap(); let data = fs::read(path).unwrap(); fs::remove_file(path).unwrap(); let name = format!("{}.png", crusader_lib::test::timed("plot")); save_file(true, name, data).unwrap(); })); tester.raw_saver = Some(Box::new(|result| { let mut writer = Cursor::new(Vec::new()); result.save_to_writer(&mut writer).unwrap(); let data = writer.into_inner(); let name = format!("{}.crr", crusader_lib::test::timed("data")); save_file(false, name, data).unwrap(); })); eframe::run_native( "Crusader Network Tester", options, Box::new(|_cc| { Ok(Box::new(App { tester, keyboard_shown: false, })) }), ) .unwrap(); } ================================================ FILE: docker/README.md ================================================ To build a statically linked server image: ``` docker build .. -t crusader -f server-static.Dockerfile ``` To build a statically linked remote image: ``` docker build .. -t crusader -f remote-static.Dockerfile ``` This image allow initiation of tests using the web application running on port 35482. Supported platforms: - `linux/i386` - `linux/x86_64` - `linux/arm/v7` - `linux/arm64` Available profiles: - `--build-arg PROFILE=release` (default) - `--build-arg PROFILE=speed` - `--build-arg PROFILE=size` ================================================ FILE: docker/remote-static.Dockerfile ================================================ FROM rust AS build ARG TARGETARCH ARG PROFILE=release COPY src /src WORKDIR /src RUN echo no-target-detected > /target RUN if [ "$TARGETARCH" = "386" ]; then\ echo i686-unknown-linux-musl > /target; fi RUN if [ "$TARGETARCH" = "amd64" ]; then\ echo x86_64-unknown-linux-musl > /target; fi RUN if [ "$TARGETARCH" = "arm" ]; then\ echo arm-unknown-linux-musleabihf > /target; fi RUN if [ "$TARGETARCH" = "arm64" ]; then\ echo aarch64-unknown-linux-musl > /target; fi ENV RUSTFLAGS="-C target-feature=+crt-static" RUN rustup target add $(cat /target) RUN cargo build -p crusader --profile=$PROFILE --target $(cat /target) RUN cp target/$(cat /target)/$PROFILE/crusader / FROM scratch COPY --from=build /crusader / EXPOSE 35482/tcp ENTRYPOINT [ "/crusader", "remote" ] ================================================ FILE: docker/server-static.Dockerfile ================================================ FROM rust AS build ARG TARGETARCH ARG PROFILE=release COPY src /src WORKDIR /src RUN echo no-target-detected > /target RUN if [ "$TARGETARCH" = "386" ]; then\ echo i686-unknown-linux-musl > /target; fi RUN if [ "$TARGETARCH" = "amd64" ]; then\ echo x86_64-unknown-linux-musl > /target; fi RUN if [ "$TARGETARCH" = "arm" ]; then\ echo arm-unknown-linux-musleabihf > /target; fi RUN if [ "$TARGETARCH" = "arm64" ]; then\ echo aarch64-unknown-linux-musl > /target; fi ENV RUSTFLAGS="-C target-feature=+crt-static" RUN rustup target add $(cat /target) RUN cargo build -p crusader --no-default-features --profile=$PROFILE --target $(cat /target) RUN cp target/$(cat /target)/$PROFILE/crusader / FROM scratch COPY --from=build /crusader / EXPOSE 35481/tcp 35481/udp 35483/udp ENTRYPOINT [ "/crusader", "serve" ] ================================================ FILE: docs/BUILDING.md ================================================ # Building Crusader from source Reminder: [Pre-built binaries](https://github.com/Zoxc/crusader/releases) are available for everyday tests. ## Required dependencies * A Rust and C toolchain. * `fontconfig` (optional, required for `crusader-gui`) _Note:_ To install `fontconfig` on Ubuntu: ```sh sudo apt install libfontconfig1-dev ``` ## Building Crusader To develop or debug Crusader, use the commands below to build the full set of binaries. Executables are placed in _src/target/release_ To build the `crusader` command line executable: ```sh cd src cargo build -p crusader --release ``` To build both command line and GUI executables: ```sh cd src cargo build --release ``` ## Debug build Create a debug build by using `cargo build` (instead of `cargo build --release`). Binaries are saved in the _src/target/debug_ directory ## Docker To build a docker container that runs the server: ```sh cd docker docker build .. -t crusader -f server-static.Dockerfile ``` ================================================ FILE: docs/CLI.md ================================================ # Running Crusader from the command line ## Server To host a Crusader server, run this on the _server_ machine: ```sh crusader serve ``` ## Client To start a test, run this on the _client machine_. See the [command-line options](#options-for-the-test-command) below for details. ```sh crusader test ``` ## Remote To host a web server that provides remote control of a Crusader client, run the command below, then connect to `http://ip-of-the-crusader-device:35482` ```sh crusader remote ``` ## Plot Crusader creates a `.png` file from a `.crr` file using `crusader plot path-to-crr-file` The resulting `.png` is saved in the same directory as the input file. ## Export Crusader exports raw data samples from a `.crr` file into a `.json` file using `crusader export path-to-crr-file` The resulting `.json` is saved in the same directory as the input file. ## Options for the `test` command **Usage: `crusader test [OPTIONS] `** **Arguments:** `` address of a Crusader server **Options:** * **`--download`** Run a download test * **`--upload`** Run an upload test * **`--bidirectional`** Run a test doing both download and upload * **`--idle`** Run a test, only measuring latency without inducing traffic. The duration is specified by `grace_duration` * **`--port `** Specify the TCP and UDP port used by the server [default: 35481] * **`--streams `** The number of TCP connections used to generate traffic in a single direction [default: 8] * **`--stream-stagger `** The delay between the start of each stream [default: 0.0] * **`--load-duration `** The duration in which traffic is generated [default: 10.0] * **`--grace-duration `** The idle time between each test [default: 2.0] * **`--latency-sample-interval `** [default: 5.0] * **`--throughput-sample-interval `** [default: 60.0] * **`--plot-transferred`** Plot transferred bytes * **`--plot-split-throughput`** Plot upload and download separately and plot streams * **`--plot-max-throughput `** Set the axis for throughput to at least this value. SI units are supported so `100M` would specify 100 Mbps * **`--plot-max-latency `** Set the axis for latency to at least this value * **`--plot-width `** * **`--plot-height `** * **`--plot-title `** * **`--latency-peer-address `** Address of another Crusader server (the "peer") which concurrently measures the latency to the server and reports the values to the client * **`--latency-peer`** Trigger the client to instruct a peer (another Crusader server) to begin measuring the latency to the main server and report the latency back * **`--out-name `** The filename prefix used for the raw data and plot filenames * **`-h, --help`** Print help (see a summary with '-h') ================================================ FILE: docs/LOCAL_TESTS.md ================================================ # Local network testing with Crusader **Background:** The Crusader Network Tester measures network throughput and latency in the presence of upload and download traffic and produces plots of the traffic rates, latency and packet loss. ## Making local tests - Wifi or Wired To test the equipment between two points on your network, Crusader requires two computers, one acting as a server, the other as a client. The Crusader program can act as both client and server. Install the latest pre-built binary of [Crusader](https://github.com/Zoxc/crusader/releases) on two separate computers. Then: 1. Connect one of those computers to your router's LAN port using an Ethernet cable. Start the Crusader program on it, then click the **Server** tab. See the [screen shot](../media/Crusader-Server.png). Click **Start server** and look for the address(es) where the Crusader Server is listening. (Note: The Crusader binary can also run on a small computer such as a Raspberry Pi. A Pi4 acting as a server can easily support 1Gbps speeds.) 2. Connect the other computer either by Ethernet, Wi-fi, or some other adapter. Run the Crusader program and click the Client tab. See the [screen shot](../media/Crusader-Client.png). Enter the address of a Crusader Server into the GUI, and click **Start test** 3. When the test completes, you'll see charts of three bursts of traffic: Download, Upload, and Bidirectional, along with the latency during that activity. See the [Crusader README](../README.md) and [Understanding Crusader Results](./RESULTS.md) for details. **Note:** If both the computers (client and server) are on the LAN side of the router (whether they are on Wi-Fi or Ethernet), the results will reflect the _switching_ capability, not the _routing_ capability of the router. To test the routing capability, test against a server that's on the WAN side of the router or the broader Internet. **Note:** The Crusader program has both a GUI and a command-line binary. Both act as a client or a server. These instructions tell how to run the client on a laptop. You may find it convenient to run the server on a remote computer. Get both binaries from the [Releases page](https://github.com/Zoxc/crusader/releases). ## What can we learn? Your local router, Wi-fi, and other network equipment all affect your total performance. Even with very high speed internet service, if the router is max'ed out it can hold back your throughput. And you're stuck with whatever latency the router and other local equipment create - it's added to any latency from your ISP network. In particular: * Ethernet-to-Ethernet connections tend to be good: high throughput with low latency. But you should always check your network. On a 1Gbps network, typical results are above 950 Mbps, and less than a dozen milliseconds of latency. * Wi-fi has a reputation for "being slow". This often is a result of the Wi-fi drivers injecting hundreds of milliseconds of latency. In addition, wireless connections will always have lower throughput than wired connections. * Many powerline adapters (that provide an Ethernet connection between two AC outlets) may have high specifications, but in practice are known to give limited throughput and have high latency. Running a Crusader test between computers on the local network measures the performance of that portion of the network. ## Why is this important? These test results - the actual throughput or latency numbers - are not very important. If you are satisfied with you network's performance, then these numerical results don't matter. But if you are experiencing problems, these tests help divide the troubleshooting problem in two. * If the local network is running fine, performance problems must be elsewhere (in your ISP or their upstream service, which likely is out of your control). * But if the local performance is not what you expected, you can start investigating your router, switch, etc. ================================================ FILE: docs/RESULTS.md ================================================ # Understanding Crusader Results The Crusader GUI provides a compact summary of the test data. Here are some hints for evaluating the results. ## Result window ![Result with statistics](../media/Crusader-Result-with-stats.png) Crusader tests the connection using three bursts of traffic. The Throughput, Latency, and Packet loss are shown in the charts. In the image above notice: * **Hovering over a chart** shows crosshairs that give the throughput or latency of that point in the chart. In the screen shot above, the Down latency peaks around 250 ms. * **Hovering over, or clicking the ⓘ symbol** opens a window that displays a summary of the measurements. See the description below for details. * **Clicking a legend** ("color") in the charts shows/hides the associated graph. In the screen shot above, the Latency's "Round-trip" legend has been clicked, hiding the (black) round-trip trace, and showing only the Up and Down latency plots. * The **Save to results** button saves two files: a plot (as `.png`) and the data (as `.crr`) to the _crusader-results_ directory in the _current directory_. * The **Open from results** button opens a `.crr` file from the _crusader-results_ directory. * The **Save** button opens a file dialog to save the current `.crr` file. * The **Open** button opens a file dialog to select a `.crr` file to open. * The **Export plot** button opens a file dialog to save a `.png` file. ## Numerical Summary Windows The Crusader GUI displays charts showing Throughput, Latency, and Packet loss. The ⓘ symbol opens a window showing a numerical summary of the charted data. ### Throughput description * Download - Steady state throughput, ignoring any startup transients, during the Download portion of the test * Upload - Steady state throughput, ignoring any startup transients, during the Upload portion of the test * Bidirectional - Sum of the Download and Upload throughputs during the Bidirectional portion of the test. Also displays the individual Download and Upload throughputs. * Streams - number of TCP connections used in each direction * Stream Stagger - The delay between the start of each stream * Throughput sample interval - Interval between throughput measurements ### Latency description Crusader smooths all the latency samples over a 400 ms window. The values shown in the window display the maximum of those smoothed values. This emphasizes sustained peaks of latency. * Download - Summarizes the round-trip latency during the Download portion of the test. Also displays the measured one-way delay for Download (from server to client) and Upload (client to server) * Upload - Summarizes the latency for the Upload portion of the test * Bidirectional - Summarizes the latency for the Bidirectional portion of the test * Idle latency - Measured latency when no traffic is present. * Latency sample interval - Interval between latency measurements ### Packet loss description When it measures packet loss, Crusader is using the UDP packets that it also uses for latency measurements. * Download - Summarizes packet loss during the Download portion of the test * Upload - Summarizes packet loss during the Upload portion of the test * Bidirectional - Summarizes packet loss during the Bidirectional portion of the test ================================================ FILE: docs/TROUBLESHOOTING.md ================================================ # Troubleshooting * Crusader requires that TCP and UDP ports 35481 are open for its tests. Crusader also uses ports 35482 for the remote webserver and port 35483 for discovering other Crusader servers. Check that your firewall is letting those ports through. * On macOS, the first time you double-click the pre-built `crusader` or `crusader-gui` icon, the OS refuses to let it run. You must use **System Preferences -> Privacy & Security** to approve Crusader to run. * The message `Warning: Load termination timed out. There may be residual untracked traffic in the background.` is not necessarily harmful. It may happen due to the TCP termination being lost or TCP incompatibilities between OSes. It's likely benign if you see throughput and latency drop to idle values after the tests in the graph. * The up and down latency measurements rely on symmetric stable latency measurements to the server. These values may be wrong if those assumption don't hold on test startup. ================================================ FILE: media/Crusader Screen Shots.md ================================================ # Verify Crusader Screen Shots This page is useful for checking the appearance of screen shots. Capture the screen shots (Cmd-Shift-5 on macOS) and save with filenames like "Client.png", "Server.png", etc., one for each of the tabs. Run the `batch_add_border.sh` script - it finds all these files, removes the drop shadow and adds "Crusader-" to each result file. Remove the original files before committing to git. ## Client ![Options](./Crusader-Client.png) ## Server ![Server](./Crusader-Server.png) ## Remote ![Remote](./Crusader-Remote.png) ## Monitor ![Monitor](./Crusader-Monitor.png) ## Result ![Result](./Crusader-Result.png) ## Result with stats ![Options](./Crusader-Result-with-stats.png) ## Throughput popup ![Throughput](./Crusader-Throughput.png) description ## Latency popup ![Latency](./Crusader-Latency.png) description ## Packet loss popup ![Packet Loss](./Crusader-Loss.png) description ================================================ FILE: media/batch_add_border.sh ================================================ #!/bin/bash # add_border.sh - This script uses ImageMagick to process # macOS screen shots for publication # Usage: # 1. Take screen shots (Cmd-Shift-5) on macOS # 2. Save the file with a name that would be a good suffix (e.g. Remote.png) # 3. Run this script. The script outputs modified files prefixed with "Crusader-" # 4. Discard the original files # The script does the following - for the .png files in the directory: # - find all .png files that don't begin with "Crusader" # - remove the transparent area/drop shadow from a macOS screen shot # - shrink the image to the size of the image # - draw a grey border 1 px wide around the window. # - save the resulting file as "Crusader-....png" # Thanks, ChatGPT # Get the directory where the script is located script_dir="$(dirname "$0")" # Define the border size (1 pixel) and colors border_size=1 border_color="gray" background_color="white" # Loop through all .png files for input_file in "$script_dir"/[a-zA-Z]*.png; do # Check if the file actually exists (in case no files match) if [ ! -f "$input_file" ]; then continue fi # Ignore files that already start with "Crusader" if [[ $(basename "$input_file") == Crusader* ]]; then echo "Skipping: $input_file" continue fi # Output file name (prepend "Crusader-" to the original file name) output_file="$script_dir/Crusader-$(basename "$input_file")" # Process the image: # 1. Remove the alpha channel (transparency) by filling with white # 2. Trim the image to its non-transparent content # 3. Add a 1-pixel grey border magick "$input_file" \ -alpha off \ -trim \ -bordercolor $border_color \ -border ${border_size}x${border_size} \ "$output_file" echo "Processed: $input_file -> $output_file" done echo "All matching .png files processed." ================================================ FILE: src/Cargo.toml ================================================ [workspace] members = ["crusader", "crusader-lib", "crusader-gui-lib", "crusader-gui"] resolver = "1" [profile.dev] panic = "abort" [profile.release] panic = "abort" strip = "symbols" [profile.speed] opt-level = 3 codegen-units = 1 inherits = "release" lto = "fat" [profile.size] inherits = "speed" opt-level = "z" [patch.crates-io] egui_plot = { git = "https://github.com/Zoxc/egui_plot", branch = "crusader" } ================================================ FILE: src/crusader/Cargo.toml ================================================ [package] name = "crusader" version = "0.1.0" edition = "2021" [dependencies] crusader-lib = { path = "../crusader-lib" } clap = { version = "4.5.13", features = ["derive", "string"] } clap-num = "1.1.1" env_logger = "0.10.0" anyhow = "1.0.86" serde_json = { version = "1.0.122", optional = true } [features] default = ["client"] client = ["crusader-lib/client", "dep:serde_json"] ================================================ FILE: src/crusader/src/main.rs ================================================ use anyhow::Context; use clap::{Parser, Subcommand}; use clap_num::si_number; #[cfg(feature = "client")] use crusader_lib::file_format::RawResult; #[cfg(feature = "client")] use crusader_lib::test::PlotConfig; use crusader_lib::{protocol, version}; #[cfg(feature = "client")] use crusader_lib::{with_time, Config}; #[cfg(feature = "client")] use std::path::PathBuf; use std::process; #[cfg(feature = "client")] use { anyhow::anyhow, std::fs::OpenOptions, std::io::{BufWriter, Write}, std::path::Path, std::time::Duration, }; #[derive(Parser)] #[command(version = version())] struct Cli { #[command(subcommand)] command: Commands, } #[derive(clap::Args)] struct PlotArgs { #[arg(long, help = "Plot transferred bytes")] plot_transferred: bool, #[arg(long, help = "Plot upload and download separately and plot streams")] plot_split_throughput: bool, #[arg(long, value_parser=si_number::, value_name = "BPS", long_help = "Sets the axis for throughput to at least this value. \ SI units are supported so `100M` would specify 100 Mbps")] plot_max_throughput: Option, #[arg( long, value_name = "MILLISECONDS", help = "Sets the axis for latency to at least this value" )] plot_max_latency: Option, #[arg(long, value_name = "PIXELS")] plot_width: Option, #[arg(long, value_name = "PIXELS")] plot_height: Option, #[arg(long)] plot_title: Option, } impl PlotArgs { #[cfg(feature = "client")] fn config(&self) -> PlotConfig { PlotConfig { transferred: self.plot_transferred, split_throughput: self.plot_split_throughput, max_throughput: self.plot_max_throughput, max_latency: self.plot_max_latency, width: self.plot_width, height: self.plot_height, title: self.plot_title.clone(), } } } #[derive(Subcommand)] enum Commands { #[command(about = "Runs the server")] Serve { #[arg(long, default_value_t = protocol::PORT, help = "Specifies the TCP and UDP port used by the server")] port: u16, #[arg(long, help = "Allow use and discovery as a peer")] peer: bool, }, #[command( long_about = "Runs a test client against a specified server and saves the result to the current directory. \ By default this does a download test, an upload test, and a test doing both download and upload while measuring the latency to the server" )] #[cfg(feature = "client")] Test { server: Option, #[arg(long, help = "Run a download test")] download: bool, #[arg(long, help = "Run an upload test")] upload: bool, #[arg(long, help = "Run a test doing both download and upload")] bidirectional: bool, #[arg( long, long_help = "Run a test only measuring latency. The duration is specified by `grace_duration`" )] idle: bool, #[arg(long, default_value_t = protocol::PORT, help = "Specifies the TCP and UDP port used by the server")] port: u16, #[arg( long, default_value_t = 8, help = "The number of TCP connections used to generate traffic in a single direction" )] streams: u64, #[arg( long, default_value_t = 0.0, value_name = "SECONDS", help = "The delay between the start of each stream" )] stream_stagger: f64, #[arg( long, default_value_t = 10.0, value_name = "SECONDS", help = "The duration in which traffic is generated" )] load_duration: f64, #[arg( long, default_value_t = 2.0, value_name = "SECONDS", help = "The idle time between each test" )] grace_duration: f64, #[arg(long, default_value_t = 5, value_name = "MILLISECONDS")] latency_sample_interval: u64, #[arg(long, default_value_t = 60, value_name = "MILLISECONDS")] throughput_sample_interval: u64, #[command(flatten)] plot: PlotArgs, #[arg( long, long_help = "Specifies another server (peer) which will also measure the latency to the server independently of the client" )] latency_peer_address: Option, #[arg( long, help = "Use another server (peer) which will also measure the latency to the server independently of the client" )] latency_peer: bool, #[arg( long, help = "The filename prefix used for the test result raw data and plot filenames" )] out_name: Option, }, #[cfg(feature = "client")] #[command(about = "Plots a previous result")] Plot { data: PathBuf, #[command(flatten)] plot: PlotArgs, }, #[cfg(feature = "client")] #[command(about = "Allows the client to be controlled over a web server")] Remote { #[arg( long, default_value_t = protocol::PORT + 1, help = "Specifies the HTTP port used by the server" )] port: u16, }, #[cfg(feature = "client")] #[command(about = "Converts a result file to JSON")] Export { data: PathBuf, #[arg( long, short('o'), help = "The path where the output JSON will be stored" )] output: Option, #[arg(long, short('f'), help = "Overwrite the file if it exists")] force: bool, }, } fn run() -> Result<(), anyhow::Error> { let cli = Cli::parse(); match &cli.command { #[cfg(feature = "client")] &Commands::Test { ref server, download, upload, bidirectional, idle, throughput_sample_interval, latency_sample_interval, ref plot, port, streams, stream_stagger, grace_duration, load_duration, ref latency_peer_address, latency_peer, ref out_name, } => { let mut config = Config { port, streams, stream_stagger: Duration::from_secs_f64(stream_stagger), grace_duration: Duration::from_secs_f64(grace_duration), load_duration: Duration::from_secs_f64(load_duration), download: !idle, upload: !idle, bidirectional: !idle, ping_interval: Duration::from_millis(latency_sample_interval), throughput_interval: Duration::from_millis(throughput_sample_interval), }; if download || upload || bidirectional { if idle { println!("Cannot run `idle` test with a load test"); process::exit(1); } config.download = download; config.upload = upload; config.bidirectional = bidirectional; } crusader_lib::test::test( config, plot.config(), server.as_deref(), (latency_peer || latency_peer_address.is_some()) .then_some(latency_peer_address.as_deref()), out_name.as_deref().unwrap_or("test"), ) } &Commands::Serve { port, peer } => crusader_lib::serve::serve(port, peer), #[cfg(feature = "client")] Commands::Remote { port } => crusader_lib::remote::run(*port), #[cfg(feature = "client")] Commands::Plot { data, plot } => { let result = RawResult::load(data).ok_or(anyhow!("Unable to load data"))?; let root = data.parent().unwrap_or(Path::new("")); let file = crusader_lib::plot::save_graph( &plot.config(), &result.to_test_result(), data.file_stem() .and_then(|name| name.to_str()) .unwrap_or("plot"), data.parent().unwrap_or(Path::new("")), )?; println!( "{}", with_time(&format!("Saved plot as {}", root.join(file).display())) ); Ok(()) } #[cfg(feature = "client")] Commands::Export { data, output, force, } => { let result = RawResult::load(data).ok_or(anyhow!("Unable to load data"))?; let output = output .clone() .unwrap_or_else(|| data.with_extension("json")); let file = OpenOptions::new() .create_new(!*force) .create(*force) .truncate(true) .write(true) .open(output) .context("Failed to create output file")?; let mut file = BufWriter::new(file); serde_json::to_writer_pretty(&mut file, &result).context("Failed to serialize data")?; file.flush().context("Failed to flush output")?; Ok(()) } } } fn main() { env_logger::init(); #[cfg(feature = "client")] crusader_lib::plot::register_fonts(); if let Err(error) = run() { println!("Error: {:?}", error); process::exit(1); } } ================================================ FILE: src/crusader-gui/Cargo.toml ================================================ [package] name = "crusader-gui" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] crusader-lib = { path = "../crusader-lib" } crusader-gui-lib = { path = "../crusader-gui-lib" } env_logger = "0.10.0" eframe = "0.28.1" font-kit = "0.14.2" ================================================ FILE: src/crusader-gui/src/main.rs ================================================ #![allow( clippy::field_reassign_with_default, clippy::option_map_unit_fn, clippy::type_complexity )] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release use std::{error::Error, process, sync::Arc}; use crusader_gui_lib::Tester; use crusader_lib::version; use eframe::{ egui::{self, Context, FontData, FontDefinitions, FontFamily}, emath::vec2, Theme, }; #[allow(unused_imports)] use font_kit::family_name::FamilyName; use font_kit::{handle::Handle, properties::Properties, source::SystemSource}; fn main() { env_logger::init(); let mut options = eframe::NativeOptions::default(); options.follow_system_theme = false; options.default_theme = Theme::Light; crusader_lib::plot::register_fonts(); let settings = std::env::current_exe() .ok() .map(|exe| exe.with_extension("toml")); let result = eframe::run_native( &format!("Crusader Network Tester {}", version()), options, Box::new(move |cc| { let ctx = &cc.egui_ctx; let mut style = ctx.style(); let style_ = Arc::make_mut(&mut style); style_.spacing.button_padding = vec2(6.0, 0.0); style_.spacing.interact_size.y = 30.0; style_.spacing.item_spacing = vec2(5.0, 5.0); let font_size = if cfg!(target_os = "macos") { 13.5 } else { 12.5 }; style_.text_styles.get_mut(&egui::TextStyle::Body).map(|v| { v.size = font_size; }); style_ .text_styles .get_mut(&egui::TextStyle::Button) .map(|v| { v.size = font_size; }); style_ .text_styles .get_mut(&egui::TextStyle::Small) .map(|v| { v.size = font_size; }); style_ .text_styles .get_mut(&egui::TextStyle::Monospace) .map(|v| { v.size = font_size; }); ctx.set_style(style); load_system_font(ctx).ok(); Ok(Box::new(App { tester: Tester::new(settings), })) }), ); if let Err(e) = result { eprintln!("Failed to run GUI: {}", e); process::exit(1); } } fn load_system_font(ctx: &Context) -> Result<(), Box> { let mut fonts = FontDefinitions::default(); let handle = SystemSource::new().select_best_match( &[ #[cfg(target_os = "macos")] FamilyName::SansSerif, #[cfg(windows)] FamilyName::Title("Segoe UI".to_string()), ], &Properties::new(), )?; let buf: Vec = match handle { Handle::Memory { bytes, .. } => bytes.to_vec(), Handle::Path { path, .. } => std::fs::read(path)?, }; const UI_FONT: &str = "System Sans Serif"; fonts .font_data .insert(UI_FONT.to_owned(), FontData::from_owned(buf)); if let Some(vec) = fonts.families.get_mut(&FontFamily::Proportional) { vec.insert(0, UI_FONT.to_owned()); } if let Some(vec) = fonts.families.get_mut(&FontFamily::Monospace) { vec.insert(0, UI_FONT.to_owned()); } ctx.set_fonts(fonts); Ok(()) } struct App { tester: Tester, } impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show(ctx, |ui| { self.tester.show(ctx, ui); }); } } ================================================ FILE: src/crusader-gui-lib/Cargo.toml ================================================ [package] name = "crusader-gui-lib" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] toml = "0.5.9" serde = { version = "1.0.137", features = ["derive"] } crusader-lib = { path = "../crusader-lib", features = ["server", "client"] } tokio = { version = "1.18.2", features = ["full"] } eframe = "0.28.1" egui_plot = "0.28.1" egui_extras = { version = "0.28.1", default-features = false } open = "5.3.0" [target.'cfg(not(target_os = "android"))'.dependencies] rfd = { version = "0.10.0", default-features = false, features = [ "xdg-portal", ] } ================================================ FILE: src/crusader-gui-lib/src/client.rs ================================================ use crate::{Tab, Tester}; use crusader_lib::{ file_format::RawResult, protocol, test::{self}, with_time, Config, }; use eframe::{ egui::{self, vec2, Grid, ScrollArea, TextEdit, Ui}, emath::Align, }; use serde::{Deserialize, Serialize}; use std::{mem, sync::Arc, time::Duration}; use tokio::sync::{ mpsc::{self}, oneshot, }; #[derive(Serialize, Deserialize, Clone, PartialEq)] #[serde(default)] pub struct ClientSettings { pub server: String, pub download: bool, pub upload: bool, pub bidirectional: bool, pub streams: u64, pub load_duration: f64, pub grace_duration: f64, pub stream_stagger: f64, pub latency_sample_interval: u64, pub throughput_sample_interval: u64, pub latency_peer: bool, pub latency_peer_server: String, pub advanced: bool, pub idle_test: bool, pub idle_duration: f64, } impl ClientSettings { fn config(&self) -> Config { Config { port: protocol::PORT, streams: self.streams, grace_duration: Duration::from_secs_f64(self.grace_duration), load_duration: Duration::from_secs_f64(self.load_duration), stream_stagger: Duration::from_secs_f64(self.stream_stagger), download: self.download, upload: self.upload, bidirectional: self.bidirectional, ping_interval: Duration::from_millis(self.latency_sample_interval), throughput_interval: Duration::from_millis(self.throughput_sample_interval), } } } impl Default for ClientSettings { fn default() -> Self { Self { server: String::new(), download: true, upload: true, bidirectional: true, streams: 8, load_duration: 10.0, grace_duration: 2.0, stream_stagger: 0.0, latency_sample_interval: 5, throughput_sample_interval: 60, latency_peer: false, latency_peer_server: String::new(), advanced: false, idle_test: false, idle_duration: 10.0, } } } pub struct Client { rx: mpsc::UnboundedReceiver, pub done: Option>>>, pub abort: Option>, } #[derive(PartialEq, Eq)] pub enum ClientState { Stopped, Stopping, Running, } impl Tester { fn start_client(&mut self, ctx: &egui::Context) { self.save_settings(); self.msgs.clear(); self.msg_scrolled = 0; let (signal_done, done) = oneshot::channel(); let (tx, rx) = mpsc::unbounded_channel(); let ctx = ctx.clone(); let ctx_ = ctx.clone(); let config = if self.settings.client.idle_test { let mut config = ClientSettings::default().config(); config.grace_duration = Duration::from_secs_f64(self.settings.client.idle_duration); config.ping_interval = Duration::from_millis(self.settings.client.latency_sample_interval); config.bidirectional = false; config.download = false; config.upload = false; config } else { self.settings.client.config() }; let abort = test::test_callback( config, (!self.settings.client.server.trim().is_empty()) .then_some(&self.settings.client.server), self.settings.client.latency_peer.then_some( (!self.settings.client.latency_peer_server.trim().is_empty()) .then_some(&self.settings.client.latency_peer_server), ), Arc::new(move |msg| { tx.send(with_time(msg)).unwrap(); ctx.request_repaint(); }), Box::new(move |result| { signal_done.send(result).map_err(|_| ()).unwrap(); ctx_.request_repaint(); }), ); self.client = Some(Client { done: Some(done), rx, abort: Some(abort), }); self.client_state = ClientState::Running; } fn idle_settings(&mut self, ui: &mut Ui) { Grid::new("idle-settings").show(ui, |ui| { ui.label("Duration: "); ui.add( egui::DragValue::new(&mut self.settings.client.idle_duration) .range(0..=1000) .speed(0.05), ); ui.label("seconds"); ui.end_row(); if self.settings.client.advanced { ui.label("Latency sample interval:"); ui.add( egui::DragValue::new(&mut self.settings.client.latency_sample_interval) .range(1..=1000) .speed(0.05), ); ui.label("milliseconds"); ui.end_row(); } }); if self.settings.client.advanced { ui.separator(); ui.horizontal_wrapped(|ui| { ui.checkbox(&mut self.settings.client.latency_peer, "Latency peer:").on_hover_text("Specifies another server (peer) which will also measure the latency to the server independently of the client"); ui.add_enabled_ui(self.settings.client.latency_peer, |ui| { ui.add( TextEdit::singleline(&mut self.settings.client.latency_peer_server) .hint_text("(Locate local peer)"), ); }); }); } ui.separator(); if !self.settings.client.advanced { let mut any = false; let config = self.settings.client.clone(); let default = ClientSettings::default(); if config.latency_sample_interval != default.latency_sample_interval { any = true; ui.label(format!( "Latency sample interval: {:.2} milliseconds", config.latency_sample_interval )); } if config.latency_peer != default.latency_peer { any = true; let server = (!config.latency_peer_server.trim().is_empty()) .then_some(&*config.latency_peer_server); ui.label(format!("Latency peer: {}", server.unwrap_or(""))); } if any { ui.separator(); } } } fn latency_under_load_settings(&mut self, ui: &mut Ui, compact: bool) { if !self.settings.client.advanced || compact { ui.horizontal_wrapped(|ui| { ui.checkbox(&mut self.settings.client.download, "Download") .on_hover_text("Run a download test"); ui.add_space(10.0); ui.checkbox(&mut self.settings.client.upload, "Upload") .on_hover_text("Run an upload test"); ui.add_space(10.0); ui.checkbox(&mut self.settings.client.bidirectional, "Bidirectional") .on_hover_text("Run a test doing both download and upload"); }); Grid::new("settings-compact").show(ui, |ui| { ui.label("Streams: ").on_hover_text( "The number of TCP connections used to generate traffic in a single direction", ); ui.add( egui::DragValue::new(&mut self.settings.client.streams) .range(1..=1000) .speed(0.05), ); ui.end_row(); ui.label("Load duration: ") .on_hover_text("The duration in which traffic is generated"); ui.add( egui::DragValue::new(&mut self.settings.client.load_duration) .range(0..=1000) .speed(0.05), ); ui.label("seconds"); ui.end_row(); if self.settings.client.advanced { ui.label("Grace duration: ") .on_hover_text("The idle time between each test"); ui.add( egui::DragValue::new(&mut self.settings.client.grace_duration) .range(0..=1000) .speed(0.05), ); ui.label("seconds"); ui.end_row(); ui.label("Stream stagger: ") .on_hover_text("The delay between the start of each stream"); ui.add( egui::DragValue::new(&mut self.settings.client.stream_stagger) .range(0..=1000) .speed(0.05), ); ui.label("seconds"); ui.end_row(); ui.label("Latency sample interval:"); ui.add( egui::DragValue::new(&mut self.settings.client.latency_sample_interval) .range(1..=1000) .speed(0.05), ); ui.label("milliseconds"); ui.end_row(); ui.label("Throughput sample interval:"); ui.add( egui::DragValue::new(&mut self.settings.client.throughput_sample_interval) .range(1..=1000) .speed(0.05), ); ui.label("milliseconds"); ui.end_row(); } }); } else { Grid::new("settings").show(ui, |ui| { ui.checkbox(&mut self.settings.client.download, "Download") .on_hover_text("Run a download test"); ui.allocate_space(vec2(1.0, 1.0)); ui.label("Streams: ").on_hover_text( "The number of TCP connections used to generate traffic in a single direction", ); ui.add( egui::DragValue::new(&mut self.settings.client.streams) .range(1..=1000) .speed(0.05), ); ui.label(""); ui.allocate_space(vec2(1.0, 1.0)); ui.label("Stream stagger: ") .on_hover_text("The delay between the start of each stream"); ui.add( egui::DragValue::new(&mut self.settings.client.stream_stagger) .range(0..=1000) .speed(0.05), ); ui.label("seconds"); ui.end_row(); ui.checkbox(&mut self.settings.client.upload, "Upload") .on_hover_text("Run an upload test"); ui.label(""); ui.label("Load duration: ") .on_hover_text("The duration in which traffic is generated"); ui.add( egui::DragValue::new(&mut self.settings.client.load_duration) .range(0..=1000) .speed(0.05), ); ui.label("seconds"); ui.label(""); ui.label("Latency sample interval: "); ui.add( egui::DragValue::new(&mut self.settings.client.latency_sample_interval) .range(1..=1000) .speed(0.05), ); ui.label("milliseconds"); ui.end_row(); ui.checkbox(&mut self.settings.client.bidirectional, "Bidirectional") .on_hover_text("Run a test doing both download and upload"); ui.label(""); ui.label("Grace duration: ") .on_hover_text("The idle time between each test"); ui.add( egui::DragValue::new(&mut self.settings.client.grace_duration) .range(0..=1000) .speed(0.05), ); ui.label("seconds"); ui.label(""); ui.label("Throughput sample interval: "); ui.add( egui::DragValue::new(&mut self.settings.client.throughput_sample_interval) .range(1..=1000) .speed(0.05), ); ui.label("milliseconds"); ui.end_row(); }); } if self.settings.client.advanced { ui.separator(); ui.horizontal_wrapped(|ui| { ui.checkbox(&mut self.settings.client.latency_peer, "Latency peer:").on_hover_text("Specifies another server (peer) which will also measure the latency to the server independently of the client"); ui.add_enabled_ui(self.settings.client.latency_peer, |ui| { ui.add( TextEdit::singleline(&mut self.settings.client.latency_peer_server) .hint_text("(Locate local peer)"), ); }); }); } ui.separator(); if !self.settings.client.advanced { let mut any = false; let config = self.settings.client.clone(); let default = ClientSettings::default(); if config.grace_duration != default.grace_duration { any = true; ui.label(format!( "Grace duration: {:.2} seconds", config.grace_duration )); } if config.stream_stagger != default.stream_stagger { any = true; ui.label(format!( "Stream stagger: {:.2} seconds", config.stream_stagger )); } if config.latency_sample_interval != default.latency_sample_interval { any = true; ui.label(format!( "Latency sample interval: {:.2} milliseconds", config.latency_sample_interval )); } if config.throughput_sample_interval != default.throughput_sample_interval { any = true; ui.label(format!( "Throughput sample interval: {:.2} milliseconds", config.throughput_sample_interval )); } if config.latency_peer != default.latency_peer { any = true; let server = (!config.latency_peer_server.trim().is_empty()) .then_some(&*config.latency_peer_server); ui.label(format!("Latency peer: {}", server.unwrap_or(""))); } if any { ui.separator(); } } } pub fn client(&mut self, ctx: &egui::Context, ui: &mut Ui, compact: bool) { let active = self.client_state == ClientState::Stopped; ui.horizontal_wrapped(|ui| { ui.add_enabled_ui(active, |ui| { ui.label("Server address:"); let response = ui.add( TextEdit::singleline(&mut self.settings.client.server) .hint_text("(Locate local server)"), ); if self.client_state == ClientState::Stopped && response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { self.start_client(ctx) } }); match self.client_state { ClientState::Running => { if ui.button("Stop test").clicked() { let client = self.client.as_mut().unwrap(); mem::take(&mut client.abort).unwrap().send(()).unwrap(); self.client_state = ClientState::Stopping; } } ClientState::Stopping => { ui.add_enabled_ui(false, |ui| { let _ = ui.button("Stopping test.."); }); } ClientState::Stopped => { if ui.button("Start test").clicked() { self.start_client(ctx) } } } }); ui.separator(); ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { ui.add_enabled_ui(active, |ui| { ui.horizontal(|ui| { ui.label("Measure:"); ui.selectable_value( &mut self.settings.client.idle_test, false, "Latency under load", ); ui.selectable_value( &mut self.settings.client.idle_test, true, "Latency only", ); }); ui.separator(); if self.settings.client.idle_test { self.idle_settings(ui); } else { self.latency_under_load_settings(ui, compact); } ui.horizontal(|ui| { let mut default = ClientSettings::default(); default.idle_test = self.settings.client.idle_test; default.advanced = self.settings.client.advanced; default.server = self.settings.client.server.clone(); default.latency_peer_server = self.settings.client.latency_peer_server.clone(); let parameters_changed = self.settings.client != default; ui.add_enabled_ui(parameters_changed, |ui| { if ui.button("Reset settings").clicked() { self.settings.client = default; } }); ui.toggle_value(&mut self.settings.client.advanced, "Advanced mode"); }) }); if self.client_state == ClientState::Running || self.client_state == ClientState::Stopping { let client = self.client.as_mut().unwrap(); while let Ok(msg) = client.rx.try_recv() { println!("[Client] {msg}"); self.msgs.push(msg); } if let Ok(result) = client.done.as_mut().unwrap().try_recv() { match result { Some(Ok(result)) => { self.msgs.push(with_time("Test complete")); let result = result.to_test_result(); self.set_result(result); if self.tab == Tab::Client { self.tab = Tab::Result; } } Some(Err(error)) => { self.msgs.push(with_time(&format!("Error: {error}"))); } None => { self.msgs.push(with_time("Aborted...")); } } self.client = None; self.client_state = ClientState::Stopped; } } if !self.msgs.is_empty() { ui.separator(); } for (i, msg) in self.msgs.iter().enumerate() { let response = ui.label(msg); if self.msg_scrolled <= i { self.msg_scrolled = i + 1; response.scroll_to_me(Some(Align::Max)); } } }); } } ================================================ FILE: src/crusader-gui-lib/src/lib.rs ================================================ #![allow( clippy::field_reassign_with_default, clippy::option_map_unit_fn, clippy::type_complexity, clippy::too_many_arguments )] use std::ffi::OsStr; use std::hash::Hash; use std::{ fs, mem, path::{Path, PathBuf}, sync::Arc, time::Duration, }; use client::{Client, ClientSettings, ClientState}; use crusader_lib::plot::{smooth, LatencySummary}; use crusader_lib::test::timed; use crusader_lib::{ file_format::{RawPing, RawResult, TestKind}, latency, plot::{self, float_max, to_rates}, protocol, remote, serve, test::{self, PlotConfig}, with_time, }; use eframe::egui::{AboveOrBelow, Label, Layout, TextWrapMode}; use eframe::{ egui::{ self, Grid, Id, PopupCloseBehavior, RichText, ScrollArea, TextEdit, TextStyle, Ui, Vec2b, }, emath::Align, epaint::Color32, }; use egui_extras::{Size, Strip, StripBuilder}; use egui_plot::{ColorConflictHandling, Legend, Line, Plot, PlotPoints}; #[cfg(not(target_os = "android"))] use rfd::FileDialog; use serde::{Deserialize, Serialize}; use tokio::sync::{ mpsc::{self, error::TryRecvError}, oneshot, }; mod client; struct Server { done: Option>, msgs: Vec, rx: mpsc::UnboundedReceiver, stop: Option>, started: oneshot::Receiver>, } enum ServerState { Stopped(Option), Starting, Stopping, Running, } struct Latency { done: Option>>>, abort: Option>, } #[derive(PartialEq, Eq)] enum Tab { Client, Server, Remote, Monitor, Result, } #[derive(Serialize, Deserialize, Clone, PartialEq)] #[serde(default)] pub struct LatencyMonitorSettings { pub server: String, pub history: f64, pub latency_sample_interval: u64, } impl Default for LatencyMonitorSettings { fn default() -> Self { Self { server: "".to_owned(), history: 60.0, latency_sample_interval: 5, } } } #[derive(Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(default)] pub struct Settings { pub client: ClientSettings, pub latency_monitor: LatencyMonitorSettings, } impl Settings { fn from_path(path: &Path) -> Self { fs::read_to_string(path) .ok() .and_then(|data| toml::from_str(&data).ok()) .unwrap_or_default() } } pub struct Tester { tab: Tab, settings: Settings, settings_path: Option, saved_settings: Settings, server_state: ServerState, server: Option, remote_state: ServerState, remote_server: Option, client_state: ClientState, client: Option, result_plot_reset: bool, result: Option, raw_result_saved: Option, open_result: Vec, result_name: String, msgs: Vec, msg_scrolled: usize, pub file_loader: Option>, pub plot_saver: Option>, pub raw_saver: Option>, latency_state: ClientState, latency: Option, latency_data: Arc, latency_stop: Duration, latency_error: Option, latency_plot_reset: bool, } pub struct LatencyResult { total: Vec<(f64, f64)>, max: f64, up: Vec<(f64, f64)>, down: Vec<(f64, f64)>, loss: Vec<(f64, Option)>, } impl LatencyResult { fn new(result: &plot::TestResult, pings: &[RawPing]) -> Self { let start = result.start.as_secs_f64(); let total: Vec<_> = pings .iter() .filter(|p| p.sent >= result.start) .filter_map(|p| { p.latency.and_then(|latency| { latency .total .map(|total| (p.sent.as_secs_f64() - start, total.as_secs_f64() * 1000.0)) }) }) .collect(); let up: Vec<_> = pings .iter() .filter(|p| p.sent >= result.start) .filter_map(|p| { p.latency.map(|latency| { ( p.sent.as_secs_f64() - start, latency.up.as_secs_f64() * 1000.0, ) }) }) .collect(); let down: Vec<_> = pings .iter() .filter(|p| p.sent >= result.start) .filter_map(|p| { p.latency.and_then(|latency| { latency .down() .map(|down| (p.sent.as_secs_f64() - start, down.as_secs_f64() * 1000.0)) }) }) .collect(); let loss = pings .iter() .filter(|p| p.sent >= result.start) .filter_map(|ping| { if ping.latency.and_then(|latency| latency.total).is_none() { let down_loss = (result.raw_result.version >= 2).then_some(ping.latency.is_some()); Some((ping.sent.as_secs_f64() - start, down_loss)) } else { None } }) .collect(); let max = float_max(total.iter().map(|v| v.1)); LatencyResult { total, up, down, loss, max, } } } pub struct TestResult { result: plot::TestResult, download: Option>, download_avg: Option>, upload: Option>, upload_avg: Option>, both_download: Option>, both_download_avg: Option>, both_upload: Option>, both_upload_avg: Option>, both: Option>, both_avg: Option>, local_latency: LatencyResult, peer_latency: Option, throughput_max: f64, } impl TestResult { fn new(result: plot::TestResult) -> Self { let smooth_interval = Duration::from_secs_f64(1.0).min(result.raw_result.config.grace_duration); let interval = result.raw_result.config.bandwidth_interval; let start = result.start.as_secs_f64(); let download = result .download_bytes .as_ref() .map(|bytes| handle_bytes(bytes, start)); let download_avg = result .download_bytes .as_ref() .map(|bytes| smooth_bytes(bytes, start, interval, smooth_interval)); let upload = result .upload_bytes .as_ref() .map(|bytes| handle_bytes(bytes, start)); let upload_avg = result .upload_bytes .as_ref() .map(|bytes| smooth_bytes(bytes, start, interval, smooth_interval)); let both_upload = result .both_upload_bytes .as_ref() .map(|bytes| handle_bytes(bytes, start)); let both_upload_avg = result .both_upload_bytes .as_ref() .map(|bytes| smooth_bytes(bytes, start, interval, smooth_interval)); let both_download = result .both_download_bytes .as_ref() .map(|bytes| handle_bytes(bytes, start)); let both_download_avg = result .both_download_bytes .as_ref() .map(|bytes| smooth_bytes(bytes, start, interval, smooth_interval)); let both = result .both_bytes .as_ref() .map(|bytes| handle_bytes(bytes, start)); let both_avg = result .both_bytes .as_ref() .map(|bytes| smooth_bytes(bytes, start, interval, smooth_interval)); let download_max = download .as_ref() .map(|data| float_max(data.iter().map(|v| v.1))); let upload_max = upload .as_ref() .map(|data| float_max(data.iter().map(|v| v.1))); let both_upload_max = both_upload .as_ref() .map(|data| float_max(data.iter().map(|v| v.1))); let both_download_max = both_download .as_ref() .map(|data| float_max(data.iter().map(|v| v.1))); let both_max = both .as_ref() .map(|data| float_max(data.iter().map(|v| v.1))); let throughput_max = float_max( [ download_max, upload_max, both_upload_max, both_download_max, both_max, ] .into_iter() .flatten(), ); TestResult { download, download_avg, upload, upload_avg, both_download, both_download_avg, both_upload, both_upload_avg, both, both_avg, throughput_max, local_latency: LatencyResult::new(&result, &result.pings), peer_latency: result .raw_result .peer_pings .as_ref() .map(|pings| LatencyResult::new(&result, pings)), result, } } } pub fn handle_bytes(data: &[(u64, f64)], start: f64) -> Vec<(f64, f64)> { to_rates(data) .into_iter() .map(|(time, speed)| (Duration::from_micros(time).as_secs_f64() - start, speed)) .collect() } pub fn smooth_bytes( data: &[(u64, f64)], start: f64, interval: Duration, smoothing_interval: Duration, ) -> Vec<(f64, f64)> { smooth(data, interval, smoothing_interval) .into_iter() .map(|(time, speed)| (Duration::from_micros(time).as_secs_f64() - start, speed)) .collect() } fn hover_popup( ui: &mut Ui, id_source: impl Hash, position: AboveOrBelow, popup: impl FnOnce(&mut Ui), ) { ui.scope(|ui| { let id = ui.make_persistent_id(id_source); ui.spacing_mut().interact_size.y = 18.0; let active = id.with("active"); let active_value = ui.memory_mut(|mem| { let active = mem.data.get_temp_mut_or_default(active); *active }); let style = ui.style_mut(); style.visuals.widgets.inactive.rounding = 50.0.into(); style.visuals.widgets.hovered.rounding = 50.0.into(); style.visuals.widgets.active.rounding = 50.0.into(); style.visuals.widgets.inactive.fg_stroke.color = Color32::from_gray(140); if active_value { style.visuals.widgets.inactive.weak_bg_fill = style.visuals.selection.bg_fill; style.visuals.widgets.hovered.weak_bg_fill = style.visuals.selection.bg_fill; } let stats = ui.button("i"); let popup_id = id.with("popup"); let contains_pointer = if let Some(pointer) = ui.ctx().input(|input| input.pointer.latest_pos()) { stats.interact_rect.expand(5.0).contains(pointer) } else { false }; if stats.hovered() && contains_pointer { ui.memory_mut(|mem| { if !mem.any_popup_open() { mem.open_popup(popup_id); } }); } else if !active_value && !contains_pointer { ui.memory_mut(|mem| { if mem.is_popup_open(popup_id) { mem.close_popup(); } }); } if stats.clicked() { ui.memory_mut(|mem| { let active: &mut bool = mem.data.get_temp_mut_or_default(active); *active = !*active; if *active { mem.open_popup(popup_id); } else { mem.close_popup(); } }); } egui::popup::popup_above_or_below_widget( ui, popup_id, &stats, position, PopupCloseBehavior::CloseOnClickOutside, |ui| { popup(ui); }, ); ui.memory_mut(|mem| { if !mem.is_popup_open(popup_id) { let active: &mut bool = mem.data.get_temp_mut_or_default(active); if *active { // ui.ctx().request_repaint(); } *active = false; } }); }); } impl Drop for Tester { fn drop(&mut self) { self.save_settings(); // Stop client self.client.as_mut().map(|client| { mem::take(&mut client.abort).map(|abort| { abort.send(()).unwrap(); }); mem::take(&mut client.done).map(|done| { done.blocking_recv().ok(); }); }); // Stop server self.server.as_mut().map(|server| { mem::take(&mut server.stop).map(|stop| { stop.send(()).unwrap(); }); mem::take(&mut server.done).map(|done| { done.blocking_recv().ok(); }); }); // Stop latency self.latency.as_mut().map(|latency| { mem::take(&mut latency.abort).map(|abort| { abort.send(()).unwrap(); }); mem::take(&mut latency.done).map(|done| { done.blocking_recv().ok(); }); }); } } impl Tester { pub fn new(settings_path: Option) -> Tester { let settings = settings_path .as_deref() .map_or(Settings::default(), Settings::from_path); Tester { tab: Tab::Client, saved_settings: settings.clone(), settings, settings_path, client_state: ClientState::Stopped, client: None, result: None, result_plot_reset: false, raw_result_saved: None, result_name: "".to_string(), open_result: Vec::new(), msgs: Vec::new(), msg_scrolled: 0, server_state: ServerState::Stopped(None), server: None, remote_state: ServerState::Stopped(None), remote_server: None, file_loader: None, raw_saver: None, plot_saver: None, latency_state: ClientState::Stopped, latency: None, latency_data: Arc::new(latency::Data::new(0, Arc::new(|| {}))), latency_stop: Duration::from_secs(0), latency_error: None, latency_plot_reset: false, } } pub fn set_result(&mut self, result: plot::TestResult) { self.result = Some(TestResult::new(result)); self.result_name = "test".to_owned(); self.result_plot_reset = true; self.raw_result_saved = None; } pub fn load_file(&mut self, name: PathBuf, raw: RawResult) { self.set_result(raw.to_test_result()); self.raw_result_saved = Some(name); } pub fn save_raw(&mut self, name: PathBuf) { self.raw_result_saved = Some(name); } fn save_settings(&mut self) { if self.settings != self.saved_settings { self.settings_path.as_deref().map(|path| { toml::ser::to_string_pretty(&self.settings) .map(|data| fs::write(path, data.as_bytes())) .ok(); }); self.saved_settings = self.settings.clone(); } } fn load_result(&mut self) { #[cfg(not(target_os = "android"))] { FileDialog::new() .add_filter("Crusader Raw Result", &["crr"]) .add_filter("All files", &["*"]) .pick_file() .map(|file| { RawResult::load(&file).map(|raw| { self.load_file(file, raw); }) }); } let file_loader = self.file_loader.take(); file_loader.as_ref().map(|loader| loader(self)); self.file_loader = file_loader; } fn latency_and_loss( &mut self, strip: &mut Strip<'_, '_>, link: Id, reset: bool, peer: bool, y_axis_size: f32, ) { let result = self.result.as_ref().unwrap(); let data = if peer { result.peer_latency.as_ref().unwrap() } else { &result.local_latency }; let latencies = if peer { &result.result.peer_latencies } else { &result.result.latencies }; let duration = result.result.duration.as_secs_f64() * 1.1; strip.cell(|ui| { ui.horizontal(|ui| { let label = if peer { "Peer latency" } else { "Latency" }; ui.label(label); hover_popup( ui, (label, "Popup"), if !peer && result.result.raw_result.idle() { AboveOrBelow::Below } else { AboveOrBelow::Above }, |ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.spacing_mut().interact_size.y = 10.0; let stats = |ui: &mut Ui, name, color, latency: &LatencySummary| { ui.vertical(|ui| { ui.add_space(5.0); ui.horizontal(|ui| { ui.label(RichText::new(format!("{name}: ")).color(color)); ui.label(format!( "{:.01} ms", latency.total.as_secs_f64() * 1000.0 )); }); ui.horizontal(|ui| { ui.label(format!( "\t\t{:.01} ms ", latency.down.as_secs_f64() * 1000.0 )); ui.label( RichText::new("down").color(Color32::from_rgb(95, 145, 62)), ); }); ui.horizontal(|ui| { ui.label(format!( "\t\t{:.01} ms ", latency.up.as_secs_f64() * 1000.0 )); ui.label( RichText::new("up").color(Color32::from_rgb(37, 83, 169)), ); }); }); }; if let Some(latency) = latencies.latencies.get(&Some(TestKind::Download)) { stats(ui, "Download", Color32::from_rgb(95, 145, 62), latency); } if let Some(latency) = latencies.latencies.get(&Some(TestKind::Upload)) { stats(ui, "Upload", Color32::from_rgb(37, 83, 169), latency); } if let Some(latency) = latencies.latencies.get(&Some(TestKind::Bidirectional)) { stats( ui, "Bidirectional", Color32::from_rgb(149, 96, 153), latency, ); } if let Some(latency) = latencies.latencies.get(&None) { stats(ui, "Latency", Color32::from_rgb(0, 0, 0), latency); } ui.vertical(|ui| { ui.add_space(5.0); ui.horizontal(|ui| { ui.label( RichText::new("Idle latency: ") .color(Color32::from_rgb(128, 128, 128)), ); ui.label(format!( "{:.02} ms", result.result.raw_result.server_latency.as_secs_f64() * 1000.0 )); }); }); ui.vertical(|ui| { ui.add_space(5.0); ui.horizontal(|ui| { ui.label( RichText::new("Latency sample interval: ") .color(Color32::from_rgb(128, 128, 128)), ); ui.label(format!( "{:.02} ms", result.result.raw_result.config.ping_interval.as_secs_f64() * 1000.0 )); }); }); }, ); }); // Latency let mut plot = Plot::new((peer, "ping")) .legend(Legend::default().insertion_order(true)) .y_axis_min_width(y_axis_size) .link_axis(link, true, false) .link_cursor(link, true, false) .include_x(0.0) .include_x(duration) .include_y(0.0) .include_y(data.max * 1.1) .label_formatter(|_, value| { format!("Latency = {:.2} ms\nTime = {:.2} s", value.y, value.x) }); if reset { plot = plot.reset(); } plot.show(ui, |plot_ui| { if result.result.raw_result.version >= 1 { let latency = data.up.iter().map(|v| [v.0, v.1]); let latency = Line::new(PlotPoints::from_iter(latency)) .color(Color32::from_rgb(37, 83, 169)) .name("Up"); plot_ui.line(latency); let latency = data.down.iter().map(|v| [v.0, v.1]); let latency = Line::new(PlotPoints::from_iter(latency)) .color(Color32::from_rgb(95, 145, 62)) .name("Down"); plot_ui.line(latency); } let latency = data.total.iter().map(|v| [v.0, v.1]); let latency = Line::new(PlotPoints::from_iter(latency)) .color(Color32::from_rgb(50, 50, 50)) .name("Round-trip"); plot_ui.line(latency); }); }); strip.cell(|ui| { ui.horizontal(|ui| { let label = if peer { "Peer packet loss" } else { "Packet loss" }; ui.label(label); hover_popup(ui, (label, "Popup"), AboveOrBelow::Above, |ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.spacing_mut().interact_size.y = 10.0; let stats = |ui: &mut Ui, name, color, (down, up): (f64, f64)| { ui.vertical(|ui| { ui.add_space(5.0); ui.horizontal(|ui| { ui.label(RichText::new(format!("{name}: ")).color(color)); if down == 0.0 && up == 0.0 { ui.label("0%"); } else { ui.label(format!( "{:.1$}% ", down * 100.0, if down == 0.0 { 0 } else { 2 } )); ui.label( RichText::new("down").color(Color32::from_rgb(95, 145, 62)), ); ui.label(format!( ", {:.1$}% ", up * 100.0, if up == 0.0 { 0 } else { 2 } )); ui.label( RichText::new("up").color(Color32::from_rgb(37, 83, 169)), ); } }); }); }; if let Some(loss) = latencies.loss.get(&Some(TestKind::Download)) { stats(ui, "Download", Color32::from_rgb(95, 145, 62), *loss); } if let Some(loss) = latencies.loss.get(&Some(TestKind::Upload)) { stats(ui, "Upload", Color32::from_rgb(37, 83, 169), *loss); } if let Some(loss) = latencies.loss.get(&Some(TestKind::Bidirectional)) { stats(ui, "Bidirectional", Color32::from_rgb(149, 96, 153), *loss); } if let Some(loss) = latencies.loss.get(&None) { stats(ui, "Packet loss", Color32::from_rgb(0, 0, 0), *loss); } }); }); // Packet loss let mut plot = Plot::new((peer, "loss")) .legend(Legend::default()) .show_axes([false, true]) .show_grid(Vec2b::new(true, false)) .y_axis_min_width(y_axis_size) .y_axis_formatter(|_, _| String::new()) .link_axis(link, true, false) .link_cursor(link, true, false) .center_y_axis(true) .allow_zoom(false) .allow_boxed_zoom(false) .include_x(0.0) .include_x(duration) .include_y(-1.0) .include_y(1.0) .height(30.0) .label_formatter(|_, value| format!("Time = {:.2} s", value.x)); if reset { plot = plot.reset(); } plot.show(ui, |plot_ui| { for &(loss, down_loss) in &data.loss { let (color, s, e) = down_loss .map(|down_loss| { if down_loss { (Color32::from_rgb(95, 145, 62), 1.0, 0.0) } else { (Color32::from_rgb(37, 83, 169), -1.0, 0.0) } }) .unwrap_or((Color32::from_rgb(193, 85, 85), -1.0, 1.0)); plot_ui.line( Line::new(PlotPoints::from_iter( [[loss, s], [loss, e]].iter().copied(), )) .color(color), ); if down_loss.is_some() { plot_ui.line( Line::new(PlotPoints::from_iter( [[loss, s], [loss, s - s / 5.0]].iter().copied(), )) .width(3.0) .color(color), ); } } }); }); } fn load_popup(&mut self, ui: &mut Ui) { if cfg!(not(target_os = "android")) { ui.add_space(10.0); let popup_id = ui.make_persistent_id("Load-Popup"); let button = ui.button("Open from results"); if button.clicked() { ui.memory_mut(|mem| { mem.toggle_popup(popup_id); if mem.is_popup_open(popup_id) { self.open_result = fs::read_dir("crusader-results") .ok() .map(|dir| { dir.filter_map(|file| { file.ok() .map(|file| file.path()) .filter(|path| path.extension() == Some(OsStr::new("crr"))) }) .collect() }) .unwrap_or_default(); } }); } egui::popup::popup_below_widget( ui, popup_id, &button, PopupCloseBehavior::CloseOnClickOutside, |ui| { ui.set_min_width(300.0); ui.horizontal_wrapped(|ui| { ui.label("Results available in the"); if ui.link("crusader-results").clicked() { open::that("crusader-results").ok(); } ui.label("folder:"); }); ScrollArea::vertical().show(ui, |ui| { ui.with_layout(Layout::top_down_justified(Align::LEFT), |ui| { for file in self.open_result.clone() { if let Some(prefix) = file.file_name().and_then(|stem| stem.to_str()) { if ui.toggle_value(&mut false, prefix).clicked() { ui.memory_mut(|mem| mem.close_popup()); RawResult::load(&file).map(|raw| { self.load_file(file, raw); }); } } } }); }); }, ); } } fn result(&mut self, _ctx: &egui::Context, ui: &mut Ui) { if self.result.is_none() { ui.horizontal_wrapped(|ui| { if ui.button("Open").clicked() { self.load_result(); } self.load_popup(ui); }); ui.separator(); ui.label("No result."); return; } ui.horizontal_wrapped(|ui| { if ui.button("Open").clicked() { self.load_result(); } if ui.button("Save").clicked() { match self.raw_saver.as_ref() { Some(saver) => { saver(&self.result.as_ref().unwrap().result.raw_result); } None => { #[cfg(not(target_os = "android"))] { FileDialog::new() .add_filter("Crusader Raw Result", &["crr"]) .add_filter("All files", &["*"]) .set_file_name(&format!("{}.crr", timed("test"))) .save_file() .map(|file| { if self .result .as_ref() .unwrap() .result .raw_result .save(&file) .is_ok() { self.raw_result_saved = Some(file); } }); } } } } self.load_popup(ui); if cfg!(not(target_os = "android")) { let popup_id = ui.make_persistent_id("Save-Popup"); let button = ui.button("Save to results"); if button.clicked() { ui.memory_mut(|mem| { mem.toggle_popup(popup_id); }); } egui::popup::popup_below_widget( ui, popup_id, &button, PopupCloseBehavior::CloseOnClickOutside, |ui| { ui.set_min_width(250.0); ui.horizontal_wrapped(|ui| { ui.label("This saves both the data and plot in the"); if ui.link("crusader-results").clicked() { open::that("crusader-results").ok(); } ui.label("folder."); }); ui.horizontal(|ui| { ui.label("Name: "); let mut click = ui .add( TextEdit::singleline(&mut self.result_name) .desired_width(175.0), ) .lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); click |= ui.button("Save").clicked(); if click { let name = timed(&self.result_name); self.raw_result_saved = test::save_raw( &self.result.as_ref().unwrap().result.raw_result, &name, Path::new("crusader-results"), ) .ok(); plot::save_graph( &PlotConfig::default(), &self.result.as_ref().unwrap().result, &name, Path::new("crusader-results"), ) .ok(); ui.memory_mut(|mem| { mem.close_popup(); }); } }); }, ); } ui.add_space(10.0); if ui.button("Export plot").clicked() { match self.plot_saver.as_ref() { Some(saver) => { saver(&self.result.as_ref().unwrap().result); } None => { #[cfg(not(target_os = "android"))] { let name = self .raw_result_saved .as_ref() .and_then(|file| { file.file_stem() .unwrap_or_default() .to_str() .map(|s| s.to_owned()) }) .unwrap_or(timed("test")); let mut dialog = FileDialog::new() .add_filter("Portable Network Graphics", &["png"]) .add_filter("All files", &["*"]) .set_file_name(&format!("{}.png", name)); if let Some(file) = self.raw_result_saved.as_ref() { if let Some(parent) = file.parent() { dialog = dialog.set_directory(parent); } } dialog.save_file().map(|file| { if plot::save_graph_to_path( &file, &PlotConfig::default(), &self.result.as_ref().unwrap().result, ) .is_ok() { file.file_name() .unwrap_or_default() .to_str() .map(|s| s.to_owned()); } }); } } } } }); ui.separator(); self.raw_result_saved .as_ref() .and_then(|file| { file.file_name() .unwrap_or_default() .to_str() .map(|s| s.to_owned()) }) .map(|file| { ui.label(format!("Saved as: {file}")); ui.separator(); }); let result = self.result.as_ref().unwrap(); if result.result.raw_result.server_overload { ui.label("Warning: Server overload detected during test. Result should be discarded."); ui.separator(); } if result.result.raw_result.load_termination_timeout { ui.label("Warning: Load termination timed out. There may be residual untracked traffic in the background."); ui.separator(); } let packet_loss_size = 75.0; let result = self.result.as_ref().unwrap(); let link = ui.id().with("result-link"); let mut strip = StripBuilder::new(ui); if result.result.raw_result.streams() > 0 { strip = strip.size(Size::remainder()); } for _ in 0..(1 + result.peer_latency.is_some() as u8) { strip = strip .size(Size::remainder()) .size(Size::exact(packet_loss_size)); } strip.vertical(|mut strip| { let reset = mem::take(&mut self.result_plot_reset); let result = self.result.as_ref().unwrap(); let y_axis_size = 30.0; let duration = result.result.duration.as_secs_f64() * 1.1; if result.result.raw_result.streams() > 0 { strip.cell(|ui| { ui.horizontal(|ui| { ui.label("Throughput"); hover_popup(ui, "Throughput-Popup", AboveOrBelow::Below, |ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.spacing_mut().interact_size.y = 10.0; if let Some(throughput) = result .result .throughputs .get(&(TestKind::Download, TestKind::Download)) { ui.vertical(|ui| { ui.add_space(5.0); ui.horizontal(|ui| { ui.label( RichText::new("Download: ") .color(Color32::from_rgb(95, 145, 62)), ); ui.label(format!("{:.02} Mbps", throughput)); }); }); } if let Some(throughput) = result .result .throughputs .get(&(TestKind::Upload, TestKind::Upload)) { ui.vertical(|ui| { ui.add_space(5.0); ui.horizontal(|ui| { ui.label( RichText::new("Upload: ") .color(Color32::from_rgb(37, 83, 169)), ); ui.label(format!("{:.02} Mbps", throughput)); }); }); } if let Some(throughput) = result .result .throughputs .get(&(TestKind::Bidirectional, TestKind::Bidirectional)) { ui.vertical(|ui| { ui.add_space(5.0); ui.horizontal(|ui| { ui.label( RichText::new("Bidirectional: ") .color(Color32::from_rgb(149, 96, 153)), ); ui.label(format!("{:.02} Mbps ", throughput)); }); if let Some(down) = result .result .throughputs .get(&(TestKind::Bidirectional, TestKind::Download)) { if let Some(up) = result .result .throughputs .get(&(TestKind::Bidirectional, TestKind::Upload)) { ui.horizontal(|ui| { ui.label(format!("\t\t{:.02} Mbps ", down)); ui.label( RichText::new("down") .color(Color32::from_rgb(95, 145, 62)), ); }); ui.horizontal(|ui| { ui.label(format!("\t\t{:.02} Mbps ", up)); ui.label( RichText::new("up") .color(Color32::from_rgb(37, 83, 169)), ); }); } } }); } ui.vertical(|ui| { ui.add_space(5.0); ui.horizontal(|ui| { ui.label( RichText::new("Streams: ") .color(Color32::from_rgb(128, 128, 128)), ); ui.label(format!("{}", result.result.raw_result.streams())); }); }); ui.vertical(|ui| { ui.add_space(5.0); ui.horizontal(|ui| { ui.label( RichText::new("Stream Stagger: ") .color(Color32::from_rgb(128, 128, 128)), ); ui.label(format!( "{:.02} seconds", result.result.raw_result.config.stagger.as_secs_f64() )); }); }); ui.vertical(|ui| { ui.add_space(5.0); ui.horizontal(|ui| { ui.label( RichText::new("Throughput sample interval: ") .color(Color32::from_rgb(128, 128, 128)), ); ui.label(format!( "{:.02} ms", result .result .raw_result .config .bandwidth_interval .as_secs_f64() * 1000.0 )); }); }); }); }); // Throughput let mut plot = Plot::new("result") .legend( Legend::default() .color_conflict_handling(ColorConflictHandling::PickFirst) .insertion_order(true), ) .y_axis_min_width(y_axis_size) .link_axis(link, true, false) .link_cursor(link, true, false) .include_x(0.0) .include_x(duration) .include_y(0.0) .include_y(result.throughput_max * 1.1) .height(ui.available_height()) .label_formatter(|_, value| { format!("Throughput = {:.2} Mbps\nTime = {:.2} s", value.y, value.x) }); if reset { plot = plot.reset(); } plot.show(ui, |plot_ui| { let width = 1.0; if let Some(data) = result.download.as_ref() { let download = data.iter().map(|v| [v.0, v.1]); let download = Line::new(PlotPoints::from_iter(download)) .color(Color32::from_rgb(95, 145, 62)) .width(width) .name("Download"); plot_ui.line(download); } if let Some(data) = result.upload.as_ref() { let upload = data.iter().map(|v| [v.0, v.1]); let upload = Line::new(PlotPoints::from_iter(upload)) .color(Color32::from_rgb(37, 83, 169)) .width(width) .name("Upload"); plot_ui.line(upload); } if let Some(data) = result.both_download.as_ref() { let download = data.iter().map(|v| [v.0, v.1]); let download = Line::new(PlotPoints::from_iter(download)) .color(Color32::from_rgb(95, 145, 62)) .width(width) .name("Download"); plot_ui.line(download); } if let Some(data) = result.both_upload.as_ref() { let upload = data.iter().map(|v| [v.0, v.1]); let upload = Line::new(PlotPoints::from_iter(upload)) .color(Color32::from_rgb(37, 83, 169)) .width(width) .name("Upload"); plot_ui.line(upload); } if let Some(data) = result.both.as_ref() { let both = data.iter().map(|v| [v.0, v.1]); let both = Line::new(PlotPoints::from_iter(both)) .color(Color32::from_rgb(149, 96, 153)) .width(width) .name("Aggregate"); plot_ui.line(both); } // Average lines let darken = 0.5; let alpha = 0.35; if let Some(data) = result.download_avg.as_ref() { let download = data.iter().map(|v| [v.0, v.1]); let download = Line::new(PlotPoints::from_iter(download)) .color( Color32::from_rgb(95, 145, 62) .lerp_to_gamma(Color32::BLACK, darken) .gamma_multiply(alpha), ) .allow_hover(false) .width(3.5) .name("Download"); plot_ui.line(download); } if let Some(data) = result.upload_avg.as_ref() { let upload = data.iter().map(|v| [v.0, v.1]); let upload = Line::new(PlotPoints::from_iter(upload)) .color( Color32::from_rgb(37, 83, 169) .lerp_to_gamma(Color32::BLACK, darken) .gamma_multiply(alpha), ) .allow_hover(false) .width(3.5) .name("Upload"); plot_ui.line(upload); } if let Some(data) = result.both_download_avg.as_ref() { let download = data.iter().map(|v| [v.0, v.1]); let download = Line::new(PlotPoints::from_iter(download)) .color( Color32::from_rgb(95, 145, 62) .lerp_to_gamma(Color32::BLACK, darken) .gamma_multiply(alpha), ) .allow_hover(false) .width(3.5) .name("Download"); plot_ui.line(download); } if let Some(data) = result.both_upload_avg.as_ref() { let upload = data.iter().map(|v| [v.0, v.1]); let upload = Line::new(PlotPoints::from_iter(upload)) .color( Color32::from_rgb(37, 83, 169) .lerp_to_gamma(Color32::BLACK, darken) .gamma_multiply(alpha), ) .allow_hover(false) .width(3.5) .name("Upload"); plot_ui.line(upload); } if let Some(data) = result.both_avg.as_ref() { let both = data.iter().map(|v| [v.0, v.1]); let both = Line::new(PlotPoints::from_iter(both)) .color( Color32::from_rgb(149, 96, 153) .lerp_to_gamma(Color32::BLACK, darken) .gamma_multiply(alpha), ) .allow_hover(false) .width(3.5) .name("Aggregate"); plot_ui.line(both); } }); }) } self.latency_and_loss(&mut strip, link, reset, false, y_axis_size); let result = self.result.as_ref().unwrap(); if result.peer_latency.is_some() { self.latency_and_loss(&mut strip, link, reset, true, y_axis_size); } }); } fn server(&mut self, ctx: &egui::Context, ui: &mut Ui) { match self.server_state { ServerState::Stopped(ref error) => { let (server_button, peer_button) = ui .horizontal_wrapped(|ui| (ui.button("Start server"), ui.button("Start peer"))) .inner; if let Some(error) = error { ui.separator(); ui.label(format!("Unable to start server: {}", error)); } if server_button.clicked() || peer_button.clicked() { let ctx = ctx.clone(); let ctx_ = ctx.clone(); let ctx__ = ctx.clone(); let (tx, rx) = mpsc::unbounded_channel(); let (signal_started, started) = oneshot::channel(); let (signal_done, done) = oneshot::channel(); let stop = serve::serve_until( protocol::PORT, peer_button.clicked(), Box::new(move |msg| { tx.send(with_time(msg)).ok(); ctx.request_repaint(); }), Box::new(move |result| { signal_started.send(result).ok(); ctx_.request_repaint(); }), Box::new(move || { signal_done.send(()).ok(); ctx__.request_repaint(); }), ) .ok(); if let Some(stop) = stop { self.server = Some(Server { done: Some(done), stop: Some(stop), started, rx, msgs: Vec::new(), }); self.server_state = ServerState::Starting; } }; ui.separator(); ui.label(format!( "A server listens on TCP and UDP port {}. It allows clients \ to run tests and measure latency against it. It can also act as a latency peer for tests connecting to another server.", protocol::PORT )); } ServerState::Running => { let server = self.server.as_mut().unwrap(); let button = ui.button("Stop server"); ui.separator(); loop { match server.rx.try_recv() { Ok(msg) => { println!("[Server] {msg}"); server.msgs.push(msg); } Err(TryRecvError::Disconnected) => panic!(), Err(TryRecvError::Empty) => break, } } ScrollArea::vertical() .stick_to_bottom(true) .auto_shrink([false; 2]) .show_rows( ui, ui.text_style_height(&TextStyle::Body), server.msgs.len(), |ui, rows| { for row in rows { ui.label(&server.msgs[row]); } }, ); if button.clicked() { mem::take(&mut server.stop).unwrap().send(()).unwrap(); self.server_state = ServerState::Stopping; }; } ServerState::Starting => { let server = self.server.as_mut().unwrap(); if let Ok(result) = server.started.try_recv() { if let Err(error) = result { self.server_state = ServerState::Stopped(Some(error)); self.server = None; } else { self.server_state = ServerState::Running; } } ui.add_enabled_ui(false, |ui| { let _ = ui.button("Starting.."); }); } ServerState::Stopping => { if let Ok(()) = self .server .as_mut() .unwrap() .done .as_mut() .unwrap() .try_recv() { self.server_state = ServerState::Stopped(None); self.server = None; } ui.add_enabled_ui(false, |ui| { let _ = ui.button("Stopping.."); }); } } } fn remote(&mut self, ctx: &egui::Context, ui: &mut Ui) { match self.remote_state { ServerState::Stopped(ref error) => { let button = ui .vertical(|ui| { let button = ui.button("Start server"); if let Some(error) = error { ui.separator(); ui.label(format!("Unable to start server: {}", error)); } button }) .inner; if button.clicked() { let ctx = ctx.clone(); let ctx_ = ctx.clone(); let ctx__ = ctx.clone(); let (tx, rx) = mpsc::unbounded_channel(); let (signal_started, started) = oneshot::channel(); let (signal_done, done) = oneshot::channel(); let stop = remote::serve_until( protocol::PORT + 1, Box::new(move |msg| { tx.send(with_time(msg)).ok(); ctx.request_repaint(); }), Box::new(move |result| { signal_started.send(result).ok(); ctx_.request_repaint(); }), Box::new(move || { signal_done.send(()).ok(); ctx__.request_repaint(); }), ) .ok(); if let Some(stop) = stop { self.remote_server = Some(Server { done: Some(done), stop: Some(stop), started, rx, msgs: Vec::new(), }); self.remote_state = ServerState::Starting; } }; ui.separator(); ui.label(format!( "A remote server runs a web server on TCP port {}. It allows web clients to remotely start \ tests against other servers.", protocol::PORT + 1 )); } ServerState::Running => { let remote_server = self.remote_server.as_mut().unwrap(); let button = ui.button("Stop server"); ui.separator(); loop { match remote_server.rx.try_recv() { Ok(msg) => { println!("[Remote] {msg}"); remote_server.msgs.push(msg); } Err(TryRecvError::Disconnected) => panic!(), Err(TryRecvError::Empty) => break, } } ScrollArea::vertical() .stick_to_bottom(true) .auto_shrink([false; 2]) .show_rows( ui, ui.text_style_height(&TextStyle::Body), remote_server.msgs.len(), |ui, rows| { for row in rows { ui.label(&remote_server.msgs[row]); } }, ); if button.clicked() { mem::take(&mut remote_server.stop) .unwrap() .send(()) .unwrap(); self.remote_state = ServerState::Stopping; }; } ServerState::Starting => { let remote_server = self.remote_server.as_mut().unwrap(); if let Ok(result) = remote_server.started.try_recv() { if let Err(error) = result { self.remote_state = ServerState::Stopped(Some(error)); self.remote_server = None; } else { self.remote_state = ServerState::Running; } } ui.add_enabled_ui(false, |ui| { let _ = ui.button("Starting.."); }); } ServerState::Stopping => { if let Ok(()) = self .remote_server .as_mut() .unwrap() .done .as_mut() .unwrap() .try_recv() { self.remote_state = ServerState::Stopped(None); self.remote_server = None; } ui.add_enabled_ui(false, |ui| { let _ = ui.button("Stopping.."); }); } } } fn start_monitor(&mut self, ctx: &egui::Context) { self.save_settings(); let (signal_done, done) = oneshot::channel(); let ctx_ = ctx.clone(); let data = Arc::new(latency::Data::new( ((self.settings.latency_monitor.history * 1000.0) / self.settings.latency_monitor.latency_sample_interval as f64) .round() as usize, Arc::new(move || { ctx_.request_repaint(); }), )); let ctx_ = ctx.clone(); let abort = latency::test_callback( latency::Config { port: protocol::PORT, ping_interval: Duration::from_millis( self.settings.latency_monitor.latency_sample_interval, ), }, (!self.settings.latency_monitor.server.trim().is_empty()) .then_some(&self.settings.latency_monitor.server), data.clone(), Box::new(move |result| { signal_done.send(result).map_err(|_| ()).unwrap(); ctx_.request_repaint(); }), ); self.latency = Some(Latency { done: Some(done), abort: Some(abort), }); self.latency_state = ClientState::Running; self.latency_data = data; self.latency_error = None; self.latency_plot_reset = true; } fn monitor(&mut self, ctx: &egui::Context, ui: &mut Ui) { let running = self.latency_state != ClientState::Stopped; if !running { ui.horizontal_wrapped(|ui| { ui.label("Server address:"); let response = ui.add( TextEdit::singleline(&mut self.settings.latency_monitor.server) .hint_text("(Locate local server)"), ); let enter = response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); if ui.button("Start test").clicked() || enter { self.start_monitor(ctx) } }); } if running { ui.horizontal(|ui| { match self.latency_state { ClientState::Running => { if ui.button("Stop test").clicked() || ui.input(|i| i.key_pressed(egui::Key::Space)) { let latency = self.latency.as_mut().unwrap(); mem::take(&mut latency.abort).unwrap().send(()).unwrap(); self.latency_state = ClientState::Stopping; } } ClientState::Stopping => { ui.add_enabled_ui(false, |ui| { let _ = ui.button("Stopping test.."); }); } ClientState::Stopped => {} } let state = match *self.latency_data.state.lock() { latency::State::Connecting => "Connecting..".to_owned(), latency::State::Monitoring { ref at } => format!("Connected to {at}"), latency::State::Syncing => "Synchronizing clocks..".to_owned(), }; ui.add(Label::new(state).wrap_mode(TextWrapMode::Truncate)); let latency = self.latency.as_mut().unwrap(); if let Ok(result) = latency.done.as_mut().unwrap().try_recv() { self.latency_error = match result { Some(Ok(())) => None, Some(Err(error)) => Some(error), None => Some("Aborted...".to_owned()), }; self.latency_stop = self.latency_data.start.elapsed(); self.latency = None; self.latency_state = ClientState::Stopped; } }); } ui.separator(); ui.add_enabled_ui(!running, |ui| { Grid::new("latency-settings-compact").show(ui, |ui| { ui.label("History: "); ui.add( egui::DragValue::new(&mut self.settings.latency_monitor.history) .range(0..=1000) .speed(0.05), ); ui.label("seconds"); ui.end_row(); ui.label("Latency sample interval:"); ui.add( egui::DragValue::new( &mut self.settings.latency_monitor.latency_sample_interval, ) .range(1..=1000) .speed(0.05), ); ui.label("milliseconds"); }); }); ui.separator(); if let Some(error) = self.latency_error.as_ref() { ui.label(format!("Error: {}", error)); ui.separator(); } self.latency_data(ctx, ui); } fn latency_data(&mut self, ctx: &egui::Context, ui: &mut Ui) { ui.vertical(|ui| { let packet_loss_size = 80.0; let height = ui.available_height(); let duration = self.settings.latency_monitor.history; let points = self.latency_data.points.blocking_lock().clone(); let now = if self.latency_state == ClientState::Running { ctx.request_repaint(); self.latency_data.start.elapsed() } else { self.latency_stop } .as_secs_f64(); let reset = mem::take(&mut self.latency_plot_reset); let link = ui.id().with("latency-link"); let y_axis_size = 30.0; // Latency let mut plot = Plot::new("latency-ping") .legend(Legend::default().insertion_order(true)) .link_axis(link, true, false) .link_cursor(link, true, false) .include_x(-duration) .include_x(0.0) .include_x(duration * 0.20) .include_y(0.0) .include_y(10.0) .height(height - packet_loss_size) .y_axis_min_width(y_axis_size) .auto_bounds(Vec2b::new(false, true)) .label_formatter(|_, value| { format!("Latency = {:.2} ms\nTime = {:.2} s", value.y, value.x) }); if reset { plot = plot.reset(); } ui.label("Latency"); plot.show(ui, |plot_ui| { let latency = points.iter().filter_map(|point| { point.up.map(|up| { let up = if let Some(total) = point.total { up.min(total) } else { up }; [point.sent.as_secs_f64() - now, 1000.0 * up.as_secs_f64()] }) }); let latency = Line::new(PlotPoints::from_iter(latency)) .color(Color32::from_rgb(37, 83, 169)) .name("Up"); plot_ui.line(latency); let latency = points.iter().filter_map(|point| { point .up .and_then(|up| point.total.map(|total| total.saturating_sub(up))) .map(|down| [point.sent.as_secs_f64() - now, 1000.0 * down.as_secs_f64()]) }); let latency = Line::new(PlotPoints::from_iter(latency)) .color(Color32::from_rgb(95, 145, 62)) .name("Down"); plot_ui.line(latency); let latency = points.iter().filter_map(|point| { point .total .map(|total| [point.sent.as_secs_f64() - now, 1000.0 * total.as_secs_f64()]) }); let latency = Line::new(PlotPoints::from_iter(latency)) .color(Color32::from_rgb(50, 50, 50)) .name("Round-trip"); plot_ui.line(latency); }); // Packet loss let mut plot = Plot::new("latency-loss") .legend(Legend::default()) .show_axes([false, true]) .show_grid(Vec2b::new(true, false)) .y_axis_min_width(y_axis_size) .y_axis_formatter(|_, _| String::new()) .link_axis(link, true, false) .link_cursor(link, true, false) .center_y_axis(true) .allow_zoom(false) .allow_boxed_zoom(false) .include_x(-duration) .include_x(0.0) .include_x(duration * 0.15) .include_y(-1.0) .include_y(1.0) .height(30.0) .label_formatter(|_, value| format!("Time = {:.2} s", value.x)); if reset { plot = plot.reset(); } ui.label("Packet loss"); plot.show(ui, |plot_ui| { let loss = points .iter() .filter(|point| !point.pending && point.total.is_none()); for point in loss { let loss = point.sent.as_secs_f64() - now; let (color, s, e) = if point.up.is_some() { (Color32::from_rgb(95, 145, 62), 1.0, 0.0) } else { (Color32::from_rgb(37, 83, 169), -1.0, 0.0) }; plot_ui.line( Line::new(PlotPoints::from_iter( [[loss, s], [loss, e]].iter().copied(), )) .color(color), ); plot_ui.line( Line::new(PlotPoints::from_iter( [[loss, s], [loss, s - s / 5.0]].iter().copied(), )) .width(3.0) .color(color), ); } }); }); } pub fn show(&mut self, ctx: &egui::Context, ui: &mut Ui) { ctx.input(|input| { if let Some(file) = input .raw .dropped_files .first() .and_then(|file| file.path.as_deref()) { RawResult::load(file).map(|raw| { self.load_file(file.to_owned(), raw); self.tab = Tab::Result; }); } }); let compact = ui.available_width() < 660.0; ui.horizontal_wrapped(|ui| { ui.selectable_value(&mut self.tab, Tab::Client, "Client"); ui.selectable_value(&mut self.tab, Tab::Server, "Server"); ui.selectable_value(&mut self.tab, Tab::Remote, "Remote"); ui.selectable_value(&mut self.tab, Tab::Monitor, "Monitor"); ui.selectable_value(&mut self.tab, Tab::Result, "Result"); }); ui.separator(); match self.tab { Tab::Client => self.client(ctx, ui, compact), Tab::Server => self.server(ctx, ui), Tab::Remote => self.remote(ctx, ui), Tab::Monitor => self.monitor(ctx, ui), Tab::Result => self.result(ctx, ui), } } } ================================================ FILE: src/crusader-lib/Cargo.toml ================================================ [package] name = "crusader-lib" version = "0.1.0" edition = "2021" build = "build.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] server = [] client = ["dep:plotters", "dep:axum", "dep:image", "dep:snap", "dep:serde_json"] [dependencies] plotters = { version = "0.3.6", default-features = false, optional = true, features = [ "ab_glyph", "bitmap_backend", "line_series", "bitmap_encoder", ] } chrono = "0.4.19" bincode = "1.3.3" serde = { version = "1.0.137", features = ["derive"] } serde_json = { version = "1.0.122", optional = true } rand = "0.8.5" parking_lot = "0.12.0" hostname = "0.4.0" tokio = { version = "1.18.2", features = ["full"] } tokio-util = { version = "0.7.2", features = ["codec"] } futures = "0.3.21" bytes = "1.1.0" snap = { version = "1.0.5", optional = true } rmp-serde = "1.1.0" socket2 = "0.4.6" nix = { version = "0.29.0", features = ["net"] } libc = "0.2" anyhow = "1.0.86" axum = { version = "0.7.5", features = [ "ws", "tokio", "http1", ], default-features = false, optional = true } image = { version = "0.24.9", optional = true } [target."cfg(target_os = \"windows\")".dependencies] ipconfig = { version = "=0.3.2", default-features = false } widestring = "=1.1.0" ================================================ FILE: src/crusader-lib/UFL.txt ================================================ ------------------------------- UBUNTU FONT LICENCE Version 1.0 ------------------------------- PREAMBLE This licence allows the licensed fonts to be used, studied, modified and redistributed freely. The fonts, including any derivative works, can be bundled, embedded, and redistributed provided the terms of this licence are met. The fonts and derivatives, however, cannot be released under any other licence. The requirement for fonts to remain under this licence does not require any document created using the fonts or their derivatives to be published under this licence, as long as the primary purpose of the document is not to be a vehicle for the distribution of the fonts. DEFINITIONS "Font Software" refers to the set of files released by the Copyright Holder(s) under this licence and clearly marked as such. This may include source files, build scripts and documentation. "Original Version" refers to the collection of Font Software components as received under this licence. "Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. "Copyright Holder(s)" refers to all individuals and companies who have a copyright ownership of the Font Software. "Substantially Changed" refers to Modified Versions which can be easily identified as dissimilar to the Font Software by users of the Font Software comparing the Original Version with the Modified Version. To "Propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification and with or without charging a redistribution fee), making available to the public, and in some countries other activities as well. PERMISSION & CONDITIONS This licence does not grant any rights under trademark law and all such rights are reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to propagate the Font Software, subject to the below conditions: 1) Each copy of the Font Software must contain the above copyright notice and this licence. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine- readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 2) The font name complies with the following: (a) The Original Version must retain its name, unmodified. (b) Modified Versions which are Substantially Changed must be renamed to avoid use of the name of the Original Version or similar names entirely. (c) Modified Versions which are not Substantially Changed must be renamed to both (i) retain the name of the Original Version and (ii) add additional naming elements to distinguish the Modified Version from the Original Version. The name of such Modified Versions must be the name of the Original Version, with "derivative X" where X represents the name of the new work, appended to that name. 3) The name(s) of the Copyright Holder(s) and any contributor to the Font Software shall not be used to promote, endorse or advertise any Modified Version, except (i) as required by this licence, (ii) to acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with their explicit written permission. 4) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this licence, and must not be distributed under any other licence. The requirement for fonts to remain under this licence does not affect any document created using the Font Software, except any version of the Font Software extracted from a document created using the Font Software may only be distributed under this licence. TERMINATION This licence becomes null and void if any of the above conditions are not met. DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. ================================================ FILE: src/crusader-lib/assets/vue.js ================================================ /** * vue v3.4.35 * (c) 2018-present Yuxi (Evan) You and Vue contributors * @license MIT **/ /*! #__NO_SIDE_EFFECTS__ */ // @__NO_SIDE_EFFECTS__ function makeMap(str, expectsLowerCase) { const set = new Set(str.split(",")); return expectsLowerCase ? (val) => set.has(val.toLowerCase()) : (val) => set.has(val); } const EMPTY_OBJ = Object.freeze({}) ; const EMPTY_ARR = Object.freeze([]) ; const NOOP = () => { }; const NO = () => false; const isOn = (key) => key.charCodeAt(0) === 111 && key.charCodeAt(1) === 110 && // uppercase letter (key.charCodeAt(2) > 122 || key.charCodeAt(2) < 97); const isModelListener = (key) => key.startsWith("onUpdate:"); const extend = Object.assign; const remove = (arr, el) => { const i = arr.indexOf(el); if (i > -1) { arr.splice(i, 1); } }; const hasOwnProperty$1 = Object.prototype.hasOwnProperty; const hasOwn = (val, key) => hasOwnProperty$1.call(val, key); const isArray = Array.isArray; const isMap = (val) => toTypeString(val) === "[object Map]"; const isSet = (val) => toTypeString(val) === "[object Set]"; const isDate = (val) => toTypeString(val) === "[object Date]"; const isRegExp = (val) => toTypeString(val) === "[object RegExp]"; const isFunction = (val) => typeof val === "function"; const isString = (val) => typeof val === "string"; const isSymbol = (val) => typeof val === "symbol"; const isObject = (val) => val !== null && typeof val === "object"; const isPromise = (val) => { return (isObject(val) || isFunction(val)) && isFunction(val.then) && isFunction(val.catch); }; const objectToString = Object.prototype.toString; const toTypeString = (value) => objectToString.call(value); const toRawType = (value) => { return toTypeString(value).slice(8, -1); }; const isPlainObject = (val) => toTypeString(val) === "[object Object]"; const isIntegerKey = (key) => isString(key) && key !== "NaN" && key[0] !== "-" && "" + parseInt(key, 10) === key; const isReservedProp = /* @__PURE__ */ makeMap( // the leading comma is intentional so empty string "" is also included ",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted" ); const isBuiltInDirective = /* @__PURE__ */ makeMap( "bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo" ); const cacheStringFunction = (fn) => { const cache = /* @__PURE__ */ Object.create(null); return (str) => { const hit = cache[str]; return hit || (cache[str] = fn(str)); }; }; const camelizeRE = /-(\w)/g; const camelize = cacheStringFunction((str) => { return str.replace(camelizeRE, (_, c) => c ? c.toUpperCase() : ""); }); const hyphenateRE = /\B([A-Z])/g; const hyphenate = cacheStringFunction( (str) => str.replace(hyphenateRE, "-$1").toLowerCase() ); const capitalize = cacheStringFunction((str) => { return str.charAt(0).toUpperCase() + str.slice(1); }); const toHandlerKey = cacheStringFunction((str) => { const s = str ? `on${capitalize(str)}` : ``; return s; }); const hasChanged = (value, oldValue) => !Object.is(value, oldValue); const invokeArrayFns = (fns, ...arg) => { for (let i = 0; i < fns.length; i++) { fns[i](...arg); } }; const def = (obj, key, value, writable = false) => { Object.defineProperty(obj, key, { configurable: true, enumerable: false, writable, value }); }; const looseToNumber = (val) => { const n = parseFloat(val); return isNaN(n) ? val : n; }; const toNumber = (val) => { const n = isString(val) ? Number(val) : NaN; return isNaN(n) ? val : n; }; let _globalThis; const getGlobalThis = () => { return _globalThis || (_globalThis = typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : {}); }; const PatchFlagNames = { [1]: `TEXT`, [2]: `CLASS`, [4]: `STYLE`, [8]: `PROPS`, [16]: `FULL_PROPS`, [32]: `NEED_HYDRATION`, [64]: `STABLE_FRAGMENT`, [128]: `KEYED_FRAGMENT`, [256]: `UNKEYED_FRAGMENT`, [512]: `NEED_PATCH`, [1024]: `DYNAMIC_SLOTS`, [2048]: `DEV_ROOT_FRAGMENT`, [-1]: `HOISTED`, [-2]: `BAIL` }; const slotFlagsText = { [1]: "STABLE", [2]: "DYNAMIC", [3]: "FORWARDED" }; const GLOBALS_ALLOWED = "Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error"; const isGloballyAllowed = /* @__PURE__ */ makeMap(GLOBALS_ALLOWED); const range = 2; function generateCodeFrame(source, start = 0, end = source.length) { start = Math.max(0, Math.min(start, source.length)); end = Math.max(0, Math.min(end, source.length)); if (start > end) return ""; let lines = source.split(/(\r?\n)/); const newlineSequences = lines.filter((_, idx) => idx % 2 === 1); lines = lines.filter((_, idx) => idx % 2 === 0); let count = 0; const res = []; for (let i = 0; i < lines.length; i++) { count += lines[i].length + (newlineSequences[i] && newlineSequences[i].length || 0); if (count >= start) { for (let j = i - range; j <= i + range || end > count; j++) { if (j < 0 || j >= lines.length) continue; const line = j + 1; res.push( `${line}${" ".repeat(Math.max(3 - String(line).length, 0))}| ${lines[j]}` ); const lineLength = lines[j].length; const newLineSeqLength = newlineSequences[j] && newlineSequences[j].length || 0; if (j === i) { const pad = start - (count - (lineLength + newLineSeqLength)); const length = Math.max( 1, end > count ? lineLength - pad : end - start ); res.push(` | ` + " ".repeat(pad) + "^".repeat(length)); } else if (j > i) { if (end > count) { const length = Math.max(Math.min(end - count, lineLength), 1); res.push(` | ` + "^".repeat(length)); } count += lineLength + newLineSeqLength; } } break; } } return res.join("\n"); } function normalizeStyle(value) { if (isArray(value)) { const res = {}; for (let i = 0; i < value.length; i++) { const item = value[i]; const normalized = isString(item) ? parseStringStyle(item) : normalizeStyle(item); if (normalized) { for (const key in normalized) { res[key] = normalized[key]; } } } return res; } else if (isString(value) || isObject(value)) { return value; } } const listDelimiterRE = /;(?![^(]*\))/g; const propertyDelimiterRE = /:([^]+)/; const styleCommentRE = /\/\*[^]*?\*\//g; function parseStringStyle(cssText) { const ret = {}; cssText.replace(styleCommentRE, "").split(listDelimiterRE).forEach((item) => { if (item) { const tmp = item.split(propertyDelimiterRE); tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim()); } }); return ret; } function stringifyStyle(styles) { let ret = ""; if (!styles || isString(styles)) { return ret; } for (const key in styles) { const value = styles[key]; if (isString(value) || typeof value === "number") { const normalizedKey = key.startsWith(`--`) ? key : hyphenate(key); ret += `${normalizedKey}:${value};`; } } return ret; } function normalizeClass(value) { let res = ""; if (isString(value)) { res = value; } else if (isArray(value)) { for (let i = 0; i < value.length; i++) { const normalized = normalizeClass(value[i]); if (normalized) { res += normalized + " "; } } } else if (isObject(value)) { for (const name in value) { if (value[name]) { res += name + " "; } } } return res.trim(); } function normalizeProps(props) { if (!props) return null; let { class: klass, style } = props; if (klass && !isString(klass)) { props.class = normalizeClass(klass); } if (style) { props.style = normalizeStyle(style); } return props; } const HTML_TAGS = "html,body,base,head,link,meta,style,title,address,article,aside,footer,header,hgroup,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,option,output,progress,select,textarea,details,dialog,menu,summary,template,blockquote,iframe,tfoot"; const SVG_TAGS = "svg,animate,animateMotion,animateTransform,circle,clipPath,color-profile,defs,desc,discard,ellipse,feBlend,feColorMatrix,feComponentTransfer,feComposite,feConvolveMatrix,feDiffuseLighting,feDisplacementMap,feDistantLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,feGaussianBlur,feImage,feMerge,feMergeNode,feMorphology,feOffset,fePointLight,feSpecularLighting,feSpotLight,feTile,feTurbulence,filter,foreignObject,g,hatch,hatchpath,image,line,linearGradient,marker,mask,mesh,meshgradient,meshpatch,meshrow,metadata,mpath,path,pattern,polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,text,textPath,title,tspan,unknown,use,view"; const MATH_TAGS = "annotation,annotation-xml,maction,maligngroup,malignmark,math,menclose,merror,mfenced,mfrac,mfraction,mglyph,mi,mlabeledtr,mlongdiv,mmultiscripts,mn,mo,mover,mpadded,mphantom,mprescripts,mroot,mrow,ms,mscarries,mscarry,msgroup,msline,mspace,msqrt,msrow,mstack,mstyle,msub,msubsup,msup,mtable,mtd,mtext,mtr,munder,munderover,none,semantics"; const VOID_TAGS = "area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr"; const isHTMLTag = /* @__PURE__ */ makeMap(HTML_TAGS); const isSVGTag = /* @__PURE__ */ makeMap(SVG_TAGS); const isMathMLTag = /* @__PURE__ */ makeMap(MATH_TAGS); const isVoidTag = /* @__PURE__ */ makeMap(VOID_TAGS); const specialBooleanAttrs = `itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`; const isSpecialBooleanAttr = /* @__PURE__ */ makeMap(specialBooleanAttrs); const isBooleanAttr = /* @__PURE__ */ makeMap( specialBooleanAttrs + `,async,autofocus,autoplay,controls,default,defer,disabled,hidden,inert,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected` ); function includeBooleanAttr(value) { return !!value || value === ""; } const isKnownHtmlAttr = /* @__PURE__ */ makeMap( `accept,accept-charset,accesskey,action,align,allow,alt,async,autocapitalize,autocomplete,autofocus,autoplay,background,bgcolor,border,buffered,capture,challenge,charset,checked,cite,class,code,codebase,color,cols,colspan,content,contenteditable,contextmenu,controls,coords,crossorigin,csp,data,datetime,decoding,default,defer,dir,dirname,disabled,download,draggable,dropzone,enctype,enterkeyhint,for,form,formaction,formenctype,formmethod,formnovalidate,formtarget,headers,height,hidden,high,href,hreflang,http-equiv,icon,id,importance,inert,integrity,ismap,itemprop,keytype,kind,label,lang,language,loading,list,loop,low,manifest,max,maxlength,minlength,media,min,multiple,muted,name,novalidate,open,optimum,pattern,ping,placeholder,poster,preload,radiogroup,readonly,referrerpolicy,rel,required,reversed,rows,rowspan,sandbox,scope,scoped,selected,shape,size,sizes,slot,span,spellcheck,src,srcdoc,srclang,srcset,start,step,style,summary,tabindex,target,title,translate,type,usemap,value,width,wrap` ); const isKnownSvgAttr = /* @__PURE__ */ makeMap( `xmlns,accent-height,accumulate,additive,alignment-baseline,alphabetic,amplitude,arabic-form,ascent,attributeName,attributeType,azimuth,baseFrequency,baseline-shift,baseProfile,bbox,begin,bias,by,calcMode,cap-height,class,clip,clipPathUnits,clip-path,clip-rule,color,color-interpolation,color-interpolation-filters,color-profile,color-rendering,contentScriptType,contentStyleType,crossorigin,cursor,cx,cy,d,decelerate,descent,diffuseConstant,direction,display,divisor,dominant-baseline,dur,dx,dy,edgeMode,elevation,enable-background,end,exponent,fill,fill-opacity,fill-rule,filter,filterRes,filterUnits,flood-color,flood-opacity,font-family,font-size,font-size-adjust,font-stretch,font-style,font-variant,font-weight,format,from,fr,fx,fy,g1,g2,glyph-name,glyph-orientation-horizontal,glyph-orientation-vertical,glyphRef,gradientTransform,gradientUnits,hanging,height,href,hreflang,horiz-adv-x,horiz-origin-x,id,ideographic,image-rendering,in,in2,intercept,k,k1,k2,k3,k4,kernelMatrix,kernelUnitLength,kerning,keyPoints,keySplines,keyTimes,lang,lengthAdjust,letter-spacing,lighting-color,limitingConeAngle,local,marker-end,marker-mid,marker-start,markerHeight,markerUnits,markerWidth,mask,maskContentUnits,maskUnits,mathematical,max,media,method,min,mode,name,numOctaves,offset,opacity,operator,order,orient,orientation,origin,overflow,overline-position,overline-thickness,panose-1,paint-order,path,pathLength,patternContentUnits,patternTransform,patternUnits,ping,pointer-events,points,pointsAtX,pointsAtY,pointsAtZ,preserveAlpha,preserveAspectRatio,primitiveUnits,r,radius,referrerPolicy,refX,refY,rel,rendering-intent,repeatCount,repeatDur,requiredExtensions,requiredFeatures,restart,result,rotate,rx,ry,scale,seed,shape-rendering,slope,spacing,specularConstant,specularExponent,speed,spreadMethod,startOffset,stdDeviation,stemh,stemv,stitchTiles,stop-color,stop-opacity,strikethrough-position,strikethrough-thickness,string,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,stroke-width,style,surfaceScale,systemLanguage,tabindex,tableValues,target,targetX,targetY,text-anchor,text-decoration,text-rendering,textLength,to,transform,transform-origin,type,u1,u2,underline-position,underline-thickness,unicode,unicode-bidi,unicode-range,units-per-em,v-alphabetic,v-hanging,v-ideographic,v-mathematical,values,vector-effect,version,vert-adv-y,vert-origin-x,vert-origin-y,viewBox,viewTarget,visibility,width,widths,word-spacing,writing-mode,x,x-height,x1,x2,xChannelSelector,xlink:actuate,xlink:arcrole,xlink:href,xlink:role,xlink:show,xlink:title,xlink:type,xmlns:xlink,xml:base,xml:lang,xml:space,y,y1,y2,yChannelSelector,z,zoomAndPan` ); function isRenderableAttrValue(value) { if (value == null) { return false; } const type = typeof value; return type === "string" || type === "number" || type === "boolean"; } function looseCompareArrays(a, b) { if (a.length !== b.length) return false; let equal = true; for (let i = 0; equal && i < a.length; i++) { equal = looseEqual(a[i], b[i]); } return equal; } function looseEqual(a, b) { if (a === b) return true; let aValidType = isDate(a); let bValidType = isDate(b); if (aValidType || bValidType) { return aValidType && bValidType ? a.getTime() === b.getTime() : false; } aValidType = isSymbol(a); bValidType = isSymbol(b); if (aValidType || bValidType) { return a === b; } aValidType = isArray(a); bValidType = isArray(b); if (aValidType || bValidType) { return aValidType && bValidType ? looseCompareArrays(a, b) : false; } aValidType = isObject(a); bValidType = isObject(b); if (aValidType || bValidType) { if (!aValidType || !bValidType) { return false; } const aKeysCount = Object.keys(a).length; const bKeysCount = Object.keys(b).length; if (aKeysCount !== bKeysCount) { return false; } for (const key in a) { const aHasKey = a.hasOwnProperty(key); const bHasKey = b.hasOwnProperty(key); if (aHasKey && !bHasKey || !aHasKey && bHasKey || !looseEqual(a[key], b[key])) { return false; } } } return String(a) === String(b); } function looseIndexOf(arr, val) { return arr.findIndex((item) => looseEqual(item, val)); } const isRef$1 = (val) => { return !!(val && val.__v_isRef === true); }; const toDisplayString = (val) => { return isString(val) ? val : val == null ? "" : isArray(val) || isObject(val) && (val.toString === objectToString || !isFunction(val.toString)) ? isRef$1(val) ? toDisplayString(val.value) : JSON.stringify(val, replacer, 2) : String(val); }; const replacer = (_key, val) => { if (isRef$1(val)) { return replacer(_key, val.value); } else if (isMap(val)) { return { [`Map(${val.size})`]: [...val.entries()].reduce( (entries, [key, val2], i) => { entries[stringifySymbol(key, i) + " =>"] = val2; return entries; }, {} ) }; } else if (isSet(val)) { return { [`Set(${val.size})`]: [...val.values()].map((v) => stringifySymbol(v)) }; } else if (isSymbol(val)) { return stringifySymbol(val); } else if (isObject(val) && !isArray(val) && !isPlainObject(val)) { return String(val); } return val; }; const stringifySymbol = (v, i = "") => { var _a; return ( // Symbol.description in es2019+ so we need to cast here to pass // the lib: es2016 check isSymbol(v) ? `Symbol(${(_a = v.description) != null ? _a : i})` : v ); }; function warn$2(msg, ...args) { console.warn(`[Vue warn] ${msg}`, ...args); } let activeEffectScope; class EffectScope { constructor(detached = false) { this.detached = detached; /** * @internal */ this._active = true; /** * @internal */ this.effects = []; /** * @internal */ this.cleanups = []; this.parent = activeEffectScope; if (!detached && activeEffectScope) { this.index = (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push( this ) - 1; } } get active() { return this._active; } run(fn) { if (this._active) { const currentEffectScope = activeEffectScope; try { activeEffectScope = this; return fn(); } finally { activeEffectScope = currentEffectScope; } } else { warn$2(`cannot run an inactive effect scope.`); } } /** * This should only be called on non-detached scopes * @internal */ on() { activeEffectScope = this; } /** * This should only be called on non-detached scopes * @internal */ off() { activeEffectScope = this.parent; } stop(fromParent) { if (this._active) { let i, l; for (i = 0, l = this.effects.length; i < l; i++) { this.effects[i].stop(); } for (i = 0, l = this.cleanups.length; i < l; i++) { this.cleanups[i](); } if (this.scopes) { for (i = 0, l = this.scopes.length; i < l; i++) { this.scopes[i].stop(true); } } if (!this.detached && this.parent && !fromParent) { const last = this.parent.scopes.pop(); if (last && last !== this) { this.parent.scopes[this.index] = last; last.index = this.index; } } this.parent = void 0; this._active = false; } } } function effectScope(detached) { return new EffectScope(detached); } function recordEffectScope(effect, scope = activeEffectScope) { if (scope && scope.active) { scope.effects.push(effect); } } function getCurrentScope() { return activeEffectScope; } function onScopeDispose(fn) { if (activeEffectScope) { activeEffectScope.cleanups.push(fn); } else { warn$2( `onScopeDispose() is called when there is no active effect scope to be associated with.` ); } } let activeEffect; class ReactiveEffect { constructor(fn, trigger, scheduler, scope) { this.fn = fn; this.trigger = trigger; this.scheduler = scheduler; this.active = true; this.deps = []; /** * @internal */ this._dirtyLevel = 4; /** * @internal */ this._trackId = 0; /** * @internal */ this._runnings = 0; /** * @internal */ this._shouldSchedule = false; /** * @internal */ this._depsLength = 0; recordEffectScope(this, scope); } get dirty() { if (this._dirtyLevel === 2 || this._dirtyLevel === 3) { this._dirtyLevel = 1; pauseTracking(); for (let i = 0; i < this._depsLength; i++) { const dep = this.deps[i]; if (dep.computed) { triggerComputed(dep.computed); if (this._dirtyLevel >= 4) { break; } } } if (this._dirtyLevel === 1) { this._dirtyLevel = 0; } resetTracking(); } return this._dirtyLevel >= 4; } set dirty(v) { this._dirtyLevel = v ? 4 : 0; } run() { this._dirtyLevel = 0; if (!this.active) { return this.fn(); } let lastShouldTrack = shouldTrack; let lastEffect = activeEffect; try { shouldTrack = true; activeEffect = this; this._runnings++; preCleanupEffect(this); return this.fn(); } finally { postCleanupEffect(this); this._runnings--; activeEffect = lastEffect; shouldTrack = lastShouldTrack; } } stop() { if (this.active) { preCleanupEffect(this); postCleanupEffect(this); this.onStop && this.onStop(); this.active = false; } } } function triggerComputed(computed) { return computed.value; } function preCleanupEffect(effect2) { effect2._trackId++; effect2._depsLength = 0; } function postCleanupEffect(effect2) { if (effect2.deps.length > effect2._depsLength) { for (let i = effect2._depsLength; i < effect2.deps.length; i++) { cleanupDepEffect(effect2.deps[i], effect2); } effect2.deps.length = effect2._depsLength; } } function cleanupDepEffect(dep, effect2) { const trackId = dep.get(effect2); if (trackId !== void 0 && effect2._trackId !== trackId) { dep.delete(effect2); if (dep.size === 0) { dep.cleanup(); } } } function effect(fn, options) { if (fn.effect instanceof ReactiveEffect) { fn = fn.effect.fn; } const _effect = new ReactiveEffect(fn, NOOP, () => { if (_effect.dirty) { _effect.run(); } }); if (options) { extend(_effect, options); if (options.scope) recordEffectScope(_effect, options.scope); } if (!options || !options.lazy) { _effect.run(); } const runner = _effect.run.bind(_effect); runner.effect = _effect; return runner; } function stop(runner) { runner.effect.stop(); } let shouldTrack = true; let pauseScheduleStack = 0; const trackStack = []; function pauseTracking() { trackStack.push(shouldTrack); shouldTrack = false; } function resetTracking() { const last = trackStack.pop(); shouldTrack = last === void 0 ? true : last; } function pauseScheduling() { pauseScheduleStack++; } function resetScheduling() { pauseScheduleStack--; while (!pauseScheduleStack && queueEffectSchedulers.length) { queueEffectSchedulers.shift()(); } } function trackEffect(effect2, dep, debuggerEventExtraInfo) { var _a; if (dep.get(effect2) !== effect2._trackId) { dep.set(effect2, effect2._trackId); const oldDep = effect2.deps[effect2._depsLength]; if (oldDep !== dep) { if (oldDep) { cleanupDepEffect(oldDep, effect2); } effect2.deps[effect2._depsLength++] = dep; } else { effect2._depsLength++; } { (_a = effect2.onTrack) == null ? void 0 : _a.call(effect2, extend({ effect: effect2 }, debuggerEventExtraInfo)); } } } const queueEffectSchedulers = []; function triggerEffects(dep, dirtyLevel, debuggerEventExtraInfo) { var _a; pauseScheduling(); for (const effect2 of dep.keys()) { let tracking; if (effect2._dirtyLevel < dirtyLevel && (tracking != null ? tracking : tracking = dep.get(effect2) === effect2._trackId)) { effect2._shouldSchedule || (effect2._shouldSchedule = effect2._dirtyLevel === 0); effect2._dirtyLevel = dirtyLevel; } if (effect2._shouldSchedule && (tracking != null ? tracking : tracking = dep.get(effect2) === effect2._trackId)) { { (_a = effect2.onTrigger) == null ? void 0 : _a.call(effect2, extend({ effect: effect2 }, debuggerEventExtraInfo)); } effect2.trigger(); if ((!effect2._runnings || effect2.allowRecurse) && effect2._dirtyLevel !== 2) { effect2._shouldSchedule = false; if (effect2.scheduler) { queueEffectSchedulers.push(effect2.scheduler); } } } } resetScheduling(); } const createDep = (cleanup, computed) => { const dep = /* @__PURE__ */ new Map(); dep.cleanup = cleanup; dep.computed = computed; return dep; }; const targetMap = /* @__PURE__ */ new WeakMap(); const ITERATE_KEY = Symbol("iterate" ); const MAP_KEY_ITERATE_KEY = Symbol("Map key iterate" ); function track(target, type, key) { if (shouldTrack && activeEffect) { let depsMap = targetMap.get(target); if (!depsMap) { targetMap.set(target, depsMap = /* @__PURE__ */ new Map()); } let dep = depsMap.get(key); if (!dep) { depsMap.set(key, dep = createDep(() => depsMap.delete(key))); } trackEffect( activeEffect, dep, { target, type, key } ); } } function trigger(target, type, key, newValue, oldValue, oldTarget) { const depsMap = targetMap.get(target); if (!depsMap) { return; } let deps = []; if (type === "clear") { deps = [...depsMap.values()]; } else if (key === "length" && isArray(target)) { const newLength = Number(newValue); depsMap.forEach((dep, key2) => { if (key2 === "length" || !isSymbol(key2) && key2 >= newLength) { deps.push(dep); } }); } else { if (key !== void 0) { deps.push(depsMap.get(key)); } switch (type) { case "add": if (!isArray(target)) { deps.push(depsMap.get(ITERATE_KEY)); if (isMap(target)) { deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)); } } else if (isIntegerKey(key)) { deps.push(depsMap.get("length")); } break; case "delete": if (!isArray(target)) { deps.push(depsMap.get(ITERATE_KEY)); if (isMap(target)) { deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)); } } break; case "set": if (isMap(target)) { deps.push(depsMap.get(ITERATE_KEY)); } break; } } pauseScheduling(); for (const dep of deps) { if (dep) { triggerEffects( dep, 4, { target, type, key, newValue, oldValue, oldTarget } ); } } resetScheduling(); } function getDepFromReactive(object, key) { const depsMap = targetMap.get(object); return depsMap && depsMap.get(key); } const isNonTrackableKeys = /* @__PURE__ */ makeMap(`__proto__,__v_isRef,__isVue`); const builtInSymbols = new Set( /* @__PURE__ */ Object.getOwnPropertyNames(Symbol).filter((key) => key !== "arguments" && key !== "caller").map((key) => Symbol[key]).filter(isSymbol) ); const arrayInstrumentations = /* @__PURE__ */ createArrayInstrumentations(); function createArrayInstrumentations() { const instrumentations = {}; ["includes", "indexOf", "lastIndexOf"].forEach((key) => { instrumentations[key] = function(...args) { const arr = toRaw(this); for (let i = 0, l = this.length; i < l; i++) { track(arr, "get", i + ""); } const res = arr[key](...args); if (res === -1 || res === false) { return arr[key](...args.map(toRaw)); } else { return res; } }; }); ["push", "pop", "shift", "unshift", "splice"].forEach((key) => { instrumentations[key] = function(...args) { pauseTracking(); pauseScheduling(); const res = toRaw(this)[key].apply(this, args); resetScheduling(); resetTracking(); return res; }; }); return instrumentations; } function hasOwnProperty(key) { if (!isSymbol(key)) key = String(key); const obj = toRaw(this); track(obj, "has", key); return obj.hasOwnProperty(key); } class BaseReactiveHandler { constructor(_isReadonly = false, _isShallow = false) { this._isReadonly = _isReadonly; this._isShallow = _isShallow; } get(target, key, receiver) { const isReadonly2 = this._isReadonly, isShallow2 = this._isShallow; if (key === "__v_isReactive") { return !isReadonly2; } else if (key === "__v_isReadonly") { return isReadonly2; } else if (key === "__v_isShallow") { return isShallow2; } else if (key === "__v_raw") { if (receiver === (isReadonly2 ? isShallow2 ? shallowReadonlyMap : readonlyMap : isShallow2 ? shallowReactiveMap : reactiveMap).get(target) || // receiver is not the reactive proxy, but has the same prototype // this means the reciever is a user proxy of the reactive proxy Object.getPrototypeOf(target) === Object.getPrototypeOf(receiver)) { return target; } return; } const targetIsArray = isArray(target); if (!isReadonly2) { if (targetIsArray && hasOwn(arrayInstrumentations, key)) { return Reflect.get(arrayInstrumentations, key, receiver); } if (key === "hasOwnProperty") { return hasOwnProperty; } } const res = Reflect.get(target, key, receiver); if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) { return res; } if (!isReadonly2) { track(target, "get", key); } if (isShallow2) { return res; } if (isRef(res)) { return targetIsArray && isIntegerKey(key) ? res : res.value; } if (isObject(res)) { return isReadonly2 ? readonly(res) : reactive(res); } return res; } } class MutableReactiveHandler extends BaseReactiveHandler { constructor(isShallow2 = false) { super(false, isShallow2); } set(target, key, value, receiver) { let oldValue = target[key]; if (!this._isShallow) { const isOldValueReadonly = isReadonly(oldValue); if (!isShallow(value) && !isReadonly(value)) { oldValue = toRaw(oldValue); value = toRaw(value); } if (!isArray(target) && isRef(oldValue) && !isRef(value)) { if (isOldValueReadonly) { return false; } else { oldValue.value = value; return true; } } } const hadKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key); const result = Reflect.set(target, key, value, receiver); if (target === toRaw(receiver)) { if (!hadKey) { trigger(target, "add", key, value); } else if (hasChanged(value, oldValue)) { trigger(target, "set", key, value, oldValue); } } return result; } deleteProperty(target, key) { const hadKey = hasOwn(target, key); const oldValue = target[key]; const result = Reflect.deleteProperty(target, key); if (result && hadKey) { trigger(target, "delete", key, void 0, oldValue); } return result; } has(target, key) { const result = Reflect.has(target, key); if (!isSymbol(key) || !builtInSymbols.has(key)) { track(target, "has", key); } return result; } ownKeys(target) { track( target, "iterate", isArray(target) ? "length" : ITERATE_KEY ); return Reflect.ownKeys(target); } } class ReadonlyReactiveHandler extends BaseReactiveHandler { constructor(isShallow2 = false) { super(true, isShallow2); } set(target, key) { { warn$2( `Set operation on key "${String(key)}" failed: target is readonly.`, target ); } return true; } deleteProperty(target, key) { { warn$2( `Delete operation on key "${String(key)}" failed: target is readonly.`, target ); } return true; } } const mutableHandlers = /* @__PURE__ */ new MutableReactiveHandler(); const readonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler(); const shallowReactiveHandlers = /* @__PURE__ */ new MutableReactiveHandler( true ); const shallowReadonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler(true); const toShallow = (value) => value; const getProto = (v) => Reflect.getPrototypeOf(v); function get(target, key, isReadonly2 = false, isShallow2 = false) { target = target["__v_raw"]; const rawTarget = toRaw(target); const rawKey = toRaw(key); if (!isReadonly2) { if (hasChanged(key, rawKey)) { track(rawTarget, "get", key); } track(rawTarget, "get", rawKey); } const { has: has2 } = getProto(rawTarget); const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive; if (has2.call(rawTarget, key)) { return wrap(target.get(key)); } else if (has2.call(rawTarget, rawKey)) { return wrap(target.get(rawKey)); } else if (target !== rawTarget) { target.get(key); } } function has(key, isReadonly2 = false) { const target = this["__v_raw"]; const rawTarget = toRaw(target); const rawKey = toRaw(key); if (!isReadonly2) { if (hasChanged(key, rawKey)) { track(rawTarget, "has", key); } track(rawTarget, "has", rawKey); } return key === rawKey ? target.has(key) : target.has(key) || target.has(rawKey); } function size(target, isReadonly2 = false) { target = target["__v_raw"]; !isReadonly2 && track(toRaw(target), "iterate", ITERATE_KEY); return Reflect.get(target, "size", target); } function add(value, _isShallow = false) { if (!_isShallow && !isShallow(value) && !isReadonly(value)) { value = toRaw(value); } const target = toRaw(this); const proto = getProto(target); const hadKey = proto.has.call(target, value); if (!hadKey) { target.add(value); trigger(target, "add", value, value); } return this; } function set(key, value, _isShallow = false) { if (!_isShallow && !isShallow(value) && !isReadonly(value)) { value = toRaw(value); } const target = toRaw(this); const { has: has2, get: get2 } = getProto(target); let hadKey = has2.call(target, key); if (!hadKey) { key = toRaw(key); hadKey = has2.call(target, key); } else { checkIdentityKeys(target, has2, key); } const oldValue = get2.call(target, key); target.set(key, value); if (!hadKey) { trigger(target, "add", key, value); } else if (hasChanged(value, oldValue)) { trigger(target, "set", key, value, oldValue); } return this; } function deleteEntry(key) { const target = toRaw(this); const { has: has2, get: get2 } = getProto(target); let hadKey = has2.call(target, key); if (!hadKey) { key = toRaw(key); hadKey = has2.call(target, key); } else { checkIdentityKeys(target, has2, key); } const oldValue = get2 ? get2.call(target, key) : void 0; const result = target.delete(key); if (hadKey) { trigger(target, "delete", key, void 0, oldValue); } return result; } function clear() { const target = toRaw(this); const hadItems = target.size !== 0; const oldTarget = isMap(target) ? new Map(target) : new Set(target) ; const result = target.clear(); if (hadItems) { trigger(target, "clear", void 0, void 0, oldTarget); } return result; } function createForEach(isReadonly2, isShallow2) { return function forEach(callback, thisArg) { const observed = this; const target = observed["__v_raw"]; const rawTarget = toRaw(target); const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive; !isReadonly2 && track(rawTarget, "iterate", ITERATE_KEY); return target.forEach((value, key) => { return callback.call(thisArg, wrap(value), wrap(key), observed); }); }; } function createIterableMethod(method, isReadonly2, isShallow2) { return function(...args) { const target = this["__v_raw"]; const rawTarget = toRaw(target); const targetIsMap = isMap(rawTarget); const isPair = method === "entries" || method === Symbol.iterator && targetIsMap; const isKeyOnly = method === "keys" && targetIsMap; const innerIterator = target[method](...args); const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive; !isReadonly2 && track( rawTarget, "iterate", isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY ); return { // iterator protocol next() { const { value, done } = innerIterator.next(); return done ? { value, done } : { value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value), done }; }, // iterable protocol [Symbol.iterator]() { return this; } }; }; } function createReadonlyMethod(type) { return function(...args) { { const key = args[0] ? `on key "${args[0]}" ` : ``; warn$2( `${capitalize(type)} operation ${key}failed: target is readonly.`, toRaw(this) ); } return type === "delete" ? false : type === "clear" ? void 0 : this; }; } function createInstrumentations() { const mutableInstrumentations2 = { get(key) { return get(this, key); }, get size() { return size(this); }, has, add, set, delete: deleteEntry, clear, forEach: createForEach(false, false) }; const shallowInstrumentations2 = { get(key) { return get(this, key, false, true); }, get size() { return size(this); }, has, add(value) { return add.call(this, value, true); }, set(key, value) { return set.call(this, key, value, true); }, delete: deleteEntry, clear, forEach: createForEach(false, true) }; const readonlyInstrumentations2 = { get(key) { return get(this, key, true); }, get size() { return size(this, true); }, has(key) { return has.call(this, key, true); }, add: createReadonlyMethod("add"), set: createReadonlyMethod("set"), delete: createReadonlyMethod("delete"), clear: createReadonlyMethod("clear"), forEach: createForEach(true, false) }; const shallowReadonlyInstrumentations2 = { get(key) { return get(this, key, true, true); }, get size() { return size(this, true); }, has(key) { return has.call(this, key, true); }, add: createReadonlyMethod("add"), set: createReadonlyMethod("set"), delete: createReadonlyMethod("delete"), clear: createReadonlyMethod("clear"), forEach: createForEach(true, true) }; const iteratorMethods = [ "keys", "values", "entries", Symbol.iterator ]; iteratorMethods.forEach((method) => { mutableInstrumentations2[method] = createIterableMethod(method, false, false); readonlyInstrumentations2[method] = createIterableMethod(method, true, false); shallowInstrumentations2[method] = createIterableMethod(method, false, true); shallowReadonlyInstrumentations2[method] = createIterableMethod( method, true, true ); }); return [ mutableInstrumentations2, readonlyInstrumentations2, shallowInstrumentations2, shallowReadonlyInstrumentations2 ]; } const [ mutableInstrumentations, readonlyInstrumentations, shallowInstrumentations, shallowReadonlyInstrumentations ] = /* @__PURE__ */ createInstrumentations(); function createInstrumentationGetter(isReadonly2, shallow) { const instrumentations = shallow ? isReadonly2 ? shallowReadonlyInstrumentations : shallowInstrumentations : isReadonly2 ? readonlyInstrumentations : mutableInstrumentations; return (target, key, receiver) => { if (key === "__v_isReactive") { return !isReadonly2; } else if (key === "__v_isReadonly") { return isReadonly2; } else if (key === "__v_raw") { return target; } return Reflect.get( hasOwn(instrumentations, key) && key in target ? instrumentations : target, key, receiver ); }; } const mutableCollectionHandlers = { get: /* @__PURE__ */ createInstrumentationGetter(false, false) }; const shallowCollectionHandlers = { get: /* @__PURE__ */ createInstrumentationGetter(false, true) }; const readonlyCollectionHandlers = { get: /* @__PURE__ */ createInstrumentationGetter(true, false) }; const shallowReadonlyCollectionHandlers = { get: /* @__PURE__ */ createInstrumentationGetter(true, true) }; function checkIdentityKeys(target, has2, key) { const rawKey = toRaw(key); if (rawKey !== key && has2.call(target, rawKey)) { const type = toRawType(target); warn$2( `Reactive ${type} contains both the raw and reactive versions of the same object${type === `Map` ? ` as keys` : ``}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.` ); } } const reactiveMap = /* @__PURE__ */ new WeakMap(); const shallowReactiveMap = /* @__PURE__ */ new WeakMap(); const readonlyMap = /* @__PURE__ */ new WeakMap(); const shallowReadonlyMap = /* @__PURE__ */ new WeakMap(); function targetTypeMap(rawType) { switch (rawType) { case "Object": case "Array": return 1 /* COMMON */; case "Map": case "Set": case "WeakMap": case "WeakSet": return 2 /* COLLECTION */; default: return 0 /* INVALID */; } } function getTargetType(value) { return value["__v_skip"] || !Object.isExtensible(value) ? 0 /* INVALID */ : targetTypeMap(toRawType(value)); } function reactive(target) { if (isReadonly(target)) { return target; } return createReactiveObject( target, false, mutableHandlers, mutableCollectionHandlers, reactiveMap ); } function shallowReactive(target) { return createReactiveObject( target, false, shallowReactiveHandlers, shallowCollectionHandlers, shallowReactiveMap ); } function readonly(target) { return createReactiveObject( target, true, readonlyHandlers, readonlyCollectionHandlers, readonlyMap ); } function shallowReadonly(target) { return createReactiveObject( target, true, shallowReadonlyHandlers, shallowReadonlyCollectionHandlers, shallowReadonlyMap ); } function createReactiveObject(target, isReadonly2, baseHandlers, collectionHandlers, proxyMap) { if (!isObject(target)) { { warn$2( `value cannot be made ${isReadonly2 ? "readonly" : "reactive"}: ${String( target )}` ); } return target; } if (target["__v_raw"] && !(isReadonly2 && target["__v_isReactive"])) { return target; } const existingProxy = proxyMap.get(target); if (existingProxy) { return existingProxy; } const targetType = getTargetType(target); if (targetType === 0 /* INVALID */) { return target; } const proxy = new Proxy( target, targetType === 2 /* COLLECTION */ ? collectionHandlers : baseHandlers ); proxyMap.set(target, proxy); return proxy; } function isReactive(value) { if (isReadonly(value)) { return isReactive(value["__v_raw"]); } return !!(value && value["__v_isReactive"]); } function isReadonly(value) { return !!(value && value["__v_isReadonly"]); } function isShallow(value) { return !!(value && value["__v_isShallow"]); } function isProxy(value) { return value ? !!value["__v_raw"] : false; } function toRaw(observed) { const raw = observed && observed["__v_raw"]; return raw ? toRaw(raw) : observed; } function markRaw(value) { if (Object.isExtensible(value)) { def(value, "__v_skip", true); } return value; } const toReactive = (value) => isObject(value) ? reactive(value) : value; const toReadonly = (value) => isObject(value) ? readonly(value) : value; const COMPUTED_SIDE_EFFECT_WARN = `Computed is still dirty after getter evaluation, likely because a computed is mutating its own dependency in its getter. State mutations in computed getters should be avoided. Check the docs for more details: https://vuejs.org/guide/essentials/computed.html#getters-should-be-side-effect-free`; class ComputedRefImpl { constructor(getter, _setter, isReadonly, isSSR) { this.getter = getter; this._setter = _setter; this.dep = void 0; this.__v_isRef = true; this["__v_isReadonly"] = false; this.effect = new ReactiveEffect( () => getter(this._value), () => triggerRefValue( this, this.effect._dirtyLevel === 2 ? 2 : 3 ) ); this.effect.computed = this; this.effect.active = this._cacheable = !isSSR; this["__v_isReadonly"] = isReadonly; } get value() { const self = toRaw(this); if ((!self._cacheable || self.effect.dirty) && hasChanged(self._value, self._value = self.effect.run())) { triggerRefValue(self, 4); } trackRefValue(self); if (self.effect._dirtyLevel >= 2) { if (this._warnRecursive) { warn$2(COMPUTED_SIDE_EFFECT_WARN, ` getter: `, this.getter); } triggerRefValue(self, 2); } return self._value; } set value(newValue) { this._setter(newValue); } // #region polyfill _dirty for backward compatibility third party code for Vue <= 3.3.x get _dirty() { return this.effect.dirty; } set _dirty(v) { this.effect.dirty = v; } // #endregion } function computed$1(getterOrOptions, debugOptions, isSSR = false) { let getter; let setter; const onlyGetter = isFunction(getterOrOptions); if (onlyGetter) { getter = getterOrOptions; setter = () => { warn$2("Write operation failed: computed value is readonly"); } ; } else { getter = getterOrOptions.get; setter = getterOrOptions.set; } const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR); if (debugOptions && !isSSR) { cRef.effect.onTrack = debugOptions.onTrack; cRef.effect.onTrigger = debugOptions.onTrigger; } return cRef; } function trackRefValue(ref2) { var _a; if (shouldTrack && activeEffect) { ref2 = toRaw(ref2); trackEffect( activeEffect, (_a = ref2.dep) != null ? _a : ref2.dep = createDep( () => ref2.dep = void 0, ref2 instanceof ComputedRefImpl ? ref2 : void 0 ), { target: ref2, type: "get", key: "value" } ); } } function triggerRefValue(ref2, dirtyLevel = 4, newVal, oldVal) { ref2 = toRaw(ref2); const dep = ref2.dep; if (dep) { triggerEffects( dep, dirtyLevel, { target: ref2, type: "set", key: "value", newValue: newVal, oldValue: oldVal } ); } } function isRef(r) { return !!(r && r.__v_isRef === true); } function ref(value) { return createRef(value, false); } function shallowRef(value) { return createRef(value, true); } function createRef(rawValue, shallow) { if (isRef(rawValue)) { return rawValue; } return new RefImpl(rawValue, shallow); } class RefImpl { constructor(value, __v_isShallow) { this.__v_isShallow = __v_isShallow; this.dep = void 0; this.__v_isRef = true; this._rawValue = __v_isShallow ? value : toRaw(value); this._value = __v_isShallow ? value : toReactive(value); } get value() { trackRefValue(this); return this._value; } set value(newVal) { const useDirectValue = this.__v_isShallow || isShallow(newVal) || isReadonly(newVal); newVal = useDirectValue ? newVal : toRaw(newVal); if (hasChanged(newVal, this._rawValue)) { const oldVal = this._rawValue; this._rawValue = newVal; this._value = useDirectValue ? newVal : toReactive(newVal); triggerRefValue(this, 4, newVal, oldVal); } } } function triggerRef(ref2) { triggerRefValue(ref2, 4, ref2.value ); } function unref(ref2) { return isRef(ref2) ? ref2.value : ref2; } function toValue(source) { return isFunction(source) ? source() : unref(source); } const shallowUnwrapHandlers = { get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)), set: (target, key, value, receiver) => { const oldValue = target[key]; if (isRef(oldValue) && !isRef(value)) { oldValue.value = value; return true; } else { return Reflect.set(target, key, value, receiver); } } }; function proxyRefs(objectWithRefs) { return isReactive(objectWithRefs) ? objectWithRefs : new Proxy(objectWithRefs, shallowUnwrapHandlers); } class CustomRefImpl { constructor(factory) { this.dep = void 0; this.__v_isRef = true; const { get, set } = factory( () => trackRefValue(this), () => triggerRefValue(this) ); this._get = get; this._set = set; } get value() { return this._get(); } set value(newVal) { this._set(newVal); } } function customRef(factory) { return new CustomRefImpl(factory); } function toRefs(object) { if (!isProxy(object)) { warn$2(`toRefs() expects a reactive object but received a plain one.`); } const ret = isArray(object) ? new Array(object.length) : {}; for (const key in object) { ret[key] = propertyToRef(object, key); } return ret; } class ObjectRefImpl { constructor(_object, _key, _defaultValue) { this._object = _object; this._key = _key; this._defaultValue = _defaultValue; this.__v_isRef = true; } get value() { const val = this._object[this._key]; return val === void 0 ? this._defaultValue : val; } set value(newVal) { this._object[this._key] = newVal; } get dep() { return getDepFromReactive(toRaw(this._object), this._key); } } class GetterRefImpl { constructor(_getter) { this._getter = _getter; this.__v_isRef = true; this.__v_isReadonly = true; } get value() { return this._getter(); } } function toRef(source, key, defaultValue) { if (isRef(source)) { return source; } else if (isFunction(source)) { return new GetterRefImpl(source); } else if (isObject(source) && arguments.length > 1) { return propertyToRef(source, key, defaultValue); } else { return ref(source); } } function propertyToRef(source, key, defaultValue) { const val = source[key]; return isRef(val) ? val : new ObjectRefImpl(source, key, defaultValue); } const TrackOpTypes = { "GET": "get", "HAS": "has", "ITERATE": "iterate" }; const TriggerOpTypes = { "SET": "set", "ADD": "add", "DELETE": "delete", "CLEAR": "clear" }; const stack$1 = []; function pushWarningContext(vnode) { stack$1.push(vnode); } function popWarningContext() { stack$1.pop(); } let isWarning = false; function warn$1(msg, ...args) { if (isWarning) return; isWarning = true; pauseTracking(); const instance = stack$1.length ? stack$1[stack$1.length - 1].component : null; const appWarnHandler = instance && instance.appContext.config.warnHandler; const trace = getComponentTrace(); if (appWarnHandler) { callWithErrorHandling( appWarnHandler, instance, 11, [ // eslint-disable-next-line no-restricted-syntax msg + args.map((a) => { var _a, _b; return (_b = (_a = a.toString) == null ? void 0 : _a.call(a)) != null ? _b : JSON.stringify(a); }).join(""), instance && instance.proxy, trace.map( ({ vnode }) => `at <${formatComponentName(instance, vnode.type)}>` ).join("\n"), trace ] ); } else { const warnArgs = [`[Vue warn]: ${msg}`, ...args]; if (trace.length && // avoid spamming console during tests true) { warnArgs.push(` `, ...formatTrace(trace)); } console.warn(...warnArgs); } resetTracking(); isWarning = false; } function getComponentTrace() { let currentVNode = stack$1[stack$1.length - 1]; if (!currentVNode) { return []; } const normalizedStack = []; while (currentVNode) { const last = normalizedStack[0]; if (last && last.vnode === currentVNode) { last.recurseCount++; } else { normalizedStack.push({ vnode: currentVNode, recurseCount: 0 }); } const parentInstance = currentVNode.component && currentVNode.component.parent; currentVNode = parentInstance && parentInstance.vnode; } return normalizedStack; } function formatTrace(trace) { const logs = []; trace.forEach((entry, i) => { logs.push(...i === 0 ? [] : [` `], ...formatTraceEntry(entry)); }); return logs; } function formatTraceEntry({ vnode, recurseCount }) { const postfix = recurseCount > 0 ? `... (${recurseCount} recursive calls)` : ``; const isRoot = vnode.component ? vnode.component.parent == null : false; const open = ` at <${formatComponentName( vnode.component, vnode.type, isRoot )}`; const close = `>` + postfix; return vnode.props ? [open, ...formatProps(vnode.props), close] : [open + close]; } function formatProps(props) { const res = []; const keys = Object.keys(props); keys.slice(0, 3).forEach((key) => { res.push(...formatProp(key, props[key])); }); if (keys.length > 3) { res.push(` ...`); } return res; } function formatProp(key, value, raw) { if (isString(value)) { value = JSON.stringify(value); return raw ? value : [`${key}=${value}`]; } else if (typeof value === "number" || typeof value === "boolean" || value == null) { return raw ? value : [`${key}=${value}`]; } else if (isRef(value)) { value = formatProp(key, toRaw(value.value), true); return raw ? value : [`${key}=Ref<`, value, `>`]; } else if (isFunction(value)) { return [`${key}=fn${value.name ? `<${value.name}>` : ``}`]; } else { value = toRaw(value); return raw ? value : [`${key}=`, value]; } } function assertNumber(val, type) { if (val === void 0) { return; } else if (typeof val !== "number") { warn$1(`${type} is not a valid number - got ${JSON.stringify(val)}.`); } else if (isNaN(val)) { warn$1(`${type} is NaN - the duration expression might be incorrect.`); } } const ErrorCodes = { "SETUP_FUNCTION": 0, "0": "SETUP_FUNCTION", "RENDER_FUNCTION": 1, "1": "RENDER_FUNCTION", "WATCH_GETTER": 2, "2": "WATCH_GETTER", "WATCH_CALLBACK": 3, "3": "WATCH_CALLBACK", "WATCH_CLEANUP": 4, "4": "WATCH_CLEANUP", "NATIVE_EVENT_HANDLER": 5, "5": "NATIVE_EVENT_HANDLER", "COMPONENT_EVENT_HANDLER": 6, "6": "COMPONENT_EVENT_HANDLER", "VNODE_HOOK": 7, "7": "VNODE_HOOK", "DIRECTIVE_HOOK": 8, "8": "DIRECTIVE_HOOK", "TRANSITION_HOOK": 9, "9": "TRANSITION_HOOK", "APP_ERROR_HANDLER": 10, "10": "APP_ERROR_HANDLER", "APP_WARN_HANDLER": 11, "11": "APP_WARN_HANDLER", "FUNCTION_REF": 12, "12": "FUNCTION_REF", "ASYNC_COMPONENT_LOADER": 13, "13": "ASYNC_COMPONENT_LOADER", "SCHEDULER": 14, "14": "SCHEDULER", "COMPONENT_UPDATE": 15, "15": "COMPONENT_UPDATE" }; const ErrorTypeStrings$1 = { ["sp"]: "serverPrefetch hook", ["bc"]: "beforeCreate hook", ["c"]: "created hook", ["bm"]: "beforeMount hook", ["m"]: "mounted hook", ["bu"]: "beforeUpdate hook", ["u"]: "updated", ["bum"]: "beforeUnmount hook", ["um"]: "unmounted hook", ["a"]: "activated hook", ["da"]: "deactivated hook", ["ec"]: "errorCaptured hook", ["rtc"]: "renderTracked hook", ["rtg"]: "renderTriggered hook", [0]: "setup function", [1]: "render function", [2]: "watcher getter", [3]: "watcher callback", [4]: "watcher cleanup function", [5]: "native event handler", [6]: "component event handler", [7]: "vnode hook", [8]: "directive hook", [9]: "transition hook", [10]: "app errorHandler", [11]: "app warnHandler", [12]: "ref function", [13]: "async component loader", [14]: "scheduler flush", [15]: "component update" }; function callWithErrorHandling(fn, instance, type, args) { try { return args ? fn(...args) : fn(); } catch (err) { handleError(err, instance, type); } } function callWithAsyncErrorHandling(fn, instance, type, args) { if (isFunction(fn)) { const res = callWithErrorHandling(fn, instance, type, args); if (res && isPromise(res)) { res.catch((err) => { handleError(err, instance, type); }); } return res; } if (isArray(fn)) { const values = []; for (let i = 0; i < fn.length; i++) { values.push(callWithAsyncErrorHandling(fn[i], instance, type, args)); } return values; } else { warn$1( `Invalid value type passed to callWithAsyncErrorHandling(): ${typeof fn}` ); } } function handleError(err, instance, type, throwInDev = true) { const contextVNode = instance ? instance.vnode : null; if (instance) { let cur = instance.parent; const exposedInstance = instance.proxy; const errorInfo = ErrorTypeStrings$1[type] ; while (cur) { const errorCapturedHooks = cur.ec; if (errorCapturedHooks) { for (let i = 0; i < errorCapturedHooks.length; i++) { if (errorCapturedHooks[i](err, exposedInstance, errorInfo) === false) { return; } } } cur = cur.parent; } const appErrorHandler = instance.appContext.config.errorHandler; if (appErrorHandler) { pauseTracking(); callWithErrorHandling( appErrorHandler, null, 10, [err, exposedInstance, errorInfo] ); resetTracking(); return; } } logError(err, type, contextVNode, throwInDev); } function logError(err, type, contextVNode, throwInDev = true) { { const info = ErrorTypeStrings$1[type]; if (contextVNode) { pushWarningContext(contextVNode); } warn$1(`Unhandled error${info ? ` during execution of ${info}` : ``}`); if (contextVNode) { popWarningContext(); } if (throwInDev) { throw err; } else { console.error(err); } } } let isFlushing = false; let isFlushPending = false; const queue = []; let flushIndex = 0; const pendingPostFlushCbs = []; let activePostFlushCbs = null; let postFlushIndex = 0; const resolvedPromise = /* @__PURE__ */ Promise.resolve(); let currentFlushPromise = null; const RECURSION_LIMIT = 100; function nextTick(fn) { const p = currentFlushPromise || resolvedPromise; return fn ? p.then(this ? fn.bind(this) : fn) : p; } function findInsertionIndex(id) { let start = flushIndex + 1; let end = queue.length; while (start < end) { const middle = start + end >>> 1; const middleJob = queue[middle]; const middleJobId = getId(middleJob); if (middleJobId < id || middleJobId === id && middleJob.pre) { start = middle + 1; } else { end = middle; } } return start; } function queueJob(job) { if (!queue.length || !queue.includes( job, isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex )) { if (job.id == null) { queue.push(job); } else { queue.splice(findInsertionIndex(job.id), 0, job); } queueFlush(); } } function queueFlush() { if (!isFlushing && !isFlushPending) { isFlushPending = true; currentFlushPromise = resolvedPromise.then(flushJobs); } } function invalidateJob(job) { const i = queue.indexOf(job); if (i > flushIndex) { queue.splice(i, 1); } } function queuePostFlushCb(cb) { if (!isArray(cb)) { if (!activePostFlushCbs || !activePostFlushCbs.includes( cb, cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex )) { pendingPostFlushCbs.push(cb); } } else { pendingPostFlushCbs.push(...cb); } queueFlush(); } function flushPreFlushCbs(instance, seen, i = isFlushing ? flushIndex + 1 : 0) { { seen = seen || /* @__PURE__ */ new Map(); } for (; i < queue.length; i++) { const cb = queue[i]; if (cb && cb.pre) { if (instance && cb.id !== instance.uid) { continue; } if (checkRecursiveUpdates(seen, cb)) { continue; } queue.splice(i, 1); i--; cb(); } } } function flushPostFlushCbs(seen) { if (pendingPostFlushCbs.length) { const deduped = [...new Set(pendingPostFlushCbs)].sort( (a, b) => getId(a) - getId(b) ); pendingPostFlushCbs.length = 0; if (activePostFlushCbs) { activePostFlushCbs.push(...deduped); return; } activePostFlushCbs = deduped; { seen = seen || /* @__PURE__ */ new Map(); } for (postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++) { const cb = activePostFlushCbs[postFlushIndex]; if (checkRecursiveUpdates(seen, cb)) { continue; } if (cb.active !== false) cb(); } activePostFlushCbs = null; postFlushIndex = 0; } } const getId = (job) => job.id == null ? Infinity : job.id; const comparator = (a, b) => { const diff = getId(a) - getId(b); if (diff === 0) { if (a.pre && !b.pre) return -1; if (b.pre && !a.pre) return 1; } return diff; }; function flushJobs(seen) { isFlushPending = false; isFlushing = true; { seen = seen || /* @__PURE__ */ new Map(); } queue.sort(comparator); const check = (job) => checkRecursiveUpdates(seen, job) ; try { for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { const job = queue[flushIndex]; if (job && job.active !== false) { if (check(job)) { continue; } callWithErrorHandling( job, job.i, job.i ? 15 : 14 ); } } } finally { flushIndex = 0; queue.length = 0; flushPostFlushCbs(seen); isFlushing = false; currentFlushPromise = null; if (queue.length || pendingPostFlushCbs.length) { flushJobs(seen); } } } function checkRecursiveUpdates(seen, fn) { if (!seen.has(fn)) { seen.set(fn, 1); } else { const count = seen.get(fn); if (count > RECURSION_LIMIT) { const instance = fn.i; const componentName = instance && getComponentName(instance.type); handleError( `Maximum recursive updates exceeded${componentName ? ` in component <${componentName}>` : ``}. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.`, null, 10 ); return true; } else { seen.set(fn, count + 1); } } } let isHmrUpdating = false; const hmrDirtyComponents = /* @__PURE__ */ new Map(); { getGlobalThis().__VUE_HMR_RUNTIME__ = { createRecord: tryWrap(createRecord), rerender: tryWrap(rerender), reload: tryWrap(reload) }; } const map = /* @__PURE__ */ new Map(); function registerHMR(instance) { const id = instance.type.__hmrId; let record = map.get(id); if (!record) { createRecord(id, instance.type); record = map.get(id); } record.instances.add(instance); } function unregisterHMR(instance) { map.get(instance.type.__hmrId).instances.delete(instance); } function createRecord(id, initialDef) { if (map.has(id)) { return false; } map.set(id, { initialDef: normalizeClassComponent(initialDef), instances: /* @__PURE__ */ new Set() }); return true; } function normalizeClassComponent(component) { return isClassComponent(component) ? component.__vccOpts : component; } function rerender(id, newRender) { const record = map.get(id); if (!record) { return; } record.initialDef.render = newRender; [...record.instances].forEach((instance) => { if (newRender) { instance.render = newRender; normalizeClassComponent(instance.type).render = newRender; } instance.renderCache = []; isHmrUpdating = true; instance.effect.dirty = true; instance.update(); isHmrUpdating = false; }); } function reload(id, newComp) { const record = map.get(id); if (!record) return; newComp = normalizeClassComponent(newComp); updateComponentDef(record.initialDef, newComp); const instances = [...record.instances]; for (let i = 0; i < instances.length; i++) { const instance = instances[i]; const oldComp = normalizeClassComponent(instance.type); let dirtyInstances = hmrDirtyComponents.get(oldComp); if (!dirtyInstances) { if (oldComp !== record.initialDef) { updateComponentDef(oldComp, newComp); } hmrDirtyComponents.set(oldComp, dirtyInstances = /* @__PURE__ */ new Set()); } dirtyInstances.add(instance); instance.appContext.propsCache.delete(instance.type); instance.appContext.emitsCache.delete(instance.type); instance.appContext.optionsCache.delete(instance.type); if (instance.ceReload) { dirtyInstances.add(instance); instance.ceReload(newComp.styles); dirtyInstances.delete(instance); } else if (instance.parent) { instance.parent.effect.dirty = true; queueJob(() => { instance.parent.update(); dirtyInstances.delete(instance); }); } else if (instance.appContext.reload) { instance.appContext.reload(); } else if (typeof window !== "undefined") { window.location.reload(); } else { console.warn( "[HMR] Root or manually mounted instance modified. Full reload required." ); } } queuePostFlushCb(() => { hmrDirtyComponents.clear(); }); } function updateComponentDef(oldComp, newComp) { extend(oldComp, newComp); for (const key in oldComp) { if (key !== "__file" && !(key in newComp)) { delete oldComp[key]; } } } function tryWrap(fn) { return (id, arg) => { try { return fn(id, arg); } catch (e) { console.error(e); console.warn( `[HMR] Something went wrong during Vue component hot-reload. Full reload required.` ); } }; } let devtools$1; let buffer = []; let devtoolsNotInstalled = false; function emit$1(event, ...args) { if (devtools$1) { devtools$1.emit(event, ...args); } else if (!devtoolsNotInstalled) { buffer.push({ event, args }); } } function setDevtoolsHook$1(hook, target) { var _a, _b; devtools$1 = hook; if (devtools$1) { devtools$1.enabled = true; buffer.forEach(({ event, args }) => devtools$1.emit(event, ...args)); buffer = []; } else if ( // handle late devtools injection - only do this if we are in an actual // browser environment to avoid the timer handle stalling test runner exit // (#4815) typeof window !== "undefined" && // some envs mock window but not fully window.HTMLElement && // also exclude jsdom // eslint-disable-next-line no-restricted-syntax !((_b = (_a = window.navigator) == null ? void 0 : _a.userAgent) == null ? void 0 : _b.includes("jsdom")) ) { const replay = target.__VUE_DEVTOOLS_HOOK_REPLAY__ = target.__VUE_DEVTOOLS_HOOK_REPLAY__ || []; replay.push((newHook) => { setDevtoolsHook$1(newHook, target); }); setTimeout(() => { if (!devtools$1) { target.__VUE_DEVTOOLS_HOOK_REPLAY__ = null; devtoolsNotInstalled = true; buffer = []; } }, 3e3); } else { devtoolsNotInstalled = true; buffer = []; } } function devtoolsInitApp(app, version) { emit$1("app:init" /* APP_INIT */, app, version, { Fragment, Text, Comment, Static }); } function devtoolsUnmountApp(app) { emit$1("app:unmount" /* APP_UNMOUNT */, app); } const devtoolsComponentAdded = /* @__PURE__ */ createDevtoolsComponentHook( "component:added" /* COMPONENT_ADDED */ ); const devtoolsComponentUpdated = /* @__PURE__ */ createDevtoolsComponentHook("component:updated" /* COMPONENT_UPDATED */); const _devtoolsComponentRemoved = /* @__PURE__ */ createDevtoolsComponentHook( "component:removed" /* COMPONENT_REMOVED */ ); const devtoolsComponentRemoved = (component) => { if (devtools$1 && typeof devtools$1.cleanupBuffer === "function" && // remove the component if it wasn't buffered !devtools$1.cleanupBuffer(component)) { _devtoolsComponentRemoved(component); } }; /*! #__NO_SIDE_EFFECTS__ */ // @__NO_SIDE_EFFECTS__ function createDevtoolsComponentHook(hook) { return (component) => { emit$1( hook, component.appContext.app, component.uid, component.parent ? component.parent.uid : void 0, component ); }; } const devtoolsPerfStart = /* @__PURE__ */ createDevtoolsPerformanceHook( "perf:start" /* PERFORMANCE_START */ ); const devtoolsPerfEnd = /* @__PURE__ */ createDevtoolsPerformanceHook( "perf:end" /* PERFORMANCE_END */ ); function createDevtoolsPerformanceHook(hook) { return (component, type, time) => { emit$1(hook, component.appContext.app, component.uid, component, type, time); }; } function devtoolsComponentEmit(component, event, params) { emit$1( "component:emit" /* COMPONENT_EMIT */, component.appContext.app, component, event, params ); } let currentRenderingInstance = null; let currentScopeId = null; function setCurrentRenderingInstance(instance) { const prev = currentRenderingInstance; currentRenderingInstance = instance; currentScopeId = instance && instance.type.__scopeId || null; return prev; } function pushScopeId(id) { currentScopeId = id; } function popScopeId() { currentScopeId = null; } const withScopeId = (_id) => withCtx; function withCtx(fn, ctx = currentRenderingInstance, isNonScopedSlot) { if (!ctx) return fn; if (fn._n) { return fn; } const renderFnWithContext = (...args) => { if (renderFnWithContext._d) { setBlockTracking(-1); } const prevInstance = setCurrentRenderingInstance(ctx); let res; try { res = fn(...args); } finally { setCurrentRenderingInstance(prevInstance); if (renderFnWithContext._d) { setBlockTracking(1); } } { devtoolsComponentUpdated(ctx); } return res; }; renderFnWithContext._n = true; renderFnWithContext._c = true; renderFnWithContext._d = true; return renderFnWithContext; } function validateDirectiveName(name) { if (isBuiltInDirective(name)) { warn$1("Do not use built-in directive ids as custom directive id: " + name); } } function withDirectives(vnode, directives) { if (currentRenderingInstance === null) { warn$1(`withDirectives can only be used inside render functions.`); return vnode; } const instance = getComponentPublicInstance(currentRenderingInstance); const bindings = vnode.dirs || (vnode.dirs = []); for (let i = 0; i < directives.length; i++) { let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]; if (dir) { if (isFunction(dir)) { dir = { mounted: dir, updated: dir }; } if (dir.deep) { traverse(value); } bindings.push({ dir, instance, value, oldValue: void 0, arg, modifiers }); } } return vnode; } function invokeDirectiveHook(vnode, prevVNode, instance, name) { const bindings = vnode.dirs; const oldBindings = prevVNode && prevVNode.dirs; for (let i = 0; i < bindings.length; i++) { const binding = bindings[i]; if (oldBindings) { binding.oldValue = oldBindings[i].value; } let hook = binding.dir[name]; if (hook) { pauseTracking(); callWithAsyncErrorHandling(hook, instance, 8, [ vnode.el, binding, vnode, prevVNode ]); resetTracking(); } } } const leaveCbKey = Symbol("_leaveCb"); const enterCbKey$1 = Symbol("_enterCb"); function useTransitionState() { const state = { isMounted: false, isLeaving: false, isUnmounting: false, leavingVNodes: /* @__PURE__ */ new Map() }; onMounted(() => { state.isMounted = true; }); onBeforeUnmount(() => { state.isUnmounting = true; }); return state; } const TransitionHookValidator = [Function, Array]; const BaseTransitionPropsValidators = { mode: String, appear: Boolean, persisted: Boolean, // enter onBeforeEnter: TransitionHookValidator, onEnter: TransitionHookValidator, onAfterEnter: TransitionHookValidator, onEnterCancelled: TransitionHookValidator, // leave onBeforeLeave: TransitionHookValidator, onLeave: TransitionHookValidator, onAfterLeave: TransitionHookValidator, onLeaveCancelled: TransitionHookValidator, // appear onBeforeAppear: TransitionHookValidator, onAppear: TransitionHookValidator, onAfterAppear: TransitionHookValidator, onAppearCancelled: TransitionHookValidator }; const recursiveGetSubtree = (instance) => { const subTree = instance.subTree; return subTree.component ? recursiveGetSubtree(subTree.component) : subTree; }; const BaseTransitionImpl = { name: `BaseTransition`, props: BaseTransitionPropsValidators, setup(props, { slots }) { const instance = getCurrentInstance(); const state = useTransitionState(); return () => { const children = slots.default && getTransitionRawChildren(slots.default(), true); if (!children || !children.length) { return; } let child = children[0]; if (children.length > 1) { let hasFound = false; for (const c of children) { if (c.type !== Comment) { if (hasFound) { warn$1( " can only be used on a single element or component. Use for lists." ); break; } child = c; hasFound = true; } } } const rawProps = toRaw(props); const { mode } = rawProps; if (mode && mode !== "in-out" && mode !== "out-in" && mode !== "default") { warn$1(`invalid mode: ${mode}`); } if (state.isLeaving) { return emptyPlaceholder(child); } const innerChild = getKeepAliveChild(child); if (!innerChild) { return emptyPlaceholder(child); } let enterHooks = resolveTransitionHooks( innerChild, rawProps, state, instance, // #11061, ensure enterHooks is fresh after clone (hooks) => enterHooks = hooks ); setTransitionHooks(innerChild, enterHooks); const oldChild = instance.subTree; const oldInnerChild = oldChild && getKeepAliveChild(oldChild); if (oldInnerChild && oldInnerChild.type !== Comment && !isSameVNodeType(innerChild, oldInnerChild) && recursiveGetSubtree(instance).type !== Comment) { const leavingHooks = resolveTransitionHooks( oldInnerChild, rawProps, state, instance ); setTransitionHooks(oldInnerChild, leavingHooks); if (mode === "out-in" && innerChild.type !== Comment) { state.isLeaving = true; leavingHooks.afterLeave = () => { state.isLeaving = false; if (instance.update.active !== false) { instance.effect.dirty = true; instance.update(); } }; return emptyPlaceholder(child); } else if (mode === "in-out" && innerChild.type !== Comment) { leavingHooks.delayLeave = (el, earlyRemove, delayedLeave) => { const leavingVNodesCache = getLeavingNodesForType( state, oldInnerChild ); leavingVNodesCache[String(oldInnerChild.key)] = oldInnerChild; el[leaveCbKey] = () => { earlyRemove(); el[leaveCbKey] = void 0; delete enterHooks.delayedLeave; }; enterHooks.delayedLeave = delayedLeave; }; } } return child; }; } }; const BaseTransition = BaseTransitionImpl; function getLeavingNodesForType(state, vnode) { const { leavingVNodes } = state; let leavingVNodesCache = leavingVNodes.get(vnode.type); if (!leavingVNodesCache) { leavingVNodesCache = /* @__PURE__ */ Object.create(null); leavingVNodes.set(vnode.type, leavingVNodesCache); } return leavingVNodesCache; } function resolveTransitionHooks(vnode, props, state, instance, postClone) { const { appear, mode, persisted = false, onBeforeEnter, onEnter, onAfterEnter, onEnterCancelled, onBeforeLeave, onLeave, onAfterLeave, onLeaveCancelled, onBeforeAppear, onAppear, onAfterAppear, onAppearCancelled } = props; const key = String(vnode.key); const leavingVNodesCache = getLeavingNodesForType(state, vnode); const callHook = (hook, args) => { hook && callWithAsyncErrorHandling( hook, instance, 9, args ); }; const callAsyncHook = (hook, args) => { const done = args[1]; callHook(hook, args); if (isArray(hook)) { if (hook.every((hook2) => hook2.length <= 1)) done(); } else if (hook.length <= 1) { done(); } }; const hooks = { mode, persisted, beforeEnter(el) { let hook = onBeforeEnter; if (!state.isMounted) { if (appear) { hook = onBeforeAppear || onBeforeEnter; } else { return; } } if (el[leaveCbKey]) { el[leaveCbKey]( true /* cancelled */ ); } const leavingVNode = leavingVNodesCache[key]; if (leavingVNode && isSameVNodeType(vnode, leavingVNode) && leavingVNode.el[leaveCbKey]) { leavingVNode.el[leaveCbKey](); } callHook(hook, [el]); }, enter(el) { let hook = onEnter; let afterHook = onAfterEnter; let cancelHook = onEnterCancelled; if (!state.isMounted) { if (appear) { hook = onAppear || onEnter; afterHook = onAfterAppear || onAfterEnter; cancelHook = onAppearCancelled || onEnterCancelled; } else { return; } } let called = false; const done = el[enterCbKey$1] = (cancelled) => { if (called) return; called = true; if (cancelled) { callHook(cancelHook, [el]); } else { callHook(afterHook, [el]); } if (hooks.delayedLeave) { hooks.delayedLeave(); } el[enterCbKey$1] = void 0; }; if (hook) { callAsyncHook(hook, [el, done]); } else { done(); } }, leave(el, remove) { const key2 = String(vnode.key); if (el[enterCbKey$1]) { el[enterCbKey$1]( true /* cancelled */ ); } if (state.isUnmounting) { return remove(); } callHook(onBeforeLeave, [el]); let called = false; const done = el[leaveCbKey] = (cancelled) => { if (called) return; called = true; remove(); if (cancelled) { callHook(onLeaveCancelled, [el]); } else { callHook(onAfterLeave, [el]); } el[leaveCbKey] = void 0; if (leavingVNodesCache[key2] === vnode) { delete leavingVNodesCache[key2]; } }; leavingVNodesCache[key2] = vnode; if (onLeave) { callAsyncHook(onLeave, [el, done]); } else { done(); } }, clone(vnode2) { const hooks2 = resolveTransitionHooks( vnode2, props, state, instance, postClone ); if (postClone) postClone(hooks2); return hooks2; } }; return hooks; } function emptyPlaceholder(vnode) { if (isKeepAlive(vnode)) { vnode = cloneVNode(vnode); vnode.children = null; return vnode; } } function getKeepAliveChild(vnode) { if (!isKeepAlive(vnode)) { return vnode; } if (vnode.component) { return vnode.component.subTree; } const { shapeFlag, children } = vnode; if (children) { if (shapeFlag & 16) { return children[0]; } if (shapeFlag & 32 && isFunction(children.default)) { return children.default(); } } } function setTransitionHooks(vnode, hooks) { if (vnode.shapeFlag & 6 && vnode.component) { setTransitionHooks(vnode.component.subTree, hooks); } else if (vnode.shapeFlag & 128) { vnode.ssContent.transition = hooks.clone(vnode.ssContent); vnode.ssFallback.transition = hooks.clone(vnode.ssFallback); } else { vnode.transition = hooks; } } function getTransitionRawChildren(children, keepComment = false, parentKey) { let ret = []; let keyedFragmentCount = 0; for (let i = 0; i < children.length; i++) { let child = children[i]; const key = parentKey == null ? child.key : String(parentKey) + String(child.key != null ? child.key : i); if (child.type === Fragment) { if (child.patchFlag & 128) keyedFragmentCount++; ret = ret.concat( getTransitionRawChildren(child.children, keepComment, key) ); } else if (keepComment || child.type !== Comment) { ret.push(key != null ? cloneVNode(child, { key }) : child); } } if (keyedFragmentCount > 1) { for (let i = 0; i < ret.length; i++) { ret[i].patchFlag = -2; } } return ret; } /*! #__NO_SIDE_EFFECTS__ */ // @__NO_SIDE_EFFECTS__ function defineComponent(options, extraOptions) { return isFunction(options) ? ( // #8326: extend call and options.name access are considered side-effects // by Rollup, so we have to wrap it in a pure-annotated IIFE. /* @__PURE__ */ (() => extend({ name: options.name }, extraOptions, { setup: options }))() ) : options; } const isAsyncWrapper = (i) => !!i.type.__asyncLoader; /*! #__NO_SIDE_EFFECTS__ */ // @__NO_SIDE_EFFECTS__ function defineAsyncComponent(source) { if (isFunction(source)) { source = { loader: source }; } const { loader, loadingComponent, errorComponent, delay = 200, timeout, // undefined = never times out suspensible = true, onError: userOnError } = source; let pendingRequest = null; let resolvedComp; let retries = 0; const retry = () => { retries++; pendingRequest = null; return load(); }; const load = () => { let thisRequest; return pendingRequest || (thisRequest = pendingRequest = loader().catch((err) => { err = err instanceof Error ? err : new Error(String(err)); if (userOnError) { return new Promise((resolve, reject) => { const userRetry = () => resolve(retry()); const userFail = () => reject(err); userOnError(err, userRetry, userFail, retries + 1); }); } else { throw err; } }).then((comp) => { if (thisRequest !== pendingRequest && pendingRequest) { return pendingRequest; } if (!comp) { warn$1( `Async component loader resolved to undefined. If you are using retry(), make sure to return its return value.` ); } if (comp && (comp.__esModule || comp[Symbol.toStringTag] === "Module")) { comp = comp.default; } if (comp && !isObject(comp) && !isFunction(comp)) { throw new Error(`Invalid async component load result: ${comp}`); } resolvedComp = comp; return comp; })); }; return defineComponent({ name: "AsyncComponentWrapper", __asyncLoader: load, get __asyncResolved() { return resolvedComp; }, setup() { const instance = currentInstance; if (resolvedComp) { return () => createInnerComp(resolvedComp, instance); } const onError = (err) => { pendingRequest = null; handleError( err, instance, 13, !errorComponent ); }; if (suspensible && instance.suspense || false) { return load().then((comp) => { return () => createInnerComp(comp, instance); }).catch((err) => { onError(err); return () => errorComponent ? createVNode(errorComponent, { error: err }) : null; }); } const loaded = ref(false); const error = ref(); const delayed = ref(!!delay); if (delay) { setTimeout(() => { delayed.value = false; }, delay); } if (timeout != null) { setTimeout(() => { if (!loaded.value && !error.value) { const err = new Error( `Async component timed out after ${timeout}ms.` ); onError(err); error.value = err; } }, timeout); } load().then(() => { loaded.value = true; if (instance.parent && isKeepAlive(instance.parent.vnode)) { instance.parent.effect.dirty = true; queueJob(instance.parent.update); } }).catch((err) => { onError(err); error.value = err; }); return () => { if (loaded.value && resolvedComp) { return createInnerComp(resolvedComp, instance); } else if (error.value && errorComponent) { return createVNode(errorComponent, { error: error.value }); } else if (loadingComponent && !delayed.value) { return createVNode(loadingComponent); } }; } }); } function createInnerComp(comp, parent) { const { ref: ref2, props, children, ce } = parent.vnode; const vnode = createVNode(comp, props, children); vnode.ref = ref2; vnode.ce = ce; delete parent.vnode.ce; return vnode; } const isKeepAlive = (vnode) => vnode.type.__isKeepAlive; const KeepAliveImpl = { name: `KeepAlive`, // Marker for special handling inside the renderer. We are not using a === // check directly on KeepAlive in the renderer, because importing it directly // would prevent it from being tree-shaken. __isKeepAlive: true, props: { include: [String, RegExp, Array], exclude: [String, RegExp, Array], max: [String, Number] }, setup(props, { slots }) { const instance = getCurrentInstance(); const sharedContext = instance.ctx; const cache = /* @__PURE__ */ new Map(); const keys = /* @__PURE__ */ new Set(); let current = null; { instance.__v_cache = cache; } const parentSuspense = instance.suspense; const { renderer: { p: patch, m: move, um: _unmount, o: { createElement } } } = sharedContext; const storageContainer = createElement("div"); sharedContext.activate = (vnode, container, anchor, namespace, optimized) => { const instance2 = vnode.component; move(vnode, container, anchor, 0, parentSuspense); patch( instance2.vnode, vnode, container, anchor, instance2, parentSuspense, namespace, vnode.slotScopeIds, optimized ); queuePostRenderEffect(() => { instance2.isDeactivated = false; if (instance2.a) { invokeArrayFns(instance2.a); } const vnodeHook = vnode.props && vnode.props.onVnodeMounted; if (vnodeHook) { invokeVNodeHook(vnodeHook, instance2.parent, vnode); } }, parentSuspense); { devtoolsComponentAdded(instance2); } }; sharedContext.deactivate = (vnode) => { const instance2 = vnode.component; invalidateMount(instance2.m); invalidateMount(instance2.a); move(vnode, storageContainer, null, 1, parentSuspense); queuePostRenderEffect(() => { if (instance2.da) { invokeArrayFns(instance2.da); } const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted; if (vnodeHook) { invokeVNodeHook(vnodeHook, instance2.parent, vnode); } instance2.isDeactivated = true; }, parentSuspense); { devtoolsComponentAdded(instance2); } }; function unmount(vnode) { resetShapeFlag(vnode); _unmount(vnode, instance, parentSuspense, true); } function pruneCache(filter) { cache.forEach((vnode, key) => { const name = getComponentName(vnode.type); if (name && (!filter || !filter(name))) { pruneCacheEntry(key); } }); } function pruneCacheEntry(key) { const cached = cache.get(key); if (!current || !isSameVNodeType(cached, current)) { unmount(cached); } else if (current) { resetShapeFlag(current); } cache.delete(key); keys.delete(key); } watch( () => [props.include, props.exclude], ([include, exclude]) => { include && pruneCache((name) => matches(include, name)); exclude && pruneCache((name) => !matches(exclude, name)); }, // prune post-render after `current` has been updated { flush: "post", deep: true } ); let pendingCacheKey = null; const cacheSubtree = () => { if (pendingCacheKey != null) { if (isSuspense(instance.subTree.type)) { queuePostRenderEffect(() => { cache.set(pendingCacheKey, getInnerChild(instance.subTree)); }, instance.subTree.suspense); } else { cache.set(pendingCacheKey, getInnerChild(instance.subTree)); } } }; onMounted(cacheSubtree); onUpdated(cacheSubtree); onBeforeUnmount(() => { cache.forEach((cached) => { const { subTree, suspense } = instance; const vnode = getInnerChild(subTree); if (cached.type === vnode.type && cached.key === vnode.key) { resetShapeFlag(vnode); const da = vnode.component.da; da && queuePostRenderEffect(da, suspense); return; } unmount(cached); }); }); return () => { pendingCacheKey = null; if (!slots.default) { return null; } const children = slots.default(); const rawVNode = children[0]; if (children.length > 1) { { warn$1(`KeepAlive should contain exactly one component child.`); } current = null; return children; } else if (!isVNode(rawVNode) || !(rawVNode.shapeFlag & 4) && !(rawVNode.shapeFlag & 128)) { current = null; return rawVNode; } let vnode = getInnerChild(rawVNode); const comp = vnode.type; const name = getComponentName( isAsyncWrapper(vnode) ? vnode.type.__asyncResolved || {} : comp ); const { include, exclude, max } = props; if (include && (!name || !matches(include, name)) || exclude && name && matches(exclude, name)) { current = vnode; return rawVNode; } const key = vnode.key == null ? comp : vnode.key; const cachedVNode = cache.get(key); if (vnode.el) { vnode = cloneVNode(vnode); if (rawVNode.shapeFlag & 128) { rawVNode.ssContent = vnode; } } pendingCacheKey = key; if (cachedVNode) { vnode.el = cachedVNode.el; vnode.component = cachedVNode.component; if (vnode.transition) { setTransitionHooks(vnode, vnode.transition); } vnode.shapeFlag |= 512; keys.delete(key); keys.add(key); } else { keys.add(key); if (max && keys.size > parseInt(max, 10)) { pruneCacheEntry(keys.values().next().value); } } vnode.shapeFlag |= 256; current = vnode; return isSuspense(rawVNode.type) ? rawVNode : vnode; }; } }; const KeepAlive = KeepAliveImpl; function matches(pattern, name) { if (isArray(pattern)) { return pattern.some((p) => matches(p, name)); } else if (isString(pattern)) { return pattern.split(",").includes(name); } else if (isRegExp(pattern)) { return pattern.test(name); } return false; } function onActivated(hook, target) { registerKeepAliveHook(hook, "a", target); } function onDeactivated(hook, target) { registerKeepAliveHook(hook, "da", target); } function registerKeepAliveHook(hook, type, target = currentInstance) { const wrappedHook = hook.__wdc || (hook.__wdc = () => { let current = target; while (current) { if (current.isDeactivated) { return; } current = current.parent; } return hook(); }); injectHook(type, wrappedHook, target); if (target) { let current = target.parent; while (current && current.parent) { if (isKeepAlive(current.parent.vnode)) { injectToKeepAliveRoot(wrappedHook, type, target, current); } current = current.parent; } } } function injectToKeepAliveRoot(hook, type, target, keepAliveRoot) { const injected = injectHook( type, hook, keepAliveRoot, true /* prepend */ ); onUnmounted(() => { remove(keepAliveRoot[type], injected); }, target); } function resetShapeFlag(vnode) { vnode.shapeFlag &= ~256; vnode.shapeFlag &= ~512; } function getInnerChild(vnode) { return vnode.shapeFlag & 128 ? vnode.ssContent : vnode; } function injectHook(type, hook, target = currentInstance, prepend = false) { if (target) { const hooks = target[type] || (target[type] = []); const wrappedHook = hook.__weh || (hook.__weh = (...args) => { pauseTracking(); const reset = setCurrentInstance(target); const res = callWithAsyncErrorHandling(hook, target, type, args); reset(); resetTracking(); return res; }); if (prepend) { hooks.unshift(wrappedHook); } else { hooks.push(wrappedHook); } return wrappedHook; } else { const apiName = toHandlerKey(ErrorTypeStrings$1[type].replace(/ hook$/, "")); warn$1( `${apiName} is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup().` + (` If you are using async setup(), make sure to register lifecycle hooks before the first await statement.` ) ); } } const createHook = (lifecycle) => (hook, target = currentInstance) => { if (!isInSSRComponentSetup || lifecycle === "sp") { injectHook(lifecycle, (...args) => hook(...args), target); } }; const onBeforeMount = createHook("bm"); const onMounted = createHook("m"); const onBeforeUpdate = createHook("bu"); const onUpdated = createHook("u"); const onBeforeUnmount = createHook("bum"); const onUnmounted = createHook("um"); const onServerPrefetch = createHook("sp"); const onRenderTriggered = createHook( "rtg" ); const onRenderTracked = createHook( "rtc" ); function onErrorCaptured(hook, target = currentInstance) { injectHook("ec", hook, target); } const COMPONENTS = "components"; const DIRECTIVES = "directives"; function resolveComponent(name, maybeSelfReference) { return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name; } const NULL_DYNAMIC_COMPONENT = Symbol.for("v-ndc"); function resolveDynamicComponent(component) { if (isString(component)) { return resolveAsset(COMPONENTS, component, false) || component; } else { return component || NULL_DYNAMIC_COMPONENT; } } function resolveDirective(name) { return resolveAsset(DIRECTIVES, name); } function resolveAsset(type, name, warnMissing = true, maybeSelfReference = false) { const instance = currentRenderingInstance || currentInstance; if (instance) { const Component = instance.type; if (type === COMPONENTS) { const selfName = getComponentName( Component, false ); if (selfName && (selfName === name || selfName === camelize(name) || selfName === capitalize(camelize(name)))) { return Component; } } const res = ( // local registration // check instance[type] first which is resolved for options API resolve(instance[type] || Component[type], name) || // global registration resolve(instance.appContext[type], name) ); if (!res && maybeSelfReference) { return Component; } if (warnMissing && !res) { const extra = type === COMPONENTS ? ` If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.` : ``; warn$1(`Failed to resolve ${type.slice(0, -1)}: ${name}${extra}`); } return res; } else { warn$1( `resolve${capitalize(type.slice(0, -1))} can only be used in render() or setup().` ); } } function resolve(registry, name) { return registry && (registry[name] || registry[camelize(name)] || registry[capitalize(camelize(name))]); } function renderList(source, renderItem, cache, index) { let ret; const cached = cache && cache[index]; if (isArray(source) || isString(source)) { ret = new Array(source.length); for (let i = 0, l = source.length; i < l; i++) { ret[i] = renderItem(source[i], i, void 0, cached && cached[i]); } } else if (typeof source === "number") { if (!Number.isInteger(source)) { warn$1(`The v-for range expect an integer value but got ${source}.`); } ret = new Array(source); for (let i = 0; i < source; i++) { ret[i] = renderItem(i + 1, i, void 0, cached && cached[i]); } } else if (isObject(source)) { if (source[Symbol.iterator]) { ret = Array.from( source, (item, i) => renderItem(item, i, void 0, cached && cached[i]) ); } else { const keys = Object.keys(source); ret = new Array(keys.length); for (let i = 0, l = keys.length; i < l; i++) { const key = keys[i]; ret[i] = renderItem(source[key], key, i, cached && cached[i]); } } } else { ret = []; } if (cache) { cache[index] = ret; } return ret; } function createSlots(slots, dynamicSlots) { for (let i = 0; i < dynamicSlots.length; i++) { const slot = dynamicSlots[i]; if (isArray(slot)) { for (let j = 0; j < slot.length; j++) { slots[slot[j].name] = slot[j].fn; } } else if (slot) { slots[slot.name] = slot.key ? (...args) => { const res = slot.fn(...args); if (res) res.key = slot.key; return res; } : slot.fn; } } return slots; } function renderSlot(slots, name, props = {}, fallback, noSlotted) { if (currentRenderingInstance.isCE || currentRenderingInstance.parent && isAsyncWrapper(currentRenderingInstance.parent) && currentRenderingInstance.parent.isCE) { if (name !== "default") props.name = name; return createVNode("slot", props, fallback && fallback()); } let slot = slots[name]; if (slot && slot.length > 1) { warn$1( `SSR-optimized slot function detected in a non-SSR-optimized render function. You need to mark this component with $dynamic-slots in the parent template.` ); slot = () => []; } if (slot && slot._c) { slot._d = false; } openBlock(); const validSlotContent = slot && ensureValidVNode(slot(props)); const rendered = createBlock( Fragment, { key: (props.key || // slot content array of a dynamic conditional slot may have a branch // key attached in the `createSlots` helper, respect that validSlotContent && validSlotContent.key || `_${name}`) + // #7256 force differentiate fallback content from actual content (!validSlotContent && fallback ? "_fb" : "") }, validSlotContent || (fallback ? fallback() : []), validSlotContent && slots._ === 1 ? 64 : -2 ); if (!noSlotted && rendered.scopeId) { rendered.slotScopeIds = [rendered.scopeId + "-s"]; } if (slot && slot._c) { slot._d = true; } return rendered; } function ensureValidVNode(vnodes) { return vnodes.some((child) => { if (!isVNode(child)) return true; if (child.type === Comment) return false; if (child.type === Fragment && !ensureValidVNode(child.children)) return false; return true; }) ? vnodes : null; } function toHandlers(obj, preserveCaseIfNecessary) { const ret = {}; if (!isObject(obj)) { warn$1(`v-on with no argument expects an object value.`); return ret; } for (const key in obj) { ret[preserveCaseIfNecessary && /[A-Z]/.test(key) ? `on:${key}` : toHandlerKey(key)] = obj[key]; } return ret; } const getPublicInstance = (i) => { if (!i) return null; if (isStatefulComponent(i)) return getComponentPublicInstance(i); return getPublicInstance(i.parent); }; const publicPropertiesMap = ( // Move PURE marker to new line to workaround compiler discarding it // due to type annotation /* @__PURE__ */ extend(/* @__PURE__ */ Object.create(null), { $: (i) => i, $el: (i) => i.vnode.el, $data: (i) => i.data, $props: (i) => shallowReadonly(i.props) , $attrs: (i) => shallowReadonly(i.attrs) , $slots: (i) => shallowReadonly(i.slots) , $refs: (i) => shallowReadonly(i.refs) , $parent: (i) => getPublicInstance(i.parent), $root: (i) => getPublicInstance(i.root), $emit: (i) => i.emit, $options: (i) => resolveMergedOptions(i) , $forceUpdate: (i) => i.f || (i.f = () => { i.effect.dirty = true; queueJob(i.update); }), $nextTick: (i) => i.n || (i.n = nextTick.bind(i.proxy)), $watch: (i) => instanceWatch.bind(i) }) ); const isReservedPrefix = (key) => key === "_" || key === "$"; const hasSetupBinding = (state, key) => state !== EMPTY_OBJ && !state.__isScriptSetup && hasOwn(state, key); const PublicInstanceProxyHandlers = { get({ _: instance }, key) { if (key === "__v_skip") { return true; } const { ctx, setupState, data, props, accessCache, type, appContext } = instance; if (key === "__isVue") { return true; } let normalizedProps; if (key[0] !== "$") { const n = accessCache[key]; if (n !== void 0) { switch (n) { case 1 /* SETUP */: return setupState[key]; case 2 /* DATA */: return data[key]; case 4 /* CONTEXT */: return ctx[key]; case 3 /* PROPS */: return props[key]; } } else if (hasSetupBinding(setupState, key)) { accessCache[key] = 1 /* SETUP */; return setupState[key]; } else if (data !== EMPTY_OBJ && hasOwn(data, key)) { accessCache[key] = 2 /* DATA */; return data[key]; } else if ( // only cache other properties when instance has declared (thus stable) // props (normalizedProps = instance.propsOptions[0]) && hasOwn(normalizedProps, key) ) { accessCache[key] = 3 /* PROPS */; return props[key]; } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { accessCache[key] = 4 /* CONTEXT */; return ctx[key]; } else if (shouldCacheAccess) { accessCache[key] = 0 /* OTHER */; } } const publicGetter = publicPropertiesMap[key]; let cssModule, globalProperties; if (publicGetter) { if (key === "$attrs") { track(instance.attrs, "get", ""); markAttrsAccessed(); } else if (key === "$slots") { track(instance, "get", key); } return publicGetter(instance); } else if ( // css module (injected by vue-loader) (cssModule = type.__cssModules) && (cssModule = cssModule[key]) ) { return cssModule; } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { accessCache[key] = 4 /* CONTEXT */; return ctx[key]; } else if ( // global properties globalProperties = appContext.config.globalProperties, hasOwn(globalProperties, key) ) { { return globalProperties[key]; } } else if (currentRenderingInstance && (!isString(key) || // #1091 avoid internal isRef/isVNode checks on component instance leading // to infinite warning loop key.indexOf("__v") !== 0)) { if (data !== EMPTY_OBJ && isReservedPrefix(key[0]) && hasOwn(data, key)) { warn$1( `Property ${JSON.stringify( key )} must be accessed via $data because it starts with a reserved character ("$" or "_") and is not proxied on the render context.` ); } else if (instance === currentRenderingInstance) { warn$1( `Property ${JSON.stringify(key)} was accessed during render but is not defined on instance.` ); } } }, set({ _: instance }, key, value) { const { data, setupState, ctx } = instance; if (hasSetupBinding(setupState, key)) { setupState[key] = value; return true; } else if (setupState.__isScriptSetup && hasOwn(setupState, key)) { warn$1(`Cannot mutate