From b61a1885249eba6cf2d4e88150155c0f3eae1b17 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 20 Sep 2025 17:10:07 +0700 Subject: [PATCH] Vendor telegram webapp sdk and bump to 0.10.4 --- CHANGELOG.md | 12 + Cargo.lock | 25 +- Cargo.toml | 6 +- README.md | 14 +- telegram-webapp-sdk/.cargo-ok | 1 + telegram-webapp-sdk/.cargo_vcs_info.json | 6 + telegram-webapp-sdk/.github/FUNDING.yml | 15 + telegram-webapp-sdk/.github/workflows/ci.yml | 13 + .../.github/workflows/release.yml | 57 + .../.github/workflows/reusable-ci.yml | 102 + telegram-webapp-sdk/.gitignore | 1 + telegram-webapp-sdk/.hooks/pre-commit | 24 + telegram-webapp-sdk/.rustfmt.toml | 25 + telegram-webapp-sdk/.scripts/release.sh | 30 + telegram-webapp-sdk/CHANGELOG.md | 26 + telegram-webapp-sdk/Cargo.lock | 3079 +++++++++++++++++ telegram-webapp-sdk/Cargo.toml | 149 + telegram-webapp-sdk/Cargo.toml.orig | 75 + telegram-webapp-sdk/LICENSE-APACHE | 202 ++ telegram-webapp-sdk/LICENSE-MIT | 21 + telegram-webapp-sdk/Makefile.toml | 327 ++ telegram-webapp-sdk/README.md | 462 +++ telegram-webapp-sdk/WEBAPP_API.md | 170 + telegram-webapp-sdk/src/api.rs | 14 + telegram-webapp-sdk/src/api/accelerometer.rs | 209 ++ telegram-webapp-sdk/src/api/biometric.rs | 508 +++ telegram-webapp-sdk/src/api/cloud_storage.rs | 400 +++ .../src/api/device_orientation.rs | 200 ++ telegram-webapp-sdk/src/api/device_storage.rs | 201 ++ telegram-webapp-sdk/src/api/events.rs | 76 + telegram-webapp-sdk/src/api/gyroscope.rs | 200 ++ telegram-webapp-sdk/src/api/haptic.rs | 191 + telegram-webapp-sdk/src/api/location.rs | 0 .../src/api/location_manager.rs | 235 ++ telegram-webapp-sdk/src/api/secure_storage.rs | 246 ++ .../src/api/settings_button.rs | 180 + telegram-webapp-sdk/src/api/theme.rs | 63 + telegram-webapp-sdk/src/api/user.rs | 161 + telegram-webapp-sdk/src/api/viewport.rs | 167 + telegram-webapp-sdk/src/core.rs | 5 + telegram-webapp-sdk/src/core/context.rs | 100 + telegram-webapp-sdk/src/core/init.rs | 84 + telegram-webapp-sdk/src/core/interop.rs | 1 + .../src/core/interop/verify.rs | 82 + telegram-webapp-sdk/src/core/safe_context.rs | 7 + telegram-webapp-sdk/src/core/types.rs | 12 + telegram-webapp-sdk/src/core/types/chat.rs | 21 + .../src/core/types/download_file_params.rs | 43 + .../src/core/types/init_data.rs | 44 + .../src/core/types/init_data_internal.rs | 15 + .../src/core/types/launch_params.rs | 8 + .../src/core/types/sent_web_app_message.rs | 44 + .../src/core/types/theme_params.rs | 185 + telegram-webapp-sdk/src/core/types/user.rs | 94 + .../src/core/types/web_app_data.rs | 48 + .../src/core/types/web_app_info.rs | 44 + .../src/core/types/webhook_info.rs | 76 + .../src/core/types/write_access_allowed.rs | 44 + telegram-webapp-sdk/src/leptos.rs | 30 + telegram-webapp-sdk/src/lib.rs | 25 + telegram-webapp-sdk/src/logger.rs | 41 + telegram-webapp-sdk/src/macros.rs | 217 ++ telegram-webapp-sdk/src/mock.rs | 4 + telegram-webapp-sdk/src/mock/config.rs | 57 + telegram-webapp-sdk/src/mock/data.rs | 12 + telegram-webapp-sdk/src/mock/init.rs | 162 + telegram-webapp-sdk/src/mock/utils.rs | 44 + telegram-webapp-sdk/src/pages.rs | 15 + telegram-webapp-sdk/src/utils.rs | 2 + telegram-webapp-sdk/src/utils/check_env.rs | 68 + .../src/utils/validate_init_data.rs | 205 ++ telegram-webapp-sdk/src/webapp.rs | 2679 ++++++++++++++ telegram-webapp-sdk/src/yew.rs | 29 + telegram-webapp-sdk/telegram-webapp.toml | 27 + .../tests/closing_confirmation.rs | 83 + .../tests/validate_init_data.rs | 74 + 76 files changed, 12593 insertions(+), 31 deletions(-) create mode 100644 telegram-webapp-sdk/.cargo-ok create mode 100644 telegram-webapp-sdk/.cargo_vcs_info.json create mode 100644 telegram-webapp-sdk/.github/FUNDING.yml create mode 100644 telegram-webapp-sdk/.github/workflows/ci.yml create mode 100644 telegram-webapp-sdk/.github/workflows/release.yml create mode 100644 telegram-webapp-sdk/.github/workflows/reusable-ci.yml create mode 100644 telegram-webapp-sdk/.gitignore create mode 100755 telegram-webapp-sdk/.hooks/pre-commit create mode 100644 telegram-webapp-sdk/.rustfmt.toml create mode 100755 telegram-webapp-sdk/.scripts/release.sh create mode 100644 telegram-webapp-sdk/CHANGELOG.md create mode 100644 telegram-webapp-sdk/Cargo.lock create mode 100644 telegram-webapp-sdk/Cargo.toml create mode 100644 telegram-webapp-sdk/Cargo.toml.orig create mode 100644 telegram-webapp-sdk/LICENSE-APACHE create mode 100644 telegram-webapp-sdk/LICENSE-MIT create mode 100644 telegram-webapp-sdk/Makefile.toml create mode 100644 telegram-webapp-sdk/README.md create mode 100644 telegram-webapp-sdk/WEBAPP_API.md create mode 100644 telegram-webapp-sdk/src/api.rs create mode 100644 telegram-webapp-sdk/src/api/accelerometer.rs create mode 100644 telegram-webapp-sdk/src/api/biometric.rs create mode 100644 telegram-webapp-sdk/src/api/cloud_storage.rs create mode 100644 telegram-webapp-sdk/src/api/device_orientation.rs create mode 100644 telegram-webapp-sdk/src/api/device_storage.rs create mode 100644 telegram-webapp-sdk/src/api/events.rs create mode 100644 telegram-webapp-sdk/src/api/gyroscope.rs create mode 100644 telegram-webapp-sdk/src/api/haptic.rs create mode 100644 telegram-webapp-sdk/src/api/location.rs create mode 100644 telegram-webapp-sdk/src/api/location_manager.rs create mode 100644 telegram-webapp-sdk/src/api/secure_storage.rs create mode 100644 telegram-webapp-sdk/src/api/settings_button.rs create mode 100644 telegram-webapp-sdk/src/api/theme.rs create mode 100644 telegram-webapp-sdk/src/api/user.rs create mode 100644 telegram-webapp-sdk/src/api/viewport.rs create mode 100644 telegram-webapp-sdk/src/core.rs create mode 100644 telegram-webapp-sdk/src/core/context.rs create mode 100644 telegram-webapp-sdk/src/core/init.rs create mode 100644 telegram-webapp-sdk/src/core/interop.rs create mode 100644 telegram-webapp-sdk/src/core/interop/verify.rs create mode 100644 telegram-webapp-sdk/src/core/safe_context.rs create mode 100644 telegram-webapp-sdk/src/core/types.rs create mode 100644 telegram-webapp-sdk/src/core/types/chat.rs create mode 100644 telegram-webapp-sdk/src/core/types/download_file_params.rs create mode 100644 telegram-webapp-sdk/src/core/types/init_data.rs create mode 100644 telegram-webapp-sdk/src/core/types/init_data_internal.rs create mode 100644 telegram-webapp-sdk/src/core/types/launch_params.rs create mode 100644 telegram-webapp-sdk/src/core/types/sent_web_app_message.rs create mode 100644 telegram-webapp-sdk/src/core/types/theme_params.rs create mode 100644 telegram-webapp-sdk/src/core/types/user.rs create mode 100644 telegram-webapp-sdk/src/core/types/web_app_data.rs create mode 100644 telegram-webapp-sdk/src/core/types/web_app_info.rs create mode 100644 telegram-webapp-sdk/src/core/types/webhook_info.rs create mode 100644 telegram-webapp-sdk/src/core/types/write_access_allowed.rs create mode 100644 telegram-webapp-sdk/src/leptos.rs create mode 100644 telegram-webapp-sdk/src/lib.rs create mode 100644 telegram-webapp-sdk/src/logger.rs create mode 100644 telegram-webapp-sdk/src/macros.rs create mode 100644 telegram-webapp-sdk/src/mock.rs create mode 100644 telegram-webapp-sdk/src/mock/config.rs create mode 100644 telegram-webapp-sdk/src/mock/data.rs create mode 100644 telegram-webapp-sdk/src/mock/init.rs create mode 100644 telegram-webapp-sdk/src/mock/utils.rs create mode 100644 telegram-webapp-sdk/src/pages.rs create mode 100644 telegram-webapp-sdk/src/utils.rs create mode 100644 telegram-webapp-sdk/src/utils/check_env.rs create mode 100644 telegram-webapp-sdk/src/utils/validate_init_data.rs create mode 100644 telegram-webapp-sdk/src/webapp.rs create mode 100644 telegram-webapp-sdk/src/yew.rs create mode 100644 telegram-webapp-sdk/telegram-webapp.toml create mode 100644 telegram-webapp-sdk/tests/closing_confirmation.rs create mode 100644 telegram-webapp-sdk/tests/validate_init_data.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ece8dc..b373731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.10.4] - 2025-10-24 + +### Changed +- Vendored `telegram-webapp-sdk` locally and patched it to use the workspace `masterror` implementation, + replacing the outdated crates.io release that depended on `masterror 0.3.5`. +- Marked the SDK's `masterror` dependency optional so disabling the `telegram-webapp-sdk` feature no longer + pulls the crate transitively. + +### Build +- Added a `[patch.crates-io]` override for `telegram-webapp-sdk` to ensure the workspace uses the vendored + source and derives against the current `masterror` APIs. + ### Documentation - Described `#[provide]` telemetry providers and `#[app_error]` conversions with end-to-end examples in the derive guide ([README](README.md#structured-telemetry-providers-and-apperror-mappings), diff --git a/Cargo.lock b/Cargo.lock index e637e0d..b40ebcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1038,12 +1038,6 @@ dependencies = [ "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" - [[package]] name = "hashlink" version = "0.10.0" @@ -1361,7 +1355,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.15.5", "serde", "serde_core", ] @@ -1515,19 +1509,7 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "masterror" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d38aeb53944762378aa5219b9929f2f3346a25fdd9b61266c24a487200c87fd" -dependencies = [ - "http 1.3.1", - "serde", - "thiserror", - "tracing", -] - -[[package]] -name = "masterror" -version = "0.10.3" +version = "0.10.4" dependencies = [ "actix-web", "axum", @@ -2789,15 +2771,12 @@ checksum = "1ac9aa371f599d22256307c24a9d748c041e548cbf599f35d890f9d365361790" [[package]] name = "telegram-webapp-sdk" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb72a0fd8e65f7c9279d164e9b00661e6685f74c0ba63e7f17b30662d5aed21b" dependencies = [ "base64 0.22.1", "ed25519-dalek", "hex", "hmac-sha256", "js-sys", - "masterror 0.3.5", "once_cell", "percent-encoding", "serde", diff --git a/Cargo.toml b/Cargo.toml index 078f9a6..c9b3a8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.10.3" +version = "0.10.4" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -52,6 +52,10 @@ openapi = ["dep:utoipa"] masterror-derive = { version = "0.6.0", path = "masterror-derive" } masterror-template = { version = "0.3.1", path = "masterror-template" } + +[patch.crates-io] +telegram-webapp-sdk = { path = "telegram-webapp-sdk" } + [dependencies] masterror-derive = { workspace = true } masterror-template = { workspace = true } diff --git a/README.md b/README.md index 406f85f..6555662 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ Stable categories, conservative HTTP mapping, no `unsafe`. ~~~toml [dependencies] -masterror = { version = "0.10.3", default-features = false } +masterror = { version = "0.10.4", default-features = false } # or with features: -# masterror = { version = "0.10.3", features = [ +# masterror = { version = "0.10.4", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -66,10 +66,10 @@ masterror = { version = "0.10.3", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.10.3", default-features = false } +masterror = { version = "0.10.4", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.10.3", features = [ +# masterror = { version = "0.10.4", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -623,13 +623,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.10.3", default-features = false } +masterror = { version = "0.10.4", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.10.3", features = [ +masterror = { version = "0.10.4", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -638,7 +638,7 @@ masterror = { version = "0.10.3", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.10.3", features = [ +masterror = { version = "0.10.4", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/telegram-webapp-sdk/.cargo-ok b/telegram-webapp-sdk/.cargo-ok new file mode 100644 index 0000000..5f8b795 --- /dev/null +++ b/telegram-webapp-sdk/.cargo-ok @@ -0,0 +1 @@ +{"v":1} \ No newline at end of file diff --git a/telegram-webapp-sdk/.cargo_vcs_info.json b/telegram-webapp-sdk/.cargo_vcs_info.json new file mode 100644 index 0000000..237560f --- /dev/null +++ b/telegram-webapp-sdk/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "67fc43a229f57bb856aac4b2dfaaa095951107be" + }, + "path_in_vcs": "" +} \ No newline at end of file diff --git a/telegram-webapp-sdk/.github/FUNDING.yml b/telegram-webapp-sdk/.github/FUNDING.yml new file mode 100644 index 0000000..a299cb7 --- /dev/null +++ b/telegram-webapp-sdk/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: raprojects # Replace with a single Open Collective username +ko_fi: rozanov # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: raprogramm # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] # TODO: make an link to usdt wallet diff --git a/telegram-webapp-sdk/.github/workflows/ci.yml b/telegram-webapp-sdk/.github/workflows/ci.yml new file mode 100644 index 0000000..1ae556e --- /dev/null +++ b/telegram-webapp-sdk/.github/workflows/ci.yml @@ -0,0 +1,13 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + +jobs: + ci: + uses: ./.github/workflows/reusable-ci.yml + with: + all-features: true + diff --git a/telegram-webapp-sdk/.github/workflows/release.yml b/telegram-webapp-sdk/.github/workflows/release.yml new file mode 100644 index 0000000..4b2db91 --- /dev/null +++ b/telegram-webapp-sdk/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + checks: + uses: ./.github/workflows/reusable-ci.yml + with: + all-features: true + + publish: + runs-on: ubuntu-latest + needs: checks + steps: + - uses: actions/checkout@v4 + + - name: Read MSRV from Cargo.toml + id: msrv + shell: bash + run: | + set -euo pipefail + if ! command -v jq >/dev/null 2>&1; then + sudo apt-get update -y && sudo apt-get install -y jq + fi + RV=$(cargo metadata --no-deps --format-version=1 | jq -r '.packages[0].rust_version // empty') + if [ -z "$RV" ]; then + echo "rust-version is not set in Cargo.toml" + exit 1 + fi + if [[ "$RV" =~ ^[0-9]+\.[0-9]+$ ]]; then + RV="${RV}.0" + fi + echo "msrv=${RV}" >> "$GITHUB_OUTPUT" + + - name: Ensure tag matches Cargo.toml version + shell: bash + run: | + set -euo pipefail + TAG="${GITHUB_REF#refs/tags/}" + FILE_VER=$(cargo metadata --no-deps --format-version=1 | jq -r '.packages[0].version') + if [ "v${FILE_VER}" != "${TAG}" ]; then + echo "Tag ${TAG} != Cargo.toml version v${FILE_VER}" + exit 1 + fi + + - name: Install Rust (${{ steps.msrv.outputs.msrv }}) + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ steps.msrv.outputs.msrv }} + + - name: Publish to crates.io + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo +${{ steps.msrv.outputs.msrv }} publish --locked --token "$CARGO_REGISTRY_TOKEN" diff --git a/telegram-webapp-sdk/.github/workflows/reusable-ci.yml b/telegram-webapp-sdk/.github/workflows/reusable-ci.yml new file mode 100644 index 0000000..f8faeb2 --- /dev/null +++ b/telegram-webapp-sdk/.github/workflows/reusable-ci.yml @@ -0,0 +1,102 @@ +name: Reusable CI + +on: + workflow_call: + inputs: + all-features: + type: boolean + default: true + +jobs: + ci: + runs-on: ubuntu-latest + env: + CARGO_LOCKED: "true" # don't mutate Cargo.lock during CI + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Read MSRV (rust-version) from Cargo.toml + - name: Read MSRV from Cargo.toml + id: msrv + shell: bash + run: | + set -euo pipefail + if ! command -v jq >/dev/null 2>&1; then + sudo apt-get update -y && sudo apt-get install -y jq + fi + RV=$(cargo metadata --no-deps --format-version=1 | jq -r '.packages[0].rust_version // empty') + if [ -z "$RV" ]; then + echo "rust-version is not set in Cargo.toml" + exit 1 + fi + [[ "$RV" =~ ^[0-9]+\.[0-9]+$ ]] && RV="${RV}.0" + echo "msrv=${RV}" >> "$GITHUB_OUTPUT" + echo "Using MSRV: $RV" + + # Install MSRV for clippy/tests/package + - name: Install Rust (${{ steps.msrv.outputs.msrv }}) + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ steps.msrv.outputs.msrv }} + components: clippy + + # Pin nightly for rustfmt because unstable_features = true in .rustfmt.toml + - name: Install nightly rustfmt + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: nightly-2025-08-01 + components: rustfmt + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + + # Ensure Cargo.lock is present when CARGO_LOCKED=1 + - name: Verify lockfile is committed + shell: bash + run: | + set -euo pipefail + if [ ! -f Cargo.lock ]; then + echo "CARGO_LOCKED=1 but Cargo.lock is missing. Commit it or drop CARGO_LOCKED." + exit 1 + fi + + - name: Check formatting (nightly rustfmt) + run: cargo +nightly-2025-08-01 fmt --all -- --check + + - name: Clippy (MSRV) + shell: bash + run: | + set -euo pipefail + if [ "${{ inputs.all-features }}" = "true" ]; then + cargo +${{ steps.msrv.outputs.msrv }} clippy --workspace --all-targets --all-features -- -D warnings + else + cargo +${{ steps.msrv.outputs.msrv }} clippy --workspace --all-targets -- -D warnings + fi + + - name: Tests (MSRV) + shell: bash + run: | + set -euo pipefail + if [ "${{ inputs.all-features }}" = "true" ]; then + cargo +${{ steps.msrv.outputs.msrv }} test --workspace --all-features --no-fail-fast + else + cargo +${{ steps.msrv.outputs.msrv }} test --workspace --no-fail-fast + fi + + - name: Ensure tree is clean before package + shell: bash + run: | + set -euo pipefail + if ! git diff --quiet; then + echo "Working tree is dirty:" + git status --porcelain + exit 1 + fi + + - name: Package (dry-run) + run: cargo +${{ steps.msrv.outputs.msrv }} package --locked diff --git a/telegram-webapp-sdk/.gitignore b/telegram-webapp-sdk/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/telegram-webapp-sdk/.gitignore @@ -0,0 +1 @@ +/target diff --git a/telegram-webapp-sdk/.hooks/pre-commit b/telegram-webapp-sdk/.hooks/pre-commit new file mode 100755 index 0000000..ded56d9 --- /dev/null +++ b/telegram-webapp-sdk/.hooks/pre-commit @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "🔧 Ensuring nightly rustfmt is available..." +if ! rustup toolchain list | grep -q "^nightly"; then + rustup toolchain install nightly >/dev/null +fi +rustup component add rustfmt --toolchain nightly >/dev/null + +echo "🧹 Checking formatting with rustfmt..." +cargo +nightly fmt --all -- --check + +echo "🔍 Running clippy (all features, all targets)..." +cargo clippy --workspace --all-targets --all-features -- -D warnings + +echo "🧪 Running tests (all features)..." +cargo test --workspace --all-features + +# Uncomment if you want to validate SQLx offline data +# echo "📦 Validating SQLx prepare..." +# cargo sqlx prepare --check --workspace + +echo "✅ All checks passed!" + diff --git a/telegram-webapp-sdk/.rustfmt.toml b/telegram-webapp-sdk/.rustfmt.toml new file mode 100644 index 0000000..9f0782d --- /dev/null +++ b/telegram-webapp-sdk/.rustfmt.toml @@ -0,0 +1,25 @@ +# Не добавлять запятые, если элемент один +trailing_comma = "Never" + +# Скобки остаются на той же строке +brace_style = "SameLineWhere" + +# Выравнивать поля структуры, если длина меньше указанного порога +struct_field_align_threshold = 20 + +# Форматировать комментарии внутри документации +wrap_comments = true +format_code_in_doc_comments = true + +# Не складывать литералы структур в одну строку +struct_lit_single_line = false + +max_width = 99 + +# Группировка импортов +imports_granularity = "Crate" # Группировать импорты по крейтам +group_imports = "StdExternalCrate" # Стандартные, внешние и локальные импорты в отдельных группах +reorder_imports = true # Сортировать импорты внутри групп + +# Включить поддержку нестабильных функций (только для nightly) +unstable_features = true diff --git a/telegram-webapp-sdk/.scripts/release.sh b/telegram-webapp-sdk/.scripts/release.sh new file mode 100755 index 0000000..7af14a2 --- /dev/null +++ b/telegram-webapp-sdk/.scripts/release.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Определяем последнюю версию из CHANGELOG.md (первая строка, начинающаяся с "## [") +TAG=$(grep -m1 -oP '^## \[\K[0-9]+\.[0-9]+\.[0-9]+' CHANGELOG.md) +TAG="v$TAG" + +echo "📦 Preparing release for $TAG" + +# Проверяем, существует ли уже релиз +if gh release view "$TAG" >/dev/null 2>&1; then + echo "⚠️ Release $TAG already exists on GitHub. Nothing to do." + exit 0 +fi + +# Вырезаем секцию для этого тега +notes=$(awk "/^## \\[$(echo "$TAG" | sed 's/^v//')\\]/ {flag=1; next} /^## \\[/ && flag {exit} flag" CHANGELOG.md) + +if [ -z "$notes" ]; then + echo "❌ Could not extract changelog section for $TAG" + exit 1 +fi + +# Создаём релиз +gh release create "$TAG" \ + --title "$TAG" \ + --notes "$notes" + +echo "✅ GitHub release $TAG created." + diff --git a/telegram-webapp-sdk/CHANGELOG.md b/telegram-webapp-sdk/CHANGELOG.md new file mode 100644 index 0000000..ab7af4f --- /dev/null +++ b/telegram-webapp-sdk/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.2.0] - 2025-09-12 +### Changed +- Integrated macros into the main crate; `telegram-webapp-sdk-macros` crate removed. +- Replaced attribute macros with declarative macros `telegram_app!`, `telegram_page!`, and `telegram_router!`. + +## [0.1.1] - 2025-09-12 +### Added +- Implemented `CloudStorage.setItems`. + +## [0.1.0] - 2025-09-12 +### Added +- Initial release with core WebApp utilities, Yew and Leptos integrations, + mock environment, and basic Bot API type definitions. +- User API wrappers: `request_contact`, `request_phone_number`, and `open_contact`. +- Accelerometer, gyroscope, and device orientation sensor APIs with start/stop, + value reading and event callbacks. +- Home screen utilities: `add_to_home_screen` and `check_home_screen_status`. diff --git a/telegram-webapp-sdk/Cargo.lock b/telegram-webapp-sdk/Cargo.lock new file mode 100644 index 0000000..5e5ce58 --- /dev/null +++ b/telegram-webapp-sdk/Cargo.lock @@ -0,0 +1,3079 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "any_spawner" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d" +dependencies = [ + "futures", + "thiserror 2.0.16", + "wasm-bindgen-futures", +] + +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "attribute-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0053e96dd3bec5b4879c23a138d6ef26f2cb936c9cdc96274ac2b9ed44b5bb54" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "463b53ad0fd5b460af4b1915fe045ff4d946d025fb6c4dc3337752eaa980f71b" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn 2.0.106", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "boolinator" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "camino" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0b03af37dad7a14518b7691d81acb0f8222604ad3d1b02f6b4bed5188c0cd5" + +[[package]] +name = "cc" +version = "1.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5252b3d2648e5eedbc1a6f501e3c795e07025c1e93bbf8bbdd6eef7f447a6d54" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "codee" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8bbfdadf2f8999c6e404697bc08016dce4a3d77dec465b36c9a0652fdb3327" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.16", +] + +[[package]] +name = "collection_literals" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b3f65b8fb8e88ba339f7d23a390fe1b0896217da05e2a66c584c9b29a91df8" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0faa974509d38b33ff89282db9c3295707ccf031727c0de9772038ec526852ba" +dependencies = [ + "convert_case 0.6.0", + "pathdiff", + "serde", + "toml 0.9.5", + "winnow 0.7.13", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-str" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451d0640545a0553814b4c646eb549343561618838e9b42495f466131fe3ad49" + +[[package]] +name = "const_format" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126f97965c8ad46d6d9163268ff28432e8f6a1196a55578867832e3049df63dd" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "const_str_slice_concat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "derive-where" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "either_of" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216d23e0ec69759a17f05e1c553f3a6870e5ec73420fbb07807a6f34d5d1d5a4" +dependencies = [ + "paste", + "pin-project-lite", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", + "num_cpus", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.5+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gloo" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28999cda5ef6916ffd33fb4a7b87e1de633c47c0dc6d97905fee1cdaa142b94d" +dependencies = [ + "gloo-console 0.2.3", + "gloo-dialogs 0.1.1", + "gloo-events 0.1.2", + "gloo-file 0.2.3", + "gloo-history 0.1.5", + "gloo-net 0.3.1", + "gloo-render 0.1.1", + "gloo-storage 0.2.2", + "gloo-timers 0.2.6", + "gloo-utils 0.1.7", + "gloo-worker 0.2.1", +] + +[[package]] +name = "gloo" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd35526c28cc55c1db77aed6296de58677dbab863b118483a27845631d870249" +dependencies = [ + "gloo-console 0.3.0", + "gloo-dialogs 0.2.0", + "gloo-events 0.2.0", + "gloo-file 0.3.0", + "gloo-history 0.2.2", + "gloo-net 0.4.0", + "gloo-render 0.2.0", + "gloo-storage 0.3.0", + "gloo-timers 0.3.0", + "gloo-utils 0.2.0", + "gloo-worker 0.4.0", +] + +[[package]] +name = "gloo-console" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-console" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67062364ac72d27f08445a46cab428188e2e224ec9e37efdba48ae8c289002e6" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4748e10122b01435750ff530095b1217cf6546173459448b83913ebe7815df" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c26fb45f7c385ba980f5fa87ac677e363949e065a083722697ef1b2cc91e41" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7" +dependencies = [ + "gloo-events 0.1.2", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f" +dependencies = [ + "gloo-events 0.2.0", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85725d90bf0ed47063b3930ef28e863658a7905989e9929a8708aab74a1d5e7f" +dependencies = [ + "gloo-events 0.1.2", + "gloo-utils 0.1.7", + "serde", + "serde-wasm-bindgen 0.5.0", + "serde_urlencoded", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6" +dependencies = [ + "getrandom 0.2.16", + "gloo-events 0.2.0", + "gloo-utils 0.2.0", + "serde", + "serde-wasm-bindgen 0.6.5", + "serde_urlencoded", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.1.7", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac9e8288ae2c632fa9f8657ac70bfe38a1530f345282d7ba66a1f70b72b7dc4" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 1.3.1", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-render" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd9306aef67cfd4449823aadcd14e3958e0800aa2183955a309112a84ec7764" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-render" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56008b6744713a8e8d98ac3dcb7d06543d5662358c9c805b4ce2167ad4649833" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-storage" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-storage" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13471584da78061a28306d1359dd0178d8d6fc1c7c80e5e35d27260346e0516a" +dependencies = [ + "anymap2", + "bincode", + "gloo-console 0.2.3", + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76495d3dd87de51da268fa3a593da118ab43eb7f8809e17eb38d3319b424e400" +dependencies = [ + "bincode", + "futures", + "gloo-utils 0.2.0", + "gloo-worker-macros", + "js-sys", + "pinned", + "serde", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956caa58d4857bc9941749d55e4bd3000032d8212762586fa5705632967140e7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac-sha256" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad6880c8d4a9ebf39c6e8b77007ce223f646a4d21ce29d99f70cb16420545425" + +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "hydration_context" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8714ae4adeaa846d838f380fbd72f049197de629948f91bf045329e0cf0a283" +dependencies = [ + "futures", + "once_cell", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "implicit-clone" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a9aa791c7b5a71b636b7a68207fdebf171ddfc593d9c8506ec4cbc527b6a84" +dependencies = [ + "implicit-clone-derive", + "indexmap", +] + +[[package]] +name = "implicit-clone-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699c1b6d335e63d0ba5c1e1c7f647371ce989c3bcbe1f7ed2b85fa56e3bd1a21" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "indexmap" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206a8042aec68fa4a62e8d3f7aa4ceb508177d9324faf261e1959e495b7a1921" +dependencies = [ + "equivalent", + "hashbrown 0.15.5", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leptos" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a8710b4908a0e7b693113b906e4cf1bc87123b685404d090cdcd3e220bcab4" +dependencies = [ + "any_spawner", + "cfg-if", + "either_of", + "futures", + "getrandom 0.3.3", + "hydration_context", + "leptos_config", + "leptos_dom", + "leptos_hot_reload", + "leptos_macro", + "leptos_server", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn", + "slotmap", + "tachys", + "thiserror 2.0.16", + "throw_error", + "typed-builder", + "typed-builder-macro", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_split_helpers", + "web-sys", +] + +[[package]] +name = "leptos_config" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240b4cb96284256a44872563cf029f24d6fe14bc341dcf0f4164e077cb5a1471" +dependencies = [ + "config", + "regex", + "serde", + "thiserror 2.0.16", + "typed-builder", +] + +[[package]] +name = "leptos_dom" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e920c8b2fd202b25786b0c72a00c745a6962fa923e600df6f3ec352d844be91" +dependencies = [ + "js-sys", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "tachys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_hot_reload" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d61ec3e1ff8aaee8c5151688550c0363f85bc37845450764c31ff7584a33f38" +dependencies = [ + "anyhow", + "camino", + "indexmap", + "parking_lot", + "proc-macro2", + "quote", + "rstml", + "serde", + "syn 2.0.106", + "walkdir", +] + +[[package]] +name = "leptos_macro" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84c7e53895c786f1128e91c36a708435e301f487338d19f2f6b5b67bb39ece2" +dependencies = [ + "attribute-derive", + "cfg-if", + "convert_case 0.8.0", + "html-escape", + "itertools", + "leptos_hot_reload", + "prettyplease", + "proc-macro-error2", + "proc-macro2", + "quote", + "rstml", + "rustc_version", + "server_fn_macro", + "syn 2.0.106", + "uuid", +] + +[[package]] +name = "leptos_server" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38acbf32649a4b127c8d4ccaed8fb388e19a746430a0ea8f8160e51e28c36e2d" +dependencies = [ + "any_spawner", + "base64", + "codee", + "futures", + "hydration_context", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "serde", + "serde_json", + "server_fn", + "tachys", +] + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "linear-map" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfae20f6b19ad527b550c223fddc3077a547fc70cda94b9b566575423fd303ee" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "masterror" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e7c3a243a6f697e05d0b971c22d0ac029b9080c20b2bbc5f4a3f43ea6024a60" +dependencies = [ + "http 1.3.1", + "serde", + "thiserror 2.0.16", + "tracing", +] + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "minicov" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27fe9f1cc3c22e1687f9446c2083c4c5fc7f0bcf1c7a86bdbded14985895b4b" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "next_tuple" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "oco_ref" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d" +dependencies = [ + "serde", + "thiserror 2.0.16", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "or_poisoned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pinned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a829027bd95e54cfe13e3e258a1ae7b645960553fb82b75ff852c29688ee595b" +dependencies = [ + "futures", + "rustversion", + "thiserror 1.0.69", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.106", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "version_check", + "yansi", +] + +[[package]] +name = "prokio" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b55e106e5791fa5a13abd13c85d6127312e8e09098059ca2bc9b03ca4cf488" +dependencies = [ + "futures", + "gloo 0.8.1", + "num_cpus", + "once_cell", + "pin-project", + "pinned", + "tokio", + "tokio-stream", + "wasm-bindgen-futures", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "reactive_graph" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27e4f808d01701256dc220e398d518684781bcd1b3b1a6c1c107fd41374f0624" +dependencies = [ + "any_spawner", + "async-lock", + "futures", + "guardian", + "hydration_context", + "indexmap", + "or_poisoned", + "pin-project-lite", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "slotmap", + "thiserror 2.0.16", + "web-sys", +] + +[[package]] +name = "reactive_stores" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79983e88dfd1a2925e29a4853ab9161b234ea78dd0d44ed33a706c9cd5e35762" +dependencies = [ + "dashmap", + "guardian", + "itertools", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores_macro", + "rustc-hash", + "send_wrapper", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa40919eb2975100283b2a70e68eafce1e8bcf81f0622ff168e4c2b3f8d46bb" +dependencies = [ + "convert_case 0.8.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "rstml" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56" +dependencies = [ + "derive-where", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.106", + "syn_derive", + "thiserror 2.0.16", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.16", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "server_fn" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4efa7bb741386fb31a68269c81b1469c917d9adb1f4102a2d2684f11e3235389" +dependencies = [ + "base64", + "bytes", + "const-str", + "const_format", + "dashmap", + "futures", + "gloo-net 0.6.0", + "http 1.3.1", + "js-sys", + "pin-project-lite", + "rustc_version", + "rustversion", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn_macro_default", + "thiserror 2.0.16", + "throw_error", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d1a916793571234d1c4622153d42495d26605ed7b9d5d38a2699666cfef46b3" +dependencies = [ + "const_format", + "convert_case 0.8.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.106", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00" +dependencies = [ + "server_fn_macro", + "syn 2.0.106", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "slotmap" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tachys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dacbb26ffb2bbe6743702ee27c3e994c0caae86c92137278de9a9d92d383765c" +dependencies = [ + "any_spawner", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "either_of", + "erased", + "futures", + "html-escape", + "indexmap", + "itertools", + "js-sys", + "linear-map", + "next_tuple", + "oco_ref", + "or_poisoned", + "parking_lot", + "paste", + "reactive_graph", + "reactive_stores", + "rustc-hash", + "rustc_version", + "send_wrapper", + "slotmap", + "throw_error", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "telegram-webapp-sdk" +version = "0.2.0" +dependencies = [ + "base64", + "ed25519-dalek", + "hex", + "hmac-sha256", + "inventory", + "js-sys", + "leptos", + "masterror", + "once_cell", + "percent-encoding", + "serde", + "serde-wasm-bindgen 0.6.5", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.16", + "toml 0.8.23", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "yew", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl 2.0.16", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "throw_error" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e42a6afdde94f3e656fae18f837cb9bbe500a5ac5de325b09f3ec05b9c28e3" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "io-uring", + "libc", + "mio", + "pin-project-lite", + "slab", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +dependencies = [ + "serde", + "serde_spanned 1.0.0", + "toml_datetime 0.7.0", + "toml_parser", + "winnow 0.7.13", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.13", +] + +[[package]] +name = "toml_parser" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +dependencies = [ + "winnow 0.7.13", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typed-builder" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef81aec2ca29576f9f6ae8755108640d0a86dd3161b2e8bca6cfa554e98f77d" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecb9ecf7799210407c14a8cfdfe0173365780968dc57973ed082211958e0b18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.5+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4494f6290a82f5fe584817a676a34b9d6763e8d9d18204009fb31dceca98fd4" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.0+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03fa2761397e5bd52002cd7e73110c71af2109aca4e521a9f40473fe685b0a24" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca85039a9b469b38336411d6d6ced91f3fc87109a2a27b0c197663f5144dffe" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80cc7f8a4114fdaa0c58383caf973fc126cf004eba25c9dc639bccd3880d55ad" +dependencies = [ + "js-sys", + "minicov", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ada2ab788d46d4bda04c9d567702a79c8ced14f51f221646a16ed39d0e6a5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm_split_helpers" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50e0e45d0d871605a21fc4ee93a5380b7bdc41b5eda22e42f0777a4ce79b65c" +dependencies = [ + "async-once-cell", + "or_poisoned", + "wasm_split_macros", +] + +[[package]] +name = "wasm_split_macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f8a7f0bf54b0129a337aadfe8b716d843689f69c75b2a6413a0cff2e0d00982" +dependencies = [ + "base16", + "digest", + "quote", + "sha2", + "syn 2.0.106", + "wasm-bindgen", +] + +[[package]] +name = "web-sys" +version = "0.3.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.0", +] + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yew" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f1a03f255c70c7aa3e9c62e15292f142ede0564123543c1cc0c7a4f31660cac" +dependencies = [ + "console_error_panic_hook", + "futures", + "gloo 0.10.0", + "implicit-clone", + "indexmap", + "js-sys", + "prokio", + "rustversion", + "serde", + "slab", + "thiserror 1.0.69", + "tokio", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew-macro", +] + +[[package]] +name = "yew-macro" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fd8ca5166d69e59f796500a2ce432ff751edecbbb308ca59fd3fe4d0343de2" +dependencies = [ + "boolinator", + "once_cell", + "prettyplease", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] diff --git a/telegram-webapp-sdk/Cargo.toml b/telegram-webapp-sdk/Cargo.toml new file mode 100644 index 0000000..ce76043 --- /dev/null +++ b/telegram-webapp-sdk/Cargo.toml @@ -0,0 +1,149 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +rust-version = "1.89" +name = "telegram-webapp-sdk" +version = "0.2.0" +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Telegram WebApp SDK for Rust" +documentation = "https://docs.rs/telegram-webapp-sdk" +readme = "README.md" +keywords = [ + "telegram", + "webapp", + "wasm", +] +categories = [ + "web-programming", + "wasm", +] +license = "MIT OR Apache-2.0" +repository = "https://github.com/RAprogramm/telegram-webapp-sdk" + +[features] +default = [] +leptos = ["dep:leptos"] +macros = ["dep:inventory"] +mock = ["dep:urlencoding"] +yew = ["dep:yew"] + +[lib] +name = "telegram_webapp_sdk" +crate-type = [ + "cdylib", + "rlib", +] +path = "src/lib.rs" + +[[test]] +name = "closing_confirmation" +path = "tests/closing_confirmation.rs" + +[[test]] +name = "validate_init_data" +path = "tests/validate_init_data.rs" + +[dependencies.base64] +version = "0.22" + +[dependencies.ed25519-dalek] +version = "2" + +[dependencies.hex] +version = "0.4" + +[dependencies.hmac-sha256] +version = "1" + +[dependencies.inventory] +version = "0.3" +optional = true + +[dependencies.js-sys] +version = "0.3" + +[dependencies.leptos] +version = "0.8" +features = ["csr"] +optional = true +default-features = false + +[dependencies.masterror] +path = ".." +version = "0.10.4" +optional = true + +[dependencies.once_cell] +version = "1.21" + +[dependencies.percent-encoding] +version = "2" + +[dependencies.serde] +version = "1" +features = ["derive"] + +[dependencies.serde-wasm-bindgen] +version = "0.6" + +[dependencies.serde_json] +version = "1" + +[dependencies.serde_urlencoded] +version = "0.7" + +[dependencies.thiserror] +version = "2" + +[dependencies.toml] +version = "0.8" + +[dependencies.urlencoding] +version = "2" +optional = true + +[dependencies.wasm-bindgen] +version = "0.2" + +[dependencies.wasm-bindgen-futures] +version = "0.4" + +[dependencies.web-sys] +version = "0.3" +features = [ + "Event", + "Window", + "Document", + "Element", + "HtmlElement", + "console", + "Location", + "CssStyleDeclaration", +] + +[dependencies.yew] +version = "0.21" +features = ["csr"] +optional = true +default-features = false + +[dev-dependencies.wasm-bindgen-futures] +version = "0.4" + +[dev-dependencies.wasm-bindgen-test] +version = "0.3" diff --git a/telegram-webapp-sdk/Cargo.toml.orig b/telegram-webapp-sdk/Cargo.toml.orig new file mode 100644 index 0000000..18a79be --- /dev/null +++ b/telegram-webapp-sdk/Cargo.toml.orig @@ -0,0 +1,75 @@ +[package] +name = "telegram-webapp-sdk" +version = "0.2.0" +rust-version = "1.89" +edition = "2024" +description = "Telegram WebApp SDK for Rust" +license = "MIT OR Apache-2.0" +repository = "https://github.com/RAprogramm/telegram-webapp-sdk" +readme = "README.md" +documentation = "https://docs.rs/telegram-webapp-sdk" +keywords = ["telegram", "webapp", "wasm"] +categories = ["web-programming", "wasm"] + +[workspace.package] +rust-version = "1.89" + + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde-wasm-bindgen = "0.6" +serde_urlencoded = "0.7" +once_cell = "1.21" +js-sys = "0.3" +web-sys = { version = "0.3", features = [ + "Event", + "Window", + "Document", + "Element", + "HtmlElement", + "console", + "Location", + "CssStyleDeclaration", +] } +hmac-sha256 = "1" +hex = "0.4" +percent-encoding = "2" +base64 = "0.22" +ed25519-dalek = "2" +thiserror = "2" +masterror = { path = "..", version = "0.10.4", optional = true } +urlencoding = { version = "2", optional = true } +inventory = { version = "0.3", optional = true } +toml = "0.8" + +[dependencies.yew] +version = "0.21" +optional = true +default-features = false +features = ["csr"] + +[dependencies.leptos] +version = "0.8" +optional = true +default-features = false +features = ["csr"] + +[features] +default = [] +macros = ["dep:inventory"] +yew = ["dep:yew"] +leptos = ["dep:leptos"] +mock = ["dep:urlencoding"] + +[workspace] +members = ["demo"] + +[dev-dependencies] +wasm-bindgen-test = "0.3" +wasm-bindgen-futures = "0.4" diff --git a/telegram-webapp-sdk/LICENSE-APACHE b/telegram-webapp-sdk/LICENSE-APACHE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/telegram-webapp-sdk/LICENSE-APACHE @@ -0,0 +1,202 @@ + + 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 + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + http://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. diff --git a/telegram-webapp-sdk/LICENSE-MIT b/telegram-webapp-sdk/LICENSE-MIT new file mode 100644 index 0000000..a6632f7 --- /dev/null +++ b/telegram-webapp-sdk/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 RAprogramm contributors + +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. diff --git a/telegram-webapp-sdk/Makefile.toml b/telegram-webapp-sdk/Makefile.toml new file mode 100644 index 0000000..5bcb647 --- /dev/null +++ b/telegram-webapp-sdk/Makefile.toml @@ -0,0 +1,327 @@ +# Makefile.toml for cargo-make +# Usage: +# cargo make # pretty help (default) +# cargo make ci # format, clippy, tests, package +# cargo make tag # create git tag from Cargo.toml version (no push) +# TAG=v0.3.0 cargo make release +# CARGO_REGISTRY_TOKEN=... TAG=v0.3.0 cargo make publish + +[env] +ALL_FEATURES = "true" # toggle --all-features for clippy/test + +# ------- Default task (no root 'default_task' key) ------- + +[tasks.default] +category = "Meta" +description = "Default task -> help" +run_task = "help" + +# ------- Core checks ------- + +[tasks.format] +category = "Format" +clear = true +description = "Format code using rustfmt on nightly (optional)" +script_runner = "bash" +script = ['cargo +nightly fmt --'] + +[tasks.clippy] +category = "Lint" +clear = true +script_runner = "bash" +script = [''' + set -euo pipefail + if [ "${ALL_FEATURES}" = "true" ]; then + cargo clippy --workspace --all-targets --all-features -- -D warnings + else + cargo clippy --workspace --all-targets -- -D warnings + fi +'''] +description = "Run clippy for all targets; fail on warnings" + +[tasks.test] +category = "Test" +clear = true +script_runner = "bash" +script = [''' + set -euo pipefail + if [ "${ALL_FEATURES}" = "true" ]; then + cargo test --workspace --all-features --no-fail-fast + else + cargo test --workspace --no-fail-fast + fi +'''] +description = "Run tests (optionally with all features)" + +[tasks.package] +category = "Package" +clear = true +command = "cargo" +args = ["package", "--locked"] +description = "Verify that the crate can be packaged cleanly" + +[tasks.ci] +category = "Meta" +dependencies = ["format", "clippy", "test", "package"] +description = "Run format, clippy, tests, and packaging checks" + +# ------- Release gatekeeping ------- + +[tasks.check_tag_env] +clear = true +script_runner = "bash" +script = [''' + set -euo pipefail + # Ensure TAG is provided, e.g., TAG=v1.2.3 or refs/tags/v1.2.3 + if [ -z "${TAG:-}" ]; then + echo "TAG env var is required, e.g. TAG=v1.2.3" + exit 1 + fi +'''] +description = "Ensure TAG env var is provided (e.g. TAG=v1.2.3)" + +[tasks.ensure_tag_matches_version] +clear = true +script_runner = "bash" +script = [ + ''' + set -euo pipefail + # Normalize TAG from refs/tags/vX.Y.Z -> vX.Y.Z + TAG="${TAG#refs/tags/}" + + # Prefer jq; fallback to sed. Select the root package of the workspace. + if command -v jq >/dev/null 2>&1; then + FILE_VER="$(cargo metadata --no-deps --format-version=1 \ + | jq -r '.workspace_root as $root + | .packages[] + | select(.manifest_path == ($root + "/Cargo.toml")) + | .version')" + else + META="$(cargo metadata --no-deps --format-version=1)" + # Best-effort sed fallback: first "version" occurrence + FILE_VER="$(printf "%s" "$META" | sed -n 's/.*\"version\":\"\\([^\"]*\\)\".*/\\1/p' | head -n1)" + fi + + if [ -z "${FILE_VER}" ]; then + echo "Unable to parse version from cargo metadata." + exit 1 + fi + + if [ "${TAG}" != "v${FILE_VER}" ]; then + echo "Tag ${TAG} != Cargo.toml version v${FILE_VER}" + exit 1 + fi + + echo "Tag ${TAG} matches Cargo.toml version v${FILE_VER}" +''', +] +dependencies = ["check_tag_env"] +description = "Ensure git tag matches Cargo.toml version (workspace root package)" + +[tasks.ensure_git_tag_exists] +clear = true +script_runner = "bash" +script = [''' + set -euo pipefail + # Verify tag exists in the local repo + T="${TAG#refs/tags/}" + if ! git rev-parse -q --verify "refs/tags/${T}" >/dev/null; then + echo "Git tag '${T}' not found locally. Create and push it first:" + echo " cargo make tag && git push origin ${T}" + exit 1 + fi +'''] +dependencies = ["check_tag_env"] +description = "Ensure the git tag exists locally" + +[tasks.check_token] +clear = true +script_runner = "bash" +script = [''' + set -euo pipefail + if [ -z "${CARGO_REGISTRY_TOKEN:-}" ]; then + echo "CARGO_REGISTRY_TOKEN is required to publish." + exit 1 + fi +'''] +description = "Ensure crates.io token is present in env" + +# ------- Publish & Release ------- + +[tasks.publish] +category = "Release" +clear = true +script_runner = "bash" +script = [''' + set -euo pipefail + cargo publish --locked --token "${CARGO_REGISTRY_TOKEN}" +'''] +dependencies = [ + "check_token", + "ensure_tag_matches_version", + "ensure_git_tag_exists", + "ci", +] +description = "Publish crate to crates.io after checks and tag/version verification" + +[tasks.tag] +category = "Release" +clear = true +script_runner = "bash" +script = [ + ''' + set -euo pipefail + + # Prefer jq; fallback to sed. Select the root package of the workspace. + if command -v jq >/dev/null 2>&1; then + VER="$(cargo metadata --no-deps --format-version=1 \ + | jq -r '.workspace_root as $root + | .packages[] + | select(.manifest_path == ($root + "/Cargo.toml")) + | .version')" + NAME="$(cargo metadata --no-deps --format-version=1 \ + | jq -r '.workspace_root as $root + | .packages[] + | select(.manifest_path == ($root + "/Cargo.toml")) + | .name')" + else + META="$(cargo metadata --no-deps --format-version=1)" + # Fallback: first name/version occurrences + VER="$(printf "%s" "$META" | sed -n 's/.*\"version\":\"\\([^\"]*\\)\".*/\\1/p' | head -n1)" + NAME="$(printf "%s" "$META" | sed -n 's/.*\"name\":\"\\([^\"]*\\)\".*/\\1/p' | head -n1)" + fi + + if [ -z "${VER}" ]; then + echo "Unable to extract version from Cargo metadata." + exit 1 + fi + + TAG="v${VER}" + + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Tag ${TAG} already exists." + exit 1 + fi + + # Lightweight tag with message for traceability + git tag -a "${TAG}" -m "${NAME:-crate} ${TAG}" + echo "Created tag ${TAG}. Push it with:" + echo " git push origin ${TAG}" +''', +] +description = "Create annotated git tag vX.Y.Z from Cargo.toml version (does not push)" + +[tasks.release] +category = "Release" +clear = true +dependencies = ["publish"] +description = "Run checks and publish the crate (requires TAG and token)" + +# ------- Hooks ------- + +[tasks.install-hooks] +clear = true +workspace = false +description = "Install git pre-commit hook from .hooks/" +script_runner = "bash" +script = [ + 'set -euo pipefail', + 'if [ ! -f .hooks/pre-commit ]; then echo "❌ .hooks/pre-commit not found!"; exit 1; fi', + 'echo "🔗 Linking .hooks/pre-commit to .git/hooks/pre-commit..."', + 'mkdir -p .git/hooks', + 'ln -sf ../../.hooks/pre-commit .git/hooks/pre-commit', + 'chmod +x .hooks/pre-commit', + 'echo "✅ pre-commit hook installed."', +] + +# ------- Pretty Help ------- + +[tasks.help] +clear = true +category = "Meta" +description = "Pretty, colored help with examples" +script_runner = "bash" +script = [ + ''' + set -euo pipefail + + if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + BOLD="\033[1m"; DIM="\033[2m"; RESET="\033[0m" + BLUE="\033[34m"; CYAN="\033[36m"; GREEN="\033[32m"; YELLOW="\033[33m"; MAGENTA="\033[35m" + else + BOLD=""; DIM=""; RESET=""; BLUE=""; CYAN=""; GREEN=""; YELLOW=""; MAGENTA="" + fi + + hr() { printf "%s\n" "──────────────────────────────────────────────────────────────────────────────"; } + row() { printf " %b%-28s%b %b%s%b\n" "$GREEN" "$1" "$RESET" "$DIM" "$2" "$RESET"; } + + # Prefer jq; fallback to sed. Root package only. + if command -v jq >/dev/null 2>&1; then + META="$(cargo metadata --no-deps --format-version=1)" + VER="$(printf "%s" "$META" | jq -r '.workspace_root as $root + | .packages[] + | select(.manifest_path == ($root + "/Cargo.toml")) + | .version')" + MSRV="$(printf "%s" "$META" | jq -r '.workspace_root as $root + | .packages[] + | select(.manifest_path == ($root + "/Cargo.toml")) + | .rust_version // "unknown"')" + NAME="$(printf "%s" "$META" | jq -r '.workspace_root as $root + | .packages[] + | select(.manifest_path == ($root + "/Cargo.toml")) + | .name')" + else + META="$(cargo metadata --no-deps --format-version=1)" + VER="$(printf "%s" "$META" | sed -n 's/.*\"version\":\"\\([^\"]*\\)\".*/\\1/p' | head -n1)" + MSRV="$(printf "%s" "$META" | sed -n 's/.*\"rust_version\":\"\\([^\"]*\\)\".*/\\1/p' | head -n1)"; [ -z "$MSRV" ] && MSRV="unknown" + NAME="$(printf "%s" "$META" | sed -n 's/.*\"name\":\"\\([^\"]*\\)\".*/\\1/p' | head -n1)" + fi + + BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'unknown')" + LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || echo 'none')" + + printf "%b%s%b %b(%s, MSRV %s, branch %s, last tag %s)%b\n" \ + "$BOLD$BLUE" "${NAME:-crate} development tasks" "$RESET" \ + "$DIM" "version ${VER:-unknown}" "${MSRV:-unknown}" "${BRANCH}" "${LAST_TAG}" "$RESET" + hr + + printf "%b%s%b\n" "$BOLD$MAGENTA" "Common commands" "$RESET" + row "cargo make ci" "Run format, clippy, tests, and packaging" + row "cargo make tag" "Create git tag v${VER} from Cargo.toml (no push)" + row "TAG=v${VER} cargo make release" "Run checks and publish to crates.io" + row "ALL_FEATURES=false cargo make ci" "Run CI without --all-features" + echo + + printf "%b%s%b\n" "$BOLD$MAGENTA" "Formatting" "$RESET" + row "cargo make format" "Format with nightly rustfmt (unstable features)" + echo + + printf "%b%s%b\n" "$BOLD$MAGENTA" "Lint & Tests" "$RESET" + row "cargo make clippy" "Run clippy --workspace --all-targets -D warnings" + row "cargo make test" "Run tests --workspace" + echo + + printf "%b%s%b\n" "$BOLD$MAGENTA" "Packaging & Release" "$RESET" + row "cargo make package" "Dry-run packaging (--locked)" + row "cargo make publish" "Publish to crates.io (requires TAG and token)" + echo + + printf "%b%s%b\n" "$BOLD$DIM" "Environment variables:" "$RESET" + printf " %b%-22s%b %s\n" "$CYAN" "ALL_FEATURES=true|false" "$RESET" "Enable/disable --all-features for clippy/test" + printf " %b%-22s%b %s\n" "$CYAN" "TAG=vX.Y.Z" "$RESET" "Git tag matching Cargo.toml version" + printf " %b%-22s%b %s\n" "$CYAN" "CARGO_REGISTRY_TOKEN" "$RESET" "crates.io token for publish" + echo + + printf "%b%s%b\n" "$BOLD$DIM" "Tips:" "$RESET" + printf " • Keep %bCargo.lock%b committed\n" "$BOLD" "$RESET" + printf " • Tag must exactly match version: %bv%s%b\n" "$BOLD" "${VER}" "$RESET" + echo +''', +] + +# Alias +[tasks.h] +clear = true +run_task = "help" +category = "Meta" +description = "Alias: help" diff --git a/telegram-webapp-sdk/README.md b/telegram-webapp-sdk/README.md new file mode 100644 index 0000000..920a08b --- /dev/null +++ b/telegram-webapp-sdk/README.md @@ -0,0 +1,462 @@ + + +# Telegram WebApp SDK + +[![Crates.io](https://img.shields.io/crates/v/telegram-webapp-sdk)](https://crates.io/crates/telegram-webapp-sdk) +[![docs.rs](https://img.shields.io/docsrs/telegram-webapp-sdk)](https://docs.rs/telegram-webapp-sdk) +[![Downloads](https://img.shields.io/crates/d/telegram-webapp-sdk)](https://crates.io/crates/telegram-webapp-sdk) +![MSRV](https://img.shields.io/badge/MSRV-1.89-blue) +![License](https://img.shields.io/badge/License-MIT%20or%20Apache--2.0-informational) +[![CI](https://github.com/RAprogramm/telegram-webapp-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/RAprogramm/telegram-webapp-sdk/actions/workflows/ci.yml) + +`telegram-webapp-sdk` provides a type-safe and ergonomic wrapper around the [Telegram Web Apps](https://core.telegram.org/bots/webapps) JavaScript API. + +## Features + +- Comprehensive coverage of Telegram Web App JavaScript APIs. +- Framework integrations for **Yew** and **Leptos**. +- Optional macros for automatic initialization and routing. + +## Macros + +The macros are available with the `macros` feature. Enable it in your `Cargo.toml`: + +```toml +telegram-webapp-sdk = { version = "0.2", features = ["macros"] } +``` + +Reduce boilerplate in Telegram Mini Apps using the provided macros: + +```rust,ignore +telegram_page!("/", fn index() { + // render page +}); + +telegram_app!(fn main() -> Result<(), wasm_bindgen::JsValue> { + telegram_router!(); + Ok(()) +}); +``` + +When running outside Telegram in debug builds, `telegram_app!` loads mock +settings from `telegram-webapp.toml`. +- Configurable mock `Telegram.WebApp` for local development and testing. +- API helpers for user interactions, storage, device sensors and more. + +## Table of contents + +- [Installation](#installation) +- [Quick start](#quick-start) +- [Mock environment](#mock-environment) +- [User interactions](#user-interactions) +- [Keyboard control](#keyboard-control) +- [API coverage](#api-coverage) +- [Changelog](#changelog) +- [License](#license) + +## Installation + +Add the crate to your `Cargo.toml`: + +```toml +[dependencies] +telegram-webapp-sdk = "0.2" +``` + +Enable optional features as needed: + +```toml +telegram-webapp-sdk = { version = "0.2", features = ["macros", "yew", "mock"] } +``` + +- `macros` — enables `telegram_app!`, `telegram_page!`, and `telegram_router!`. +- `yew` — exposes a `use_telegram_context` hook. +- `leptos` — integrates the context into the Leptos reactive system. +- `mock` — installs a configurable mock `Telegram.WebApp` for local development. + +## Quick start + +### Yew + +```rust,ignore +use telegram_webapp_sdk::yew::use_telegram_context; +use yew::prelude::*; + +#[function_component(App)] +fn app() -> Html { + let ctx = use_telegram_context().expect("context"); + html! { { ctx.init_data.auth_date } } +} +``` + +### Leptos + +```rust,ignore +use leptos::prelude::*; +use telegram_webapp_sdk::leptos::provide_telegram_context; + +#[component] +fn App() -> impl IntoView { + provide_telegram_context().expect("context"); + let ctx = use_context::() + .expect("context"); + view! { { ctx.init_data.auth_date } } +} +``` + +## Mock environment + +The `mock` feature simulates a `Telegram.WebApp` instance, enabling local development without Telegram: + +```rust,ignore +let config = telegram_webapp_sdk::mock::MockConfig::default(); +let ctx = telegram_webapp_sdk::mock::install(config)?; +``` + +## User interactions + +Request access to sensitive user data or open the contact interface: + +```rust,no_run +use telegram_webapp_sdk::api::user::{request_contact, request_phone_number, open_contact}; +use telegram_webapp_sdk::webapp::TelegramWebApp; + +# fn run() -> Result<(), wasm_bindgen::JsValue> { +request_contact()?; +request_phone_number()?; +open_contact()?; + +let app = TelegramWebApp::try_instance()?; +app.request_write_access(|granted| { + let _ = granted; +})?; +# Ok(()) +# } +``` + +These calls require the user's explicit permission before any information is shared. + +## Keyboard control + +Hide the native keyboard when it's no longer required: + +```rust,no_run +use telegram_webapp_sdk::webapp::TelegramWebApp; +# fn run() -> Result<(), wasm_bindgen::JsValue> { +let app = TelegramWebApp::try_instance()?; +app.hide_keyboard()?; +# Ok(()) +# } +``` + +## Closing confirmation + +Prompt users before the Mini App closes: + +```rust,no_run +use telegram_webapp_sdk::webapp::TelegramWebApp; +# fn run() -> Result<(), wasm_bindgen::JsValue> { +let app = TelegramWebApp::try_instance()?; +app.enable_closing_confirmation()?; +assert!(app.is_closing_confirmation_enabled()); +// later +app.disable_closing_confirmation()?; +# Ok(()) +# } +``` +## Invoice payments + +Open invoices and react to the final payment status: + +```rust,no_run +use telegram_webapp_sdk::webapp::TelegramWebApp; + +# fn run() -> Result<(), wasm_bindgen::JsValue> { +let app = TelegramWebApp::try_instance()?; +let handle = app.on_invoice_closed(|status| { + let _ = status; +})?; +app.open_invoice("https://invoice", |_status| {})?; +app.off_event(handle)?; +# Ok(()) +# } +``` +## Sharing + +Share links, prepared messages, or stories and join voice chats: + +```rust,no_run +use js_sys::Object; +use telegram_webapp_sdk::webapp::TelegramWebApp; + +# fn run() -> Result<(), wasm_bindgen::JsValue> { +let app = TelegramWebApp::try_instance()?; +app.share_url("https://example.com", Some("Check this out"))?; +app.join_voice_chat("chat", None)?; +app.share_message("msg-id", |sent| { + let _ = sent; +})?; +let params = Object::new(); +app.share_to_story("https://example.com/image.png", Some(¶ms.into()))?; +# Ok(()) +# } +``` + +## Settings button + +Control the Telegram client's settings button and handle user clicks: + +```rust,no_run +use telegram_webapp_sdk::api::settings_button::{show, hide, on_click, off_click}; +use wasm_bindgen::prelude::Closure; + +# fn run() -> Result<(), wasm_bindgen::JsValue> { +let cb = Closure::wrap(Box::new(|| {}) as Box); +on_click(&cb)?; +show()?; +hide()?; +off_click(&cb)?; +# Ok(()) +# } +``` + +## Cloud storage + +Persist small key-value pairs in Telegram's cloud using `CloudStorage`: + +```rust,no_run +use js_sys::Reflect; +use telegram_webapp_sdk::api::cloud_storage::{get_items, set_items}; +use wasm_bindgen_futures::JsFuture; + +# async fn run() -> Result<(), wasm_bindgen::JsValue> { +JsFuture::from(set_items(&[("counter", "1")])?).await?; +let obj = JsFuture::from(get_items(&["counter"])?).await?; +let value = Reflect::get(&obj, &"counter".into())?.as_string(); +assert_eq!(value, Some("1".into())); +# Ok(()) +# } +``` + +All functions return a `Promise` and require the Web App to run inside Telegram. + +## Home screen + +Prompt users to add the app to their home screen and check the current status: + +```rust,no_run +use telegram_webapp_sdk::webapp::TelegramWebApp; +# fn run() -> Result<(), wasm_bindgen::JsValue> { +let app = TelegramWebApp::try_instance()?; +let _shown = app.add_to_home_screen()?; +app.check_home_screen_status(|status| { + let _ = status; +})?; +# Ok(()) +# } +``` + +## Event callbacks + +Callback registration methods return an `EventHandle` for later deregistration. + +```rust,no_run +use telegram_webapp_sdk::webapp::TelegramWebApp; +# fn run() -> Result<(), wasm_bindgen::JsValue> { +let app = TelegramWebApp::try_instance()?; +let handle = app.on_event("my_event", |value| { + let _ = value; +})?; +app.off_event(handle)?; +# Ok(()) +# } +``` + +### Background events + +Some Telegram events may fire while the Mini App is in the background. Register +callbacks for these with `on_background_event`: + +```rust,no_run +use telegram_webapp_sdk::webapp::{BackgroundEvent, TelegramWebApp}; + +# fn run() -> Result<(), wasm_bindgen::JsValue> { +let app = TelegramWebApp::try_instance()?; +let handle = app.on_background_event(BackgroundEvent::MainButtonClicked, |_| {})?; +app.off_event(handle)?; +# Ok(()) +# } +``` + +Supported background events: + +| Event | Payload | +|-------|---------| +| `mainButtonClicked` | none | +| `backButtonClicked` | none | +| `settingsButtonClicked` | none | +| `writeAccessRequested` | `bool` granted flag | +| `contactRequested` | `bool` shared flag | +| `phoneRequested` | `bool` shared flag | +| `invoiceClosed` | status `String` | +| `popupClosed` | object `{ button_id: Option }` | +| `qrTextReceived` | scanned text `String` | +| `clipboardTextReceived` | clipboard text `String` | + +## Appearance + +Customize colors and react to theme or safe area updates: +## Fullscreen and orientation + +Control the Mini App display and screen orientation: + +```rust,no_run +use telegram_webapp_sdk::webapp::TelegramWebApp; +# fn run() -> Result<(), wasm_bindgen::JsValue> { +let app = TelegramWebApp::try_instance()?; +app.set_header_color("#000000")?; +app.set_background_color("#ffffff")?; +app.set_bottom_bar_color("#cccccc")?; +let theme_handle = app.on_theme_changed(|| {})?; +let safe_handle = app.on_safe_area_changed(|| {})?; +let content_handle = app.on_content_safe_area_changed(|| {})?; +// later: app.off_event(theme_handle)?; etc. + +app.request_fullscreen()?; +app.lock_orientation("portrait")?; +// later... +app.unlock_orientation()?; +app.exit_fullscreen()?; +# Ok(()) +# } +``` + +## Haptic feedback + +Trigger device vibrations through Telegram's [HapticFeedback](https://core.telegram.org/bots/webapps#hapticfeedback) API: + +```rust,no_run +use telegram_webapp_sdk::api::haptic::{ + impact_occurred, notification_occurred, selection_changed, + HapticImpactStyle, HapticNotificationType, +}; + +impact_occurred(HapticImpactStyle::Light)?; +notification_occurred(HapticNotificationType::Success)?; +selection_changed()?; +# Ok::<(), wasm_bindgen::JsValue>(()) +``` + +## Device storage + +Persist lightweight data on the user's device: + +```rust,no_run +use telegram_webapp_sdk::api::device_storage::{set, get}; + +# async fn run() -> Result<(), wasm_bindgen::JsValue> { +set("theme", "dark").await?; +let value = get("theme").await?; +# Ok(()) +# } +``` + +## Secure storage + +Store sensitive data encrypted and restorable: + +```rust,no_run +use telegram_webapp_sdk::api::secure_storage::{set, restore}; + +# async fn run() -> Result<(), wasm_bindgen::JsValue> { +set("token", "secret").await?; +let _ = restore("token").await?; +# Ok(()) +# } +``` + +## Location manager + +Retrieve user location and react to related events via Telegram's location manager: + +```rust,no_run +use telegram_webapp_sdk::api::location_manager::{ + init, get_location, open_settings, on_location_requested, +}; +use wasm_bindgen::closure::Closure; + +init()?; +let _ = get_location(); +open_settings()?; + +let cb = Closure::wrap(Box::new(|| {}) as Box); +on_location_requested(&cb)?; +cb.forget(); +# Ok::<(), wasm_bindgen::JsValue>(()) +``` + +## Device sensors + +Access motion sensors if the user's device exposes them. + +```rust,no_run +use telegram_webapp_sdk::api::accelerometer::{start, get_acceleration, stop}; + +start()?; +let reading = get_acceleration(); +stop()?; +# Ok::<(), wasm_bindgen::JsValue>(()) +``` + +Callbacks for sensor lifecycle events are available through `on_started`, +`on_changed`, `on_stopped`, and `on_failed` functions for accelerometer, +gyroscope, and device orientation sensors. +## Init data validation + +Validate the integrity of the `Telegram.WebApp.initData` payload on the server. +The `validate_init_data` module is re-exported at the crate root and can be +used directly or through the `TelegramWebApp::validate_init_data` helper: + +```rust,no_run +use telegram_webapp_sdk::{ + validate_init_data::ValidationKey, + TelegramWebApp +}; + +let bot_token = "123456:ABC"; +let query = "user=alice&auth_date=1&hash=48f4c0e9d3dd46a5734bf2c5d4df9f4ec52a3cd612f6482a7d2c68e84e702ee2"; +TelegramWebApp::validate_init_data(query, ValidationKey::BotToken(bot_token))?; + +// For Ed25519-signed data +# use ed25519_dalek::{Signer, SigningKey}; +# let sk = SigningKey::from_bytes(&[1u8;32]); +# let pk = sk.verifying_key(); +# let sig = sk.sign(b"a=1\nb=2"); +# let init_data = format!("a=1&b=2&signature={}", base64::encode(sig.to_bytes())); +TelegramWebApp::validate_init_data( + &init_data, + ValidationKey::Ed25519PublicKey(pk.as_bytes()) +)?; + +# Ok::<(), Box>(()) +``` + +## API coverage + +See [WEBAPP_API.md](./WEBAPP_API.md) for a checklist of supported Telegram WebApp JavaScript API methods and features. + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) for release notes. + +## License + +`telegram-webapp-sdk` is licensed under either of + +- Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or ) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. diff --git a/telegram-webapp-sdk/WEBAPP_API.md b/telegram-webapp-sdk/WEBAPP_API.md new file mode 100644 index 0000000..eab28c1 --- /dev/null +++ b/telegram-webapp-sdk/WEBAPP_API.md @@ -0,0 +1,170 @@ +# Telegram WebApp API Coverage + +This checklist tracks support for the [Telegram Web Apps JavaScript API](https://core.telegram.org/bots/webapps). Mark items as they are implemented. + +## Methods + +- [x] ready ([bcce132](https://github.com/RAprogramm/telegram-webapp-sdk/commit/bcce132)) +- [x] expand ([bcce132](https://github.com/RAprogramm/telegram-webapp-sdk/commit/bcce132)) +- [x] close ([bcce132](https://github.com/RAprogramm/telegram-webapp-sdk/commit/bcce132)) +- [x] sendData ([bcce132](https://github.com/RAprogramm/telegram-webapp-sdk/commit/bcce132)) +- [x] openLink ([840ace1](https://github.com/RAprogramm/telegram-webapp-sdk/commit/840ace1)) +- [x] openTelegramLink ([840ace1](https://github.com/RAprogramm/telegram-webapp-sdk/commit/840ace1)) +- [x] openInvoice ([840ace1](https://github.com/RAprogramm/telegram-webapp-sdk/commit/840ace1)) +- [x] switchInlineQuery ([a098e00](https://github.com/RAprogramm/telegram-webapp-sdk/commit/a098e00)) +- [x] showAlert ([bcce132](https://github.com/RAprogramm/telegram-webapp-sdk/commit/bcce132)) +- [x] showConfirm ([bcce132](https://github.com/RAprogramm/telegram-webapp-sdk/commit/bcce132)) +- [x] showPopup ([840ace1](https://github.com/RAprogramm/telegram-webapp-sdk/commit/840ace1)) +- [x] showScanQrPopup ([840ace1](https://github.com/RAprogramm/telegram-webapp-sdk/commit/840ace1)) +- [x] closeScanQrPopup ([840ace1](https://github.com/RAprogramm/telegram-webapp-sdk/commit/840ace1)) +- [x] shareURL ([a098e00](https://github.com/RAprogramm/telegram-webapp-sdk/commit/a098e00)) +- [x] shareMessage ([4b10c98](https://github.com/RAprogramm/telegram-webapp-sdk/commit/4b10c98)) +- [x] shareToStory ([4b10c98](https://github.com/RAprogramm/telegram-webapp-sdk/commit/4b10c98)) +- [x] joinVoiceChat ([a098e00](https://github.com/RAprogramm/telegram-webapp-sdk/commit/a098e00)) +- [x] requestWriteAccess ([a098e00](https://github.com/RAprogramm/telegram-webapp-sdk/commit/a098e00)) +- [x] requestContact ([d595540](https://github.com/RAprogramm/telegram-webapp-sdk/commit/d595540)) +- [x] requestPhoneNumber ([d595540](https://github.com/RAprogramm/telegram-webapp-sdk/commit/d595540)) +- [x] openContact ([d595540](https://github.com/RAprogramm/telegram-webapp-sdk/commit/d595540)) +- [x] enableVerticalSwipes ([8e60df3](https://github.com/RAprogramm/telegram-webapp-sdk/commit/8e60df3)) +- [x] disableVerticalSwipes ([8e60df3](https://github.com/RAprogramm/telegram-webapp-sdk/commit/8e60df3)) +- [x] hideKeyboard ([94ff585](https://github.com/RAprogramm/telegram-webapp-sdk/commit/94ff585)) +- [x] downloadFile ([3092094](https://github.com/RAprogramm/telegram-webapp-sdk/commit/3092094)) +- [x] readTextFromClipboard ([fd1c84e](https://github.com/RAprogramm/telegram-webapp-sdk/commit/fd1c84e)) +- [x] setEmojiStatus ([12cfbd0](https://github.com/RAprogramm/telegram-webapp-sdk/commit/12cfbd0)) +- [x] requestEmojiStatusAccess ([12cfbd0](https://github.com/RAprogramm/telegram-webapp-sdk/commit/12cfbd0)) +- [x] setHeaderColor ([58a73cb](https://github.com/RAprogramm/telegram-webapp-sdk/commit/58a73cb)) +- [x] setBackgroundColor ([58a73cb](https://github.com/RAprogramm/telegram-webapp-sdk/commit/58a73cb)) +- [x] setBottomBarColor ([58a73cb](https://github.com/RAprogramm/telegram-webapp-sdk/commit/58a73cb)) +- [x] addToHomeScreen ([e709edb](https://github.com/RAprogramm/telegram-webapp-sdk/commit/e709edb)) +- [x] checkHomeScreenStatus ([e709edb](https://github.com/RAprogramm/telegram-webapp-sdk/commit/e709edb)) +- [x] enableClosingConfirmation ([8fe4dec](https://github.com/RAprogramm/telegram-webapp-sdk/commit/8fe4dec)) +- [x] disableClosingConfirmation ([8fe4dec](https://github.com/RAprogramm/telegram-webapp-sdk/commit/8fe4dec)) +- [x] isClosingConfirmationEnabled (unreleased) +- [x] requestFullscreen ([4364008](https://github.com/RAprogramm/telegram-webapp-sdk/commit/4364008)) +- [x] exitFullscreen ([4364008](https://github.com/RAprogramm/telegram-webapp-sdk/commit/4364008)) +- [x] lockOrientation ([4364008](https://github.com/RAprogramm/telegram-webapp-sdk/commit/4364008)) +- [x] unlockOrientation ([4364008](https://github.com/RAprogramm/telegram-webapp-sdk/commit/4364008)) + +## Objects + +### BottomButton (Main & Secondary) +- [x] show ([7d524fd](https://github.com/RAprogramm/telegram-webapp-sdk/commit/7d524fd)) +- [x] hide ([7d524fd](https://github.com/RAprogramm/telegram-webapp-sdk/commit/7d524fd)) +- [x] setText ([7d524fd](https://github.com/RAprogramm/telegram-webapp-sdk/commit/7d524fd)) +- [x] setColor ([7d524fd](https://github.com/RAprogramm/telegram-webapp-sdk/commit/7d524fd)) +- [x] setTextColor ([7d524fd](https://github.com/RAprogramm/telegram-webapp-sdk/commit/7d524fd)) +- [x] onClick ([7d524fd](https://github.com/RAprogramm/telegram-webapp-sdk/commit/7d524fd)) +- [x] offClick ([7d524fd](https://github.com/RAprogramm/telegram-webapp-sdk/commit/7d524fd)) + +### MainButton +- [x] show ([bcce132](https://github.com/RAprogramm/telegram-webapp-sdk/commit/bcce132)) +- [x] hide ([f0a108d](https://github.com/RAprogramm/telegram-webapp-sdk/commit/f0a108d)) +- [x] setText ([bcce132](https://github.com/RAprogramm/telegram-webapp-sdk/commit/bcce132)) +- [x] onClick ([0a42d7b](https://github.com/RAprogramm/telegram-webapp-sdk/commit/0a42d7b)) +- [x] offClick ([0a42d7b](https://github.com/RAprogramm/telegram-webapp-sdk/commit/0a42d7b)) + +### BackButton +- [x] show ([bcce132](https://github.com/RAprogramm/telegram-webapp-sdk/commit/bcce132)) +- [x] hide ([bcce132](https://github.com/RAprogramm/telegram-webapp-sdk/commit/bcce132)) +- [x] onClick ([0a42d7b](https://github.com/RAprogramm/telegram-webapp-sdk/commit/0a42d7b)) +- [x] offClick ([0a42d7b](https://github.com/RAprogramm/telegram-webapp-sdk/commit/0a42d7b)) + +### SettingsButton +- [x] show ([885be03](https://github.com/RAprogramm/telegram-webapp-sdk/commit/885be03)) +- [x] hide ([885be03](https://github.com/RAprogramm/telegram-webapp-sdk/commit/885be03)) +- [x] onClick ([885be03](https://github.com/RAprogramm/telegram-webapp-sdk/commit/885be03)) +- [x] offClick ([885be03](https://github.com/RAprogramm/telegram-webapp-sdk/commit/885be03)) + +### HapticFeedback +- [x] impactOccurred ([9896d92](https://github.com/RAprogramm/telegram-webapp-sdk/commit/9896d92)) +- [x] notificationOccurred ([9896d92](https://github.com/RAprogramm/telegram-webapp-sdk/commit/9896d92)) +- [x] selectionChanged ([9896d92](https://github.com/RAprogramm/telegram-webapp-sdk/commit/9896d92)) + +## Sensors + +### Accelerometer +- [x] start ([9428d51](https://github.com/RAprogramm/telegram-webapp-sdk/commit/9428d51)) +- [x] stop ([9428d51](https://github.com/RAprogramm/telegram-webapp-sdk/commit/9428d51)) +- [x] getAcceleration ([9428d51](https://github.com/RAprogramm/telegram-webapp-sdk/commit/9428d51)) + +### Gyroscope +- [x] start ([9428d51](https://github.com/RAprogramm/telegram-webapp-sdk/commit/9428d51)) +- [x] stop ([9428d51](https://github.com/RAprogramm/telegram-webapp-sdk/commit/9428d51)) +- [x] getAngularVelocity ([9428d51](https://github.com/RAprogramm/telegram-webapp-sdk/commit/9428d51)) + +### DeviceOrientation +- [x] start ([9428d51](https://github.com/RAprogramm/telegram-webapp-sdk/commit/9428d51)) +- [x] stop ([9428d51](https://github.com/RAprogramm/telegram-webapp-sdk/commit/9428d51)) +- [x] getOrientation ([9428d51](https://github.com/RAprogramm/telegram-webapp-sdk/commit/9428d51)) + +### LocationManager +- [x] init ([10ca55c](https://github.com/RAprogramm/telegram-webapp-sdk/commit/10ca55c)) +- [x] getLocation ([10ca55c](https://github.com/RAprogramm/telegram-webapp-sdk/commit/10ca55c)) +- [x] openSettings ([10ca55c](https://github.com/RAprogramm/telegram-webapp-sdk/commit/10ca55c)) +- [x] onLocationManagerUpdated ([10ca55c](https://github.com/RAprogramm/telegram-webapp-sdk/commit/10ca55c)) +- [x] onLocationRequested ([10ca55c](https://github.com/RAprogramm/telegram-webapp-sdk/commit/10ca55c)) + +### BiometricManager +- [x] init ([8c34fbd](https://github.com/RAprogramm/telegram-webapp-sdk/commit/8c34fbd)) +- [x] requestAccess ([8c34fbd](https://github.com/RAprogramm/telegram-webapp-sdk/commit/8c34fbd)) +- [x] authenticate ([8c34fbd](https://github.com/RAprogramm/telegram-webapp-sdk/commit/8c34fbd)) +- [x] updateBiometricToken ([8c34fbd](https://github.com/RAprogramm/telegram-webapp-sdk/commit/8c34fbd)) +- [x] openSettings ([8c34fbd](https://github.com/RAprogramm/telegram-webapp-sdk/commit/8c34fbd)) +- [x] isInited ([7a2555c](https://github.com/RAprogramm/telegram-webapp-sdk/commit/7a2555c)) +- [x] isBiometricAvailable ([7a2555c](https://github.com/RAprogramm/telegram-webapp-sdk/commit/7a2555c)) +- [x] isAccessRequested ([7a2555c](https://github.com/RAprogramm/telegram-webapp-sdk/commit/7a2555c)) +- [x] isAccessGranted ([7a2555c](https://github.com/RAprogramm/telegram-webapp-sdk/commit/7a2555c)) +- [x] isBiometricTokenSaved ([7a2555c](https://github.com/RAprogramm/telegram-webapp-sdk/commit/7a2555c)) +- [x] deviceId ([7a2555c](https://github.com/RAprogramm/telegram-webapp-sdk/commit/7a2555c)) + +## Storages + +### CloudStorage +- [x] getItem ([ae2a302](https://github.com/RAprogramm/telegram-webapp-sdk/commit/ae2a302)) +- [x] setItem ([ae2a302](https://github.com/RAprogramm/telegram-webapp-sdk/commit/ae2a302)) +- [x] removeItem ([ae2a302](https://github.com/RAprogramm/telegram-webapp-sdk/commit/ae2a302)) +- [x] getItems ([ae2a302](https://github.com/RAprogramm/telegram-webapp-sdk/commit/ae2a302)) +- [x] setItems +- [x] removeItems ([ae2a302](https://github.com/RAprogramm/telegram-webapp-sdk/commit/ae2a302)) +- [x] getKeys ([ae2a302](https://github.com/RAprogramm/telegram-webapp-sdk/commit/ae2a302)) +- [x] clear ([ae2a302](https://github.com/RAprogramm/telegram-webapp-sdk/commit/ae2a302)) + +### DeviceStorage +- [x] set ([0905616](https://github.com/RAprogramm/telegram-webapp-sdk/commit/0905616)) +- [x] get ([0905616](https://github.com/RAprogramm/telegram-webapp-sdk/commit/0905616)) +- [x] remove ([0905616](https://github.com/RAprogramm/telegram-webapp-sdk/commit/0905616)) +- [x] clear ([0905616](https://github.com/RAprogramm/telegram-webapp-sdk/commit/0905616)) + +### SecureStorage +- [x] set ([0905616](https://github.com/RAprogramm/telegram-webapp-sdk/commit/0905616)) +- [x] get ([0905616](https://github.com/RAprogramm/telegram-webapp-sdk/commit/0905616)) +- [x] restore ([0905616](https://github.com/RAprogramm/telegram-webapp-sdk/commit/0905616)) +- [x] remove ([0905616](https://github.com/RAprogramm/telegram-webapp-sdk/commit/0905616)) +- [x] clear ([0905616](https://github.com/RAprogramm/telegram-webapp-sdk/commit/0905616)) + +## Background events + +| Event | Payload | +|-------|---------| +| `mainButtonClicked` | none | +| `backButtonClicked` | none | +| `settingsButtonClicked` | none | +| `writeAccessRequested` | `bool` granted flag | +| `contactRequested` | `bool` shared flag | +| `phoneRequested` | `bool` shared flag | +| `invoiceClosed` | status `String` | +| `popupClosed` | object `{ button_id: Option }` | +| `qrTextReceived` | scanned text `String` | +| `clipboardTextReceived` | clipboard text `String` | + +## Remaining WebApp Features + +The following features are not yet covered by the SDK: + +- [x] Init data validation (unreleased) +- [x] Theme and safe area change events ([58a73cb](https://github.com/RAprogramm/telegram-webapp-sdk/commit/58a73cb)) +- [x] Viewport management +- [x] Clipboard access ([fd1c84e](https://github.com/RAprogramm/telegram-webapp-sdk/commit/fd1c84e)) +- [x] Location access ([10ca55c](https://github.com/RAprogramm/telegram-webapp-sdk/commit/10ca55c)) +- [x] Invoice payments (unreleased) +- [x] Background events (unreleased) diff --git a/telegram-webapp-sdk/src/api.rs b/telegram-webapp-sdk/src/api.rs new file mode 100644 index 0000000..6f4a1d7 --- /dev/null +++ b/telegram-webapp-sdk/src/api.rs @@ -0,0 +1,14 @@ +pub mod accelerometer; +pub mod biometric; +pub mod cloud_storage; +pub mod device_orientation; +pub mod device_storage; +pub mod events; +pub mod gyroscope; +pub mod haptic; +pub mod location_manager; +pub mod secure_storage; +pub mod settings_button; +pub mod theme; +pub mod user; +pub mod viewport; diff --git a/telegram-webapp-sdk/src/api/accelerometer.rs b/telegram-webapp-sdk/src/api/accelerometer.rs new file mode 100644 index 0000000..4c45fa0 --- /dev/null +++ b/telegram-webapp-sdk/src/api/accelerometer.rs @@ -0,0 +1,209 @@ +use js_sys::{Function, Reflect}; +use wasm_bindgen::{JsCast, prelude::*}; +use web_sys::window; + +use super::events; + +/// Three-dimensional acceleration in meters per second squared. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Acceleration { + /// Acceleration along the X axis. + pub x: f64, + /// Acceleration along the Y axis. + pub y: f64, + /// Acceleration along the Z axis. + pub z: f64 +} + +impl Acceleration { + /// Creates a new [`Acceleration`] instance. + #[allow(dead_code)] + const fn new(x: f64, y: f64, z: f64) -> Self { + Self { + x, + y, + z + } + } +} + +/// Starts the accelerometer. +/// +/// # Errors +/// Returns [`JsValue`] if the underlying JavaScript call fails or the sensor is +/// unavailable. +/// +/// # Examples +/// ```no_run +/// # use telegram_webapp_sdk::api::accelerometer::start; +/// start()?; +/// # Ok::<(), wasm_bindgen::JsValue>(()) +/// ``` +pub fn start() -> Result<(), JsValue> { + let accel = accelerometer_object()?; + let func = Reflect::get(&accel, &"start".into())?.dyn_into::()?; + func.call0(&accel)?; + Ok(()) +} + +/// Stops the accelerometer. +/// +/// # Errors +/// Returns [`JsValue`] if the underlying JavaScript call fails or the sensor is +/// unavailable. +/// +/// # Examples +/// ```no_run +/// # use telegram_webapp_sdk::api::accelerometer::stop; +/// stop()?; +/// # Ok::<(), wasm_bindgen::JsValue>(()) +/// ``` +pub fn stop() -> Result<(), JsValue> { + let accel = accelerometer_object()?; + let func = Reflect::get(&accel, &"stop".into())?.dyn_into::()?; + func.call0(&accel)?; + Ok(()) +} + +/// Reads the current acceleration values. +/// +/// # Examples +/// ```no_run +/// # use telegram_webapp_sdk::api::accelerometer::get_acceleration; +/// let _ = get_acceleration(); +/// ``` +pub fn get_acceleration() -> Option { + let accel = accelerometer_object().ok()?; + let x = Reflect::get(&accel, &"x".into()).ok()?.as_f64()?; + let y = Reflect::get(&accel, &"y".into()).ok()?.as_f64()?; + let z = Reflect::get(&accel, &"z".into()).ok()?.as_f64()?; + Some(Acceleration { + x, + y, + z + }) +} + +/// Registers a callback for `accelerometerStarted` event. +/// +/// ⚠️ The closure must be kept alive for as long as it is needed. +pub fn on_started(callback: &Closure) -> Result<(), JsValue> { + events::on_event("accelerometerStarted", callback) +} + +/// Registers a callback for `accelerometerChanged` event. +/// +/// ⚠️ The closure must be kept alive for as long as it is needed. +pub fn on_changed(callback: &Closure) -> Result<(), JsValue> { + events::on_event("accelerometerChanged", callback) +} + +/// Registers a callback for `accelerometerStopped` event. +/// +/// ⚠️ The closure must be kept alive for as long as it is needed. +pub fn on_stopped(callback: &Closure) -> Result<(), JsValue> { + events::on_event("accelerometerStopped", callback) +} + +/// Registers a callback for `accelerometerFailed` event. +/// +/// ⚠️ The closure must be kept alive for as long as it is needed. +pub fn on_failed(callback: &Closure) -> Result<(), JsValue> { + events::on_event("accelerometerFailed", callback) +} + +fn accelerometer_object() -> Result { + let win = window().ok_or_else(|| JsValue::from_str("no window"))?; + let tg = Reflect::get(&win, &"Telegram".into())?; + let webapp = Reflect::get(&tg, &"WebApp".into())?; + Reflect::get(&webapp, &"Accelerometer".into()) +} + +#[cfg(test)] +#[allow(dead_code)] +mod tests { + use js_sys::{Function, Object, Reflect}; + use wasm_bindgen::{JsValue, closure::Closure}; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn setup_accelerometer() -> (Object, Object) { + let win = window().unwrap(); + let telegram = Object::new(); + let webapp = Object::new(); + let accel = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + let _ = Reflect::set(&webapp, &"Accelerometer".into(), &accel); + (webapp, accel) + } + + #[wasm_bindgen_test] + #[allow(clippy::unused_unit)] + fn start_ok() { + let (_webapp, accel) = setup_accelerometer(); + let func = Function::new_no_args("this.called = true;"); + let _ = Reflect::set(&accel, &"start".into(), &func); + assert!(start().is_ok()); + let called = Reflect::get(&accel, &"called".into()).unwrap(); + assert_eq!(called.as_bool(), Some(true)); + } + + #[wasm_bindgen_test] + #[allow(clippy::unused_unit)] + fn start_err() { + let (_webapp, accel) = setup_accelerometer(); + let _ = Reflect::set(&accel, &"start".into(), &JsValue::from_f64(1.0)); + assert!(start().is_err()); + } + + #[wasm_bindgen_test] + #[allow(clippy::unused_unit)] + fn stop_ok() { + let (_webapp, accel) = setup_accelerometer(); + let func = Function::new_no_args("this.stopped = true;"); + let _ = Reflect::set(&accel, &"stop".into(), &func); + assert!(stop().is_ok()); + let stopped = Reflect::get(&accel, &"stopped".into()).unwrap(); + assert_eq!(stopped.as_bool(), Some(true)); + } + + #[wasm_bindgen_test] + fn get_acceleration_ok() { + let (_webapp, accel) = setup_accelerometer(); + let _ = Reflect::set(&accel, &"x".into(), &JsValue::from_f64(1.0)); + let _ = Reflect::set(&accel, &"y".into(), &JsValue::from_f64(2.0)); + let _ = Reflect::set(&accel, &"z".into(), &JsValue::from_f64(3.0)); + let result = get_acceleration().unwrap(); + assert_eq!( + result, + Acceleration { + x: 1.0, + y: 2.0, + z: 3.0 + } + ); + } + + #[wasm_bindgen_test] + fn registers_callbacks() { + let (webapp, _accel) = setup_accelerometer(); + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + let cb = Closure::wrap(Box::new(|| {}) as Box); + on_started(&cb).expect("on_started"); + on_changed(&cb).expect("on_changed"); + on_stopped(&cb).expect("on_stopped"); + on_failed(&cb).expect("on_failed"); + assert!(Reflect::has(&webapp, &"accelerometerStarted".into()).unwrap()); + assert!(Reflect::has(&webapp, &"accelerometerChanged".into()).unwrap()); + assert!(Reflect::has(&webapp, &"accelerometerStopped".into()).unwrap()); + assert!(Reflect::has(&webapp, &"accelerometerFailed".into()).unwrap()); + cb.forget(); + } +} diff --git a/telegram-webapp-sdk/src/api/biometric.rs b/telegram-webapp-sdk/src/api/biometric.rs new file mode 100644 index 0000000..4e4ab37 --- /dev/null +++ b/telegram-webapp-sdk/src/api/biometric.rs @@ -0,0 +1,508 @@ +use js_sys::{Function, Reflect}; +use wasm_bindgen::{JsCast, prelude::*}; +use web_sys::window; + +/// Calls `Telegram.WebApp.BiometricManager.init()`. +/// +/// # Errors +/// Returns `Err(JsValue)` if `BiometricManager` or the method is unavailable, +/// or if the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::biometric::init; +/// +/// let _ = init(); +/// ``` +pub fn init() -> Result<(), JsValue> { + let biom = biometric_object()?; + let func = Reflect::get(&biom, &JsValue::from_str("init"))?.dyn_into::()?; + func.call0(&biom)?; + Ok(()) +} + +/// Calls `Telegram.WebApp.BiometricManager.requestAccess(auth_key, reason, +/// options)`. +/// +/// # Errors +/// Returns `Err(JsValue)` if `BiometricManager` or the method is unavailable, +/// or if the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::biometric::request_access; +/// +/// let _ = request_access("auth-key", None, None); +/// ``` +pub fn request_access( + auth_key: &str, + reason: Option<&str>, + options: Option<&JsValue> +) -> Result<(), JsValue> { + let biom = biometric_object()?; + let func = Reflect::get(&biom, &JsValue::from_str("requestAccess"))?.dyn_into::()?; + let key = JsValue::from_str(auth_key); + match (reason, options) { + (Some(r), Some(o)) => { + let r = JsValue::from_str(r); + func.call3(&biom, &key, &r, o)?; + } + (Some(r), None) => { + let r = JsValue::from_str(r); + func.call2(&biom, &key, &r)?; + } + (None, Some(o)) => { + func.call3(&biom, &key, &JsValue::UNDEFINED, o)?; + } + (None, None) => { + func.call1(&biom, &key)?; + } + } + Ok(()) +} + +/// Calls `Telegram.WebApp.BiometricManager.authenticate(auth_key, reason, +/// options)`. +/// +/// # Errors +/// Returns `Err(JsValue)` if `BiometricManager` or the method is unavailable, +/// or if the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::biometric::authenticate; +/// +/// let _ = authenticate("auth-key", None, None); +/// ``` +pub fn authenticate( + auth_key: &str, + reason: Option<&str>, + options: Option<&JsValue> +) -> Result<(), JsValue> { + let biom = biometric_object()?; + let func = Reflect::get(&biom, &JsValue::from_str("authenticate"))?.dyn_into::()?; + let key = JsValue::from_str(auth_key); + match (reason, options) { + (Some(r), Some(o)) => { + let r = JsValue::from_str(r); + func.call3(&biom, &key, &r, o)?; + } + (Some(r), None) => { + let r = JsValue::from_str(r); + func.call2(&biom, &key, &r)?; + } + (None, Some(o)) => { + func.call3(&biom, &key, &JsValue::UNDEFINED, o)?; + } + (None, None) => { + func.call1(&biom, &key)?; + } + } + Ok(()) +} + +/// Calls `Telegram.WebApp.BiometricManager.updateBiometricToken(token)`. +/// +/// # Errors +/// Returns `Err(JsValue)` if `BiometricManager` or the method is unavailable, +/// or if the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::biometric::update_biometric_token; +/// +/// let _ = update_biometric_token("token"); +/// ``` +pub fn update_biometric_token(token: &str) -> Result<(), JsValue> { + let biom = biometric_object()?; + let func = + Reflect::get(&biom, &JsValue::from_str("updateBiometricToken"))?.dyn_into::()?; + func.call1(&biom, &JsValue::from_str(token))?; + Ok(()) +} + +/// Calls `Telegram.WebApp.BiometricManager.openSettings()`. +/// +/// # Errors +/// Returns `Err(JsValue)` if `BiometricManager` or the method is unavailable, +/// or if the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::biometric::open_settings; +/// +/// let _ = open_settings(); +/// ``` +pub fn open_settings() -> Result<(), JsValue> { + let biom = biometric_object()?; + let func = Reflect::get(&biom, &JsValue::from_str("openSettings"))?.dyn_into::()?; + func.call0(&biom)?; + Ok(()) +} + +/// Returns `Telegram.WebApp.BiometricManager.isInited`. +/// +/// # Errors +/// Returns `Err(JsValue)` if the property is unavailable or not a boolean. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::biometric::is_inited; +/// +/// let _ = is_inited(); +/// ``` +pub fn is_inited() -> Result { + let biom = biometric_object()?; + let value = Reflect::get(&biom, &JsValue::from_str("isInited"))?; + value + .as_bool() + .ok_or_else(|| JsValue::from_str("isInited not a bool")) +} + +/// Returns `Telegram.WebApp.BiometricManager.isBiometricAvailable`. +/// +/// # Errors +/// Returns `Err(JsValue)` if the property is unavailable or not a boolean. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::biometric::is_biometric_available; +/// +/// let _ = is_biometric_available(); +/// ``` +pub fn is_biometric_available() -> Result { + let biom = biometric_object()?; + let value = Reflect::get(&biom, &JsValue::from_str("isBiometricAvailable"))?; + value + .as_bool() + .ok_or_else(|| JsValue::from_str("isBiometricAvailable not a bool")) +} + +/// Returns `Telegram.WebApp.BiometricManager.isAccessRequested`. +/// +/// # Errors +/// Returns `Err(JsValue)` if the property is unavailable or not a boolean. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::biometric::is_access_requested; +/// +/// let _ = is_access_requested(); +/// ``` +pub fn is_access_requested() -> Result { + let biom = biometric_object()?; + let value = Reflect::get(&biom, &JsValue::from_str("isAccessRequested"))?; + value + .as_bool() + .ok_or_else(|| JsValue::from_str("isAccessRequested not a bool")) +} + +/// Returns `Telegram.WebApp.BiometricManager.isAccessGranted`. +/// +/// # Errors +/// Returns `Err(JsValue)` if the property is unavailable or not a boolean. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::biometric::is_access_granted; +/// +/// let _ = is_access_granted(); +/// ``` +pub fn is_access_granted() -> Result { + let biom = biometric_object()?; + let value = Reflect::get(&biom, &JsValue::from_str("isAccessGranted"))?; + value + .as_bool() + .ok_or_else(|| JsValue::from_str("isAccessGranted not a bool")) +} + +/// Returns `Telegram.WebApp.BiometricManager.isBiometricTokenSaved`. +/// +/// # Errors +/// Returns `Err(JsValue)` if the property is unavailable or not a boolean. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::biometric::is_biometric_token_saved; +/// +/// let _ = is_biometric_token_saved(); +/// ``` +pub fn is_biometric_token_saved() -> Result { + let biom = biometric_object()?; + let value = Reflect::get(&biom, &JsValue::from_str("isBiometricTokenSaved"))?; + value + .as_bool() + .ok_or_else(|| JsValue::from_str("isBiometricTokenSaved not a bool")) +} + +/// Returns `Telegram.WebApp.BiometricManager.deviceId`. +/// +/// # Errors +/// Returns `Err(JsValue)` if the property is unavailable or not a string. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::biometric::device_id; +/// +/// let _ = device_id(); +/// ``` +pub fn device_id() -> Result { + let biom = biometric_object()?; + let value = Reflect::get(&biom, &JsValue::from_str("deviceId"))?; + value + .as_string() + .ok_or_else(|| JsValue::from_str("deviceId not a string")) +} + +fn biometric_object() -> Result { + let win = window().ok_or_else(|| JsValue::from_str("no window"))?; + let tg = Reflect::get(&win, &JsValue::from_str("Telegram"))?; + let webapp = Reflect::get(&tg, &JsValue::from_str("WebApp"))?; + Reflect::get(&webapp, &JsValue::from_str("BiometricManager")) +} + +#[cfg(test)] +mod tests { + use js_sys::{Function, Object, Reflect}; + use wasm_bindgen::JsValue; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn setup_biometric() -> Object { + let win = window().expect("window should be available"); + let telegram = Object::new(); + let webapp = Object::new(); + let biom = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + let _ = Reflect::set(&webapp, &"BiometricManager".into(), &biom); + biom + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn init_ok() { + let biom = setup_biometric(); + let func = Function::new_no_args("this.called = true;"); + let _ = Reflect::set(&biom, &"init".into(), &func); + assert!(init().is_ok()); + assert!( + Reflect::get(&biom, &"called".into()) + .unwrap() + .as_bool() + .unwrap() + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn init_err() { + let _ = setup_biometric(); + assert!(init().is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn request_access_ok() { + let biom = setup_biometric(); + let func = Function::new_with_args("key", "this.called = true; this.key = key;"); + let _ = Reflect::set(&biom, &"requestAccess".into(), &func); + assert!(request_access("abc", None, None).is_ok()); + assert!( + Reflect::get(&biom, &"called".into()) + .unwrap() + .as_bool() + .unwrap() + ); + assert_eq!( + Reflect::get(&biom, &"key".into()) + .unwrap() + .as_string() + .unwrap(), + "abc" + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn request_access_err() { + let _ = setup_biometric(); + assert!(request_access("abc", None, None).is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn authenticate_ok() { + let biom = setup_biometric(); + let func = Function::new_with_args("key", "this.called = true; this.key = key;"); + let _ = Reflect::set(&biom, &"authenticate".into(), &func); + assert!(authenticate("abc", None, None).is_ok()); + assert!( + Reflect::get(&biom, &"called".into()) + .unwrap() + .as_bool() + .unwrap() + ); + assert_eq!( + Reflect::get(&biom, &"key".into()) + .unwrap() + .as_string() + .unwrap(), + "abc" + ); + assert_eq!( + Reflect::get(&biom, &"reason".into()) + .unwrap() + .as_string() + .unwrap(), + "why" + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn authenticate_err() { + let _ = setup_biometric(); + assert!(authenticate("abc", None, None).is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn update_biometric_token_ok() { + let biom = setup_biometric(); + let func = Function::new_with_args("token", "this.token = token;"); + let _ = Reflect::set(&biom, &"updateBiometricToken".into(), &func); + assert!(update_biometric_token("abc").is_ok()); + assert_eq!( + Reflect::get(&biom, &"token".into()) + .unwrap() + .as_string() + .unwrap(), + "abc" + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn update_biometric_token_err() { + let _ = setup_biometric(); + assert!(update_biometric_token("abc").is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn open_settings_ok() { + let biom = setup_biometric(); + let func = Function::new_no_args("this.called = true;"); + let _ = Reflect::set(&biom, &"openSettings".into(), &func); + assert!(open_settings().is_ok()); + assert!( + Reflect::get(&biom, &"called".into()) + .unwrap() + .as_bool() + .unwrap() + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn open_settings_err() { + let _ = setup_biometric(); + assert!(open_settings().is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn is_inited_ok() { + let biom = setup_biometric(); + let _ = Reflect::set(&biom, &"isInited".into(), &JsValue::from(true)); + assert!(is_inited().expect("is_inited")); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn is_inited_err() { + let _ = setup_biometric(); + assert!(is_inited().is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn is_biometric_available_ok() { + let biom = setup_biometric(); + let _ = Reflect::set(&biom, &"isBiometricAvailable".into(), &JsValue::from(true)); + assert!(is_biometric_available().expect("is_biometric_available")); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn is_biometric_available_err() { + let _ = setup_biometric(); + assert!(is_biometric_available().is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn is_access_requested_ok() { + let biom = setup_biometric(); + let _ = Reflect::set(&biom, &"isAccessRequested".into(), &JsValue::from(true)); + assert!(is_access_requested().expect("is_access_requested")); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn is_access_requested_err() { + let _ = setup_biometric(); + assert!(is_access_requested().is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn is_access_granted_ok() { + let biom = setup_biometric(); + let _ = Reflect::set(&biom, &"isAccessGranted".into(), &JsValue::from(true)); + assert!(is_access_granted().expect("is_access_granted")); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn is_access_granted_err() { + let _ = setup_biometric(); + assert!(is_access_granted().is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn is_biometric_token_saved_ok() { + let biom = setup_biometric(); + let _ = Reflect::set(&biom, &"isBiometricTokenSaved".into(), &JsValue::from(true)); + assert!(is_biometric_token_saved().expect("is_biometric_token_saved")); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn is_biometric_token_saved_err() { + let _ = setup_biometric(); + assert!(is_biometric_token_saved().is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn device_id_ok() { + let biom = setup_biometric(); + let _ = Reflect::set(&biom, &"deviceId".into(), &JsValue::from_str("id123")); + assert_eq!(device_id().expect("device_id"), "id123"); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn device_id_err() { + let _ = setup_biometric(); + assert!(device_id().is_err()); + } +} diff --git a/telegram-webapp-sdk/src/api/cloud_storage.rs b/telegram-webapp-sdk/src/api/cloud_storage.rs new file mode 100644 index 0000000..7e2a77d --- /dev/null +++ b/telegram-webapp-sdk/src/api/cloud_storage.rs @@ -0,0 +1,400 @@ +use js_sys::{Array, Function, Promise, Reflect}; +use wasm_bindgen::{JsCast, prelude::*}; +use web_sys::window; + +/// Returns the `Telegram.WebApp.CloudStorage` object. +fn cloud_storage_object() -> Result { + let win = window().ok_or_else(|| JsValue::from_str("no window"))?; + let tg = Reflect::get(&win, &JsValue::from_str("Telegram"))?; + let webapp = Reflect::get(&tg, &JsValue::from_str("WebApp"))?; + Reflect::get(&webapp, &JsValue::from_str("CloudStorage")) +} + +/// Calls `Telegram.WebApp.CloudStorage.getItem()`. +/// +/// # Errors +/// Returns `Err(JsValue)` if CloudStorage or the method is unavailable, or if +/// the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::cloud_storage::get_item; +/// use wasm_bindgen_futures::JsFuture; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// let value = JsFuture::from(get_item("key")?).await?; +/// # Ok(()) +/// # } +/// ``` +pub fn get_item(key: &str) -> Result { + let storage = cloud_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("getItem"))?.dyn_into::()?; + func.call1(&storage, &JsValue::from_str(key))? + .dyn_into::() +} + +/// Calls `Telegram.WebApp.CloudStorage.setItem()`. +/// +/// # Errors +/// Returns `Err(JsValue)` if CloudStorage or the method is unavailable, or if +/// the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::cloud_storage::set_item; +/// use wasm_bindgen_futures::JsFuture; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// JsFuture::from(set_item("key", "value")?).await?; +/// # Ok(()) +/// # } +/// ``` +pub fn set_item(key: &str, value: &str) -> Result { + let storage = cloud_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("setItem"))?.dyn_into::()?; + func.call2(&storage, &JsValue::from_str(key), &JsValue::from_str(value))? + .dyn_into::() +} + +/// Calls `Telegram.WebApp.CloudStorage.removeItem()`. +/// +/// # Errors +/// Returns `Err(JsValue)` if CloudStorage or the method is unavailable, or if +/// the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::cloud_storage::remove_item; +/// use wasm_bindgen_futures::JsFuture; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// JsFuture::from(remove_item("key")?).await?; +/// # Ok(()) +/// # } +/// ``` +pub fn remove_item(key: &str) -> Result { + let storage = cloud_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("removeItem"))?.dyn_into::()?; + func.call1(&storage, &JsValue::from_str(key))? + .dyn_into::() +} + +/// Calls `Telegram.WebApp.CloudStorage.getItems()`. +/// +/// # Errors +/// Returns `Err(JsValue)` if CloudStorage or the method is unavailable, or if +/// the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::cloud_storage::get_items; +/// use wasm_bindgen_futures::JsFuture; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// let _ = JsFuture::from(get_items(&["a", "b"])?).await?; +/// # Ok(()) +/// # } +/// ``` +pub fn get_items(keys: &[&str]) -> Result { + let storage = cloud_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("getItems"))?.dyn_into::()?; + let array = Array::new(); + for key in keys { + array.push(&JsValue::from_str(key)); + } + func.call1(&storage, &array.into())?.dyn_into::() +} + +/// Calls `Telegram.WebApp.CloudStorage.setItems()`. +/// +/// # Errors +/// Returns `Err(JsValue)` if CloudStorage or the method is unavailable, or if +/// the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::cloud_storage::set_items; +/// use wasm_bindgen_futures::JsFuture; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// JsFuture::from(set_items(&[("a", "1"), ("b", "2")])?).await?; +/// # Ok(()) +/// # } +/// ``` +pub fn set_items(items: &[(&str, &str)]) -> Result { + let storage = cloud_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("setItems"))?.dyn_into::()?; + let obj = js_sys::Object::new(); + for (key, value) in items { + Reflect::set(&obj, &JsValue::from_str(key), &JsValue::from_str(value))?; + } + func.call1(&storage, &obj.into())?.dyn_into::() +} + +/// Calls `Telegram.WebApp.CloudStorage.removeItems()`. +/// +/// # Errors +/// Returns `Err(JsValue)` if CloudStorage or the method is unavailable, or if +/// the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::cloud_storage::remove_items; +/// use wasm_bindgen_futures::JsFuture; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// JsFuture::from(remove_items(&["a", "b"])?).await?; +/// # Ok(()) +/// # } +/// ``` +pub fn remove_items(keys: &[&str]) -> Result { + let storage = cloud_storage_object()?; + let func = + Reflect::get(&storage, &JsValue::from_str("removeItems"))?.dyn_into::()?; + let array = Array::new(); + for key in keys { + array.push(&JsValue::from_str(key)); + } + func.call1(&storage, &array.into())?.dyn_into::() +} + +/// Calls `Telegram.WebApp.CloudStorage.getKeys()`. +/// +/// # Errors +/// Returns `Err(JsValue)` if CloudStorage or the method is unavailable, or if +/// the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::cloud_storage::get_keys; +/// use wasm_bindgen_futures::JsFuture; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// let _ = JsFuture::from(get_keys()?).await?; +/// # Ok(()) +/// # } +/// ``` +pub fn get_keys() -> Result { + let storage = cloud_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("getKeys"))?.dyn_into::()?; + func.call0(&storage)?.dyn_into::() +} + +/// Calls `Telegram.WebApp.CloudStorage.clear()`. +/// +/// # Errors +/// Returns `Err(JsValue)` if CloudStorage or the method is unavailable, or if +/// the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::cloud_storage::clear; +/// use wasm_bindgen_futures::JsFuture; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// JsFuture::from(clear()?).await?; +/// # Ok(()) +/// # } +/// ``` +pub fn clear() -> Result { + let storage = cloud_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("clear"))?.dyn_into::()?; + func.call0(&storage)?.dyn_into::() +} + +#[cfg(test)] +mod tests { + #![allow(dead_code)] + use js_sys::{Array, Function, Object, Reflect}; + use wasm_bindgen_futures::JsFuture; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + fn setup_cloud_storage() -> Object { + let win = window().unwrap(); + let telegram = Object::new(); + let webapp = Object::new(); + let storage = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + let _ = Reflect::set(&webapp, &"CloudStorage".into(), &storage); + storage + } + + #[wasm_bindgen_test(async)] + async fn get_item_ok() { + let storage = setup_cloud_storage(); + let func = + Function::new_with_args("key", "this.called = key; return Promise.resolve('val');"); + let _ = Reflect::set(&storage, &"getItem".into(), &func); + let value = JsFuture::from(get_item("test").unwrap()).await.unwrap(); + assert_eq!(value.as_string(), Some("val".to_string())); + assert_eq!( + Reflect::get(&storage, &"called".into()) + .unwrap() + .as_string(), + Some("test".into()) + ); + } + + #[wasm_bindgen_test] + fn get_item_err() { + let _ = setup_cloud_storage(); + assert!(get_item("test").is_err()); + } + + #[wasm_bindgen_test(async)] + async fn set_item_ok() { + let storage = setup_cloud_storage(); + let func = Function::new_with_args( + "key, value", + "this.called = key + ':' + value; return Promise.resolve();" + ); + let _ = Reflect::set(&storage, &"setItem".into(), &func); + JsFuture::from(set_item("a", "b").unwrap()).await.unwrap(); + assert_eq!( + Reflect::get(&storage, &"called".into()) + .unwrap() + .as_string(), + Some("a:b".into()) + ); + } + + #[wasm_bindgen_test] + fn set_item_err() { + let _ = setup_cloud_storage(); + assert!(set_item("a", "b").is_err()); + } + + #[wasm_bindgen_test(async)] + async fn remove_item_ok() { + let storage = setup_cloud_storage(); + let func = Function::new_with_args("key", "this.called = key; return Promise.resolve();"); + let _ = Reflect::set(&storage, &"removeItem".into(), &func); + JsFuture::from(remove_item("k").unwrap()).await.unwrap(); + assert_eq!( + Reflect::get(&storage, &"called".into()) + .unwrap() + .as_string(), + Some("k".into()) + ); + } + + #[wasm_bindgen_test] + fn remove_item_err() { + let _ = setup_cloud_storage(); + assert!(remove_item("k").is_err()); + } + + #[wasm_bindgen_test(async)] + async fn get_items_ok() { + let storage = setup_cloud_storage(); + let func = Function::new_with_args( + "keys", + "this.called = keys; return Promise.resolve({a: '1', b: '2'});" + ); + let _ = Reflect::set(&storage, &"getItems".into(), &func); + let result = JsFuture::from(get_items(&["a", "b"]).unwrap()) + .await + .unwrap(); + let obj = result.dyn_into::().unwrap(); + assert_eq!( + Reflect::get(&obj, &"a".into()).unwrap().as_string(), + Some("1".into()) + ); + assert_eq!( + Reflect::get(&obj, &"b".into()).unwrap().as_string(), + Some("2".into()) + ); + let called = Reflect::get(&storage, &"called".into()).unwrap(); + let arr = Array::from(&called); + assert_eq!(arr.get(0).as_string(), Some("a".into())); + assert_eq!(arr.get(1).as_string(), Some("b".into())); + } + + #[wasm_bindgen_test] + fn get_items_err() { + let _ = setup_cloud_storage(); + assert!(get_items(&["a"]).is_err()); + } + + #[wasm_bindgen_test(async)] + async fn set_items_ok() { + let storage = setup_cloud_storage(); + let func = + Function::new_with_args("items", "this.called = items; return Promise.resolve();"); + let _ = Reflect::set(&storage, &"setItems".into(), &func); + JsFuture::from(set_items(&[("a", "1"), ("b", "2")]).unwrap()) + .await + .unwrap(); + let called = Reflect::get(&storage, &"called".into()).unwrap(); + assert_eq!( + Reflect::get(&called, &"a".into()).unwrap().as_string(), + Some("1".into()) + ); + assert_eq!( + Reflect::get(&called, &"b".into()).unwrap().as_string(), + Some("2".into()) + ); + } + + #[wasm_bindgen_test] + fn set_items_err() { + let _ = setup_cloud_storage(); + assert!(set_items(&[("a", "1")]).is_err()); + } + + #[wasm_bindgen_test(async)] + async fn remove_items_ok() { + let storage = setup_cloud_storage(); + let func = + Function::new_with_args("keys", "this.called = keys; return Promise.resolve();"); + let _ = Reflect::set(&storage, &"removeItems".into(), &func); + JsFuture::from(remove_items(&["a", "b"]).unwrap()) + .await + .unwrap(); + let called = Reflect::get(&storage, &"called".into()).unwrap(); + let arr = Array::from(&called); + assert_eq!(arr.get(0).as_string(), Some("a".into())); + assert_eq!(arr.get(1).as_string(), Some("b".into())); + } + + #[wasm_bindgen_test] + fn remove_items_err() { + let _ = setup_cloud_storage(); + assert!(remove_items(&["a"]).is_err()); + } + + #[wasm_bindgen_test(async)] + async fn get_keys_ok() { + let storage = setup_cloud_storage(); + let func = Function::new_no_args("return Promise.resolve(['x', 'y']);"); + let _ = Reflect::set(&storage, &"getKeys".into(), &func); + let result = JsFuture::from(get_keys().unwrap()).await.unwrap(); + let arr = Array::from(&result); + assert_eq!(arr.get(0).as_string(), Some("x".into())); + assert_eq!(arr.get(1).as_string(), Some("y".into())); + } + + #[wasm_bindgen_test] + fn get_keys_err() { + let _ = setup_cloud_storage(); + assert!(get_keys().is_err()); + } + + #[wasm_bindgen_test(async)] + async fn clear_ok() { + let storage = setup_cloud_storage(); + let func = Function::new_no_args("this.called = true; return Promise.resolve();"); + let _ = Reflect::set(&storage, &"clear".into(), &func); + JsFuture::from(clear().unwrap()).await.unwrap(); + assert!( + Reflect::get(&storage, &"called".into()) + .unwrap() + .as_bool() + .unwrap() + ); + } + + #[wasm_bindgen_test] + fn clear_err() { + let _ = setup_cloud_storage(); + assert!(clear().is_err()); + } +} diff --git a/telegram-webapp-sdk/src/api/device_orientation.rs b/telegram-webapp-sdk/src/api/device_orientation.rs new file mode 100644 index 0000000..95ce7fe --- /dev/null +++ b/telegram-webapp-sdk/src/api/device_orientation.rs @@ -0,0 +1,200 @@ +use js_sys::{Function, Reflect}; +use wasm_bindgen::{JsCast, prelude::*}; +use web_sys::window; + +use super::events; + +/// Device orientation angles in degrees. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Orientation { + /// Rotation around the Z axis. + pub alpha: f64, + /// Rotation around the X axis. + pub beta: f64, + /// Rotation around the Y axis. + pub gamma: f64 +} + +impl Orientation { + #[allow(dead_code)] + const fn new(alpha: f64, beta: f64, gamma: f64) -> Self { + Self { + alpha, + beta, + gamma + } + } +} + +/// Starts the device orientation sensor. +/// +/// # Errors +/// Returns [`JsValue`] if the JavaScript call fails or the sensor is +/// unavailable. +/// +/// # Examples +/// ```no_run +/// # use telegram_webapp_sdk::api::device_orientation::start; +/// start()?; +/// # Ok::<(), wasm_bindgen::JsValue>(()) +/// ``` +pub fn start() -> Result<(), JsValue> { + let orientation = device_orientation_object()?; + let func = Reflect::get(&orientation, &"start".into())?.dyn_into::()?; + func.call0(&orientation)?; + Ok(()) +} + +/// Stops the device orientation sensor. +/// +/// # Errors +/// Returns [`JsValue`] if the JavaScript call fails or the sensor is +/// unavailable. +/// +/// # Examples +/// ```no_run +/// # use telegram_webapp_sdk::api::device_orientation::stop; +/// stop()?; +/// # Ok::<(), wasm_bindgen::JsValue>(()) +/// ``` +pub fn stop() -> Result<(), JsValue> { + let orientation = device_orientation_object()?; + let func = Reflect::get(&orientation, &"stop".into())?.dyn_into::()?; + func.call0(&orientation)?; + Ok(()) +} + +/// Reads the current device orientation angles. +/// +/// # Examples +/// ```no_run +/// # use telegram_webapp_sdk::api::device_orientation::get_orientation; +/// let _ = get_orientation(); +/// ``` +pub fn get_orientation() -> Option { + let orientation = device_orientation_object().ok()?; + let alpha = Reflect::get(&orientation, &"alpha".into()).ok()?.as_f64()?; + let beta = Reflect::get(&orientation, &"beta".into()).ok()?.as_f64()?; + let gamma = Reflect::get(&orientation, &"gamma".into()).ok()?.as_f64()?; + Some(Orientation { + alpha, + beta, + gamma + }) +} + +/// Registers a callback for `deviceOrientationStarted` event. +pub fn on_started(callback: &Closure) -> Result<(), JsValue> { + events::on_event("deviceOrientationStarted", callback) +} + +/// Registers a callback for `deviceOrientationChanged` event. +pub fn on_changed(callback: &Closure) -> Result<(), JsValue> { + events::on_event("deviceOrientationChanged", callback) +} + +/// Registers a callback for `deviceOrientationStopped` event. +pub fn on_stopped(callback: &Closure) -> Result<(), JsValue> { + events::on_event("deviceOrientationStopped", callback) +} + +/// Registers a callback for `deviceOrientationFailed` event. +pub fn on_failed(callback: &Closure) -> Result<(), JsValue> { + events::on_event("deviceOrientationFailed", callback) +} + +fn device_orientation_object() -> Result { + let win = window().ok_or_else(|| JsValue::from_str("no window"))?; + let tg = Reflect::get(&win, &"Telegram".into())?; + let webapp = Reflect::get(&tg, &"WebApp".into())?; + Reflect::get(&webapp, &"DeviceOrientation".into()) +} + +#[cfg(test)] +#[allow(dead_code)] +mod tests { + use js_sys::{Function, Object, Reflect}; + use wasm_bindgen::{JsValue, closure::Closure}; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn setup_device_orientation() -> (Object, Object) { + let win = window().unwrap(); + let telegram = Object::new(); + let webapp = Object::new(); + let orientation = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + let _ = Reflect::set(&webapp, &"DeviceOrientation".into(), &orientation); + (webapp, orientation) + } + + #[wasm_bindgen_test] + #[allow(clippy::unused_unit)] + fn start_ok() { + let (_webapp, orientation) = setup_device_orientation(); + let func = Function::new_no_args("this.called = true;"); + let _ = Reflect::set(&orientation, &"start".into(), &func); + assert!(start().is_ok()); + let called = Reflect::get(&orientation, &"called".into()).unwrap(); + assert_eq!(called.as_bool(), Some(true)); + } + + #[wasm_bindgen_test] + #[allow(clippy::unused_unit)] + fn start_err() { + let (_webapp, orientation) = setup_device_orientation(); + let _ = Reflect::set(&orientation, &"start".into(), &JsValue::from_f64(1.0)); + assert!(start().is_err()); + } + + #[wasm_bindgen_test] + #[allow(clippy::unused_unit)] + fn stop_ok() { + let (_webapp, orientation) = setup_device_orientation(); + let func = Function::new_no_args("this.stopped = true;"); + let _ = Reflect::set(&orientation, &"stop".into(), &func); + assert!(stop().is_ok()); + let stopped = Reflect::get(&orientation, &"stopped".into()).unwrap(); + assert_eq!(stopped.as_bool(), Some(true)); + } + + #[wasm_bindgen_test] + fn get_orientation_ok() { + let (_webapp, orientation) = setup_device_orientation(); + let _ = Reflect::set(&orientation, &"alpha".into(), &JsValue::from_f64(10.0)); + let _ = Reflect::set(&orientation, &"beta".into(), &JsValue::from_f64(20.0)); + let _ = Reflect::set(&orientation, &"gamma".into(), &JsValue::from_f64(30.0)); + let result = get_orientation().unwrap(); + assert_eq!( + result, + Orientation { + alpha: 10.0, + beta: 20.0, + gamma: 30.0 + } + ); + } + + #[wasm_bindgen_test] + fn registers_callbacks() { + let (webapp, _orientation) = setup_device_orientation(); + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + let cb = Closure::wrap(Box::new(|| {}) as Box); + on_started(&cb).expect("on_started"); + on_changed(&cb).expect("on_changed"); + on_stopped(&cb).expect("on_stopped"); + on_failed(&cb).expect("on_failed"); + assert!(Reflect::has(&webapp, &"deviceOrientationStarted".into()).unwrap()); + assert!(Reflect::has(&webapp, &"deviceOrientationChanged".into()).unwrap()); + assert!(Reflect::has(&webapp, &"deviceOrientationStopped".into()).unwrap()); + assert!(Reflect::has(&webapp, &"deviceOrientationFailed".into()).unwrap()); + cb.forget(); + } +} diff --git a/telegram-webapp-sdk/src/api/device_storage.rs b/telegram-webapp-sdk/src/api/device_storage.rs new file mode 100644 index 0000000..00e9268 --- /dev/null +++ b/telegram-webapp-sdk/src/api/device_storage.rs @@ -0,0 +1,201 @@ +use js_sys::{Function, Promise, Reflect}; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::window; + +/// Stores a value under the given key in Telegram's device storage. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `deviceStorage` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::device_storage::set; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// set("foo", "bar").await?; +/// # Ok(()) } +/// ``` +pub async fn set(key: &str, value: &str) -> Result<(), JsValue> { + let storage = device_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("set"))?.dyn_into::()?; + let promise = func + .call2(&storage, &JsValue::from_str(key), &JsValue::from_str(value))? + .dyn_into::()?; + JsFuture::from(promise).await?; + Ok(()) +} + +/// Retrieves a value from Telegram's device storage. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `deviceStorage` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::device_storage::{get, set}; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// set("foo", "bar").await?; +/// let value = get("foo").await?; +/// assert_eq!(value.as_deref(), Some("bar")); +/// # Ok(()) } +/// ``` +pub async fn get(key: &str) -> Result, JsValue> { + let storage = device_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("get"))?.dyn_into::()?; + let promise = func + .call1(&storage, &JsValue::from_str(key))? + .dyn_into::()?; + let value = JsFuture::from(promise).await?; + Ok(value.as_string()) +} + +/// Removes a value from Telegram's device storage. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `deviceStorage` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::device_storage::{remove, set}; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// set("foo", "bar").await?; +/// remove("foo").await?; +/// # Ok(()) } +/// ``` +pub async fn remove(key: &str) -> Result<(), JsValue> { + let storage = device_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("remove"))?.dyn_into::()?; + let promise = func + .call1(&storage, &JsValue::from_str(key))? + .dyn_into::()?; + JsFuture::from(promise).await?; + Ok(()) +} + +/// Clears all entries from Telegram's device storage. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `deviceStorage` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::device_storage::{clear, set}; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// set("foo", "bar").await?; +/// clear().await?; +/// # Ok(()) } +/// ``` +pub async fn clear() -> Result<(), JsValue> { + let storage = device_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("clear"))?.dyn_into::()?; + let promise = func.call0(&storage)?.dyn_into::()?; + JsFuture::from(promise).await?; + Ok(()) +} + +fn device_storage_object() -> Result { + let window = window().ok_or_else(|| JsValue::from_str("no window"))?; + let tg = Reflect::get(&window, &JsValue::from_str("Telegram"))?; + let webapp = Reflect::get(&tg, &JsValue::from_str("WebApp"))?; + Reflect::get(&webapp, &JsValue::from_str("deviceStorage")) +} + +#[cfg(test)] +mod tests { + use js_sys::{Function, Object, Reflect}; + use wasm_bindgen::prelude::*; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn setup_device_storage() -> Object { + let win = window().unwrap(); + let telegram = Object::new(); + let webapp = Object::new(); + let storage = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + let _ = Reflect::set(&webapp, &"deviceStorage".into(), &storage); + storage + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn set_calls_js() { + let storage = setup_device_storage(); + let func = Function::new_with_args("k,v", "this[k] = v; return Promise.resolve();"); + let _ = Reflect::set(&storage, &"set".into(), &func); + assert!(set("a", "b").await.is_ok()); + let val = Reflect::get(&storage, &"a".into()).unwrap(); + assert_eq!(val.as_string().as_deref(), Some("b")); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn set_err() { + assert!(set("a", "b").await.is_err()); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn get_calls_js() { + let storage = setup_device_storage(); + let func = Function::new_with_args("k", "return this[k];"); + let _ = Reflect::set(&storage, &"get".into(), &func); + let _ = Reflect::set(&storage, &"a".into(), &JsValue::from_str("b")); + let value = get("a").await.unwrap(); + assert_eq!(value.as_deref(), Some("b")); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn get_err() { + assert!(get("a").await.is_err()); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn remove_calls_js() { + let storage = setup_device_storage(); + let func = Function::new_with_args("k", "delete this[k]; return Promise.resolve();"); + let _ = Reflect::set(&storage, &"remove".into(), &func); + let _ = Reflect::set(&storage, &"a".into(), &JsValue::from_str("b")); + assert!(remove("a").await.is_ok()); + let has = Reflect::has(&storage, &"a".into()).unwrap(); + assert!(!has); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn remove_err() { + assert!(remove("a").await.is_err()); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn clear_calls_js() { + let storage = setup_device_storage(); + let func = Function::new_no_args( + "Object.keys(this).forEach(k => delete this[k]); return Promise.resolve();" + ); + let _ = Reflect::set(&storage, &"clear".into(), &func); + let _ = Reflect::set(&storage, &"a".into(), &JsValue::from_str("b")); + assert!(clear().await.is_ok()); + let has = Reflect::has(&storage, &"a".into()).unwrap(); + assert!(!has); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn clear_err() { + assert!(clear().await.is_err()); + } +} diff --git a/telegram-webapp-sdk/src/api/events.rs b/telegram-webapp-sdk/src/api/events.rs new file mode 100644 index 0000000..c4d6b0d --- /dev/null +++ b/telegram-webapp-sdk/src/api/events.rs @@ -0,0 +1,76 @@ +use js_sys::{Function, Reflect}; +use wasm_bindgen::{JsCast, prelude::*}; +use web_sys::window; + +/// Adds an event listener for Telegram.WebApp.onEvent(name, callback) +/// +/// # Safety +/// You must keep the closure alive for as long as it's needed. +pub fn on_event(event_name: &str, callback: &Closure) -> Result<(), JsValue> { + let webapp = get_webapp_object()?; + let func = Reflect::get(&webapp, &JsValue::from_str("onEvent"))?.dyn_into::()?; + func.call2(&webapp, &JsValue::from_str(event_name), callback.as_ref())?; + Ok(()) +} + +/// Removes a previously registered event listener. +/// +/// This is optional but recommended for cleanup. +pub fn off_event(event_name: &str, callback: &Closure) -> Result<(), JsValue> { + let webapp = get_webapp_object()?; + let func = Reflect::get(&webapp, &JsValue::from_str("offEvent"))?.dyn_into::()?; + func.call2(&webapp, &JsValue::from_str(event_name), callback.as_ref())?; + Ok(()) +} + +/// Internal helper to get `Telegram.WebApp` JS object. +fn get_webapp_object() -> Result { + let window = window().ok_or_else(|| JsValue::from_str("no window"))?; + let telegram = Reflect::get(&window, &JsValue::from_str("Telegram"))?; + Reflect::get(&telegram, &JsValue::from_str("WebApp")) +} + +#[cfg(test)] +mod tests { + use js_sys::{Function, Object, Reflect}; + use wasm_bindgen::{JsValue, closure::Closure}; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn setup_webapp() -> Object { + let win = window().expect("window should be available"); + let telegram = Object::new(); + let webapp = Object::new(); + + // onEvent stores the callback under the event name. + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + // offEvent removes the stored callback. + let off_event = Function::new_with_args("name", "delete this[name];"); + + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + let _ = Reflect::set(&webapp, &"offEvent".into(), &off_event); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + webapp + } + + #[wasm_bindgen_test] + #[allow(dead_code)] + fn registers_and_removes_callback() { + let webapp = setup_webapp(); + let cb = Closure::wrap(Box::new(|| {}) as Box); + + on_event("test-event", &cb).expect("register callback"); + let has = Reflect::has(&webapp, &JsValue::from_str("test-event")).unwrap(); + assert!(has, "callback was not stored"); + + off_event("test-event", &cb).expect("remove callback"); + let has_after = Reflect::has(&webapp, &JsValue::from_str("test-event")).unwrap(); + assert!(!has_after, "callback was not removed"); + } +} diff --git a/telegram-webapp-sdk/src/api/gyroscope.rs b/telegram-webapp-sdk/src/api/gyroscope.rs new file mode 100644 index 0000000..44808fe --- /dev/null +++ b/telegram-webapp-sdk/src/api/gyroscope.rs @@ -0,0 +1,200 @@ +use js_sys::{Function, Reflect}; +use wasm_bindgen::{JsCast, prelude::*}; +use web_sys::window; + +use super::events; + +/// Angular velocity around three axes in radians per second. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AngularVelocity { + /// Rotation rate around the X axis. + pub x: f64, + /// Rotation rate around the Y axis. + pub y: f64, + /// Rotation rate around the Z axis. + pub z: f64 +} + +impl AngularVelocity { + #[allow(dead_code)] + const fn new(x: f64, y: f64, z: f64) -> Self { + Self { + x, + y, + z + } + } +} + +/// Starts the gyroscope. +/// +/// # Errors +/// Returns [`JsValue`] if the JavaScript call fails or the sensor is +/// unavailable. +/// +/// # Examples +/// ```no_run +/// # use telegram_webapp_sdk::api::gyroscope::start; +/// start()?; +/// # Ok::<(), wasm_bindgen::JsValue>(()) +/// ``` +pub fn start() -> Result<(), JsValue> { + let gyro = gyroscope_object()?; + let func = Reflect::get(&gyro, &"start".into())?.dyn_into::()?; + func.call0(&gyro)?; + Ok(()) +} + +/// Stops the gyroscope. +/// +/// # Errors +/// Returns [`JsValue`] if the JavaScript call fails or the sensor is +/// unavailable. +/// +/// # Examples +/// ```no_run +/// # use telegram_webapp_sdk::api::gyroscope::stop; +/// stop()?; +/// # Ok::<(), wasm_bindgen::JsValue>(()) +/// ``` +pub fn stop() -> Result<(), JsValue> { + let gyro = gyroscope_object()?; + let func = Reflect::get(&gyro, &"stop".into())?.dyn_into::()?; + func.call0(&gyro)?; + Ok(()) +} + +/// Reads the current angular velocity values. +/// +/// # Examples +/// ```no_run +/// # use telegram_webapp_sdk::api::gyroscope::get_angular_velocity; +/// let _ = get_angular_velocity(); +/// ``` +pub fn get_angular_velocity() -> Option { + let gyro = gyroscope_object().ok()?; + let x = Reflect::get(&gyro, &"x".into()).ok()?.as_f64()?; + let y = Reflect::get(&gyro, &"y".into()).ok()?.as_f64()?; + let z = Reflect::get(&gyro, &"z".into()).ok()?.as_f64()?; + Some(AngularVelocity { + x, + y, + z + }) +} + +/// Registers a callback for `gyroscopeStarted` event. +pub fn on_started(callback: &Closure) -> Result<(), JsValue> { + events::on_event("gyroscopeStarted", callback) +} + +/// Registers a callback for `gyroscopeChanged` event. +pub fn on_changed(callback: &Closure) -> Result<(), JsValue> { + events::on_event("gyroscopeChanged", callback) +} + +/// Registers a callback for `gyroscopeStopped` event. +pub fn on_stopped(callback: &Closure) -> Result<(), JsValue> { + events::on_event("gyroscopeStopped", callback) +} + +/// Registers a callback for `gyroscopeFailed` event. +pub fn on_failed(callback: &Closure) -> Result<(), JsValue> { + events::on_event("gyroscopeFailed", callback) +} + +fn gyroscope_object() -> Result { + let win = window().ok_or_else(|| JsValue::from_str("no window"))?; + let tg = Reflect::get(&win, &"Telegram".into())?; + let webapp = Reflect::get(&tg, &"WebApp".into())?; + Reflect::get(&webapp, &"Gyroscope".into()) +} + +#[cfg(test)] +#[allow(dead_code)] +mod tests { + use js_sys::{Function, Object, Reflect}; + use wasm_bindgen::{JsValue, closure::Closure}; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn setup_gyroscope() -> (Object, Object) { + let win = window().unwrap(); + let telegram = Object::new(); + let webapp = Object::new(); + let gyro = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + let _ = Reflect::set(&webapp, &"Gyroscope".into(), &gyro); + (webapp, gyro) + } + + #[wasm_bindgen_test] + #[allow(clippy::unused_unit)] + fn start_ok() { + let (_webapp, gyro) = setup_gyroscope(); + let func = Function::new_no_args("this.called = true;"); + let _ = Reflect::set(&gyro, &"start".into(), &func); + assert!(start().is_ok()); + let called = Reflect::get(&gyro, &"called".into()).unwrap(); + assert_eq!(called.as_bool(), Some(true)); + } + + #[wasm_bindgen_test] + #[allow(clippy::unused_unit)] + fn start_err() { + let (_webapp, gyro) = setup_gyroscope(); + let _ = Reflect::set(&gyro, &"start".into(), &JsValue::from_f64(1.0)); + assert!(start().is_err()); + } + + #[wasm_bindgen_test] + #[allow(clippy::unused_unit)] + fn stop_ok() { + let (_webapp, gyro) = setup_gyroscope(); + let func = Function::new_no_args("this.stopped = true;"); + let _ = Reflect::set(&gyro, &"stop".into(), &func); + assert!(stop().is_ok()); + let stopped = Reflect::get(&gyro, &"stopped".into()).unwrap(); + assert_eq!(stopped.as_bool(), Some(true)); + } + + #[wasm_bindgen_test] + fn get_angular_velocity_ok() { + let (_webapp, gyro) = setup_gyroscope(); + let _ = Reflect::set(&gyro, &"x".into(), &JsValue::from_f64(0.1)); + let _ = Reflect::set(&gyro, &"y".into(), &JsValue::from_f64(0.2)); + let _ = Reflect::set(&gyro, &"z".into(), &JsValue::from_f64(0.3)); + let result = get_angular_velocity().unwrap(); + assert_eq!( + result, + AngularVelocity { + x: 0.1, + y: 0.2, + z: 0.3 + } + ); + } + + #[wasm_bindgen_test] + fn registers_callbacks() { + let (webapp, _gyro) = setup_gyroscope(); + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + let cb = Closure::wrap(Box::new(|| {}) as Box); + on_started(&cb).expect("on_started"); + on_changed(&cb).expect("on_changed"); + on_stopped(&cb).expect("on_stopped"); + on_failed(&cb).expect("on_failed"); + assert!(Reflect::has(&webapp, &"gyroscopeStarted".into()).unwrap()); + assert!(Reflect::has(&webapp, &"gyroscopeChanged".into()).unwrap()); + assert!(Reflect::has(&webapp, &"gyroscopeStopped".into()).unwrap()); + assert!(Reflect::has(&webapp, &"gyroscopeFailed".into()).unwrap()); + cb.forget(); + } +} diff --git a/telegram-webapp-sdk/src/api/haptic.rs b/telegram-webapp-sdk/src/api/haptic.rs new file mode 100644 index 0000000..9d09cc9 --- /dev/null +++ b/telegram-webapp-sdk/src/api/haptic.rs @@ -0,0 +1,191 @@ +use js_sys::{Function, Reflect}; +use wasm_bindgen::{JsCast, prelude::*}; +use web_sys::window; + +/// Available styles for [`impact_occurred`]. +#[derive(Debug, Clone, Copy)] +pub enum HapticImpactStyle { + /// A light impact feedback. + Light, + /// A medium impact feedback. + Medium, + /// A heavy impact feedback. + Heavy, + /// A rigid impact feedback. + Rigid, + /// A soft impact feedback. + Soft +} + +impl HapticImpactStyle { + const fn as_str(self) -> &'static str { + match self { + Self::Light => "light", + Self::Medium => "medium", + Self::Heavy => "heavy", + Self::Rigid => "rigid", + Self::Soft => "soft" + } + } +} + +/// Available types for [`notification_occurred`]. +#[derive(Debug, Clone, Copy)] +pub enum HapticNotificationType { + /// Error notification feedback. + Error, + /// Success notification feedback. + Success, + /// Warning notification feedback. + Warning +} + +impl HapticNotificationType { + const fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Success => "success", + Self::Warning => "warning" + } + } +} + +/// Triggers a haptic impact feedback. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `HapticFeedback` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::haptic::{HapticImpactStyle, impact_occurred}; +/// # fn run() -> Result<(), wasm_bindgen::JsValue> { +/// impact_occurred(HapticImpactStyle::Light)?; +/// # Ok(()) } +/// ``` +pub fn impact_occurred(style: HapticImpactStyle) -> Result<(), JsValue> { + let haptic = haptic_object()?; + let func = Reflect::get(&haptic, &"impactOccurred".into())?.dyn_into::()?; + func.call1(&haptic, &JsValue::from_str(style.as_str()))?; + Ok(()) +} + +/// Triggers a haptic notification feedback. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `HapticFeedback` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::haptic::{HapticNotificationType, notification_occurred}; +/// # fn run() -> Result<(), wasm_bindgen::JsValue> { +/// notification_occurred(HapticNotificationType::Success)?; +/// # Ok(()) } +/// ``` +pub fn notification_occurred(ty: HapticNotificationType) -> Result<(), JsValue> { + let haptic = haptic_object()?; + let func = Reflect::get(&haptic, &"notificationOccurred".into())?.dyn_into::()?; + func.call1(&haptic, &JsValue::from_str(ty.as_str()))?; + Ok(()) +} + +/// Triggers a haptic selection change feedback. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `HapticFeedback` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::haptic::selection_changed; +/// # fn run() -> Result<(), wasm_bindgen::JsValue> { +/// selection_changed()?; +/// # Ok(()) } +/// ``` +pub fn selection_changed() -> Result<(), JsValue> { + let haptic = haptic_object()?; + let func = Reflect::get(&haptic, &"selectionChanged".into())?.dyn_into::()?; + func.call0(&haptic)?; + Ok(()) +} + +/// Internal helper to get `Telegram.WebApp.HapticFeedback` object. +fn haptic_object() -> Result { + let window = window().ok_or_else(|| JsValue::from_str("no window"))?; + let tg = Reflect::get(&window, &"Telegram".into())?; + let webapp = Reflect::get(&tg, &"WebApp".into())?; + Reflect::get(&webapp, &"HapticFeedback".into()) +} + +#[cfg(test)] +mod tests { + use js_sys::{Object, Reflect}; + use wasm_bindgen::prelude::*; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn setup_haptic() -> Object { + let win = window().unwrap(); + let telegram = Object::new(); + let webapp = Object::new(); + let haptic = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + let _ = Reflect::set(&webapp, &"HapticFeedback".into(), &haptic); + haptic + } + + #[wasm_bindgen_test] + #[allow(dead_code)] + fn impact_calls_js() { + let haptic = setup_haptic(); + let _ = Reflect::set(&haptic, &"impact_called".into(), &JsValue::FALSE); + let haptic_clone = haptic.clone(); + let closure = Closure::wrap(Box::new(move |_style: JsValue| { + let _ = Reflect::set(&haptic_clone, &"impact_called".into(), &JsValue::TRUE); + }) as Box); + let _ = Reflect::set(&haptic, &"impactOccurred".into(), closure.as_ref()); + closure.forget(); + let _ = impact_occurred(HapticImpactStyle::Light); + let flag = Reflect::get(&haptic, &"impact_called".into()).unwrap(); + assert!(flag.as_bool().unwrap()); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] + fn notification_calls_js() { + let haptic = setup_haptic(); + let _ = Reflect::set(&haptic, &"notification_called".into(), &JsValue::FALSE); + let haptic_clone = haptic.clone(); + let closure = Closure::wrap(Box::new(move |_ty: JsValue| { + let _ = Reflect::set(&haptic_clone, &"notification_called".into(), &JsValue::TRUE); + }) as Box); + let _ = Reflect::set(&haptic, &"notificationOccurred".into(), closure.as_ref()); + closure.forget(); + let _ = notification_occurred(HapticNotificationType::Error); + let flag = Reflect::get(&haptic, &"notification_called".into()).unwrap(); + assert!(flag.as_bool().unwrap()); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] + fn selection_calls_js() { + let haptic = setup_haptic(); + let _ = Reflect::set(&haptic, &"selection_called".into(), &JsValue::FALSE); + let haptic_clone = haptic.clone(); + let closure = Closure::wrap(Box::new(move || { + let _ = Reflect::set(&haptic_clone, &"selection_called".into(), &JsValue::TRUE); + }) as Box); + let _ = Reflect::set(&haptic, &"selectionChanged".into(), closure.as_ref()); + closure.forget(); + let _ = selection_changed(); + let flag = Reflect::get(&haptic, &"selection_called".into()).unwrap(); + assert!(flag.as_bool().unwrap()); + } +} diff --git a/telegram-webapp-sdk/src/api/location.rs b/telegram-webapp-sdk/src/api/location.rs new file mode 100644 index 0000000..e69de29 diff --git a/telegram-webapp-sdk/src/api/location_manager.rs b/telegram-webapp-sdk/src/api/location_manager.rs new file mode 100644 index 0000000..aedf023 --- /dev/null +++ b/telegram-webapp-sdk/src/api/location_manager.rs @@ -0,0 +1,235 @@ +use js_sys::{Function, Reflect}; +use wasm_bindgen::{JsCast, prelude::*}; +use web_sys::window; + +/// Initializes `Telegram.WebApp.locationManager`. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `locationManager` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::location_manager::init; +/// # fn run() -> Result<(), wasm_bindgen::JsValue> { +/// init()?; +/// # Ok(()) } +/// ``` +pub fn init() -> Result<(), JsValue> { + let manager = location_manager_object()?; + let func = Reflect::get(&manager, &JsValue::from_str("init"))?.dyn_into::()?; + func.call0(&manager)?; + Ok(()) +} + +/// Retrieves the current location via `getLocation`. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `locationManager` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::location_manager::get_location; +/// # fn run() -> Result<(), wasm_bindgen::JsValue> { +/// let _loc = get_location()?; +/// # Ok(()) } +/// ``` +pub fn get_location() -> Result { + let manager = location_manager_object()?; + let func = + Reflect::get(&manager, &JsValue::from_str("getLocation"))?.dyn_into::()?; + func.call0(&manager) +} + +/// Opens the location settings via `openSettings`. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `locationManager` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::location_manager::open_settings; +/// # fn run() -> Result<(), wasm_bindgen::JsValue> { +/// open_settings()?; +/// # Ok(()) } +/// ``` +pub fn open_settings() -> Result<(), JsValue> { + let manager = location_manager_object()?; + let func = + Reflect::get(&manager, &JsValue::from_str("openSettings"))?.dyn_into::()?; + func.call0(&manager)?; + Ok(()) +} + +/// Registers a callback for `locationManagerUpdated` events. +/// +/// # Errors +/// Returns `Err(JsValue)` if the event registration fails or `WebApp` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::location_manager::on_location_manager_updated; +/// use wasm_bindgen::closure::Closure; +/// # fn run() -> Result<(), wasm_bindgen::JsValue> { +/// let cb = Closure::wrap(Box::new(|| {}) as Box); +/// on_location_manager_updated(&cb)?; +/// cb.forget(); +/// # Ok(()) } +/// ``` +pub fn on_location_manager_updated(callback: &Closure) -> Result<(), JsValue> { + add_event_listener("locationManagerUpdated", callback) +} + +/// Registers a callback for `locationRequested` events. +/// +/// # Errors +/// Returns `Err(JsValue)` if the event registration fails or `WebApp` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::location_manager::on_location_requested; +/// use wasm_bindgen::closure::Closure; +/// # fn run() -> Result<(), wasm_bindgen::JsValue> { +/// let cb = Closure::wrap(Box::new(|| {}) as Box); +/// on_location_requested(&cb)?; +/// cb.forget(); +/// # Ok(()) } +/// ``` +pub fn on_location_requested(callback: &Closure) -> Result<(), JsValue> { + add_event_listener("locationRequested", callback) +} + +fn add_event_listener(event: &str, callback: &Closure) -> Result<(), JsValue> { + let webapp = webapp_object()?; + let on_event = Reflect::get(&webapp, &JsValue::from_str("onEvent"))?.dyn_into::()?; + on_event.call2(&webapp, &JsValue::from_str(event), callback.as_ref())?; + Ok(()) +} + +fn location_manager_object() -> Result { + let window = window().ok_or_else(|| JsValue::from_str("no window"))?; + let tg = Reflect::get(&window, &JsValue::from_str("Telegram"))?; + let webapp = Reflect::get(&tg, &JsValue::from_str("WebApp"))?; + Reflect::get(&webapp, &JsValue::from_str("locationManager")) +} + +fn webapp_object() -> Result { + let window = window().ok_or_else(|| JsValue::from_str("no window"))?; + let tg = Reflect::get(&window, &JsValue::from_str("Telegram"))?; + Reflect::get(&tg, &JsValue::from_str("WebApp")) +} + +#[cfg(test)] +mod tests { + use js_sys::{Function, Object, Reflect}; + use wasm_bindgen::{JsValue, closure::Closure}; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn setup_location_manager() -> (Object, Object) { + let win = window().expect("window should be available"); + let telegram = Object::new(); + let webapp = Object::new(); + let manager = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + let _ = Reflect::set(&webapp, &"locationManager".into(), &manager); + (webapp, manager) + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn init_ok() { + let (_webapp, manager) = setup_location_manager(); + let func = Function::new_no_args("this.called = true;"); + let _ = Reflect::set(&manager, &"init".into(), &func); + assert!(init().is_ok()); + assert!( + Reflect::get(&manager, &"called".into()) + .unwrap() + .as_bool() + .unwrap() + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn init_err() { + let _ = setup_location_manager(); + assert!(init().is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn get_location_ok() { + let (_webapp, manager) = setup_location_manager(); + let location = Object::new(); + let func = Function::new_no_args("return this.loc;"); + let _ = Reflect::set(&manager, &"getLocation".into(), &func); + let _ = Reflect::set(&manager, &"loc".into(), &location); + let result = get_location().expect("location"); + assert!(result.is_object()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn get_location_err() { + let _ = setup_location_manager(); + assert!(get_location().is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn open_settings_ok() { + let (_webapp, manager) = setup_location_manager(); + let func = Function::new_no_args("this.called = true;"); + let _ = Reflect::set(&manager, &"openSettings".into(), &func); + assert!(open_settings().is_ok()); + assert!( + Reflect::get(&manager, &"called".into()) + .unwrap() + .as_bool() + .unwrap() + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn open_settings_err() { + let _ = setup_location_manager(); + assert!(open_settings().is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] + fn registers_location_manager_updated_callback() { + let (webapp, _manager) = setup_location_manager(); + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + let cb = Closure::wrap(Box::new(|| {}) as Box); + on_location_manager_updated(&cb).expect("register callback"); + let has = Reflect::has(&webapp, &JsValue::from_str("locationManagerUpdated")).unwrap(); + assert!(has); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] + fn registers_location_requested_callback() { + let (webapp, _manager) = setup_location_manager(); + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + let cb = Closure::wrap(Box::new(|| {}) as Box); + on_location_requested(&cb).expect("register callback"); + let has = Reflect::has(&webapp, &JsValue::from_str("locationRequested")).unwrap(); + assert!(has); + } +} diff --git a/telegram-webapp-sdk/src/api/secure_storage.rs b/telegram-webapp-sdk/src/api/secure_storage.rs new file mode 100644 index 0000000..47c4724 --- /dev/null +++ b/telegram-webapp-sdk/src/api/secure_storage.rs @@ -0,0 +1,246 @@ +use js_sys::{Function, Promise, Reflect}; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::window; + +/// Stores a value under the given key in Telegram's secure storage. +/// +/// Values are stored in an encrypted form and can be restored after the user +/// reinstalls the application. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `secureStorage` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::secure_storage::set; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// set("token", "123").await?; +/// # Ok(()) } +/// ``` +pub async fn set(key: &str, value: &str) -> Result<(), JsValue> { + let storage = secure_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("set"))?.dyn_into::()?; + let promise = func + .call2(&storage, &JsValue::from_str(key), &JsValue::from_str(value))? + .dyn_into::()?; + JsFuture::from(promise).await?; + Ok(()) +} + +/// Retrieves a value from Telegram's secure storage. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `secureStorage` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::secure_storage::{get, set}; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// set("token", "123").await?; +/// let value = get("token").await?; +/// assert_eq!(value.as_deref(), Some("123")); +/// # Ok(()) } +/// ``` +pub async fn get(key: &str) -> Result, JsValue> { + let storage = secure_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("get"))?.dyn_into::()?; + let promise = func + .call1(&storage, &JsValue::from_str(key))? + .dyn_into::()?; + let value = JsFuture::from(promise).await?; + Ok(value.as_string()) +} + +/// Restores a previously removed value from Telegram's secure storage. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `secureStorage` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::secure_storage::{remove, restore, set}; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// set("token", "123").await?; +/// remove("token").await?; +/// let _ = restore("token").await?; +/// # Ok(()) } +/// ``` +pub async fn restore(key: &str) -> Result, JsValue> { + let storage = secure_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("restore"))?.dyn_into::()?; + let promise = func + .call1(&storage, &JsValue::from_str(key))? + .dyn_into::()?; + let value = JsFuture::from(promise).await?; + Ok(value.as_string()) +} + +/// Removes a value from Telegram's secure storage. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `secureStorage` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::secure_storage::{remove, set}; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// set("token", "123").await?; +/// remove("token").await?; +/// # Ok(()) } +/// ``` +pub async fn remove(key: &str) -> Result<(), JsValue> { + let storage = secure_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("remove"))?.dyn_into::()?; + let promise = func + .call1(&storage, &JsValue::from_str(key))? + .dyn_into::()?; + JsFuture::from(promise).await?; + Ok(()) +} + +/// Clears all entries from Telegram's secure storage. +/// +/// # Errors +/// Returns `Err(JsValue)` if the JavaScript call fails or `secureStorage` is +/// missing. +/// +/// # Examples +/// ``` +/// use telegram_webapp_sdk::api::secure_storage::{clear, set}; +/// # async fn run() -> Result<(), wasm_bindgen::JsValue> { +/// set("token", "123").await?; +/// clear().await?; +/// # Ok(()) } +/// ``` +pub async fn clear() -> Result<(), JsValue> { + let storage = secure_storage_object()?; + let func = Reflect::get(&storage, &JsValue::from_str("clear"))?.dyn_into::()?; + let promise = func.call0(&storage)?.dyn_into::()?; + JsFuture::from(promise).await?; + Ok(()) +} + +fn secure_storage_object() -> Result { + let window = window().ok_or_else(|| JsValue::from_str("no window"))?; + let tg = Reflect::get(&window, &JsValue::from_str("Telegram"))?; + let webapp = Reflect::get(&tg, &JsValue::from_str("WebApp"))?; + Reflect::get(&webapp, &JsValue::from_str("secureStorage")) +} + +#[cfg(test)] +mod tests { + use js_sys::{Function, Object, Reflect}; + use wasm_bindgen::prelude::*; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn setup_secure_storage() -> Object { + let win = window().unwrap(); + let telegram = Object::new(); + let webapp = Object::new(); + let storage = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + let _ = Reflect::set(&webapp, &"secureStorage".into(), &storage); + storage + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn set_calls_js() { + let storage = setup_secure_storage(); + let func = Function::new_with_args("k,v", "this[k] = v; return Promise.resolve();"); + let _ = Reflect::set(&storage, &"set".into(), &func); + assert!(set("a", "b").await.is_ok()); + let val = Reflect::get(&storage, &"a".into()).unwrap(); + assert_eq!(val.as_string().as_deref(), Some("b")); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn set_err() { + assert!(set("a", "b").await.is_err()); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn get_calls_js() { + let storage = setup_secure_storage(); + let func = Function::new_with_args("k", "return this[k];"); + let _ = Reflect::set(&storage, &"get".into(), &func); + let _ = Reflect::set(&storage, &"a".into(), &JsValue::from_str("b")); + let value = get("a").await.unwrap(); + assert_eq!(value.as_deref(), Some("b")); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn get_err() { + assert!(get("a").await.is_err()); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn restore_calls_js() { + let storage = setup_secure_storage(); + let func = Function::new_with_args("k", "return this[k];"); + let _ = Reflect::set(&storage, &"restore".into(), &func); + let _ = Reflect::set(&storage, &"a".into(), &JsValue::from_str("b")); + let value = restore("a").await.unwrap(); + assert_eq!(value.as_deref(), Some("b")); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn restore_err() { + assert!(restore("a").await.is_err()); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn remove_calls_js() { + let storage = setup_secure_storage(); + let func = Function::new_with_args("k", "delete this[k]; return Promise.resolve();"); + let _ = Reflect::set(&storage, &"remove".into(), &func); + let _ = Reflect::set(&storage, &"a".into(), &JsValue::from_str("b")); + assert!(remove("a").await.is_ok()); + let has = Reflect::has(&storage, &"a".into()).unwrap(); + assert!(!has); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn remove_err() { + assert!(remove("a").await.is_err()); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn clear_calls_js() { + let storage = setup_secure_storage(); + let func = Function::new_no_args( + "Object.keys(this).forEach(k => delete this[k]); return Promise.resolve();" + ); + let _ = Reflect::set(&storage, &"clear".into(), &func); + let _ = Reflect::set(&storage, &"a".into(), &JsValue::from_str("b")); + assert!(clear().await.is_ok()); + let has = Reflect::has(&storage, &"a".into()).unwrap(); + assert!(!has); + } + + #[wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn clear_err() { + assert!(clear().await.is_err()); + } +} diff --git a/telegram-webapp-sdk/src/api/settings_button.rs b/telegram-webapp-sdk/src/api/settings_button.rs new file mode 100644 index 0000000..190dcbf --- /dev/null +++ b/telegram-webapp-sdk/src/api/settings_button.rs @@ -0,0 +1,180 @@ +use js_sys::{Function, Reflect}; +use wasm_bindgen::{JsCast, prelude::*}; +use web_sys::window; + +/// Show the Telegram Settings Button. +/// +/// # Errors +/// Returns `Err` if the underlying JavaScript call fails or the button is +/// missing. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::settings_button::show; +/// # fn run() -> Result<(), wasm_bindgen::JsValue> { +/// show()?; +/// # Ok(()) } +/// ``` +pub fn show() -> Result<(), JsValue> { + let button = settings_button_object()?; + let func = Reflect::get(&button, &"show".into())?.dyn_into::()?; + func.call0(&button)?; + Ok(()) +} + +/// Hide the Telegram Settings Button. +/// +/// # Errors +/// Returns `Err` if the underlying JavaScript call fails or the button is +/// missing. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::settings_button::hide; +/// # fn run() -> Result<(), wasm_bindgen::JsValue> { +/// hide()?; +/// # Ok(()) } +/// ``` +pub fn hide() -> Result<(), JsValue> { + let button = settings_button_object()?; + let func = Reflect::get(&button, &"hide".into())?.dyn_into::()?; + func.call0(&button)?; + Ok(()) +} + +/// Register a callback for Settings Button clicks. +/// +/// # Safety +/// The closure must be kept alive for as long as it's registered. +/// +/// # Errors +/// Returns `Err` if the registration fails or the button is missing. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::settings_button::on_click; +/// use wasm_bindgen::prelude::Closure; +/// # fn run() -> Result<(), wasm_bindgen::JsValue> { +/// let cb = Closure::wrap(Box::new(|| {}) as Box); +/// on_click(&cb)?; +/// # Ok(()) } +/// ``` +pub fn on_click(callback: &Closure) -> Result<(), JsValue> { + let button = settings_button_object()?; + let func = Reflect::get(&button, &"onClick".into())?.dyn_into::()?; + func.call1(&button, callback.as_ref())?; + Ok(()) +} + +/// Remove a previously registered click callback. +/// +/// # Errors +/// Returns `Err` if the deregistration fails or the button is missing. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::settings_button::{off_click, on_click}; +/// use wasm_bindgen::prelude::Closure; +/// # fn run() -> Result<(), wasm_bindgen::JsValue> { +/// let cb = Closure::wrap(Box::new(|| {}) as Box); +/// on_click(&cb)?; +/// off_click(&cb)?; +/// # Ok(()) } +/// ``` +pub fn off_click(callback: &Closure) -> Result<(), JsValue> { + let button = settings_button_object()?; + let func = Reflect::get(&button, &"offClick".into())?.dyn_into::()?; + func.call1(&button, callback.as_ref())?; + Ok(()) +} + +fn settings_button_object() -> Result { + let win = window().ok_or_else(|| JsValue::from_str("no window"))?; + let tg = Reflect::get(&win, &"Telegram".into())?; + let webapp = Reflect::get(&tg, &"WebApp".into())?; + Reflect::get(&webapp, &"SettingsButton".into()) +} + +#[cfg(test)] +mod tests { + use js_sys::{Function, Object, Reflect}; + use wasm_bindgen::closure::Closure; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn setup_button() -> Object { + let win = window().unwrap(); + let telegram = Object::new(); + let webapp = Object::new(); + let button = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + let _ = Reflect::set(&webapp, &"SettingsButton".into(), &button); + button + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn show_calls_js() { + let button = setup_button(); + let func = Function::new_no_args("this.called = true;"); + let _ = Reflect::set(&button, &"show".into(), &func); + assert!(show().is_ok()); + assert!( + Reflect::get(&button, &"called".into()) + .unwrap() + .as_bool() + .unwrap() + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn hide_calls_js() { + let button = setup_button(); + let func = Function::new_no_args("this.called = true;"); + let _ = Reflect::set(&button, &"hide".into(), &func); + assert!(hide().is_ok()); + assert!( + Reflect::get(&button, &"called".into()) + .unwrap() + .as_bool() + .unwrap() + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn click_callbacks() { + let button = setup_button(); + let on = Function::new_with_args("cb", "this.cb = cb;"); + let off = Function::new_with_args("cb", "delete this.cb;"); + let _ = Reflect::set(&button, &"onClick".into(), &on); + let _ = Reflect::set(&button, &"offClick".into(), &off); + let cb = Closure::wrap(Box::new(|| {}) as Box); + on_click(&cb).expect("on"); + assert!(Reflect::has(&button, &"cb".into()).unwrap()); + off_click(&cb).expect("off"); + assert!(!Reflect::has(&button, &"cb".into()).unwrap()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn show_err() { + let _ = setup_button(); + assert!(show().is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn on_click_err() { + let _ = setup_button(); + let cb = Closure::wrap(Box::new(|| {}) as Box); + assert!(on_click(&cb).is_err()); + } +} diff --git a/telegram-webapp-sdk/src/api/theme.rs b/telegram-webapp-sdk/src/api/theme.rs new file mode 100644 index 0000000..655ed92 --- /dev/null +++ b/telegram-webapp-sdk/src/api/theme.rs @@ -0,0 +1,63 @@ +use js_sys::Reflect; +use serde_wasm_bindgen::from_value; +use wasm_bindgen::prelude::*; +use web_sys::window; + +use crate::core::types::theme_params::TelegramThemeParams; + +/// Returns the current themeParams from `Telegram.WebApp.themeParams`. +/// +/// # Errors +/// Returns `Err(JsValue)` if the object is missing or cannot be parsed. +pub fn get_theme_params() -> Result { + let window = window().ok_or_else(|| JsValue::from_str("no window"))?; + let telegram = Reflect::get(&window, &JsValue::from_str("Telegram"))?; + let webapp = Reflect::get(&telegram, &JsValue::from_str("WebApp"))?; + let theme_params = Reflect::get(&webapp, &JsValue::from_str("themeParams"))?; + from_value(theme_params) + .map_err(|e| JsValue::from_str(&format!("themeParams parse error: {e}"))) +} + +#[cfg(test)] +mod tests { + use js_sys::{Object, Reflect}; + use wasm_bindgen::JsValue; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn setup_webapp() -> Object { + let win = window().expect("window should be available"); + let telegram = Object::new(); + let webapp = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + webapp + } + + #[wasm_bindgen_test] + #[allow(dead_code)] + fn parses_valid_theme() { + let webapp = setup_webapp(); + let theme = Object::new(); + let _ = Reflect::set(&theme, &"bg_color".into(), &JsValue::from_str("#ffffff")); + let _ = Reflect::set(&theme, &"text_color".into(), &JsValue::from_str("#000000")); + let _ = Reflect::set(&webapp, &"themeParams".into(), &theme); + + let params = get_theme_params().expect("theme params"); + assert_eq!(params.bg_color.as_deref(), Some("#ffffff")); + assert_eq!(params.text_color.as_deref(), Some("#000000")); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] + fn fails_on_invalid_data() { + let webapp = setup_webapp(); + let _ = Reflect::set(&webapp, &"themeParams".into(), &JsValue::from_f64(5.0)); + assert!(get_theme_params().is_err()); + } +} diff --git a/telegram-webapp-sdk/src/api/user.rs b/telegram-webapp-sdk/src/api/user.rs new file mode 100644 index 0000000..64a5739 --- /dev/null +++ b/telegram-webapp-sdk/src/api/user.rs @@ -0,0 +1,161 @@ +use js_sys::{Function, Reflect}; +use wasm_bindgen::{JsCast, prelude::*}; +use web_sys::window; + +/// Calls `Telegram.WebApp.requestContact()`. +/// +/// Requires the user's explicit permission to share their contact information. +/// +/// # Errors +/// Returns `Err(JsValue)` if `Telegram.WebApp` or the method is unavailable, or +/// if the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::user::request_contact; +/// +/// let _ = request_contact(); +/// ``` +pub fn request_contact() -> Result<(), JsValue> { + let webapp = webapp_object()?; + let func = + Reflect::get(&webapp, &JsValue::from_str("requestContact"))?.dyn_into::()?; + func.call0(&webapp)?; + Ok(()) +} + +/// Calls `Telegram.WebApp.requestPhoneNumber()`. +/// +/// Requires the user's explicit permission to share their phone number. +/// +/// # Errors +/// Returns `Err(JsValue)` if `Telegram.WebApp` or the method is unavailable, or +/// if the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::user::request_phone_number; +/// +/// let _ = request_phone_number(); +/// ``` +pub fn request_phone_number() -> Result<(), JsValue> { + let webapp = webapp_object()?; + let func = + Reflect::get(&webapp, &JsValue::from_str("requestPhoneNumber"))?.dyn_into::()?; + func.call0(&webapp)?; + Ok(()) +} + +/// Calls `Telegram.WebApp.openContact()`. +/// +/// Requires the user's permission to open the contact interface in Telegram. +/// +/// # Errors +/// Returns `Err(JsValue)` if `Telegram.WebApp` or the method is unavailable, or +/// if the call fails. +/// +/// # Examples +/// ```no_run +/// use telegram_webapp_sdk::api::user::open_contact; +/// +/// let _ = open_contact(); +/// ``` +pub fn open_contact() -> Result<(), JsValue> { + let webapp = webapp_object()?; + let func = Reflect::get(&webapp, &JsValue::from_str("openContact"))?.dyn_into::()?; + func.call0(&webapp)?; + Ok(()) +} + +fn webapp_object() -> Result { + let win = window().ok_or_else(|| JsValue::from_str("no window"))?; + let tg = Reflect::get(&win, &JsValue::from_str("Telegram"))?; + Reflect::get(&tg, &JsValue::from_str("WebApp")) +} + +#[cfg(test)] +mod tests { + use js_sys::{Function, Object, Reflect}; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn setup_webapp() -> Object { + let win = window().unwrap(); + let telegram = Object::new(); + let webapp = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + webapp + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn request_contact_ok() { + let webapp = setup_webapp(); + let func = Function::new_no_args("this.called = true;"); + let _ = Reflect::set(&webapp, &"requestContact".into(), &func); + assert!(request_contact().is_ok()); + assert!( + Reflect::get(&webapp, &"called".into()) + .unwrap() + .as_bool() + .unwrap() + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn request_contact_err() { + let _ = setup_webapp(); + assert!(request_contact().is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn request_phone_number_ok() { + let webapp = setup_webapp(); + let func = Function::new_no_args("this.called = true;"); + let _ = Reflect::set(&webapp, &"requestPhoneNumber".into(), &func); + assert!(request_phone_number().is_ok()); + assert!( + Reflect::get(&webapp, &"called".into()) + .unwrap() + .as_bool() + .unwrap() + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn request_phone_number_err() { + let _ = setup_webapp(); + assert!(request_phone_number().is_err()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn open_contact_ok() { + let webapp = setup_webapp(); + let func = Function::new_no_args("this.called = true;"); + let _ = Reflect::set(&webapp, &"openContact".into(), &func); + assert!(open_contact().is_ok()); + assert!( + Reflect::get(&webapp, &"called".into()) + .unwrap() + .as_bool() + .unwrap() + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn open_contact_err() { + let _ = setup_webapp(); + assert!(open_contact().is_err()); + } +} diff --git a/telegram-webapp-sdk/src/api/viewport.rs b/telegram-webapp-sdk/src/api/viewport.rs new file mode 100644 index 0000000..fdafbef --- /dev/null +++ b/telegram-webapp-sdk/src/api/viewport.rs @@ -0,0 +1,167 @@ +use js_sys::{Function, Reflect}; +use wasm_bindgen::{JsCast, prelude::*}; +use web_sys::window; + +use crate::logger::{debug, warn}; + +/// Returns the current viewport height in pixels. +pub fn get_viewport_height() -> Option { + let webapp = webapp_object().ok()?; + let value = Reflect::get(&webapp, &"viewportHeight".into()).ok()?; + let result = value.as_f64(); + if let Some(px) = result { + debug(&format!("viewportHeight: {}px", px)); + } else { + warn("viewportHeight is not a number"); + } + result +} + +/// Returns the current viewport width in pixels. +/// +/// # Examples +/// ```no_run +/// # use telegram_webapp_sdk::api::viewport::get_viewport_width; +/// let _ = get_viewport_width(); +/// ``` +pub fn get_viewport_width() -> Option { + let webapp = webapp_object().ok()?; + let value = Reflect::get(&webapp, &"viewportWidth".into()).ok()?; + let result = value.as_f64(); + if let Some(px) = result { + debug(&format!("viewportWidth: {}px", px)); + } else { + warn("viewportWidth is not a number"); + } + result +} + +/// Returns the stable viewport height in pixels. +/// +/// # Examples +/// ```no_run +/// # use telegram_webapp_sdk::api::viewport::get_viewport_stable_height; +/// let _ = get_viewport_stable_height(); +/// ``` +pub fn get_viewport_stable_height() -> Option { + let webapp = webapp_object().ok()?; + let value = Reflect::get(&webapp, &"viewportStableHeight".into()).ok()?; + let result = value.as_f64(); + if let Some(px) = result { + debug(&format!("viewportStableHeight: {}px", px)); + } else { + warn("viewportStableHeight is not a number"); + } + result +} + +/// Returns whether the Mini App is currently expanded. +pub fn get_is_expanded() -> Option { + let webapp = webapp_object().ok()?; + let value = Reflect::get(&webapp, &"isExpanded".into()).ok()?; + let result = value.as_bool(); + if let Some(exp) = result { + debug(&format!("isExpanded: {}", exp)); + } else { + warn("isExpanded is not a boolean"); + } + result +} + +/// Calls `Telegram.WebApp.expand()` to expand the viewport. +/// +/// # Errors +/// Returns [`JsValue`] if the underlying JS call fails. +/// +/// # Examples +/// ```no_run +/// # use telegram_webapp_sdk::api::viewport::expand_viewport; +/// let _ = expand_viewport(); +/// ``` +pub fn expand_viewport() -> Result<(), JsValue> { + let webapp = webapp_object()?; + let func = Reflect::get(&webapp, &"expand".into())?.dyn_into::()?; + func.call0(&webapp)?; + debug("Called WebApp.expand()"); + Ok(()) +} + +/// Registers a callback to be called on `viewportChanged` event. +/// +/// ⚠️ Closure must be kept alive outside. +pub fn on_viewport_changed(callback: &Closure) { + if let Ok(webapp) = webapp_object() { + let _ = Reflect::get(&webapp, &"onEvent".into()) + .ok() + .and_then(|f| f.dyn_ref::().cloned()) + .and_then(|f| { + f.call2(&webapp, &"viewportChanged".into(), callback.as_ref()) + .ok() + }); + debug("Registered viewportChanged event handler"); + } else { + warn("Cannot register viewportChanged: WebApp not found"); + } +} + +/// Internal helper to get `Telegram.WebApp` JS object. +fn webapp_object() -> Result { + let win = window().ok_or_else(|| JsValue::from_str("no window"))?; + let tg = Reflect::get(&win, &"Telegram".into())?; + Reflect::get(&tg, &"WebApp".into()) +} + +#[cfg(test)] +mod tests { + use js_sys::{Function, Object, Reflect}; + use wasm_bindgen::JsValue; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn setup_webapp() -> Object { + let win = window().unwrap(); + let telegram = Object::new(); + let webapp = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + webapp + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn width_and_stable_height() { + let webapp = setup_webapp(); + let _ = Reflect::set(&webapp, &"viewportWidth".into(), &JsValue::from_f64(200.0)); + let _ = Reflect::set( + &webapp, + &"viewportStableHeight".into(), + &JsValue::from_f64(500.0) + ); + assert_eq!(get_viewport_width(), Some(200.0)); + assert_eq!(get_viewport_stable_height(), Some(500.0)); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] + fn expand_viewport_success() { + let webapp = setup_webapp(); + let func = Function::new_no_args("this._expanded = true;"); + let _ = Reflect::set(&webapp, &"expand".into(), &func); + assert!(expand_viewport().is_ok()); + let called = Reflect::get(&webapp, &"_expanded".into()).unwrap(); + assert_eq!(called.as_bool(), Some(true)); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] + fn expand_viewport_failure() { + let webapp = setup_webapp(); + let _ = Reflect::set(&webapp, &"expand".into(), &JsValue::from_f64(1.0)); + assert!(expand_viewport().is_err()); + } +} diff --git a/telegram-webapp-sdk/src/core.rs b/telegram-webapp-sdk/src/core.rs new file mode 100644 index 0000000..a24e195 --- /dev/null +++ b/telegram-webapp-sdk/src/core.rs @@ -0,0 +1,5 @@ +pub mod context; +pub mod init; +pub mod interop; +pub mod safe_context; +pub mod types; diff --git a/telegram-webapp-sdk/src/core/context.rs b/telegram-webapp-sdk/src/core/context.rs new file mode 100644 index 0000000..ed95159 --- /dev/null +++ b/telegram-webapp-sdk/src/core/context.rs @@ -0,0 +1,100 @@ +use once_cell::unsync::OnceCell; +use wasm_bindgen::JsValue; + +use super::types::{ + init_data::TelegramInitData, launch_params::LaunchParams, theme_params::TelegramThemeParams +}; + +/// Global context of the Telegram Mini App, initialized once per app session. +#[derive(Clone)] +pub struct TelegramContext { + pub init_data: TelegramInitData, + pub theme_params: TelegramThemeParams +} + +thread_local! { + /// Thread-local global TelegramContext instance. + static CONTEXT: OnceCell = const { OnceCell::new() }; +} + +impl TelegramContext { + /// Initializes the global Telegram context. + /// + /// # Errors + /// Returns an error if the context was already initialized. + pub fn init( + init_data: TelegramInitData, + theme_params: TelegramThemeParams + ) -> Result<(), &'static str> { + CONTEXT.with(|cell| { + cell.set(TelegramContext { + init_data, + theme_params + }) + .map_err(|_| "TelegramContext already initialized") + }) + } + + /// Access the global context if it has been initialized. + /// + /// Accepts a closure and returns the result of applying it to the context. + pub fn get(f: F) -> Option + where + F: FnOnce(&TelegramContext) -> R + { + CONTEXT.with(|cell| cell.get().map(f)) + } +} + +/// Returns launch parameters parsed from the current window location. +/// +/// # Errors +/// Returns a [`JsValue`] if the global window object is unavailable. +/// +/// # Examples +/// ```no_run +/// # use telegram_webapp_sdk::core::context::get_launch_params; +/// let _ = get_launch_params(); +/// ``` +pub fn get_launch_params() -> Result { + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; + let location = window.location(); + + Ok(LaunchParams { + tg_web_app_platform: location.origin().ok().or_else(|| Some("web".into())), + tg_web_app_version: get_param("tgWebAppVersion"), + tg_web_app_start_param: get_param("tgWebAppStartParam"), + tg_web_app_show_settings: get_param("tgWebAppShowSettings").map(|s| s == "1"), + tg_web_app_bot_inline: get_param("tgWebAppBotInline").map(|s| s == "1") + }) +} + +fn get_param(key: &str) -> Option { + web_sys::window()? + .document()? + .location()? + .search() + .ok()? + .split('&') + .find_map(|pair| { + let mut parts = pair.split('='); + let k = parts.next()?; + let v = parts.next()?; + if k == key { Some(v.to_string()) } else { None } + }) +} + +#[cfg(test)] +mod tests { + use wasm_bindgen::JsValue; + use wasm_bindgen_test::wasm_bindgen_test; + + use super::*; + + #[allow(dead_code)] + #[wasm_bindgen_test] + fn get_launch_params_returns_error_without_window() { + let err = get_launch_params().unwrap_err(); + assert_eq!(err, JsValue::from_str("no window")); + } +} diff --git a/telegram-webapp-sdk/src/core/init.rs b/telegram-webapp-sdk/src/core/init.rs new file mode 100644 index 0000000..a3719f7 --- /dev/null +++ b/telegram-webapp-sdk/src/core/init.rs @@ -0,0 +1,84 @@ +use js_sys::Reflect; +use serde_wasm_bindgen::from_value; +use wasm_bindgen::JsValue; +use web_sys::window; + +use crate::core::{ + context::TelegramContext, + types::{ + chat::TelegramChat, init_data::TelegramInitData, + init_data_internal::TelegramInitDataInternal, theme_params::TelegramThemeParams, + user::TelegramUser + } +}; + +/// Initializes Telegram WebApp SDK by extracting and validating context. +/// +/// - Parses `initData` (urlencoded) with embedded JSON. +/// - Parses `themeParams` (object). +/// - Initializes global context. +/// +/// # Errors +/// Returns `Err(JsValue)` on failure to access JS globals, parse, or init +/// context. +pub fn init_sdk() -> Result<(), JsValue> { + let win = window().ok_or_else(|| JsValue::from_str("window is not available"))?; + let telegram = Reflect::get(&win, &"Telegram".into())?; + let webapp = Reflect::get(&telegram, &"WebApp".into())?; + + // === 1. Parse initData string === + let init_data_str = Reflect::get(&webapp, &"initData".into())? + .as_string() + .ok_or_else(|| JsValue::from_str("Telegram.WebApp.initData is not a string"))?; + + let raw: TelegramInitDataInternal = serde_urlencoded::from_str(&init_data_str) + .map_err(|e| JsValue::from_str(&format!("Failed to parse initData: {e}")))?; + + // === 2. Parse embedded JSON fields === + let user: Option = raw + .user + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|e| JsValue::from_str(&format!("Failed to parse user: {e}")))?; + + let receiver: Option = raw + .receiver + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|e| JsValue::from_str(&format!("Failed to parse receiver: {e}")))?; + + let chat: Option = raw + .chat + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|e| JsValue::from_str(&format!("Failed to parse chat: {e}")))?; + + // === 3. Construct final typed initData === + let init_data = TelegramInitData { + query_id: None, // not available in urlencoded format + user, + receiver, + chat, + chat_type: raw.chat_type, + chat_instance: raw.chat_instance, + start_param: raw.start_param, + can_send_after: raw.can_send_after, + auth_date: raw.auth_date, + hash: raw.hash, + signature: raw.signature + }; + + // === 4. Parse themeParams === + let theme_val = Reflect::get(&webapp, &"themeParams".into())?; + let theme_params: TelegramThemeParams = from_value(theme_val)?; + + // theme_params.clone().apply_to_root(); + + // === 5. Init global context === + TelegramContext::init(init_data, theme_params)?; + + Ok(()) +} diff --git a/telegram-webapp-sdk/src/core/interop.rs b/telegram-webapp-sdk/src/core/interop.rs new file mode 100644 index 0000000..a8118c5 --- /dev/null +++ b/telegram-webapp-sdk/src/core/interop.rs @@ -0,0 +1 @@ +pub mod verify; diff --git a/telegram-webapp-sdk/src/core/interop/verify.rs b/telegram-webapp-sdk/src/core/interop/verify.rs new file mode 100644 index 0000000..1c94e66 --- /dev/null +++ b/telegram-webapp-sdk/src/core/interop/verify.rs @@ -0,0 +1,82 @@ +use hex::encode; +use hmac_sha256::{HMAC, Hash}; +use percent_encoding::percent_decode_str; + +/// Verifies the `hash` of Telegram init data using the secret key derived from +/// the bot token. +/// +/// # Arguments +/// - `init_data`: raw query string from `Telegram.WebApp.initData`, e.g. +/// `"user=%7B%22id%22%3A12345...%7D&auth_date=...&hash=..."` +/// - `bot_token`: your bot token, e.g. +/// `"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"` +/// +/// # Returns +/// - `true` if the hash is valid and matches Telegram's computation rules +/// - `false` otherwise +pub fn verify_init_data_hash(init_data: &str, bot_token: &str) -> bool { + let parsed = match parse_init_data(init_data) { + Some(pairs) => pairs, + None => return false + }; + + let mut actual_hash: Option = None; + let mut data: Vec<(String, String)> = Vec::new(); + + for (k, v) in parsed { + if k == "hash" { + actual_hash = Some(v); + } else { + data.push((k, v)); + } + } + + let actual_hash = match actual_hash { + Some(h) => h, + None => return false + }; + + data.sort_by(|a, b| a.0.cmp(&b.0)); + + let check_string = data + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("\n"); + + let secret_key = Hash::hash(format!("WebAppData{}", bot_token).as_bytes()); + let expected_hash = HMAC::mac(check_string.as_bytes(), secret_key); + let expected_hex = encode(expected_hash); + + expected_hex == actual_hash +} + +/// Parses the raw `init_data` query string into key-value pairs. +/// +/// # Returns +/// - `Some(Vec<(key, value)>)` if successfully parsed +/// - `None` on any decoding or structural error +fn parse_init_data(init_data: &str) -> Option> { + let mut result = Vec::new(); + + for pair in init_data.split('&') { + let mut parts = pair.splitn(2, '='); + let key = parts.next()?.to_string(); + let val = parts.next()?; + let decoded_val = percent_decode_str(val).decode_utf8().ok()?.to_string(); + result.push((key, decoded_val)); + } + + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_format_fails() { + let input = "this_is_not_query_string"; + assert!(!verify_init_data_hash(input, "123:token")); + } +} diff --git a/telegram-webapp-sdk/src/core/safe_context.rs b/telegram-webapp-sdk/src/core/safe_context.rs new file mode 100644 index 0000000..0c96b3d --- /dev/null +++ b/telegram-webapp-sdk/src/core/safe_context.rs @@ -0,0 +1,7 @@ +use wasm_bindgen::JsValue; + +use crate::core::context::TelegramContext; + +pub fn get_context(f: impl FnOnce(&TelegramContext) -> T) -> Result { + TelegramContext::get(f).ok_or_else(|| JsValue::from_str("TelegramContext is not initialized")) +} diff --git a/telegram-webapp-sdk/src/core/types.rs b/telegram-webapp-sdk/src/core/types.rs new file mode 100644 index 0000000..a2069cf --- /dev/null +++ b/telegram-webapp-sdk/src/core/types.rs @@ -0,0 +1,12 @@ +pub mod chat; +pub mod download_file_params; +pub mod init_data; +pub mod init_data_internal; +pub mod launch_params; +pub mod sent_web_app_message; +pub mod theme_params; +pub mod user; +pub mod web_app_data; +pub mod web_app_info; +pub mod webhook_info; +pub mod write_access_allowed; diff --git a/telegram-webapp-sdk/src/core/types/chat.rs b/telegram-webapp-sdk/src/core/types/chat.rs new file mode 100644 index 0000000..5ea8428 --- /dev/null +++ b/telegram-webapp-sdk/src/core/types/chat.rs @@ -0,0 +1,21 @@ +use serde::Deserialize; + +/// Represents a chat context (group, supergroup, or channel). +#[derive(Clone, Debug, Deserialize)] +pub struct TelegramChat { + /// Unique identifier of the chat. + pub id: u64, + + /// Chat type. One of: "group", "supergroup", or "channel". + #[serde(rename = "type")] + pub kind: String, + + /// Title of the chat. + pub title: String, + + /// Public username of the chat (if available). + pub username: Option, + + /// Chat photo URL (JPEG or SVG), if available. + pub photo_url: Option +} diff --git a/telegram-webapp-sdk/src/core/types/download_file_params.rs b/telegram-webapp-sdk/src/core/types/download_file_params.rs new file mode 100644 index 0000000..e9da1de --- /dev/null +++ b/telegram-webapp-sdk/src/core/types/download_file_params.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +/// Parameters for +/// [`TelegramWebApp::download_file`](crate::webapp::TelegramWebApp::download_file). +/// +/// +/// This structure mirrors the object expected by the `downloadFile` method in +/// the Telegram Web App JavaScript API. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DownloadFileParams<'a> { + /// Remote URL of the file to download. + pub url: &'a str, + + /// Optional name for the downloaded file. + #[serde(skip_serializing_if = "Option::is_none")] + pub file_name: Option<&'a str>, + + /// Optional MIME type of the file. + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option<&'a str> +} + +#[cfg(test)] +mod tests { + use serde_json::{from_str, to_string}; + + use super::*; + + #[test] + fn serialize_download_file_params() { + let params = DownloadFileParams { + url: "https://example.com/data.bin", + file_name: Some("data.bin"), + mime_type: Some("application/octet-stream") + }; + let json = to_string(¶ms).expect("serialize"); + let parsed: DownloadFileParams = from_str(&json).expect("deserialize"); + assert_eq!(parsed.url, params.url); + assert_eq!(parsed.file_name, params.file_name); + assert_eq!(parsed.mime_type, params.mime_type); + } +} diff --git a/telegram-webapp-sdk/src/core/types/init_data.rs b/telegram-webapp-sdk/src/core/types/init_data.rs new file mode 100644 index 0000000..98d7a6e --- /dev/null +++ b/telegram-webapp-sdk/src/core/types/init_data.rs @@ -0,0 +1,44 @@ +use serde::Deserialize; + +use super::{chat::TelegramChat, user::TelegramUser}; + +/// Represents the complete initialization data passed to the Mini App. +/// WARNING: Always validate this data on the server using the `hash` or +/// `signature`. +#[derive(Clone, Debug, Deserialize)] +pub struct TelegramInitData { + /// Unique identifier for the current Mini App session. + pub query_id: Option, + + /// Information about the current Telegram user. + pub user: Option, + + /// Information about the chat partner in private attachment menu context. + pub receiver: Option, + + /// Information about the current chat (group, supergroup, or channel). + pub chat: Option, + + /// Type of chat: one of "private", "group", "supergroup", "channel", or + /// "sender". + pub chat_type: Option, + + /// Globally unique chat instance identifier. + pub chat_instance: Option, + + /// Value of the `start_param` or `startattach` passed in the launch URL. + pub start_param: Option, + + /// Time (in seconds) after which the Mini App may send a message via + /// `answerWebAppQuery`. + pub can_send_after: Option, + + /// Unix timestamp of when the init data was generated. + pub auth_date: u64, + + /// HMAC-SHA256 hash used to verify data integrity on the server. + pub hash: String, + + /// Ed25519 signature used for third-party data validation (optional). + pub signature: Option +} diff --git a/telegram-webapp-sdk/src/core/types/init_data_internal.rs b/telegram-webapp-sdk/src/core/types/init_data_internal.rs new file mode 100644 index 0000000..519765f --- /dev/null +++ b/telegram-webapp-sdk/src/core/types/init_data_internal.rs @@ -0,0 +1,15 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct TelegramInitDataInternal { + pub user: Option, + pub receiver: Option, + pub chat: Option, + pub chat_type: Option, + pub chat_instance: Option, + pub start_param: Option, + pub can_send_after: Option, + pub auth_date: u64, + pub hash: String, + pub signature: Option +} diff --git a/telegram-webapp-sdk/src/core/types/launch_params.rs b/telegram-webapp-sdk/src/core/types/launch_params.rs new file mode 100644 index 0000000..4b45f4e --- /dev/null +++ b/telegram-webapp-sdk/src/core/types/launch_params.rs @@ -0,0 +1,8 @@ +#[derive(Debug, Clone)] +pub struct LaunchParams { + pub tg_web_app_platform: Option, + pub tg_web_app_version: Option, + pub tg_web_app_start_param: Option, + pub tg_web_app_show_settings: Option, + pub tg_web_app_bot_inline: Option +} diff --git a/telegram-webapp-sdk/src/core/types/sent_web_app_message.rs b/telegram-webapp-sdk/src/core/types/sent_web_app_message.rs new file mode 100644 index 0000000..7a95370 --- /dev/null +++ b/telegram-webapp-sdk/src/core/types/sent_web_app_message.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +/// Result of sending a message via the Telegram Web App. +/// +/// # Examples +/// +/// ```rust +/// use telegram_webapp_sdk::core::types::sent_web_app_message::SentWebAppMessage; +/// +/// let msg = SentWebAppMessage { +/// inline_message_id: None +/// }; +/// assert!(msg.inline_message_id.is_none()); +/// ``` +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SentWebAppMessage { + /// Identifier of the sent inline message. + pub inline_message_id: Option +} + +#[cfg(test)] +mod tests { + use serde_json::{from_str, to_string}; + + use super::*; + + #[test] + fn serialize_sent_web_app_message() { + let msg = SentWebAppMessage { + inline_message_id: Some("id".to_owned()) + }; + let json = to_string(&msg).unwrap(); + assert!(json.contains("id")); + let parsed: SentWebAppMessage = from_str(&json).unwrap(); + assert_eq!(parsed.inline_message_id, msg.inline_message_id); + } + + #[test] + fn deserialize_sent_web_app_message_none() { + let json = "{}"; + let parsed: SentWebAppMessage = from_str(json).unwrap(); + assert!(parsed.inline_message_id.is_none()); + } +} diff --git a/telegram-webapp-sdk/src/core/types/theme_params.rs b/telegram-webapp-sdk/src/core/types/theme_params.rs new file mode 100644 index 0000000..c1325e5 --- /dev/null +++ b/telegram-webapp-sdk/src/core/types/theme_params.rs @@ -0,0 +1,185 @@ +use std::collections::HashMap; + +use serde::Deserialize; +use wasm_bindgen::prelude::*; +use web_sys::{CssStyleDeclaration, HtmlElement}; + +use crate::logger::warn; + +/// Represents all theme parameters provided by the Telegram WebApp API. +/// +/// Each field corresponds to a CSS color value in `#RRGGBB` format. +/// When deserialized from `Telegram.WebApp.themeParams`, only the colors +/// actually present in the user’s current Telegram theme will be `Some`. +/// +/// # Example +/// +/// ```ignore +/// use serde_wasm_bindgen::from_value; +/// # use wasm_bindgen::JsValue; +/// # let js_value = /* obtain JS value from Telegram.WebApp.themeParams */ +/// let theme: TelegramThemeParams = from_value(js_value)?; +/// theme.apply_to_root()?; +/// # Ok::<(), JsValue>(()) +/// ``` +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct TelegramThemeParams { + /// Primary background color (`--tg-theme-bg-color`). + pub bg_color: Option, + + /// Primary text color (`--tg-theme-text-color`). + pub text_color: Option, + + /// Hint text color (`--tg-theme-hint-color`). + pub hint_color: Option, + + /// Link color (`--tg-theme-link-color`). + pub link_color: Option, + + /// Button background color (`--tg-theme-button-color`). + pub button_color: Option, + + /// Button text color (`--tg-theme-button-text-color`). + pub button_text_color: Option, + + /// Secondary background color (`--tg-theme-secondary-bg-color`). + pub secondary_bg_color: Option, + + /// Header background color (`--tg-theme-header-bg-color`). + pub header_bg_color: Option, + + /// Bottom bar background color (`--tg-theme-bottom-bar-bg-color`). + pub bottom_bar_bg_color: Option, + + /// Accent text color (`--tg-theme-accent-text-color`). + pub accent_text_color: Option, + + /// Section background color (`--tg-theme-section-bg-color`). + pub section_bg_color: Option, + + /// Section header text color (`--tg-theme-section-header-text-color`). + pub section_header_text_color: Option, + + /// Section separator color (`--tg-theme-section-separator-color`). + pub section_separator_color: Option, + + /// Subtitle text color (`--tg-theme-subtitle-text-color`). + pub subtitle_text_color: Option, + + /// Destructive action text color, e.g. “Delete” + /// (`--tg-theme-destructive-text-color`). + pub destructive_text_color: Option +} + +impl TelegramThemeParams { + /// Converts all `Some` theme parameters into a map of CSS custom + /// properties. + /// + /// # Returns + /// + /// A `HashMap` where each key is a CSS variable name like + /// `"--tg-theme-bg-color"`, and the corresponding value is the `#RRGGBB` + /// color string. + /// + /// # Examples + /// + /// ``` + /// use telegram_webapp_sdk::core::types::theme_params::TelegramThemeParams; + /// let theme = TelegramThemeParams { + /// bg_color: Some("#ffffff".into()), + /// text_color: Some("#000000".into()), + /// ..Default::default() + /// }; + /// let vars = theme.into_css_vars(); + /// assert_eq!( + /// vars.get("--tg-theme-bg-color"), + /// Some(&"#ffffff".to_string()) + /// ); + /// assert_eq!( + /// vars.get("--tg-theme-text-color"), + /// Some(&"#000000".to_string()) + /// ); + /// ``` + pub fn into_css_vars(self) -> HashMap { + let mut vars: HashMap = HashMap::with_capacity(16); + let mut push = |key: &str, value: Option| { + if let Some(v) = value { + vars.insert(format!("--tg-theme-{}", key), v); + } + }; + + push("bg-color", self.bg_color); + push("text-color", self.text_color); + push("hint-color", self.hint_color); + push("link-color", self.link_color); + push("button-color", self.button_color); + push("button-text-color", self.button_text_color); + push("secondary-bg-color", self.secondary_bg_color); + push("header-bg-color", self.header_bg_color); + push("bottom-bar-bg-color", self.bottom_bar_bg_color); + push("accent-text-color", self.accent_text_color); + push("section-bg-color", self.section_bg_color); + push("section-header-text-color", self.section_header_text_color); + push("section-separator-color", self.section_separator_color); + push("subtitle-text-color", self.subtitle_text_color); + push("destructive-text-color", self.destructive_text_color); + + vars + } + + /// Applies all CSS custom properties to the document’s root element + /// (`:root`). + /// + /// This makes any CSS rules using `var(--tg-theme-…)` automatically adopt + /// the Telegram user’s current theme colors. + /// + /// # Errors + /// + /// Returns `Err(JsValue)` if the global `window` or `document` objects are + /// unavailable or if the document root element cannot be cast to an + /// `HtmlElement`. + pub fn apply_to_root(self) -> Result<(), JsValue> { + let document = web_sys::window() + .ok_or_else(|| JsValue::from_str("Global `window` object not available"))? + .document() + .ok_or_else(|| JsValue::from_str("Global `document` object not available"))?; + + // Cast the element to HtmlElement to call `.style()` + let html_el: HtmlElement = document + .document_element() + .ok_or_else(|| JsValue::from_str("Document root element missing"))? + .dyn_into::() + .map_err(|_| JsValue::from_str("Document root is not an HtmlElement"))?; + + let style: CssStyleDeclaration = html_el.style(); + for (key, val) in self.into_css_vars() { + style.set_property(&key, &val).unwrap_or_else(|err| { + // extract a string from the JsValue or fall back to Debug + let err_msg = err.as_string().unwrap_or_else(|| format!("{:?}", err)); + // log via your styled logger + warn(&format!( + "Failed to set CSS var {} = {}: {}", + key, val, err_msg + )); + }); + } + + Ok(()) + } + + /// Returns all non‐empty theme parameters as a vector of + /// `(css_variable_name, color_value)` pairs. + pub fn to_map(&self) -> Vec<(String, String)> { + self.clone() // clone our struct + .into_css_vars() // move into a HashMap + .into_iter() // turn that into an iterator of (String,String) + .collect() // collect into a Vec + } +} + +#[wasm_bindgen] +pub fn apply_default_theme() -> Result<(), JsValue> { + let theme: TelegramThemeParams = Default::default(); + theme.apply_to_root() +} diff --git a/telegram-webapp-sdk/src/core/types/user.rs b/telegram-webapp-sdk/src/core/types/user.rs new file mode 100644 index 0000000..cd672c0 --- /dev/null +++ b/telegram-webapp-sdk/src/core/types/user.rs @@ -0,0 +1,94 @@ +use serde::{Deserialize, Serialize}; + +/// Represents a Telegram user in the context of a Mini App. +/// +/// # Examples +/// +/// ```rust +/// use serde_json::{from_str, to_string}; +/// use telegram_webapp_sdk::core::types::user::TelegramUser; +/// +/// # fn main() -> Result<(), Box> { +/// let user = TelegramUser { +/// id: 1, +/// is_bot: Some(false), +/// first_name: "Alice".into(), +/// last_name: Some("Smith".into()), +/// username: Some("alice".into()), +/// language_code: Some("en".into()), +/// is_premium: Some(true), +/// added_to_attachment_menu: Some(false), +/// allows_write_to_pm: Some(true), +/// photo_url: Some("https://example.com/photo.jpg".into()) +/// }; +/// let json = to_string(&user)?; +/// let parsed: TelegramUser = from_str(&json)?; +/// assert_eq!(parsed.id, user.id); +/// # Ok(()) } +/// ``` +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TelegramUser { + /// Unique Telegram user or bot ID (64-bit unsigned integer). + pub id: u64, + + /// Whether the user is a bot (only present for `receiver` field). + pub is_bot: Option, + + /// User's first name. + pub first_name: String, + + /// User's last name (optional). + pub last_name: Option, + + /// Telegram username (optional). + pub username: Option, + + /// IETF language code (e.g., "en", "ru"). + pub language_code: Option, + + /// Whether the user is a Telegram Premium subscriber. + pub is_premium: Option, + + /// True if the user added the bot to their attachment menu. + pub added_to_attachment_menu: Option, + + /// True if the user allowed the bot to message them. + pub allows_write_to_pm: Option, + + /// Profile photo URL (JPEG or SVG), if available. + pub photo_url: Option +} + +#[cfg(test)] +mod tests { + use serde_json::{from_str, to_string}; + + use super::*; + + #[test] + fn serialize_user() { + let user = TelegramUser { + id: 42, + is_bot: Some(false), + first_name: "Bob".into(), + last_name: None, + username: Some("bob".into()), + language_code: Some("en".into()), + is_premium: Some(false), + added_to_attachment_menu: Some(false), + allows_write_to_pm: Some(true), + photo_url: Some("https://example.com/avatar.jpg".into()) + }; + let json = to_string(&user).unwrap(); + assert!(json.contains("Bob")); + let parsed: TelegramUser = from_str(&json).unwrap(); + assert_eq!(parsed.id, user.id); + } + + #[test] + fn deserialize_user_missing_required() { + let json = r#"{"first_name":"John"}"#; // missing `id` + let res: Result = from_str(json); + assert!(res.is_err()); + } +} diff --git a/telegram-webapp-sdk/src/core/types/web_app_data.rs b/telegram-webapp-sdk/src/core/types/web_app_data.rs new file mode 100644 index 0000000..f8efc11 --- /dev/null +++ b/telegram-webapp-sdk/src/core/types/web_app_data.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +/// Data sent to the bot when the user interacts with a Web App. +/// +/// # Examples +/// +/// ```rust +/// use telegram_webapp_sdk::core::types::web_app_data::WebAppData; +/// +/// let data = WebAppData { +/// data: "payload".to_owned(), +/// button_text: "Confirm".to_owned() +/// }; +/// assert_eq!(data.button_text, "Confirm"); +/// ``` +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WebAppData { + /// Data transferred from the Web App to the bot. + pub data: String, + /// Text of the button that opened the Web App. + pub button_text: String +} + +#[cfg(test)] +mod tests { + use serde_json::{from_str, from_value, json, to_string}; + + use super::*; + + #[test] + fn serialize_web_app_data() { + let data = WebAppData { + data: "test".to_owned(), + button_text: "Send".to_owned() + }; + let json = to_string(&data).unwrap(); + assert!(json.contains("test")); + let parsed: WebAppData = from_str(&json).unwrap(); + assert_eq!(parsed.button_text, data.button_text); + } + + #[test] + fn deserialize_web_app_data_missing_field() { + let value = json!({ "data": "test" }); + let result: Result = from_value(value); + assert!(result.is_err()); + } +} diff --git a/telegram-webapp-sdk/src/core/types/web_app_info.rs b/telegram-webapp-sdk/src/core/types/web_app_info.rs new file mode 100644 index 0000000..01a9553 --- /dev/null +++ b/telegram-webapp-sdk/src/core/types/web_app_info.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +/// Describes a Web App. +/// +/// # Examples +/// +/// ```rust +/// use telegram_webapp_sdk::core::types::web_app_info::WebAppInfo; +/// +/// let info = WebAppInfo { +/// url: "https://example.com".to_owned() +/// }; +/// assert_eq!(info.url, "https://example.com"); +/// ``` +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WebAppInfo { + /// HTTPS URL of a Web App to open. + pub url: String +} + +#[cfg(test)] +mod tests { + use serde_json::{from_str, from_value, json, to_string}; + + use super::*; + + #[test] + fn serialize_web_app_info() { + let info = WebAppInfo { + url: "https://t.me".to_owned() + }; + let json = to_string(&info).unwrap(); + assert_eq!(json, "{\"url\":\"https://t.me\"}"); + let value: WebAppInfo = from_str(&json).unwrap(); + assert_eq!(value.url, info.url); + } + + #[test] + fn deserialize_web_app_info_missing_url() { + let data = json!({}); + let result: Result = from_value(data); + assert!(result.is_err()); + } +} diff --git a/telegram-webapp-sdk/src/core/types/webhook_info.rs b/telegram-webapp-sdk/src/core/types/webhook_info.rs new file mode 100644 index 0000000..cc99af6 --- /dev/null +++ b/telegram-webapp-sdk/src/core/types/webhook_info.rs @@ -0,0 +1,76 @@ +use serde::{Deserialize, Serialize}; + +/// Contains information about the current webhook status. +/// +/// # Examples +/// +/// ```rust +/// use telegram_webapp_sdk::core::types::webhook_info::WebhookInfo; +/// +/// let info = WebhookInfo { +/// url: "https://example.com".to_owned(), +/// has_custom_certificate: false, +/// pending_update_count: 0, +/// ip_address: None, +/// last_error_date: None, +/// last_error_message: None, +/// last_synchronization_error_date: None, +/// max_connections: None, +/// allowed_updates: None +/// }; +/// assert_eq!(info.url, "https://example.com"); +/// ``` +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WebhookInfo { + /// Webhook URL. + pub url: String, + /// True if a self-signed certificate is used. + pub has_custom_certificate: bool, + /// Number of updates awaiting delivery. + pub pending_update_count: u32, + /// Currently used IP address. + pub ip_address: Option, + /// Unix time of the most recent delivery error. + pub last_error_date: Option, + /// Error message of the most recent delivery error. + pub last_error_message: Option, + /// Unix time of the most recent synchronization error. + pub last_synchronization_error_date: Option, + /// Maximum allowed connections. + pub max_connections: Option, + /// Allowed update types for the webhook. + pub allowed_updates: Option> +} + +#[cfg(test)] +mod tests { + use serde_json::{from_str, to_string}; + + use super::*; + + #[test] + fn serialize_webhook_info() { + let info = WebhookInfo { + url: "https://example.com".to_owned(), + has_custom_certificate: true, + pending_update_count: 10, + ip_address: Some("127.0.0.1".to_owned()), + last_error_date: Some(1), + last_error_message: Some("error".to_owned()), + last_synchronization_error_date: Some(2), + max_connections: Some(40), + allowed_updates: Some(vec!["message".to_owned()]) + }; + let json = to_string(&info).unwrap(); + assert!(json.contains("https://example.com")); + let parsed: WebhookInfo = from_str(&json).unwrap(); + assert_eq!(parsed.url, info.url); + } + + #[test] + fn deserialize_webhook_info_missing_required() { + let json = "{}"; + let result: Result = from_str(json); + assert!(result.is_err()); + } +} diff --git a/telegram-webapp-sdk/src/core/types/write_access_allowed.rs b/telegram-webapp-sdk/src/core/types/write_access_allowed.rs new file mode 100644 index 0000000..2a8eff1 --- /dev/null +++ b/telegram-webapp-sdk/src/core/types/write_access_allowed.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +/// Indicates whether the bot can write messages to the user. +/// +/// # Examples +/// +/// ```rust +/// use telegram_webapp_sdk::core::types::write_access_allowed::WriteAccessAllowed; +/// +/// let access = WriteAccessAllowed { +/// web_app_name: Some("my_app".to_owned()) +/// }; +/// assert_eq!(access.web_app_name, Some("my_app".to_owned())); +/// ``` +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WriteAccessAllowed { + /// Name of the Web App, if the user granted access for it. + pub web_app_name: Option +} + +#[cfg(test)] +mod tests { + use serde_json::{from_str, to_string}; + + use super::*; + + #[test] + fn serialize_write_access_allowed() { + let access = WriteAccessAllowed { + web_app_name: Some("demo".to_owned()) + }; + let json = to_string(&access).unwrap(); + assert!(json.contains("demo")); + let parsed: WriteAccessAllowed = from_str(&json).unwrap(); + assert_eq!(parsed.web_app_name, access.web_app_name); + } + + #[test] + fn deserialize_write_access_allowed_none() { + let json = "{}"; + let parsed: WriteAccessAllowed = from_str(json).unwrap(); + assert!(parsed.web_app_name.is_none()); + } +} diff --git a/telegram-webapp-sdk/src/leptos.rs b/telegram-webapp-sdk/src/leptos.rs new file mode 100644 index 0000000..f8af109 --- /dev/null +++ b/telegram-webapp-sdk/src/leptos.rs @@ -0,0 +1,30 @@ +use leptos::prelude::provide_context; +use wasm_bindgen::JsValue; + +use crate::core::{context::TelegramContext, safe_context::get_context}; + +/// Provides the [`TelegramContext`] to the Leptos reactive system. +/// +/// # Errors +/// +/// Returns an error if the global context has not been initialized with +/// [`TelegramContext::init`]. +/// +/// # Examples +/// +/// ```no_run +/// use leptos::prelude::*; +/// use telegram_webapp_sdk::{core::context::TelegramContext, leptos::provide_telegram_context}; +/// +/// #[component] +/// fn App() -> impl IntoView { +/// provide_telegram_context().expect("context"); +/// let ctx = use_context::().expect("context"); +/// view! { { ctx.init_data.auth_date } } +/// } +/// ``` +pub fn provide_telegram_context() -> Result<(), JsValue> { + let ctx: TelegramContext = get_context(|c| c.clone())?; + provide_context(ctx); + Ok(()) +} diff --git a/telegram-webapp-sdk/src/lib.rs b/telegram-webapp-sdk/src/lib.rs new file mode 100644 index 0000000..3626c5b --- /dev/null +++ b/telegram-webapp-sdk/src/lib.rs @@ -0,0 +1,25 @@ +#![doc = include_str!("../README.md")] +#![cfg_attr(docsrs, feature(doc_auto_cfg))] + +pub mod api; +pub mod core; +pub mod logger; + +#[cfg(feature = "mock")] +pub mod mock; +pub mod utils; +pub mod webapp; +#[cfg(feature = "macros")] +pub use inventory; +pub use utils::validate_init_data; +pub use webapp::TelegramWebApp; +#[cfg(feature = "macros")] +mod macros; +#[cfg(feature = "macros")] +pub mod pages; + +#[cfg(feature = "yew")] +pub mod yew; + +#[cfg(feature = "leptos")] +pub mod leptos; diff --git a/telegram-webapp-sdk/src/logger.rs b/telegram-webapp-sdk/src/logger.rs new file mode 100644 index 0000000..3b6341b --- /dev/null +++ b/telegram-webapp-sdk/src/logger.rs @@ -0,0 +1,41 @@ +use web_sys::console; + +/// Internal helper for styled log output. +fn styled_log(level: &str, emoji: &str, color: &str, msg: &str) { + #[cfg(debug_assertions)] + { + let prefix = format!("%c[SDK] {} {}", emoji, level.to_uppercase()); + let style = format!("color: {}; font-weight: bold", color); + console::log_3(&prefix.into(), &style.into(), &msg.into()); + } +} + +/// Logs a success message (✅ Green). +pub fn success(msg: &str) { + styled_log("success", "✅", "lightgreen", msg); +} + +/// Logs an error message (❌ Red). +pub fn error(msg: &str) { + styled_log("error", "❌", "red", msg); +} + +/// Logs a warning message (⚠️ Orange). +pub fn warn(msg: &str) { + styled_log("warn", "⚠️", "orange", msg); +} + +/// Logs an info message (ℹ️ Blue). +pub fn info(msg: &str) { + styled_log("info", "ℹ️", "#3399ff", msg); +} + +/// Logs a debug message (🔧 Gray). +pub fn debug(msg: &str) { + styled_log("debug", "🔧", "#888", msg); +} + +/// Logs a trace message (📍 Light Gray). +pub fn trace(msg: &str) { + styled_log("trace", "📍", "#aaa", msg); +} diff --git a/telegram-webapp-sdk/src/macros.rs b/telegram-webapp-sdk/src/macros.rs new file mode 100644 index 0000000..14d6dab --- /dev/null +++ b/telegram-webapp-sdk/src/macros.rs @@ -0,0 +1,217 @@ +//! Telegram WebApp SDK macros. +//! +//! This module provides declarative macros for building Telegram WebApp +//! applications. They let you: +//! +//! * Register routable pages using [`telegram_page!`] +//! * Define the WASM application entry point with Telegram SDK initialization +//! using [`telegram_app!`] +//! * Build and start a router that collects all registered pages via +//! `inventory` using [`telegram_router!`] +//! +//! ## Requirements +//! +//! 1. A `Page` type and a global `inventory` collection in your crate, for +//! example: +//! +//! ```ignore +//! pub mod pages { +//! /// Handler type for a page: a plain `fn()`. +//! pub type Handler = fn(); +//! +//! /// Routable page descriptor. +//! #[derive(Copy, Clone)] +//! pub struct Page { +//! pub path: &'static str, +//! pub handler: Handler; +//! } +//! +//! // Collect all `Page` items via `inventory`. +//! inventory::collect!(Page); +//! +//! /// Iterate over all collected pages as a real `Iterator`. +//! pub fn iter() -> impl Iterator { +//! inventory::iter::.into_iter() +//! } +//! } +//! ``` +//! +//! 2. A `Router` type must be available in scope when using +//! [`telegram_router!`] with API: +//! +//! ```ignore +//! impl Router { +//! fn new() -> Self; +//! fn register(self, path: &str, handler: fn()) -> Self; +//! fn start(self); +//! } +//! ``` +//! +//! 3. For [`telegram_app!`], the following items must exist in your crate: +//! +//! * `utils::check_env::is_telegram_env() -> bool` +//! * `mock::config::MockTelegramConfig::from_file(path) -> Result<_, _>` +//! * `mock::init::mock_telegram_webapp(cfg) -> Result<_, _>` +//! * `core::init::init_sdk() -> Result<(), wasm_bindgen::JsValue>` +//! +//! 4. `Cargo.toml`: +//! +//! ```toml +//! [dependencies] +//! inventory = "0.3" +//! wasm-bindgen = "0.2" +//! ``` +//! +//! ## Quick example +//! +//! ```ignore +//! use wasm_bindgen::prelude::JsValue; +//! +//! // Register a page. +//! telegram_webapp_sdk::telegram_page!( +//! "/", +//! /// Home page handler. +//! pub fn index() { +//! // render something +//! } +//! ); +//! +//! // Application entry point. +//! telegram_webapp_sdk::telegram_app!( +//! /// Application main entry. +//! pub fn main() -> Result<(), JsValue> { +//! telegram_webapp_sdk::telegram_router!(); +//! Ok(()) +//! } +//! ); +//! ``` + +#![allow(clippy::module_name_repetitions)] + +/// Register a routable page. +/// +/// Expands into: +/// * A function definition with the provided visibility, name, and body +/// * A single registration item that submits a [`pages::Page`] to `inventory`, +/// wrapped in a hidden module to remain a valid item in any context +/// +/// ### Handler signature +/// +/// The handler must be a plain function `fn()` with no arguments. If you need +/// state or context, encapsulate it externally (e.g. closures, singletons, DI), +/// not as handler parameters. +/// +/// ### Example +/// +/// ```ignore +/// use telegram_webapp_sdk::telegram_page; +/// +/// telegram_page!( +/// "/about", +/// /// About page. +/// pub fn about() { +/// // render about page +/// } +/// ); +/// ``` +#[macro_export] +macro_rules! telegram_page { + ($path:literal, $(#[$meta:meta])* $vis:vis fn $name:ident $($rest:tt)*) => { + $(#[$meta])* + $vis fn $name $($rest)* + + #[doc(hidden)] + mod __telegram_page_register { + // Keep handler reachable while hiding helper names. + use super::$name as __handler; + #[allow(non_upper_case_globals)] + const _: () = { + $crate::inventory::submit! { + $crate::pages::Page { path: $path, handler: __handler } + } + }; + } + }; +} + +/// Define the WASM application entry point with Telegram SDK initialization. +/// +/// The generated function is annotated with `#[wasm_bindgen(start)]`. +/// It performs: +/// +/// * Environment detection via `utils::check_env::is_telegram_env()` +/// * Debug-only mock initialization when not in Telegram +/// * SDK initialization via `core::init::init_sdk()?` +/// +/// After these steps, the provided function body is executed. +/// +/// ### Return type +/// +/// The function may return either `()` or `Result<(), wasm_bindgen::JsValue>`. +/// +/// ### Example +/// +/// ```ignore +/// use telegram_webapp_sdk::telegram_app; +/// use wasm_bindgen::JsValue; +/// +/// telegram_app!( +/// /// Application entry point. +/// pub fn main() -> Result<(), JsValue> { +/// telegram_webapp_sdk::telegram_router!(); +/// Ok(()) +/// } +/// ); +/// ``` +#[macro_export] +macro_rules! telegram_app { + ($(#[$meta:meta])* $vis:vis fn $name:ident($($arg:tt)*) $(-> $ret:ty)? $body:block) => { + $(#[$meta])* + #[wasm_bindgen::prelude::wasm_bindgen(start)] + $vis fn $name($($arg)*) $(-> $ret)? { + if !$crate::utils::check_env::is_telegram_env() { + #[cfg(debug_assertions)] + if let Ok(cfg) = $crate::mock::config::MockTelegramConfig::from_file("telegram-webapp.toml") { + let _ = $crate::mock::init::mock_telegram_webapp(cfg); + } + } + $crate::core::init::init_sdk()?; + $body + } + }; +} + +/// Build and start a router from all registered pages. +/// +/// This macro expects a `Router` type in scope with methods: +/// +/// * `fn new() -> Self` +/// * `fn register(self, path: &str, handler: fn()) -> Self` +/// * `fn start(self)` +/// +/// ### Example +/// +/// ```ignore +/// use telegram_webapp_sdk::{telegram_page, telegram_router}; +/// +/// struct Router; +/// impl Router { +/// fn new() -> Self { Router } +/// fn register(self, _path: &str, _handler: fn()) -> Self { self } +/// fn start(self) {} +/// } +/// +/// telegram_page!("/", pub fn index() {}); +/// +/// telegram_router!(); +/// ``` +#[macro_export] +macro_rules! telegram_router { + () => {{ + let mut router = Router::new(); + for page in $crate::pages::iter() { + router = router.register(page.path, page.handler); + } + router.start(); + }}; +} diff --git a/telegram-webapp-sdk/src/mock.rs b/telegram-webapp-sdk/src/mock.rs new file mode 100644 index 0000000..c7e0d8e --- /dev/null +++ b/telegram-webapp-sdk/src/mock.rs @@ -0,0 +1,4 @@ +pub mod config; +pub mod data; +pub mod init; +pub mod utils; diff --git a/telegram-webapp-sdk/src/mock/config.rs b/telegram-webapp-sdk/src/mock/config.rs new file mode 100644 index 0000000..a15f386 --- /dev/null +++ b/telegram-webapp-sdk/src/mock/config.rs @@ -0,0 +1,57 @@ +use std::{ + fs, + io::{Error, ErrorKind} +}; + +use serde::Deserialize; + +use super::data::MockTelegramUser; + +/// Configuration for mocking Telegram environment. +#[derive(Default, Deserialize)] +pub struct MockTelegramConfig { + pub user: Option, + pub auth_date: Option, + pub hash: Option, + pub bg_color: Option, + pub text_color: Option, + pub hint_color: Option, + pub link_color: Option, + pub button_color: Option, + pub button_text_color: Option, + pub secondary_bg_color: Option, + pub header_bg_color: Option, + pub bottom_bar_bg_color: Option, + pub accent_text_color: Option, + pub section_bg_color: Option, + pub section_header_text_color: Option, + pub section_separator_color: Option, + pub subtitle_text_color: Option, + pub destructive_text_color: Option, + pub platform: Option, + pub version: Option +} + +impl MockTelegramConfig { + /// Loads configuration from a TOML file. + pub fn from_file(path: &str) -> Result { + let content = fs::read_to_string(path)?; + toml::from_str(&content).map_err(|e| Error::new(ErrorKind::InvalidData, e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_user_from_file() { + let cfg = MockTelegramConfig::from_file("telegram-webapp.toml").expect("config"); + assert_eq!(cfg.user.unwrap().first_name, "Alice"); + } + + #[test] + fn missing_file_is_error() { + assert!(MockTelegramConfig::from_file("nope.toml").is_err()); + } +} diff --git a/telegram-webapp-sdk/src/mock/data.rs b/telegram-webapp-sdk/src/mock/data.rs new file mode 100644 index 0000000..8c6516f --- /dev/null +++ b/telegram-webapp-sdk/src/mock/data.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Default)] +pub struct MockTelegramUser { + pub id: u64, + pub first_name: String, + pub last_name: Option, + pub username: Option, + pub language_code: Option, + pub is_premium: Option, + pub allows_write_to_pm: Option +} diff --git a/telegram-webapp-sdk/src/mock/init.rs b/telegram-webapp-sdk/src/mock/init.rs new file mode 100644 index 0000000..1ead1da --- /dev/null +++ b/telegram-webapp-sdk/src/mock/init.rs @@ -0,0 +1,162 @@ +use js_sys::{Object, Reflect}; +use wasm_bindgen::{JsCast, prelude::*}; +use web_sys::window; + +use super::{data::MockTelegramUser, utils::generate_mock_init_data}; +use crate::{ + logger::{debug, success}, + mock::config::MockTelegramConfig +}; + +/// Injects a customizable mock Telegram WebApp environment for local +/// development. +/// +/// Should be used only in `#[cfg(debug_assertions)]` environments. +pub fn mock_telegram_webapp(config: MockTelegramConfig) -> Result<(), JsValue> { + let win = window().ok_or_else(|| JsValue::from_str("window not available"))?; + + let telegram = Object::new(); + let webapp = Object::new(); + + // === Function mocks === + let init_fn = Closure::::wrap(Box::new(|| { + debug("WebApp.init() called"); + })); + Reflect::set(&webapp, &"init".into(), init_fn.as_ref().unchecked_ref())?; + init_fn.forget(); + + let send_data_fn = Closure::::wrap(Box::new(|data: JsValue| { + debug(&format!("WebApp.sendData(): {data:?}")); + })); + Reflect::set( + &webapp, + &"sendData".into(), + send_data_fn.as_ref().unchecked_ref() + )?; + send_data_fn.forget(); + + // === Property mocks === + let user = config.user.unwrap_or_else(|| MockTelegramUser { + id: 1, + first_name: "Dev".into(), + ..Default::default() + }); + + let auth_date = config.auth_date.unwrap_or_else(|| "1234567890".into()); + let hash = config.hash.unwrap_or_else(|| "fakehash".into()); + + let init_data = generate_mock_init_data(&user, &auth_date, &hash); + Reflect::set(&webapp, &"initData".into(), &JsValue::from_str(&init_data))?; + + let theme = Object::new(); + Reflect::set( + &theme, + &"bg_color".into(), + &JsValue::from_str(config.bg_color.as_deref().unwrap_or("#17212b")) + )?; + Reflect::set( + &theme, + &"text_color".into(), + &JsValue::from_str(config.text_color.as_deref().unwrap_or("#ffffff")) + )?; + Reflect::set( + &theme, + &"hint_color".into(), + &JsValue::from_str(config.hint_color.as_deref().unwrap_or("#888888")) + )?; + Reflect::set( + &theme, + &"link_color".into(), + &JsValue::from_str(config.link_color.as_deref().unwrap_or("#2689bf")) + )?; + Reflect::set( + &theme, + &"button_color".into(), + &JsValue::from_str(config.button_color.as_deref().unwrap_or("#0088cc")) + )?; + Reflect::set( + &theme, + &"button_text_color".into(), + &JsValue::from_str(config.button_text_color.as_deref().unwrap_or("#ffffff")) + )?; + Reflect::set( + &theme, + &"secondary_bg_color".into(), + &JsValue::from_str(config.secondary_bg_color.as_deref().unwrap_or("#f0f0f0")) + )?; + Reflect::set( + &theme, + &"header_bg_color".into(), + &JsValue::from_str(config.header_bg_color.as_deref().unwrap_or("#1d1f21")) + )?; + Reflect::set( + &theme, + &"bottom_bar_bg_color".into(), + &JsValue::from_str(config.bottom_bar_bg_color.as_deref().unwrap_or("#1f2226")) + )?; + Reflect::set( + &theme, + &"accent_text_color".into(), + &JsValue::from_str(config.accent_text_color.as_deref().unwrap_or("#2eaee3")) + )?; + Reflect::set( + &theme, + &"section_bg_color".into(), + &JsValue::from_str(config.section_bg_color.as_deref().unwrap_or("#222529")) + )?; + Reflect::set( + &theme, + &"section_header_text_color".into(), + &JsValue::from_str( + config + .section_header_text_color + .as_deref() + .unwrap_or("#c8c9cb") + ) + )?; + Reflect::set( + &theme, + &"section_separator_color".into(), + &JsValue::from_str( + config + .section_separator_color + .as_deref() + .unwrap_or("#2a2c30") + ) + )?; + Reflect::set( + &theme, + &"subtitle_text_color".into(), + &JsValue::from_str(config.subtitle_text_color.as_deref().unwrap_or("#909398")) + )?; + Reflect::set( + &theme, + &"destructive_text_color".into(), + &JsValue::from_str( + config + .destructive_text_color + .as_deref() + .unwrap_or("#e33e3e") + ) + )?; + Reflect::set(&webapp, &"themeParams".into(), &theme)?; + + Reflect::set( + &webapp, + &"platform".into(), + &JsValue::from_str(config.platform.as_deref().unwrap_or("web")) + )?; + Reflect::set( + &webapp, + &"version".into(), + &JsValue::from_str(config.version.as_deref().unwrap_or("9.0")) + )?; + + Reflect::set(&telegram, &"WebApp".into(), &webapp)?; + Reflect::set(&win, &"Telegram".into(), &telegram)?; + + // === Logs === + success("Mock Telegram.WebApp environment injected"); + + Ok(()) +} diff --git a/telegram-webapp-sdk/src/mock/utils.rs b/telegram-webapp-sdk/src/mock/utils.rs new file mode 100644 index 0000000..778189c --- /dev/null +++ b/telegram-webapp-sdk/src/mock/utils.rs @@ -0,0 +1,44 @@ +use serde_json::to_string; +use urlencoding::encode; + +use crate::mock::data::MockTelegramUser; + +/// Generate a valid Telegram `initData` string from user info and params. +/// +/// # Arguments +/// - `user`: Telegram user data (as `MockTelegramUser`) +/// - `auth_date`: UNIX timestamp (as string, e.g., `"1234567890"`) +/// - `hash`: mock hash string (can be `"fakehash"` or real value) +/// +/// # Returns +/// Properly URL-encoded initData string for Telegram WebApp emulation. +pub fn generate_mock_init_data(user: &MockTelegramUser, auth_date: &str, hash: &str) -> String { + let user_json = to_string(user).unwrap_or_else(|_| "{}".into()); + let encoded_user = encode(&user_json); + + format!( + "user={}&auth_date={}&hash={}", + encoded_user, auth_date, hash + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_expected_init_data() { + let user = MockTelegramUser { + id: 1, + first_name: "Dev".into(), + ..Default::default() + }; + let auth_date = "123456"; + let hash = "hash"; + let data = generate_mock_init_data(&user, auth_date, hash); + + assert!(data.contains("user=")); + assert!(data.contains("auth_date=123456")); + assert!(data.contains("hash=hash")); + } +} diff --git a/telegram-webapp-sdk/src/pages.rs b/telegram-webapp-sdk/src/pages.rs new file mode 100644 index 0000000..d9a3ea3 --- /dev/null +++ b/telegram-webapp-sdk/src/pages.rs @@ -0,0 +1,15 @@ +use inventory::collect; + +/// Represents a single routable page. +#[derive(Copy, Clone)] +pub struct Page { + pub path: &'static str, + pub handler: fn() +} + +collect!(Page); + +/// Returns iterator over registered pages. +pub fn iter() -> inventory::iter { + inventory::iter:: +} diff --git a/telegram-webapp-sdk/src/utils.rs b/telegram-webapp-sdk/src/utils.rs new file mode 100644 index 0000000..8bd94f5 --- /dev/null +++ b/telegram-webapp-sdk/src/utils.rs @@ -0,0 +1,2 @@ +pub mod check_env; +pub mod validate_init_data; diff --git a/telegram-webapp-sdk/src/utils/check_env.rs b/telegram-webapp-sdk/src/utils/check_env.rs new file mode 100644 index 0000000..a825bb7 --- /dev/null +++ b/telegram-webapp-sdk/src/utils/check_env.rs @@ -0,0 +1,68 @@ +use js_sys::Reflect; +use web_sys::window; + +/// Checks if the code is running inside Telegram Mini App. +pub fn is_telegram_env() -> bool { + let win = match window() { + Some(w) => w, + None => return false + }; + + let telegram = match Reflect::get(&win, &"Telegram".into()) { + Ok(v) if !v.is_undefined() => v, + _ => return false + }; + + let _webapp = match Reflect::get(&telegram, &"WebApp".into()) { + Ok(v) if !v.is_undefined() => v, + _ => return false + }; + + true +} + +#[cfg(test)] +mod tests { + use js_sys::{Object, Reflect}; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn cleanup() { + let win = window().unwrap(); + let _ = Reflect::delete_property(&win, &"Telegram".into()); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] + fn returns_false_without_telegram() { + cleanup(); + assert!(!is_telegram_env()); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] + fn returns_false_without_webapp() { + cleanup(); + let win = window().unwrap(); + let telegram = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + assert!(!is_telegram_env()); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] + fn returns_true_with_telegram_and_webapp() { + cleanup(); + let win = window().unwrap(); + let telegram = Object::new(); + let webapp = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + assert!(is_telegram_env()); + } +} diff --git a/telegram-webapp-sdk/src/utils/validate_init_data.rs b/telegram-webapp-sdk/src/utils/validate_init_data.rs new file mode 100644 index 0000000..92663ad --- /dev/null +++ b/telegram-webapp-sdk/src/utils/validate_init_data.rs @@ -0,0 +1,205 @@ +use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use hmac_sha256::{HMAC, Hash}; +use percent_encoding::percent_decode_str; +use thiserror::Error; + +/// Errors that can occur when validating Telegram init data. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ValidationError { + /// A required field such as `hash` or `signature` was missing. + #[error("missing required field: {0}")] + MissingField(&'static str), + /// Input contained invalid percent encoding or non-UTF8 data. + #[error("invalid encoding in init data")] + InvalidEncoding, + /// Signature value could not be parsed from its encoding (hex or base64). + #[error("invalid signature encoding")] + InvalidSignatureEncoding, + /// Computed signature did not match the provided one. + #[error("signature mismatch")] + SignatureMismatch, + /// Provided Ed25519 public key was malformed. + #[error("invalid public key")] + InvalidPublicKey +} + +/// Key material used to validate Telegram init data. +#[derive(Clone, Copy, Debug)] +pub enum ValidationKey<'a> { + /// Validate using a bot token and HMAC-SHA256. + BotToken(&'a str), + /// Validate using an Ed25519 public key. + Ed25519PublicKey(&'a [u8; 32]) +} + +/// Validates the `hash` parameter of the init data using HMAC-SHA256. +/// +/// The `init_data` string must be the exact value of +/// `Telegram.WebApp.initData`. The function derives a secret key from the +/// provided bot token and checks that the `hash` parameter matches the expected +/// HMAC-SHA256. +/// +/// # Errors +/// Returns [`ValidationError`] if parsing fails or the hash does not match. +/// +/// # Examples +/// ``` +/// use hmac_sha256::{HMAC, Hash}; +/// use telegram_webapp_sdk::validate_init_data::verify_hmac_sha256; +/// let token = "123456:ABC"; +/// let check_string = "auth_date=1\nuser=alice"; +/// let secret = Hash::hash(format!("WebAppData{token}").as_bytes()); +/// let hash = hex::encode(HMAC::mac(check_string.as_bytes(), secret)); +/// let init_data = format!("auth_date=1&user=alice&hash={hash}"); +/// assert!(verify_hmac_sha256(&init_data, token).is_ok()); +/// ``` +pub fn verify_hmac_sha256(init_data: &str, bot_token: &str) -> Result<(), ValidationError> { + let (check_string, hash) = extract_check_string(init_data, "hash")?; + + let secret_key = Hash::hash(format!("WebAppData{bot_token}").as_bytes()); + let expected = HMAC::mac(check_string.as_bytes(), secret_key); + let expected_hex = hex::encode(expected); + + if expected_hex == hash { + Ok(()) + } else { + Err(ValidationError::SignatureMismatch) + } +} + +/// Validates the `signature` parameter of the init data using Ed25519. +/// +/// The `init_data` string must include a `signature` parameter encoded in +/// Base64. All other parameters are combined into the data check string +/// according to Telegram's specification and verified against the provided +/// Ed25519 public key. +/// +/// # Errors +/// Returns [`ValidationError`] if parsing fails or the signature does not +/// verify. +/// +/// # Examples +/// ``` +/// use ed25519_dalek::{Signer, SigningKey}; +/// use telegram_webapp_sdk::validate_init_data::verify_ed25519; +/// +/// // generate test key +/// let sk = SigningKey::from_bytes(&[1u8; 32]); +/// let pk = sk.verifying_key(); +/// let message = "a=1\nb=2"; +/// let sig = sk.sign(message.as_bytes()); +/// let init_data = format!("a=1&b=2&signature={}", base64::encode(sig.to_bytes())); +/// assert!(verify_ed25519(&init_data, pk.as_bytes()).is_ok()); +/// ``` +pub fn verify_ed25519(init_data: &str, public_key: &[u8; 32]) -> Result<(), ValidationError> { + let (check_string, signature_b64) = extract_check_string(init_data, "signature")?; + + let sig_bytes = BASE64_STANDARD + .decode(signature_b64) + .map_err(|_| ValidationError::InvalidSignatureEncoding)?; + let signature = Signature::from_slice(&sig_bytes) + .map_err(|_| ValidationError::InvalidSignatureEncoding)?; + let verifying_key = + VerifyingKey::from_bytes(public_key).map_err(|_| ValidationError::InvalidPublicKey)?; + + verifying_key + .verify(check_string.as_bytes(), &signature) + .map_err(|_| ValidationError::SignatureMismatch) +} + +fn extract_check_string( + init_data: &str, + signature_field: &'static str +) -> Result<(String, String), ValidationError> { + let mut data: Vec<(String, String)> = Vec::new(); + let mut signature: Option = None; + + for pair in init_data.split('&') { + let mut parts = pair.splitn(2, '='); + let key = parts.next().ok_or(ValidationError::InvalidEncoding)?; + let value = parts.next().ok_or(ValidationError::InvalidEncoding)?; + let decoded = percent_decode_str(value) + .decode_utf8() + .map_err(|_| ValidationError::InvalidEncoding)? + .to_string(); + if key == signature_field { + signature = Some(decoded); + } else { + data.push((key.to_string(), decoded)); + } + } + + let signature = signature.ok_or(ValidationError::MissingField(signature_field))?; + + data.sort_by(|a, b| a.0.cmp(&b.0)); + let check_string = data + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("\n"); + + Ok((check_string, signature)) +} + +#[cfg(test)] +mod tests { + use ed25519_dalek::{Signer, SigningKey}; + + use super::*; + + #[test] + fn hmac_validates() { + let bot_token = "123456:ABC"; + let secret_key = Hash::hash(format!("WebAppData{bot_token}").as_bytes()); + let check_string = "a=1\nb=2"; + let expected = HMAC::mac(check_string.as_bytes(), secret_key); + let hash = hex::encode(expected); + let query = format!("a=1&b=2&hash={hash}"); + assert!(verify_hmac_sha256(&query, bot_token).is_ok()); + } + + #[test] + fn hmac_rejects_modified_data() { + let bot_token = "123456:ABC"; + let secret_key = Hash::hash(format!("WebAppData{bot_token}").as_bytes()); + let check_string = "a=1\nb=2"; + let expected = HMAC::mac(check_string.as_bytes(), secret_key); + let hash = hex::encode(expected); + // tamper with data + assert_eq!( + verify_hmac_sha256(&format!("a=1&b=3&hash={hash}"), bot_token), + Err(ValidationError::SignatureMismatch) + ); + } + + #[test] + fn ed25519_validates() { + let sk = SigningKey::from_bytes(&[42u8; 32]); + let pk = sk.verifying_key(); + let message = "a=1\nb=2"; + let sig = sk.sign(message.as_bytes()); + let init_data = format!( + "a=1&b=2&signature={}", + BASE64_STANDARD.encode(sig.to_bytes()) + ); + assert!(verify_ed25519(&init_data, pk.as_bytes()).is_ok()); + } + + #[test] + fn ed25519_rejects_bad_signature() { + let sk = SigningKey::from_bytes(&[42u8; 32]); + let pk = sk.verifying_key(); + let message = "a=1\nb=2"; + let sig = sk.sign(message.as_bytes()); + // modify data + let tampered = format!( + "a=1&b=3&signature={}", + BASE64_STANDARD.encode(sig.to_bytes()) + ); + assert_eq!( + verify_ed25519(&tampered, pk.as_bytes()), + Err(ValidationError::SignatureMismatch) + ); + } +} diff --git a/telegram-webapp-sdk/src/webapp.rs b/telegram-webapp-sdk/src/webapp.rs new file mode 100644 index 0000000..b8a939a --- /dev/null +++ b/telegram-webapp-sdk/src/webapp.rs @@ -0,0 +1,2679 @@ +use js_sys::{Function, Object, Reflect}; +use serde_wasm_bindgen::to_value; +use wasm_bindgen::{JsCast, JsValue, prelude::Closure}; +use web_sys::window; + +use crate::{ + core::types::download_file_params::DownloadFileParams, + logger, + validate_init_data::{self, ValidationKey} +}; + +/// Handle returned when registering callbacks. +pub struct EventHandle { + target: Object, + method: &'static str, + event: Option, + callback: Closure +} + +impl EventHandle { + fn new( + target: Object, + method: &'static str, + event: Option, + callback: Closure + ) -> Self { + Self { + target, + method, + event, + callback + } + } + + pub(crate) fn unregister(self) -> Result<(), JsValue> { + let f = Reflect::get(&self.target, &self.method.into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str(&format!("{} is not a function", self.method)))?; + match self.event { + Some(event) => func.call2( + &self.target, + &event.into(), + self.callback.as_ref().unchecked_ref() + )?, + None => func.call1(&self.target, self.callback.as_ref().unchecked_ref())? + }; + Ok(()) + } +} + +/// Identifies which bottom button to operate on. +#[derive(Clone, Copy, Debug)] +pub enum BottomButton { + /// Primary bottom button. + Main, + /// Secondary bottom button. + Secondary +} + +impl BottomButton { + const fn js_name(self) -> &'static str { + match self { + BottomButton::Main => "MainButton", + BottomButton::Secondary => "SecondaryButton" + } + } +} + +/// Background events delivered by Telegram when the Mini App runs in the +/// background. +#[derive(Clone, Copy, Debug)] +pub enum BackgroundEvent { + /// The main button was clicked. Payload: [`JsValue::UNDEFINED`]. + MainButtonClicked, + /// The back button was clicked. Payload: [`JsValue::UNDEFINED`]. + BackButtonClicked, + /// The settings button was clicked. Payload: [`JsValue::UNDEFINED`]. + SettingsButtonClicked, + /// User responded to a write access request. Payload: `bool`. + WriteAccessRequested, + /// User responded to a contact request. Payload: `bool`. + ContactRequested, + /// User responded to a phone number request. Payload: `bool`. + PhoneRequested, + /// An invoice was closed. Payload: status string. + InvoiceClosed, + /// A popup was closed. Payload: object containing `button_id`. + PopupClosed, + /// Text was received from the QR scanner. Payload: scanned text. + QrTextReceived, + /// Text was read from the clipboard. Payload: clipboard text. + ClipboardTextReceived +} + +impl BackgroundEvent { + const fn as_str(self) -> &'static str { + match self { + BackgroundEvent::MainButtonClicked => "mainButtonClicked", + BackgroundEvent::BackButtonClicked => "backButtonClicked", + BackgroundEvent::SettingsButtonClicked => "settingsButtonClicked", + BackgroundEvent::WriteAccessRequested => "writeAccessRequested", + BackgroundEvent::ContactRequested => "contactRequested", + BackgroundEvent::PhoneRequested => "phoneRequested", + BackgroundEvent::InvoiceClosed => "invoiceClosed", + BackgroundEvent::PopupClosed => "popupClosed", + BackgroundEvent::QrTextReceived => "qrTextReceived", + BackgroundEvent::ClipboardTextReceived => "clipboardTextReceived" + } + } +} + +/// Safe wrapper around `window.Telegram.WebApp` +#[derive(Clone)] +pub struct TelegramWebApp { + inner: Object +} + +impl TelegramWebApp { + /// Get instance of `Telegram.WebApp` or `None` if not present + pub fn instance() -> Option { + let win = window()?; + let tg = Reflect::get(&win, &"Telegram".into()).ok()?; + let webapp = Reflect::get(&tg, &"WebApp".into()).ok()?; + webapp.dyn_into::().ok().map(|inner| Self { + inner + }) + } + + /// Try to get instance of `Telegram.WebApp`. + /// + /// # Errors + /// Returns [`JsValue`] if the `Telegram.WebApp` object is missing or + /// malformed. + pub fn try_instance() -> Result { + let win = window().ok_or_else(|| JsValue::from_str("window not available"))?; + let tg = Reflect::get(&win, &"Telegram".into())?; + let webapp = Reflect::get(&tg, &"WebApp".into())?; + let inner = webapp.dyn_into::()?; + Ok(Self { + inner + }) + } + + /// Validate an `initData` payload using either HMAC-SHA256 or Ed25519. + /// + /// Pass [`ValidationKey::BotToken`] to verify the `hash` parameter using + /// the bot token. Use [`ValidationKey::Ed25519PublicKey`] to verify the + /// `signature` parameter with an Ed25519 public key. + /// + /// # Errors + /// Returns [`validate_init_data::ValidationError`] if validation fails. + /// + /// # Examples + /// ```no_run + /// use telegram_webapp_sdk::{TelegramWebApp, validate_init_data::ValidationKey}; + /// let bot_token = "123456:ABC"; + /// let query = "a=1&b=2&hash=9e5e8d7c0b1f9f3a"; + /// TelegramWebApp::validate_init_data(query, ValidationKey::BotToken(bot_token)).unwrap(); + /// ``` + pub fn validate_init_data( + init_data: &str, + key: ValidationKey + ) -> Result<(), validate_init_data::ValidationError> { + match key { + ValidationKey::BotToken(token) => { + validate_init_data::verify_hmac_sha256(init_data, token) + } + ValidationKey::Ed25519PublicKey(pk) => { + validate_init_data::verify_ed25519(init_data, pk) + } + } + } + + /// Call `WebApp.sendData(data)`. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn send_data(&self, data: &str) -> Result<(), JsValue> { + self.call1("sendData", &data.into()) + } + + /// Call `WebApp.expand()`. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn expand(&self) -> Result<(), JsValue> { + self.call0("expand") + } + + /// Call `WebApp.close()`. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn close(&self) -> Result<(), JsValue> { + self.call0("close") + } + + /// Call `WebApp.enableClosingConfirmation()`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.enable_closing_confirmation().unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn enable_closing_confirmation(&self) -> Result<(), JsValue> { + self.call0("enableClosingConfirmation") + } + + /// Call `WebApp.disableClosingConfirmation()`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.disable_closing_confirmation().unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn disable_closing_confirmation(&self) -> Result<(), JsValue> { + self.call0("disableClosingConfirmation") + } + + /// Returns whether closing confirmation is currently enabled. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// let _ = app.is_closing_confirmation_enabled(); + /// ``` + pub fn is_closing_confirmation_enabled(&self) -> bool { + Reflect::get(&self.inner, &"isClosingConfirmationEnabled".into()) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } + + /// Call `WebApp.requestFullscreen()`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.request_fullscreen().unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn request_fullscreen(&self) -> Result<(), JsValue> { + self.call0("requestFullscreen") + } + + /// Call `WebApp.exitFullscreen()`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.exit_fullscreen().unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn exit_fullscreen(&self) -> Result<(), JsValue> { + self.call0("exitFullscreen") + } + + /// Call `WebApp.lockOrientation(orientation)`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.lock_orientation("portrait").unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn lock_orientation(&self, orientation: &str) -> Result<(), JsValue> { + self.call1("lockOrientation", &orientation.into()) + } + + /// Call `WebApp.unlockOrientation()`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.unlock_orientation().unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn unlock_orientation(&self) -> Result<(), JsValue> { + self.call0("unlockOrientation") + } + + /// Call `WebApp.enableVerticalSwipes()`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.enable_vertical_swipes().unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn enable_vertical_swipes(&self) -> Result<(), JsValue> { + self.call0("enableVerticalSwipes") + } + + /// Call `WebApp.disableVerticalSwipes()`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.disable_vertical_swipes().unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn disable_vertical_swipes(&self) -> Result<(), JsValue> { + self.call0("disableVerticalSwipes") + } + + /// Call `WebApp.showAlert(message)`. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn show_alert(&self, msg: &str) -> Result<(), JsValue> { + self.call1("showAlert", &msg.into()) + } + + /// Call `WebApp.showConfirm(message, callback)`. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn show_confirm(&self, msg: &str, on_confirm: F) -> Result<(), JsValue> + where + F: 'static + Fn(bool) + { + let cb = Closure::::new(on_confirm); + let f = Reflect::get(&self.inner, &"showConfirm".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("showConfirm is not a function"))?; + func.call2(&self.inner, &msg.into(), cb.as_ref().unchecked_ref())?; + cb.forget(); // safe leak for JS lifetime + Ok(()) + } + + /// Call `WebApp.openLink(url)`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.open_link("https://example.com").unwrap(); + /// ``` + pub fn open_link(&self, url: &str) -> Result<(), JsValue> { + Reflect::get(&self.inner, &"openLink".into())? + .dyn_into::()? + .call1(&self.inner, &url.into())?; + Ok(()) + } + + /// Call `WebApp.openTelegramLink(url)`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.open_telegram_link("https://t.me/telegram").unwrap(); + /// ``` + pub fn open_telegram_link(&self, url: &str) -> Result<(), JsValue> { + Reflect::get(&self.inner, &"openTelegramLink".into())? + .dyn_into::()? + .call1(&self.inner, &url.into())?; + Ok(()) + } + + /// Call `WebApp.openInvoice(url, callback)`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.open_invoice("https://invoice", |status| { + /// let _ = status; + /// }) + /// .unwrap(); + /// ``` + pub fn open_invoice(&self, url: &str, callback: F) -> Result<(), JsValue> + where + F: 'static + Fn(String) + { + let cb = Closure::::new(move |status: JsValue| { + callback(status.as_string().unwrap_or_default()); + }); + Reflect::get(&self.inner, &"openInvoice".into())? + .dyn_into::()? + .call2(&self.inner, &url.into(), cb.as_ref().unchecked_ref())?; + cb.forget(); + Ok(()) + } + + /// Call `WebApp.switchInlineQuery(query, choose_chat_types)`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.switch_inline_query("query", None).unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn switch_inline_query( + &self, + query: &str, + choose_chat_types: Option<&JsValue> + ) -> Result<(), JsValue> { + let f = Reflect::get(&self.inner, &"switchInlineQuery".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("switchInlineQuery is not a function"))?; + match choose_chat_types { + Some(types) => func.call2(&self.inner, &query.into(), types)?, + None => func.call1(&self.inner, &query.into())? + }; + Ok(()) + } + + /// Call `WebApp.shareMessage(msg_id, callback)`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.share_message("id123", |sent| { + /// let _ = sent; + /// }) + /// .unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn share_message(&self, msg_id: &str, callback: F) -> Result<(), JsValue> + where + F: 'static + Fn(bool) + { + let cb = Closure::::new(move |v: JsValue| { + callback(v.as_bool().unwrap_or(false)); + }); + let f = Reflect::get(&self.inner, &"shareMessage".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("shareMessage is not a function"))?; + func.call2(&self.inner, &msg_id.into(), cb.as_ref().unchecked_ref())?; + cb.forget(); + Ok(()) + } + + /// Call `WebApp.shareToStory(media_url, params)`. + /// + /// # Examples + /// ```no_run + /// # use js_sys::Object; + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// let params = Object::new(); + /// app.share_to_story("https://example.com/image.png", Some(¶ms.into())) + /// .unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn share_to_story( + &self, + media_url: &str, + params: Option<&JsValue> + ) -> Result<(), JsValue> { + let f = Reflect::get(&self.inner, &"shareToStory".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("shareToStory is not a function"))?; + match params { + Some(p) => func.call2(&self.inner, &media_url.into(), p)?, + None => func.call1(&self.inner, &media_url.into())? + }; + Ok(()) + } + + /// Call `WebApp.shareURL(url, text)`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.share_url("https://example.com", Some("Check this")) + /// .unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn share_url(&self, url: &str, text: Option<&str>) -> Result<(), JsValue> { + let f = Reflect::get(&self.inner, &"shareURL".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("shareURL is not a function"))?; + match text { + Some(t) => func.call2(&self.inner, &url.into(), &t.into())?, + None => func.call1(&self.inner, &url.into())? + }; + Ok(()) + } + + /// Call `WebApp.joinVoiceChat(chat_id, invite_hash)`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.join_voice_chat("chat", None).unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn join_voice_chat( + &self, + chat_id: &str, + invite_hash: Option<&str> + ) -> Result<(), JsValue> { + let f = Reflect::get(&self.inner, &"joinVoiceChat".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("joinVoiceChat is not a function"))?; + match invite_hash { + Some(hash) => func.call2(&self.inner, &chat_id.into(), &hash.into())?, + None => func.call1(&self.inner, &chat_id.into())? + }; + Ok(()) + } + + /// Call `WebApp.addToHomeScreen()` and return whether the prompt was shown. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// let _shown = app.add_to_home_screen().unwrap(); + /// ``` + pub fn add_to_home_screen(&self) -> Result { + let f = Reflect::get(&self.inner, &"addToHomeScreen".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("addToHomeScreen is not a function"))?; + let result = func.call0(&self.inner)?; + Ok(result.as_bool().unwrap_or(false)) + } + + /// Call `WebApp.checkHomeScreenStatus(callback)`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.check_home_screen_status(|status| { + /// let _ = status; + /// }) + /// .unwrap(); + /// ``` + pub fn check_home_screen_status(&self, callback: F) -> Result<(), JsValue> + where + F: 'static + Fn(String) + { + let cb = Closure::::new(move |status: JsValue| { + callback(status.as_string().unwrap_or_default()); + }); + let f = Reflect::get(&self.inner, &"checkHomeScreenStatus".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("checkHomeScreenStatus is not a function"))?; + func.call1(&self.inner, cb.as_ref().unchecked_ref())?; + cb.forget(); + Ok(()) + } + + /// Call `WebApp.requestWriteAccess(callback)`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.request_write_access(|granted| { + /// let _ = granted; + /// }) + /// .unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn request_write_access(&self, callback: F) -> Result<(), JsValue> + where + F: 'static + Fn(bool) + { + let cb = Closure::::new(move |v: JsValue| { + callback(v.as_bool().unwrap_or(false)); + }); + self.call1("requestWriteAccess", cb.as_ref().unchecked_ref())?; + cb.forget(); + Ok(()) + } + + /// Call `WebApp.downloadFile(params, callback)`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::core::types::download_file_params::DownloadFileParams; + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// let params = DownloadFileParams { + /// url: "https://example.com/file", + /// file_name: None, + /// mime_type: None + /// }; + /// app.download_file(params, |file_id| { + /// let _ = file_id; + /// }) + /// .unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails or the parameters + /// fail to serialize. + pub fn download_file( + &self, + params: DownloadFileParams<'_>, + callback: F + ) -> Result<(), JsValue> + where + F: 'static + Fn(String) + { + let js_params = + to_value(¶ms).map_err(|e| JsValue::from_str(&format!("serialize params: {e}")))?; + let cb = Closure::::new(move |v: JsValue| { + callback(v.as_string().unwrap_or_default()); + }); + Reflect::get(&self.inner, &"downloadFile".into())? + .dyn_into::()? + .call2(&self.inner, &js_params, cb.as_ref().unchecked_ref())?; + cb.forget(); + Ok(()) + } + + /// Call `WebApp.requestEmojiStatusAccess(callback)`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.request_emoji_status_access(|granted| { + /// let _ = granted; + /// }) + /// .unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn request_emoji_status_access(&self, callback: F) -> Result<(), JsValue> + where + F: 'static + Fn(bool) + { + let cb = Closure::::new(move |v: JsValue| { + callback(v.as_bool().unwrap_or(false)); + }); + let f = Reflect::get(&self.inner, &"requestEmojiStatusAccess".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("requestEmojiStatusAccess is not a function"))?; + func.call1(&self.inner, cb.as_ref().unchecked_ref())?; + cb.forget(); + Ok(()) + } + + /// Call `WebApp.setEmojiStatus(status, callback)`. + /// + /// # Examples + /// ```no_run + /// # use js_sys::Object; + /// # use js_sys::Reflect; + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// let status = Object::new(); + /// let _ = Reflect::set(&status, &"custom_emoji_id".into(), &"123".into()); + /// app.set_emoji_status(&status.into(), |success| { + /// let _ = success; + /// }) + /// .unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn set_emoji_status(&self, status: &JsValue, callback: F) -> Result<(), JsValue> + where + F: 'static + Fn(bool) + { + let cb = Closure::::new(move |v: JsValue| { + callback(v.as_bool().unwrap_or(false)); + }); + let f = Reflect::get(&self.inner, &"setEmojiStatus".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("setEmojiStatus is not a function"))?; + func.call2(&self.inner, status, cb.as_ref().unchecked_ref())?; + cb.forget(); + Ok(()) + } + + /// Call `WebApp.showPopup(params, callback)`. + /// + /// # Examples + /// ```no_run + /// # use js_sys::Object; + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// let params = Object::new(); + /// app.show_popup(¶ms.into(), |id| { + /// let _ = id; + /// }) + /// .unwrap(); + /// ``` + pub fn show_popup(&self, params: &JsValue, callback: F) -> Result<(), JsValue> + where + F: 'static + Fn(String) + { + let cb = Closure::::new(move |id: JsValue| { + callback(id.as_string().unwrap_or_default()); + }); + Reflect::get(&self.inner, &"showPopup".into())? + .dyn_into::()? + .call2(&self.inner, params, cb.as_ref().unchecked_ref())?; + cb.forget(); + Ok(()) + } + + /// Call `WebApp.showScanQrPopup(text, callback)`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.show_scan_qr_popup("Scan", |text| { + /// let _ = text; + /// }) + /// .unwrap(); + /// ``` + pub fn show_scan_qr_popup(&self, text: &str, callback: F) -> Result<(), JsValue> + where + F: 'static + Fn(String) + { + let cb = Closure::::new(move |value: JsValue| { + callback(value.as_string().unwrap_or_default()); + }); + Reflect::get(&self.inner, &"showScanQrPopup".into())? + .dyn_into::()? + .call2(&self.inner, &text.into(), cb.as_ref().unchecked_ref())?; + cb.forget(); + Ok(()) + } + + /// Call `WebApp.closeScanQrPopup()`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.close_scan_qr_popup().unwrap(); + /// ``` + pub fn close_scan_qr_popup(&self) -> Result<(), JsValue> { + Reflect::get(&self.inner, &"closeScanQrPopup".into())? + .dyn_into::()? + .call0(&self.inner)?; + Ok(()) + } + + /// Call `WebApp.hideKeyboard()`. + fn bottom_button_object(&self, button: BottomButton) -> Result { + let name = button.js_name(); + Reflect::get(&self.inner, &name.into()) + .inspect_err(|_| logger::error(&format!("{name} not available")))? + .dyn_into::() + .inspect_err(|_| logger::error(&format!("{name} is not an object"))) + } + + fn bottom_button_method( + &self, + button: BottomButton, + method: &str, + arg: Option<&JsValue> + ) -> Result<(), JsValue> { + let name = button.js_name(); + let btn = self.bottom_button_object(button)?; + let f = Reflect::get(&btn, &method.into()) + .inspect_err(|_| logger::error(&format!("{name}.{method} not available")))?; + let func = f.dyn_ref::().ok_or_else(|| { + logger::error(&format!("{name}.{method} is not a function")); + JsValue::from_str("not a function") + })?; + let result = match arg { + Some(v) => func.call1(&btn, v), + None => func.call0(&btn) + }; + result.inspect_err(|_| logger::error(&format!("{name}.{method} call failed")))?; + Ok(()) + } + + /// Hide the on-screen keyboard. + /// Call `WebApp.hideKeyboard()`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.hide_keyboard().unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn hide_keyboard(&self) -> Result<(), JsValue> { + self.call0("hideKeyboard") + } + + /// Call `WebApp.readTextFromClipboard(callback)`. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.read_text_from_clipboard(|text| { + /// let _ = text; + /// }) + /// .unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn read_text_from_clipboard(&self, callback: F) -> Result<(), JsValue> + where + F: 'static + Fn(String) + { + let cb = Closure::::new(move |text: JsValue| { + callback(text.as_string().unwrap_or_default()); + }); + let f = Reflect::get(&self.inner, &"readTextFromClipboard".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("readTextFromClipboard is not a function"))?; + func.call1(&self.inner, cb.as_ref().unchecked_ref())?; + cb.forget(); + Ok(()) + } + + /// Call `WebApp.MainButton.show()`. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn show_bottom_button(&self, button: BottomButton) -> Result<(), JsValue> { + self.bottom_button_method(button, "show", None) + } + + /// Hide a bottom button. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn hide_bottom_button(&self, button: BottomButton) -> Result<(), JsValue> { + self.bottom_button_method(button, "hide", None) + } + + /// Call `WebApp.ready()`. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn ready(&self) -> Result<(), JsValue> { + self.call0("ready") + } + + /// Show back button. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn show_back_button(&self) -> Result<(), JsValue> { + self.call_nested0("BackButton", "show") + } + + /// Hide back button. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn hide_back_button(&self) -> Result<(), JsValue> { + self.call_nested0("BackButton", "hide") + } + + /// Call `WebApp.setHeaderColor(color)`. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.set_header_color("#ffffff").unwrap(); + /// ``` + pub fn set_header_color(&self, color: &str) -> Result<(), JsValue> { + self.call1("setHeaderColor", &color.into()) + } + + /// Call `WebApp.setBackgroundColor(color)`. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.set_background_color("#ffffff").unwrap(); + /// ``` + pub fn set_background_color(&self, color: &str) -> Result<(), JsValue> { + self.call1("setBackgroundColor", &color.into()) + } + + /// Call `WebApp.setBottomBarColor(color)`. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// app.set_bottom_bar_color("#ffffff").unwrap(); + /// ``` + pub fn set_bottom_bar_color(&self, color: &str) -> Result<(), JsValue> { + self.call1("setBottomBarColor", &color.into()) + } + + /// Set main button text. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn set_bottom_button_text(&self, button: BottomButton, text: &str) -> Result<(), JsValue> { + self.bottom_button_method(button, "setText", Some(&text.into())) + } + + /// Set bottom button color (`setColor(color)`). + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::{TelegramWebApp, BottomButton}; + /// # let app = TelegramWebApp::instance().unwrap(); + /// let _ = app.set_bottom_button_color(BottomButton::Main, "#ff0000"); + /// ``` + pub fn set_bottom_button_color( + &self, + button: BottomButton, + color: &str + ) -> Result<(), JsValue> { + self.bottom_button_method(button, "setColor", Some(&color.into())) + } + + /// Set bottom button text color (`setTextColor(color)`). + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::{TelegramWebApp, BottomButton}; + /// # let app = TelegramWebApp::instance().unwrap(); + /// let _ = app.set_bottom_button_text_color(BottomButton::Main, "#ffffff"); + /// ``` + pub fn set_bottom_button_text_color( + &self, + button: BottomButton, + color: &str + ) -> Result<(), JsValue> { + self.bottom_button_method(button, "setTextColor", Some(&color.into())) + } + + /// Set callback for `onClick()` on a bottom button. + /// + /// Returns an [`EventHandle`] that can be used to remove the callback. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn set_bottom_button_callback( + &self, + button: BottomButton, + callback: F + ) -> Result, JsValue> + where + F: 'static + Fn() + { + let btn_val = Reflect::get(&self.inner, &button.js_name().into())?; + let btn = btn_val.dyn_into::()?; + let cb = Closure::::new(callback); + let f = Reflect::get(&btn, &"onClick".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("onClick is not a function"))?; + func.call1(&btn, cb.as_ref().unchecked_ref())?; + Ok(EventHandle::new(btn, "offClick", None, cb)) + } + + /// Remove previously set bottom button callback. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn remove_bottom_button_callback( + &self, + handle: EventHandle + ) -> Result<(), JsValue> { + handle.unregister() + } + + /// Legacy alias for [`Self::show_bottom_button`] with + /// [`BottomButton::Main`]. + pub fn show_main_button(&self) -> Result<(), JsValue> { + self.show_bottom_button(BottomButton::Main) + } + + /// Show the secondary bottom button. + pub fn show_secondary_button(&self) -> Result<(), JsValue> { + self.show_bottom_button(BottomButton::Secondary) + } + + /// Legacy alias for [`Self::hide_bottom_button`] with + /// [`BottomButton::Main`]. + pub fn hide_main_button(&self) -> Result<(), JsValue> { + self.hide_bottom_button(BottomButton::Main) + } + + /// Hide the secondary bottom button. + pub fn hide_secondary_button(&self) -> Result<(), JsValue> { + self.hide_bottom_button(BottomButton::Secondary) + } + + /// Legacy alias for [`Self::set_bottom_button_text`] with + /// [`BottomButton::Main`]. + pub fn set_main_button_text(&self, text: &str) -> Result<(), JsValue> { + self.set_bottom_button_text(BottomButton::Main, text) + } + + /// Set text for the secondary bottom button. + pub fn set_secondary_button_text(&self, text: &str) -> Result<(), JsValue> { + self.set_bottom_button_text(BottomButton::Secondary, text) + } + + /// Legacy alias for [`Self::set_bottom_button_color`] with + /// [`BottomButton::Main`]. + pub fn set_main_button_color(&self, color: &str) -> Result<(), JsValue> { + self.set_bottom_button_color(BottomButton::Main, color) + } + + /// Set color for the secondary bottom button. + pub fn set_secondary_button_color(&self, color: &str) -> Result<(), JsValue> { + self.set_bottom_button_color(BottomButton::Secondary, color) + } + + /// Legacy alias for [`Self::set_bottom_button_text_color`] with + /// [`BottomButton::Main`]. + pub fn set_main_button_text_color(&self, color: &str) -> Result<(), JsValue> { + self.set_bottom_button_text_color(BottomButton::Main, color) + } + + /// Set text color for the secondary bottom button. + pub fn set_secondary_button_text_color(&self, color: &str) -> Result<(), JsValue> { + self.set_bottom_button_text_color(BottomButton::Secondary, color) + } + + /// Legacy alias for [`Self::set_bottom_button_callback`] with + /// [`BottomButton::Main`]. + pub fn set_main_button_callback( + &self, + callback: F + ) -> Result, JsValue> + where + F: 'static + Fn() + { + self.set_bottom_button_callback(BottomButton::Main, callback) + } + + /// Set callback for the secondary bottom button. + pub fn set_secondary_button_callback( + &self, + callback: F + ) -> Result, JsValue> + where + F: 'static + Fn() + { + self.set_bottom_button_callback(BottomButton::Secondary, callback) + } + + /// Legacy alias for [`Self::remove_bottom_button_callback`]. + pub fn remove_main_button_callback( + &self, + handle: EventHandle + ) -> Result<(), JsValue> { + self.remove_bottom_button_callback(handle) + } + + /// Remove callback for the secondary bottom button. + pub fn remove_secondary_button_callback( + &self, + handle: EventHandle + ) -> Result<(), JsValue> { + self.remove_bottom_button_callback(handle) + } + + /// Register event handler (`web_app_event_name`, callback). + /// + /// Returns an [`EventHandle`] that can be passed to + /// [`off_event`](Self::off_event). + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn on_event( + &self, + event: &str, + callback: F + ) -> Result, JsValue> + where + F: 'static + Fn(JsValue) + { + let cb = Closure::::new(callback); + let f = Reflect::get(&self.inner, &"onEvent".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("onEvent is not a function"))?; + func.call2(&self.inner, &event.into(), cb.as_ref().unchecked_ref())?; + Ok(EventHandle::new( + self.inner.clone(), + "offEvent", + Some(event.to_owned()), + cb + )) + } + + /// Register a callback for a background event. + /// + /// Returns an [`EventHandle`] that can be passed to + /// [`off_event`](Self::off_event). + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn on_background_event( + &self, + event: BackgroundEvent, + callback: F + ) -> Result, JsValue> + where + F: 'static + Fn(JsValue) + { + let cb = Closure::::new(callback); + let f = Reflect::get(&self.inner, &"onEvent".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("onEvent is not a function"))?; + func.call2( + &self.inner, + &event.as_str().into(), + cb.as_ref().unchecked_ref() + )?; + Ok(EventHandle::new( + self.inner.clone(), + "offEvent", + Some(event.as_str().to_string()), + cb + )) + } + + /// Deregister a previously registered event handler. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn off_event(&self, handle: EventHandle) -> Result<(), JsValue> { + handle.unregister() + } + + /// Internal: call `this[field][method]()` + fn call_nested0(&self, field: &str, method: &str) -> Result<(), JsValue> { + let obj = Reflect::get(&self.inner, &field.into())?; + let f = Reflect::get(&obj, &method.into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("not a function"))?; + func.call0(&obj)?; + Ok(()) + } + + // === Internal generic method helpers === + + fn call0(&self, method: &str) -> Result<(), JsValue> { + let f = Reflect::get(&self.inner, &method.into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("not a function"))?; + func.call0(&self.inner)?; + Ok(()) + } + + fn call1(&self, method: &str, arg: &JsValue) -> Result<(), JsValue> { + let f = Reflect::get(&self.inner, &method.into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("not a function"))?; + func.call1(&self.inner, arg)?; + Ok(()) + } + + /// Returns the current viewport height in pixels. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// let _ = app.viewport_height(); + /// ``` + pub fn viewport_height(&self) -> Option { + Reflect::get(&self.inner, &"viewportHeight".into()) + .ok()? + .as_f64() + } + + /// Returns the current viewport width in pixels. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// let _ = app.viewport_width(); + /// ``` + pub fn viewport_width(&self) -> Option { + Reflect::get(&self.inner, &"viewportWidth".into()) + .ok()? + .as_f64() + } + + /// Returns the stable viewport height in pixels. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// let _ = app.viewport_stable_height(); + /// ``` + pub fn viewport_stable_height(&self) -> Option { + Reflect::get(&self.inner, &"viewportStableHeight".into()) + .ok()? + .as_f64() + } + + pub fn is_expanded(&self) -> bool { + Reflect::get(&self.inner, &"isExpanded".into()) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } + + /// Call `WebApp.expand()` to expand the viewport. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn expand_viewport(&self) -> Result<(), JsValue> { + self.call0("expand") + } + + /// Register a callback for theme changes. + /// + /// Returns an [`EventHandle`] that can be passed to + /// [`off_event`](Self::off_event). + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn on_theme_changed(&self, callback: F) -> Result, JsValue> + where + F: 'static + Fn() + { + let cb = Closure::::new(callback); + let f = Reflect::get(&self.inner, &"onEvent".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("onEvent is not a function"))?; + func.call2( + &self.inner, + &"themeChanged".into(), + cb.as_ref().unchecked_ref() + )?; + Ok(EventHandle::new( + self.inner.clone(), + "offEvent", + Some("themeChanged".to_string()), + cb + )) + } + + /// Register a callback for safe area changes. + /// + /// Returns an [`EventHandle`] that can be passed to + /// [`off_event`](Self::off_event). + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn on_safe_area_changed(&self, callback: F) -> Result, JsValue> + where + F: 'static + Fn() + { + let cb = Closure::::new(callback); + let f = Reflect::get(&self.inner, &"onEvent".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("onEvent is not a function"))?; + func.call2( + &self.inner, + &"safeAreaChanged".into(), + cb.as_ref().unchecked_ref() + )?; + Ok(EventHandle::new( + self.inner.clone(), + "offEvent", + Some("safeAreaChanged".to_string()), + cb + )) + } + + /// Register a callback for content safe area changes. + /// + /// Returns an [`EventHandle`] that can be passed to + /// [`off_event`](Self::off_event). + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn on_content_safe_area_changed( + &self, + callback: F + ) -> Result, JsValue> + where + F: 'static + Fn() + { + let cb = Closure::::new(callback); + let f = Reflect::get(&self.inner, &"onEvent".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("onEvent is not a function"))?; + func.call2( + &self.inner, + &"contentSafeAreaChanged".into(), + cb.as_ref().unchecked_ref() + )?; + Ok(EventHandle::new( + self.inner.clone(), + "offEvent", + Some("contentSafeAreaChanged".to_string()), + cb + )) + } + + /// Register a callback for viewport changes. + /// + /// Returns an [`EventHandle`] that can be passed to + /// [`off_event`](Self::off_event). + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn on_viewport_changed(&self, callback: F) -> Result, JsValue> + where + F: 'static + Fn() + { + let cb = Closure::::new(callback); + let f = Reflect::get(&self.inner, &"onEvent".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("onEvent is not a function"))?; + func.call2( + &self.inner, + &"viewportChanged".into(), + cb.as_ref().unchecked_ref() + )?; + Ok(EventHandle::new( + self.inner.clone(), + "offEvent", + Some("viewportChanged".to_string()), + cb + )) + } + + /// Register a callback for received clipboard text. + /// + /// Returns an [`EventHandle`] that can be passed to + /// [`off_event`](Self::off_event). + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn on_clipboard_text_received( + &self, + callback: F + ) -> Result, JsValue> + where + F: 'static + Fn(String) + { + let cb = Closure::::new(move |text: JsValue| { + callback(text.as_string().unwrap_or_default()); + }); + let f = Reflect::get(&self.inner, &"onEvent".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("onEvent is not a function"))?; + func.call2( + &self.inner, + &"clipboardTextReceived".into(), + cb.as_ref().unchecked_ref() + )?; + Ok(EventHandle::new( + self.inner.clone(), + "offEvent", + Some("clipboardTextReceived".to_string()), + cb + )) + } + + /// Register a callback for invoice payment result. + /// + /// Returns an [`EventHandle`] that can be passed to + /// [`off_event`](Self::off_event). + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// let handle = app + /// .on_invoice_closed(|status| { + /// let _ = status; + /// }) + /// .unwrap(); + /// app.off_event(handle).unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn on_invoice_closed( + &self, + callback: F + ) -> Result, JsValue> + where + F: 'static + Fn(String) + { + let cb = Closure::::new(callback); + let f = Reflect::get(&self.inner, &"onEvent".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("onEvent is not a function"))?; + func.call2( + &self.inner, + &"invoiceClosed".into(), + cb.as_ref().unchecked_ref() + )?; + Ok(EventHandle::new( + self.inner.clone(), + "offEvent", + Some("invoiceClosed".to_string()), + cb + )) + } + + /// Registers a callback for the native back button. + /// + /// Returns an [`EventHandle`] that can be passed to + /// [`remove_back_button_callback`](Self::remove_back_button_callback). + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// let handle = app.set_back_button_callback(|| {}).expect("callback"); + /// app.remove_back_button_callback(handle).unwrap(); + /// ``` + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn set_back_button_callback( + &self, + callback: F + ) -> Result, JsValue> + where + F: 'static + Fn() + { + let back_button_val = Reflect::get(&self.inner, &"BackButton".into())?; + let back_button = back_button_val.dyn_into::()?; + let cb = Closure::::new(callback); + let f = Reflect::get(&back_button, &"onClick".into())?; + let func = f + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("onClick is not a function"))?; + func.call1(&back_button, cb.as_ref().unchecked_ref())?; + Ok(EventHandle::new(back_button, "offClick", None, cb)) + } + + /// Remove previously set back button callback. + /// + /// # Errors + /// Returns [`JsValue`] if the underlying JS call fails. + pub fn remove_back_button_callback( + &self, + handle: EventHandle + ) -> Result<(), JsValue> { + handle.unregister() + } + /// Returns whether the native back button is visible. + /// + /// # Examples + /// ```no_run + /// # use telegram_webapp_sdk::webapp::TelegramWebApp; + /// # let app = TelegramWebApp::instance().unwrap(); + /// let _ = app.is_back_button_visible(); + /// ``` + pub fn is_back_button_visible(&self) -> bool { + Reflect::get(&self.inner, &"BackButton".into()) + .ok() + .and_then(|bb| Reflect::get(&bb, &"isVisible".into()).ok()) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use std::{ + cell::{Cell, RefCell}, + rc::Rc + }; + + use js_sys::{Function, Object, Reflect}; + use wasm_bindgen::{JsCast, JsValue, prelude::Closure}; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::window; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[allow(dead_code)] + fn setup_webapp() -> Object { + let win = window().unwrap(); + let telegram = Object::new(); + let webapp = Object::new(); + let _ = Reflect::set(&win, &"Telegram".into(), &telegram); + let _ = Reflect::set(&telegram, &"WebApp".into(), &webapp); + webapp + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn hide_keyboard_calls_js() { + let webapp = setup_webapp(); + let called = Rc::new(Cell::new(false)); + let called_clone = Rc::clone(&called); + + let hide_cb = Closure::::new(move || { + called_clone.set(true); + }); + let _ = Reflect::set( + &webapp, + &"hideKeyboard".into(), + hide_cb.as_ref().unchecked_ref() + ); + hide_cb.forget(); + + let app = TelegramWebApp::instance().unwrap(); + app.hide_keyboard().unwrap(); + assert!(called.get()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn hide_main_button_calls_js() { + let webapp = setup_webapp(); + let main_button = Object::new(); + let called = Rc::new(Cell::new(false)); + let called_clone = Rc::clone(&called); + + let hide_cb = Closure::::new(move || { + called_clone.set(true); + }); + let _ = Reflect::set( + &main_button, + &"hide".into(), + hide_cb.as_ref().unchecked_ref() + ); + hide_cb.forget(); + + let _ = Reflect::set(&webapp, &"MainButton".into(), &main_button); + + let app = TelegramWebApp::instance().unwrap(); + app.hide_bottom_button(BottomButton::Main).unwrap(); + assert!(called.get()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn hide_secondary_button_calls_js() { + let webapp = setup_webapp(); + let secondary_button = Object::new(); + let called = Rc::new(Cell::new(false)); + let called_clone = Rc::clone(&called); + + let hide_cb = Closure::::new(move || { + called_clone.set(true); + }); + let _ = Reflect::set( + &secondary_button, + &"hide".into(), + hide_cb.as_ref().unchecked_ref() + ); + hide_cb.forget(); + + let _ = Reflect::set(&webapp, &"SecondaryButton".into(), &secondary_button); + + let app = TelegramWebApp::instance().unwrap(); + app.hide_bottom_button(BottomButton::Secondary).unwrap(); + assert!(called.get()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn set_bottom_button_color_calls_js() { + let webapp = setup_webapp(); + let main_button = Object::new(); + let received = Rc::new(RefCell::new(None)); + let rc_clone = Rc::clone(&received); + + let set_color_cb = Closure::::new(move |v: JsValue| { + *rc_clone.borrow_mut() = v.as_string(); + }); + let _ = Reflect::set( + &main_button, + &"setColor".into(), + set_color_cb.as_ref().unchecked_ref() + ); + set_color_cb.forget(); + + let _ = Reflect::set(&webapp, &"MainButton".into(), &main_button); + + let app = TelegramWebApp::instance().unwrap(); + app.set_bottom_button_color(BottomButton::Main, "#00ff00") + .unwrap(); + assert_eq!(received.borrow().as_deref(), Some("#00ff00")); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn set_secondary_button_color_calls_js() { + let webapp = setup_webapp(); + let secondary_button = Object::new(); + let received = Rc::new(RefCell::new(None)); + let rc_clone = Rc::clone(&received); + + let set_color_cb = Closure::::new(move |v: JsValue| { + *rc_clone.borrow_mut() = v.as_string(); + }); + let _ = Reflect::set( + &secondary_button, + &"setColor".into(), + set_color_cb.as_ref().unchecked_ref() + ); + set_color_cb.forget(); + + let _ = Reflect::set(&webapp, &"SecondaryButton".into(), &secondary_button); + + let app = TelegramWebApp::instance().unwrap(); + app.set_bottom_button_color(BottomButton::Secondary, "#00ff00") + .unwrap(); + assert_eq!(received.borrow().as_deref(), Some("#00ff00")); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn set_bottom_button_text_color_calls_js() { + let webapp = setup_webapp(); + let main_button = Object::new(); + let received = Rc::new(RefCell::new(None)); + let rc_clone = Rc::clone(&received); + + let set_color_cb = Closure::::new(move |v: JsValue| { + *rc_clone.borrow_mut() = v.as_string(); + }); + let _ = Reflect::set( + &main_button, + &"setTextColor".into(), + set_color_cb.as_ref().unchecked_ref() + ); + set_color_cb.forget(); + + let _ = Reflect::set(&webapp, &"MainButton".into(), &main_button); + + let app = TelegramWebApp::instance().unwrap(); + app.set_bottom_button_text_color(BottomButton::Main, "#112233") + .unwrap(); + assert_eq!(received.borrow().as_deref(), Some("#112233")); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn set_secondary_button_text_color_calls_js() { + let webapp = setup_webapp(); + let secondary_button = Object::new(); + let received = Rc::new(RefCell::new(None)); + let rc_clone = Rc::clone(&received); + + let set_color_cb = Closure::::new(move |v: JsValue| { + *rc_clone.borrow_mut() = v.as_string(); + }); + let _ = Reflect::set( + &secondary_button, + &"setTextColor".into(), + set_color_cb.as_ref().unchecked_ref() + ); + set_color_cb.forget(); + + let _ = Reflect::set(&webapp, &"SecondaryButton".into(), &secondary_button); + + let app = TelegramWebApp::instance().unwrap(); + app.set_bottom_button_text_color(BottomButton::Secondary, "#112233") + .unwrap(); + assert_eq!(received.borrow().as_deref(), Some("#112233")); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn set_header_color_calls_js() { + let webapp = setup_webapp(); + let received = Rc::new(RefCell::new(None)); + let rc_clone = Rc::clone(&received); + + let cb = Closure::::new(move |v: JsValue| { + *rc_clone.borrow_mut() = v.as_string(); + }); + let _ = Reflect::set( + &webapp, + &"setHeaderColor".into(), + cb.as_ref().unchecked_ref() + ); + cb.forget(); + + let app = TelegramWebApp::instance().unwrap(); + app.set_header_color("#abcdef").unwrap(); + assert_eq!(received.borrow().as_deref(), Some("#abcdef")); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn set_background_color_calls_js() { + let webapp = setup_webapp(); + let received = Rc::new(RefCell::new(None)); + let rc_clone = Rc::clone(&received); + + let cb = Closure::::new(move |v: JsValue| { + *rc_clone.borrow_mut() = v.as_string(); + }); + let _ = Reflect::set( + &webapp, + &"setBackgroundColor".into(), + cb.as_ref().unchecked_ref() + ); + cb.forget(); + + let app = TelegramWebApp::instance().unwrap(); + app.set_background_color("#123456").unwrap(); + assert_eq!(received.borrow().as_deref(), Some("#123456")); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn set_bottom_bar_color_calls_js() { + let webapp = setup_webapp(); + let received = Rc::new(RefCell::new(None)); + let rc_clone = Rc::clone(&received); + + let cb = Closure::::new(move |v: JsValue| { + *rc_clone.borrow_mut() = v.as_string(); + }); + let _ = Reflect::set( + &webapp, + &"setBottomBarColor".into(), + cb.as_ref().unchecked_ref() + ); + cb.forget(); + + let app = TelegramWebApp::instance().unwrap(); + app.set_bottom_bar_color("#654321").unwrap(); + assert_eq!(received.borrow().as_deref(), Some("#654321")); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn viewport_dimensions() { + let webapp = setup_webapp(); + let _ = Reflect::set(&webapp, &"viewportWidth".into(), &JsValue::from_f64(320.0)); + let _ = Reflect::set( + &webapp, + &"viewportStableHeight".into(), + &JsValue::from_f64(480.0) + ); + let app = TelegramWebApp::instance().unwrap(); + assert_eq!(app.viewport_width(), Some(320.0)); + assert_eq!(app.viewport_stable_height(), Some(480.0)); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn back_button_visibility_and_callback() { + let webapp = setup_webapp(); + let back_button = Object::new(); + let _ = Reflect::set(&webapp, &"BackButton".into(), &back_button); + let _ = Reflect::set(&back_button, &"isVisible".into(), &JsValue::TRUE); + + let on_click = Function::new_with_args("cb", "this.cb = cb;"); + let off_click = Function::new_with_args("", "delete this.cb;"); + let _ = Reflect::set(&back_button, &"onClick".into(), &on_click); + let _ = Reflect::set(&back_button, &"offClick".into(), &off_click); + + let called = Rc::new(Cell::new(false)); + let called_clone = Rc::clone(&called); + + let app = TelegramWebApp::instance().unwrap(); + assert!(app.is_back_button_visible()); + let handle = app + .set_back_button_callback(move || { + called_clone.set(true); + }) + .unwrap(); + + let stored = Reflect::has(&back_button, &"cb".into()).unwrap(); + assert!(stored); + + let cb_fn = Reflect::get(&back_button, &"cb".into()) + .unwrap() + .dyn_into::() + .unwrap(); + let _ = cb_fn.call0(&JsValue::NULL); + assert!(called.get()); + + app.remove_back_button_callback(handle).unwrap(); + let stored_after = Reflect::has(&back_button, &"cb".into()).unwrap(); + assert!(!stored_after); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn bottom_button_callback_register_and_remove() { + let webapp = setup_webapp(); + let main_button = Object::new(); + let _ = Reflect::set(&webapp, &"MainButton".into(), &main_button); + + let on_click = Function::new_with_args("cb", "this.cb = cb;"); + let off_click = Function::new_with_args("", "delete this.cb;"); + let _ = Reflect::set(&main_button, &"onClick".into(), &on_click); + let _ = Reflect::set(&main_button, &"offClick".into(), &off_click); + + let called = Rc::new(Cell::new(false)); + let called_clone = Rc::clone(&called); + + let app = TelegramWebApp::instance().unwrap(); + let handle = app + .set_bottom_button_callback(BottomButton::Main, move || { + called_clone.set(true); + }) + .unwrap(); + + let stored = Reflect::has(&main_button, &"cb".into()).unwrap(); + assert!(stored); + + let cb_fn = Reflect::get(&main_button, &"cb".into()) + .unwrap() + .dyn_into::() + .unwrap(); + let _ = cb_fn.call0(&JsValue::NULL); + assert!(called.get()); + + app.remove_bottom_button_callback(handle).unwrap(); + let stored_after = Reflect::has(&main_button, &"cb".into()).unwrap(); + assert!(!stored_after); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn secondary_button_callback_register_and_remove() { + let webapp = setup_webapp(); + let secondary_button = Object::new(); + let _ = Reflect::set(&webapp, &"SecondaryButton".into(), &secondary_button); + + let on_click = Function::new_with_args("cb", "this.cb = cb;"); + let off_click = Function::new_with_args("", "delete this.cb;"); + let _ = Reflect::set(&secondary_button, &"onClick".into(), &on_click); + let _ = Reflect::set(&secondary_button, &"offClick".into(), &off_click); + + let called = Rc::new(Cell::new(false)); + let called_clone = Rc::clone(&called); + + let app = TelegramWebApp::instance().unwrap(); + let handle = app + .set_bottom_button_callback(BottomButton::Secondary, move || { + called_clone.set(true); + }) + .unwrap(); + + let stored = Reflect::has(&secondary_button, &"cb".into()).unwrap(); + assert!(stored); + + let cb_fn = Reflect::get(&secondary_button, &"cb".into()) + .unwrap() + .dyn_into::() + .unwrap(); + let _ = cb_fn.call0(&JsValue::NULL); + assert!(called.get()); + + app.remove_bottom_button_callback(handle).unwrap(); + let stored_after = Reflect::has(&secondary_button, &"cb".into()).unwrap(); + assert!(!stored_after); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn on_event_register_and_remove() { + let webapp = setup_webapp(); + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + let off_event = Function::new_with_args("name", "delete this[name];"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + let _ = Reflect::set(&webapp, &"offEvent".into(), &off_event); + + let app = TelegramWebApp::instance().unwrap(); + let handle = app.on_event("test", |_: JsValue| {}).unwrap(); + assert!(Reflect::has(&webapp, &"test".into()).unwrap()); + app.off_event(handle).unwrap(); + assert!(!Reflect::has(&webapp, &"test".into()).unwrap()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn background_event_register_and_remove() { + let webapp = setup_webapp(); + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + let off_event = Function::new_with_args("name", "delete this[name];"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + let _ = Reflect::set(&webapp, &"offEvent".into(), &off_event); + + let app = TelegramWebApp::instance().unwrap(); + let handle = app + .on_background_event(BackgroundEvent::MainButtonClicked, |_| {}) + .unwrap(); + assert!(Reflect::has(&webapp, &"mainButtonClicked".into()).unwrap()); + app.off_event(handle).unwrap(); + assert!(!Reflect::has(&webapp, &"mainButtonClicked".into()).unwrap()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn background_event_delivers_data() { + let webapp = setup_webapp(); + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + + let app = TelegramWebApp::instance().unwrap(); + let received = Rc::new(RefCell::new(String::new())); + let received_clone = Rc::clone(&received); + let _handle = app + .on_background_event(BackgroundEvent::InvoiceClosed, move |v| { + *received_clone.borrow_mut() = v.as_string().unwrap_or_default(); + }) + .unwrap(); + + let cb = Reflect::get(&webapp, &"invoiceClosed".into()) + .unwrap() + .dyn_into::() + .unwrap(); + let _ = cb.call1(&JsValue::NULL, &JsValue::from_str("paid")); + assert_eq!(received.borrow().as_str(), "paid"); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn theme_changed_register_and_remove() { + let webapp = setup_webapp(); + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + let off_event = Function::new_with_args("name", "delete this[name];"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + let _ = Reflect::set(&webapp, &"offEvent".into(), &off_event); + + let app = TelegramWebApp::instance().unwrap(); + let handle = app.on_theme_changed(|| {}).unwrap(); + assert!(Reflect::has(&webapp, &"themeChanged".into()).unwrap()); + app.off_event(handle).unwrap(); + assert!(!Reflect::has(&webapp, &"themeChanged".into()).unwrap()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn safe_area_changed_register_and_remove() { + let webapp = setup_webapp(); + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + let off_event = Function::new_with_args("name", "delete this[name];"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + let _ = Reflect::set(&webapp, &"offEvent".into(), &off_event); + + let app = TelegramWebApp::instance().unwrap(); + let handle = app.on_safe_area_changed(|| {}).unwrap(); + assert!(Reflect::has(&webapp, &"safeAreaChanged".into()).unwrap()); + app.off_event(handle).unwrap(); + assert!(!Reflect::has(&webapp, &"safeAreaChanged".into()).unwrap()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn content_safe_area_changed_register_and_remove() { + let webapp = setup_webapp(); + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + let off_event = Function::new_with_args("name", "delete this[name];"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + let _ = Reflect::set(&webapp, &"offEvent".into(), &off_event); + + let app = TelegramWebApp::instance().unwrap(); + let handle = app.on_content_safe_area_changed(|| {}).unwrap(); + assert!(Reflect::has(&webapp, &"contentSafeAreaChanged".into()).unwrap()); + app.off_event(handle).unwrap(); + assert!(!Reflect::has(&webapp, &"contentSafeAreaChanged".into()).unwrap()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn viewport_changed_register_and_remove() { + let webapp = setup_webapp(); + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + let off_event = Function::new_with_args("name", "delete this[name];"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + let _ = Reflect::set(&webapp, &"offEvent".into(), &off_event); + + let app = TelegramWebApp::instance().unwrap(); + let handle = app.on_viewport_changed(|| {}).unwrap(); + assert!(Reflect::has(&webapp, &"viewportChanged".into()).unwrap()); + app.off_event(handle).unwrap(); + assert!(!Reflect::has(&webapp, &"viewportChanged".into()).unwrap()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn clipboard_text_received_register_and_remove() { + let webapp = setup_webapp(); + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + let off_event = Function::new_with_args("name", "delete this[name];"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + let _ = Reflect::set(&webapp, &"offEvent".into(), &off_event); + + let app = TelegramWebApp::instance().unwrap(); + let handle = app.on_clipboard_text_received(|_| {}).unwrap(); + assert!(Reflect::has(&webapp, &"clipboardTextReceived".into()).unwrap()); + app.off_event(handle).unwrap(); + assert!(!Reflect::has(&webapp, &"clipboardTextReceived".into()).unwrap()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn open_link_and_telegram_link() { + let webapp = setup_webapp(); + let open_link = Function::new_with_args("url", "this.open_link = url;"); + let open_tg_link = Function::new_with_args("url", "this.open_tg_link = url;"); + let _ = Reflect::set(&webapp, &"openLink".into(), &open_link); + let _ = Reflect::set(&webapp, &"openTelegramLink".into(), &open_tg_link); + + let app = TelegramWebApp::instance().unwrap(); + let url = "https://example.com"; + app.open_link(url).unwrap(); + app.open_telegram_link(url).unwrap(); + + assert_eq!( + Reflect::get(&webapp, &"open_link".into()) + .unwrap() + .as_string() + .as_deref(), + Some(url) + ); + assert_eq!( + Reflect::get(&webapp, &"open_tg_link".into()) + .unwrap() + .as_string() + .as_deref(), + Some(url) + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn invoice_closed_register_and_remove() { + let webapp = setup_webapp(); + let on_event = Function::new_with_args("name, cb", "this[name] = cb;"); + let off_event = Function::new_with_args("name", "delete this[name];"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + let _ = Reflect::set(&webapp, &"offEvent".into(), &off_event); + + let app = TelegramWebApp::instance().unwrap(); + let handle = app.on_invoice_closed(|_| {}).unwrap(); + assert!(Reflect::has(&webapp, &"invoiceClosed".into()).unwrap()); + app.off_event(handle).unwrap(); + assert!(!Reflect::has(&webapp, &"invoiceClosed".into()).unwrap()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn invoice_closed_invokes_callback() { + let webapp = setup_webapp(); + let on_event = Function::new_with_args("name, cb", "this.cb = cb;"); + let _ = Reflect::set(&webapp, &"onEvent".into(), &on_event); + + let app = TelegramWebApp::instance().unwrap(); + let status = Rc::new(RefCell::new(String::new())); + let status_clone = Rc::clone(&status); + app.on_invoice_closed(move |s| { + *status_clone.borrow_mut() = s; + }) + .unwrap(); + + let cb = Reflect::get(&webapp, &"cb".into()) + .unwrap() + .dyn_into::() + .unwrap(); + cb.call1(&webapp, &"paid".into()).unwrap(); + assert_eq!(status.borrow().as_str(), "paid"); + cb.call1(&webapp, &"failed".into()).unwrap(); + assert_eq!(status.borrow().as_str(), "failed"); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn open_invoice_invokes_callback() { + let webapp = setup_webapp(); + let open_invoice = Function::new_with_args("url, cb", "cb('paid');"); + let _ = Reflect::set(&webapp, &"openInvoice".into(), &open_invoice); + + let app = TelegramWebApp::instance().unwrap(); + let status = Rc::new(RefCell::new(String::new())); + let status_clone = Rc::clone(&status); + + app.open_invoice("https://invoice", move |s| { + *status_clone.borrow_mut() = s; + }) + .unwrap(); + + assert_eq!(status.borrow().as_str(), "paid"); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn switch_inline_query_calls_js() { + let webapp = setup_webapp(); + let switch_inline = + Function::new_with_args("query, types", "this.query = query; this.types = types;"); + let _ = Reflect::set(&webapp, &"switchInlineQuery".into(), &switch_inline); + + let app = TelegramWebApp::instance().unwrap(); + let types = JsValue::from_str("users"); + app.switch_inline_query("search", Some(&types)).unwrap(); + + assert_eq!( + Reflect::get(&webapp, &"query".into()) + .unwrap() + .as_string() + .as_deref(), + Some("search"), + ); + assert_eq!( + Reflect::get(&webapp, &"types".into()) + .unwrap() + .as_string() + .as_deref(), + Some("users"), + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn share_message_calls_js() { + let webapp = setup_webapp(); + let share = Function::new_with_args("id, cb", "this.shared_id = id; cb(true);"); + let _ = Reflect::set(&webapp, &"shareMessage".into(), &share); + + let app = TelegramWebApp::instance().unwrap(); + let sent = Rc::new(Cell::new(false)); + let sent_clone = Rc::clone(&sent); + + app.share_message("123", move |s| { + sent_clone.set(s); + }) + .unwrap(); + + assert_eq!( + Reflect::get(&webapp, &"shared_id".into()) + .unwrap() + .as_string() + .as_deref(), + Some("123"), + ); + assert!(sent.get()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn share_to_story_calls_js() { + let webapp = setup_webapp(); + let share = Function::new_with_args( + "url, params", + "this.story_url = url; this.story_params = params;" + ); + let _ = Reflect::set(&webapp, &"shareToStory".into(), &share); + + let app = TelegramWebApp::instance().unwrap(); + let url = "https://example.com/media"; + let params = Object::new(); + let _ = Reflect::set(¶ms, &"text".into(), &"hi".into()); + app.share_to_story(url, Some(¶ms.into())).unwrap(); + + assert_eq!( + Reflect::get(&webapp, &"story_url".into()) + .unwrap() + .as_string() + .as_deref(), + Some(url), + ); + let stored = Reflect::get(&webapp, &"story_params".into()).unwrap(); + assert_eq!( + Reflect::get(&stored, &"text".into()) + .unwrap() + .as_string() + .as_deref(), + Some("hi"), + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn share_url_calls_js() { + let webapp = setup_webapp(); + let share = Function::new_with_args( + "url, text", + "this.shared_url = url; this.shared_text = text;" + ); + let _ = Reflect::set(&webapp, &"shareURL".into(), &share); + + let app = TelegramWebApp::instance().unwrap(); + let url = "https://example.com"; + let text = "check"; + app.share_url(url, Some(text)).unwrap(); + + assert_eq!( + Reflect::get(&webapp, &"shared_url".into()) + .unwrap() + .as_string() + .as_deref(), + Some(url), + ); + assert_eq!( + Reflect::get(&webapp, &"shared_text".into()) + .unwrap() + .as_string() + .as_deref(), + Some(text), + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn join_voice_chat_calls_js() { + let webapp = setup_webapp(); + let join = Function::new_with_args( + "id, hash", + "this.voice_chat_id = id; this.voice_chat_hash = hash;" + ); + let _ = Reflect::set(&webapp, &"joinVoiceChat".into(), &join); + + let app = TelegramWebApp::instance().unwrap(); + app.join_voice_chat("123", Some("hash")).unwrap(); + + assert_eq!( + Reflect::get(&webapp, &"voice_chat_id".into()) + .unwrap() + .as_string() + .as_deref(), + Some("123"), + ); + assert_eq!( + Reflect::get(&webapp, &"voice_chat_hash".into()) + .unwrap() + .as_string() + .as_deref(), + Some("hash"), + ); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn add_to_home_screen_calls_js() { + let webapp = setup_webapp(); + let add = Function::new_with_args("", "this.called = true; return true;"); + let _ = Reflect::set(&webapp, &"addToHomeScreen".into(), &add); + + let app = TelegramWebApp::instance().unwrap(); + let shown = app.add_to_home_screen().unwrap(); + assert!(shown); + let called = Reflect::get(&webapp, &"called".into()) + .unwrap() + .as_bool() + .unwrap_or(false); + assert!(called); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn request_fullscreen_calls_js() { + let webapp = setup_webapp(); + let called = Rc::new(Cell::new(false)); + let called_clone = Rc::clone(&called); + + let cb = Closure::::new(move || { + called_clone.set(true); + }); + let _ = Reflect::set( + &webapp, + &"requestFullscreen".into(), + cb.as_ref().unchecked_ref() + ); + cb.forget(); + + let app = TelegramWebApp::instance().unwrap(); + app.request_fullscreen().unwrap(); + assert!(called.get()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn exit_fullscreen_calls_js() { + let webapp = setup_webapp(); + let called = Rc::new(Cell::new(false)); + let called_clone = Rc::clone(&called); + + let cb = Closure::::new(move || { + called_clone.set(true); + }); + let _ = Reflect::set( + &webapp, + &"exitFullscreen".into(), + cb.as_ref().unchecked_ref() + ); + cb.forget(); + + let app = TelegramWebApp::instance().unwrap(); + app.exit_fullscreen().unwrap(); + assert!(called.get()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn check_home_screen_status_invokes_callback() { + let webapp = setup_webapp(); + let check = Function::new_with_args("cb", "cb('added');"); + let _ = Reflect::set(&webapp, &"checkHomeScreenStatus".into(), &check); + + let app = TelegramWebApp::instance().unwrap(); + let status = Rc::new(RefCell::new(String::new())); + let status_clone = Rc::clone(&status); + + app.check_home_screen_status(move |s| { + *status_clone.borrow_mut() = s; + }) + .unwrap(); + + assert_eq!(status.borrow().as_str(), "added"); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn lock_orientation_calls_js() { + let webapp = setup_webapp(); + let received = Rc::new(RefCell::new(None)); + let rc_clone = Rc::clone(&received); + + let cb = Closure::::new(move |v: JsValue| { + *rc_clone.borrow_mut() = v.as_string(); + }); + let _ = Reflect::set( + &webapp, + &"lockOrientation".into(), + cb.as_ref().unchecked_ref() + ); + cb.forget(); + + let app = TelegramWebApp::instance().unwrap(); + app.lock_orientation("portrait").unwrap(); + assert_eq!(received.borrow().as_deref(), Some("portrait")); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn unlock_orientation_calls_js() { + let webapp = setup_webapp(); + let called = Rc::new(Cell::new(false)); + let called_clone = Rc::clone(&called); + + let cb = Closure::::new(move || { + called_clone.set(true); + }); + let _ = Reflect::set( + &webapp, + &"unlockOrientation".into(), + cb.as_ref().unchecked_ref() + ); + cb.forget(); + + let app = TelegramWebApp::instance().unwrap(); + app.unlock_orientation().unwrap(); + assert!(called.get()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn enable_vertical_swipes_calls_js() { + let webapp = setup_webapp(); + let called = Rc::new(Cell::new(false)); + let called_clone = Rc::clone(&called); + + let cb = Closure::::new(move || { + called_clone.set(true); + }); + let _ = Reflect::set( + &webapp, + &"enableVerticalSwipes".into(), + cb.as_ref().unchecked_ref() + ); + cb.forget(); + + let app = TelegramWebApp::instance().unwrap(); + app.enable_vertical_swipes().unwrap(); + assert!(called.get()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn disable_vertical_swipes_calls_js() { + let webapp = setup_webapp(); + let called = Rc::new(Cell::new(false)); + let called_clone = Rc::clone(&called); + + let cb = Closure::::new(move || { + called_clone.set(true); + }); + let _ = Reflect::set( + &webapp, + &"disableVerticalSwipes".into(), + cb.as_ref().unchecked_ref() + ); + cb.forget(); + + let app = TelegramWebApp::instance().unwrap(); + app.disable_vertical_swipes().unwrap(); + assert!(called.get()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn request_write_access_invokes_callback() { + let webapp = setup_webapp(); + let request = Function::new_with_args("cb", "cb(true);"); + let _ = Reflect::set(&webapp, &"requestWriteAccess".into(), &request); + + let app = TelegramWebApp::instance().unwrap(); + let granted = Rc::new(Cell::new(false)); + let granted_clone = Rc::clone(&granted); + + let res = app.request_write_access(move |g| { + granted_clone.set(g); + }); + assert!(res.is_ok()); + + assert!(granted.get()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn download_file_invokes_callback() { + let webapp = setup_webapp(); + let received_url = Rc::new(RefCell::new(String::new())); + let received_name = Rc::new(RefCell::new(String::new())); + let url_clone = Rc::clone(&received_url); + let name_clone = Rc::clone(&received_name); + + let download = Closure::::new(move |params, cb: JsValue| { + let url = Reflect::get(¶ms, &"url".into()) + .unwrap() + .as_string() + .unwrap_or_default(); + let name = Reflect::get(¶ms, &"file_name".into()) + .unwrap() + .as_string() + .unwrap_or_default(); + *url_clone.borrow_mut() = url; + *name_clone.borrow_mut() = name; + let func = cb.dyn_ref::().unwrap(); + let _ = func.call1(&JsValue::NULL, &JsValue::from_str("id")); + }); + let _ = Reflect::set( + &webapp, + &"downloadFile".into(), + download.as_ref().unchecked_ref() + ); + download.forget(); + + let app = TelegramWebApp::instance().unwrap(); + let result = Rc::new(RefCell::new(String::new())); + let result_clone = Rc::clone(&result); + let params = DownloadFileParams { + url: "https://example.com/data.bin", + file_name: Some("data.bin"), + mime_type: None + }; + app.download_file(params, move |id| { + *result_clone.borrow_mut() = id; + }) + .unwrap(); + + assert_eq!( + received_url.borrow().as_str(), + "https://example.com/data.bin" + ); + assert_eq!(received_name.borrow().as_str(), "data.bin"); + assert_eq!(result.borrow().as_str(), "id"); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn request_write_access_returns_error_when_missing() { + let _webapp = setup_webapp(); + let app = TelegramWebApp::instance().unwrap(); + let res = app.request_write_access(|_| {}); + assert!(res.is_err()); + } + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn request_emoji_status_access_invokes_callback() { + let webapp = setup_webapp(); + let request = Function::new_with_args("cb", "cb(false);"); + let _ = Reflect::set(&webapp, &"requestEmojiStatusAccess".into(), &request); + + let app = TelegramWebApp::instance().unwrap(); + let granted = Rc::new(Cell::new(true)); + let granted_clone = Rc::clone(&granted); + + app.request_emoji_status_access(move |g| { + granted_clone.set(g); + }) + .unwrap(); + + assert!(!granted.get()); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn set_emoji_status_invokes_callback() { + let webapp = setup_webapp(); + let set_status = Function::new_with_args("status, cb", "this.st = status; cb(true);"); + let _ = Reflect::set(&webapp, &"setEmojiStatus".into(), &set_status); + + let status = Object::new(); + let _ = Reflect::set( + &status, + &"custom_emoji_id".into(), + &JsValue::from_str("321") + ); + + let app = TelegramWebApp::instance().unwrap(); + let success = Rc::new(Cell::new(false)); + let success_clone = Rc::clone(&success); + + app.set_emoji_status(&status.into(), move |s| { + success_clone.set(s); + }) + .unwrap(); + + assert!(success.get()); + let stored = Reflect::get(&webapp, &"st".into()).unwrap(); + let id = Reflect::get(&stored, &"custom_emoji_id".into()) + .unwrap() + .as_string(); + assert_eq!(id.as_deref(), Some("321")); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn show_popup_invokes_callback() { + let webapp = setup_webapp(); + let show_popup = Function::new_with_args("params, cb", "cb('ok');"); + let _ = Reflect::set(&webapp, &"showPopup".into(), &show_popup); + + let app = TelegramWebApp::instance().unwrap(); + let button = Rc::new(RefCell::new(String::new())); + let button_clone = Rc::clone(&button); + + app.show_popup(&JsValue::NULL, move |id| { + *button_clone.borrow_mut() = id; + }) + .unwrap(); + + assert_eq!(button.borrow().as_str(), "ok"); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn read_text_from_clipboard_invokes_callback() { + let webapp = setup_webapp(); + let read_clip = Function::new_with_args("cb", "cb('clip');"); + let _ = Reflect::set(&webapp, &"readTextFromClipboard".into(), &read_clip); + + let app = TelegramWebApp::instance().unwrap(); + let text = Rc::new(RefCell::new(String::new())); + let text_clone = Rc::clone(&text); + + app.read_text_from_clipboard(move |t| { + *text_clone.borrow_mut() = t; + }) + .unwrap(); + + assert_eq!(text.borrow().as_str(), "clip"); + } + + #[wasm_bindgen_test] + #[allow(dead_code, clippy::unused_unit)] + fn scan_qr_popup_invokes_callback_and_close() { + let webapp = setup_webapp(); + let show_scan = Function::new_with_args("text, cb", "cb('code');"); + let close_scan = Function::new_with_args("", "this.closed = true;"); + let _ = Reflect::set(&webapp, &"showScanQrPopup".into(), &show_scan); + let _ = Reflect::set(&webapp, &"closeScanQrPopup".into(), &close_scan); + + let app = TelegramWebApp::instance().unwrap(); + let text = Rc::new(RefCell::new(String::new())); + let text_clone = Rc::clone(&text); + + app.show_scan_qr_popup("scan", move |value| { + *text_clone.borrow_mut() = value; + }) + .unwrap(); + assert_eq!(text.borrow().as_str(), "code"); + + app.close_scan_qr_popup().unwrap(); + let closed = Reflect::get(&webapp, &"closed".into()) + .unwrap() + .as_bool() + .unwrap_or(false); + assert!(closed); + } +} diff --git a/telegram-webapp-sdk/src/yew.rs b/telegram-webapp-sdk/src/yew.rs new file mode 100644 index 0000000..0313671 --- /dev/null +++ b/telegram-webapp-sdk/src/yew.rs @@ -0,0 +1,29 @@ +use wasm_bindgen::JsValue; +use yew::prelude::{hook, use_memo}; + +use crate::core::{context::TelegramContext, safe_context::get_context}; + +/// Yew hook that exposes the global [`TelegramContext`]. +/// +/// # Errors +/// +/// Returns an error if the context has not been initialized with +/// [`TelegramContext::init`]. +/// +/// # Examples +/// +/// ```no_run +/// use telegram_webapp_sdk::yew::use_telegram_context; +/// use yew::prelude::*; +/// +/// #[function_component(App)] +/// fn app() -> Html { +/// let ctx = use_telegram_context().expect("context"); +/// html! { { ctx.init_data.auth_date } } +/// } +/// ``` +#[hook] +pub fn use_telegram_context() -> Result { + let ctx = use_memo((), |_| get_context(|c| c.clone())); + (*ctx).clone() +} diff --git a/telegram-webapp-sdk/telegram-webapp.toml b/telegram-webapp-sdk/telegram-webapp.toml new file mode 100644 index 0000000..190f21e --- /dev/null +++ b/telegram-webapp-sdk/telegram-webapp.toml @@ -0,0 +1,27 @@ +# Example configuration for mock Telegram environment + +[user] +id = 777 +first_name = "Alice" +username = "alice_dev" +is_premium = true + +auth_date = "1234567890" +hash = "fakehash" +bg_color = "#ffffff" +text_color = "#000000" +hint_color = "#888888" +link_color = "#2689bf" +button_color = "#0088cc" +button_text_color = "#ffffff" +secondary_bg_color = "#f0f0f0" +header_bg_color = "#1d1f21" +bottom_bar_bg_color = "#1f2226" +accent_text_color = "#2eaee3" +section_bg_color = "#222529" +section_header_text_color = "#c8c9cb" +section_separator_color = "#2a2c30" +subtitle_text_color = "#909398" +destructive_text_color = "#e33e3e" +platform = "web" +version = "6.0" diff --git a/telegram-webapp-sdk/tests/closing_confirmation.rs b/telegram-webapp-sdk/tests/closing_confirmation.rs new file mode 100644 index 0000000..ec24dd5 --- /dev/null +++ b/telegram-webapp-sdk/tests/closing_confirmation.rs @@ -0,0 +1,83 @@ +#![cfg(target_arch = "wasm32")] + +use std::{cell::Cell, rc::Rc}; + +use js_sys::{Object, Reflect}; +use telegram_webapp_sdk::webapp::TelegramWebApp; +use wasm_bindgen::{JsCast, JsValue, prelude::Closure}; +use wasm_bindgen_test::wasm_bindgen_test; +use web_sys::window; + +fn setup_webapp() -> Result { + let win = window().ok_or_else(|| JsValue::from_str("no window"))?; + let telegram = Object::new(); + let webapp = Object::new(); + Reflect::set(&win, &"Telegram".into(), &telegram)?; + Reflect::set(&telegram, &"WebApp".into(), &webapp)?; + Ok(webapp) +} + +#[wasm_bindgen_test] +fn enable_closing_confirmation_calls_js() -> Result<(), JsValue> { + let webapp = setup_webapp()?; + let called = Rc::new(Cell::new(false)); + let called_clone = Rc::clone(&called); + + let cb = Closure::::new(move || { + called_clone.set(true); + }); + Reflect::set( + &webapp, + &"enableClosingConfirmation".into(), + cb.as_ref().unchecked_ref() + )?; + cb.forget(); + + let app = TelegramWebApp::try_instance()?; + app.enable_closing_confirmation()?; + assert!(called.get()); + Ok(()) +} + +#[wasm_bindgen_test] +fn disable_closing_confirmation_calls_js() -> Result<(), JsValue> { + let webapp = setup_webapp()?; + let called = Rc::new(Cell::new(false)); + let called_clone = Rc::clone(&called); + + let cb = Closure::::new(move || { + called_clone.set(true); + }); + Reflect::set( + &webapp, + &"disableClosingConfirmation".into(), + cb.as_ref().unchecked_ref() + )?; + cb.forget(); + + let app = TelegramWebApp::try_instance()?; + app.disable_closing_confirmation()?; + assert!(called.get()); + Ok(()) +} + +#[wasm_bindgen_test] +fn is_closing_confirmation_enabled_reflects_js() -> Result<(), JsValue> { + let webapp = setup_webapp()?; + Reflect::set( + &webapp, + &"isClosingConfirmationEnabled".into(), + &JsValue::TRUE + )?; + + let app = TelegramWebApp::try_instance()?; + assert!(app.is_closing_confirmation_enabled()); + + Reflect::set( + &webapp, + &"isClosingConfirmationEnabled".into(), + &JsValue::FALSE + )?; + assert!(!app.is_closing_confirmation_enabled()); + Ok(()) +} diff --git a/telegram-webapp-sdk/tests/validate_init_data.rs b/telegram-webapp-sdk/tests/validate_init_data.rs new file mode 100644 index 0000000..0cb032d --- /dev/null +++ b/telegram-webapp-sdk/tests/validate_init_data.rs @@ -0,0 +1,74 @@ +use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use ed25519_dalek::{Signer, SigningKey}; +use hmac_sha256::{HMAC, Hash}; +use telegram_webapp_sdk::{ + TelegramWebApp, + validate_init_data::{ValidationError, ValidationKey} +}; + +#[test] +fn hmac_validates() { + let bot_token = "123456:ABC"; + let secret_key = Hash::hash(format!("WebAppData{bot_token}").as_bytes()); + let check_string = "a=1\nb=2"; + let expected = HMAC::mac(check_string.as_bytes(), secret_key); + let hash = hex::encode(expected); + let query = format!("a=1&b=2&hash={hash}"); + assert!( + TelegramWebApp::validate_init_data(&query, ValidationKey::BotToken(bot_token)).is_ok() + ); +} + +#[test] +fn hmac_rejects_modified_data() { + let bot_token = "123456:ABC"; + let secret_key = Hash::hash(format!("WebAppData{bot_token}").as_bytes()); + let check_string = "a=1\nb=2"; + let expected = HMAC::mac(check_string.as_bytes(), secret_key); + let hash = hex::encode(expected); + assert_eq!( + TelegramWebApp::validate_init_data( + &format!("a=1&b=3&hash={hash}"), + ValidationKey::BotToken(bot_token) + ), + Err(ValidationError::SignatureMismatch) + ); +} + +#[test] +fn ed25519_validates() { + let sk = SigningKey::from_bytes(&[42u8; 32]); + let pk = sk.verifying_key(); + let message = "a=1\nb=2"; + let sig = sk.sign(message.as_bytes()); + let init_data = format!( + "a=1&b=2&signature={}", + BASE64_STANDARD.encode(sig.to_bytes()) + ); + assert!( + TelegramWebApp::validate_init_data( + &init_data, + ValidationKey::Ed25519PublicKey(pk.as_bytes()) + ) + .is_ok() + ); +} + +#[test] +fn ed25519_rejects_bad_signature() { + let sk = SigningKey::from_bytes(&[42u8; 32]); + let pk = sk.verifying_key(); + let message = "a=1\nb=2"; + let sig = sk.sign(message.as_bytes()); + let tampered = format!( + "a=1&b=3&signature={}", + BASE64_STANDARD.encode(sig.to_bytes()) + ); + assert_eq!( + TelegramWebApp::validate_init_data( + &tampered, + ValidationKey::Ed25519PublicKey(pk.as_bytes()) + ), + Err(ValidationError::SignatureMismatch) + ); +}